AI initial commit

This commit is contained in:
tapframe 2025-09-11 16:32:25 +05:30
parent 0a853e7460
commit e0ca614893
9 changed files with 1763 additions and 3 deletions

View file

@ -33,6 +33,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Sentry from '@sentry/react-native';
import UpdateService from './src/services/updateService';
import { memoryMonitorService } from './src/services/memoryMonitorService';
import { aiService } from './src/services/aiService';
Sentry.init({
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
@ -97,6 +98,10 @@ const ThemedApp = () => {
memoryMonitorService; // Just accessing it starts the monitoring
console.log('Memory monitoring service initialized');
// Initialize AI service
await aiService.initialize();
console.log('AI service initialized');
} catch (error) {
console.error('Error initializing app:', error);
// Default to showing onboarding if we can't check

@ -1 +1 @@
Subproject commit 5c3e2d8bccf7d076e392fdc397233f392e2a1563
Subproject commit 5c020cca433f0400e23eb553f3e4de09f65b66d3

View file

@ -94,7 +94,8 @@ const ActionButtons = memo(({
animatedStyle,
isWatched,
watchProgress,
groupedEpisodes
groupedEpisodes,
metadata
}: {
handleShowStreams: () => void;
toggleLibrary: () => void;
@ -107,6 +108,7 @@ const ActionButtons = memo(({
isWatched: boolean;
watchProgress: any;
groupedEpisodes?: { [seasonNumber: number]: any[] };
metadata: any;
}) => {
const { currentTheme } = useTheme();
@ -287,6 +289,45 @@ const ActionButtons = memo(({
</Text>
</TouchableOpacity>
{/* AI Chat Button */}
<TouchableOpacity
style={[styles.iconButton, isTablet && styles.tabletIconButton]}
onPress={() => {
// Extract episode info if it's a series
let episodeData = null;
if (type === 'series' && watchProgress?.episodeId) {
const parts = watchProgress.episodeId.split(':');
if (parts.length >= 3) {
episodeData = {
seasonNumber: parseInt(parts[1], 10),
episodeNumber: parseInt(parts[2], 10)
};
}
}
navigation.navigate('AIChat', {
contentId: id,
contentType: type,
episodeId: episodeData ? watchProgress.episodeId : undefined,
seasonNumber: episodeData?.seasonNumber,
episodeNumber: episodeData?.episodeNumber,
title: metadata?.name || metadata?.title || 'Unknown'
});
}}
activeOpacity={0.85}
>
{Platform.OS === 'ios' ? (
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
) : (
<View style={styles.androidFallbackBlurRound} />
)}
<MaterialIcons
name="smart-toy"
size={isTablet ? 28 : 24}
color={currentTheme.colors.white}
/>
</TouchableOpacity>
{type === 'series' && (
<TouchableOpacity
style={[styles.iconButton, isTablet && styles.tabletIconButton]}
@ -1423,6 +1464,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
isWatched={isWatched}
watchProgress={watchProgress}
groupedEpisodes={groupedEpisodes}
metadata={metadata}
/>
</View>
</LinearGradient>

View file

@ -48,6 +48,8 @@ import { LoadingProvider, useLoading } from '../contexts/LoadingContext';
import PluginsScreen from '../screens/PluginsScreen';
import CastMoviesScreen from '../screens/CastMoviesScreen';
import UpdateScreen from '../screens/UpdateScreen';
import AISettingsScreen from '../screens/AISettingsScreen';
import AIChatScreen from '../screens/AIChatScreen';
// Stack navigator types
export type RootStackParamList = {
@ -127,6 +129,15 @@ export type RootStackParamList = {
character?: string;
};
};
AISettings: undefined;
AIChat: {
contentId: string;
contentType: 'movie' | 'series';
episodeId?: string;
seasonNumber?: number;
episodeNumber?: number;
title: string;
};
};
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
@ -1224,6 +1235,36 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
},
}}
/>
<Stack.Screen
name="AISettings"
component={AISettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="AIChat"
component={AIChatScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_bottom' : 'slide_from_bottom',
animationDuration: Platform.OS === 'android' ? 250 : 300,
presentation: 'modal',
gestureEnabled: true,
gestureDirection: 'vertical',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
</Stack.Navigator>
</View>
</PaperProvider>

View file

@ -0,0 +1,773 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
ScrollView,
SafeAreaView,
StatusBar,
KeyboardAvoidingView,
Platform,
Dimensions,
ActivityIndicator,
Alert,
} from 'react-native';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { aiService, ChatMessage, ContentContext, createMovieContext, createEpisodeContext, generateConversationStarters } from '../services/aiService';
import { tmdbService } from '../services/tmdbService';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
interpolate,
Extrapolate
} 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<AIChatRouteParams, 'AIChat'>;
interface ChatBubbleProps {
message: ChatMessage;
isLast: boolean;
}
const ChatBubble: React.FC<ChatBubbleProps> = ({ 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: [
{
translateY: interpolate(
bubbleAnimation.value,
[0, 1],
[20, 0],
Extrapolate.CLAMP
)
},
{
scale: interpolate(
bubbleAnimation.value,
[0, 1],
[0.8, 1],
Extrapolate.CLAMP
)
}
]
}));
return (
<Animated.View style={[
styles.messageContainer,
isUser ? styles.userMessageContainer : styles.assistantMessageContainer,
isLast && styles.lastMessageContainer,
animatedStyle
]}>
{!isUser && (
<View style={[styles.avatarContainer, { backgroundColor: currentTheme.colors.primary }]}>
<MaterialIcons name="smart-toy" size={16} color="white" />
</View>
)}
<View style={[
styles.messageBubble,
isUser ? [
styles.userBubble,
{ backgroundColor: currentTheme.colors.primary }
] : [
styles.assistantBubble,
{ backgroundColor: currentTheme.colors.elevation2 }
]
]}>
<Text style={[
styles.messageText,
{ color: isUser ? 'white' : currentTheme.colors.highEmphasis }
]}>
{message.content}
</Text>
<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'
})}
</Text>
</View>
{isUser && (
<View style={[styles.userAvatarContainer, { backgroundColor: currentTheme.colors.elevation2 }]}>
<MaterialIcons name="person" size={16} color={currentTheme.colors.primary} />
</View>
)}
</Animated.View>
);
};
interface SuggestionChipProps {
text: string;
onPress: () => void;
}
const SuggestionChip: React.FC<SuggestionChipProps> = ({ text, onPress }) => {
const { currentTheme } = useTheme();
return (
<TouchableOpacity
style={[styles.suggestionChip, { backgroundColor: currentTheme.colors.elevation1 }]}
onPress={onPress}
activeOpacity={0.7}
>
<Text style={[styles.suggestionText, { color: currentTheme.colors.primary }]}>
{text}
</Text>
</TouchableOpacity>
);
};
const AIChatScreen: React.FC = () => {
const route = useRoute<AIChatScreenRouteProp>();
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);
const [context, setContext] = useState<ContentContext | null>(null);
const [isLoadingContext, setIsLoadingContext] = useState(true);
const [suggestions, setSuggestions] = useState<string[]>([]);
const scrollViewRef = useRef<ScrollView>(null);
const inputRef = useRef<TextInput>(null);
// Animation values
const headerOpacity = useSharedValue(1);
const inputContainerY = useSharedValue(0);
useEffect(() => {
loadContext();
}, []);
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);
} 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, episodeData] = await Promise.all([
tmdbService.getTVShowDetails(tmdbNumericId),
episodeId && seasonNumber && episodeNumber ?
tmdbService.getEpisodeDetails(tmdbNumericId, seasonNumber, episodeNumber) :
null
]);
if (!showData) throw new Error('Unable to load TV show details');
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);
}
}
} catch (error) {
if (__DEV__) console.error('Error loading context:', error);
Alert.alert('Error', 'Failed to load content details for AI chat');
} finally {
setIsLoadingContext(false);
}
};
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 ('showTitle' in context) {
const sxe = messageText.match(/s(\d+)e(\d+)/i);
const words = messageText.match(/season\s+(\d+)[^\d]+episode\s+(\d+)/i);
const season = sxe ? parseInt(sxe[1], 10) : (words ? parseInt(words[1], 10) : undefined);
const episode = sxe ? parseInt(sxe[2], 10) : (words ? parseInt(words[2], 10) : undefined);
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 ('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}`;
}
return context.title || title;
};
const headerAnimatedStyle = useAnimatedStyle(() => ({
opacity: headerOpacity.value,
}));
const inputAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: inputContainerY.value }],
}));
if (isLoadingContext) {
return (
<View style={[styles.loadingContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
Loading AI context...
</Text>
</View>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
{/* Header */}
<Animated.View style={[
styles.header,
{
backgroundColor: currentTheme.colors.darkBackground,
paddingTop: insets.top
},
headerAnimatedStyle
]}>
<View style={styles.headerContent}>
<TouchableOpacity
onPress={() => 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>
</View>
<View style={[styles.aiIndicator, { backgroundColor: currentTheme.colors.primary }]}>
<MaterialIcons name="smart-toy" size={20} color="white" />
</View>
</View>
</Animated.View>
{/* Chat Messages */}
<KeyboardAvoidingView
style={styles.chatContainer}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={insets.top + 60}
>
<ScrollView
ref={scrollViewRef}
style={styles.messagesContainer}
contentContainerStyle={styles.messagesContent}
showsVerticalScrollIndicator={false}
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 */}
<Animated.View style={[
styles.inputContainer,
{ backgroundColor: currentTheme.colors.darkBackground },
inputAnimatedStyle
]}>
<View style={[styles.inputWrapper, { backgroundColor: currentTheme.colors.elevation1 }]}>
<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>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: 16,
},
loadingText: {
fontSize: 16,
textAlign: 'center',
},
header: {
paddingHorizontal: 16,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
backButton: {
padding: 8,
},
headerInfo: {
flex: 1,
},
headerTitle: {
fontSize: 20,
fontWeight: '700',
},
headerSubtitle: {
fontSize: 14,
marginTop: 2,
},
aiIndicator: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
chatContainer: {
flex: 1,
},
messagesContainer: {
flex: 1,
},
messagesContent: {
padding: 16,
paddingBottom: 8,
},
welcomeContainer: {
alignItems: 'center',
paddingVertical: 32,
paddingHorizontal: 24,
},
welcomeIcon: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
welcomeTitle: {
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
},
welcomeSubtitle: {
fontSize: 22,
fontWeight: '700',
textAlign: 'center',
marginTop: 4,
marginBottom: 12,
},
welcomeDescription: {
fontSize: 16,
lineHeight: 24,
textAlign: 'center',
marginBottom: 32,
},
suggestionsContainer: {
width: '100%',
},
suggestionsTitle: {
fontSize: 14,
fontWeight: '600',
marginBottom: 12,
textAlign: 'center',
},
suggestionsGrid: {
gap: 8,
},
suggestionChip: {
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 20,
alignSelf: 'flex-start',
marginBottom: 8,
},
suggestionText: {
fontSize: 15,
fontWeight: '500',
},
messageContainer: {
marginBottom: 16,
flexDirection: 'row',
alignItems: 'flex-end',
},
userMessageContainer: {
justifyContent: 'flex-end',
},
assistantMessageContainer: {
justifyContent: 'flex-start',
},
lastMessageContainer: {
marginBottom: 8,
},
avatarContainer: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
marginRight: 8,
},
userAvatarContainer: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
marginLeft: 8,
},
messageBubble: {
maxWidth: width * 0.75,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 20,
},
userBubble: {
borderBottomRightRadius: 4,
},
assistantBubble: {
borderBottomLeftRadius: 4,
},
messageText: {
fontSize: 16,
lineHeight: 22,
},
messageTime: {
fontSize: 12,
marginTop: 4,
opacity: 0.8,
},
typingIndicator: {
flexDirection: 'row',
alignItems: 'flex-end',
marginBottom: 16,
},
typingBubble: {
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 20,
borderBottomLeftRadius: 4,
marginLeft: 40,
},
typingDots: {
flexDirection: 'row',
gap: 4,
},
typingDot: {
width: 8,
height: 8,
borderRadius: 4,
},
inputContainer: {
paddingHorizontal: 16,
paddingVertical: 12,
paddingBottom: 16,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'flex-end',
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 8,
gap: 12,
},
textInput: {
flex: 1,
fontSize: 16,
lineHeight: 22,
maxHeight: 100,
paddingVertical: 8,
},
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
});
export default AIChatScreen;

View file

@ -0,0 +1,500 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
SafeAreaView,
StatusBar,
TextInput,
TouchableOpacity,
Alert,
Linking,
Platform,
Dimensions,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { width } = Dimensions.get('window');
const isTablet = width >= 768;
const AISettingsScreen: React.FC = () => {
const navigation = useNavigation();
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const [apiKey, setApiKey] = useState('');
const [loading, setLoading] = useState(false);
const [isKeySet, setIsKeySet] = useState(false);
useEffect(() => {
loadApiKey();
}, []);
const loadApiKey = async () => {
try {
const savedKey = await AsyncStorage.getItem('openrouter_api_key');
if (savedKey) {
setApiKey(savedKey);
setIsKeySet(true);
}
} catch (error) {
if (__DEV__) console.error('Error loading OpenRouter API key:', error);
}
};
const handleSaveApiKey = async () => {
if (!apiKey.trim()) {
Alert.alert('Error', 'Please enter a valid API key');
return;
}
if (!apiKey.startsWith('sk-or-')) {
Alert.alert('Error', 'OpenRouter API keys should start with "sk-or-"');
return;
}
setLoading(true);
try {
await AsyncStorage.setItem('openrouter_api_key', apiKey.trim());
setIsKeySet(true);
Alert.alert('Success', 'OpenRouter API key saved successfully!');
} catch (error) {
Alert.alert('Error', 'Failed to save API key');
if (__DEV__) console.error('Error saving OpenRouter API key:', error);
} finally {
setLoading(false);
}
};
const handleRemoveApiKey = () => {
Alert.alert(
'Remove API Key',
'Are you sure you want to remove your OpenRouter API key? This will disable AI chat features.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
try {
await AsyncStorage.removeItem('openrouter_api_key');
setApiKey('');
setIsKeySet(false);
Alert.alert('Success', 'API key removed successfully');
} catch (error) {
Alert.alert('Error', 'Failed to remove API key');
}
}
}
]
);
};
const handleGetApiKey = () => {
Linking.openURL('https://openrouter.ai/keys');
};
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
const headerHeight = headerBaseHeight + topSpacing;
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
{/* Header */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<View style={styles.headerContent}>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
>
<MaterialIcons
name="arrow-back"
size={24}
color={currentTheme.colors.text}
/>
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
AI Assistant
</Text>
</View>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* Info Card */}
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.infoHeader}>
<MaterialIcons
name="smart-toy"
size={24}
color={currentTheme.colors.primary}
/>
<Text style={[styles.infoTitle, { color: currentTheme.colors.highEmphasis }]}>
AI-Powered Chat
</Text>
</View>
<Text style={[styles.infoDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Ask questions about any movie or TV show episode using advanced AI. Get insights about plot, characters, themes, trivia, and more - all powered by comprehensive TMDB data.
</Text>
<View style={styles.featureList}>
<View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
Episode-specific context and analysis
</Text>
</View>
<View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
Plot explanations and character insights
</Text>
</View>
<View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
Behind-the-scenes trivia and facts
</Text>
</View>
<View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
Your own free OpenRouter API key
</Text>
</View>
</View>
</View>
{/* API Key Configuration */}
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
OPENROUTER API KEY
</Text>
<View style={styles.apiKeySection}>
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
API Key
</Text>
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
Enter your OpenRouter API key to enable AI chat features
</Text>
<TextInput
style={[
styles.input,
{
backgroundColor: currentTheme.colors.elevation2,
color: currentTheme.colors.highEmphasis,
borderColor: currentTheme.colors.elevation2
}
]}
value={apiKey}
onChangeText={setApiKey}
placeholder="sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxx"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
secureTextEntry={true}
autoCapitalize="none"
autoCorrect={false}
/>
<View style={styles.buttonContainer}>
{!isKeySet ? (
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleSaveApiKey}
disabled={loading}
>
<MaterialIcons
name="save"
size={20}
color={currentTheme.colors.white}
style={{ marginRight: 8 }}
/>
<Text style={styles.saveButtonText}>
{loading ? 'Saving...' : 'Save API Key'}
</Text>
</TouchableOpacity>
) : (
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.updateButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleSaveApiKey}
disabled={loading}
>
<MaterialIcons
name="update"
size={20}
color={currentTheme.colors.white}
style={{ marginRight: 8 }}
/>
<Text style={styles.updateButtonText}>Update</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.removeButton, { borderColor: currentTheme.colors.error }]}
onPress={handleRemoveApiKey}
>
<MaterialIcons
name="delete"
size={20}
color={currentTheme.colors.error}
style={{ marginRight: 8 }}
/>
<Text style={[styles.removeButtonText, { color: currentTheme.colors.error }]}>
Remove
</Text>
</TouchableOpacity>
</View>
)}
</View>
<TouchableOpacity
style={[styles.getKeyButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={handleGetApiKey}
>
<MaterialIcons
name="open-in-new"
size={20}
color={currentTheme.colors.primary}
style={{ marginRight: 8 }}
/>
<Text style={[styles.getKeyButtonText, { color: currentTheme.colors.primary }]}>
Get Free API Key from OpenRouter
</Text>
</TouchableOpacity>
</View>
</View>
{/* Status Card */}
{isKeySet && (
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.statusHeader}>
<MaterialIcons
name="check-circle"
size={24}
color={currentTheme.colors.success || '#4CAF50'}
/>
<Text style={[styles.statusTitle, { color: currentTheme.colors.success || '#4CAF50' }]}>
AI Chat Enabled
</Text>
</View>
<Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}>
You can now ask questions about movies and TV shows. Look for the "Ask AI" button on content pages!
</Text>
</View>
)}
{/* Usage Info */}
<View style={[styles.usageCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.usageTitle, { color: currentTheme.colors.highEmphasis }]}>
How it works
</Text>
<Text style={[styles.usageText, { color: currentTheme.colors.mediumEmphasis }]}>
OpenRouter provides access to multiple AI models{'\n'}
Your API key stays private and secure{'\n'}
Free tier includes generous usage limits{'\n'}
Chat with context about specific episodes/movies{'\n'}
Get detailed analysis and explanations
</Text>
</View>
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
paddingHorizontal: Math.max(16, width * 0.05),
justifyContent: 'flex-end',
paddingBottom: 8,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
},
backButton: {
marginRight: 16,
padding: 8,
},
headerTitle: {
fontSize: Math.min(28, width * 0.07),
fontWeight: '800',
letterSpacing: 0.3,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: Math.max(16, width * 0.05),
paddingBottom: 40,
},
infoCard: {
borderRadius: 16,
padding: 20,
marginBottom: 20,
},
infoHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
infoTitle: {
fontSize: 20,
fontWeight: '700',
marginLeft: 12,
},
infoDescription: {
fontSize: 16,
lineHeight: 24,
marginBottom: 16,
},
featureList: {
gap: 8,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
},
featureText: {
fontSize: 15,
marginLeft: 8,
flex: 1,
},
card: {
borderRadius: 16,
padding: 20,
marginBottom: 20,
},
cardTitle: {
fontSize: 13,
fontWeight: '600',
letterSpacing: 0.8,
marginBottom: 16,
},
apiKeySection: {
gap: 12,
},
label: {
fontSize: 16,
fontWeight: '600',
},
description: {
fontSize: 14,
lineHeight: 20,
},
input: {
borderRadius: 12,
padding: 16,
fontSize: 16,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
borderWidth: 1,
},
buttonContainer: {
marginTop: 8,
},
buttonRow: {
flexDirection: 'row',
gap: 12,
},
saveButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: 12,
},
saveButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
updateButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: 12,
flex: 1,
},
updateButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
removeButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: 12,
borderWidth: 2,
flex: 1,
},
removeButtonText: {
fontSize: 16,
fontWeight: '600',
},
getKeyButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
marginTop: 8,
},
getKeyButtonText: {
fontSize: 15,
fontWeight: '500',
},
statusCard: {
borderRadius: 16,
padding: 20,
marginBottom: 20,
},
statusHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
statusTitle: {
fontSize: 18,
fontWeight: '600',
marginLeft: 12,
},
statusDescription: {
fontSize: 15,
lineHeight: 22,
},
usageCard: {
borderRadius: 16,
padding: 20,
},
usageTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 12,
},
usageText: {
fontSize: 15,
lineHeight: 24,
},
});
export default AISettingsScreen;

View file

@ -43,6 +43,7 @@ const SETTINGS_CATEGORIES = [
{ id: 'content', title: 'Content & Discovery', icon: 'explore' as keyof typeof MaterialIcons.glyphMap },
{ id: 'appearance', title: 'Appearance', icon: 'palette' as keyof typeof MaterialIcons.glyphMap },
{ id: 'integrations', title: 'Integrations', icon: 'extension' as keyof typeof MaterialIcons.glyphMap },
{ id: 'ai', title: 'AI Assistant', icon: 'smart-toy' as keyof typeof MaterialIcons.glyphMap },
{ id: 'playback', title: 'Playback', icon: 'play-circle-outline' as keyof typeof MaterialIcons.glyphMap },
{ id: 'updates', title: 'Updates', icon: 'system-update' as keyof typeof MaterialIcons.glyphMap },
{ id: 'about', title: 'About', icon: 'info-outline' as keyof typeof MaterialIcons.glyphMap },
@ -256,6 +257,7 @@ const SettingsScreen: React.FC = () => {
const [addonCount, setAddonCount] = useState<number>(0);
const [catalogCount, setCatalogCount] = useState<number>(0);
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
const [openRouterKeySet, setOpenRouterKeySet] = useState<boolean>(false);
const loadData = useCallback(async () => {
try {
@ -289,6 +291,10 @@ const SettingsScreen: React.FC = () => {
// Check MDBList API key status
const mdblistKey = await AsyncStorage.getItem('mdblist_api_key');
setMdblistKeySet(!!mdblistKey);
// Check OpenRouter API key status
const openRouterKey = await AsyncStorage.getItem('openrouter_api_key');
setOpenRouterKeySet(!!openRouterKey);
} catch (error) {
if (__DEV__) console.error('Error loading settings data:', error);
@ -526,6 +532,21 @@ const SettingsScreen: React.FC = () => {
</SettingsCard>
);
case 'ai':
return (
<SettingsCard title="AI ASSISTANT" isTablet={isTablet}>
<SettingItem
title="OpenRouter API"
description={openRouterKeySet ? "Connected" : "Add your API key to enable AI chat"}
icon="smart-toy"
renderControl={ChevronRight}
onPress={() => navigation.navigate('AISettings')}
isLast={true}
isTablet={isTablet}
/>
</SettingsCard>
);
case 'playback':
return (
<SettingsCard title="PLAYBACK" isTablet={isTablet}>
@ -765,6 +786,7 @@ const SettingsScreen: React.FC = () => {
{renderCategoryContent('content')}
{renderCategoryContent('appearance')}
{renderCategoryContent('integrations')}
{renderCategoryContent('ai')}
{renderCategoryContent('playback')}
{renderCategoryContent('updates')}
{renderCategoryContent('about')}

375
src/services/aiService.ts Normal file
View file

@ -0,0 +1,375 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}
export interface MovieContext {
id: string;
title: string;
overview: string;
releaseDate: string;
genres: string[];
cast: Array<{
name: string;
character: string;
}>;
crew: Array<{
name: string;
job: string;
}>;
runtime?: number;
tagline?: string;
keywords?: string[];
}
export interface EpisodeContext {
id: string;
showId: string;
showTitle: string;
episodeTitle: string;
seasonNumber: number;
episodeNumber: number;
overview: string;
airDate: string;
released: boolean;
runtime?: number;
cast: Array<{
name: string;
character: string;
}>;
crew: Array<{
name: string;
job: string;
}>;
guestStars?: Array<{
name: string;
character: string;
}>;
}
export type ContentContext = MovieContext | EpisodeContext;
interface OpenRouterResponse {
choices: Array<{
message: {
content: string;
role: string;
};
finish_reason: string;
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
class AIService {
private static instance: AIService;
private apiKey: string | null = null;
private baseUrl = 'https://openrouter.ai/api/v1';
private constructor() {}
static getInstance(): AIService {
if (!AIService.instance) {
AIService.instance = new AIService();
}
return AIService.instance;
}
async initialize(): Promise<boolean> {
try {
this.apiKey = await AsyncStorage.getItem('openrouter_api_key');
return !!this.apiKey;
} catch (error) {
if (__DEV__) console.error('Failed to initialize AI service:', error);
return false;
}
}
async isConfigured(): Promise<boolean> {
if (!this.apiKey) {
await this.initialize();
}
return !!this.apiKey;
}
private createSystemPrompt(context: ContentContext): string {
const isEpisode = 'showTitle' in context;
if (isEpisode) {
const ep = context as EpisodeContext;
return `You are an AI assistant specialized in TV shows and episodes. You have detailed knowledge about "${ep.showTitle}" Season ${ep.seasonNumber}, Episode ${ep.episodeNumber}: "${ep.episodeTitle}".
Episode Details:
- Show: ${ep.showTitle}
- Episode: S${ep.seasonNumber}E${ep.episodeNumber} - "${ep.episodeTitle}"
- Air Date: ${ep.airDate}
- Release Status: ${ep.released ? 'Released' : 'Unreleased'}
- Runtime: ${ep.runtime ? `${ep.runtime} minutes` : 'Unknown'}
- Synopsis: ${ep.overview}
Cast:
${ep.cast.map(c => `- ${c.name} as ${c.character}`).join('\n')}
${ep.guestStars && ep.guestStars.length > 0 ? `Guest Stars:\n${ep.guestStars.map(g => `- ${g.name} as ${g.character}`).join('\n')}` : ''}
Crew:
${ep.crew.map(c => `- ${c.name} (${c.job})`).join('\n')}
Guidance:
- Never provide spoilers under any circumstances. Always keep responses spoiler-safe.
- If Release Status is Released, do not claim the episode is unreleased. Provide specific, accurate details.
- If Release Status is Unreleased, avoid spoilers and focus on official information only.
- Be specific to this episode and provide detailed, informative responses. If asked about other episodes or seasons, politely redirect the conversation back to this specific episode while acknowledging the broader context of the show.`;
} else {
const movie = context as MovieContext;
return `You are an AI assistant specialized in movies and cinema. You have detailed knowledge about the movie "${movie.title}".
Movie Details:
- Title: ${movie.title}
- Release Date: ${movie.releaseDate}
- Runtime: ${movie.runtime ? `${movie.runtime} minutes` : 'Unknown'}
- Genres: ${movie.genres.join(', ')}
- Tagline: ${movie.tagline || 'N/A'}
- Synopsis: ${movie.overview}
Cast:
${movie.cast.map(c => `- ${c.name} as ${c.character}`).join('\n')}
Crew:
${movie.crew.map(c => `- ${c.name} (${c.job})`).join('\n')}
${movie.keywords && movie.keywords.length > 0 ? `Keywords: ${movie.keywords.join(', ')}` : ''}
Guidance:
- Never provide spoilers under any circumstances. Always keep responses spoiler-safe.
- You can discuss themes, production, performances, and high-level plot setup without revealing twists, surprises, or outcomes.
- If users explicitly request spoilers, refuse gently and offer a spoiler-safe summary or analysis instead.
You should answer questions about this movie, including plot analysis, character development, themes, cinematography, production notes, trivia, and critical analysis. Provide detailed, informative responses that demonstrate deep knowledge of the film while remaining spoiler-safe. Be specific and focus on this particular movie.`;
}
}
async sendMessage(
message: string,
context: ContentContext,
conversationHistory: ChatMessage[] = []
): Promise<string> {
if (!await this.isConfigured()) {
throw new Error('AI service not configured. Please add your OpenRouter API key in settings.');
}
try {
const systemPrompt = this.createSystemPrompt(context);
// Prepare messages for API
const messages = [
{ role: 'system', content: systemPrompt },
...conversationHistory
.filter(msg => msg.role !== 'system')
.slice(-10) // Keep last 10 messages for context
.map(msg => ({
role: msg.role,
content: msg.content
})),
{ role: 'user', content: message }
];
if (__DEV__) {
console.log('[AIService] Sending request to OpenRouter with context:', {
contentType: 'showTitle' in context ? 'episode' : 'movie',
title: 'showTitle' in context ?
`${(context as EpisodeContext).showTitle} S${(context as EpisodeContext).seasonNumber}E${(context as EpisodeContext).episodeNumber}` :
(context as MovieContext).title,
messageCount: messages.length
});
}
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://nuvio.app',
'X-Title': 'Nuvio - AI Chat',
},
body: JSON.stringify({
model: 'anthropic/claude-3.5-sonnet', // Using Claude for better analysis
messages,
max_tokens: 1000,
temperature: 0.7,
top_p: 0.9,
frequency_penalty: 0.1,
presence_penalty: 0.1,
}),
});
if (!response.ok) {
const errorText = await response.text();
if (__DEV__) console.error('[AIService] API Error:', response.status, errorText);
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
const data: OpenRouterResponse = await response.json();
if (!data.choices || data.choices.length === 0) {
throw new Error('No response received from AI service');
}
const responseContent = data.choices[0].message.content;
if (__DEV__ && data.usage) {
console.log('[AIService] Token usage:', data.usage);
}
return responseContent;
} catch (error) {
if (__DEV__) console.error('[AIService] Error sending message:', error);
throw error;
}
}
// Helper method to create context from TMDB movie data
static createMovieContext(movieData: any): MovieContext {
if (__DEV__) {
console.log('[AIService] Creating movie context from TMDB data:', {
id: movieData.id,
title: movieData.title || movieData.name,
hasCredits: !!movieData.credits,
castCount: movieData.credits?.cast?.length || 0,
crewCount: movieData.credits?.crew?.length || 0,
hasKeywords: !!(movieData.keywords?.keywords || movieData.keywords?.results),
keywordCount: (movieData.keywords?.keywords || movieData.keywords?.results)?.length || 0,
genreCount: movieData.genres?.length || 0
});
}
return {
id: movieData.id?.toString() || '',
title: movieData.title || movieData.name || '',
overview: movieData.overview || '',
releaseDate: movieData.release_date || movieData.first_air_date || '',
genres: movieData.genres?.map((g: any) => g.name) || [],
cast: movieData.credits?.cast?.slice(0, 10).map((c: any) => ({
name: c.name,
character: c.character
})) || [],
crew: movieData.credits?.crew?.slice(0, 5).map((c: any) => ({
name: c.name,
job: c.job
})) || [],
runtime: movieData.runtime,
tagline: movieData.tagline,
keywords: movieData.keywords?.keywords?.map((k: any) => k.name) ||
movieData.keywords?.results?.map((k: any) => k.name) || []
};
}
// Helper method to create context from TMDB episode data
static createEpisodeContext(
episodeData: any,
showData: any,
seasonNumber: number,
episodeNumber: number
): EpisodeContext {
// Compute release status from TMDB air date
const airDate: string = episodeData.air_date || '';
let released = false;
try {
if (airDate) {
const parsed = new Date(airDate);
if (!isNaN(parsed.getTime())) released = parsed.getTime() <= Date.now();
}
} catch {}
// Heuristics: if TMDB provides meaningful content, treat as released
if (!released) {
const hasOverview = typeof episodeData.overview === 'string' && episodeData.overview.trim().length > 40;
const hasRuntime = typeof episodeData.runtime === 'number' && episodeData.runtime > 0;
const hasVotes = typeof episodeData.vote_average === 'number' && episodeData.vote_average > 0;
if (hasOverview || hasRuntime || hasVotes) {
released = true;
}
}
if (__DEV__) {
console.log('[AIService] Creating episode context from TMDB data:', {
showId: showData.id,
showTitle: showData.name || showData.title,
episodeId: episodeData.id,
episodeTitle: episodeData.name,
season: seasonNumber,
episode: episodeNumber,
hasShowCredits: !!showData.credits,
showCastCount: showData.credits?.cast?.length || 0,
hasEpisodeCredits: !!episodeData.credits,
episodeGuestStarsCount: episodeData.credits?.guest_stars?.length || 0,
episodeCrewCount: episodeData.credits?.crew?.length || 0
});
}
return {
id: episodeData.id?.toString() || '',
showId: showData.id?.toString() || '',
showTitle: showData.name || showData.title || '',
episodeTitle: episodeData.name || `Episode ${episodeNumber}`,
seasonNumber,
episodeNumber,
overview: episodeData.overview || '',
airDate,
released,
runtime: episodeData.runtime,
cast: showData.credits?.cast?.slice(0, 8).map((c: any) => ({
name: c.name,
character: c.character
})) || [],
crew: episodeData.credits?.crew?.slice(0, 5).map((c: any) => ({
name: c.name,
job: c.job
})) || showData.credits?.crew?.slice(0, 5).map((c: any) => ({
name: c.name,
job: c.job
})) || [],
guestStars: episodeData.credits?.guest_stars?.map((g: any) => ({
name: g.name,
character: g.character
})) || []
};
}
// Generate conversation starter suggestions
static generateConversationStarters(context: ContentContext): string[] {
const isEpisode = 'showTitle' in context;
if (isEpisode) {
const ep = context as EpisodeContext;
return [
`What happened in this episode of ${ep.showTitle}?`,
`Explain the main plot points of "${ep.episodeTitle}"`,
`What character development occurred in this episode?`,
`Are there any hidden details or easter eggs I might have missed?`,
`How does this episode connect to the overall story arc?`
];
} else {
const movie = context as MovieContext;
return [
`What is ${movie.title} about?`,
`Explain the themes in this movie`,
`What's the significance of the ending?`,
`Tell me about the main characters and their development`,
`Are there any interesting production facts about this film?`
];
}
}
}
export const aiService = AIService.getInstance();
// Export static methods for easier access
export const createMovieContext = AIService.createMovieContext;
export const createEpisodeContext = AIService.createEpisodeContext;
export const generateConversationStarters = AIService.generateConversationStarters;

View file

@ -166,6 +166,7 @@ export class TMDBService {
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
append_to_response: 'external_ids,credits,keywords' // Append external IDs, cast/crew, and keywords for AI context
}),
});
return response.data;
@ -300,6 +301,7 @@ export class TMDBService {
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
append_to_response: 'credits' // Include guest stars and crew for episode context
}),
}
);
@ -585,7 +587,7 @@ export class TMDBService {
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
append_to_response: 'external_ids' // Append external IDs
append_to_response: 'external_ids,credits,keywords' // Append external IDs, cast/crew, and keywords for AI context
}),
});
return response.data;