mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
web init
This commit is contained in:
parent
97f558faf4
commit
4c50fd8d8d
55 changed files with 1720 additions and 1166 deletions
33
App.tsx
33
App.tsx
|
|
@ -44,21 +44,27 @@ import { mmkvStorage } from './src/services/mmkvStorage';
|
|||
import AnnouncementOverlay from './src/components/AnnouncementOverlay';
|
||||
import { CampaignManager } from './src/components/promotions/CampaignManager';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
|
||||
// Only initialize Sentry on native platforms
|
||||
if (Platform.OS !== 'web') {
|
||||
Sentry.init({
|
||||
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
|
||||
|
||||
// Adds more context data to events (IP address, cookies, user, etc.)
|
||||
// For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
|
||||
sendDefaultPii: true,
|
||||
// Adds more context data to events (IP address, cookies, user, etc.)
|
||||
// For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
|
||||
sendDefaultPii: true,
|
||||
|
||||
// Configure Session Replay conservatively to avoid startup overhead in production
|
||||
replaysSessionSampleRate: __DEV__ ? 0.1 : 0,
|
||||
replaysOnErrorSampleRate: __DEV__ ? 1 : 0,
|
||||
integrations: [Sentry.feedbackIntegration()],
|
||||
// Configure Session Replay conservatively to avoid startup overhead in production
|
||||
replaysSessionSampleRate: __DEV__ ? 0.1 : 0,
|
||||
replaysOnErrorSampleRate: __DEV__ ? 1 : 0,
|
||||
integrations: [
|
||||
// Feedback integration may not be available on web
|
||||
...(typeof Sentry.feedbackIntegration === 'function' ? [Sentry.feedbackIntegration()] : []),
|
||||
],
|
||||
|
||||
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
|
||||
// spotlight: __DEV__,
|
||||
});
|
||||
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
|
||||
// spotlight: __DEV__,
|
||||
});
|
||||
}
|
||||
|
||||
// Force LTR layout to prevent RTL issues when Arabic is set as system language
|
||||
// This ensures posters and UI elements remain visible and properly positioned
|
||||
|
|
@ -268,4 +274,5 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
});
|
||||
|
||||
export default Sentry.wrap(App);
|
||||
// Only wrap with Sentry on native platforms
|
||||
export default Platform.OS !== 'web' ? Sentry.wrap(App) : App;
|
||||
|
|
@ -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
13
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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%',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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' }]}>
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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%',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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: () => { },
|
||||
};
|
||||
};
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 }]}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
|
|
|
|||
|
|
@ -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 }]}>
|
||||
|
|
|
|||
|
|
@ -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() }]}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
167
src/utils/FastImageCompat.tsx
Normal file
167
src/utils/FastImageCompat.tsx
Normal 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
164
src/utils/VideoCompat.tsx
Normal 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;
|
||||
Loading…
Reference in a new issue