This commit is contained in:
tapframe 2025-12-28 16:17:49 +05:30
parent 97f558faf4
commit 4c50fd8d8d
55 changed files with 1720 additions and 1166 deletions

33
App.tsx
View file

@ -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);
// Only wrap with Sentry on native platforms
export default Platform.OS !== 'web' ? Sentry.wrap(App) : App;

View file

@ -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;

13
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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 <Animated.View style={[style, animatedStyle]} />;
}
return (
<Animated.View style={[style, animatedStyle]}>
<FastImage
source={source}
style={StyleSheet.absoluteFillObject}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
onLoad={onLoad}
/>
</Animated.View>

View file

@ -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 (
<TouchableOpacity
style={[
styles.streamCard,
isLoading && styles.streamCardLoading,
isDebrid && styles.streamCardHighlighted
]}
onPress={onPress}
onLongPress={handleLongPress}
disabled={isLoading}
activeOpacity={0.7}
>
{/* Scraper Logo */}
{showLogos && scraperLogo && (
<View style={styles.scraperLogoContainer}>
{scraperLogo.toLowerCase().endsWith('.svg') || scraperLogo.toLowerCase().includes('.svg?') ? (
<Image
source={{ uri: scraperLogo }}
style={styles.scraperLogo}
resizeMode="contain"
/>
) : (
<FastImage
source={{ uri: scraperLogo }}
style={styles.scraperLogo}
resizeMode={FastImage.resizeMode.contain}
/>
)}
</View>
)}
<View style={styles.streamDetails}>
<View style={styles.streamNameRow}>
<View style={styles.streamTitleContainer}>
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
{streamInfo.displayName}
</Text>
{streamInfo.subTitle && (
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
{streamInfo.subTitle}
</Text>
)}
</View>
{/* Show loading indicator if stream is loading */}
{isLoading && (
<View style={styles.loadingIndicator}>
<ActivityIndicator size="small" color={theme.colors.primary} />
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
{statusMessage || "Loading..."}
</Text>
</View>
)}
</View>
<View style={styles.streamMetaRow}>
{streamInfo.isDolby && (
<QualityBadge type="VISION" />
)}
{streamInfo.size && (
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>💾 {streamInfo.size}</Text>
</View>
)}
{streamInfo.isDebrid && (
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
</View>
)}
</View>
</View>
{settings?.enableDownloads !== false && (
<TouchableOpacity
style={[styles.streamAction, { marginLeft: 8, backgroundColor: theme.colors.elevation2 }]}
onPress={handleDownload}
activeOpacity={0.7}
>
<MaterialIcons
name="download"
size={20}
color={theme.colors.highEmphasis}
style={[
styles.streamCard,
isLoading && styles.streamCardLoading,
isDebrid && styles.streamCardHighlighted
]}
onPress={onPress}
onLongPress={handleLongPress}
disabled={isLoading}
activeOpacity={0.7}
>
{/* Scraper Logo */}
{showLogos && scraperLogo && (
<View style={styles.scraperLogoContainer}>
{scraperLogo.toLowerCase().endsWith('.svg') || scraperLogo.toLowerCase().includes('.svg?') ? (
<Image
source={{ uri: scraperLogo }}
style={styles.scraperLogo}
resizeMode="contain"
/>
</TouchableOpacity>
)}
</TouchableOpacity>
) : (
<FastImage
source={{ uri: scraperLogo }}
style={styles.scraperLogo}
resizeMode={FIResizeMode.contain}
/>
)}
</View>
)}
<View style={styles.streamDetails}>
<View style={styles.streamNameRow}>
<View style={styles.streamTitleContainer}>
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
{streamInfo.displayName}
</Text>
{streamInfo.subTitle && (
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
{streamInfo.subTitle}
</Text>
)}
</View>
{/* Show loading indicator if stream is loading */}
{isLoading && (
<View style={styles.loadingIndicator}>
<ActivityIndicator size="small" color={theme.colors.primary} />
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
{statusMessage || "Loading..."}
</Text>
</View>
)}
</View>
<View style={styles.streamMetaRow}>
{streamInfo.isDolby && (
<QualityBadge type="VISION" />
)}
{streamInfo.size && (
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>💾 {streamInfo.size}</Text>
</View>
)}
{streamInfo.isDebrid && (
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
</View>
)}
</View>
</View>
{settings?.enableDownloads !== false && (
<TouchableOpacity
style={[styles.streamAction, { marginLeft: 8, backgroundColor: theme.colors.elevation2 }]}
onPress={handleDownload}
activeOpacity={0.7}
>
<MaterialIcons
name="download"
size={20}
color={theme.colors.highEmphasis}
/>
</TouchableOpacity>
)}
</TouchableOpacity>
);
});

View file

