diff --git a/app.json b/app.json index 21407fc2..d8331163 100644 --- a/app.json +++ b/app.json @@ -82,7 +82,7 @@ ], "updates": { "enabled": true, - "checkAutomatically": "ON_ERROR", + "checkAutomatically": "ON_LOAD", "fallbackToCacheTimeout": 30000, "url": "https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest" }, diff --git a/src/screens/AIChatScreen.tsx b/src/screens/AIChatScreen.tsx index 9a4db361..953ae822 100644 --- a/src/screens/AIChatScreen.tsx +++ b/src/screens/AIChatScreen.tsx @@ -6,22 +6,31 @@ import { TextInput, TouchableOpacity, ScrollView, - SafeAreaView, StatusBar, KeyboardAvoidingView, Platform, Dimensions, ActivityIndicator, Alert, + Keyboard, } from 'react-native'; import { useRoute, useNavigation, RouteProp, useFocusEffect } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../contexts/ThemeContext'; import { Image } from 'expo-image'; import { BlurView as ExpoBlurView } from 'expo-blur'; -import { BlurView as CommunityBlurView } from '@react-native-community/blur'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { aiService, ChatMessage, ContentContext, createMovieContext, createEpisodeContext, generateConversationStarters } from '../services/aiService'; +// 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; + } +} +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, { @@ -112,9 +121,9 @@ const ChatBubble: React.FC = ({ message, isLast }) => { ]}> {!isUser && ( - {Platform.OS === 'ios' - ? - : } + {Platform.OS === 'android' && AndroidBlurView + ? + : } )} @@ -294,6 +303,7 @@ const AIChatScreen: React.FC = () => { 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( @@ -319,6 +329,21 @@ const AIChatScreen: React.FC = () => { loadContext(); }, []); + // Track keyboard to adjust bottom padding only when hidden on iOS + useEffect(() => { + const showSub = Platform.OS === 'ios' + ? Keyboard.addListener('keyboardWillShow', () => setIsKeyboardVisible(true)) + : Keyboard.addListener('keyboardDidShow', () => setIsKeyboardVisible(true)); + const hideSub = Platform.OS === 'ios' + ? Keyboard.addListener('keyboardWillHide', () => setIsKeyboardVisible(false)) + : Keyboard.addListener('keyboardDidHide', () => setIsKeyboardVisible(false)); + + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + // Animate in on Android for full-screen modal feel useEffect(() => { if (Platform.OS === 'android') { @@ -373,11 +398,9 @@ const AIChatScreen: React.FC = () => { if (!tmdbNumericId) throw new Error('Unable to resolve TMDB ID for series'); - const [showData, episodeData] = await Promise.all([ + const [showData, allEpisodes] = await Promise.all([ tmdbService.getTVShowDetails(tmdbNumericId), - episodeId && seasonNumber && episodeNumber ? - tmdbService.getEpisodeDetails(tmdbNumericId, seasonNumber, episodeNumber) : - null + tmdbService.getAllEpisodes(tmdbNumericId) ]); if (!showData) throw new Error('Unable to load TV show details'); @@ -386,35 +409,9 @@ const AIChatScreen: React.FC = () => { if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`); } catch {} - if (episodeData && seasonNumber && episodeNumber) { - const episodeContext = createEpisodeContext( - episodeData, - showData, - seasonNumber, - episodeNumber - ); - setContext(episodeContext); - } else { - // Fallback: synthesize a show-level episode-like context so AI treats it as a series - const syntheticEpisode: any = { - id: `${showData?.id ?? ''}-overview`, - name: 'Series Overview', - overview: showData?.overview ?? '', - air_date: showData?.first_air_date ?? '', - runtime: undefined, - credits: { - guest_stars: [], - crew: [], - }, - }; - const episodeContext = createEpisodeContext( - syntheticEpisode, - showData, - 0, - 0 - ); - setContext(episodeContext); - } + 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); @@ -442,7 +439,14 @@ const AIChatScreen: React.FC = () => { try { // If series overview is loaded, parse user query for specific episode and fetch on-demand let requestContext = context; - if ('showTitle' in 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); @@ -543,15 +547,14 @@ const AIChatScreen: React.FC = () => { const getDisplayTitle = () => { if (!context) return title; - if ('showTitle' in context) { - const ep = context as any; - // For series overview (synthetic S0E0), show just the show title - if (ep.seasonNumber === 0 && ep.episodeNumber === 0) { - return ep.showTitle; - } - return `${ep.showTitle} S${ep.seasonNumber}E${ep.episodeNumber}`; + 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 context.title || title; + return ('title' in (context as any) && (context as any).title) ? (context as any).title : title; }; const headerAnimatedStyle = useAnimatedStyle(() => ({ @@ -576,7 +579,7 @@ const AIChatScreen: React.FC = () => { return ( - + {backdropUrl && ( { contentFit="cover" recyclingKey={backdropUrl || undefined} /> - {Platform.OS === 'ios' - ? - : } + {Platform.OS === 'android' && AndroidBlurView + ? + : } )} @@ -644,7 +647,7 @@ const AIChatScreen: React.FC = () => { style={styles.messagesContainer} contentContainerStyle={[ styles.messagesContent, - { paddingBottom: 120 + insets.bottom } + { paddingBottom: ((Platform.OS === 'ios' ? (isKeyboardVisible ? 120 : 190) : 120) + insets.bottom) } ]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" @@ -703,19 +706,20 @@ const AIChatScreen: React.FC = () => { {/* Input Container */} + - {Platform.OS === 'ios' - ? - : } + {Platform.OS === 'android' && AndroidBlurView + ? + : } { + diff --git a/src/services/aiService.ts b/src/services/aiService.ts index b1553861..ff66249e 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -52,7 +52,34 @@ export interface EpisodeContext { }>; } -export type ContentContext = MovieContext | EpisodeContext; +export interface SeriesContext { + id: string; + title: string; + overview: string; + firstAirDate: string; + lastAirDate?: string; + totalSeasons: number; + totalEpisodes: number; + genres: string[]; + cast: Array<{ + name: string; + character: string; + }>; + crew: Array<{ + name: string; + job: string; + }>; + episodesBySeason: Record>; +} + +export type ContentContext = MovieContext | EpisodeContext | SeriesContext; interface OpenRouterResponse { choices: Array<{ @@ -101,9 +128,58 @@ class AIService { } private createSystemPrompt(context: ContentContext): string { - const isEpisode = 'showTitle' in context; + const isSeries = 'episodesBySeason' in (context as any); + const isEpisode = !isSeries && 'showTitle' in (context as any); - if (isEpisode) { + if (isSeries) { + const series = context as SeriesContext; + const currentDate = new Date().toISOString().split('T')[0]; + const seasonsSummary = Object.keys(series.episodesBySeason) + .sort((a, b) => Number(a) - Number(b)) + .map(sn => { + const episodes = series.episodesBySeason[Number(sn)] || []; + const releasedCount = episodes.filter(e => e.released).length; + return `- Season ${sn}: ${episodes.length} episodes (${releasedCount} released)`; + }) + .join('\n'); + return `You are an AI assistant with access to current, up-to-date information about the TV series "${series.title}" across ALL seasons and episodes. + +CRITICAL: Today's date is ${currentDate}. Use ONLY the verified information provided below from our database. IGNORE any conflicting information from your training data which may be outdated. + +VERIFIED CURRENT SERIES INFORMATION FROM DATABASE: +- Title: ${series.title} +- First Air Date: ${series.firstAirDate || 'Unknown'} +- Last Air Date: ${series.lastAirDate || 'Unknown'} +- Seasons: ${series.totalSeasons} +- Episodes: ${series.totalEpisodes} +- Genres: ${series.genres.join(', ') || 'Unknown'} +- Synopsis: ${series.overview || 'No synopsis available'} + +Cast: +${series.cast.map(c => `- ${c.name} as ${c.character}`).join('\n')} + +Crew: +${series.crew.map(c => `- ${c.name} (${c.job})`).join('\n')} + +Seasons & Episode Counts: +${seasonsSummary} + +CRITICAL INSTRUCTIONS: +1. Never provide spoilers under any circumstances. Keep responses spoiler-safe. +2. The information above is from our verified database and is more current than your training data. +3. You can answer questions about ANY episode or season in the series. If dates indicate unreleased episodes, do not reveal plot details and clearly state they are unreleased. +4. Compare air dates to today's date (${currentDate}) to determine if an episode has already aired. +5. Base ALL responses on the verified information above, NOT on your training knowledge. +6. If asked about release dates or availability of episodes, refer ONLY to the database information provided. + +FORMATTING RULES (use Markdown): +- Use short paragraphs separated by blank lines. +- Use clear headings (## or ###) when helpful. +- Use bullet lists for points and steps. +- Add a blank line before and after lists and headings. +- Keep lines concise; avoid giant unbroken blocks of text. +- Wrap inline code/terms with backticks only when appropriate.`; + } else if (isEpisode) { const ep = context as EpisodeContext; const currentDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format return `You are an AI assistant with access to current, up-to-date information about "${ep.showTitle}" Season ${ep.seasonNumber}, Episode ${ep.episodeNumber}: "${ep.episodeTitle}". @@ -407,11 +483,83 @@ Answer questions about this movie using only the verified database information a }; } + // Helper to create a series-wide context including all episodes + static createSeriesContext(showData: any, episodesBySeason: Record): SeriesContext { + // Build flattened cast/crew from show credits + const cast = showData.credits?.cast?.slice(0, 12).map((c: any) => ({ + name: c.name, + character: c.character + })) || []; + const crew = showData.credits?.crew?.slice(0, 8).map((c: any) => ({ + name: c.name, + job: c.job + })) || []; + + // Normalize episodes map + const normalized: SeriesContext['episodesBySeason'] = {}; + Object.keys(episodesBySeason || {}).forEach(k => { + const seasonNum = Number(k); + normalized[seasonNum] = (episodesBySeason[seasonNum] || []).map((ep: any) => { + const airDate: string = ep.air_date || ''; + let released = false; + try { + if (airDate) { + const parsed = new Date(airDate); + if (!isNaN(parsed.getTime())) released = parsed.getTime() <= Date.now(); + } + } catch {} + if (!released) { + const hasOverview = typeof ep.overview === 'string' && ep.overview.trim().length > 40; + const hasRuntime = typeof ep.runtime === 'number' && ep.runtime > 0; + const hasVotes = typeof ep.vote_average === 'number' && ep.vote_average > 0; + if (hasOverview || hasRuntime || hasVotes) released = true; + } + return { + seasonNumber: ep.season_number ?? seasonNum, + episodeNumber: ep.episode_number, + title: ep.name || `Episode ${ep.episode_number}`, + airDate, + released, + overview: ep.overview || '' + }; + }); + }); + + const totalSeasons = Array.isArray(showData.seasons) + ? showData.seasons.filter((s: any) => s.season_number > 0).length + : Object.keys(normalized).length; + const totalEpisodes = Object.values(normalized).reduce((sum, eps) => sum + (eps?.length || 0), 0); + + return { + id: showData.id?.toString() || '', + title: showData.name || showData.title || '', + overview: showData.overview || '', + firstAirDate: showData.first_air_date || '', + lastAirDate: showData.last_air_date || '', + totalSeasons, + totalEpisodes, + genres: showData.genres?.map((g: any) => g.name) || [], + cast, + crew, + episodesBySeason: normalized, + }; + } + // Generate conversation starter suggestions static generateConversationStarters(context: ContentContext): string[] { - const isEpisode = 'showTitle' in context; - - if (isEpisode) { + const isSeries = 'episodesBySeason' in (context as any); + const isEpisode = !isSeries && 'showTitle' in (context as any); + + if (isSeries) { + const series = context as SeriesContext; + return [ + `What is ${series.title} about overall?`, + `Summarize key arcs across all seasons`, + `Which episodes are the highest rated and why?`, + `List pivotal episodes for character development`, + `How did themes evolve from Season 1 onward?` + ]; + } else if (isEpisode) { const ep = context as EpisodeContext; return [ `What happened in this episode of ${ep.showTitle}?`, @@ -439,3 +587,4 @@ export const aiService = AIService.getInstance(); export const createMovieContext = AIService.createMovieContext; export const createEpisodeContext = AIService.createEpisodeContext; export const generateConversationStarters = AIService.generateConversationStarters; +export const createSeriesContext = AIService.createSeriesContext;