import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, StatusBar, KeyboardAvoidingView, Platform, Dimensions, ActivityIndicator, Keyboard, } from 'react-native'; 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 { BlurView as ExpoBlurView } from 'expo-blur'; import { LinearGradient } from 'expo-linear-gradient'; // Lazy-safe community blur import (avoid bundling issues on web) let AndroidBlurView: any = null; if (Platform.OS === 'android') { try { // eslint-disable-next-line @typescript-eslint/no-var-requires AndroidBlurView = require('@react-native-community/blur').BlurView; } catch (_) { AndroidBlurView = null; } } // Optional iOS Glass effect (expo-glass-effect) with safe fallback for AIChatScreen let GlassViewComp: any = null; let liquidGlassAvailable = false; if (Platform.OS === 'ios') { try { // Dynamically require so app still runs if the package isn't installed yet const glass = require('expo-glass-effect'); GlassViewComp = glass.GlassView; liquidGlassAvailable = typeof glass.isLiquidGlassAvailable === 'function' ? glass.isLiquidGlassAvailable() : false; } catch { GlassViewComp = null; liquidGlassAvailable = false; } } 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, withTiming, withRepeat, withSequence, withDelay, interpolate, Extrapolate, runOnJS } from 'react-native-reanimated'; const { width, height } = Dimensions.get('window'); const isTablet = width >= 768; type AIChatRouteParams = { AIChat: { contentId: string; contentType: 'movie' | 'series'; episodeId?: string; seasonNumber?: number; episodeNumber?: number; title: string; }; }; type AIChatScreenRouteProp = RouteProp; interface ChatBubbleProps { message: ChatMessage; isLast: boolean; } // Animated typing dot component const TypingDot: React.FC<{ delay: number; color: string }> = ({ delay, color }) => { const opacity = useSharedValue(0.3); const scale = useSharedValue(1); useEffect(() => { opacity.value = withDelay( delay, withRepeat( withSequence( withTiming(1, { duration: 400 }), withTiming(0.3, { duration: 400 }) ), -1, false ) ); scale.value = withDelay( delay, withRepeat( withSequence( withTiming(1.2, { duration: 400 }), withTiming(1, { duration: 400 }) ), -1, false ) ); }, []); const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value, transform: [{ scale: scale.value }], })); return ( ); }; const ChatBubble: React.FC = React.memo(({ message, isLast }) => { const { currentTheme } = useTheme(); const isUser = message.role === 'user'; const bubbleAnimation = useSharedValue(0); useEffect(() => { bubbleAnimation.value = withSpring(1, { damping: 18, stiffness: 100 }); }, []); const animatedStyle = useAnimatedStyle(() => ({ opacity: bubbleAnimation.value, transform: [ { translateY: interpolate( bubbleAnimation.value, [0, 1], [16, 0], Extrapolate.CLAMP ) }, { scale: interpolate( bubbleAnimation.value, [0, 1], [0.95, 1], Extrapolate.CLAMP ) } ] })); return ( {!isUser && ( )} {!isUser && ( {Platform.OS === 'android' && AndroidBlurView ? : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable ? : } )} {isUser ? ( {message.content} ) : ( {message.content} )} {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {isUser && ( )} ); }, (prev, next) => { return ( prev.isLast === next.isLast && prev.message.id === next.message.id && prev.message.role === next.message.role && prev.message.content === next.message.content && prev.message.timestamp === next.message.timestamp ); }); interface SuggestionChipProps { text: string; onPress: () => void; index: number; } const SuggestionChip: React.FC = React.memo(({ text, onPress, index }) => { const { currentTheme } = useTheme(); const animValue = useSharedValue(0); useEffect(() => { animValue.value = withDelay( index * 80, withSpring(1, { damping: 18, stiffness: 120 }) ); }, []); const animatedStyle = useAnimatedStyle(() => ({ opacity: animValue.value, transform: [ { translateY: interpolate(animValue.value, [0, 1], [12, 0], Extrapolate.CLAMP) }, { scale: interpolate(animValue.value, [0, 1], [0.95, 1], Extrapolate.CLAMP) }, ], })); return ( {text} ); }, (prev, next) => prev.text === next.text && prev.onPress === next.onPress && prev.index === next.index); const AIChatScreen: React.FC = () => { // CustomAlert state const [alertVisible, setAlertVisible] = useState(false); const [alertTitle, setAlertTitle] = useState(''); const [alertMessage, setAlertMessage] = useState(''); const [alertActions, setAlertActions] = useState void; style?: object }>>([ { label: 'OK', onPress: () => setAlertVisible(false) }, ]); const openAlert = ( title: string, message: string, actions?: Array<{ label: string; onPress?: () => void; style?: object }> ) => { setAlertTitle(title); setAlertMessage(message); if (actions && actions.length > 0) { setAlertActions( actions.map(a => ({ label: a.label, style: a.style, onPress: () => { a.onPress?.(); setAlertVisible(false); }, })) ); } else { setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); } setAlertVisible(true); }; const route = useRoute(); const navigation = useNavigation(); const { currentTheme } = useTheme(); const insets = useSafeAreaInsets(); const { contentId, contentType, episodeId, seasonNumber, episodeNumber, title } = route.params; const [messages, setMessages] = useState([]); const [inputText, setInputText] = useState(''); const [isLoading, setIsLoading] = useState(false); const [context, setContext] = useState(null); const [isLoadingContext, setIsLoadingContext] = useState(true); const [suggestions, setSuggestions] = useState([]); const [backdropUrl, setBackdropUrl] = useState(null); const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); // Ensure Android cleans up heavy image resources when leaving the screen to avoid flash on back useFocusEffect( React.useCallback(() => { return () => { if (Platform.OS === 'android') { setBackdropUrl(null); } }; }, []) ); const scrollViewRef = useRef(null); const inputRef = useRef(null); // Animation values const headerOpacity = useSharedValue(1); const inputContainerY = useSharedValue(0); // Android full-screen modal fade const modalOpacity = useSharedValue(Platform.OS === 'android' ? 0 : 1); useEffect(() => { loadContext(); }, []); // Track keyboard and animate input to avoid gaps on iOS useEffect(() => { const onShow = (e: any) => { setIsKeyboardVisible(true); if (Platform.OS === 'ios') { const kbHeight = e?.endCoordinates?.height ?? 0; const lift = Math.max(0, kbHeight - insets.bottom); inputContainerY.value = withTiming(-lift, { duration: 220 }); } }; const onHide = () => { setIsKeyboardVisible(false); if (Platform.OS === 'ios') { inputContainerY.value = withTiming(0, { duration: 220 }); } }; const showSub = Platform.OS === 'ios' ? Keyboard.addListener('keyboardWillShow', onShow) : Keyboard.addListener('keyboardDidShow', onShow); const hideSub = Platform.OS === 'ios' ? Keyboard.addListener('keyboardWillHide', onHide) : Keyboard.addListener('keyboardDidHide', onHide); return () => { showSub.remove(); hideSub.remove(); }; }, [insets.bottom, inputContainerY]); // Animate in on Android for full-screen modal feel useEffect(() => { if (Platform.OS === 'android') { // Use spring to avoid jank on some devices modalOpacity.value = withSpring(1, { damping: 20, stiffness: 140 }); } }, [modalOpacity]); useEffect(() => { if (context && messages.length === 0) { // Generate conversation starters const starters = generateConversationStarters(context); setSuggestions(starters); } }, [context, messages.length]); 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); if (!movieData) { // Try resolve TMDB id from IMDb id const tmdbId = await tmdbService.findTMDBIdByIMDB(contentId); if (tmdbId) { movieData = await tmdbService.getMovieDetails(String(tmdbId)); } } if (!movieData) throw new Error('Unable to load movie details'); const movieContext = createMovieContext(movieData); setContext(movieContext); try { const path = movieData.backdrop_path || movieData.poster_path || null; if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`); } catch { } } else { // Series: resolve TMDB numeric id first (contentId may be IMDb/stremio id) let tmdbNumericId: number | null = null; if (/^\d+$/.test(contentId)) { tmdbNumericId = parseInt(contentId, 10); } else { // Try to resolve from IMDb id or stremio-like id tmdbNumericId = await tmdbService.findTMDBIdByIMDB(contentId); if (!tmdbNumericId && episodeId) { tmdbNumericId = await tmdbService.extractTMDBIdFromStremioId(episodeId); } } if (!tmdbNumericId) throw new Error('Unable to resolve TMDB ID for series'); const [showData, allEpisodes] = await Promise.all([ tmdbService.getTVShowDetails(tmdbNumericId), tmdbService.getAllEpisodes(tmdbNumericId) ]); if (!showData) throw new Error('Unable to load TV show details'); try { const path = showData.backdrop_path || showData.poster_path || null; if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`); } 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'); } finally { setIsLoadingContext(false); {/* CustomAlert at root */ } setAlertVisible(false)} actions={alertActions} /> } }; const sendMessage = useCallback(async (messageText: string) => { if (!messageText.trim() || !context || isLoading) return; const userMessage: ChatMessage = { id: Date.now().toString(), role: 'user', content: messageText.trim(), timestamp: Date.now(), }; setMessages(prev => [...prev, userMessage]); setInputText(''); setIsLoading(true); setSuggestions([]); // Hide suggestions after first message try { // If series overview is loaded, parse user query for specific episode and fetch on-demand let requestContext = context; if ('episodesBySeason' in (context as any)) { // Series-wide context; optionally detect SxE patterns to focus answer, but keep series context const sxe = messageText.match(/s(\d+)e(\d+)/i) || messageText.match(/season\s+(\d+)[^\d]+episode\s+(\d+)/i); if (sxe) { // We will append a brief hint to the user question to scope, but still pass series context messageText = `${messageText} (about Season ${sxe[1]}, Episode ${sxe[2]})`; } } else if ('showTitle' in (context as any)) { 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); episode = 1; } if (season && episode) { try { // Resolve TMDB id for the show let tmdbNumericId: number | null = null; if (/^\d+$/.test(contentId)) { tmdbNumericId = parseInt(contentId, 10); } else { tmdbNumericId = await tmdbService.findTMDBIdByIMDB(contentId); if (!tmdbNumericId && episodeId) { tmdbNumericId = await tmdbService.extractTMDBIdFromStremioId(episodeId); } } if (tmdbNumericId) { const [showData, episodeData] = await Promise.all([ tmdbService.getTVShowDetails(tmdbNumericId), tmdbService.getEpisodeDetails(tmdbNumericId, season, episode) ]); if (showData && episodeData) { requestContext = createEpisodeContext(episodeData, showData, season, episode); } } } catch { } } } const response = await aiService.sendMessage( messageText.trim(), requestContext, messages ); const assistantMessage: ChatMessage = { id: (Date.now() + 1).toString(), role: 'assistant', content: response, timestamp: Date.now(), }; 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')) { errorMessage = 'Please configure your OpenRouter API key in Settings > AI Assistant.'; } else if (error.message.includes('API request failed')) { errorMessage = 'Failed to connect to AI service. Please check your internet connection and API key.'; } } const errorResponse: ChatMessage = { id: (Date.now() + 1).toString(), role: 'assistant', content: errorMessage, timestamp: Date.now(), }; setMessages(prev => [...prev, errorResponse]); } finally { setIsLoading(false); } }, [context, messages, isLoading]); const handleSendPress = useCallback(() => { sendMessage(inputText); }, [inputText, sendMessage]); const handleSuggestionPress = useCallback((suggestion: string) => { sendMessage(suggestion); }, [sendMessage]); const scrollToBottom = useCallback(() => { setTimeout(() => { scrollViewRef.current?.scrollToEnd({ animated: true }); }, 100); }, []); useEffect(() => { if (messages.length > 0) { scrollToBottom(); } }, [messages, scrollToBottom]); const getDisplayTitle = () => { if (!context) return title; if ('episodesBySeason' in (context as any)) { // Always show just the series title return (context as any).title; } else if ('showTitle' in (context as any)) { // For episode contexts, now also only show show title to avoid episode in title per requirement return (context as any).showTitle; } return ('title' in (context as any) && (context as any).title) ? (context as any).title : title; }; const headerAnimatedStyle = useAnimatedStyle(() => ({ opacity: headerOpacity.value, })); const inputAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateY: inputContainerY.value }], })); if (isLoadingContext) { return ( Loading AI context... ); } return ( {backdropUrl && ( {Platform.OS === 'android' && AndroidBlurView ? : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable ? : } )} {/* Header */} { if (Platform.OS === 'android') { modalOpacity.value = withSpring(0, { damping: 18, stiffness: 160 }, (finished) => { if (finished) runOnJS(navigation.goBack)(); }); } else { navigation.goBack(); } }} style={styles.backButton} > AI Chat {getDisplayTitle()} {/* Chat Messages */} {messages.length === 0 && suggestions.length > 0 && ( Ask me anything about {getDisplayTitle()} I have detailed knowledge about this content and can answer questions about plot, characters, themes, and more. Try asking: {suggestions.map((suggestion, index) => ( handleSuggestionPress(suggestion)} index={index} /> ))} )} {messages.map((message, index) => ( ))} {isLoading && ( )} {/* Input Container */} {Platform.OS === 'android' && AndroidBlurView ? : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable ? : } {inputText.trim() ? ( ) : ( )} setAlertVisible(false)} actions={alertActions} /> ); }; const styles = StyleSheet.create({ container: { flex: 1, }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 20, }, loadingText: { fontSize: 15, fontWeight: '500', letterSpacing: 0.3, textAlign: 'center', }, header: { paddingHorizontal: 20, paddingBottom: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: 'rgba(255,255,255,0.08)', }, headerContent: { flexDirection: 'row', alignItems: 'center', gap: 14, }, backButton: { padding: 10, marginLeft: -6, borderRadius: 12, }, headerInfo: { flex: 1, }, headerTitle: { fontSize: 22, fontWeight: '700', letterSpacing: -0.3, }, headerSubtitle: { fontSize: 14, fontWeight: '500', marginTop: 3, opacity: 0.7, letterSpacing: 0.1, }, aiIndicator: { width: 44, height: 44, borderRadius: 22, justifyContent: 'center', alignItems: 'center', }, chatContainer: { flex: 1, }, messagesContainer: { flex: 1, }, messagesContent: { padding: 20, paddingBottom: 12, }, welcomeContainer: { alignItems: 'center', paddingVertical: 40, paddingHorizontal: 28, }, welcomeIcon: { width: 72, height: 72, borderRadius: 36, justifyContent: 'center', alignItems: 'center', marginBottom: 20, }, welcomeTitle: { fontSize: 18, fontWeight: '500', textAlign: 'center', letterSpacing: 0.2, opacity: 0.85, }, welcomeSubtitle: { fontSize: 24, fontWeight: '700', textAlign: 'center', marginTop: 6, marginBottom: 16, letterSpacing: -0.4, }, welcomeDescription: { fontSize: 15, lineHeight: 23, textAlign: 'center', marginBottom: 36, opacity: 0.7, letterSpacing: 0.15, maxWidth: 320, }, suggestionsContainer: { width: '100%', }, suggestionsTitle: { fontSize: 13, fontWeight: '600', marginBottom: 14, textAlign: 'center', textTransform: 'uppercase', letterSpacing: 1.2, opacity: 0.6, }, suggestionsGrid: { gap: 10, }, suggestionChip: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14, borderRadius: 14, marginBottom: 0, }, suggestionIcon: { marginRight: 10, }, suggestionText: { flex: 1, fontSize: 14.5, fontWeight: '500', letterSpacing: 0.1, }, messageContainer: { marginBottom: 20, flexDirection: 'row', alignItems: 'flex-end', }, userMessageContainer: { justifyContent: 'flex-end', }, assistantMessageContainer: { justifyContent: 'flex-start', }, lastMessageContainer: { marginBottom: 12, }, avatarWrapper: { marginRight: 10, }, avatarContainer: { width: 30, height: 30, borderRadius: 15, justifyContent: 'center', alignItems: 'center', }, userAvatarContainer: { width: 30, height: 30, borderRadius: 15, justifyContent: 'center', alignItems: 'center', marginLeft: 10, }, messageBubble: { maxWidth: width * 0.78, paddingHorizontal: 18, paddingVertical: 14, borderRadius: 22, overflow: 'hidden', }, userBubble: { borderBottomRightRadius: 6, }, assistantBubble: { borderBottomLeftRadius: 6, }, assistantBlurBackdrop: { ...StyleSheet.absoluteFillObject, borderRadius: 22, }, messageText: { fontSize: 15.5, lineHeight: 23, letterSpacing: 0.15, }, userMessageText: { color: 'white', fontWeight: '400', }, messageTime: { fontSize: 11, marginTop: 8, fontWeight: '500', letterSpacing: 0.3, }, typingIndicator: { flexDirection: 'row', alignItems: 'flex-end', marginBottom: 20, }, typingBubble: { paddingHorizontal: 18, paddingVertical: 14, borderRadius: 22, borderBottomLeftRadius: 6, marginLeft: 40, }, typingDots: { flexDirection: 'row', gap: 6, alignItems: 'center', }, typingDot: { width: 7, height: 7, borderRadius: 3.5, }, inputContainer: { paddingHorizontal: 20, paddingVertical: 14, paddingBottom: 18, }, inputWrapper: { flexDirection: 'row', alignItems: 'flex-end', borderRadius: 26, paddingHorizontal: 18, paddingVertical: 10, gap: 14, overflow: 'hidden', borderWidth: 1, borderColor: 'rgba(255,255,255,0.06)', }, inputBlurBackdrop: { ...StyleSheet.absoluteFillObject, borderRadius: 26, }, textInput: { flex: 1, fontSize: 16, lineHeight: 23, maxHeight: 120, paddingVertical: 10, letterSpacing: 0.15, }, sendButtonWrapper: { marginLeft: 2, }, sendButton: { width: 44, height: 44, borderRadius: 22, justifyContent: 'center', alignItems: 'center', }, }); export default AIChatScreen;