diff --git a/App.tsx b/App.tsx index ff4b26f..001c143 100644 --- a/App.tsx +++ b/App.tsx @@ -44,21 +44,27 @@ import { mmkvStorage } from './src/services/mmkvStorage'; import AnnouncementOverlay from './src/components/AnnouncementOverlay'; import { CampaignManager } from './src/components/promotions/CampaignManager'; -Sentry.init({ - dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992', +// Only initialize Sentry on native platforms +if (Platform.OS !== 'web') { + Sentry.init({ + dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992', - // Adds more context data to events (IP address, cookies, user, etc.) - // For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/ - sendDefaultPii: true, + // Adds more context data to events (IP address, cookies, user, etc.) + // For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/ + sendDefaultPii: true, - // Configure Session Replay conservatively to avoid startup overhead in production - replaysSessionSampleRate: __DEV__ ? 0.1 : 0, - replaysOnErrorSampleRate: __DEV__ ? 1 : 0, - integrations: [Sentry.feedbackIntegration()], + // Configure Session Replay conservatively to avoid startup overhead in production + replaysSessionSampleRate: __DEV__ ? 0.1 : 0, + replaysOnErrorSampleRate: __DEV__ ? 1 : 0, + integrations: [ + // Feedback integration may not be available on web + ...(typeof Sentry.feedbackIntegration === 'function' ? [Sentry.feedbackIntegration()] : []), + ], - // uncomment the line below to enable Spotlight (https://spotlightjs.com) - // spotlight: __DEV__, -}); + // uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: __DEV__, + }); +} // Force LTR layout to prevent RTL issues when Arabic is set as system language // This ensures posters and UI elements remain visible and properly positioned @@ -268,4 +274,5 @@ const styles = StyleSheet.create({ }, }); -export default Sentry.wrap(App); \ No newline at end of file +// Only wrap with Sentry on native platforms +export default Platform.OS !== 'web' ? Sentry.wrap(App) : App; \ No newline at end of file diff --git a/metro.config.js b/metro.config.js index db91346..66c5621 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,8 +1,13 @@ -const { - getSentryExpoConfig -} = require("@sentry/react-native/metro"); - -const config = getSentryExpoConfig(__dirname); +// Conditionally use Sentry config for native platforms only +let config; +try { + const { getSentryExpoConfig } = require("@sentry/react-native/metro"); + config = getSentryExpoConfig(__dirname); +} catch (e) { + // Fallback to default expo config for web + const { getDefaultConfig } = require('expo/metro-config'); + config = getDefaultConfig(__dirname); +} // Enable tree shaking and better minification config.transformer = { @@ -28,6 +33,39 @@ config.resolver = { assetExts: [...config.resolver.assetExts.filter((ext) => ext !== 'svg'), 'zip'], sourceExts: [...config.resolver.sourceExts, 'svg'], resolverMainFields: ['react-native', 'browser', 'main'], + platforms: ['ios', 'android', 'web'], + resolveRequest: (context, moduleName, platform) => { + // Prevent bundling native-only modules for web + const nativeOnlyModules = [ + '@react-native-community/blur', + '@d11/react-native-fast-image', + 'react-native-fast-image', + 'react-native-video', + 'react-native-immersive-mode', + 'react-native-google-cast', + '@adrianso/react-native-device-brightness', + 'react-native-image-colors', + 'react-native-boost', + 'react-native-nitro-modules', + '@sentry/react-native', + 'expo-glass-effect', + 'react-native-mmkv', + '@react-native-community/slider', + '@react-native-picker/picker', + 'react-native-bottom-tabs', + '@bottom-tabs/react-navigation', + 'posthog-react-native', + '@backpackapp-io/react-native-toast', + ]; + + if (platform === 'web' && nativeOnlyModules.includes(moduleName)) { + return { + type: 'empty', + }; + } + // Default resolution + return context.resolveRequest(context, moduleName, platform); + }, }; module.exports = config; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e690a85..d9323e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "lottie-react-native": "~7.3.1", "posthog-react-native": "^4.4.0", "react": "19.1.0", + "react-dom": "19.1.0", "react-native": "0.81.4", "react-native-boost": "^0.6.2", "react-native-bottom-tabs": "^1.0.2", @@ -10482,6 +10483,18 @@ } } }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, "node_modules/react-freeze": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", diff --git a/package.json b/package.json index f6c33bd..d024951 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "lottie-react-native": "~7.3.1", "posthog-react-native": "^4.4.0", "react": "19.1.0", + "react-dom": "19.1.0", "react-native": "0.81.4", "react-native-boost": "^0.6.2", "react-native-bottom-tabs": "^1.0.2", diff --git a/src/components/AnimatedImage.tsx b/src/components/AnimatedImage.tsx index a0119c9..08a2a25 100644 --- a/src/components/AnimatedImage.tsx +++ b/src/components/AnimatedImage.tsx @@ -1,11 +1,11 @@ import React, { memo, useEffect } from 'react'; import { StyleSheet } from 'react-native'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming } from 'react-native-reanimated'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; interface AnimatedImageProps { source: { uri: string } | undefined; @@ -41,12 +41,17 @@ const AnimatedImage = memo(({ }; }, []); + // Don't render FastImage if no source + if (!source?.uri) { + return ; + } + return ( diff --git a/src/components/StreamCard.tsx b/src/components/StreamCard.tsx index 2040c4f..45fb029 100644 --- a/src/components/StreamCard.tsx +++ b/src/components/StreamCard.tsx @@ -10,7 +10,7 @@ import { Image, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { Stream } from '../types/metadata'; import QualityBadge from './metadata/QualityBadge'; import { useSettings } from '../hooks/useSettings'; @@ -38,36 +38,36 @@ interface StreamCardProps { parentImdbId?: string; } -const StreamCard = memo(({ - stream, - onPress, - index, - isLoading, - statusMessage, - theme, - showLogos, - scraperLogo, - showAlert, - parentTitle, - parentType, - parentSeason, - parentEpisode, - parentEpisodeTitle, - parentPosterUrl, - providerName, - parentId, - parentImdbId +const StreamCard = memo(({ + stream, + onPress, + index, + isLoading, + statusMessage, + theme, + showLogos, + scraperLogo, + showAlert, + parentTitle, + parentType, + parentSeason, + parentEpisode, + parentEpisodeTitle, + parentPosterUrl, + providerName, + parentId, + parentImdbId }: StreamCardProps) => { const { settings } = useSettings(); const { startDownload } = useDownloads(); const { showSuccess, showInfo } = useToast(); - + // Handle long press to copy stream URL to clipboard const handleLongPress = useCallback(async () => { if (stream.url) { try { await Clipboard.setString(stream.url); - + // Use toast for Android, custom alert for iOS if (Platform.OS === 'android') { showSuccess('URL Copied', 'Stream URL copied to clipboard!'); @@ -85,13 +85,13 @@ const StreamCard = memo(({ } } }, [stream.url, showAlert, showSuccess, showInfo]); - + const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); - + const streamInfo = useMemo(() => { const title = stream.title || ''; const name = stream.name || ''; - + // Helper function to format size from bytes const formatSize = (bytes: number): string => { if (bytes === 0) return '0 Bytes'; @@ -100,16 +100,16 @@ const StreamCard = memo(({ const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; - + // Get size from title (legacy format) or from stream.size field let sizeDisplay = title.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1]; if (!sizeDisplay && stream.size && typeof stream.size === 'number' && stream.size > 0) { sizeDisplay = formatSize(stream.size); } - + // Extract quality for badge display const basicQuality = title.match(/(\d+)p/)?.[1] || null; - + return { quality: basicQuality, isHDR: title.toLowerCase().includes('hdr'), @@ -120,7 +120,7 @@ const StreamCard = memo(({ subTitle: title && title !== name ? title : null }; }, [stream.name, stream.title, stream.behaviorHints, stream.size]); - + const handleDownload = useCallback(async () => { try { const url = stream.url; @@ -132,7 +132,7 @@ const StreamCard = memo(({ showAlert('Already Downloading', 'This download has already started for this exact link.'); return; } - } catch {} + } catch { } // Show immediate feedback on both platforms showAlert('Starting Download', 'Download will be started.'); const parent: any = stream as any; @@ -143,10 +143,10 @@ const StreamCard = memo(({ const episodeTitle = parentEpisodeTitle || parent.episodeTitle || parent.episode_name; // Prefer the stream's display name (often includes provider + resolution) const provider = (stream.name as any) || (stream.title as any) || providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider'; - + // Use parentId first (from route params), fallback to stream metadata const idForContent = parentId || parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle; - + // Extract tmdbId if available (from parentId or parent metadata) let tmdbId: number | undefined = undefined; if (parentId && parentId.startsWith('tmdb:')) { @@ -172,99 +172,99 @@ const StreamCard = memo(({ tmdbId: tmdbId, }); showAlert('Download Started', 'Your download has been added to the queue.'); - } catch {} + } catch { } }, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title, parentId, parentImdbId, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName]); const isDebrid = streamInfo.isDebrid; return ( - {/* Scraper Logo */} - {showLogos && scraperLogo && ( - - {scraperLogo.toLowerCase().endsWith('.svg') || scraperLogo.toLowerCase().includes('.svg?') ? ( - - ) : ( - - )} - - )} - - - - - - {streamInfo.displayName} - - {streamInfo.subTitle && ( - - {streamInfo.subTitle} - - )} - - - {/* Show loading indicator if stream is loading */} - {isLoading && ( - - - - {statusMessage || "Loading..."} - - - )} - - - - {streamInfo.isDolby && ( - - )} - - {streamInfo.size && ( - - 💾 {streamInfo.size} - - )} - - {streamInfo.isDebrid && ( - - DEBRID - - )} - - - - - {settings?.enableDownloads !== false && ( - - + {/* Scraper Logo */} + {showLogos && scraperLogo && ( + + {scraperLogo.toLowerCase().endsWith('.svg') || scraperLogo.toLowerCase().includes('.svg?') ? ( + - - )} - + ) : ( + + )} + + )} + + + + + + {streamInfo.displayName} + + {streamInfo.subTitle && ( + + {streamInfo.subTitle} + + )} + + + {/* Show loading indicator if stream is loading */} + {isLoading && ( + + + + {statusMessage || "Loading..."} + + + )} + + + + {streamInfo.isDolby && ( + + )} + + {streamInfo.size && ( + + 💾 {streamInfo.size} + + )} + + {streamInfo.isDebrid && ( + + DEBRID + + )} + + + + + {settings?.enableDownloads !== false && ( + + + + )} + ); }); diff --git a/src/components/TabletStreamsLayout.tsx b/src/components/TabletStreamsLayout.tsx index 6a05b27..54d17c9 100644 --- a/src/components/TabletStreamsLayout.tsx +++ b/src/components/TabletStreamsLayout.tsx @@ -9,12 +9,12 @@ import { } from 'react-native'; import { LegendList } from '@legendapp/list'; import { LinearGradient } from 'expo-linear-gradient'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { MaterialIcons } from '@expo/vector-icons'; import { BlurView as ExpoBlurView } from 'expo-blur'; -import Animated, { - useSharedValue, - useAnimatedStyle, +import Animated, { + useSharedValue, + useAnimatedStyle, withTiming, withDelay, Easing @@ -44,36 +44,36 @@ interface TabletStreamsLayoutProps { metadata?: any; type: string; currentEpisode?: any; - + // Movie logo props movieLogoError: boolean; setMovieLogoError: (error: boolean) => void; - + // Stream-related props streamsEmpty: boolean; selectedProvider: string; filterItems: Array<{ id: string; name: string; }>; handleProviderChange: (provider: string) => void; activeFetchingScrapers: string[]; - + // Loading states isAutoplayWaiting: boolean; autoplayTriggered: boolean; showNoSourcesError: boolean; showInitialLoading: boolean; showStillFetching: boolean; - + // Stream rendering props sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null>; renderSectionHeader: ({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => React.ReactElement; handleStreamPress: (stream: Stream) => void; openAlert: (title: string, message: string) => void; - + // Settings and theme settings: any; currentTheme: any; colors: any; - + // Other props navigation: RootStackNavigationProp; insets: any; @@ -122,19 +122,19 @@ const TabletStreamsLayout: React.FC = ({ hasStremioStreamProviders, }) => { const styles = React.useMemo(() => createStyles(colors), [colors]); - + // Animation values for backdrop entrance const backdropOpacity = useSharedValue(0); const backdropScale = useSharedValue(1.05); const [backdropLoaded, setBackdropLoaded] = useState(false); const [backdropError, setBackdropError] = useState(false); - + // Animation values for content panels const leftPanelOpacity = useSharedValue(0); const leftPanelTranslateX = useSharedValue(-30); const rightPanelOpacity = useSharedValue(0); const rightPanelTranslateX = useSharedValue(30); - + // Get the backdrop source - prioritize episode thumbnail, then show backdrop, then poster // For episodes without thumbnails, use show's backdrop instead of poster const backdropSource = React.useMemo(() => { @@ -148,7 +148,7 @@ const TabletStreamsLayout: React.FC = ({ backdropError }); } - + // If episodeImage failed to load, skip it and use backdrop if (backdropError && episodeImage && episodeImage !== metadata?.poster) { if (__DEV__) console.log('[TabletStreamsLayout] Episode thumbnail failed, falling back to backdrop'); @@ -157,25 +157,25 @@ const TabletStreamsLayout: React.FC = ({ return { uri: bannerImage }; } } - + // If episodeImage exists and is not the same as poster, use it (real episode thumbnail) if (episodeImage && episodeImage !== metadata?.poster && !backdropError) { if (__DEV__) console.log('[TabletStreamsLayout] Using episode thumbnail:', episodeImage); return { uri: episodeImage }; } - + // If episodeImage is the same as poster (fallback case), prioritize backdrop if (bannerImage) { if (__DEV__) console.log('[TabletStreamsLayout] Using show backdrop:', bannerImage); return { uri: bannerImage }; } - + // No fallback to poster images - + if (__DEV__) console.log('[TabletStreamsLayout] No backdrop source found'); return undefined; }, [episodeImage, bannerImage, metadata?.poster, backdropError]); - + // Animate backdrop when it loads, or animate content immediately if no backdrop useEffect(() => { if (backdropSource?.uri && backdropLoaded) { @@ -188,7 +188,7 @@ const TabletStreamsLayout: React.FC = ({ duration: 1000, easing: Easing.out(Easing.cubic) }); - + // Animate content panels with delay after backdrop starts loading leftPanelOpacity.value = withDelay(300, withTiming(1, { duration: 600, @@ -198,7 +198,7 @@ const TabletStreamsLayout: React.FC = ({ duration: 600, easing: Easing.out(Easing.cubic) })); - + rightPanelOpacity.value = withDelay(500, withTiming(1, { duration: 600, easing: Easing.out(Easing.cubic) @@ -217,7 +217,7 @@ const TabletStreamsLayout: React.FC = ({ duration: 600, easing: Easing.out(Easing.cubic) }); - + rightPanelOpacity.value = withDelay(200, withTiming(1, { duration: 600, easing: Easing.out(Easing.cubic) @@ -228,7 +228,7 @@ const TabletStreamsLayout: React.FC = ({ })); } }, [backdropSource?.uri, backdropLoaded, backdropError]); - + // Reset animation when episode changes useEffect(() => { backdropOpacity.value = 0; @@ -240,28 +240,28 @@ const TabletStreamsLayout: React.FC = ({ setBackdropLoaded(false); setBackdropError(false); }, [episodeImage]); - + // Animated styles for backdrop const backdropAnimatedStyle = useAnimatedStyle(() => ({ opacity: backdropOpacity.value, transform: [{ scale: backdropScale.value }], })); - + // Animated styles for content panels const leftPanelAnimatedStyle = useAnimatedStyle(() => ({ opacity: leftPanelOpacity.value, transform: [{ translateX: leftPanelTranslateX.value }], })); - + const rightPanelAnimatedStyle = useAnimatedStyle(() => ({ opacity: rightPanelOpacity.value, transform: [{ translateX: rightPanelTranslateX.value }], })); - + const handleBackdropLoad = () => { setBackdropLoaded(true); }; - + const handleBackdropError = () => { if (__DEV__) console.log('[TabletStreamsLayout] Backdrop image failed to load:', backdropSource?.uri); setBackdropError(true); @@ -294,8 +294,8 @@ const TabletStreamsLayout: React.FC = ({ {isAutoplayWaiting ? 'Finding best stream for autoplay...' : - showStillFetching ? 'Still fetching streams…' : - 'Finding available streams...'} + showStillFetching ? 'Still fetching streams…' : + 'Finding available streams...'} ); @@ -311,7 +311,7 @@ const TabletStreamsLayout: React.FC = ({ // Flatten sections into a single list with header items type ListItem = { type: 'header'; title: string; addonId: string } | { type: 'stream'; stream: Stream; index: number }; - + const flatListData: ListItem[] = []; sections .filter(Boolean) @@ -327,7 +327,7 @@ const TabletStreamsLayout: React.FC = ({ if (item.type === 'header') { return renderSectionHeader({ section: { title: item.title, addonId: item.addonId } }); } - + const stream = item.stream; return ( = ({ @@ -414,7 +414,7 @@ const TabletStreamsLayout: React.FC = ({ locations={[0, 0.5, 1]} style={styles.tabletFullScreenGradient} /> - + {/* Left Panel: Movie Logo/Episode Info */} {type === 'movie' && metadata ? ( @@ -423,7 +423,7 @@ const TabletStreamsLayout: React.FC = ({ setMovieLogoError(true)} /> ) : ( @@ -717,14 +717,41 @@ const createStyles = (colors: any) => StyleSheet.create({ position: 'relative', }, tabletFullScreenBackground: { - ...StyleSheet.absoluteFillObject, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + width: '100%', + height: '100%', + }, + fullScreenImage: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + width: '100%', + height: '100%', }, tabletNoBackdropBackground: { - ...StyleSheet.absoluteFillObject, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + width: '100%', + height: '100%', backgroundColor: colors.darkBackground, }, tabletFullScreenGradient: { - ...StyleSheet.absoluteFillObject, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + width: '100%', + height: '100%', }, tabletLeftPanel: { width: '40%', diff --git a/src/components/common/OptimizedImage.tsx b/src/components/common/OptimizedImage.tsx index b9d26b9..572f978 100644 --- a/src/components/common/OptimizedImage.tsx +++ b/src/components/common/OptimizedImage.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { View, StyleSheet, Dimensions } from 'react-native'; -import FastImage from '@d11/react-native-fast-image'; +import { View, StyleSheet, Dimensions, Platform } from 'react-native'; +import FastImage, { priority as FIPriority, cacheControl as FICacheControl, resizeMode as FIResizeMode, preload as FIPreload } from '../../utils/FastImageCompat'; import { logger } from '../../utils/logger'; interface OptimizedImageProps { @@ -28,7 +28,7 @@ const getOptimizedImageUrl = (originalUrl: string, containerWidth?: number, cont if (originalUrl.includes('image.tmdb.org')) { const width = containerWidth || 300; let size = 'w300'; - + if (width <= 92) size = 'w92'; else if (width <= 154) size = 'w154'; else if (width <= 185) size = 'w185'; @@ -36,7 +36,7 @@ const getOptimizedImageUrl = (originalUrl: string, containerWidth?: number, cont else if (width <= 500) size = 'w500'; else if (width <= 780) size = 'w780'; else size = 'w1280'; - + // Replace the size in the URL return originalUrl.replace(/\/w\d+\//, `/${size}/`); } @@ -106,7 +106,7 @@ const OptimizedImage: React.FC = ({ if (!optimizedUrl || !isVisible) return; try { - FastImage.preload([{ uri: optimizedUrl }]); + FIPreload([{ uri: optimizedUrl }]); if (!mountedRef.current) return; setIsLoaded(true); onLoad?.(); @@ -135,25 +135,25 @@ const OptimizedImage: React.FC = ({ ); } return ( { setIsLoaded(true); onLoad?.(); }} - onError={(error) => { + onError={(error: any) => { setHasError(true); onError?.(error); }} diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 29a53c4..ab7fd7a 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -14,7 +14,7 @@ import { import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { priority as FIPriority, cacheControl as FICacheControl, resizeMode as FIResizeMode } from '../../utils/FastImageCompat'; import { MaterialIcons, Entypo } from '@expo/vector-icons'; import Animated, { FadeIn, @@ -1013,11 +1013,11 @@ const AppleTVHero: React.FC = ({ setBannerLoaded((prev) => ({ ...prev, [currentIndex]: true }))} /> @@ -1028,11 +1028,11 @@ const AppleTVHero: React.FC = ({ setBannerLoaded((prev) => ({ ...prev, [nextIndex]: true }))} /> diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index a7e4e60..6b138e1 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -1,8 +1,14 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useToast } from '../../contexts/ToastContext'; import { DeviceEventEmitter } from 'react-native'; -import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Share } from 'react-native'; -import FastImage from '@d11/react-native-fast-image'; +import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Share, Image } from 'react-native'; + +import FastImage, { + priority as FastImagePriority, + cacheControl as FastImageCacheControl, + resizeMode as FastImageResizeMode +} from '../../utils/FastImageCompat'; + import { MaterialIcons, Feather } from '@expo/vector-icons'; import { useTheme } from '../../contexts/ThemeContext'; import { useSettings } from '../../hooks/useSettings'; @@ -315,11 +321,11 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe { setImageError(false); }} diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 96d132c..0da9097 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -17,7 +17,7 @@ import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; import { StreamingContent, catalogService } from '../../services/catalogService'; import { LinearGradient } from 'expo-linear-gradient'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { priority as FIPriority, cacheControl as FICacheControl, resizeMode as FIResizeMode } from '../../utils/FastImageCompat'; import { useTheme } from '../../contexts/ThemeContext'; import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; @@ -1107,11 +1107,11 @@ const ContinueWatchingSection = React.forwardRef((props, re {/* Delete Indicator Overlay */} diff --git a/src/components/home/DropUpMenu.tsx b/src/components/home/DropUpMenu.tsx index 41054ea..7deb1f0 100644 --- a/src/components/home/DropUpMenu.tsx +++ b/src/components/home/DropUpMenu.tsx @@ -11,7 +11,7 @@ import { Platform } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { priority as FIPriority, cacheControl as FICacheControl, resizeMode as FIResizeMode } from '../../utils/FastImageCompat'; import { useTraktContext } from '../../contexts/TraktContext'; import { colors } from '../../styles/colors'; import Animated, { @@ -165,11 +165,11 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index 31d399c..6037ef1 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -10,12 +10,18 @@ import { TextStyle, ImageStyle, ActivityIndicator, - Platform + Platform, + Image } from 'react-native'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; -import FastImage from '@d11/react-native-fast-image'; + +import FastImage, { + priority as FastImagePriority, + cacheControl as FastImageCacheControl, + resizeMode as FastImageResizeMode +} from '../../utils/FastImageCompat'; import { MaterialIcons, Feather } from '@expo/vector-icons'; import Animated, { FadeIn, @@ -443,7 +449,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin @@ -536,7 +542,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin - + {/* Bottom fade to blend with background */} @@ -663,7 +669,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin - + {/* Bottom fade to blend with background */} = ({ items, loading = false }) = const result: { uri: string; priority?: any }[] = []; const bannerOrPoster = it.banner || it.poster; if (bannerOrPoster) { - result.push({ uri: bannerOrPoster, priority: (FastImage as any).priority?.low }); + result.push({ uri: bannerOrPoster, priority: FIPriority.low }); } if (it.logo) { - result.push({ uri: it.logo, priority: (FastImage as any).priority?.normal }); + result.push({ uri: it.logo, priority: FIPriority.normal }); } return result; }); // de-duplicate by uri const uniqueSources = Array.from(new Map(sources.map((s) => [s.uri, s])).values()); - if (uniqueSources.length && (FastImage as any).preload) { - (FastImage as any).preload(uniqueSources); + if (uniqueSources.length) { + FIPreload(uniqueSources); } } catch { // no-op: prefetch is best-effort @@ -309,11 +309,11 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = {Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable ? ( = memo(({ = memo(({ setLogoLoaded(true)} /> @@ -806,11 +806,11 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail setBannerLoaded(true)} /> @@ -819,9 +819,9 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail {item.logo && !logoFailed ? ( ) : ( @@ -866,11 +866,11 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail setBannerLoaded(true)} /> @@ -882,11 +882,11 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail setLogoLoaded(true)} onError={onLogoError} /> @@ -920,18 +920,18 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail {/* Overlay removed for performance - readability via text shadows */} {item.logo && !logoFailed ? ( ) : ( diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx index fbace08..25965b1 100644 --- a/src/components/home/ThisWeekSection.tsx +++ b/src/components/home/ThisWeekSection.tsx @@ -10,7 +10,7 @@ import { } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { priority as FIPriority, cacheControl as FICacheControl, resizeMode as FIResizeMode } from '../../utils/FastImageCompat'; import { LinearGradient } from 'expo-linear-gradient'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../../contexts/ThemeContext'; @@ -272,11 +272,11 @@ export const ThisWeekSection = React.memo(() => { = ({ if (visible && castMember) { modalOpacity.value = withTiming(1, { duration: 250 }); modalScale.value = withSpring(1, { damping: 20, stiffness: 200 }); - + if (!hasFetched || personDetails?.id !== castMember.id) { fetchPersonDetails(); } } else { modalOpacity.value = withTiming(0, { duration: 200 }); modalScale.value = withTiming(0.9, { duration: 200 }); - + if (!visible) { setHasFetched(false); setPersonDetails(null); @@ -99,7 +99,7 @@ export const CastDetailsModal: React.FC = ({ const fetchPersonDetails = async () => { if (!castMember || loading) return; - + setLoading(true); try { const details = await tmdbService.getPersonDetails(castMember.id); @@ -150,11 +150,11 @@ export const CastDetailsModal: React.FC = ({ const birthDate = new Date(birthday); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); - + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } - + return age; }; @@ -196,8 +196,8 @@ export const CastDetailsModal: React.FC = ({ height: MODAL_HEIGHT, overflow: 'hidden', borderRadius: isTablet ? 32 : 24, - backgroundColor: Platform.OS === 'android' - ? 'rgba(20, 20, 20, 0.95)' + backgroundColor: Platform.OS === 'android' + ? 'rgba(20, 20, 20, 0.95)' : 'transparent', }, modalStyle, @@ -261,7 +261,7 @@ export const CastDetailsModal: React.FC = ({ uri: `https://image.tmdb.org/t/p/w185${castMember.profile_path}`, }} style={{ width: '100%', height: '100%' }} - resizeMode={FastImage.resizeMode.cover} + resizeMode={FIResizeMode.cover} /> ) : ( = ({ )} - + = ({ borderColor: 'rgba(255, 255, 255, 0.06)', }}> {personDetails?.birthday && ( - diff --git a/src/components/metadata/CastSection.tsx b/src/components/metadata/CastSection.tsx index 3f24812..4223cb1 100644 --- a/src/components/metadata/CastSection.tsx +++ b/src/components/metadata/CastSection.tsx @@ -8,7 +8,7 @@ import { ActivityIndicator, Dimensions, } from 'react-native'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../../utils/FastImageCompat'; import Animated, { FadeIn, } from 'react-native-reanimated'; @@ -40,7 +40,7 @@ export const CastSection: React.FC = ({ // 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'; @@ -48,13 +48,13 @@ export const CastSection: React.FC = ({ 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 const horizontalPadding = useMemo(() => { switch (deviceType) { @@ -68,7 +68,7 @@ export const CastSection: React.FC = ({ return 16; // phone } }, [deviceType]); - + // Enhanced cast card sizing const castCardWidth = useMemo(() => { switch (deviceType) { @@ -82,7 +82,7 @@ export const CastSection: React.FC = ({ return 90; // phone } }, [deviceType]); - + const castImageSize = useMemo(() => { switch (deviceType) { case 'tv': @@ -95,7 +95,7 @@ export const CastSection: React.FC = ({ return 80; // phone } }, [deviceType]); - + const castCardSpacing = useMemo(() => { switch (deviceType) { case 'tv': @@ -122,7 +122,7 @@ export const CastSection: React.FC = ({ } return ( - @@ -131,8 +131,8 @@ export const CastSection: React.FC = ({ { paddingHorizontal: horizontalPadding } ]}> = ({ ]} keyExtractor={(item) => item.id.toString()} renderItem={({ item, index }) => ( - - = ({ uri: `https://image.tmdb.org/t/p/w185${item.profile_path}`, }} style={styles.castImage} - resizeMode={FastImage.resizeMode.cover} + resizeMode={FIResizeMode.cover} /> ) : ( = ({ )} = ({ ]} numberOfLines={1}>{item.name} {isTmdbEnrichmentEnabled && item.character && ( = ({ - collectionName, - collectionMovies, - loadingCollection +export const CollectionSection: React.FC = ({ + collectionName, + collectionMovies, + loadingCollection }) => { const { currentTheme } = useTheme(); const navigation = useNavigation>(); @@ -82,7 +82,7 @@ export const CollectionSection: React.FC = ({ default: return 180; } }, [deviceType]); - const backdropHeight = React.useMemo(() => backdropWidth * (9/16), [backdropWidth]); // 16:9 aspect ratio + const backdropHeight = React.useMemo(() => backdropWidth * (9 / 16), [backdropWidth]); // 16:9 aspect ratio const [alertVisible, setAlertVisible] = React.useState(false); const [alertTitle, setAlertTitle] = React.useState(''); @@ -93,15 +93,15 @@ export const CollectionSection: React.FC = ({ try { // Extract TMDB ID from the tmdb:123456 format const tmdbId = item.id.replace('tmdb:', ''); - + // Get Stremio ID directly using catalogService const stremioId = await catalogService.getStremioId(item.type, tmdbId); - + if (stremioId) { navigation.dispatch( - StackActions.push('Metadata', { - id: stremioId, - type: item.type + StackActions.push('Metadata', { + id: stremioId, + type: item.type }) ); } else { @@ -111,7 +111,7 @@ export const CollectionSection: React.FC = ({ if (__DEV__) console.error('Error navigating to collection item:', error); setAlertTitle('Error'); setAlertMessage('Unable to load this content. Please try again later.'); - setAlertActions([{ label: 'OK', onPress: () => {} }]); + setAlertActions([{ label: 'OK', onPress: () => { } }]); setAlertVisible(true); } }; @@ -120,9 +120,9 @@ export const CollectionSection: React.FC = ({ // Upcoming/unreleased movies without a year will be sorted last const sortedCollectionMovies = React.useMemo(() => { if (!collectionMovies) return []; - + const FUTURE_YEAR_PLACEHOLDER = 9999; // Very large number to sort unreleased movies last - + return [...collectionMovies].sort((a, b) => { // Treat missing years as future year placeholder (sorts last) const yearA = a.year ? parseInt(a.year.toString()) : FUTURE_YEAR_PLACEHOLDER; @@ -132,31 +132,31 @@ export const CollectionSection: React.FC = ({ }, [collectionMovies]); const renderItem = ({ item }: { item: StreamingContent }) => ( - handleItemPress(item)} > - {item.name} {item.year && ( - {item.year} @@ -177,11 +177,11 @@ export const CollectionSection: React.FC = ({ } return ( - - + {collectionName} @@ -191,9 +191,9 @@ export const CollectionSection: React.FC = ({ keyExtractor={(item) => item.id} horizontal showsHorizontalScrollIndicator={false} - contentContainerStyle={[styles.listContentContainer, { - paddingHorizontal: horizontalPadding, - paddingRight: horizontalPadding + itemSpacing + contentContainerStyle={[styles.listContentContainer, { + paddingHorizontal: horizontalPadding, + paddingRight: horizontalPadding + itemSpacing }]} /> = ({ height: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18 } ]} - resizeMode={FastImage.resizeMode.contain} + resizeMode={FIResizeMode.contain} /> = ({ - recommendations, - loadingRecommendations +export const MoreLikeThisSection: React.FC = ({ + recommendations, + loadingRecommendations }) => { const { currentTheme } = useTheme(); const navigation = useNavigation>(); @@ -91,16 +91,16 @@ export const MoreLikeThisSection: React.FC = ({ try { // Extract TMDB ID from the tmdb:123456 format const tmdbId = item.id.replace('tmdb:', ''); - + // Get Stremio ID directly using catalogService // The catalogService.getStremioId method already handles the conversion internally const stremioId = await catalogService.getStremioId(item.type, tmdbId); - + if (stremioId) { navigation.dispatch( - StackActions.push('Metadata', { - id: stremioId, - type: item.type + StackActions.push('Metadata', { + id: stremioId, + type: item.type }) ); } else { @@ -110,20 +110,20 @@ export const MoreLikeThisSection: React.FC = ({ if (__DEV__) console.error('Error navigating to recommendation:', error); setAlertTitle('Error'); setAlertMessage('Unable to load this content. Please try again later.'); - setAlertActions([{ label: 'OK', onPress: () => {} }]); + setAlertActions([{ label: 'OK', onPress: () => { } }]); setAlertVisible(true); } }; const renderItem = ({ item }: { item: StreamingContent }) => ( - handleItemPress(item)} > {item.name} @@ -144,7 +144,7 @@ export const MoreLikeThisSection: React.FC = ({ } return ( - + More Like This = ({ {selectedSeason === season && ( = ({ = ({ height: isTV ? 17 : isLargeTablet ? 16 : isTablet ? 15 : 15 } ]} - resizeMode={FastImage.resizeMode.contain} + resizeMode={FIResizeMode.contain} /> = ({ height: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14 } ]} - resizeMode={FastImage.resizeMode.contain} + resizeMode={FIResizeMode.contain} /> = ({ {/* Standard Gradient Overlay */} @@ -1432,7 +1432,7 @@ const SeriesContentComponent: React.FC = ({ height: isTV ? 17 : isLargeTablet ? 16 : isTablet ? 15 : 15 } ]} - resizeMode={FastImage.resizeMode.contain} + resizeMode={FIResizeMode.contain} /> = 768; @@ -135,7 +135,7 @@ const TrailerModal: React.FC = memo(({ const handleClose = useCallback(() => { setIsPlaying(false); - + // Resume hero section trailer when modal closes try { resumeTrailer(); @@ -143,7 +143,7 @@ const TrailerModal: React.FC = memo(({ } catch (error) { logger.warn('TrailerModal', 'Error resuming hero trailer:', error); } - + onClose(); }, [onClose, resumeTrailer]); diff --git a/src/components/metadata/TrailersSection.tsx b/src/components/metadata/TrailersSection.tsx index c1f261d..b7f710f 100644 --- a/src/components/metadata/TrailersSection.tsx +++ b/src/components/metadata/TrailersSection.tsx @@ -12,7 +12,7 @@ import { Modal, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../../utils/FastImageCompat'; import { useTheme } from '../../contexts/ThemeContext'; import { useSettings } from '../../hooks/useSettings'; import { useTrailer } from '../../contexts/TrailerContext'; @@ -675,7 +675,7 @@ const TrailersSection: React.FC = memo(({ borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16 } ]} - resizeMode={FastImage.resizeMode.cover} + resizeMode={FIResizeMode.cover} /> {/* Subtle Gradient Overlay */} ('KSPlayerView'); -const KSPlayerModule = NativeModules.KSPlayerModule; +// Only require native component on iOS +const KSPlayerViewManager = Platform.OS === 'ios' + ? requireNativeComponent('KSPlayerView') + : View as any; +const KSPlayerModule = Platform.OS === 'ios' ? NativeModules.KSPlayerModule : null; export interface KSPlayerRef { seek: (time: number) => void; diff --git a/src/components/player/cards/EpisodeCard.tsx b/src/components/player/cards/EpisodeCard.tsx index 33834d6..801a24a 100644 --- a/src/components/player/cards/EpisodeCard.tsx +++ b/src/components/player/cards/EpisodeCard.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../../../utils/FastImageCompat'; import { MaterialIcons } from '@expo/vector-icons'; import { Episode } from '../../../types/metadata'; @@ -28,7 +28,7 @@ export const EpisodeCard: React.FC = ({ }) => { const { width } = Dimensions.get('window'); const isTablet = width >= 768; - + // Get episode image let episodeImage = EPISODE_PLACEHOLDER; if (episode.still_path) { @@ -42,11 +42,11 @@ export const EpisodeCard: React.FC = ({ } 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')}` : ''; - + // 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}`]; @@ -60,7 +60,7 @@ export const EpisodeCard: React.FC = ({ const progress = episodeProgress?.[episodeId]; const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0; const showProgress = progress && progressPercent < 85; - + const formatRuntime = (runtime: number) => { if (!runtime) return null; const hours = Math.floor(runtime / 60); @@ -70,7 +70,7 @@ export const EpisodeCard: React.FC = ({ } return `${minutes}m`; }; - + const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleDateString('en-US', { @@ -94,7 +94,7 @@ export const EpisodeCard: React.FC = ({ {isCurrent && ( @@ -106,11 +106,11 @@ export const EpisodeCard: React.FC = ({ {showProgress && ( - )} @@ -138,7 +138,7 @@ export const EpisodeCard: React.FC = ({ {effectiveVote.toFixed(1)} diff --git a/src/components/player/components/PauseOverlay.tsx b/src/components/player/components/PauseOverlay.tsx index 31cd49a..e7b3514 100644 --- a/src/components/player/components/PauseOverlay.tsx +++ b/src/components/player/components/PauseOverlay.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { View, Text, TouchableOpacity, ScrollView, Animated, StyleSheet } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../../../utils/FastImageCompat'; import { MaterialIcons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -175,7 +175,7 @@ export const PauseOverlay: React.FC = ({ )} diff --git a/src/components/video/TrailerPlayer.tsx b/src/components/video/TrailerPlayer.tsx index c39b585..5a82113 100644 --- a/src/components/video/TrailerPlayer.tsx +++ b/src/components/video/TrailerPlayer.tsx @@ -10,7 +10,7 @@ import { AppStateStatus, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import Video, { VideoRef, OnLoadData, OnProgressData } from 'react-native-video'; +import Video, { VideoRef, OnLoadData, OnProgressData } from '../../utils/VideoCompat'; import { LinearGradient } from 'expo-linear-gradient'; import Animated, { useAnimatedStyle, @@ -64,7 +64,7 @@ const TrailerPlayer = React.forwardRef(({ const { currentTheme } = useTheme(); const { isTrailerPlaying: globalTrailerPlaying } = useTrailer(); const videoRef = useRef(null); - + const [isLoading, setIsLoading] = useState(true); const [isPlaying, setIsPlaying] = useState(autoPlay); const [isMuted, setIsMuted] = useState(muted); @@ -90,16 +90,16 @@ const TrailerPlayer = React.forwardRef(({ if (videoRef.current) { // Pause the video setIsPlaying(false); - + // Seek to beginning to stop any background processing videoRef.current.seek(0); - + // Clear any pending timeouts if (hideControlsTimeout.current) { clearTimeout(hideControlsTimeout.current); hideControlsTimeout.current = null; } - + logger.info('TrailerPlayer', 'Video cleanup completed'); } } catch (error) { @@ -138,7 +138,7 @@ const TrailerPlayer = React.forwardRef(({ // Component mount/unmount tracking useEffect(() => { setIsComponentMounted(true); - + return () => { setIsComponentMounted(false); cleanupVideo(); @@ -185,15 +185,15 @@ const TrailerPlayer = React.forwardRef(({ const showControlsWithTimeout = useCallback(() => { if (!isComponentMounted) return; - + setShowControls(true); controlsOpacity.value = withTiming(1, { duration: 200 }); - + // Clear existing timeout if (hideControlsTimeout.current) { clearTimeout(hideControlsTimeout.current); } - + // Set new timeout to hide controls hideControlsTimeout.current = setTimeout(() => { if (isComponentMounted) { @@ -205,7 +205,7 @@ const TrailerPlayer = React.forwardRef(({ const handleVideoPress = useCallback(() => { if (!isComponentMounted) return; - + if (showControls) { // If controls are visible, toggle play/pause handlePlayPause(); @@ -218,7 +218,7 @@ const TrailerPlayer = React.forwardRef(({ const handlePlayPause = useCallback(async () => { try { if (!videoRef.current || !isComponentMounted) return; - + playButtonScale.value = withTiming(0.8, { duration: 100 }, () => { if (isComponentMounted) { playButtonScale.value = withTiming(1, { duration: 100 }); @@ -226,7 +226,7 @@ const TrailerPlayer = React.forwardRef(({ }); setIsPlaying(!isPlaying); - + showControlsWithTimeout(); } catch (error) { logger.error('TrailerPlayer', 'Error toggling playback:', error); @@ -236,7 +236,7 @@ const TrailerPlayer = React.forwardRef(({ const handleMuteToggle = useCallback(async () => { try { if (!videoRef.current || !isComponentMounted) return; - + setIsMuted(!isMuted); showControlsWithTimeout(); } catch (error) { @@ -246,7 +246,7 @@ const TrailerPlayer = React.forwardRef(({ const handleLoadStart = useCallback(() => { if (!isComponentMounted) return; - + setIsLoading(true); setHasError(false); // Only show loading spinner if not hidden @@ -257,7 +257,7 @@ const TrailerPlayer = React.forwardRef(({ const handleLoad = useCallback((data: OnLoadData) => { if (!isComponentMounted) return; - + setIsLoading(false); loadingOpacity.value = withTiming(0, { duration: 300 }); setDuration(data.duration * 1000); // Convert to milliseconds @@ -267,7 +267,7 @@ const TrailerPlayer = React.forwardRef(({ const handleError = useCallback((error: any) => { if (!isComponentMounted) return; - + setIsLoading(false); setHasError(true); loadingOpacity.value = withTiming(0, { duration: 300 }); @@ -278,10 +278,10 @@ const TrailerPlayer = React.forwardRef(({ const handleProgress = useCallback((data: OnProgressData) => { if (!isComponentMounted) return; - + setPosition(data.currentTime * 1000); // Convert to milliseconds onProgress?.(data); - + if (onPlaybackStatusUpdate) { onPlaybackStatusUpdate({ isLoaded: data.currentTime > 0, @@ -304,7 +304,7 @@ const TrailerPlayer = React.forwardRef(({ clearTimeout(hideControlsTimeout.current); hideControlsTimeout.current = null; } - + // Reset all animated values to prevent memory leaks try { controlsOpacity.value = 0; @@ -313,7 +313,7 @@ const TrailerPlayer = React.forwardRef(({ } catch (error) { logger.error('TrailerPlayer', 'Error cleaning up animation values:', error); } - + // Ensure video is stopped cleanupVideo(); }; @@ -420,9 +420,9 @@ const TrailerPlayer = React.forwardRef(({ )} - {/* Video controls overlay */} + {/* Video controls overlay */} {!hideControls && ( - (({ - @@ -457,8 +457,8 @@ const TrailerPlayer = React.forwardRef(({ {/* Progress bar */} - @@ -466,27 +466,27 @@ const TrailerPlayer = React.forwardRef(({ {/* Control buttons */} - - + - - + {onFullscreenToggle && ( - )} diff --git a/src/hooks/useMetadataAssets.ts b/src/hooks/useMetadataAssets.ts index 694ddef..46e9a93 100644 --- a/src/hooks/useMetadataAssets.ts +++ b/src/hooks/useMetadataAssets.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { logger } from '../utils/logger'; import { TMDBService } from '../services/tmdbService'; import { isTmdbUrl } from '../utils/logoUtils'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage from '../utils/FastImageCompat'; import { mmkvStorage } from '../services/mmkvStorage'; // Cache for image availability checks @@ -14,7 +14,7 @@ const checkImageAvailability = async (url: string): Promise => { if (imageAvailabilityCache[url] !== undefined) { return imageAvailabilityCache[url]; } - + // Check AsyncStorage cache try { const cachedResult = await mmkvStorage.getItem(`image_available:${url}`); @@ -31,7 +31,7 @@ const checkImageAvailability = async (url: string): Promise => { try { const response = await fetch(url, { method: 'HEAD' }); const isAvailable = response.ok; - + // Update caches imageAvailabilityCache[url] = isAvailable; try { @@ -39,7 +39,7 @@ const checkImageAvailability = async (url: string): Promise => { } catch (error) { // Ignore AsyncStorage errors } - + return isAvailable; } catch (error) { return false; @@ -47,9 +47,9 @@ const checkImageAvailability = async (url: string): Promise => { }; export const useMetadataAssets = ( - metadata: any, - id: string, - type: string, + metadata: any, + id: string, + type: string, imdbId: string | null, settings: any, setMetadata: (metadata: any) => void @@ -58,22 +58,22 @@ export const useMetadataAssets = ( const [bannerImage, setBannerImage] = useState(null); const [loadingBanner, setLoadingBanner] = useState(false); const forcedBannerRefreshDone = useRef(false); - + // Add source tracking to prevent mixing sources const [bannerSource, setBannerSource] = useState<'tmdb' | 'metahub' | 'default' | null>(null); - + // For TMDB ID tracking const [foundTmdbId, setFoundTmdbId] = useState(null); - - + + const isMountedRef = useRef(true); - + // CRITICAL: AbortController to cancel in-flight requests when component unmounts const abortControllerRef = useRef(new AbortController()); - + // Track pending requests to prevent duplicate concurrent API calls const pendingFetchRef = useRef | null>(null); - + // Cleanup on unmount useEffect(() => { return () => { @@ -82,12 +82,12 @@ export const useMetadataAssets = ( abortControllerRef.current.abort(); }; }, []); - - + + useEffect(() => { abortControllerRef.current = new AbortController(); }, [id, type]); - + // Force reset when preference changes useEffect(() => { // Reset all cached data when preference changes @@ -101,7 +101,7 @@ export const useMetadataAssets = ( // Optimized banner fetching with race condition fixes const fetchBanner = useCallback(async () => { if (!metadata || !isMountedRef.current) return; - + // Prevent concurrent fetch requests for the same metadata if (pendingFetchRef.current) { try { @@ -110,16 +110,16 @@ export const useMetadataAssets = ( // Previous request failed, allow new attempt } } - + // Create a promise to track this fetch operation const fetchPromise = (async () => { try { if (!isMountedRef.current) return; - + if (isMountedRef.current) { setLoadingBanner(true); } - + // If enrichment is disabled, use addon banner and don't fetch from external sources if (!settings.enrichMetadataWithTMDB) { const addonBanner = metadata?.banner || null; @@ -132,15 +132,15 @@ export const useMetadataAssets = ( } return; } - + try { const currentPreference = settings.logoSourcePreference || 'tmdb'; const contentType = type === 'series' ? 'tv' : 'movie'; - + // Collect final state before updating to prevent intermediate null states let finalBanner: string | null = bannerImage; // Start with current to prevent flicker let bannerSourceType: 'tmdb' | 'default' = (bannerSource === 'tmdb' || bannerSource === 'default') ? bannerSource : 'default'; - + // TMDB path only if (currentPreference === 'tmdb') { let tmdbId = null; @@ -163,24 +163,24 @@ export const useMetadataAssets = ( logger.debug('[useMetadataAssets] TMDB ID lookup failed:', error); } } - + if (tmdbId && isMountedRef.current) { try { const tmdbService = TMDBService.getInstance(); const endpoint = contentType === 'tv' ? 'tv' : 'movie'; - + // Fetch details (AbortSignal will be used for future implementations) - const details = endpoint === 'movie' - ? await tmdbService.getMovieDetails(tmdbId) + const details = endpoint === 'movie' + ? await tmdbService.getMovieDetails(tmdbId) : await tmdbService.getTVShowDetails(Number(tmdbId)); - + // Only update if request wasn't aborted and component is still mounted if (!isMountedRef.current) return; - + if (details?.backdrop_path) { finalBanner = tmdbService.getImageUrl(details.backdrop_path); bannerSourceType = 'tmdb'; - + // Preload the image if (finalBanner) { FastImage.preload([{ uri: finalBanner }]); @@ -196,10 +196,10 @@ export const useMetadataAssets = ( // Request was cancelled, don't update state return; } - + // Only update state if still mounted after error if (!isMountedRef.current) return; - + logger.debug('[useMetadataAssets] TMDB details fetch failed:', error); // Keep current banner on error instead of setting to null finalBanner = bannerImage || metadata?.banner || null; @@ -207,27 +207,27 @@ export const useMetadataAssets = ( } } } - + // Final fallback to metadata banner only if (!finalBanner) { finalBanner = metadata?.banner || null; bannerSourceType = 'default'; } - + // CRITICAL: Batch all state updates into a single call to prevent race conditions // This ensures the native view hierarchy doesn't receive conflicting unmount/remount signals if (isMountedRef.current && (finalBanner !== bannerImage || bannerSourceType !== bannerSource)) { setBannerImage(finalBanner); setBannerSource(bannerSourceType); } - + if (isMountedRef.current) { forcedBannerRefreshDone.current = true; } } catch (error) { // Outer catch for any unexpected errors if (!isMountedRef.current) return; - + logger.error('[useMetadataAssets] Unexpected error in banner fetch:', error); // Use current banner on error, don't set to null const defaultBanner = bannerImage || metadata?.banner || null; @@ -244,7 +244,7 @@ export const useMetadataAssets = ( pendingFetchRef.current = null; } })(); - + pendingFetchRef.current = fetchPromise; return fetchPromise; }, [metadata, id, type, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference, settings.enrichMetadataWithTMDB, foundTmdbId, bannerImage, bannerSource]); @@ -252,9 +252,9 @@ export const useMetadataAssets = ( // Fetch banner when needed useEffect(() => { if (!isMountedRef.current) return; - + const currentPreference = settings.logoSourcePreference || 'tmdb'; - + if (bannerSource !== currentPreference && !forcedBannerRefreshDone.current) { fetchBanner(); } @@ -267,6 +267,6 @@ export const useMetadataAssets = ( setBannerImage, bannerSource, logoLoadError: false, - setLogoLoadError: () => {}, + setLogoLoadError: () => { }, }; }; \ No newline at end of file diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index ed0c4ce..1a4e0ac 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -15,7 +15,15 @@ import { HeaderVisibility } from '../contexts/HeaderVisibility'; import { Stream } from '../types/streams'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; -import { PostHogProvider } from 'posthog-react-native'; +// PostHogProvider is conditionally imported to avoid issues on web +let PostHogProvider: any = ({ children }: { children: React.ReactNode }) => <>{children}; +if (Platform.OS !== 'web') { + try { + PostHogProvider = require('posthog-react-native').PostHogProvider; + } catch (e) { + // Fallback already set above + } +} import { ScrollToTopProvider, useScrollToTopEmitter } from '../contexts/ScrollToTopContext'; // Optional iOS Glass effect (expo-glass-effect) with safe fallback @@ -866,6 +874,7 @@ const MainTabs = () => { }; // iOS: Use native bottom tabs (@bottom-tabs/react-navigation) + // Exclude web to use standard tabs instead if (Platform.OS === 'ios') { // Dynamically require to avoid impacting Android bundle const { createNativeBottomTabNavigator } = require('@bottom-tabs/react-navigation'); diff --git a/src/screens/AIChatScreen.tsx b/src/screens/AIChatScreen.tsx index 34ad7cc..976c437 100644 --- a/src/screens/AIChatScreen.tsx +++ b/src/screens/AIChatScreen.tsx @@ -18,7 +18,7 @@ import CustomAlert from '../components/CustomAlert'; import { useRoute, useNavigation, RouteProp, useFocusEffect } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../contexts/ThemeContext'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { BlurView as ExpoBlurView } from 'expo-blur'; // Lazy-safe community blur import (avoid bundling issues on web) let AndroidBlurView: any = null; @@ -49,10 +49,10 @@ import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context' import { aiService, ChatMessage, ContentContext, createMovieContext, createEpisodeContext, createSeriesContext, generateConversationStarters } from '../services/aiService'; import { tmdbService } from '../services/tmdbService'; import Markdown from 'react-native-markdown-display'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withSpring, +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, withTiming, interpolate, Extrapolate, @@ -83,13 +83,13 @@ interface ChatBubbleProps { const ChatBubble: React.FC = React.memo(({ message, isLast }) => { const { currentTheme } = useTheme(); const isUser = message.role === 'user'; - + const bubbleAnimation = useSharedValue(0); - + useEffect(() => { bubbleAnimation.value = withSpring(1, { damping: 15, stiffness: 120 }); }, []); - + const animatedStyle = useAnimatedStyle(() => ({ opacity: bubbleAnimation.value, transform: [ @@ -124,11 +124,11 @@ const ChatBubble: React.FC = React.memo(({ message, isLast }) = )} - + = React.memo(({ message, isLast }) = {Platform.OS === 'android' && AndroidBlurView ? : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable - ? - : } + ? + : } )} - {isUser ? ( - - {message.content} - - ) : ( - - {message.content} - - )} + {isUser ? ( + + {message.content} + + ) : ( + + {message.content} + + )} - {new Date(message.timestamp).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit' + {new Date(message.timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' })} - + {isUser && ( @@ -300,7 +300,7 @@ interface SuggestionChipProps { const SuggestionChip: React.FC = React.memo(({ text, onPress }) => { const { currentTheme } = useTheme(); - + return ( { const navigation = useNavigation(); const { currentTheme } = useTheme(); const insets = useSafeAreaInsets(); - + const { contentId, contentType, episodeId, seasonNumber, episodeNumber, title } = route.params; - + const [messages, setMessages] = useState([]); const [inputText, setInputText] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -369,10 +369,10 @@ const AIChatScreen: React.FC = () => { }; }, []) ); - + const scrollViewRef = useRef(null); const inputRef = useRef(null); - + // Animation values const headerOpacity = useSharedValue(1); const inputContainerY = useSharedValue(0); @@ -432,7 +432,7 @@ const AIChatScreen: React.FC = () => { const loadContext = async () => { try { setIsLoadingContext(true); - + if (contentType === 'movie') { // Movies: contentId may be TMDB id string or IMDb id (tt...) let movieData = await tmdbService.getMovieDetails(contentId); @@ -451,7 +451,7 @@ const AIChatScreen: React.FC = () => { try { const path = movieData.backdrop_path || movieData.poster_path || null; if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`); - } catch {} + } catch { } } else { // Series: resolve TMDB numeric id first (contentId may be IMDb/stremio id) let tmdbNumericId: number | null = null; @@ -476,25 +476,25 @@ const AIChatScreen: React.FC = () => { try { const path = showData.backdrop_path || showData.poster_path || null; if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`); - } catch {} - + } catch { } + if (!showData) throw new Error('Unable to load TV show details'); const seriesContext = createSeriesContext(showData, allEpisodes || {}); setContext(seriesContext); } } catch (error) { if (__DEV__) console.error('Error loading context:', error); - openAlert('Error', 'Failed to load content details for AI chat'); + openAlert('Error', 'Failed to load content details for AI chat'); } finally { setIsLoadingContext(false); - {/* CustomAlert at root */} - setAlertVisible(false)} - actions={alertActions} - /> + {/* CustomAlert at root */ } + setAlertVisible(false)} + actions={alertActions} + /> } }; @@ -527,10 +527,10 @@ const AIChatScreen: React.FC = () => { const sxe = messageText.match(/s(\d+)e(\d+)/i); const words = messageText.match(/season\s+(\d+)[^\d]+episode\s+(\d+)/i); const seasonOnly = messageText.match(/s(\d+)(?!e)/i) || messageText.match(/season\s+(\d+)/i); - + let season = sxe ? parseInt(sxe[1], 10) : (words ? parseInt(words[1], 10) : undefined); let episode = sxe ? parseInt(sxe[2], 10) : (words ? parseInt(words[2], 10) : undefined); - + // If only season mentioned (like "s2" or "season 2"), default to episode 1 if (!season && seasonOnly) { season = parseInt(seasonOnly[1], 10); @@ -558,7 +558,7 @@ const AIChatScreen: React.FC = () => { requestContext = createEpisodeContext(episodeData, showData, season, episode); } } - } catch {} + } catch { } } } @@ -578,7 +578,7 @@ const AIChatScreen: React.FC = () => { setMessages(prev => [...prev, assistantMessage]); } catch (error) { if (__DEV__) console.error('Error sending message:', error); - + let errorMessage = 'Sorry, I encountered an error. Please try again.'; if (error instanceof Error) { if (error.message.includes('not configured')) { @@ -623,7 +623,7 @@ const AIChatScreen: React.FC = () => { const getDisplayTitle = () => { if (!context) return title; - + if ('episodesBySeason' in (context as any)) { // Always show just the series title return (context as any).title; @@ -656,200 +656,200 @@ const AIChatScreen: React.FC = () => { return ( - - {backdropUrl && ( - - - {Platform.OS === 'android' && AndroidBlurView - ? - : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable - ? - : } - - - )} - - - {/* Header */} - - - { - if (Platform.OS === 'android') { - modalOpacity.value = withSpring(0, { damping: 18, stiffness: 160 }, (finished) => { - if (finished) runOnJS(navigation.goBack)(); - }); - } else { - navigation.goBack(); - } - }} - style={styles.backButton} - > - - - - - - AI Chat - - - {getDisplayTitle()} - + + {backdropUrl && ( + + + {Platform.OS === 'android' && AndroidBlurView + ? + : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable + ? + : } + - - - - - - + )} + - {/* Chat Messages */} - - - {messages.length === 0 && suggestions.length > 0 && ( - - - - - - Ask me anything about + {/* Header */} + + + { + if (Platform.OS === 'android') { + modalOpacity.value = withSpring(0, { damping: 18, stiffness: 160 }, (finished) => { + if (finished) runOnJS(navigation.goBack)(); + }); + } else { + navigation.goBack(); + } + }} + style={styles.backButton} + > + + + + + + AI Chat - + {getDisplayTitle()} - - I have detailed knowledge about this content and can answer questions about plot, characters, themes, and more. - - - - - Try asking: - - - {suggestions.map((suggestion, index) => ( - handleSuggestionPress(suggestion)} - /> - ))} - - - )} - - {messages.map((message, index) => ( - - ))} - - {isLoading && ( - - - - - - - - - - )} - - {/* Input Container */} - - - - - {Platform.OS === 'android' && AndroidBlurView - ? - : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable - ? - : } - + + - - - - - - - - - setAlertVisible(false)} - actions={alertActions} - /> + + {/* Chat Messages */} + + + {messages.length === 0 && suggestions.length > 0 && ( + + + + + + Ask me anything about + + + {getDisplayTitle()} + + + I have detailed knowledge about this content and can answer questions about plot, characters, themes, and more. + + + + + Try asking: + + + {suggestions.map((suggestion, index) => ( + handleSuggestionPress(suggestion)} + /> + ))} + + + + )} + + {messages.map((message, index) => ( + + ))} + + {isLoading && ( + + + + + + + + + + )} + + + {/* Input Container */} + + + + + {Platform.OS === 'android' && AndroidBlurView + ? + : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable + ? + : } + + + + + + + + + + + + + setAlertVisible(false)} + actions={alertActions} + /> ); }; diff --git a/src/screens/AccountManageScreen.tsx b/src/screens/AccountManageScreen.tsx index 6b129dd..e337422 100644 --- a/src/screens/AccountManageScreen.tsx +++ b/src/screens/AccountManageScreen.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, StatusBar, Platform, Animated, Easing, TextInput, ActivityIndicator } from 'react-native'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialIcons } from '@expo/vector-icons'; @@ -52,7 +52,7 @@ const AccountManageScreen: React.FC = () => { if (err) { setAlertTitle('Error'); setAlertMessage(err); - setAlertActions([{ label: 'OK', onPress: () => {} }]); + setAlertActions([{ label: 'OK', onPress: () => { } }]); setAlertVisible(true); } setSaving(false); @@ -62,7 +62,7 @@ const AccountManageScreen: React.FC = () => { setAlertTitle('Sign out'); setAlertMessage('Are you sure you want to sign out?'); setAlertActions([ - { label: 'Cancel', onPress: () => {} }, + { label: 'Cancel', onPress: () => { } }, { label: 'Sign out', onPress: async () => { @@ -70,7 +70,7 @@ const AccountManageScreen: React.FC = () => { await signOut(); // @ts-ignore navigation.goBack(); - } catch (_) {} + } catch (_) { } }, style: { opacity: 1 }, }, @@ -109,11 +109,11 @@ const AccountManageScreen: React.FC = () => { {/* Profile Badge */} {avatarUrl && !avatarError ? ( - + setAvatarError(true)} /> diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index 85528a5..c771e51 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -21,7 +21,7 @@ import { } from 'react-native'; import { stremioService, Manifest } from '../services/stremioService'; import { MaterialIcons } from '@expo/vector-icons'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { LinearGradient } from 'expo-linear-gradient'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -1004,7 +1004,7 @@ const AddonsScreen = () => { ) : ( @@ -1080,7 +1080,7 @@ const AddonsScreen = () => { ) : ( @@ -1272,7 +1272,7 @@ const AddonsScreen = () => { ) : ( @@ -1350,7 +1350,7 @@ const AddonsScreen = () => { ) : ( @@ -1456,7 +1456,7 @@ const AddonsScreen = () => { ) : ( diff --git a/src/screens/BackdropGalleryScreen.tsx b/src/screens/BackdropGalleryScreen.tsx index b9b71bb..ab9a7c9 100644 --- a/src/screens/BackdropGalleryScreen.tsx +++ b/src/screens/BackdropGalleryScreen.tsx @@ -11,7 +11,7 @@ import { } from 'react-native'; import { useRoute, useNavigation } from '@react-navigation/native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { MaterialIcons } from '@expo/vector-icons'; import { TMDBService } from '../services/tmdbService'; import { useTheme } from '../contexts/ThemeContext'; @@ -51,7 +51,7 @@ const BackdropGalleryScreen: React.FC = () => { try { setLoading(true); const tmdbService = TMDBService.getInstance(); - + // Get language preference const language = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; @@ -100,7 +100,7 @@ const BackdropGalleryScreen: React.FC = () => { diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index a3318d3..918ae8a 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -16,7 +16,7 @@ import { import { InteractionManager } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { useTheme } from '../contexts/ThemeContext'; @@ -67,7 +67,7 @@ const CalendarScreen = () => { continueWatching, loadAllCollections } = useTraktContext(); - + logger.log(`[Calendar] Initial load - Library has ${libraryItems?.length || 0} items, loading: ${libraryLoading}`); const [refreshing, setRefreshing] = useState(false); const [uiReady, setUiReady] = useState(false); @@ -89,7 +89,7 @@ const CalendarScreen = () => { }); return () => task.cancel(); }, []); - + const handleSeriesPress = useCallback((seriesId: string, episode?: CalendarEpisode) => { navigation.navigate('Metadata', { id: seriesId, @@ -97,14 +97,14 @@ const CalendarScreen = () => { episodeId: episode ? `${episode.seriesId}:${episode.season}:${episode.episode}` : undefined }); }, [navigation]); - + const handleEpisodePress = useCallback((episode: CalendarEpisode) => { // For series without episode dates, just go to the series page if (!episode.releaseDate) { handleSeriesPress(episode.seriesId, episode); return; } - + // For episodes with dates, go to the stream screen const episodeId = `${episode.seriesId}:${episode.season}:${episode.episode}`; navigation.navigate('Streams', { @@ -113,23 +113,23 @@ const CalendarScreen = () => { episodeId }); }, [navigation, handleSeriesPress]); - + const renderEpisodeItem = ({ item }: { item: CalendarEpisode }) => { const hasReleaseDate = !!item.releaseDate; const releaseDate = hasReleaseDate ? parseISO(item.releaseDate) : null; const formattedDate = releaseDate ? format(releaseDate, 'MMM d, yyyy') : ''; const isFuture = releaseDate ? isAfter(releaseDate, new Date()) : false; - + // Use episode still image if available, fallback to series poster - const imageUrl = item.still_path ? - tmdbService.getImageUrl(item.still_path) : - (item.season_poster_path ? - tmdbService.getImageUrl(item.season_poster_path) : + const imageUrl = item.still_path ? + tmdbService.getImageUrl(item.still_path) : + (item.season_poster_path ? + tmdbService.getImageUrl(item.season_poster_path) : item.poster); - + return ( - handleEpisodePress(item)} activeOpacity={0.7} @@ -141,43 +141,43 @@ const CalendarScreen = () => { - + {item.seriesName} - + {hasReleaseDate ? ( <> S{item.season}:E{item.episode} - {item.title} - + {item.overview ? ( {item.overview} ) : null} - + - {formattedDate} - + {item.vote_average > 0 && ( - {item.vote_average.toFixed(1)} @@ -192,10 +192,10 @@ const CalendarScreen = () => { No scheduled episodes - Check back later @@ -206,18 +206,18 @@ const CalendarScreen = () => { ); }; - + const renderSectionHeader = ({ section }: { section: CalendarSection }) => ( - {section.title} ); - + // Process all episodes once data is loaded - using memory-efficient approach const allEpisodes = React.useMemo(() => { if (!uiReady) return [] as CalendarEpisode[]; @@ -229,7 +229,7 @@ const CalendarScreen = () => { // Global cap to keep memory bounded return memoryManager.limitArraySize(episodes, 1500); }, [calendarData, uiReady]); - + // Log when rendering with relevant state info logger.log(`[Calendar] Rendering: loading=${loading}, calendarData sections=${calendarData.length}, allEpisodes=${allEpisodes.length}`); @@ -246,19 +246,19 @@ const CalendarScreen = () => { } else { logger.log(`[Calendar] No calendarData sections available`); } - + // Handle date selection from calendar const handleDateSelect = useCallback((date: Date) => { logger.log(`[Calendar] Date selected: ${format(date, 'yyyy-MM-dd')}`); setSelectedDate(date); - + // Filter episodes for the selected date const filtered = allEpisodes.filter(episode => { if (!episode.releaseDate) return false; const episodeDate = parseISO(episode.releaseDate); return isSameDay(episodeDate, date); }); - + logger.log(`[Calendar] Filtered episodes for selected date: ${filtered.length}`); setFilteredEpisodes(filtered); }, [allEpisodes]); @@ -269,7 +269,7 @@ const CalendarScreen = () => { setSelectedDate(null); setFilteredEpisodes([]); }, []); - + if ((loading || !uiReady) && !refreshing) { return ( @@ -281,13 +281,13 @@ const CalendarScreen = () => { ); } - + return ( - + - navigation.goBack()} > @@ -296,7 +296,7 @@ const CalendarScreen = () => { Calendar - + {selectedDate && filteredEpisodes.length > 0 && ( @@ -307,12 +307,12 @@ const CalendarScreen = () => { )} - - - + {selectedDate && filteredEpisodes.length > 0 ? ( { No episodes for {format(selectedDate, 'MMMM d, yyyy')} - diff --git a/src/screens/CastMoviesScreen.tsx b/src/screens/CastMoviesScreen.tsx index 87e397c..84e7afc 100644 --- a/src/screens/CastMoviesScreen.tsx +++ b/src/screens/CastMoviesScreen.tsx @@ -10,7 +10,7 @@ import { FlatList, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import Animated, { FadeIn, FadeOut, @@ -89,27 +89,27 @@ const CastMoviesScreen: React.FC = () => { const fetchCastCredits = async () => { if (!castMember) return; - + setLoading(true); try { const credits = await tmdbService.getPersonCombinedCredits(castMember.id); - + if (credits && credits.cast) { const currentDate = new Date(); - + // Combine cast roles with enhanced data, excluding talk shows and variety shows const allCredits = credits.cast .filter((item: any) => { // Filter out talk shows, variety shows, and ensure we have required data const hasPoster = item.poster_path; const hasReleaseDate = item.release_date || item.first_air_date; - + if (!hasPoster || !hasReleaseDate) return false; - + // Enhanced talk show filtering const title = (item.title || item.name || '').toLowerCase(); const overview = (item.overview || '').toLowerCase(); - + // List of common talk show and variety show keywords const talkShowKeywords = [ 'talk', 'show', 'late night', 'tonight show', 'jimmy fallon', 'snl', 'saturday night live', @@ -120,18 +120,18 @@ const CastMoviesScreen: React.FC = () => { 'red carpet', 'premiere', 'after party', 'behind the scenes', 'making of', 'documentary', 'special', 'concert', 'live performance', 'mtv', 'vh1', 'bet', 'comedy', 'roast' ]; - + // Check if any keyword matches - const isTalkShow = talkShowKeywords.some(keyword => + const isTalkShow = talkShowKeywords.some(keyword => title.includes(keyword) || overview.includes(keyword) ); - + return !isTalkShow; }) .map((item: any) => { const releaseDate = new Date(item.release_date || item.first_air_date); const isUpcoming = releaseDate > currentDate; - + return { id: item.id, title: item.title || item.name, @@ -144,7 +144,7 @@ const CastMoviesScreen: React.FC = () => { isUpcoming, }; }); - + setMovies(allCredits); } } catch (error) { @@ -223,41 +223,41 @@ const CastMoviesScreen: React.FC = () => { isUpcoming: movie.isUpcoming }); } - + try { if (__DEV__) console.log('Attempting to get Stremio ID for:', movie.media_type, movie.id.toString()); - + // Get Stremio ID using catalogService const stremioId = await catalogService.getStremioId(movie.media_type, movie.id.toString()); - + if (__DEV__) console.log('Stremio ID result:', stremioId); - + if (stremioId) { if (__DEV__) console.log('Successfully found Stremio ID, navigating to Metadata with:', { id: stremioId, type: movie.media_type }); - + // Convert TMDB media type to Stremio media type const stremioType = movie.media_type === 'tv' ? 'series' : movie.media_type; - + if (__DEV__) console.log('Navigating with Stremio type conversion:', { originalType: movie.media_type, stremioType: stremioType, id: stremioId }); - + navigation.dispatch( - StackActions.push('Metadata', { - id: stremioId, - type: stremioType + StackActions.push('Metadata', { + id: stremioId, + type: stremioType }) ); } else { if (__DEV__) console.warn('Stremio ID is null/undefined for movie:', movie.title); throw new Error('Could not find Stremio ID'); } - } catch (error: any) { + } catch (error: any) { if (__DEV__) { console.error('=== Error in handleMoviePress ==='); console.error('Movie:', movie.title); @@ -267,7 +267,7 @@ const CastMoviesScreen: React.FC = () => { } setAlertTitle('Error'); setAlertMessage(`Unable to load "${movie.title}". Please try again later.`); - setAlertActions([{ label: 'OK', onPress: () => {} }]); + setAlertActions([{ label: 'OK', onPress: () => { } }]); setAlertVisible(true); } }; @@ -278,7 +278,7 @@ const CastMoviesScreen: React.FC = () => { const renderFilterButton = (filter: 'all' | 'movies' | 'tv', label: string, count: number) => { const isSelected = selectedFilter === filter; - + return ( { paddingHorizontal: 18, paddingVertical: 10, borderRadius: 25, - backgroundColor: isSelected - ? currentTheme.colors.primary + backgroundColor: isSelected + ? currentTheme.colors.primary : 'rgba(255, 255, 255, 0.08)', marginRight: 12, borderWidth: isSelected ? 0 : 1, @@ -311,7 +311,7 @@ const CastMoviesScreen: React.FC = () => { const renderSortButton = (sort: 'popularity' | 'latest' | 'upcoming', label: string, icon: string) => { const isSelected = sortBy === sort; - + return ( { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, - backgroundColor: isSelected - ? 'rgba(255, 255, 255, 0.15)' + backgroundColor: isSelected + ? 'rgba(255, 255, 255, 0.15)' : 'transparent', marginRight: 12, flexDirection: 'row', @@ -329,10 +329,10 @@ const CastMoviesScreen: React.FC = () => { onPress={() => setSortBy(sort)} activeOpacity={0.7} > - { uri: `https://image.tmdb.org/t/p/w500${item.poster_path}`, }} style={{ width: '100%', height: '100%' }} - resizeMode={FastImage.resizeMode.cover} + resizeMode={FIResizeMode.cover} /> ) : ( { )} - + {/* Upcoming indicator */} {item.isUpcoming && ( { }} /> - + { }} numberOfLines={2}> {`${item.title}`} - + {item.character && ( { {`as ${item.character}`} )} - + { {`${new Date(item.release_date).getFullYear()}`} )} - + {item.isUpcoming && ( { [1, 0.9], Extrapolate.CLAMP ); - + return { opacity, }; @@ -547,7 +547,7 @@ const CastMoviesScreen: React.FC = () => { return ( {/* Minimal Header */} - { headerAnimatedStyle ]} > - @@ -579,7 +579,7 @@ const CastMoviesScreen: React.FC = () => { > - + { uri: `https://image.tmdb.org/t/p/w185${castMember.profile_path}`, }} style={{ width: '100%', height: '100%' }} - resizeMode={FastImage.resizeMode.cover} + resizeMode={FIResizeMode.cover} /> ) : ( { )} - + { }}> Filter - @@ -677,8 +677,8 @@ const CastMoviesScreen: React.FC = () => { }}> Sort By - @@ -763,7 +763,7 @@ const CastMoviesScreen: React.FC = () => { ) : null } ListEmptyComponent={ - { lineHeight: 20, fontWeight: '500', }}> - {sortBy === 'upcoming' + {sortBy === 'upcoming' ? 'No upcoming releases available for this actor' - : selectedFilter === 'all' + : selectedFilter === 'all' ? 'No content available for this actor' : selectedFilter === 'movies' ? 'No movies available for this actor' diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 5932980..2b56b71 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -19,7 +19,7 @@ import { StackNavigationProp } from '@react-navigation/stack'; import { RootStackParamList } from '../navigation/AppNavigator'; import { Meta, stremioService, CatalogExtra } from '../services/stremioService'; import { useTheme } from '../contexts/ThemeContext'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { BlurView } from 'expo-blur'; import { MaterialIcons } from '@expo/vector-icons'; @@ -776,7 +776,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { {type === 'movie' && nowPlayingMovies.has(item.id) && ( diff --git a/src/screens/ContributorsScreen.tsx b/src/screens/ContributorsScreen.tsx index 0e77958..59055f7 100644 --- a/src/screens/ContributorsScreen.tsx +++ b/src/screens/ContributorsScreen.tsx @@ -18,7 +18,7 @@ import { import { mmkvStorage } from '../services/mmkvStorage'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { Feather, FontAwesome5 } from '@expo/vector-icons'; import { useTheme } from '../contexts/ThemeContext'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -106,7 +106,7 @@ const ContributorCard: React.FC = ({ contributor, currentT styles.avatar, isTablet && styles.tabletAvatar ]} - resizeMode={FastImage.resizeMode.cover} + resizeMode={FIResizeMode.cover} /> = ({ mention, curren styles.avatar, isTablet && styles.tabletAvatar ]} - resizeMode={FastImage.resizeMode.cover} + resizeMode={FIResizeMode.cover} /> )} diff --git a/src/screens/DownloadsScreen.tsx b/src/screens/DownloadsScreen.tsx index 1042b94..8a27715 100644 --- a/src/screens/DownloadsScreen.tsx +++ b/src/screens/DownloadsScreen.tsx @@ -27,7 +27,7 @@ import Animated, { import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { useDownloads } from '../contexts/DownloadsContext'; import { useSettings } from '../hooks/useSettings'; import { VideoPlayerService } from '../services/videoPlayerService'; @@ -216,7 +216,7 @@ const DownloadItemComponent: React.FC<{ {/* Status indicator overlay */} diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 026ae69..5123bff 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -29,7 +29,7 @@ import { stremioService } from '../services/stremioService'; import { Stream } from '../types/metadata'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { priority as FIPriority, cacheControl as FICacheControl, preload as FIPreload, clearMemoryCache as FIClearMemoryCache } from '../utils/FastImageCompat'; import Animated, { FadeIn, Layout, useSharedValue, useAnimatedScrollHandler } from 'react-native-reanimated'; import { PanGestureHandler } from 'react-native-gesture-handler'; import { @@ -483,7 +483,7 @@ const HomeScreen = () => { // Only clear memory cache when app goes to background // This frees memory while keeping disk cache intact for fast restoration try { - FastImage.clearMemoryCache(); + FIClearMemoryCache(); if (__DEV__) console.log('[HomeScreen] Cleared memory cache on background'); } catch (error) { if (__DEV__) console.warn('[HomeScreen] Failed to clear memory cache:', error); @@ -534,12 +534,12 @@ const HomeScreen = () => { // FastImage preload with proper source format const sources = posterImages.map(uri => ({ uri, - priority: FastImage.priority.normal, - cache: FastImage.cacheControl.immutable + priority: FIPriority.normal, + cache: FICacheControl.immutable })); // Preload all images at once - FastImage handles batching internally - FastImage.preload(sources); + FIPreload(sources); } catch (error) { // Silently handle preload errors if (__DEV__) console.warn('Image preload error:', error); diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index ef90ee7..01dc8b8 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -24,7 +24,7 @@ import { FlashList } from '@shopify/flash-list'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons, Feather } from '@expo/vector-icons'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { LinearGradient } from 'expo-linear-gradient'; import { catalogService } from '../services/catalogService'; @@ -133,7 +133,7 @@ const TraktItem = React.memo(({ ) : ( @@ -409,7 +409,7 @@ const LibraryScreen = () => { {item.watched && ( diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 28c90c3..f2b87c4 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -52,7 +52,7 @@ import { RootStackParamList } from '../navigation/AppNavigator'; import { useSettings } from '../hooks/useSettings'; import { MetadataLoadingScreen, MetadataLoadingScreenRef } from '../components/loading/MetadataLoadingScreen'; import { useTrailer } from '../contexts/TrailerContext'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; // Import our optimized components and hooks import HeroSection from '../components/metadata/HeroSection'; @@ -1050,7 +1050,7 @@ const MetadataScreen: React.FC = () => { height: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 22 } ]} - resizeMode={FastImage.resizeMode.contain} + resizeMode={FIResizeMode.contain} /> ) : ( { height: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 22 } ]} - resizeMode={FastImage.resizeMode.contain} + resizeMode={FIResizeMode.contain} /> ))} diff --git a/src/screens/PluginsScreen.tsx b/src/screens/PluginsScreen.tsx index f0bf46d..6e62678 100644 --- a/src/screens/PluginsScreen.tsx +++ b/src/screens/PluginsScreen.tsx @@ -17,7 +17,7 @@ import { Image, } from 'react-native'; import CustomAlert from '../components/CustomAlert'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useNavigation } from '@react-navigation/native'; @@ -1644,7 +1644,7 @@ const PluginsScreen: React.FC = () => { ) ) : ( diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index d4db046..225643f 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -22,7 +22,7 @@ import { useNavigation, useRoute, useFocusEffect } from '@react-navigation/nativ import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons, Feather } from '@expo/vector-icons'; import { catalogService, StreamingContent, GroupedSearchResults, AddonSearchResults } from '../services/catalogService'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import debounce from 'lodash/debounce'; import { DropUpMenu } from '../components/home/DropUpMenu'; import { DeviceEventEmitter, Share } from 'react-native'; @@ -685,7 +685,7 @@ const SearchScreen = () => { {/* Bookmark and watched icons top right, bookmark to the left of watched */} {inLibrary && ( diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 3355de3..8984a24 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -17,7 +17,7 @@ import { import { mmkvStorage } from '../services/mmkvStorage'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import LottieView from 'lottie-react-native'; import { Feather } from '@expo/vector-icons'; import { Picker } from '@react-native-picker/picker'; @@ -966,7 +966,7 @@ const SettingsScreen: React.FC = () => { @@ -980,7 +980,7 @@ const SettingsScreen: React.FC = () => { Discord @@ -997,7 +997,7 @@ const SettingsScreen: React.FC = () => { Reddit @@ -1022,7 +1022,7 @@ const SettingsScreen: React.FC = () => { @@ -1101,7 +1101,7 @@ const SettingsScreen: React.FC = () => { @@ -1115,7 +1115,7 @@ const SettingsScreen: React.FC = () => { Discord @@ -1132,7 +1132,7 @@ const SettingsScreen: React.FC = () => { Reddit @@ -1157,7 +1157,7 @@ const SettingsScreen: React.FC = () => { diff --git a/src/screens/ShowRatingsScreen.tsx b/src/screens/ShowRatingsScreen.tsx index 07f82a7..7b2093f 100644 --- a/src/screens/ShowRatingsScreen.tsx +++ b/src/screens/ShowRatingsScreen.tsx @@ -10,7 +10,7 @@ import { Platform, StatusBar, } from 'react-native'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { BlurView } from 'expo-blur'; import { useTheme } from '../contexts/ThemeContext'; @@ -118,8 +118,8 @@ const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, getIMDbRating return ( @@ -149,7 +149,7 @@ const RatingSourceToggle = memo(({ ratingSource, setRatingSource, theme }: { ]} onPress={() => setRatingSource(source as RatingSource)} > - { @@ -192,11 +192,10 @@ const ShowInfo = memo(({ show, theme }: { show: Show | null, theme: any }) => { {show?.first_air_date - ? `${new Date(show.first_air_date).getFullYear()} - ${ - show.last_air_date - ? new Date(show.last_air_date).getFullYear() - : "Present" - }` + ? `${new Date(show.first_air_date).getFullYear()} - ${show.last_air_date + ? new Date(show.last_air_date).getFullYear() + : "Present" + }` : ""} @@ -227,13 +226,13 @@ const ShowRatingsScreen = ({ route }: Props) => { const [ratingSource, setRatingSource] = useState('imdb'); const [visibleSeasonRange, setVisibleSeasonRange] = useState({ start: 0, end: 8 }); const [loadingProgress, setLoadingProgress] = useState(0); - const ratingsCache = useRef<{[key: string]: number | null}>({}); + const ratingsCache = useRef<{ [key: string]: number | null }>({}); const fetchTVMazeData = async (imdbId: string) => { try { const lookupResponse = await axios.get(`https://api.tvmaze.com/lookup/shows?imdb=${imdbId}`); const tvmazeId = lookupResponse.data?.id; - + if (tvmazeId) { const showResponse = await axios.get(`https://api.tvmaze.com/shows/${tvmazeId}?embed=episodes`); if (showResponse.data?._embedded?.episodes) { @@ -252,8 +251,8 @@ const ShowRatingsScreen = ({ route }: Props) => { try { const tmdb = TMDBService.getInstance(); const seasonsToLoad = show.seasons - .filter(season => - season.season_number > 0 && + .filter(season => + season.season_number > 0 && !loadedSeasons.includes(season.season_number) && season.season_number > visibleSeasonRange.start && season.season_number <= visibleSeasonRange.end @@ -262,7 +261,7 @@ const ShowRatingsScreen = ({ route }: Props) => { // Load seasons in parallel in larger batches const batchSize = 4; // Load 4 seasons at a time const batches = []; - + for (let i = 0; i < seasonsToLoad.length; i += batchSize) { const batch = seasonsToLoad.slice(i, i + batchSize); batches.push(batch); @@ -273,7 +272,7 @@ const ShowRatingsScreen = ({ route }: Props) => { for (const batch of batches) { const batchResults = await Promise.all( - batch.map(season => + batch.map(season => tmdb.getSeasonDetails(showId, season.season_number, show.name) ) ); @@ -281,7 +280,7 @@ const ShowRatingsScreen = ({ route }: Props) => { const validResults = batchResults.filter((s): s is TMDBSeason => s !== null); setSeasons(prev => [...prev, ...validResults]); setLoadedSeasons(prev => [...prev, ...batch.map(s => s.season_number)]); - + loadedCount += batch.length; setLoadingProgress((loadedCount / totalToLoad) * 100); } @@ -296,7 +295,7 @@ const ShowRatingsScreen = ({ route }: Props) => { const onScroll = useCallback((event: any) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const isCloseToRight = (contentOffset.x + layoutMeasurement.width) >= (contentSize.width * 0.8); - + if (isCloseToRight && show && !loadingSeasons) { const maxSeasons = Math.max(...show.seasons.map(s => s.season_number)); if (visibleSeasonRange.end < maxSeasons) { @@ -312,26 +311,26 @@ const ShowRatingsScreen = ({ route }: Props) => { const fetchShowData = async () => { try { const tmdb = TMDBService.getInstance(); - + // Log the showId being used logger.log(`[ShowRatingsScreen] Fetching show details for ID: ${showId}`); - + const showData = await tmdb.getTVShowDetails(showId); if (showData) { setShow(showData); - + // Fetch IMDb ratings for all seasons const imdbRatingsData = await tmdb.getIMDbRatings(showId); if (imdbRatingsData) { setImdbRatings(imdbRatingsData); } - + // Get external IDs to fetch TVMaze data const externalIds = await tmdb.getShowExternalIds(showId); if (externalIds?.imdb_id) { fetchTVMazeData(externalIds.imdb_id); } - + // Set initial season range const initialEnd = Math.min(8, Math.max(...showData.seasons.map(s => s.season_number))); setVisibleSeasonRange({ start: 0, end: initialEnd }); @@ -361,16 +360,16 @@ const ShowRatingsScreen = ({ route }: Props) => { // Flatten all episodes from all seasons and find the matching one for (const season of imdbRatings) { if (!season.episodes) continue; - + const episode = season.episodes.find( ep => ep.season_number === seasonNumber && ep.episode_number === episodeNumber ); - + if (episode) { return episode.vote_average || null; } } - + return null; }, [imdbRatings]); @@ -420,32 +419,32 @@ const ShowRatingsScreen = ({ route }: Props) => { Loading content... }> - - - - - - @@ -470,7 +469,7 @@ const ShowRatingsScreen = ({ route }: Props) => { - @@ -491,8 +490,8 @@ const ShowRatingsScreen = ({ route }: Props) => { {/* Scrollable Seasons */} - { {/* Seasons Header */} {seasons.map((season) => ( - @@ -528,12 +527,12 @@ const ShowRatingsScreen = ({ route }: Props) => { {Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => ( {seasons.map((season) => ( - - {season.episodes[episodeIndex] && + {season.episodes[episodeIndex] && { {logo && ( )} {!logo && ( diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index 74acf7c..2ef37a4 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -15,7 +15,7 @@ import { import { useNavigation } from '@react-navigation/native'; import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat'; import { traktService, TraktUser } from '../services/traktService'; import { useSettings } from '../hooks/useSettings'; import { logger } from '../utils/logger'; @@ -53,7 +53,7 @@ const TraktSettingsScreen: React.FC = () => { const [isAuthenticated, setIsAuthenticated] = useState(false); const [userProfile, setUserProfile] = useState(null); const { currentTheme } = useTheme(); - + const { settings: autosyncSettings, isSyncing, @@ -101,7 +101,7 @@ const TraktSettingsScreen: React.FC = () => { try { const authenticated = await traktService.isAuthenticated(); setIsAuthenticated(authenticated); - + if (authenticated) { const profile = await traktService.getUserProfile(); setUserProfile(profile); @@ -151,8 +151,8 @@ const TraktSettingsScreen: React.FC = () => { 'Successfully Connected', 'Your Trakt account has been connected successfully.', [ - { - label: 'OK', + { + label: 'OK', onPress: () => navigation.goBack(), } ] @@ -190,9 +190,9 @@ const TraktSettingsScreen: React.FC = () => { 'Sign Out', 'Are you sure you want to sign out of your Trakt account?', [ - { label: 'Cancel', onPress: () => {} }, - { - label: 'Sign Out', + { label: 'Cancel', onPress: () => { } }, + { + label: 'Sign Out', onPress: async () => { setIsLoading(true); try { @@ -224,26 +224,26 @@ const TraktSettingsScreen: React.FC = () => { onPress={() => navigation.goBack()} style={styles.backButton} > - Settings - + {/* Empty for now, but ready for future actions */} - + Trakt Settings - @@ -259,10 +259,10 @@ const TraktSettingsScreen: React.FC = () => { {userProfile.avatar ? ( - ) : ( @@ -315,7 +315,7 @@ const TraktSettingsScreen: React.FC = () => { ) : ( - { )} - + {effectiveEpisodeVote.toFixed(1)} @@ -94,7 +94,7 @@ const EpisodeHero = memo( {effectiveEpisodeVote.toFixed(1)} diff --git a/src/screens/streams/components/MovieHero.tsx b/src/screens/streams/components/MovieHero.tsx index d0e6027..69cd43e 100644 --- a/src/screens/streams/components/MovieHero.tsx +++ b/src/screens/streams/components/MovieHero.tsx @@ -1,6 +1,6 @@ import React, { memo } from 'react'; import { View, StyleSheet, Platform, Dimensions } from 'react-native'; -import FastImage from '@d11/react-native-fast-image'; +import FastImage, { resizeMode as FIResizeMode } from '../../../utils/FastImageCompat'; import AnimatedText from '../../../components/AnimatedText'; @@ -30,7 +30,7 @@ const MovieHero = memo( setMovieLogoError(true)} /> ) : ( diff --git a/src/services/mmkvStorage.ts b/src/services/mmkvStorage.ts index daab954..81f3b77 100644 --- a/src/services/mmkvStorage.ts +++ b/src/services/mmkvStorage.ts @@ -1,15 +1,102 @@ -import { createMMKV } from 'react-native-mmkv'; +import { Platform } from 'react-native'; import { logger } from '../utils/logger'; +// Platform-specific storage implementation +let createMMKV: any = null; +if (Platform.OS !== 'web') { + try { + createMMKV = require('react-native-mmkv').createMMKV; + } catch (e) { + logger.warn('[MMKVStorage] react-native-mmkv not available, using fallback'); + } +} + +// Web fallback storage interface +class WebStorage { + getString(key: string): string | undefined { + try { + const value = localStorage.getItem(key); + return value ?? undefined; + } catch { + return undefined; + } + } + + set(key: string, value: string | number | boolean): void { + try { + localStorage.setItem(key, String(value)); + } catch (e) { + logger.error('[WebStorage] Error setting item:', e); + } + } + + getNumber(key: string): number | undefined { + try { + const value = localStorage.getItem(key); + return value ? Number(value) : undefined; + } catch { + return undefined; + } + } + + getBoolean(key: string): boolean | undefined { + try { + const value = localStorage.getItem(key); + return value === 'true' ? true : value === 'false' ? false : undefined; + } catch { + return undefined; + } + } + + contains(key: string): boolean { + try { + return localStorage.getItem(key) !== null; + } catch { + return false; + } + } + + remove(key: string): void { + try { + localStorage.removeItem(key); + } catch (e) { + logger.error('[WebStorage] Error removing item:', e); + } + } + + clearAll(): void { + try { + localStorage.clear(); + } catch (e) { + logger.error('[WebStorage] Error clearing storage:', e); + } + } + + getAllKeys(): string[] { + try { + return Object.keys(localStorage); + } catch { + return []; + } + } +} + class MMKVStorage { private static instance: MMKVStorage; - private storage = createMMKV(); + private storage: any; // In-memory cache for frequently accessed data private cache = new Map(); private readonly CACHE_TTL = 30000; // 30 seconds private readonly MAX_CACHE_SIZE = 100; // Limit cache size to prevent memory issues - private constructor() {} + private constructor() { + // Use MMKV on native platforms, localStorage on web + if (createMMKV) { + this.storage = createMMKV(); + } else { + this.storage = new WebStorage(); + } + } public static getInstance(): MMKVStorage { if (!MMKVStorage.instance) { @@ -57,16 +144,16 @@ class MMKVStorage { if (cached !== null) { return cached; } - + // Read from storage const value = this.storage.getString(key); const result = value ?? null; - + // Cache the result if (result !== null) { this.setCached(key, result); } - + return result; } catch (error) { logger.error(`[MMKVStorage] Error getting item ${key}:`, error); diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index fa9ee95..b068239 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -3,7 +3,7 @@ import { Platform, AppState, AppStateStatus } from 'react-native'; import { mmkvStorage } from './mmkvStorage'; import { parseISO, differenceInHours, isToday, addDays, isAfter, startOfToday } from 'date-fns'; import { stremioService } from './stremioService'; -import { catalogService } from './catalogService'; +// catalogService is imported lazily to avoid circular dependency import { traktService } from './traktService'; import { tmdbService } from './tmdbService'; import { logger } from '../utils/logger'; @@ -64,7 +64,8 @@ class NotificationService { this.configureNotifications(); this.loadSettings(); this.loadScheduledNotifications(); - this.setupLibraryIntegration(); + // Defer library integration setup to avoid circular dependency + // It will be set up lazily when first needed this.setupBackgroundSync(); this.setupAppStateHandling(); } @@ -265,8 +266,15 @@ class NotificationService { } // Setup library integration - automatically sync notifications when library changes + // This is called lazily to avoid circular dependency issues private setupLibraryIntegration(): void { + // Skip if already set up + if (this.librarySubscription) return; + try { + // Lazy import to avoid circular dependency + const { catalogService } = require('./catalogService'); + // Subscribe to library updates from catalog service this.librarySubscription = catalogService.subscribeToLibraryUpdates(async (libraryItems) => { if (!this.settings.enabled) return; @@ -421,13 +429,17 @@ class NotificationService { // Perform comprehensive background sync including Trakt integration private async performBackgroundSync(): Promise { try { + // Ensure library integration is set up (lazy initialization) + this.setupLibraryIntegration(); + // Update last sync time at the start this.lastSyncTime = Date.now(); // Reduced logging verbosity // logger.log('[NotificationService] Starting comprehensive background sync'); - // Get library items + // Get library items - use lazy import to avoid circular dependency + const { catalogService } = require('./catalogService'); const libraryItems = await catalogService.getLibraryItems(); await this.syncNotificationsForLibrary(libraryItems); diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index 99ba703..c2dfc6c 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { Platform } from 'react-native'; import { mmkvStorage } from './mmkvStorage'; import { logger } from '../utils/logger'; @@ -165,6 +166,8 @@ export class TMDBService { } private async remoteSetCachedData(key: string, data: any): Promise { + // Skip remote cache writes on web to avoid CORS errors + if (Platform.OS === 'web') return; if (!USE_REMOTE_CACHE || !REMOTE_CACHE_URL) return; try { const url = `${REMOTE_CACHE_URL}/cache/${REMOTE_CACHE_NAMESPACE}/${encodeURIComponent(key)}`; @@ -260,15 +263,15 @@ export class TMDBService { if (data === null || data === undefined) { return; } - + try { if (!DISABLE_LOCAL_CACHE) { - const cacheEntry = { - data, - timestamp: Date.now() - }; - mmkvStorage.setString(key, JSON.stringify(cacheEntry)); - logger.log(`[TMDB Cache] 💾 STORED: ${key}`); + const cacheEntry = { + data, + timestamp: Date.now() + }; + mmkvStorage.setString(key, JSON.stringify(cacheEntry)); + logger.log(`[TMDB Cache] 💾 STORED: ${key}`); } else { logger.log(`[TMDB Cache] ⛔ LOCAL WRITE SKIPPED: ${key}`); } @@ -312,15 +315,15 @@ export class TMDBService { mmkvStorage.getItem(TMDB_API_KEY_STORAGE_KEY), mmkvStorage.getItem(USE_CUSTOM_TMDB_API_KEY) ]); - + this.useCustomKey = savedUseCustomKey === 'true'; - + if (this.useCustomKey && savedKey) { this.apiKey = savedKey; } else { this.apiKey = DEFAULT_API_KEY; } - + this.apiKeyLoaded = true; } catch (error) { this.apiKey = DEFAULT_API_KEY; @@ -333,7 +336,7 @@ export class TMDBService { if (!this.apiKeyLoaded) { await this.loadApiKey(); } - + return { 'Content-Type': 'application/json', }; @@ -344,7 +347,7 @@ export class TMDBService { if (!this.apiKeyLoaded) { await this.loadApiKey(); } - + return { api_key: this.apiKey, ...additionalParams @@ -360,7 +363,7 @@ export class TMDBService { */ async searchTVShow(query: string): Promise { const cacheKey = this.generateCacheKey('search_tv', { query }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -389,7 +392,7 @@ export class TMDBService { */ async getTVShowDetails(tmdbId: number, language: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`tv_${tmdbId}`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -419,7 +422,7 @@ export class TMDBService { episodeNumber: number ): Promise<{ imdb_id: string | null } | null> { const cacheKey = this.generateCacheKey(`tv_${tmdbId}_season_${seasonNumber}_episode_${episodeNumber}_external_ids`); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote<{ imdb_id: string | null }>(cacheKey); if (cached !== null) return cached; @@ -446,7 +449,7 @@ export class TMDBService { */ async getIMDbRating(showName: string, seasonNumber: number, episodeNumber: number): Promise { const cacheKey = this.generateRatingCacheKey(showName, seasonNumber, episodeNumber); - + // Check cache first if (TMDBService.ratingCache.has(cacheKey)) { return TMDBService.ratingCache.get(cacheKey) ?? null; @@ -462,7 +465,7 @@ export class TMDBService { Episode: episodeNumber } }); - + let rating: number | null = null; if (response.data && response.data.imdbRating && response.data.imdbRating !== 'N/A') { rating = parseFloat(response.data.imdbRating); @@ -484,14 +487,14 @@ export class TMDBService { */ async getIMDbRatings(tmdbId: number): Promise { const IMDB_RATINGS_API_BASE_URL = process.env.EXPO_PUBLIC_IMDB_RATINGS_API_BASE_URL; - + if (!IMDB_RATINGS_API_BASE_URL) { logger.error('[TMDB API] Missing EXPO_PUBLIC_IMDB_RATINGS_API_BASE_URL environment variable'); return null; } const cacheKey = this.generateCacheKey(`imdb_ratings_${tmdbId}`); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -505,13 +508,13 @@ export class TMDBService { 'Content-Type': 'application/json', }, }); - + const data = response.data; if (data && Array.isArray(data)) { this.setCachedData(cacheKey, data); return data; } - + return null; } catch (error) { logger.error('[TMDB API] Error fetching IMDb ratings:', error); @@ -525,7 +528,7 @@ export class TMDBService { */ async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string, language: string = 'en-US'): Promise { const cacheKey = this.generateCacheKey(`tv_${tmdbId}_season_${seasonNumber}`, { language, showName }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -556,7 +559,7 @@ export class TMDBService { language: string = 'en-US' ): Promise { const cacheKey = this.generateCacheKey(`tv_${tmdbId}_season_${seasonNumber}_episode_${episodeNumber}`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -589,7 +592,7 @@ export class TMDBService { try { // Extract the base IMDB ID (remove season/episode info if present) const imdbId = stremioId.split(':')[0]; - + // Use the existing findTMDBIdByIMDB function to get the TMDB ID const tmdbId = await this.findTMDBIdByIMDB(imdbId); return tmdbId; @@ -603,7 +606,7 @@ export class TMDBService { */ async findTMDBIdByIMDB(imdbId: string): Promise { const cacheKey = this.generateCacheKey('find_imdb', { imdbId }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -611,7 +614,7 @@ export class TMDBService { try { // Extract the IMDB ID without season/episode info const baseImdbId = imdbId.split(':')[0]; - + const response = await axios.get(`${BASE_URL}/find/${baseImdbId}`, { headers: await this.getHeaders(), params: await this.getParams({ @@ -619,23 +622,23 @@ export class TMDBService { language: 'en-US', }), }); - + let result: number | null = null; - + // Check TV results first if (response.data.tv_results && response.data.tv_results.length > 0) { result = response.data.tv_results[0].id; } - + // Check movie results as fallback if (!result && response.data.movie_results && response.data.movie_results.length > 0) { result = response.data.movie_results[0].id; } - + if (result !== null) { this.setCachedData(cacheKey, result); } - + return result; } catch (error) { return null; @@ -649,10 +652,10 @@ export class TMDBService { if (!path) { return null; } - + const baseImageUrl = 'https://image.tmdb.org/t/p/'; const fullUrl = `${baseImageUrl}${size}${path}`; - + return fullUrl; } @@ -666,7 +669,7 @@ export class TMDBService { if (!showDetails) return {}; const allEpisodes: { [seasonNumber: number]: TMDBEpisode[] } = {}; - + // Get episodes for each season (in parallel) const seasonPromises = showDetails.seasons .filter(season => season.season_number > 0) // Filter out specials (season 0) @@ -676,7 +679,7 @@ export class TMDBService { allEpisodes[season.season_number] = seasonDetails.episodes; } }); - + await Promise.all(seasonPromises); return allEpisodes; } catch (error) { @@ -692,7 +695,7 @@ export class TMDBService { if (episode.still_path) { return this.getImageUrl(episode.still_path, size); } - + // Try season poster as fallback if (show && show.seasons) { const season = show.seasons.find(s => s.season_number === episode.season_number); @@ -700,12 +703,12 @@ export class TMDBService { return this.getImageUrl(season.poster_path, size); } } - + // Use show poster as last resort if (show && show.poster_path) { return this.getImageUrl(show.poster_path, size); } - + return null; } @@ -714,7 +717,7 @@ export class TMDBService { */ formatAirDate(airDate: string | null): string { if (!airDate) return 'Unknown'; - + try { const date = new Date(airDate); return date.toLocaleDateString('en-US', { @@ -729,7 +732,7 @@ export class TMDBService { async getCredits(tmdbId: number, type: string) { const cacheKey = this.generateCacheKey(`${type}_${tmdbId}_credits`); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote<{ cast: any[]; crew: any[] }>(cacheKey); if (cached !== null) return cached; @@ -754,7 +757,7 @@ export class TMDBService { async getPersonDetails(personId: number) { const cacheKey = this.generateCacheKey(`person_${personId}`); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -779,7 +782,7 @@ export class TMDBService { */ async getPersonMovieCredits(personId: number) { const cacheKey = this.generateCacheKey(`person_${personId}_movie_credits`); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -804,7 +807,7 @@ export class TMDBService { */ async getPersonTvCredits(personId: number) { const cacheKey = this.generateCacheKey(`person_${personId}_tv_credits`); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -829,7 +832,7 @@ export class TMDBService { */ async getPersonCombinedCredits(personId: number) { const cacheKey = this.generateCacheKey(`person_${personId}_combined_credits`); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -854,7 +857,7 @@ export class TMDBService { */ async getShowExternalIds(tmdbId: number): Promise<{ imdb_id: string | null } | null> { const cacheKey = this.generateCacheKey(`tv_${tmdbId}_external_ids`); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote<{ imdb_id: string | null }>(cacheKey); if (cached !== null) return cached; @@ -879,9 +882,9 @@ export class TMDBService { if (!this.apiKey) { return []; } - + const cacheKey = this.generateCacheKey(`${type}_${tmdbId}_recommendations`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -901,7 +904,7 @@ export class TMDBService { async searchMulti(query: string): Promise { const cacheKey = this.generateCacheKey('search_multi', { query }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -929,7 +932,7 @@ export class TMDBService { */ async getMovieDetails(movieId: string, language: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`movie_${movieId}`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -955,7 +958,7 @@ export class TMDBService { */ async getCollectionDetails(collectionId: number, language: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`collection_${collectionId}`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -980,7 +983,7 @@ export class TMDBService { */ async getCollectionImages(collectionId: number, language: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`collection_${collectionId}_images`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1006,14 +1009,14 @@ export class TMDBService { */ async getMovieImagesFull(movieId: number | string, language: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`movie_${movieId}_images_full`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) { return cached; } - + try { const response = await axios.get(`${BASE_URL}/movie/${movieId}/images`, { headers: await this.getHeaders(), @@ -1024,7 +1027,7 @@ export class TMDBService { const data = response.data; - + this.setCachedData(cacheKey, data); return data; } catch (error) { @@ -1037,7 +1040,7 @@ export class TMDBService { */ async getMovieImages(movieId: number | string, preferredLanguage: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`movie_${movieId}_logo`, { preferredLanguage }); - + // Check cache const cached = this.getCachedData(cacheKey); if (cached !== null) return cached; @@ -1051,15 +1054,15 @@ export class TMDBService { }); const images = response.data; - + let result: string | null = null; - + if (images && images.logos && images.logos.length > 0) { // First prioritize preferred language SVG logos if not English if (preferredLanguage !== 'en') { - const preferredSvgLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.svg') && + const preferredSvgLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.svg') && logo.iso_639_1 === preferredLanguage ); if (preferredSvgLogo) { @@ -1068,19 +1071,19 @@ export class TMDBService { // Then preferred language PNG logos if (!result) { - const preferredPngLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.png') && + const preferredPngLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.png') && logo.iso_639_1 === preferredLanguage ); if (preferredPngLogo) { result = this.getImageUrl(preferredPngLogo.file_path); } } - + // Then any preferred language logo if (!result) { - const preferredLogo = images.logos.find((logo: any) => + const preferredLogo = images.logos.find((logo: any) => logo.iso_639_1 === preferredLanguage ); if (preferredLogo) { @@ -1091,9 +1094,9 @@ export class TMDBService { // Then prioritize English SVG logos if (!result) { - const enSvgLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.svg') && + const enSvgLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.svg') && logo.iso_639_1 === 'en' ); if (enSvgLogo) { @@ -1103,19 +1106,19 @@ export class TMDBService { // Then English PNG logos if (!result) { - const enPngLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.png') && + const enPngLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.png') && logo.iso_639_1 === 'en' ); if (enPngLogo) { result = this.getImageUrl(enPngLogo.file_path); } } - + // Then any English logo if (!result) { - const enLogo = images.logos.find((logo: any) => + const enLogo = images.logos.find((logo: any) => logo.iso_639_1 === 'en' ); if (enLogo) { @@ -1125,7 +1128,7 @@ export class TMDBService { // Fallback to any SVG logo if (!result) { - const svgLogo = images.logos.find((logo: any) => + const svgLogo = images.logos.find((logo: any) => logo.file_path && logo.file_path.endsWith('.svg') ); if (svgLogo) { @@ -1135,14 +1138,14 @@ export class TMDBService { // Then any PNG logo if (!result) { - const pngLogo = images.logos.find((logo: any) => + const pngLogo = images.logos.find((logo: any) => logo.file_path && logo.file_path.endsWith('.png') ); if (pngLogo) { result = this.getImageUrl(pngLogo.file_path); } } - + // Last resort: any logo if (!result) { result = this.getImageUrl(images.logos[0].file_path); @@ -1161,7 +1164,7 @@ export class TMDBService { */ async getTvShowImagesFull(showId: number | string, language: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`tv_${showId}_images_full`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1187,7 +1190,7 @@ export class TMDBService { */ async getTvShowImages(showId: number | string, preferredLanguage: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`tv_${showId}_logo`, { preferredLanguage }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1201,15 +1204,15 @@ export class TMDBService { }); const images = response.data; - + let result: string | null = null; - + if (images && images.logos && images.logos.length > 0) { // First prioritize preferred language SVG logos if not English if (preferredLanguage !== 'en') { - const preferredSvgLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.svg') && + const preferredSvgLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.svg') && logo.iso_639_1 === preferredLanguage ); if (preferredSvgLogo) { @@ -1218,19 +1221,19 @@ export class TMDBService { // Then preferred language PNG logos if (!result) { - const preferredPngLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.png') && + const preferredPngLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.png') && logo.iso_639_1 === preferredLanguage ); if (preferredPngLogo) { result = this.getImageUrl(preferredPngLogo.file_path); } } - + // Then any preferred language logo if (!result) { - const preferredLogo = images.logos.find((logo: any) => + const preferredLogo = images.logos.find((logo: any) => logo.iso_639_1 === preferredLanguage ); if (preferredLogo) { @@ -1241,9 +1244,9 @@ export class TMDBService { // First prioritize English SVG logos if (!result) { - const enSvgLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.svg') && + const enSvgLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.svg') && logo.iso_639_1 === 'en' ); if (enSvgLogo) { @@ -1253,19 +1256,19 @@ export class TMDBService { // Then English PNG logos if (!result) { - const enPngLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.png') && + const enPngLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.png') && logo.iso_639_1 === 'en' ); if (enPngLogo) { result = this.getImageUrl(enPngLogo.file_path); } } - + // Then any English logo if (!result) { - const enLogo = images.logos.find((logo: any) => + const enLogo = images.logos.find((logo: any) => logo.iso_639_1 === 'en' ); if (enLogo) { @@ -1275,7 +1278,7 @@ export class TMDBService { // Fallback to any SVG logo if (!result) { - const svgLogo = images.logos.find((logo: any) => + const svgLogo = images.logos.find((logo: any) => logo.file_path && logo.file_path.endsWith('.svg') ); if (svgLogo) { @@ -1285,14 +1288,14 @@ export class TMDBService { // Then any PNG logo if (!result) { - const pngLogo = images.logos.find((logo: any) => + const pngLogo = images.logos.find((logo: any) => logo.file_path && logo.file_path.endsWith('.png') ); if (pngLogo) { result = this.getImageUrl(pngLogo.file_path); } } - + // Last resort: any logo if (!result) { result = this.getImageUrl(images.logos[0].file_path); @@ -1311,14 +1314,14 @@ export class TMDBService { */ async getContentLogo(type: 'movie' | 'tv', id: number | string, preferredLanguage: string = 'en'): Promise { try { - const result = type === 'movie' + const result = type === 'movie' ? await this.getMovieImages(id, preferredLanguage) : await this.getTvShowImages(id, preferredLanguage); - + if (result) { } else { } - + return result; } catch (error) { return null; @@ -1330,14 +1333,14 @@ export class TMDBService { */ async getCertification(type: string, id: number): Promise { const cacheKey = this.generateCacheKey(`${type}_${id}_certification`); - + // Check cache const cached = this.getCachedData(cacheKey); if (cached !== null) return cached; try { let result: string | null = null; - + if (type === 'movie') { const response = await axios.get(`${BASE_URL}/movie/${id}/release_dates`, { headers: await this.getHeaders(), @@ -1390,7 +1393,7 @@ export class TMDBService { } } } - + this.setCachedData(cacheKey, result); return result; } catch (error) { @@ -1405,7 +1408,7 @@ export class TMDBService { */ async getTrending(type: 'movie' | 'tv', timeWindow: 'day' | 'week'): Promise { const cacheKey = this.generateCacheKey(`trending_${type}_${timeWindow}`); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1454,7 +1457,7 @@ export class TMDBService { */ async getPopular(type: 'movie' | 'tv', page: number = 1): Promise { const cacheKey = this.generateCacheKey(`popular_${type}`, { page }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1504,7 +1507,7 @@ export class TMDBService { */ async getUpcoming(type: 'movie' | 'tv', page: number = 1): Promise { const cacheKey = this.generateCacheKey(`upcoming_${type}`, { page }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1512,7 +1515,7 @@ export class TMDBService { try { // For movies use upcoming, for TV use on_the_air const endpoint = type === 'movie' ? 'upcoming' : 'on_the_air'; - + const response = await axios.get(`${BASE_URL}/${type}/${endpoint}`, { headers: await this.getHeaders(), params: await this.getParams({ @@ -1557,7 +1560,7 @@ export class TMDBService { */ async getNowPlaying(page: number = 1, region: string = 'US'): Promise { const cacheKey = this.generateCacheKey('now_playing', { page, region }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1606,7 +1609,7 @@ export class TMDBService { */ async getMovieGenres(): Promise<{ id: number; name: string }[]> { const cacheKey = this.generateCacheKey('genres_movie'); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote<{ id: number; name: string }[]>(cacheKey); if (cached !== null) return cached; @@ -1631,7 +1634,7 @@ export class TMDBService { */ async getTvGenres(): Promise<{ id: number; name: string }[]> { const cacheKey = this.generateCacheKey('genres_tv'); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote<{ id: number; name: string }[]>(cacheKey); if (cached !== null) return cached; @@ -1659,23 +1662,23 @@ export class TMDBService { */ async discoverByGenre(type: 'movie' | 'tv', genreName: string, page: number = 1): Promise { const cacheKey = this.generateCacheKey(`discover_${type}`, { genreName, page }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; try { // First get the genre ID from the name - const genreList = type === 'movie' - ? await this.getMovieGenres() + const genreList = type === 'movie' + ? await this.getMovieGenres() : await this.getTvGenres(); - + const genre = genreList.find(g => g.name.toLowerCase() === genreName.toLowerCase()); - + if (!genre) { return []; } - + const response = await axios.get(`${BASE_URL}/discover/${type}`, { headers: await this.getHeaders(), params: await this.getParams({ diff --git a/src/utils/FastImageCompat.tsx b/src/utils/FastImageCompat.tsx new file mode 100644 index 0000000..1bb0a5f --- /dev/null +++ b/src/utils/FastImageCompat.tsx @@ -0,0 +1,167 @@ +/** + * FastImage compatibility wrapper + * Handles both Web and Native platforms in a single file to ensure consistent module resolution + */ +import React from 'react'; +import { Image as RNImage, Platform, ImageProps, ImageStyle, StyleProp } from 'react-native'; + +// Define types for FastImage properties +export interface FastImageSource { + uri?: string; + priority?: string; + cache?: string; + headers?: { [key: string]: string }; +} + +export interface FastImageProps { + source: FastImageSource | number; + style?: StyleProp; + resizeMode?: 'contain' | 'cover' | 'stretch' | 'center'; + onError?: (error?: any) => void; + onLoad?: () => void; + onLoadStart?: () => void; + onLoadEnd?: () => void; + [key: string]: any; +} + +let NativeFastImage: any = null; +const isWeb = Platform.OS === 'web'; + +if (!isWeb) { + try { + NativeFastImage = require('@d11/react-native-fast-image').default; + } catch (e) { + console.warn('FastImageCompat: Failed to load @d11/react-native-fast-image', e); + } +} + +// Define constants with fallbacks +export const priority = (NativeFastImage?.priority) || { + low: 'low', + normal: 'normal', + high: 'high', +}; + +export const cacheControl = (NativeFastImage?.cacheControl) || { + immutable: 'immutable', + web: 'web', + cacheOnly: 'cacheOnly', +}; + +export const resizeMode = (NativeFastImage?.resizeMode) || { + contain: 'contain', + cover: 'cover', + stretch: 'stretch', + center: 'center', +}; + +// Preload helper +export const preload = (sources: { uri: string }[]) => { + if (isWeb) { + sources.forEach(({ uri }) => { + if (typeof window !== 'undefined') { + const img = new window.Image(); + img.src = uri; + } + }); + } else if (NativeFastImage?.preload) { + NativeFastImage.preload(sources); + } +}; + +// Clear cache helpers +export const clearMemoryCache = () => { + if (!isWeb && NativeFastImage?.clearMemoryCache) { + NativeFastImage.clearMemoryCache(); + } +}; + +export const clearDiskCache = () => { + if (!isWeb && NativeFastImage?.clearDiskCache) { + NativeFastImage.clearDiskCache(); + } +}; + +// Web Image Component - a simple wrapper that uses a standard img tag +const WebImage = React.forwardRef(({ source, style, resizeMode: resizeModeProp, onError, onLoad, onLoadStart, onLoadEnd, ...rest }, ref) => { + // Handle source - can be an object with uri or a require'd number + let uri: string | undefined; + if (typeof source === 'object' && source !== null && 'uri' in source) { + uri = source.uri; + } + + // If no valid URI, render nothing + if (!uri) { + return null; + } + + // Convert React Native style to web-compatible style + const objectFitValue = resizeModeProp === 'contain' ? 'contain' : + resizeModeProp === 'cover' ? 'cover' : + resizeModeProp === 'stretch' ? 'fill' : + resizeModeProp === 'center' ? 'none' : 'cover'; + + // Flatten style if it's an array and merge with webStyle + const flattenedStyle = Array.isArray(style) + ? Object.assign({}, ...style.filter(Boolean)) + : (style || {}); + + // Clean up React Native specific style props that don't work on web + const { + resizeMode: _rm, // Remove resizeMode from styles + ...cleanedStyle + } = flattenedStyle as any; + + return ( + onError() : undefined} + onLoad={onLoad} + /> + ); +}); + +WebImage.displayName = 'WebImage'; + +// Component Implementation +const FastImageComponent = React.forwardRef((props, ref) => { + if (isWeb) { + return ; + } + + // On Native, use FastImage if available, otherwise fallback to RNImage + const Comp = NativeFastImage || RNImage; + return ; +}); + +FastImageComponent.displayName = 'FastImage'; + +// Attach static properties to the component +(FastImageComponent as any).priority = priority; +(FastImageComponent as any).cacheControl = cacheControl; +(FastImageComponent as any).resizeMode = resizeMode; +(FastImageComponent as any).preload = preload; +(FastImageComponent as any).clearMemoryCache = clearMemoryCache; +(FastImageComponent as any).clearDiskCache = clearDiskCache; + +// Define the type for the component with statics +type FastImageType = React.ForwardRefExoticComponent> & { + priority: typeof priority; + cacheControl: typeof cacheControl; + resizeMode: typeof resizeMode; + preload: typeof preload; + clearMemoryCache: typeof clearMemoryCache; + clearDiskCache: typeof clearDiskCache; +}; + +// Export the component with the correct type +export default FastImageComponent as unknown as FastImageType; + +// Also export named for flexibility +export const FastImage = FastImageComponent as unknown as FastImageType; diff --git a/src/utils/VideoCompat.tsx b/src/utils/VideoCompat.tsx new file mode 100644 index 0000000..c7dfad7 --- /dev/null +++ b/src/utils/VideoCompat.tsx @@ -0,0 +1,164 @@ +/** + * Video compatibility wrapper + * Handles both Web and Native platforms + */ +import React, { useEffect, useRef, useImperativeHandle, forwardRef } from 'react'; +import { Platform, View, StyleSheet, ImageProps, ViewStyle } from 'react-native'; +// Use require for the native module to prevent web bundlers from choking on it if it's not web-compatible +let VideoOriginal: any; +let VideoRefType: any = Object; + +if (Platform.OS !== 'web') { + try { + const VideoModule = require('react-native-video'); + VideoOriginal = VideoModule.default; + VideoRefType = VideoModule.VideoRef; + } catch (e) { + VideoOriginal = View; + } +} else { + VideoOriginal = View; +} + +// Define types locally or assume any to avoid import errors +export type VideoRef = any; +export type OnLoadData = any; +export type OnProgressData = any; + +const isWeb = Platform.OS === 'web'; + +// Web Video Implementation +const WebVideo = forwardRef(({ + source, + style, + resizeMode, + paused, + muted, + volume, + onLoad, + onProgress, + onEnd, + onError, + repeat, + controls, + ...props +}, ref) => { + const videoRef = useRef(null); + + useImperativeHandle(ref, () => ({ + seek: (time: number) => { + if (videoRef.current) { + videoRef.current.currentTime = time; + } + }, + presentFullscreenPlayer: () => { + if (videoRef.current?.requestFullscreen) { + videoRef.current.requestFullscreen(); + } else if ((videoRef.current as any)?.webkitEnterFullscreen) { + (videoRef.current as any).webkitEnterFullscreen(); + } + }, + dismissFullscreenPlayer: () => { + if (document.exitFullscreen) { + document.exitFullscreen(); + } + }, + })); + + useEffect(() => { + if (videoRef.current) { + if (paused) { + videoRef.current.pause(); + } else { + const playPromise = videoRef.current.play(); + if (playPromise !== undefined) { + playPromise.catch(error => { + // Auto-play was prevented + // console.log('Auto-play prevent', error); + }); + } + } + } + }, [paused]); + + useEffect(() => { + if (videoRef.current && volume !== undefined) { + videoRef.current.volume = volume; + } + }, [volume]); + + useEffect(() => { + if (videoRef.current && muted !== undefined) { + videoRef.current.muted = muted; + } + }, [muted]); + + const uri = source?.uri || ''; + + // Map resizeMode to object-fit + const objectFit = resizeMode === 'contain' ? 'contain' : 'cover'; + + return ( +