Added TMDB Multilang Support

This commit is contained in:
tapframe 2025-10-01 00:41:34 +05:30
parent 2a89695b0b
commit 3220e91f1c
5 changed files with 501 additions and 235 deletions

View file

@ -110,7 +110,7 @@ interface UseMetadataReturn {
}
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
const { settings } = useSettings();
const { settings, isLoaded: settingsLoaded } = useSettings();
const [metadata, setMetadata] = useState<StreamingContent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -421,7 +421,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// For TMDB IDs, we need to handle metadata differently
if (type === 'movie') {
if (__DEV__) logger.log('Fetching movie details from TMDB for:', tmdbId);
const movieDetails = await tmdbService.getMovieDetails(tmdbId);
const movieDetails = await tmdbService.getMovieDetails(
tmdbId,
settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US'
);
if (movieDetails) {
const imdbId = movieDetails.imdb_id || movieDetails.external_ids?.imdb_id;
if (imdbId) {
@ -485,7 +488,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Handle TV shows with TMDB IDs
if (__DEV__) logger.log('Fetching TV show details from TMDB for:', tmdbId);
try {
const showDetails = await tmdbService.getTVShowDetails(parseInt(tmdbId));
const showDetails = await tmdbService.getTVShowDetails(
parseInt(tmdbId),
settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US'
);
if (showDetails) {
// Get external IDs to check for IMDb ID
const externalIds = await tmdbService.getShowExternalIds(parseInt(tmdbId));
@ -587,16 +593,52 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (content.status === 'fulfilled' && content.value) {
if (__DEV__) logger.log('[loadMetadata] addon metadata:success', { id: content.value?.id, type: content.value?.type, name: content.value?.name });
setMetadata(content.value);
// Check if item is in library
// Start with addon metadata
let finalMetadata = content.value as StreamingContent;
// If localization is enabled, merge TMDB localized text (name/overview) before first render
try {
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
const tmdbSvc = TMDBService.getInstance();
// Ensure we have a TMDB ID
let finalTmdbId: number | null = tmdbId;
if (!finalTmdbId) {
finalTmdbId = await tmdbSvc.extractTMDBIdFromStremioId(actualId);
if (finalTmdbId) setTmdbId(finalTmdbId);
}
if (finalTmdbId) {
const lang = settings.tmdbLanguagePreference || 'en';
if (type === 'movie') {
const localized = await tmdbSvc.getMovieDetails(String(finalTmdbId), lang);
if (localized) {
finalMetadata = {
...finalMetadata,
name: localized.title || finalMetadata.name,
description: localized.overview || finalMetadata.description,
};
}
} else {
const localized = await tmdbSvc.getTVShowDetails(Number(finalTmdbId), lang);
if (localized) {
finalMetadata = {
...finalMetadata,
name: localized.name || finalMetadata.name,
description: localized.overview || finalMetadata.description,
};
}
}
}
}
} catch (e) {
if (__DEV__) console.log('[useMetadata] failed to merge localized TMDB text', e);
}
// Commit final metadata once and cache it
setMetadata(finalMetadata);
cacheService.setMetadata(id, type, finalMetadata);
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
setInLibrary(isInLib);
cacheService.setMetadata(id, type, content.value);
// Set the final metadata state without fetching logo (this will be handled by MetadataScreen)
setMetadata(content.value);
// Update cache
cacheService.setMetadata(id, type, content.value);
} else {
if (__DEV__) logger.warn('[loadMetadata] addon metadata:not found or failed', { status: content.status, reason: (content as any)?.reason?.message });
throw new Error('Content not found');
@ -693,6 +735,40 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (__DEV__) logger.log('[loadSeriesData] TMDB enrichment disabled; skipping season poster fetch');
}
// If localized TMDB text is enabled, merge episode names/overviews per language
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
try {
const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null);
if (tmdbIdToUse) {
const lang = `${settings.tmdbLanguagePreference || 'en'}-US`;
const seasons = Object.keys(groupedAddonEpisodes).map(Number);
for (const seasonNum of seasons) {
const seasonEps = groupedAddonEpisodes[seasonNum];
// Parallel fetch a reasonable batch (limit concurrency implicitly by season)
const localized = await Promise.all(
seasonEps.map(async ep => {
try {
const data = await tmdbService.getEpisodeDetails(Number(tmdbIdToUse), seasonNum, ep.episode_number, lang);
if (data) {
return {
...ep,
name: data.name || ep.name,
overview: data.overview || ep.overview,
};
}
} catch {}
return ep;
})
);
groupedAddonEpisodes[seasonNum] = localized;
}
if (__DEV__) logger.log('[useMetadata] merged localized episode names/overviews from TMDB');
}
} catch (e) {
if (__DEV__) console.log('[useMetadata] failed to merge localized episode text', e);
}
}
setGroupedEpisodes(groupedAddonEpisodes);
// Determine initial season only once per series
@ -1242,8 +1318,17 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}, [error, loadAttempts]);
useEffect(() => {
if (!settingsLoaded) return;
loadMetadata();
}, [id, type]);
}, [id, type, settingsLoaded]);
// Re-fetch when localization settings change to guarantee selected language at open
useEffect(() => {
if (!settingsLoaded) return;
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
loadMetadata();
}
}, [settingsLoaded, settings.enrichMetadataWithTMDB, settings.useTmdbLocalizedMetadata, settings.tmdbLanguagePreference]);
// Re-run series data loading when metadata updates with videos
useEffect(() => {

View file

@ -78,6 +78,7 @@ export interface AppSettings {
aiChatEnabled: boolean; // Enable/disable Ask AI and AI features
// Metadata enrichment
enrichMetadataWithTMDB: boolean; // Use TMDB to enrich metadata (cast, certification, posters, fallbacks)
useTmdbLocalizedMetadata: boolean; // Use TMDB localized metadata (titles, overviews) per tmdbLanguagePreference
}
export const DEFAULT_SETTINGS: AppSettings = {
@ -128,6 +129,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
aiChatEnabled: false,
// Metadata enrichment
enrichMetadataWithTMDB: true,
useTmdbLocalizedMetadata: false,
};
const SETTINGS_STORAGE_KEY = 'app_settings';

View file

@ -25,6 +25,9 @@ import CustomAlert from '../components/CustomAlert';
// TMDB API key - since the default key might be private in the service, we'll use our own
const TMDB_API_KEY = '439c478a771f35c05022f9feabcca01c';
// Extra TMDB logo languages to always offer (only Arabic per request)
const COMMON_TMDB_LANGUAGES: string[] = ['ar'];
// Define example shows with their IMDB IDs and TMDB IDs
const EXAMPLE_SHOWS = [
{
@ -407,6 +410,9 @@ const LogoSourceSettings = () => {
const [tmdbBanner, setTmdbBanner] = useState<string | null>(null);
const [metahubBanner, setMetahubBanner] = useState<string | null>(null);
const [loadingLogos, setLoadingLogos] = useState(true);
// Track which language the preview is actually using and if it is a fallback
const [previewLanguage, setPreviewLanguage] = useState<string>('');
const [isPreviewFallback, setIsPreviewFallback] = useState<boolean>(false);
// State for TMDB language selection
// Store unique language codes as strings
@ -471,6 +477,7 @@ const LogoSourceSettings = () => {
initialLogoPath = preferredLogo.file_path;
initialLanguage = preferredTmdbLanguage;
logger.log(`[LogoSourceSettings] Found initial ${preferredTmdbLanguage} TMDB logo for ${show.name}`);
setIsPreviewFallback(false);
} else {
// Fallback to English logo
const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === 'en');
@ -479,22 +486,27 @@ const LogoSourceSettings = () => {
initialLogoPath = englishLogo.file_path;
initialLanguage = 'en';
logger.log(`[LogoSourceSettings] Found initial English TMDB logo for ${show.name}`);
setIsPreviewFallback(true);
} else if (imagesData.logos[0]) {
// Fallback to the first available logo
initialLogoPath = imagesData.logos[0].file_path;
initialLanguage = imagesData.logos[0].iso_639_1;
logger.log(`[LogoSourceSettings] No English logo, using first available (${initialLanguage}) TMDB logo for ${show.name}`);
setIsPreviewFallback(true);
}
}
if (initialLogoPath) {
setTmdbLogo(`https://image.tmdb.org/t/p/original${initialLogoPath}`);
setPreviewLanguage(initialLanguage || '');
} else {
logger.warn(`[LogoSourceSettings] No valid initial TMDB logo found for ${show.name}`);
}
} else {
logger.warn(`[LogoSourceSettings] No TMDB logos found in response for ${show.name}`);
setUniqueTmdbLanguages([]); // Ensure it's empty if no logos
setPreviewLanguage('');
setIsPreviewFallback(false);
}
// Get TMDB banner (backdrop)
@ -603,8 +615,24 @@ const LogoSourceSettings = () => {
if (selectedLogoData) {
setTmdbLogo(`https://image.tmdb.org/t/p/original${selectedLogoData.file_path}`);
logger.log(`[LogoSourceSettings] Switched TMDB logo preview to language: ${languageCode}`);
setPreviewLanguage(languageCode);
setIsPreviewFallback(false);
} else {
logger.warn(`[LogoSourceSettings] Could not find logo data for selected language: ${languageCode}`);
// Fallback to English, then first available if English is not present
const englishData = tmdbLogosData.find(logo => logo.iso_639_1 === 'en');
if (englishData) {
setTmdbLogo(`https://image.tmdb.org/t/p/original${englishData.file_path}`);
setPreviewLanguage('en');
setIsPreviewFallback(true);
} else if (tmdbLogosData[0]) {
setTmdbLogo(`https://image.tmdb.org/t/p/original${tmdbLogosData[0].file_path}`);
setPreviewLanguage(tmdbLogosData[0].iso_639_1 || '');
setIsPreviewFallback(true);
} else {
setPreviewLanguage('');
setIsPreviewFallback(false);
}
}
}
@ -833,15 +861,18 @@ const LogoSourceSettings = () => {
<View style={styles.exampleContainer}>
<Text style={styles.exampleLabel}>Example:</Text>
{renderLogoExample(tmdbLogo, tmdbBanner, loadingLogos)}
<Text style={styles.logoSourceLabel}>
{`Preview language: ${(previewLanguage || '').toUpperCase() || 'N/A'}${isPreviewFallback ? ' (fallback)' : ''}`}
</Text>
<Text style={styles.logoSourceLabel}>{selectedShow.name} logo from TMDB</Text>
</View>
{/* TMDB Language Selector */}
{uniqueTmdbLanguages.length > 1 && (
{true && (
<View style={styles.languageSelectorContainer}>
<Text style={styles.languageSelectorTitle}>Logo Language</Text>
<Text style={styles.languageSelectorDescription}>
Select your preferred language for TMDB logos.
Select your preferred language for TMDB logos (includes common languages like Arabic even if not shown in this preview).
</Text>
<ScrollView
horizontal
@ -850,8 +881,8 @@ const LogoSourceSettings = () => {
scrollEventThrottle={32}
decelerationRate="normal"
>
{/* Iterate over unique language codes */}
{uniqueTmdbLanguages.map((langCode) => (
{/* Merge unique languages from TMDB with a common list to ensure wider options */}
{Array.from(new Set<string>([...uniqueTmdbLanguages, ...COMMON_TMDB_LANGUAGES])).map((langCode) => (
<TouchableOpacity
key={langCode} // Use the unique code as key
style={[

View file

@ -17,6 +17,7 @@ import {
Image,
KeyboardAvoidingView,
TouchableWithoutFeedback,
Modal,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
@ -50,6 +51,8 @@ const TMDBSettingsScreen = () => {
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const { settings, updateSetting } = useSettings();
const [languagePickerVisible, setLanguagePickerVisible] = useState(false);
const [languageSearch, setLanguageSearch] = useState('');
const openAlert = (
title: string,
@ -284,165 +287,311 @@ const TMDBSettingsScreen = () => {
</Text>
</View>
<ScrollView
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={[styles.switchCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.switchTextContainer}>
<Text style={[styles.switchTitle, { color: currentTheme.colors.text }]}>Enrich Metadata with TMDb</Text>
<Text style={[styles.switchDescription, { color: currentTheme.colors.mediumEmphasis }]}>When enabled, the app augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback. Disable to strictly use addon metadata only.</Text>
{/* Metadata Enrichment Section */}
<View style={[styles.sectionCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.sectionHeader}>
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Metadata Enrichment</Text>
</View>
<Switch
value={settings.enrichMetadataWithTMDB}
onValueChange={(v) => updateSetting('enrichMetadataWithTMDB', v)}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (settings.enrichMetadataWithTMDB ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
<View style={[styles.switchCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.switchTextContainer}>
<Text style={[styles.switchTitle, { color: currentTheme.colors.text }]}>Use Custom TMDb API Key</Text>
<Text style={[styles.switchDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Enable to use your own TMDb API key instead of the built-in one.
Using your own API key may provide better performance and higher rate limits.
</Text>
</View>
<Switch
value={useCustomKey}
onValueChange={toggleUseCustomKey}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (useCustomKey ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Enhance your content metadata with TMDb data for better details and information.
</Text>
{useCustomKey && (
<>
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<MaterialIcons
name={isKeySet ? "check-circle" : "error-outline"}
size={28}
color={isKeySet ? currentTheme.colors.success : currentTheme.colors.warning}
style={styles.statusIconContainer}
/>
<View style={styles.statusTextContainer}>
<Text style={[styles.statusTitle, { color: currentTheme.colors.text }]}>
{isKeySet ? "API Key Active" : "API Key Required"}
</Text>
<Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}>
{isKeySet
? "Your custom TMDb API key is set and active."
: "Add your TMDb API key below."}
</Text>
</View>
</View>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.text }]}>API Key</Text>
<View style={styles.inputContainer}>
<TextInput
ref={apiKeyInputRef}
style={[
styles.input,
{
backgroundColor: currentTheme.colors.elevation1,
color: currentTheme.colors.text,
borderColor: isInputFocused ? currentTheme.colors.primary : 'transparent'
}
]}
value={apiKey}
onChangeText={(text) => {
setApiKey(text);
if (testResult) setTestResult(null);
}}
placeholder="Paste your TMDb API key (v3)"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
/>
<TouchableOpacity
style={styles.pasteButton}
onPress={pasteFromClipboard}
>
<MaterialIcons name="content-paste" size={20} color={currentTheme.colors.primary} />
</TouchableOpacity>
</View>
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={saveApiKey}
>
<Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save API Key</Text>
</TouchableOpacity>
{isKeySet && (
<TouchableOpacity
style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]}
onPress={clearApiKey}
>
<Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text>
</TouchableOpacity>
)}
</View>
{testResult && (
<View style={[
styles.resultMessage,
{ backgroundColor: testResult.success ? currentTheme.colors.success + '1A' : currentTheme.colors.error + '1A' }
]}>
<MaterialIcons
name={testResult.success ? "check-circle" : "error"}
size={18}
color={testResult.success ? currentTheme.colors.success : currentTheme.colors.error}
style={styles.resultIcon}
/>
<Text style={[
styles.resultText,
{ color: testResult.success ? currentTheme.colors.success : currentTheme.colors.error }
]}>
{testResult.message}
</Text>
</View>
)}
<TouchableOpacity
style={styles.helpLink}
onPress={openTMDBWebsite}
>
<MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} />
<Text style={[styles.helpText, { color: currentTheme.colors.primary }]}>
How to get a TMDb API key?
</Text>
</TouchableOpacity>
</View>
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
To get your own TMDb API key (v3), you need to create a TMDb account and request an API key from their website.
Using your own API key gives you dedicated quota and may improve app performance.
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Enable Enrichment</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback.
</Text>
</View>
</>
)}
{!useCustomKey && (
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
Currently using the built-in TMDb API key. This key is shared among all users.
For better performance and reliability, consider using your own API key.
</Text>
<Switch
value={settings.enrichMetadataWithTMDB}
onValueChange={(v) => updateSetting('enrichMetadataWithTMDB', v)}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (settings.enrichMetadataWithTMDB ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
)}
{settings.enrichMetadataWithTMDB && (
<>
<View style={styles.divider} />
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Localized Text</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Fetch titles and descriptions in your preferred language from TMDb.
</Text>
</View>
<Switch
value={settings.useTmdbLocalizedMetadata}
onValueChange={(v) => updateSetting('useTmdbLocalizedMetadata', v)}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (settings.useTmdbLocalizedMetadata ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
{settings.useTmdbLocalizedMetadata && (
<>
<View style={styles.divider} />
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Language</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Current: {(settings.tmdbLanguagePreference || 'en').toUpperCase()}
</Text>
</View>
<TouchableOpacity
onPress={() => setLanguagePickerVisible(true)}
style={[styles.languageButton, { backgroundColor: currentTheme.colors.primary }]}
>
<Text style={[styles.languageButtonText, { color: currentTheme.colors.white }]}>Change</Text>
</TouchableOpacity>
</View>
</>
)}
</>
)}
</View>
{/* API Configuration Section */}
<View style={[styles.sectionCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.sectionHeader}>
<MaterialIcons name="api" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>API Configuration</Text>
</View>
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Configure your TMDb API access for enhanced functionality.
</Text>
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Custom API Key</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Use your own TMDb API key for better performance and dedicated rate limits.
</Text>
</View>
<Switch
value={useCustomKey}
onValueChange={toggleUseCustomKey}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (useCustomKey ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
{useCustomKey && (
<>
<View style={styles.divider} />
{/* API Key Status */}
<View style={styles.statusRow}>
<MaterialIcons
name={isKeySet ? "check-circle" : "error-outline"}
size={20}
color={isKeySet ? currentTheme.colors.success : currentTheme.colors.warning}
/>
<Text style={[styles.statusText, {
color: isKeySet ? currentTheme.colors.success : currentTheme.colors.warning
}]}>
{isKeySet ? "Custom API key active" : "API key required"}
</Text>
</View>
{/* API Key Input */}
<View style={styles.apiKeyContainer}>
<View style={styles.inputContainer}>
<TextInput
ref={apiKeyInputRef}
style={[
styles.input,
{
backgroundColor: currentTheme.colors.elevation1,
color: currentTheme.colors.text,
borderColor: isInputFocused ? currentTheme.colors.primary : 'transparent'
}
]}
value={apiKey}
onChangeText={(text) => {
setApiKey(text);
if (testResult) setTestResult(null);
}}
placeholder="Paste your TMDb API key (v3)"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
/>
<TouchableOpacity
style={styles.pasteButton}
onPress={pasteFromClipboard}
>
<MaterialIcons name="content-paste" size={20} color={currentTheme.colors.primary} />
</TouchableOpacity>
</View>
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={saveApiKey}
>
<Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save</Text>
</TouchableOpacity>
{isKeySet && (
<TouchableOpacity
style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]}
onPress={clearApiKey}
>
<Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text>
</TouchableOpacity>
)}
</View>
{testResult && (
<View style={[
styles.resultMessage,
{ backgroundColor: testResult.success ? currentTheme.colors.success + '1A' : currentTheme.colors.error + '1A' }
]}>
<MaterialIcons
name={testResult.success ? "check-circle" : "error"}
size={16}
color={testResult.success ? currentTheme.colors.success : currentTheme.colors.error}
style={styles.resultIcon}
/>
<Text style={[
styles.resultText,
{ color: testResult.success ? currentTheme.colors.success : currentTheme.colors.error }
]}>
{testResult.message}
</Text>
</View>
)}
<TouchableOpacity
style={styles.helpLink}
onPress={openTMDBWebsite}
>
<MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} />
<Text style={[styles.helpText, { color: currentTheme.colors.primary }]}>
How to get a TMDb API key?
</Text>
</TouchableOpacity>
</View>
</>
)}
{!useCustomKey && (
<View style={styles.infoContainer}>
<MaterialIcons name="info-outline" size={18} color={currentTheme.colors.primary} />
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
Currently using built-in API key. Consider using your own key for better performance.
</Text>
</View>
)}
</View>
{/* Language Picker Modal */}
<Modal
visible={languagePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setLanguagePickerVisible(false)}
>
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }}>
<View style={{ backgroundColor: currentTheme.colors.darkBackground, borderTopLeftRadius: 16, borderTopRightRadius: 16, width: '100%', maxHeight: '80%', padding: 16 }}>
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: currentTheme.colors.elevation3, alignSelf: 'center', marginBottom: 10 }} />
<Text style={{ color: currentTheme.colors.text, fontSize: 18, fontWeight: '800', marginBottom: 12 }}>Select Language</Text>
<View style={{ flexDirection: 'row', marginBottom: 12 }}>
<View style={{ flex: 1, borderRadius: 10, backgroundColor: currentTheme.colors.elevation1, paddingHorizontal: 12, paddingVertical: Platform.OS === 'ios' ? 10 : 0 }}>
<TextInput
placeholder="Search language (e.g. Arabic)"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
style={{ color: currentTheme.colors.text, paddingVertical: Platform.OS === 'ios' ? 0 : 8 }}
value={languageSearch}
onChangeText={setLanguageSearch}
/>
</View>
<TouchableOpacity onPress={() => setLanguageSearch('')} style={{ marginLeft: 8, paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, backgroundColor: currentTheme.colors.elevation1 }}>
<Text style={{ color: currentTheme.colors.text }}>Clear</Text>
</TouchableOpacity>
</View>
{/* Most used quick chips */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginBottom: 8 }} contentContainerStyle={{ paddingVertical: 2 }}>
{['en','ar','es','fr','de','tr'].map(code => (
<TouchableOpacity key={code} onPress={() => { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }} style={{ paddingHorizontal: 10, paddingVertical: 6, backgroundColor: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.elevation1, borderRadius: 999, marginRight: 8 }}>
<Text style={{ color: settings.tmdbLanguagePreference === code ? currentTheme.colors.white : currentTheme.colors.text }}>{code.toUpperCase()}</Text>
</TouchableOpacity>
))}
</ScrollView>
<ScrollView style={{ maxHeight: '70%' }}>
{[
{ code: 'en', label: 'English' },
{ code: 'ar', label: 'Arabic' },
{ code: 'es', label: 'Spanish' },
{ code: 'fr', label: 'French' },
{ code: 'de', label: 'German' },
{ code: 'it', label: 'Italian' },
{ code: 'pt', label: 'Portuguese' },
{ code: 'ru', label: 'Russian' },
{ code: 'tr', label: 'Turkish' },
{ code: 'ja', label: 'Japanese' },
{ code: 'ko', label: 'Korean' },
{ code: 'zh', label: 'Chinese' },
{ code: 'hi', label: 'Hindi' },
{ code: 'he', label: 'Hebrew' },
{ code: 'id', label: 'Indonesian' },
{ code: 'nl', label: 'Dutch' },
{ code: 'sv', label: 'Swedish' },
{ code: 'no', label: 'Norwegian' },
{ code: 'da', label: 'Danish' },
{ code: 'fi', label: 'Finnish' },
{ code: 'pl', label: 'Polish' },
{ code: 'cs', label: 'Czech' },
{ code: 'ro', label: 'Romanian' },
{ code: 'uk', label: 'Ukrainian' },
{ code: 'vi', label: 'Vietnamese' },
{ code: 'th', label: 'Thai' },
]
.filter(({ label, code }) =>
(languageSearch || '').length === 0 ||
label.toLowerCase().includes(languageSearch.toLowerCase()) || code.toLowerCase().includes(languageSearch.toLowerCase())
)
.map(({ code, label }) => (
<TouchableOpacity
key={code}
onPress={() => { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }}
style={{ paddingVertical: 12, paddingHorizontal: 6, borderRadius: 10, backgroundColor: settings.tmdbLanguagePreference === code ? currentTheme.colors.elevation1 : 'transparent', marginBottom: 4 }}
activeOpacity={0.8}
>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ color: currentTheme.colors.text, fontSize: 16 }}>
{label} ({code.toUpperCase()})
</Text>
{settings.tmdbLanguagePreference === code && (
<MaterialIcons name="check-circle" size={20} color={currentTheme.colors.primary} />
)}
</View>
</TouchableOpacity>
))}
</ScrollView>
<TouchableOpacity onPress={() => setLanguagePickerVisible(false)} style={{ marginTop: 12, paddingVertical: 12, alignItems: 'center', borderRadius: 10, backgroundColor: currentTheme.colors.primary }}>
<Text style={{ color: currentTheme.colors.white, fontWeight: '700' }}>Done</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</ScrollView>
<CustomAlert
visible={alertVisible}
@ -502,73 +651,86 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingBottom: 40,
},
switchCard: {
sectionCard: {
borderRadius: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 20,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
switchTextContainer: {
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
marginLeft: 8,
},
sectionDescription: {
fontSize: 14,
lineHeight: 20,
marginBottom: 20,
},
settingRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 16,
},
settingTextContainer: {
flex: 1,
marginRight: 16,
},
switchTitle: {
settingTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
switchDescription: {
settingDescription: {
fontSize: 14,
lineHeight: 20,
opacity: 0.8,
},
statusCard: {
flexDirection: 'row',
borderRadius: 16,
marginBottom: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
languageButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
alignItems: 'center',
},
statusIconContainer: {
marginRight: 12,
},
statusTextContainer: {
flex: 1,
},
statusTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
statusDescription: {
languageButtonText: {
fontSize: 14,
opacity: 0.8,
},
card: {
borderRadius: 16,
marginBottom: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
cardTitle: {
fontSize: 16,
fontWeight: '600',
},
divider: {
height: 1,
backgroundColor: 'rgba(255,255,255,0.1)',
marginVertical: 16,
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
statusText: {
fontSize: 14,
fontWeight: '500',
marginLeft: 8,
},
apiKeyContainer: {
marginTop: 16,
},
infoContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
marginTop: 16,
padding: 12,
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 8,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
@ -640,27 +802,12 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '500',
},
infoCard: {
borderRadius: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'flex-start',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
infoIcon: {
marginRight: 12,
marginTop: 2,
},
infoText: {
fontSize: 14,
flex: 1,
lineHeight: 20,
opacity: 0.8,
marginLeft: 8,
},
});

View file

@ -160,12 +160,12 @@ export class TMDBService {
/**
* Get TV show details by TMDB ID
*/
async getTVShowDetails(tmdbId: number): Promise<TMDBShow | null> {
async getTVShowDetails(tmdbId: number, language: string = 'en'): Promise<TMDBShow | null> {
try {
const response = await axios.get(`${BASE_URL}/tv/${tmdbId}`, {
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
language,
append_to_response: 'external_ids,credits,keywords' // Append external IDs, cast/crew, and keywords for AI context
}),
});
@ -237,12 +237,12 @@ export class TMDBService {
/**
* Get season details including all episodes with IMDb ratings
*/
async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string): Promise<TMDBSeason | null> {
async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string, language: string = 'en-US'): Promise<TMDBSeason | null> {
try {
const response = await axios.get(`${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}`, {
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
language,
}),
});
@ -292,7 +292,8 @@ export class TMDBService {
async getEpisodeDetails(
tmdbId: number,
seasonNumber: number,
episodeNumber: number
episodeNumber: number,
language: string = 'en-US'
): Promise<TMDBEpisode | null> {
try {
const response = await axios.get(
@ -300,7 +301,7 @@ export class TMDBService {
{
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
language,
append_to_response: 'credits' // Include guest stars and crew for episode context
}),
}
@ -546,14 +547,14 @@ export class TMDBService {
}
}
async getRecommendations(type: 'movie' | 'tv', tmdbId: string): Promise<any[]> {
async getRecommendations(type: 'movie' | 'tv', tmdbId: string, language: string = 'en-US'): Promise<any[]> {
if (!this.apiKey) {
return [];
}
try {
const response = await axios.get(`${BASE_URL}/${type}/${tmdbId}/recommendations`, {
headers: await this.getHeaders(),
params: await this.getParams({ language: 'en-US' })
params: await this.getParams({ language })
});
return response.data.results || [];
} catch (error) {
@ -581,12 +582,12 @@ export class TMDBService {
/**
* Get movie details by TMDB ID
*/
async getMovieDetails(movieId: string): Promise<any> {
async getMovieDetails(movieId: string, language: string = 'en'): Promise<any> {
try {
const response = await axios.get(`${BASE_URL}/movie/${movieId}`, {
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
language,
append_to_response: 'external_ids,credits,keywords,release_dates' // Include release dates for accurate availability
}),
});