mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-19 07:42:09 +00:00
ai bug fix
This commit is contained in:
parent
6162c86347
commit
9410b18962
3 changed files with 219 additions and 65 deletions
2
app.json
2
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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<ChatBubbleProps> = ({ message, isLast }) => {
|
|||
]}>
|
||||
{!isUser && (
|
||||
<View style={styles.assistantBlurBackdrop} pointerEvents="none">
|
||||
{Platform.OS === 'ios'
|
||||
? <ExpoBlurView intensity={70} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
: <CommunityBlurView blurAmount={16} blurRadius={8} style={StyleSheet.absoluteFill} />}
|
||||
{Platform.OS === 'android' && AndroidBlurView
|
||||
? <AndroidBlurView blurAmount={16} blurRadius={8} style={StyleSheet.absoluteFill} />
|
||||
: <ExpoBlurView intensity={70} tint="dark" style={StyleSheet.absoluteFill} />}
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.50)' }]} />
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -294,6 +303,7 @@ const AIChatScreen: React.FC = () => {
|
|||
const [isLoadingContext, setIsLoadingContext] = useState(true);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [backdropUrl, setBackdropUrl] = useState<string | null>(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 (
|
||||
<Animated.View style={{ flex: 1, opacity: modalOpacity }}>
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<SafeAreaView edges={['top','bottom']} style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
{backdropUrl && (
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
<Image
|
||||
|
|
@ -585,9 +588,9 @@ const AIChatScreen: React.FC = () => {
|
|||
contentFit="cover"
|
||||
recyclingKey={backdropUrl || undefined}
|
||||
/>
|
||||
{Platform.OS === 'ios'
|
||||
? <ExpoBlurView intensity={60} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
: <CommunityBlurView blurAmount={12} blurRadius={6} style={StyleSheet.absoluteFill} />}
|
||||
{Platform.OS === 'android' && AndroidBlurView
|
||||
? <AndroidBlurView blurAmount={12} blurRadius={6} style={StyleSheet.absoluteFill} />
|
||||
: <ExpoBlurView intensity={60} tint="dark" style={StyleSheet.absoluteFill} />}
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: Platform.OS === 'android' ? 'rgba(0,0,0,0.28)' : 'rgba(0,0,0,0.45)' }]} />
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -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 = () => {
|
|||
</ScrollView>
|
||||
|
||||
{/* Input Container */}
|
||||
<SafeAreaView edges={['bottom']} style={{ backgroundColor: 'transparent' }}>
|
||||
<Animated.View style={[
|
||||
styles.inputContainer,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
paddingBottom: 12 + insets.bottom
|
||||
paddingBottom: Platform.OS === 'ios' ? (isKeyboardVisible ? 12 : 56) : 12
|
||||
},
|
||||
inputAnimatedStyle
|
||||
]}>
|
||||
<View style={[styles.inputWrapper, { backgroundColor: 'transparent' }]}>
|
||||
<View style={styles.inputBlurBackdrop} pointerEvents="none">
|
||||
{Platform.OS === 'ios'
|
||||
? <ExpoBlurView intensity={50} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
: <CommunityBlurView blurAmount={10} blurRadius={4} style={StyleSheet.absoluteFill} />}
|
||||
{Platform.OS === 'android' && AndroidBlurView
|
||||
? <AndroidBlurView blurAmount={10} blurRadius={4} style={StyleSheet.absoluteFill} />
|
||||
: <ExpoBlurView intensity={50} tint="dark" style={StyleSheet.absoluteFill} />}
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: Platform.OS === 'android' ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.25)' }]} />
|
||||
</View>
|
||||
<TextInput
|
||||
|
|
@ -754,6 +758,7 @@ const AIChatScreen: React.FC = () => {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</SafeAreaView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
|
|
|
|||
|
|
@ -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<number, Array<{
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
title: string;
|
||||
airDate: string;
|
||||
released: boolean;
|
||||
overview?: string;
|
||||
}>>;
|
||||
}
|
||||
|
||||
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<number, any[]>): 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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue