From e0ca6148935ea2d08d6f5a0a798b0fbc3444a136 Mon Sep 17 00:00:00 2001 From: tapframe Date: Thu, 11 Sep 2025 16:32:25 +0530 Subject: [PATCH] AI initial commit --- App.tsx | 5 + local-scrapers-repo | 2 +- src/components/metadata/HeroSection.tsx | 44 +- src/navigation/AppNavigator.tsx | 41 ++ src/screens/AIChatScreen.tsx | 773 ++++++++++++++++++++++++ src/screens/AISettingsScreen.tsx | 500 +++++++++++++++ src/screens/SettingsScreen.tsx | 22 + src/services/aiService.ts | 375 ++++++++++++ src/services/tmdbService.ts | 4 +- 9 files changed, 1763 insertions(+), 3 deletions(-) create mode 100644 src/screens/AIChatScreen.tsx create mode 100644 src/screens/AISettingsScreen.tsx create mode 100644 src/services/aiService.ts diff --git a/App.tsx b/App.tsx index f7ffad4..2527397 100644 --- a/App.tsx +++ b/App.tsx @@ -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 diff --git a/local-scrapers-repo b/local-scrapers-repo index 5c3e2d8..5c020cc 160000 --- a/local-scrapers-repo +++ b/local-scrapers-repo @@ -1 +1 @@ -Subproject commit 5c3e2d8bccf7d076e392fdc397233f392e2a1563 +Subproject commit 5c020cca433f0400e23eb553f3e4de09f65b66d3 diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index f6fd336..b5af419 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -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(({ + {/* AI Chat Button */} + { + // 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' ? ( + + ) : ( + + )} + + + {type === 'series' && ( = memo(({ isWatched={isWatched} watchProgress={watchProgress} groupedEpisodes={groupedEpisodes} + metadata={metadata} /> diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 5f26e1c..451e4d0 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -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; @@ -1224,6 +1235,36 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> + + diff --git a/src/screens/AIChatScreen.tsx b/src/screens/AIChatScreen.tsx new file mode 100644 index 0000000..fbc8170 --- /dev/null +++ b/src/screens/AIChatScreen.tsx @@ -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; + +interface ChatBubbleProps { + message: ChatMessage; + isLast: boolean; +} + +const ChatBubble: React.FC = ({ 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 ( + + {!isUser && ( + + + + )} + + + + {message.content} + + + {new Date(message.timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + })} + + + + {isUser && ( + + + + )} + + ); +}; + +interface SuggestionChipProps { + text: string; + onPress: () => void; +} + +const SuggestionChip: React.FC = ({ text, onPress }) => { + const { currentTheme } = useTheme(); + + return ( + + + {text} + + + ); +}; + +const AIChatScreen: React.FC = () => { + 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 scrollViewRef = useRef(null); + const inputRef = useRef(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 ( + + + + + Loading AI context... + + + ); + } + + return ( + + + + {/* Header */} + + + 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)} + /> + ))} + + + + )} + + {messages.map((message, index) => ( + + ))} + + {isLoading && ( + + + + + + + + + + )} + + + {/* Input Container */} + + + + + + + + + + + + ); +}; + +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; diff --git a/src/screens/AISettingsScreen.tsx b/src/screens/AISettingsScreen.tsx new file mode 100644 index 0000000..57da4f5 --- /dev/null +++ b/src/screens/AISettingsScreen.tsx @@ -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 ( + + + + {/* Header */} + + + navigation.goBack()} + style={styles.backButton} + > + + + + AI Assistant + + + + + + {/* Info Card */} + + + + + AI-Powered Chat + + + + 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. + + + + + + + Episode-specific context and analysis + + + + + + Plot explanations and character insights + + + + + + Behind-the-scenes trivia and facts + + + + + + Your own free OpenRouter API key + + + + + + {/* API Key Configuration */} + + + OPENROUTER API KEY + + + + + API Key + + + Enter your OpenRouter API key to enable AI chat features + + + + + + {!isKeySet ? ( + + + + {loading ? 'Saving...' : 'Save API Key'} + + + ) : ( + + + + Update + + + + + + Remove + + + + )} + + + + + + Get Free API Key from OpenRouter + + + + + + {/* Status Card */} + {isKeySet && ( + + + + + AI Chat Enabled + + + + You can now ask questions about movies and TV shows. Look for the "Ask AI" button on content pages! + + + )} + + {/* Usage Info */} + + + How it works + + + • 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 + + + + + ); +}; + +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; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index cf890e4..ef04633 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -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(0); const [catalogCount, setCatalogCount] = useState(0); const [mdblistKeySet, setMdblistKeySet] = useState(false); + const [openRouterKeySet, setOpenRouterKeySet] = useState(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 = () => { ); + case 'ai': + return ( + + navigation.navigate('AISettings')} + isLast={true} + isTablet={isTablet} + /> + + ); + case 'playback': return ( @@ -765,6 +786,7 @@ const SettingsScreen: React.FC = () => { {renderCategoryContent('content')} {renderCategoryContent('appearance')} {renderCategoryContent('integrations')} + {renderCategoryContent('ai')} {renderCategoryContent('playback')} {renderCategoryContent('updates')} {renderCategoryContent('about')} diff --git a/src/services/aiService.ts b/src/services/aiService.ts new file mode 100644 index 0000000..be2b68a --- /dev/null +++ b/src/services/aiService.ts @@ -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 { + 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 { + 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 { + 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; diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index a377213..0575724 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -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;