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 (
+