@ -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<TabletStreamsLayoutProps> = ({
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<TabletStreamsLayoutProps> = ({
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<TabletStreamsLayoutProps> = ({
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<TabletStreamsLayoutProps> = ({
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<TabletStreamsLayoutProps> = ({
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<TabletStreamsLayoutProps> = ({
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<TabletStreamsLayoutProps> = ({
}));
}
}, [backdropSource?.uri, backdropLoaded, backdropError]);
// Reset animation when episode changes
useEffect(() => {
backdropOpacity.value = 0;
@ -240,28 +240,28 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
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<TabletStreamsLayoutProps> = ({
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>
{isAutoplayWaiting ? 'Finding best stream for autoplay...' :
showStillFetching ? 'Still fetching streams…' :
'Finding available streams...'}
showStillFetching ? 'Still fetching streams…' :
'Finding available streams...'}
</Text>
</View>
);
@ -311,7 +311,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
// 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<TabletStreamsLayoutProps> = ({
if (item.type === 'header') {
return renderSectionHeader({ section: { title: item.title, addonId: item.addonId } });
}
const stream = item.stream;
return (
<StreamCard
@ -398,8 +398,8 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
<Animated.View style={[styles.tabletFullScreenBackground, backdropAnimatedStyle]}>
<FastImage
source={backdropSource}
style={StyleSheet.absoluteFillObject}
resizeMode={FastImage.resizeMode.cover}
style={styles.fullScreenImage}
resizeMode={FIResizeMode.cover}
onLoad={handleBackdropLoad}
onError={handleBackdropError}
/>
@ -414,7 +414,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
locations={[0, 0.5, 1]}
style={styles.tabletFullScreenGradient}
/>
{/* Left Panel: Movie Logo/Episode Info */}
<Animated.View style={[styles.tabletLeftPanel, leftPanelAnimatedStyle]}>
{type === 'movie' && metadata ? (
@ -423,7 +423,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
<FastImage
source={{ uri: metadata.logo }}
style={styles.tabletMovieLogo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
onError={() => 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%',

View file

@ -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<OptimizedImageProps> = ({
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<OptimizedImageProps> = ({
<FastImage
source={{ uri: placeholder }}
style={style}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
);
}
return (
<FastImage
source={{
source={{
uri: optimizedUrl,
priority: priority === 'high' ? FastImage.priority.high : priority === 'low' ? FastImage.priority.low : FastImage.priority.normal,
cache: FastImage.cacheControl.immutable
priority: priority === 'high' ? FIPriority.high : priority === 'low' ? FIPriority.low : FIPriority.normal,
cache: FICacheControl.immutable
}}
style={style}
resizeMode={contentFit === 'contain' ? FastImage.resizeMode.contain : contentFit === 'cover' ? FastImage.resizeMode.cover : FastImage.resizeMode.cover}
resizeMode={contentFit === 'contain' ? FIResizeMode.contain : contentFit === 'cover' ? FIResizeMode.cover : FIResizeMode.cover}
onLoad={() => {
setIsLoaded(true);
onLoad?.();
}}
onError={(error) => {
onError={(error: any) => {
setHasError(true);
onError?.(error);
}}

View file

@ -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<AppleTVHeroProps> = ({
<FastImage
source={{
uri: bannerUrl,
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable,
priority: FIPriority.high,
cache: FICacheControl.immutable,
}}
style={styles.backgroundImage}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
onLoad={() => setBannerLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
/>
</Animated.View>
@ -1028,11 +1028,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
<FastImage
source={{
uri: nextBannerUrl,
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable,
priority: FIPriority.high,
cache: FICacheControl.immutable,
}}
style={styles.backgroundImage}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
onLoad={() => setBannerLoaded((prev) => ({ ...prev, [nextIndex]: true }))}
/>
</Animated.View>

View file

@ -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
<FastImage
source={{
uri: optimizedPosterUrl,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable
priority: FastImagePriority.normal,
cache: FastImageCacheControl.immutable
}}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius }]}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FastImageResizeMode.cover}
onLoad={() => {
setImageError(false);
}}

View file

@ -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<ContinueWatchingRef>((props, re
<FastImage
source={{
uri: item.poster || 'https://via.placeholder.com/300x450',
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable
priority: FIPriority.high,
cache: FICacheControl.immutable
}}
style={styles.continueWatchingPoster}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
{/* Delete Indicator Overlay */}

View file

@ -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
<FastImage
source={{
uri: item.poster,
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable
priority: FIPriority.high,
cache: FICacheControl.immutable
}}
style={styles.menuPoster}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
<View style={styles.menuTitleContainer}>
<Text style={[styles.menuTitle, { color: isDarkMode ? '#FFFFFF' : '#000000' }]}>

View file

@ -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
<LinearGradient
colors={[
'transparent',
'transparent',
'transparent',
'rgba(0,0,0,0.3)',
'rgba(0,0,0,0.7)',
'rgba(0,0,0,0.95)'
@ -459,13 +465,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
{logoUrl && !logoLoadError ? (
<Animated.View style={logoAnimatedStyle}>
<FastImage
source={{
source={{
uri: logoUrl,
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable
priority: FastImagePriority.high,
cache: FastImageCacheControl.immutable
}}
style={styles.tabletLogo as any}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FastImageResizeMode.contain}
onError={onLogoLoadError}
/>
</Animated.View>
@ -536,7 +542,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
</TouchableOpacity>
</Animated.View>
</Animated.View>
{/* Bottom fade to blend with background */}
<LinearGradient
colors={[
@ -589,13 +595,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
{logoUrl && !logoLoadError ? (
<Animated.View style={logoAnimatedStyle}>
<FastImage
source={{
source={{
uri: logoUrl,
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable
priority: FastImagePriority.high,
cache: FastImageCacheControl.immutable
}}
style={styles.featuredLogo as any}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FastImageResizeMode.contain}
onError={onLogoLoadError}
/>
</Animated.View>
@ -663,7 +669,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
</ImageBackground>
</Animated.View>
</TouchableOpacity>
{/* Bottom fade to blend with background */}
<LinearGradient
colors={[

View file

@ -3,7 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity, ViewStyle, TextStyle, ImageSt
import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle, useAnimatedScrollHandler, useAnimatedReaction, runOnJS, SharedValue, interpolate, Extrapolation } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import FastImage from '@d11/react-native-fast-image';
import FastImage, { priority as FIPriority, cacheControl as FICacheControl, resizeMode as FIResizeMode, preload as FIPreload } from '../../utils/FastImageCompat';
import { Pagination } from 'react-native-reanimated-carousel';
import { Ionicons } from '@expo/vector-icons';
@ -106,17 +106,17 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ 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<HeroCarouselProps> = ({ items, loading = false }) =
<FastImage
source={{
uri: item.banner || item.poster,
priority: FastImage.priority.low,
cache: FastImage.cacheControl.immutable
priority: FIPriority.low,
cache: FICacheControl.immutable
}}
style={styles.backgroundImage as any}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
{Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable ? (
<GlassViewComp
@ -550,11 +550,11 @@ const AnimatedCardWrapper: React.FC<AnimatedCardWrapperProps> = memo(({
<FastImage
source={{
uri: item.banner || item.poster,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable
priority: FIPriority.normal,
cache: FICacheControl.immutable
}}
style={{ width: '100%', height: '100%', position: 'absolute' }}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.6)"]}
@ -567,11 +567,11 @@ const AnimatedCardWrapper: React.FC<AnimatedCardWrapperProps> = memo(({
<FastImage
source={{
uri: item.logo,
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable
priority: FIPriority.high,
cache: FICacheControl.immutable
}}
style={{ width: Math.round(cardWidth * 0.72), height: 64 }}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
onLoad={() => setLogoLoaded(true)}
/>
</Animated.View>
@ -806,11 +806,11 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
<FastImage
source={{
uri: item.banner || item.poster,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable
priority: FIPriority.normal,
cache: FICacheControl.immutable
}}
style={styles.banner as any}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
onLoad={() => setBannerLoaded(true)}
/>
</Animated.View>
@ -819,9 +819,9 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
<View style={styles.backContent as ViewStyle}>
{item.logo && !logoFailed ? (
<FastImage
source={{ uri: item.logo, priority: FastImage.priority.normal, cache: FastImage.cacheControl.immutable }}
source={{ uri: item.logo, priority: FIPriority.normal, cache: FICacheControl.immutable }}
style={[styles.logo as any, { width: Math.round(cardWidth * 0.72) }]}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
) : (
<Text style={[styles.backTitle as TextStyle, { color: colors.highEmphasis }]} numberOfLines={1}>
@ -866,11 +866,11 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
<FastImage
source={{
uri: item.banner || item.poster,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable
priority: FIPriority.normal,
cache: FICacheControl.immutable
}}
style={styles.banner as any}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
onLoad={() => setBannerLoaded(true)}
/>
</Animated.View>
@ -882,11 +882,11 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
<FastImage
source={{
uri: item.logo,
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable
priority: FIPriority.high,
cache: FICacheControl.immutable
}}
style={[styles.logo as any, { width: Math.round(cardWidth * 0.72) }]}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
onLoad={() => setLogoLoaded(true)}
onError={onLogoError}
/>
@ -920,18 +920,18 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
<Animated.View style={[styles.flipFace as any, styles.backFace as any, backFlipStyle]} pointerEvents={flipped ? 'auto' : 'none'}>
<View style={styles.bannerContainer as ViewStyle}>
<FastImage
source={{ uri: item.banner || item.poster, priority: FastImage.priority.low, cache: FastImage.cacheControl.immutable }}
source={{ uri: item.banner || item.poster, priority: FIPriority.low, cache: FICacheControl.immutable }}
style={styles.banner as any}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
{/* Overlay removed for performance - readability via text shadows */}
</View>
<View style={styles.backContent as ViewStyle}>
{item.logo && !logoFailed ? (
<FastImage
source={{ uri: item.logo, priority: FastImage.priority.normal, cache: FastImage.cacheControl.immutable }}
source={{ uri: item.logo, priority: FIPriority.normal, cache: FICacheControl.immutable }}
style={[styles.logo as any, { width: Math.round(cardWidth * 0.72) }]}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
) : (
<Text style={[styles.backTitle as TextStyle, { color: colors.highEmphasis }]} numberOfLines={1}>

View file

@ -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(() => {
<FastImage
source={{
uri: imageUrl || undefined,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable
priority: FIPriority.normal,
cache: FICacheControl.immutable
}}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
<LinearGradient

View file

@ -10,7 +10,7 @@ import {
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import FastImage from '@d11/react-native-fast-image';
import FastImage, { resizeMode as FIResizeMode } from '../../utils/FastImageCompat';
// Optional iOS Glass effect (expo-glass-effect) with safe fallback for CastDetailsModal
let GlassViewComp: any = null;
@ -82,14 +82,14 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
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<CastDetailsModalProps> = ({
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<CastDetailsModalProps> = ({
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<CastDetailsModalProps> = ({
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<CastDetailsModalProps> = ({
uri: `https://image.tmdb.org/t/p/w185${castMember.profile_path}`,
}}
style={{ width: '100%', height: '100%' }}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
) : (
<View style={{
@ -280,7 +280,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
</View>
)}
</View>
<View style={{ flex: 1 }}>
<Text style={{
color: '#fff',
@ -352,8 +352,8 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
borderColor: 'rgba(255, 255, 255, 0.06)',
}}>
{personDetails?.birthday && (
<View style={{
flexDirection: 'row',
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: personDetails?.place_of_birth ? 10 : 0
}}>

View file

@ -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<CastSectionProps> = ({
// 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<CastSectionProps> = ({
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<CastSectionProps> = ({
return 16; // phone
}
}, [deviceType]);
// Enhanced cast card sizing
const castCardWidth = useMemo(() => {
switch (deviceType) {
@ -82,7 +82,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
return 90; // phone
}
}, [deviceType]);
const castImageSize = useMemo(() => {
switch (deviceType) {
case 'tv':
@ -95,7 +95,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
return 80; // phone
}
}, [deviceType]);
const castCardSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
@ -122,7 +122,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
}
return (
<Animated.View
<Animated.View
style={styles.castSection}
entering={FadeIn.duration(300).delay(150)}
>
@ -131,8 +131,8 @@ export const CastSection: React.FC<CastSectionProps> = ({
{ paddingHorizontal: horizontalPadding }
]}>
<Text style={[
styles.sectionTitle,
{
styles.sectionTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
@ -149,10 +149,10 @@ export const CastSection: React.FC<CastSectionProps> = ({
]}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item, index }) => (
<Animated.View
entering={FadeIn.duration(300).delay(50 + index * 30)}
<Animated.View
entering={FadeIn.duration(300).delay(50 + index * 30)}
>
<TouchableOpacity
<TouchableOpacity
style={[
styles.castCard,
{
@ -178,19 +178,19 @@ export const CastSection: React.FC<CastSectionProps> = ({
uri: `https://image.tmdb.org/t/p/w185${item.profile_path}`,
}}
style={styles.castImage}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
) : (
<View style={[
styles.castImagePlaceholder,
{
styles.castImagePlaceholder,
{
backgroundColor: currentTheme.colors.darkBackground,
borderRadius: castImageSize / 2
}
]}>
<Text style={[
styles.placeholderText,
{
styles.placeholderText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
}
@ -201,8 +201,8 @@ export const CastSection: React.FC<CastSectionProps> = ({
)}
</View>
<Text style={[
styles.castName,
{
styles.castName,
{
color: currentTheme.colors.text,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
width: castCardWidth
@ -210,8 +210,8 @@ export const CastSection: React.FC<CastSectionProps> = ({
]} numberOfLines={1}>{item.name}</Text>
{isTmdbEnrichmentEnabled && item.character && (
<Text style={[
styles.characterName,
{
styles.characterName,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12,
width: castCardWidth,

View file

@ -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 { useNavigation, StackActions } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
@ -34,10 +34,10 @@ interface CollectionSectionProps {
loadingCollection: boolean;
}
export const CollectionSection: React.FC<CollectionSectionProps> = ({
collectionName,
collectionMovies,
loadingCollection
export const CollectionSection: React.FC<CollectionSectionProps> = ({
collectionName,
collectionMovies,
loadingCollection
}) => {
const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -82,7 +82,7 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
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<CollectionSectionProps> = ({
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<CollectionSectionProps> = ({
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<CollectionSectionProps> = ({
// 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<CollectionSectionProps> = ({
}, [collectionMovies]);
const renderItem = ({ item }: { item: StreamingContent }) => (
<TouchableOpacity
<TouchableOpacity
style={[styles.itemContainer, { width: backdropWidth, marginRight: itemSpacing }]}
onPress={() => handleItemPress(item)}
>
<FastImage
source={{ uri: item.banner || item.poster }}
style={[styles.backdrop, {
backgroundColor: currentTheme.colors.elevation1,
width: backdropWidth,
height: backdropHeight,
borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8
style={[styles.backdrop, {
backgroundColor: currentTheme.colors.elevation1,
width: backdropWidth,
height: backdropHeight,
borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8
}]}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
<Text style={[styles.title, {
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13,
lineHeight: isTV ? 20 : 18
<Text style={[styles.title, {
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13,
lineHeight: isTV ? 20 : 18
}]} numberOfLines={2}>
{item.name}
</Text>
{item.year && (
<Text style={[styles.year, {
color: currentTheme.colors.textMuted,
fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 11 : 11
<Text style={[styles.year, {
color: currentTheme.colors.textMuted,
fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 11 : 11
}]}>
{item.year}
</Text>
@ -177,11 +177,11 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
}
return (
<View style={[styles.container, { paddingLeft: 0 }] }>
<Text style={[styles.sectionTitle, {
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
paddingHorizontal: horizontalPadding
<View style={[styles.container, { paddingLeft: 0 }]}>
<Text style={[styles.sectionTitle, {
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
paddingHorizontal: horizontalPadding
}]}>
{collectionName}
</Text>
@ -191,9 +191,9 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
keyExtractor={(item) => item.id}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[styles.listContentContainer, {
paddingHorizontal: horizontalPadding,
paddingRight: horizontalPadding + itemSpacing
contentContainerStyle={[styles.listContentContainer, {
paddingHorizontal: horizontalPadding,
paddingRight: horizontalPadding + itemSpacing
}]}
/>
<CustomAlert

View file

@ -16,7 +16,17 @@ import { MaterialIcons, Entypo, Feather } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
// Replaced FastImage with standard Image for logos
import { BlurView as ExpoBlurView } from 'expo-blur';
import { BlurView as CommunityBlurView } from '@react-native-community/blur';
// Lazy-safe community blur import (avoid bundling issues on web)
let CommunityBlurView: any = null;
if (Platform.OS === 'android') {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
CommunityBlurView = require('@react-native-community/blur').BlurView;
} catch (_) {
CommunityBlurView = null;
}
}
// Optional iOS Glass effect (expo-glass-effect) with safe fallback for HeroSection
let GlassViewComp: any = null;
@ -1956,6 +1966,7 @@ const styles = StyleSheet.create({
heroGradient: {
flex: 1,
justifyContent: 'flex-end',
alignItems: 'center',
paddingBottom: 20,
},
bottomFadeGradient: {
@ -1972,31 +1983,26 @@ const styles = StyleSheet.create({
paddingBottom: isTablet ? 16 : 8,
position: 'relative',
zIndex: 2,
width: '100%',
alignItems: 'center',
},
logoContainer: {
alignItems: 'center',
justifyContent: 'center',
width: '100%',
marginBottom: 4,
flex: 0,
display: 'flex',
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
maxWidth: isTablet ? 600 : undefined,
},
titleLogoContainer: {
alignItems: 'center',
justifyContent: 'center',
width: '100%',
flex: 0,
display: 'flex',
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
maxWidth: isTablet ? 600 : undefined,
},
titleLogo: {
width: width * 0.75,
width: '75%',
maxWidth: 400,
height: 90,
alignSelf: 'center',
textAlign: 'center',
},
heroTitle: {
fontSize: 26,
@ -2016,8 +2022,8 @@ const styles = StyleSheet.create({
marginTop: 6,
marginBottom: 14,
gap: 0,
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
width: '100%',
maxWidth: isTablet ? 600 : undefined,
},
genreText: {
fontSize: 12,
@ -2046,17 +2052,16 @@ const styles = StyleSheet.create({
justifyContent: 'center',
width: '100%',
position: 'relative',
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
maxWidth: isTablet ? 600 : undefined,
},
singleRowLayout: {
flexDirection: 'row',
gap: 4,
gap: 8,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
maxWidth: isTablet ? 600 : undefined,
flexWrap: 'nowrap',
},
singleRowPlayButton: {
flex: 2,
@ -2070,7 +2075,8 @@ const styles = StyleSheet.create({
width: isTablet ? 50 : 44,
height: isTablet ? 50 : 44,
borderRadius: isTablet ? 25 : 22,
flex: 0,
flexShrink: 0,
flexGrow: 0,
},
singleRowPlayButtonFullWidth: {
flex: 1,
@ -2087,7 +2093,8 @@ const styles = StyleSheet.create({
},
primaryActionButton: {
flex: 1,
maxWidth: '48%',
minWidth: 100,
maxWidth: 200,
},
playButtonRow: {
flexDirection: 'row',
@ -2133,6 +2140,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
flexShrink: 0,
},
traktButton: {
width: 50,
@ -2163,8 +2171,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
minHeight: 36,
position: 'relative',
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
maxWidth: isTablet ? 600 : undefined,
},
progressGlassBackground: {
width: '75%',

View file

@ -8,7 +8,7 @@ import {
Dimensions,
} 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,
useAnimatedStyle,
@ -242,7 +242,7 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
height: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
}
]}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
<Text style={[
styles.ratingText,

View file

@ -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 { useNavigation, StackActions } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
@ -33,9 +33,9 @@ interface MoreLikeThisSectionProps {
loadingRecommendations: boolean;
}
export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
recommendations,
loadingRecommendations
export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
recommendations,
loadingRecommendations
}) => {
const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -91,16 +91,16 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
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<MoreLikeThisSectionProps> = ({
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 }) => (
<TouchableOpacity
<TouchableOpacity
style={[styles.itemContainer, { width: posterWidth, marginRight: itemSpacing }]}
onPress={() => handleItemPress(item)}
>
<FastImage
source={{ uri: item.poster }}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, width: posterWidth, height: posterHeight, borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8 }]}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
<Text style={[styles.title, { color: currentTheme.colors.mediumEmphasis, fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13, lineHeight: isTV ? 20 : 18 }]} numberOfLines={2}>
{item.name}
@ -144,7 +144,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
}
return (
<View style={[styles.container, { paddingLeft: 0 }] }>
<View style={[styles.container, { paddingLeft: 0 }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>More Like This</Text>
<FlatList
data={recommendations}

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef, useCallback, useMemo, memo } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList, Modal, Pressable } from 'react-native';
import * as Haptics from 'expo-haptics';
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 { FlashList, FlashListRef } from '@shopify/flash-list';
@ -911,7 +911,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
<FastImage
source={{ uri: seasonPoster }}
style={styles.seasonPoster}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
{selectedSeason === season && (
<View style={[
@ -1050,7 +1050,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
<FastImage
source={{ uri: episodeImage }}
style={styles.episodeImage}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
<View style={[
styles.episodeNumberBadge,
@ -1166,7 +1166,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
height: isTV ? 17 : isLargeTablet ? 16 : isTablet ? 15 : 15
}
]}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
<Text style={[
styles.ratingText,
@ -1190,7 +1190,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
height: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
}
]}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
<Text style={[
styles.ratingText,
@ -1329,7 +1329,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
<FastImage
source={{ uri: episodeImage }}
style={styles.episodeBackgroundImage}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
{/* Standard Gradient Overlay */}
@ -1432,7 +1432,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
height: isTV ? 17 : isLargeTablet ? 16 : isTablet ? 15 : 15
}
]}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
<Text style={[
styles.ratingTextHorizontal,

View file

@ -14,7 +14,7 @@ import { useTheme } from '../../contexts/ThemeContext';
import { useTrailer } from '../../contexts/TrailerContext';
import { logger } from '../../utils/logger';
import TrailerService from '../../services/trailerService';
import Video, { VideoRef, OnLoadData, OnProgressData } from 'react-native-video';
import Video, { VideoRef, OnLoadData, OnProgressData } from '../../utils/VideoCompat';
const { width, height } = Dimensions.get('window');
const isTablet = width >= 768;
@ -135,7 +135,7 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
const handleClose = useCallback(() => {
setIsPlaying(false);
// Resume hero section trailer when modal closes
try {
resumeTrailer();
@ -143,7 +143,7 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
} catch (error) {
logger.warn('TrailerModal', 'Error resuming hero trailer:', error);
}
onClose();
}, [onClose, resumeTrailer]);

View file

@ -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<TrailersSectionProps> = memo(({
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
}
]}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
{/* Subtle Gradient Overlay */}
<View style={[

View file

@ -1,5 +1,5 @@
import React, { useRef, useImperativeHandle, forwardRef, useEffect, useState } from 'react';
import { View, requireNativeComponent, UIManager, findNodeHandle, NativeModules } from 'react-native';
import { View, requireNativeComponent, UIManager, findNodeHandle, NativeModules, Platform } from 'react-native';
export interface KSPlayerSource {
uri: string;
@ -29,8 +29,11 @@ interface KSPlayerViewProps {
style?: any;
}
const KSPlayerViewManager = requireNativeComponent<KSPlayerViewProps>('KSPlayerView');
const KSPlayerModule = NativeModules.KSPlayerModule;
// Only require native component on iOS
const KSPlayerViewManager = Platform.OS === 'ios'
? requireNativeComponent<KSPlayerViewProps>('KSPlayerView')
: View as any;
const KSPlayerModule = Platform.OS === 'ios' ? NativeModules.KSPlayerModule : null;
export interface KSPlayerRef {
seek: (time: number) => void;

View file

@ -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<EpisodeCardProps> = ({
}) => {
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<EpisodeCardProps> = ({
} 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<EpisodeCardProps> = ({
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<EpisodeCardProps> = ({
}
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<EpisodeCardProps> = ({
<FastImage
source={{ uri: episodeImage }}
style={styles.episodeImage}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
{isCurrent && (
<View style={styles.currentBadge}>
@ -106,11 +106,11 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({
</View>
{showProgress && (
<View style={styles.progressBarContainer}>
<View
<View
style={[
styles.progressBar,
{ width: `${progressPercent}%`, backgroundColor: currentTheme.colors.primary }
]}
]}
/>
</View>
)}
@ -138,7 +138,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({
<FastImage
source={{ uri: TMDB_LOGO }}
style={styles.tmdbLogo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
<Text style={[styles.ratingText, { color: currentTheme.colors.textMuted }]}>
{effectiveVote.toFixed(1)}

View file

@ -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<PauseOverlayProps> = ({
<FastImage
source={{ uri: `https://image.tmdb.org/t/p/w300${selectedCastMember.profile_path}` }}
style={{ width: Math.min(120, screenDimensions.width * 0.18), height: Math.min(180, screenDimensions.width * 0.27), borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.1)' }}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
</View>
)}

View file

@ -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<any, TrailerPlayerProps>(({
const { currentTheme } = useTheme();
const { isTrailerPlaying: globalTrailerPlaying } = useTrailer();
const videoRef = useRef<VideoRef>(null);
const [isLoading, setIsLoading] = useState(true);
const [isPlaying, setIsPlaying] = useState(autoPlay);
const [isMuted, setIsMuted] = useState(muted);
@ -90,16 +90,16 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
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<any, TrailerPlayerProps>(({
// Component mount/unmount tracking
useEffect(() => {
setIsComponentMounted(true);
return () => {
setIsComponentMounted(false);
cleanupVideo();
@ -185,15 +185,15 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
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<any, TrailerPlayerProps>(({
const handleVideoPress = useCallback(() => {
if (!isComponentMounted) return;
if (showControls) {
// If controls are visible, toggle play/pause
handlePlayPause();
@ -218,7 +218,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
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<any, TrailerPlayerProps>(({
});
setIsPlaying(!isPlaying);
showControlsWithTimeout();
} catch (error) {
logger.error('TrailerPlayer', 'Error toggling playback:', error);
@ -236,7 +236,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
const handleMuteToggle = useCallback(async () => {
try {
if (!videoRef.current || !isComponentMounted) return;
setIsMuted(!isMuted);
showControlsWithTimeout();
} catch (error) {
@ -246,7 +246,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
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<any, TrailerPlayerProps>(({
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<any, TrailerPlayerProps>(({
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<any, TrailerPlayerProps>(({
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<any, TrailerPlayerProps>(({
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<any, TrailerPlayerProps>(({
} catch (error) {
logger.error('TrailerPlayer', 'Error cleaning up animation values:', error);
}
// Ensure video is stopped
cleanupVideo();
};
@ -420,9 +420,9 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
</Animated.View>
)}
{/* Video controls overlay */}
{/* Video controls overlay */}
{!hideControls && (
<TouchableOpacity
<TouchableOpacity
style={styles.videoOverlay}
onPress={handleVideoPress}
activeOpacity={1}
@ -439,10 +439,10 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
<View style={styles.centerControls}>
<Animated.View style={playButtonAnimatedStyle}>
<TouchableOpacity style={styles.playButton} onPress={handlePlayPause}>
<MaterialIcons
name={isPlaying ? 'pause' : 'play-arrow'}
size={isTablet ? 64 : 48}
color="white"
<MaterialIcons
name={isPlaying ? 'pause' : 'play-arrow'}
size={isTablet ? 64 : 48}
color="white"
/>
</TouchableOpacity>
</Animated.View>
@ -457,8 +457,8 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
{/* Progress bar */}
<View style={styles.progressContainer}>
<View style={styles.progressBar}>
<View
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
<View
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
/>
</View>
</View>
@ -466,27 +466,27 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
{/* Control buttons */}
<View style={styles.controlButtons}>
<TouchableOpacity style={styles.controlButton} onPress={handlePlayPause}>
<MaterialIcons
name={isPlaying ? 'pause' : 'play-arrow'}
size={isTablet ? 32 : 24}
color="white"
<MaterialIcons
name={isPlaying ? 'pause' : 'play-arrow'}
size={isTablet ? 32 : 24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity style={styles.controlButton} onPress={handleMuteToggle}>
<MaterialIcons
name={isMuted ? 'volume-off' : 'volume-up'}
size={isTablet ? 32 : 24}
color="white"
<MaterialIcons
name={isMuted ? 'volume-off' : 'volume-up'}
size={isTablet ? 32 : 24}
color="white"
/>
</TouchableOpacity>
{onFullscreenToggle && (
<TouchableOpacity style={styles.controlButton} onPress={onFullscreenToggle}>
<MaterialIcons
name="fullscreen"
size={isTablet ? 32 : 24}
color="white"
<MaterialIcons
name="fullscreen"
size={isTablet ? 32 : 24}
color="white"
/>
</TouchableOpacity>
)}

View file

@ -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<boolean> => {
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<boolean> => {
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<boolean> => {
} catch (error) {
// Ignore AsyncStorage errors
}
return isAvailable;
} catch (error) {
return false;
@ -47,9 +47,9 @@ const checkImageAvailability = async (url: string): Promise<boolean> => {
};
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<string | null>(null);
const [loadingBanner, setLoadingBanner] = useState<boolean>(false);
const forcedBannerRefreshDone = useRef<boolean>(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<string | null>(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<Promise<void> | 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: () => { },
};
};

View file

@ -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');

View file

@ -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<ChatBubbleProps> = 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<ChatBubbleProps> = React.memo(({ message, isLast }) =
<MaterialIcons name="smart-toy" size={16} color="white" />
</View>
)}
<View style={[
styles.messageBubble,
isUser ? [
styles.userBubble,
styles.userBubble,
{ backgroundColor: currentTheme.colors.primary }
] : [
styles.assistantBubble,
@ -140,142 +140,142 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
{Platform.OS === 'android' && AndroidBlurView
? <AndroidBlurView blurAmount={16} blurRadius={8} style={StyleSheet.absoluteFill} />
: Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable
? <GlassViewComp style={StyleSheet.absoluteFill} glassEffectStyle="regular" />
: <ExpoBlurView intensity={70} tint="dark" style={StyleSheet.absoluteFill} />}
? <GlassViewComp style={StyleSheet.absoluteFill} glassEffectStyle="regular" />
: <ExpoBlurView intensity={70} tint="dark" style={StyleSheet.absoluteFill} />}
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.50)' }]} />
</View>
)}
{isUser ? (
<Text style={[styles.messageText, { color: 'white' }]}>
{message.content}
</Text>
) : (
<Markdown
style={{
body: {
color: currentTheme.colors.highEmphasis,
fontSize: 16,
lineHeight: 22,
margin: 0,
padding: 0
},
paragraph: {
marginBottom: 8,
marginTop: 0,
color: currentTheme.colors.highEmphasis
},
heading1: {
fontSize: 20,
fontWeight: '700',
color: currentTheme.colors.highEmphasis,
marginBottom: 8,
marginTop: 0
},
heading2: {
fontSize: 18,
fontWeight: '600',
color: currentTheme.colors.highEmphasis,
marginBottom: 6,
marginTop: 0
},
link: {
color: currentTheme.colors.primary,
textDecorationLine: 'underline'
},
code_inline: {
backgroundColor: currentTheme.colors.elevation2,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 14,
color: currentTheme.colors.highEmphasis,
},
code_block: {
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 8,
padding: 12,
marginVertical: 8,
color: currentTheme.colors.highEmphasis,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 14,
},
fence: {
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 8,
padding: 12,
marginVertical: 8,
color: currentTheme.colors.highEmphasis,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 14,
},
bullet_list: {
marginBottom: 8,
marginTop: 0
},
ordered_list: {
marginBottom: 8,
marginTop: 0
},
list_item: {
marginBottom: 4,
color: currentTheme.colors.highEmphasis
},
strong: {
fontWeight: '700',
color: currentTheme.colors.highEmphasis
},
em: {
fontStyle: 'italic',
color: currentTheme.colors.highEmphasis
},
blockquote: {
backgroundColor: currentTheme.colors.elevation1,
borderLeftWidth: 4,
borderLeftColor: currentTheme.colors.primary,
paddingLeft: 12,
paddingVertical: 8,
marginVertical: 8,
borderRadius: 4,
},
table: {
borderWidth: 1,
borderColor: currentTheme.colors.elevation2,
borderRadius: 8,
marginVertical: 8,
},
thead: {
backgroundColor: currentTheme.colors.elevation1,
},
th: {
padding: 8,
fontWeight: '600',
color: currentTheme.colors.highEmphasis,
borderBottomWidth: 1,
borderBottomColor: currentTheme.colors.elevation2,
},
td: {
padding: 8,
color: currentTheme.colors.highEmphasis,
borderBottomWidth: 1,
borderBottomColor: currentTheme.colors.elevation2,
},
}}
>
{message.content}
</Markdown>
)}
{isUser ? (
<Text style={[styles.messageText, { color: 'white' }]}>
{message.content}
</Text>
) : (
<Markdown
style={{
body: {
color: currentTheme.colors.highEmphasis,
fontSize: 16,
lineHeight: 22,
margin: 0,
padding: 0
},
paragraph: {
marginBottom: 8,
marginTop: 0,
color: currentTheme.colors.highEmphasis
},
heading1: {
fontSize: 20,
fontWeight: '700',
color: currentTheme.colors.highEmphasis,
marginBottom: 8,
marginTop: 0
},
heading2: {
fontSize: 18,
fontWeight: '600',
color: currentTheme.colors.highEmphasis,
marginBottom: 6,
marginTop: 0
},
link: {
color: currentTheme.colors.primary,
textDecorationLine: 'underline'
},
code_inline: {
backgroundColor: currentTheme.colors.elevation2,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 14,
color: currentTheme.colors.highEmphasis,
},
code_block: {
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 8,
padding: 12,
marginVertical: 8,
color: currentTheme.colors.highEmphasis,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 14,
},
fence: {
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 8,
padding: 12,
marginVertical: 8,
color: currentTheme.colors.highEmphasis,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 14,
},
bullet_list: {
marginBottom: 8,
marginTop: 0
},
ordered_list: {
marginBottom: 8,
marginTop: 0
},
list_item: {
marginBottom: 4,
color: currentTheme.colors.highEmphasis
},
strong: {
fontWeight: '700',
color: currentTheme.colors.highEmphasis
},
em: {
fontStyle: 'italic',
color: currentTheme.colors.highEmphasis
},
blockquote: {
backgroundColor: currentTheme.colors.elevation1,
borderLeftWidth: 4,
borderLeftColor: currentTheme.colors.primary,
paddingLeft: 12,
paddingVertical: 8,
marginVertical: 8,
borderRadius: 4,
},
table: {
borderWidth: 1,
borderColor: currentTheme.colors.elevation2,
borderRadius: 8,
marginVertical: 8,
},
thead: {
backgroundColor: currentTheme.colors.elevation1,
},
th: {
padding: 8,
fontWeight: '600',
color: currentTheme.colors.highEmphasis,
borderBottomWidth: 1,
borderBottomColor: currentTheme.colors.elevation2,
},
td: {
padding: 8,
color: currentTheme.colors.highEmphasis,
borderBottomWidth: 1,
borderBottomColor: currentTheme.colors.elevation2,
},
}}
>
{message.content}
</Markdown>
)}
<Text style={[
styles.messageTime,
{ color: isUser ? 'rgba(255,255,255,0.7)' : currentTheme.colors.mediumEmphasis }
]}>
{new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
{new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</Text>
</View>
{isUser && (
<View style={[styles.userAvatarContainer, { backgroundColor: currentTheme.colors.elevation2 }]}>
<MaterialIcons name="person" size={16} color={currentTheme.colors.primary} />
@ -300,7 +300,7 @@ interface SuggestionChipProps {
const SuggestionChip: React.FC<SuggestionChipProps> = React.memo(({ text, onPress }) => {
const { currentTheme } = useTheme();
return (
<TouchableOpacity
style={[styles.suggestionChip, { backgroundColor: currentTheme.colors.elevation1 }]}
@ -347,9 +347,9 @@ const AIChatScreen: React.FC = () => {
const navigation = useNavigation();
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const { contentId, contentType, episodeId, seasonNumber, episodeNumber, title } = route.params;
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
@ -369,10 +369,10 @@ const AIChatScreen: React.FC = () => {
};
}, [])
);
const scrollViewRef = useRef<ScrollView>(null);
const inputRef = useRef<TextInput>(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 */}
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
onClose={() => setAlertVisible(false)}
actions={alertActions}
/>
{/* CustomAlert at root */ }
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
onClose={() => 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 (
<Animated.View style={{ flex: 1, opacity: modalOpacity }}>
<SafeAreaView edges={['top','bottom']} style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
{backdropUrl && (
<View style={StyleSheet.absoluteFill} pointerEvents="none">
<FastImage
source={{ uri: backdropUrl }}
style={StyleSheet.absoluteFill}
resizeMode={FastImage.resizeMode.cover}
/>
{Platform.OS === 'android' && AndroidBlurView
? <AndroidBlurView blurAmount={12} blurRadius={6} style={StyleSheet.absoluteFill} />
: Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable
? <GlassViewComp style={StyleSheet.absoluteFill} glassEffectStyle="regular" />
: <ExpoBlurView intensity={60} tint="dark" style={StyleSheet.absoluteFill} />}
<View style={[StyleSheet.absoluteFill, { backgroundColor: Platform.OS === 'android' ? 'rgba(0,0,0,0.28)' : 'rgba(0,0,0,0.45)' }]} />
</View>
)}
<StatusBar barStyle="light-content" />
{/* Header */}
<Animated.View style={[
styles.header,
{
backgroundColor: 'transparent',
paddingTop: Platform.OS === 'ios' ? insets.top : insets.top
},
headerAnimatedStyle
]}>
<View style={styles.headerContent}>
<TouchableOpacity
onPress={() => {
if (Platform.OS === 'android') {
modalOpacity.value = withSpring(0, { damping: 18, stiffness: 160 }, (finished) => {
if (finished) runOnJS(navigation.goBack)();
});
} else {
navigation.goBack();
}
}}
style={styles.backButton}
>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
<View style={styles.headerInfo}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
AI Chat
</Text>
<Text style={[styles.headerSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
{getDisplayTitle()}
</Text>
<SafeAreaView edges={['top', 'bottom']} style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
{backdropUrl && (
<View style={StyleSheet.absoluteFill} pointerEvents="none">
<FastImage
source={{ uri: backdropUrl }}
style={StyleSheet.absoluteFill}
resizeMode={FIResizeMode.cover}
/>
{Platform.OS === 'android' && AndroidBlurView
? <AndroidBlurView blurAmount={12} blurRadius={6} style={StyleSheet.absoluteFill} />
: Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable
? <GlassViewComp style={StyleSheet.absoluteFill} glassEffectStyle="regular" />
: <ExpoBlurView intensity={60} tint="dark" style={StyleSheet.absoluteFill} />}
<View style={[StyleSheet.absoluteFill, { backgroundColor: Platform.OS === 'android' ? 'rgba(0,0,0,0.28)' : 'rgba(0,0,0,0.45)' }]} />
</View>
<View style={[styles.aiIndicator, { backgroundColor: currentTheme.colors.primary }]}>
<MaterialIcons name="smart-toy" size={20} color="white" />
</View>
</View>
</Animated.View>
)}
<StatusBar barStyle="light-content" />
{/* Chat Messages */}
<KeyboardAvoidingView
style={styles.chatContainer}
behavior={Platform.OS === 'ios' ? undefined : undefined}
keyboardVerticalOffset={0}
>
<ScrollView
ref={scrollViewRef}
style={styles.messagesContainer}
contentContainerStyle={[
styles.messagesContent,
{ paddingBottom: isKeyboardVisible ? 20 : (56 + (isLoading ? 20 : 0)) }
]}
showsVerticalScrollIndicator={false}
removeClippedSubviews
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
keyboardShouldPersistTaps="handled"
>
{messages.length === 0 && suggestions.length > 0 && (
<View style={styles.welcomeContainer}>
<View style={[styles.welcomeIcon, { backgroundColor: currentTheme.colors.primary }]}>
<MaterialIcons name="smart-toy" size={32} color="white" />
</View>
<Text style={[styles.welcomeTitle, { color: currentTheme.colors.highEmphasis }]}>
Ask me anything about
{/* Header */}
<Animated.View style={[
styles.header,
{
backgroundColor: 'transparent',
paddingTop: Platform.OS === 'ios' ? insets.top : insets.top
},
headerAnimatedStyle
]}>
<View style={styles.headerContent}>
<TouchableOpacity
onPress={() => {
if (Platform.OS === 'android') {
modalOpacity.value = withSpring(0, { damping: 18, stiffness: 160 }, (finished) => {
if (finished) runOnJS(navigation.goBack)();
});
} else {
navigation.goBack();
}
}}
style={styles.backButton}
>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
<View style={styles.headerInfo}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
AI Chat
</Text>
<Text style={[styles.welcomeSubtitle, { color: currentTheme.colors.primary }]}>
<Text style={[styles.headerSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
{getDisplayTitle()}
</Text>
<Text style={[styles.welcomeDescription, { color: currentTheme.colors.mediumEmphasis }]}>
I have detailed knowledge about this content and can answer questions about plot, characters, themes, and more.
</Text>
<View style={styles.suggestionsContainer}>
<Text style={[styles.suggestionsTitle, { color: currentTheme.colors.mediumEmphasis }]}>
Try asking:
</Text>
<View style={styles.suggestionsGrid}>
{suggestions.map((suggestion, index) => (
<SuggestionChip
key={index}
text={suggestion}
onPress={() => handleSuggestionPress(suggestion)}
/>
))}
</View>
</View>
</View>
)}
{messages.map((message, index) => (
<ChatBubble
key={message.id}
message={message}
isLast={index === messages.length - 1}
/>
))}
{isLoading && (
<View style={styles.typingIndicator}>
<View style={[styles.typingBubble, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.typingDots}>
<View style={[styles.typingDot, { backgroundColor: currentTheme.colors.mediumEmphasis }]} />
<View style={[styles.typingDot, { backgroundColor: currentTheme.colors.mediumEmphasis }]} />
<View style={[styles.typingDot, { backgroundColor: currentTheme.colors.mediumEmphasis }]} />
</View>
</View>
</View>
)}
</ScrollView>
{/* Input Container */}
<SafeAreaView edges={['bottom']} style={{ backgroundColor: 'transparent' }}>
<Animated.View style={[
styles.inputContainer,
{
backgroundColor: 'transparent',
paddingBottom: 12
},
inputAnimatedStyle
]}>
<View style={[styles.inputWrapper, { backgroundColor: 'transparent' }]}>
<View style={styles.inputBlurBackdrop} pointerEvents="none">
{Platform.OS === 'android' && AndroidBlurView
? <AndroidBlurView blurAmount={10} blurRadius={4} style={StyleSheet.absoluteFill} />
: Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable
? <GlassViewComp style={StyleSheet.absoluteFill} glassEffectStyle="regular" />
: <ExpoBlurView intensity={50} tint="dark" style={StyleSheet.absoluteFill} />}
<View style={[StyleSheet.absoluteFill, { backgroundColor: Platform.OS === 'android' ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.25)' }]} />
<View style={[styles.aiIndicator, { backgroundColor: currentTheme.colors.primary }]}>
<MaterialIcons name="smart-toy" size={20} color="white" />
</View>
<TextInput
ref={inputRef}
style={[
styles.textInput,
{ color: currentTheme.colors.highEmphasis }
]}
value={inputText}
onChangeText={setInputText}
placeholder="Ask about this content..."
placeholderTextColor={currentTheme.colors.mediumEmphasis}
multiline
maxLength={500}
editable={!isLoading}
onSubmitEditing={handleSendPress}
blurOnSubmit={false}
/>
<TouchableOpacity
style={[
styles.sendButton,
{
backgroundColor: inputText.trim() ? currentTheme.colors.primary : currentTheme.colors.elevation2
}
]}
onPress={handleSendPress}
disabled={!inputText.trim() || isLoading}
activeOpacity={0.7}
>
<MaterialIcons
name="send"
size={20}
color={inputText.trim() ? 'white' : currentTheme.colors.mediumEmphasis}
/>
</TouchableOpacity>
</View>
</Animated.View>
</SafeAreaView>
</KeyboardAvoidingView>
</SafeAreaView>
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
onClose={() => setAlertVisible(false)}
actions={alertActions}
/>
{/* Chat Messages */}
<KeyboardAvoidingView
style={styles.chatContainer}
behavior={Platform.OS === 'ios' ? undefined : undefined}
keyboardVerticalOffset={0}
>
<ScrollView
ref={scrollViewRef}
style={styles.messagesContainer}
contentContainerStyle={[
styles.messagesContent,
{ paddingBottom: isKeyboardVisible ? 20 : (56 + (isLoading ? 20 : 0)) }
]}
showsVerticalScrollIndicator={false}
removeClippedSubviews
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
keyboardShouldPersistTaps="handled"
>
{messages.length === 0 && suggestions.length > 0 && (
<View style={styles.welcomeContainer}>
<View style={[styles.welcomeIcon, { backgroundColor: currentTheme.colors.primary }]}>
<MaterialIcons name="smart-toy" size={32} color="white" />
</View>
<Text style={[styles.welcomeTitle, { color: currentTheme.colors.highEmphasis }]}>
Ask me anything about
</Text>
<Text style={[styles.welcomeSubtitle, { color: currentTheme.colors.primary }]}>
{getDisplayTitle()}
</Text>
<Text style={[styles.welcomeDescription, { color: currentTheme.colors.mediumEmphasis }]}>
I have detailed knowledge about this content and can answer questions about plot, characters, themes, and more.
</Text>
<View style={styles.suggestionsContainer}>
<Text style={[styles.suggestionsTitle, { color: currentTheme.colors.mediumEmphasis }]}>
Try asking:
</Text>
<View style={styles.suggestionsGrid}>
{suggestions.map((suggestion, index) => (
<SuggestionChip
key={index}
text={suggestion}
onPress={() => handleSuggestionPress(suggestion)}
/>
))}
</View>
</View>
</View>
)}
{messages.map((message, index) => (
<ChatBubble
key={message.id}
message={message}
isLast={index === messages.length - 1}
/>
))}
{isLoading && (
<View style={styles.typingIndicator}>
<View style={[styles.typingBubble, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.typingDots}>
<View style={[styles.typingDot, { backgroundColor: currentTheme.colors.mediumEmphasis }]} />
<View style={[styles.typingDot, { backgroundColor: currentTheme.colors.mediumEmphasis }]} />
<View style={[styles.typingDot, { backgroundColor: currentTheme.colors.mediumEmphasis }]} />
</View>
</View>
</View>
)}
</ScrollView>
{/* Input Container */}
<SafeAreaView edges={['bottom']} style={{ backgroundColor: 'transparent' }}>
<Animated.View style={[
styles.inputContainer,
{
backgroundColor: 'transparent',
paddingBottom: 12
},
inputAnimatedStyle
]}>
<View style={[styles.inputWrapper, { backgroundColor: 'transparent' }]}>
<View style={styles.inputBlurBackdrop} pointerEvents="none">
{Platform.OS === 'android' && AndroidBlurView
? <AndroidBlurView blurAmount={10} blurRadius={4} style={StyleSheet.absoluteFill} />
: Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable
? <GlassViewComp style={StyleSheet.absoluteFill} glassEffectStyle="regular" />
: <ExpoBlurView intensity={50} tint="dark" style={StyleSheet.absoluteFill} />}
<View style={[StyleSheet.absoluteFill, { backgroundColor: Platform.OS === 'android' ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.25)' }]} />
</View>
<TextInput
ref={inputRef}
style={[
styles.textInput,
{ color: currentTheme.colors.highEmphasis }
]}
value={inputText}
onChangeText={setInputText}
placeholder="Ask about this content..."
placeholderTextColor={currentTheme.colors.mediumEmphasis}
multiline
maxLength={500}
editable={!isLoading}
onSubmitEditing={handleSendPress}
blurOnSubmit={false}
/>
<TouchableOpacity
style={[
styles.sendButton,
{
backgroundColor: inputText.trim() ? currentTheme.colors.primary : currentTheme.colors.elevation2
}
]}
onPress={handleSendPress}
disabled={!inputText.trim() || isLoading}
activeOpacity={0.7}
>
<MaterialIcons
name="send"
size={20}
color={inputText.trim() ? 'white' : currentTheme.colors.mediumEmphasis}
/>
</TouchableOpacity>
</View>
</Animated.View>
</SafeAreaView>
</KeyboardAvoidingView>
</SafeAreaView>
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
onClose={() => setAlertVisible(false)}
actions={alertActions}
/>
</Animated.View>
);
};

View file

@ -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 */}
<View style={styles.profileContainer}>
{avatarUrl && !avatarError ? (
<View style={[styles.avatar, { overflow: 'hidden' }]}>
<View style={[styles.avatar, { overflow: 'hidden' }]}>
<FastImage
source={{ uri: avatarUrl }}
style={styles.avatarImage}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
onError={() => setAvatarError(true)}
/>
</View>

View file

@ -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 = () => {
<FastImage
source={{ uri: logo }}
style={styles.addonIcon}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
) : (
<View style={styles.addonIconPlaceholder}>
@ -1080,7 +1080,7 @@ const AddonsScreen = () => {
<FastImage
source={{ uri: logo }}
style={styles.communityAddonIcon}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
) : (
<View style={styles.communityAddonIconPlaceholder}>
@ -1272,7 +1272,7 @@ const AddonsScreen = () => {
<FastImage
source={{ uri: promoAddon.logo }}
style={styles.addonIcon}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
) : (
<View style={styles.addonIconPlaceholder}>
@ -1350,7 +1350,7 @@ const AddonsScreen = () => {
<FastImage
source={{ uri: item.manifest.logo }}
style={styles.addonIcon}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
) : (
<View style={styles.addonIconPlaceholder}>
@ -1456,7 +1456,7 @@ const AddonsScreen = () => {
<FastImage
source={{ uri: addonDetails.logo }}
style={styles.addonLogo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
) : (
<View style={styles.addonLogoPlaceholder}>

View file

@ -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 = () => {
<FastImage
source={{ uri: imageUrl }}
style={styles.backdropImage}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
<View style={styles.backdropInfo}>
<Text style={[styles.backdropResolution, { color: currentTheme.colors.highEmphasis }]}>

View file

@ -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 (
<Animated.View entering={FadeIn.duration(300).delay(100)}>
<TouchableOpacity
<TouchableOpacity
style={[styles.episodeItem, { borderBottomColor: currentTheme.colors.border + '20' }]}
onPress={() => handleEpisodePress(item)}
activeOpacity={0.7}
@ -141,43 +141,43 @@ const CalendarScreen = () => {
<FastImage
source={{ uri: imageUrl || '' }}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
</TouchableOpacity>
<View style={styles.episodeDetails}>
<Text style={[styles.seriesName, { color: currentTheme.colors.text }]} numberOfLines={1}>
{item.seriesName}
</Text>
{hasReleaseDate ? (
<>
<Text style={[styles.episodeTitle, { color: currentTheme.colors.lightGray }]} numberOfLines={2}>
S{item.season}:E{item.episode} - {item.title}
</Text>
{item.overview ? (
<Text style={[styles.overview, { color: currentTheme.colors.lightGray }]} numberOfLines={2}>
{item.overview}
</Text>
) : null}
<View style={styles.metadataContainer}>
<View style={styles.dateContainer}>
<MaterialIcons
name={isFuture ? "event" : "event-available"}
size={16}
color={currentTheme.colors.lightGray}
<MaterialIcons
name={isFuture ? "event" : "event-available"}
size={16}
color={currentTheme.colors.lightGray}
/>
<Text style={[styles.date, { color: currentTheme.colors.lightGray }]}>{formattedDate}</Text>
</View>
{item.vote_average > 0 && (
<View style={styles.ratingContainer}>
<MaterialIcons
name="star"
size={16}
color={currentTheme.colors.primary}
<MaterialIcons
name="star"
size={16}
color={currentTheme.colors.primary}
/>
<Text style={[styles.rating, { color: currentTheme.colors.primary }]}>
{item.vote_average.toFixed(1)}
@ -192,10 +192,10 @@ const CalendarScreen = () => {
No scheduled episodes
</Text>
<View style={styles.dateContainer}>
<MaterialIcons
name="event-busy"
size={16}
color={currentTheme.colors.lightGray}
<MaterialIcons
name="event-busy"
size={16}
color={currentTheme.colors.lightGray}
/>
<Text style={[styles.date, { color: currentTheme.colors.lightGray }]}>Check back later</Text>
</View>
@ -206,18 +206,18 @@ const CalendarScreen = () => {
</Animated.View>
);
};
const renderSectionHeader = ({ section }: { section: CalendarSection }) => (
<View style={[styles.sectionHeader, {
<View style={[styles.sectionHeader, {
backgroundColor: currentTheme.colors.darkBackground,
borderBottomColor: currentTheme.colors.border
borderBottomColor: currentTheme.colors.border
}]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>
{section.title}
</Text>
</View>
);
// 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 (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
@ -281,13 +281,13 @@ const CalendarScreen = () => {
</SafeAreaView>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={[styles.header, { borderBottomColor: currentTheme.colors.border }]}>
<TouchableOpacity
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
@ -296,7 +296,7 @@ const CalendarScreen = () => {
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>Calendar</Text>
<View style={{ width: 40 }} />
</View>
{selectedDate && filteredEpisodes.length > 0 && (
<View style={[styles.filterInfoContainer, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.filterInfoText, { color: currentTheme.colors.text }]}>
@ -307,12 +307,12 @@ const CalendarScreen = () => {
</TouchableOpacity>
</View>
)}
<CalendarSectionComponent
<CalendarSectionComponent
episodes={allEpisodes}
onSelectDate={handleDateSelect}
/>
{selectedDate && filteredEpisodes.length > 0 ? (
<FlatList
data={filteredEpisodes}
@ -339,7 +339,7 @@ const CalendarScreen = () => {
<Text style={[styles.emptyFilterText, { color: currentTheme.colors.text }]}>
No episodes for {format(selectedDate, 'MMMM d, yyyy')}
</Text>
<TouchableOpacity
<TouchableOpacity
style={[styles.clearFilterButtonLarge, { backgroundColor: currentTheme.colors.primary }]}
onPress={clearDateFilter}
>

View file

@ -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 (
<Animated.View entering={FadeIn.delay(100)}>
<TouchableOpacity
@ -286,8 +286,8 @@ const CastMoviesScreen: React.FC = () => {
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 (
<Animated.View entering={FadeIn.delay(200)}>
<TouchableOpacity
@ -319,8 +319,8 @@ const CastMoviesScreen: React.FC = () => {
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}
>
<MaterialIcons
name={icon as any}
size={16}
color={isSelected ? currentTheme.colors.primary : 'rgba(255, 255, 255, 0.6)'}
<MaterialIcons
name={icon as any}
size={16}
color={isSelected ? currentTheme.colors.primary : 'rgba(255, 255, 255, 0.6)'}
style={{ marginRight: 6 }}
/>
<Text style={{
@ -384,7 +384,7 @@ const CastMoviesScreen: React.FC = () => {
uri: `https://image.tmdb.org/t/p/w500${item.poster_path}`,
}}
style={{ width: '100%', height: '100%' }}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
) : (
<View style={{
@ -397,7 +397,7 @@ const CastMoviesScreen: React.FC = () => {
<MaterialIcons name="movie" size={32} color="rgba(255, 255, 255, 0.2)" />
</View>
)}
{/* Upcoming indicator */}
{item.isUpcoming && (
<View style={{
@ -463,7 +463,7 @@ const CastMoviesScreen: React.FC = () => {
}}
/>
</View>
<View style={{ paddingHorizontal: 4, marginTop: 8 }}>
<Text style={{
color: '#fff',
@ -474,7 +474,7 @@ const CastMoviesScreen: React.FC = () => {
}} numberOfLines={2}>
{`${item.title}`}
</Text>
{item.character && (
<Text style={{
color: 'rgba(255, 255, 255, 0.65)',
@ -485,7 +485,7 @@ const CastMoviesScreen: React.FC = () => {
{`as ${item.character}`}
</Text>
)}
<View style={{
flexDirection: 'row',
alignItems: 'center',
@ -502,7 +502,7 @@ const CastMoviesScreen: React.FC = () => {
{`${new Date(item.release_date).getFullYear()}`}
</Text>
)}
{item.isUpcoming && (
<View style={{
flexDirection: 'row',
@ -538,7 +538,7 @@ const CastMoviesScreen: React.FC = () => {
[1, 0.9],
Extrapolate.CLAMP
);
return {
opacity,
};
@ -547,7 +547,7 @@ const CastMoviesScreen: React.FC = () => {
return (
<View style={{ flex: 1, backgroundColor: currentTheme.colors.darkBackground }}>
{/* Minimal Header */}
<Animated.View
<Animated.View
style={[
{
paddingTop: safeAreaTop + 16,
@ -560,7 +560,7 @@ const CastMoviesScreen: React.FC = () => {
headerAnimatedStyle
]}
>
<Animated.View
<Animated.View
entering={SlideInDown.delay(100)}
style={{ flexDirection: 'row', alignItems: 'center' }}
>
@ -579,7 +579,7 @@ const CastMoviesScreen: React.FC = () => {
>
<MaterialIcons name="arrow-back" size={20} color="rgba(255, 255, 255, 0.9)" />
</TouchableOpacity>
<View style={{
width: 44,
height: 44,
@ -594,7 +594,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}
/>
) : (
<View style={{
@ -613,7 +613,7 @@ const CastMoviesScreen: React.FC = () => {
</View>
)}
</View>
<View style={{ flex: 1 }}>
<Text style={{
color: '#fff',
@ -654,8 +654,8 @@ const CastMoviesScreen: React.FC = () => {
}}>
Filter
</Text>
<ScrollView
horizontal
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingRight: 20 }}
>
@ -677,8 +677,8 @@ const CastMoviesScreen: React.FC = () => {
}}>
Sort By
</Text>
<ScrollView
horizontal
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingRight: 20 }}
>
@ -763,7 +763,7 @@ const CastMoviesScreen: React.FC = () => {
) : null
}
ListEmptyComponent={
<Animated.View
<Animated.View
entering={FadeIn.delay(400)}
style={{
alignItems: 'center',
@ -799,9 +799,9 @@ const CastMoviesScreen: React.FC = () => {
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'

View file

@ -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<CatalogScreenProps> = ({ route, navigation }) => {
<FastImage
source={{ uri: optimizePosterUrl(item.poster) }}
style={[styles.poster, { aspectRatio }]}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
{type === 'movie' && nowPlayingMovies.has(item.id) && (

View file

@ -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<ContributorCardProps> = ({ contributor, currentT
styles.avatar,
isTablet && styles.tabletAvatar
]}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
<View style={styles.contributorInfo}>
<Text style={[
@ -190,7 +190,7 @@ const SpecialMentionCard: React.FC<SpecialMentionCardProps> = ({ mention, curren
styles.avatar,
isTablet && styles.tabletAvatar
]}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
)}
<View style={[styles.discordBadgeSmall, { backgroundColor: DISCORD_BRAND_COLOR }]}>

View file

@ -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<{
<FastImage
source={{ uri: optimizePosterUrl(posterUrl) }}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
{/* Status indicator overlay */}
<View style={[styles.statusOverlay, { backgroundColor: getStatusColor() }]}>

View file

@ -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);

View file

@ -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(({
<FastImage
source={{ uri: posterUrl }}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
) : (
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center' }]}>
@ -409,7 +409,7 @@ const LibraryScreen = () => {
<FastImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
{item.watched && (
<View style={styles.watchedIndicator}>

View file

@ -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}
/>
) : (
<Text style={[
@ -1122,7 +1122,7 @@ const MetadataScreen: React.FC = () => {
height: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 22
}
]}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
</View>
))}

View file

@ -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 = () => {
<FastImage
source={{ uri: scraper.logo }}
style={styles.scraperLogo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
)
) : (

View file

@ -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 = () => {
<FastImage
source={{ uri: item.poster || PLACEHOLDER_POSTER }}
style={styles.horizontalItemPoster}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
{/* Bookmark and watched icons top right, bookmark to the left of watched */}
{inLibrary && (

View file

@ -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 = () => {
<FastImage
source={require('../../assets/support_me_on_kofi_red.png')}
style={styles.kofiImage}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
</TouchableOpacity>
@ -980,7 +980,7 @@ const SettingsScreen: React.FC = () => {
<FastImage
source={{ uri: 'https://pngimg.com/uploads/discord/discord_PNG3.png' }}
style={styles.discordLogo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
<Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}>
Discord
@ -997,7 +997,7 @@ const SettingsScreen: React.FC = () => {
<FastImage
source={{ uri: 'https://www.iconpacks.net/icons/2/free-reddit-logo-icon-2436-thumb.png' }}
style={styles.discordLogo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
<Text style={[styles.discordButtonText, { color: '#FF4500' }]}>
Reddit
@ -1022,7 +1022,7 @@ const SettingsScreen: React.FC = () => {
<FastImage
source={require('../../assets/nuviotext.png')}
style={styles.brandLogo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
</View>
@ -1101,7 +1101,7 @@ const SettingsScreen: React.FC = () => {
<FastImage
source={require('../../assets/support_me_on_kofi_red.png')}
style={styles.kofiImage}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
</TouchableOpacity>
@ -1115,7 +1115,7 @@ const SettingsScreen: React.FC = () => {
<FastImage
source={{ uri: 'https://pngimg.com/uploads/discord/discord_PNG3.png' }}
style={styles.discordLogo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
<Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}>
Discord
@ -1132,7 +1132,7 @@ const SettingsScreen: React.FC = () => {
<FastImage
source={{ uri: 'https://www.iconpacks.net/icons/2/free-reddit-logo-icon-2436-thumb.png' }}
style={styles.discordLogo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
<Text style={[styles.discordButtonText, { color: '#FF4500' }]}>
Reddit
@ -1157,7 +1157,7 @@ const SettingsScreen: React.FC = () => {
<FastImage
source={require('../../assets/nuviotext.png')}
style={styles.brandLogo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
</View>

View file

@ -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 (
<Animated.View style={styles.ratingCellContainer}>
<Animated.View style={[
styles.ratingCell,
{
styles.ratingCell,
{
backgroundColor: getRatingColor(rating),
}
]}>
@ -149,7 +149,7 @@ const RatingSourceToggle = memo(({ ratingSource, setRatingSource, theme }: {
]}
onPress={() => setRatingSource(source as RatingSource)}
>
<Text
<Text
style={{
fontSize: 13,
fontWeight: isActive ? '700' : '600',
@ -182,7 +182,7 @@ const ShowInfo = memo(({ show, theme }: { show: Show | null, theme: any }) => {
<FastImage
source={{ uri: `https://image.tmdb.org/t/p/w500${show?.poster_path}` }}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
<View style={styles.showDetails}>
@ -192,11 +192,10 @@ const ShowInfo = memo(({ show, theme }: { show: Show | null, theme: any }) => {
<Text style={[styles.showYear, { color: theme.colors.lightGray }]}>
{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"
}`
: ""}
</Text>
@ -227,13 +226,13 @@ const ShowRatingsScreen = ({ route }: Props) => {
const [ratingSource, setRatingSource] = useState<RatingSource>('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) => {
<Text style={[styles.loadingText, { color: colors.lightGray }]}>Loading content...</Text>
</View>
}>
<ScrollView
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
contentContainerStyle={styles.scrollViewContent}
>
<View style={styles.content}>
<Animated.View
<Animated.View
entering={FadeIn.duration(300)}
style={styles.showInfoContainer}
>
<ShowInfo show={show} theme={currentTheme} />
</Animated.View>
<Animated.View
<Animated.View
entering={FadeIn.delay(100).duration(300)}
style={styles.section}
>
<RatingSourceToggle
ratingSource={ratingSource}
setRatingSource={setRatingSource}
theme={currentTheme}
<RatingSourceToggle
ratingSource={ratingSource}
setRatingSource={setRatingSource}
theme={currentTheme}
/>
</Animated.View>
<Animated.View
<Animated.View
entering={FadeIn.delay(200).duration(300)}
style={styles.section}
>
@ -470,7 +469,7 @@ const ShowRatingsScreen = ({ route }: Props) => {
</View>
</Animated.View>
<Animated.View
<Animated.View
entering={FadeIn.delay(300).duration(300)}
style={styles.section}
>
@ -491,8 +490,8 @@ const ShowRatingsScreen = ({ route }: Props) => {
</View>
{/* Scrollable Seasons */}
<ScrollView
horizontal
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.seasonsScrollView}
onScroll={onScroll}
@ -502,8 +501,8 @@ const ShowRatingsScreen = ({ route }: Props) => {
{/* Seasons Header */}
<View style={[styles.gridHeader, { borderBottomColor: colors.black + '40' }]}>
{seasons.map((season) => (
<Animated.View
key={`s${season.season_number}`}
<Animated.View
key={`s${season.season_number}`}
style={styles.ratingColumn}
entering={FadeIn.delay(season.season_number * 20).duration(200)}
>
@ -528,12 +527,12 @@ const ShowRatingsScreen = ({ route }: Props) => {
{Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => (
<View key={`e${episodeIndex + 1}`} style={styles.gridRow}>
{seasons.map((season) => (
<Animated.View
key={`s${season.season_number}e${episodeIndex + 1}`}
<Animated.View
key={`s${season.season_number}e${episodeIndex + 1}`}
style={styles.ratingColumn}
entering={FadeIn.delay((season.season_number + episodeIndex) * 5).duration(200)}
>
{season.episodes[episodeIndex] &&
{season.episodes[episodeIndex] &&
<RatingCell
episode={season.episodes[episodeIndex]}
ratingSource={ratingSource}

View file

@ -21,7 +21,7 @@ import {
import { useNavigation } from '@react-navigation/native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { mmkvStorage } from '../services/mmkvStorage';
import FastImage from '@d11/react-native-fast-image';
import FastImage, { resizeMode as FIResizeMode } from '../utils/FastImageCompat';
import { tmdbService } from '../services/tmdbService';
import { useSettings } from '../hooks/useSettings';
import { logger } from '../utils/logger';
@ -450,14 +450,14 @@ const TMDBSettingsScreen = () => {
<FastImage
source={{ uri: banner || undefined }}
style={styles.bannerImage}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
<View style={styles.bannerOverlay} />
{logo && (
<FastImage
source={{ uri: logo }}
style={styles.logoOverBanner}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
)}
{!logo && (

View file

@ -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<TraktUser | null>(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}
>
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark}
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark}
/>
<Text style={[styles.backText, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}>
Settings
</Text>
</TouchableOpacity>
<View style={styles.headerActions}>
{/* Empty for now, but ready for future actions */}
</View>
</View>
<Text style={[styles.headerTitle, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}>
Trakt Settings
</Text>
<ScrollView
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
@ -259,10 +259,10 @@ const TraktSettingsScreen: React.FC = () => {
<View style={styles.profileContainer}>
<View style={styles.profileHeader}>
{userProfile.avatar ? (
<FastImage
source={{ uri: userProfile.avatar }}
<FastImage
source={{ uri: userProfile.avatar }}
style={styles.avatar}
resizeMode={FastImage.resizeMode.cover}
resizeMode={FIResizeMode.cover}
/>
) : (
<View style={[styles.avatarPlaceholder, { backgroundColor: currentTheme.colors.primary }]}>
@ -315,7 +315,7 @@ const TraktSettingsScreen: React.FC = () => {
</View>
) : (
<View style={styles.signInContainer}>
<TraktIcon
<TraktIcon
width={120}
height={120}
style={styles.traktLogo}
@ -497,7 +497,7 @@ const TraktSettingsScreen: React.FC = () => {
</View>
)}
</ScrollView>
<CustomAlert
visible={alertVisible}
title={alertTitle}

View file

@ -1,7 +1,7 @@
import React, { memo } from 'react';
import { View, Text, 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 AnimatedImage from '../../../components/AnimatedImage';
@ -83,7 +83,7 @@ const EpisodeHero = memo(
<FastImage
source={{ uri: IMDb_LOGO }}
style={styles.imdbLogo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
<Text style={[styles.ratingText, { color: '#F5C518' }]}>
{effectiveEpisodeVote.toFixed(1)}
@ -94,7 +94,7 @@ const EpisodeHero = memo(
<FastImage
source={{ uri: TMDB_LOGO }}
style={styles.tmdbLogo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
/>
<Text style={styles.ratingText}>{effectiveEpisodeVote.toFixed(1)}</Text>
</>

View file

@ -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(
<FastImage
source={{ uri: metadata.logo }}
style={styles.logo}
resizeMode={FastImage.resizeMode.contain}
resizeMode={FIResizeMode.contain}
onError={() => setMovieLogoError(true)}
/>
) : (

View file

@ -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<string, { value: any; timestamp: number }>();
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);

View file

@ -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<void> {
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);

View file

@ -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<void> {
// 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<TMDBShow[]> {
const cacheKey = this.generateCacheKey('search_tv', { query });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<TMDBShow[]>(cacheKey);
if (cached !== null) return cached;
@ -389,7 +392,7 @@ export class TMDBService {
*/
async getTVShowDetails(tmdbId: number, language: string = 'en'): Promise<TMDBShow | null> {
const cacheKey = this.generateCacheKey(`tv_${tmdbId}`, { language });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<TMDBShow>(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<number | null> {
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<IMDbRatings | null> {
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<IMDbRatings>(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<TMDBSeason | null> {
const cacheKey = this.generateCacheKey(`tv_${tmdbId}_season_${seasonNumber}`, { language, showName });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<TMDBSeason>(cacheKey);
if (cached !== null) return cached;
@ -556,7 +559,7 @@ export class TMDBService {
language: string = 'en-US'
): Promise<TMDBEpisode | null> {
const cacheKey = this.generateCacheKey(`tv_${tmdbId}_season_${seasonNumber}_episode_${episodeNumber}`, { language });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<TMDBEpisode>(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<number | null> {
const cacheKey = this.generateCacheKey('find_imdb', { imdbId });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<number>(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<any>(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<any>(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<any>(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<any>(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<any[]>(cacheKey);
if (cached !== null) return cached;
@ -901,7 +904,7 @@ export class TMDBService {
async searchMulti(query: string): Promise<any[]> {
const cacheKey = this.generateCacheKey('search_multi', { query });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<any[]>(cacheKey);
if (cached !== null) return cached;
@ -929,7 +932,7 @@ export class TMDBService {
*/
async getMovieDetails(movieId: string, language: string = 'en'): Promise<any> {
const cacheKey = this.generateCacheKey(`movie_${movieId}`, { language });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<any>(cacheKey);
if (cached !== null) return cached;
@ -955,7 +958,7 @@ export class TMDBService {
*/
async getCollectionDetails(collectionId: number, language: string = 'en'): Promise<TMDBCollection | null> {
const cacheKey = this.generateCacheKey(`collection_${collectionId}`, { language });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<TMDBCollection>(cacheKey);
if (cached !== null) return cached;
@ -980,7 +983,7 @@ export class TMDBService {
*/
async getCollectionImages(collectionId: number, language: string = 'en'): Promise<any> {
const cacheKey = this.generateCacheKey(`collection_${collectionId}_images`, { language });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<any>(cacheKey);
if (cached !== null) return cached;
@ -1006,14 +1009,14 @@ export class TMDBService {
*/
async getMovieImagesFull(movieId: number | string, language: string = 'en'): Promise<any> {
const cacheKey = this.generateCacheKey(`movie_${movieId}_images_full`, { language });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<any>(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<string | null> {
const cacheKey = this.generateCacheKey(`movie_${movieId}_logo`, { preferredLanguage });
// Check cache
const cached = this.getCachedData<string>(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<any> {
const cacheKey = this.generateCacheKey(`tv_${showId}_images_full`, { language });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<any>(cacheKey);
if (cached !== null) return cached;
@ -1187,7 +1190,7 @@ export class TMDBService {
*/
async getTvShowImages(showId: number | string, preferredLanguage: string = 'en'): Promise<string | null> {
const cacheKey = this.generateCacheKey(`tv_${showId}_logo`, { preferredLanguage });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<string>(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<string | null> {
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<string | null> {
const cacheKey = this.generateCacheKey(`${type}_${id}_certification`);
// Check cache
const cached = this.getCachedData<string>(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<TMDBTrendingResult[]> {
const cacheKey = this.generateCacheKey(`trending_${type}_${timeWindow}`);
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<TMDBTrendingResult[]>(cacheKey);
if (cached !== null) return cached;
@ -1454,7 +1457,7 @@ export class TMDBService {
*/
async getPopular(type: 'movie' | 'tv', page: number = 1): Promise<TMDBTrendingResult[]> {
const cacheKey = this.generateCacheKey(`popular_${type}`, { page });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<TMDBTrendingResult[]>(cacheKey);
if (cached !== null) return cached;
@ -1504,7 +1507,7 @@ export class TMDBService {
*/
async getUpcoming(type: 'movie' | 'tv', page: number = 1): Promise<TMDBTrendingResult[]> {
const cacheKey = this.generateCacheKey(`upcoming_${type}`, { page });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<TMDBTrendingResult[]>(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<TMDBTrendingResult[]> {
const cacheKey = this.generateCacheKey('now_playing', { page, region });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<TMDBTrendingResult[]>(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<TMDBTrendingResult[]> {
const cacheKey = this.generateCacheKey(`discover_${type}`, { genreName, page });
// Check cache (local or remote)
const cached = await this.getFromCacheOrRemote<TMDBTrendingResult[]>(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({

View file

@ -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<ImageStyle>;
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<HTMLImageElement, FastImageProps>(({ 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 (
<img
ref={ref}
src={uri}
alt=""
style={{
...cleanedStyle,
objectFit: objectFitValue,
} as React.CSSProperties}
onError={onError ? () => onError() : undefined}
onLoad={onLoad}
/>
);
});
WebImage.displayName = 'WebImage';
// Component Implementation
const FastImageComponent = React.forwardRef<any, FastImageProps>((props, ref) => {
if (isWeb) {
return <WebImage {...props} ref={ref} />;
}
// On Native, use FastImage if available, otherwise fallback to RNImage
const Comp = NativeFastImage || RNImage;
return <Comp {...props} ref={ref} />;
});
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<FastImageProps & React.RefAttributes<any>> & {
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;

164
src/utils/VideoCompat.tsx Normal file
View file

@ -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<any, any>(({
source,
style,
resizeMode,
paused,
muted,
volume,
onLoad,
onProgress,
onEnd,
onError,
repeat,
controls,
...props
}, ref) => {
const videoRef = useRef<HTMLVideoElement>(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 (
<video
ref={videoRef}
src={uri}
style={{
width: '100%',
height: '100%',
objectFit,
...(StyleSheet.flatten(style) as any),
}}
loop={repeat}
controls={controls}
onLoadedMetadata={(e) => {
if (onLoad) {
onLoad({
duration: (e.target as HTMLVideoElement).duration,
currentTime: (e.target as HTMLVideoElement).currentTime,
naturalSize: {
width: (e.target as HTMLVideoElement).videoWidth,
height: (e.target as HTMLVideoElement).videoHeight,
orientation: 'landscape',
},
canPlayFastForward: true,
canPlaySlowForward: true,
canPlaySlowReverse: true,
canPlayReverse: true,
canStepBackward: true,
canStepForward: true,
});
}
}}
onTimeUpdate={(e) => {
if (onProgress) {
onProgress({
currentTime: (e.target as HTMLVideoElement).currentTime,
playableDuration: (e.target as HTMLVideoElement).duration,
seekableDuration: (e.target as HTMLVideoElement).duration,
});
}
}}
onEnded={onEnd}
onError={onError}
muted={muted} // attribute for initial render
{...props}
/>
);
});
WebVideo.displayName = 'WebVideo';
// Component Implementation
const VideoCompat = forwardRef<any, any>((props, ref) => {
if (isWeb) {
return <WebVideo {...props} ref={ref} />;
}
// Native implementation
const NativeVideo = VideoOriginal || View;
return <NativeVideo {...props} ref={ref} />;
});
VideoCompat.displayName = 'VideoCompat';
export default VideoCompat;