diff --git a/package-lock.json b/package-lock.json index 9d3c2b2..f36b7ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "~14.0.4", "@lottiefiles/dotlottie-react": "^0.6.5", - "@react-native-async-storage/async-storage": "^1.23.1", + "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/blur": "^4.4.1", "@react-native-community/netinfo": "^11.4.1", "@react-native-community/slider": "4.5.5", @@ -28,7 +28,7 @@ "@supabase/supabase-js": "^2.54.0", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", - "axios": "^1.12.2", + "axios": "^1.11.0", "axios-cookiejar-support": "^6.0.4", "cheerio-without-node-native": "^0.20.2", "crypto-js": "^4.2.0", @@ -15007,6 +15007,17 @@ "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", "license": "MIT" }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", diff --git a/package.json b/package.json index 8a91332..99c31ee 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "~14.0.4", "@lottiefiles/dotlottie-react": "^0.6.5", - "@react-native-async-storage/async-storage": "^1.23.1", + "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/blur": "^4.4.1", "@react-native-community/netinfo": "^11.4.1", "@react-native-community/slider": "4.5.5", @@ -28,7 +28,7 @@ "@supabase/supabase-js": "^2.54.0", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", - "axios": "^1.12.2", + "axios": "^1.11.0", "axios-cookiejar-support": "^6.0.4", "cheerio-without-node-native": "^0.20.2", "crypto-js": "^4.2.0", diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index e7d44c4..a63dd2c 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -1,14 +1,11 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { toast } from '@backpackapp-io/react-native-toast'; -import { DeviceEventEmitter } from 'react-native'; -import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Animated, Share } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Animated } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../../contexts/ThemeContext'; import { useSettings } from '../../hooks/useSettings'; import { catalogService, StreamingContent } from '../../services/catalogService'; import { DropUpMenu } from './DropUpMenu'; -import AsyncStorage from '@react-native-async-storage/async-storage'; interface ContentItemProps { item: StreamingContent; @@ -73,17 +70,6 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe }); return () => unsubscribe(); }, [item.id, item.type]); - - // Load watched state from AsyncStorage when item changes - useEffect(() => { - const updateWatched = () => { - AsyncStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setIsWatched(val === 'true')); - }; - updateWatched(); - const sub = DeviceEventEmitter.addListener('watchedStatusChanged', updateWatched); - return () => sub.remove(); - }, [item.id, item.type]); - const [menuVisible, setMenuVisible] = useState(false); const [isWatched, setIsWatched] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); @@ -121,37 +107,17 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe case 'library': if (inLibrary) { catalogService.removeFromLibrary(item.type, item.id); - toast('Removed from Library', { duration: 1200 }); } else { catalogService.addToLibrary(item); - toast('Added to Library', { duration: 1200 }); } break; - case 'watched': { - setIsWatched(prevWatched => { - const newWatched = !prevWatched; - AsyncStorage.setItem(`watched:${item.type}:${item.id}`, newWatched ? 'true' : 'false'); - toast(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', { duration: 1200 }); - // Fire a custom event so other screens can update - setTimeout(() => { - DeviceEventEmitter.emit('watchedStatusChanged'); - }, 100); - return newWatched; - }); - setMenuVisible(false); + case 'watched': + setIsWatched(prev => !prev); break; - } case 'playlist': break; - case 'share': { - let url = ''; - if (item.id) { - url = `https://www.imdb.com/title/${item.id}/`; - } - const message = `${item.name}\n${url}`; - Share.share({ message, url, title: item.name }); + case 'share': break; - } } }, [item, inLibrary]); diff --git a/src/components/home/DropUpMenu.tsx b/src/components/home/DropUpMenu.tsx index bbe9c04..d84ca62 100644 --- a/src/components/home/DropUpMenu.tsx +++ b/src/components/home/DropUpMenu.tsx @@ -92,9 +92,9 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is // Robustly determine if the item is in the library (saved) const isSaved = typeof isSavedProp === 'boolean' ? isSavedProp : !!item.inLibrary; const isWatched = !!isWatchedProp; - let menuOptions = [ + const menuOptions = [ { - icon: 'bookmark', + icon: isSaved ? 'bookmark' : 'bookmark-border', label: isSaved ? 'Remove from Library' : 'Add to Library', action: 'library' }, @@ -103,13 +103,11 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is label: isWatched ? 'Mark as Unwatched' : 'Mark as Watched', action: 'watched' }, - /* { icon: 'playlist-add', label: 'Add to Playlist', action: 'playlist' }, - */ { icon: 'share', label: 'Share', @@ -117,11 +115,6 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is } ]; - // If used in LibraryScreen, only show 'Remove from Library' if item is in library - if (isSavedProp === true) { - menuOptions = menuOptions.filter(opt => opt.action !== 'library' || isSaved); - } - const backgroundColor = isDarkMode ? '#1A1A1A' : '#FFFFFF'; return ( diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx index 2ad89c1..307a069 100644 --- a/src/components/metadata/MetadataDetails.tsx +++ b/src/components/metadata/MetadataDetails.tsx @@ -97,6 +97,14 @@ const MetadataDetails: React.FC = ({ overflow: 'hidden', })); +function formatRuntime(runtime: string): string { + const r = parseInt(runtime, 10); + if (isNaN(r) || r < 60) return runtime; + const h = Math.floor(r / 60); + const m = r % 60; + return `${h}H ${m}MIN`; +} + return ( <> {/* Metadata Source Selector removed */} @@ -118,13 +126,7 @@ const MetadataDetails: React.FC = ({ )} {metadata.runtime && ( - {(() => { - const r = parseInt(metadata.runtime, 10); - if (isNaN(r) || r < 60) return metadata.runtime; - const h = Math.floor(r / 60); - const m = r % 60; - return `${h}H ${m < 10 ? '0' : ''}${m}MIN`; - })()} + {formatRuntime(metadata.runtime)} )} {metadata.certification && ( diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 0790fb4..185a1ae 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -110,7 +110,7 @@ interface UseMetadataReturn { } export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => { - const { settings, isLoaded: settingsLoaded } = useSettings(); + const { settings } = useSettings(); const [metadata, setMetadata] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -421,10 +421,7 @@ 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, - settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US' - ); + const movieDetails = await tmdbService.getMovieDetails(tmdbId); if (movieDetails) { const imdbId = movieDetails.imdb_id || movieDetails.external_ids?.imdb_id; if (imdbId) { @@ -488,10 +485,7 @@ 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), - settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US' - ); + const showDetails = await tmdbService.getTVShowDetails(parseInt(tmdbId)); if (showDetails) { // Get external IDs to check for IMDb ID const externalIds = await tmdbService.getShowExternalIds(parseInt(tmdbId)); @@ -593,52 +587,16 @@ 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 }); - - // 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); + setMetadata(content.value); + // Check if item is in library 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'); @@ -735,40 +693,6 @@ 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 @@ -1318,17 +1242,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat }, [error, loadAttempts]); useEffect(() => { - if (!settingsLoaded) return; loadMetadata(); - }, [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]); + }, [id, type]); // Re-run series data loading when metadata updates with videos useEffect(() => { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 030d8ae..f782b5b 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -78,7 +78,6 @@ 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 = { @@ -129,7 +128,6 @@ export const DEFAULT_SETTINGS: AppSettings = { aiChatEnabled: false, // Metadata enrichment enrichMetadataWithTMDB: true, - useTmdbLocalizedMetadata: false, }; const SETTINGS_STORAGE_KEY = 'app_settings'; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 854e526..293aed7 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1337,17 +1337,4 @@ const styles = StyleSheet.create({ }, }); -import { DeviceEventEmitter } from 'react-native'; - -const HomeScreenWithFocusSync = (props: any) => { - const navigation = useNavigation>(); - useEffect(() => { - const unsubscribe = navigation.addListener('focus', () => { - DeviceEventEmitter.emit('watchedStatusChanged'); - }); - return () => unsubscribe(); - }, [navigation]); - return ; -}; - -export default React.memo(HomeScreenWithFocusSync); \ No newline at end of file +export default React.memo(HomeScreen); \ No newline at end of file diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 7e7d230..71a6848 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -1,9 +1,4 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { DeviceEventEmitter } from 'react-native'; -import { Share } from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { toast } from '@backpackapp-io/react-native-toast'; -import DropUpMenu from '../components/home/DropUpMenu'; import { View, Text, @@ -45,7 +40,6 @@ interface LibraryItem extends StreamingContent { imdbId?: string; traktId: number; images?: TraktImages; - watched?: boolean; } interface TraktDisplayItem { @@ -211,9 +205,6 @@ const LibraryScreen = () => { const [filter, setFilter] = useState<'trakt' | 'movies' | 'series'>('movies'); const [showTraktContent, setShowTraktContent] = useState(false); const [selectedTraktFolder, setSelectedTraktFolder] = useState(null); - // DropUpMenu state - const [menuVisible, setMenuVisible] = useState(false); - const [selectedItem, setSelectedItem] = useState(null); const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); @@ -276,22 +267,7 @@ const LibraryScreen = () => { setLoading(true); try { const items = await catalogService.getLibraryItems(); - // Load watched status for each item from AsyncStorage - const updatedItems = await Promise.all(items.map(async (item) => { - // Map StreamingContent to LibraryItem shape - const libraryItem: LibraryItem = { - ...item, - gradient: Array.isArray((item as any).gradient) ? (item as any).gradient : ['#222', '#444'], - traktId: typeof (item as any).traktId === 'number' ? (item as any).traktId : 0, - }; - const key = `watched:${item.type}:${item.id}`; - const watched = await AsyncStorage.getItem(key); - return { - ...libraryItem, - watched: watched === 'true' - }; - })); - setLibraryItems(updatedItems); + setLibraryItems(items as LibraryItem[]); } catch (error) { logger.error('Failed to load library:', error); } finally { @@ -302,37 +278,14 @@ const LibraryScreen = () => { loadLibrary(); // Subscribe to library updates - const unsubscribe = catalogService.subscribeToLibraryUpdates(async (items) => { - // Sync watched status on update - const updatedItems = await Promise.all(items.map(async (item) => { - // Map StreamingContent to LibraryItem shape - const libraryItem: LibraryItem = { - ...item, - gradient: Array.isArray((item as any).gradient) ? (item as any).gradient : ['#222', '#444'], - traktId: typeof (item as any).traktId === 'number' ? (item as any).traktId : 0, - }; - const key = `watched:${item.type}:${item.id}`; - const watched = await AsyncStorage.getItem(key); - return { - ...libraryItem, - watched: watched === 'true' - }; - })); - setLibraryItems(updatedItems); + const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => { + setLibraryItems(items as LibraryItem[]); }); - // Listen for watched status changes - const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', loadLibrary); - - // Refresh when screen regains focus - const focusSub = navigation.addListener('focus', loadLibrary); - return () => { unsubscribe(); - watchedSub.remove(); - focusSub(); }; - }, [navigation]); + }, []); const filteredItems = libraryItems.filter(item => { if (filter === 'movies') return item.type === 'movie'; @@ -395,25 +348,17 @@ const LibraryScreen = () => { navigation.navigate('Metadata', { id: item.id, type: item.type })} - onLongPress={() => { - setSelectedItem(item); - setMenuVisible(true); - }} activeOpacity={0.7} > - + - {item.watched && ( - - - - )} + {item.progress !== undefined && item.progress < 1 && ( { )} - + {item.name} @@ -987,62 +932,6 @@ const LibraryScreen = () => { {showTraktContent ? renderTraktContent() : renderContent()} - - {/* DropUpMenu integration */} - {selectedItem && ( - setMenuVisible(false)} - item={selectedItem} - isWatched={!!selectedItem.watched} - isSaved={true} // Since this is from library, it's always saved - onOptionSelect={async (option) => { - if (!selectedItem) return; - switch (option) { - case 'library': { - try { - await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); - toast('Removed from Library', { duration: 1200 }); - setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type))); - setMenuVisible(false); - } catch (error) { - toast('Failed to update Library', { duration: 1200 }); - } - break; - } - case 'watched': { - try { - // Use AsyncStorage to store watched status by key - const key = `watched:${selectedItem.type}:${selectedItem.id}`; - const newWatched = !selectedItem.watched; - await AsyncStorage.setItem(key, newWatched ? 'true' : 'false'); - toast(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', { duration: 1200 }); - // Instantly update local state - setLibraryItems(prev => prev.map(item => - item.id === selectedItem.id && item.type === selectedItem.type - ? { ...item, watched: newWatched } - : item - )); - } catch (error) { - toast('Failed to update watched status', { duration: 1200 }); - } - break; - } - case 'share': { - let url = ''; - if (selectedItem.id) { - url = `https://www.imdb.com/title/${selectedItem.id}/`; - } - const message = `${selectedItem.name}\n${url}`; - Share.share({ message, url, title: selectedItem.name }); - break; - } - default: - break; - } - }} - /> - )} ); }; @@ -1058,14 +947,6 @@ const styles = StyleSheet.create({ right: 0, zIndex: 1, }, - watchedIndicator: { - position: 'absolute', - top: 8, - right: 8, - borderRadius: 12, - padding: 2, - zIndex: 2, - }, contentContainer: { flex: 1, }, diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 70c1a3e..2f5b5d0 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -25,9 +25,6 @@ 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 = [ { @@ -410,9 +407,6 @@ const LogoSourceSettings = () => { const [tmdbBanner, setTmdbBanner] = useState(null); const [metahubBanner, setMetahubBanner] = useState(null); const [loadingLogos, setLoadingLogos] = useState(true); - // Track which language the preview is actually using and if it is a fallback - const [previewLanguage, setPreviewLanguage] = useState(''); - const [isPreviewFallback, setIsPreviewFallback] = useState(false); // State for TMDB language selection // Store unique language codes as strings @@ -477,7 +471,6 @@ 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'); @@ -486,27 +479,22 @@ 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) @@ -615,24 +603,8 @@ 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); - } } } @@ -861,18 +833,15 @@ const LogoSourceSettings = () => { Example: {renderLogoExample(tmdbLogo, tmdbBanner, loadingLogos)} - - {`Preview language: ${(previewLanguage || '').toUpperCase() || 'N/A'}${isPreviewFallback ? ' (fallback)' : ''}`} - {selectedShow.name} logo from TMDB {/* TMDB Language Selector */} - {true && ( + {uniqueTmdbLanguages.length > 1 && ( Logo Language - Select your preferred language for TMDB logos (includes common languages like Arabic even if not shown in this preview). + Select your preferred language for TMDB logos. { scrollEventThrottle={32} decelerationRate="normal" > - {/* Merge unique languages from TMDB with a common list to ensure wider options */} - {Array.from(new Set([...uniqueTmdbLanguages, ...COMMON_TMDB_LANGUAGES])).map((langCode) => ( + {/* Iterate over unique language codes */} + {uniqueTmdbLanguages.map((langCode) => ( { const inputRef = useRef(null); const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); - // DropUpMenu state - const [menuVisible, setMenuVisible] = useState(false); - const [selectedItem, setSelectedItem] = useState(null); - const [isSaved, setIsSaved] = useState(false); - const [isWatched, setIsWatched] = useState(false); - const [refreshFlag, setRefreshFlag] = React.useState(false); - - // Update isSaved and isWatched when selectedItem changes - useEffect(() => { - if (!selectedItem) return; - (async () => { - // Check if item is in library - const items = await catalogService.getLibraryItems(); - const found = items.find((libItem: any) => libItem.id === selectedItem.id && libItem.type === selectedItem.type); - setIsSaved(!!found); - // Check watched status - const val = await AsyncStorage.getItem(`watched:${selectedItem.type}:${selectedItem.id}`); - setIsWatched(val === 'true'); - })(); - }, [selectedItem]); + // Animation values const searchBarWidth = useSharedValue(width - 32); const searchBarOpacity = useSharedValue(1); @@ -462,85 +441,35 @@ const SearchScreen = () => { ); }; - const SearchResultItem = ({ item, index, navigation, setSelectedItem, setMenuVisible, currentTheme, refreshFlag }: { - item: StreamingContent; - index: number; - navigation: any; - setSelectedItem: (item: StreamingContent) => void; - setMenuVisible: (visible: boolean) => void; - currentTheme: any; - refreshFlag: boolean; - }) => { - const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary); - const [watched, setWatched] = React.useState(false); - // Re-check status when refreshFlag changes - React.useEffect(() => { - AsyncStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true')); - const items = catalogService.getLibraryItems(); - const found = items.find((libItem: any) => libItem.id === item.id && libItem.type === item.type); - setInLibrary(!!found); - }, [refreshFlag, item.id, item.type]); - React.useEffect(() => { - const updateWatched = () => { - AsyncStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true')); - }; - updateWatched(); - const sub = DeviceEventEmitter.addListener('watchedStatusChanged', updateWatched); - return () => sub.remove(); - }, [item.id, item.type]); - React.useEffect(() => { - const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => { - const found = items.find((libItem) => libItem.id === item.id && libItem.type === item.type); - setInLibrary(!!found); - }); - return () => unsubscribe(); - }, [item.id, item.type]); + const renderHorizontalItem = ({ item, index }: { item: StreamingContent, index: number }) => { return ( { navigation.navigate('Metadata', { id: item.id, type: item.type }); }} - onLongPress={() => { - setSelectedItem(item); - setMenuVisible(true); - // Do NOT toggle refreshFlag here - }} - delayLongPress={300} entering={FadeIn.duration(300).delay(index * 50)} activeOpacity={0.7} > + }]}> - {/* Bookmark and watched icons top right, bookmark to the left of watched */} - {inLibrary && ( - - - - )} - {watched && ( - - - - )} - {/* 'series'/'movie' text in original place */} - + {item.type === 'movie' ? 'MOVIE' : 'SERIES'} {item.imdbRating && ( - + {item.imdbRating} @@ -553,7 +482,7 @@ const SearchScreen = () => { {item.name} {item.year && ( - + {item.year} )} @@ -577,17 +506,6 @@ const SearchScreen = () => { const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; const headerHeight = headerBaseHeight + topSpacing + 60; - useEffect(() => { - const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', () => setRefreshFlag(f => !f)); - const librarySub = catalogService.subscribeToLibraryUpdates(() => setRefreshFlag(f => !f)); - const focusSub = navigation.addListener('focus', () => setRefreshFlag(f => !f)); - return () => { - watchedSub.remove(); - librarySub(); - focusSub(); - }; - }, []); - return ( { backgroundColor="transparent" translucent /> + {/* Fixed position header background to prevent shifts */} + {/* Header Section with proper top spacing */} @@ -660,6 +580,7 @@ const SearchScreen = () => { + {/* Content Container */} {searching ? ( @@ -679,10 +600,10 @@ const SearchScreen = () => { size={64} color={currentTheme.colors.lightGray} /> - + Keep typing... - + Type at least 2 characters to search @@ -696,10 +617,10 @@ const SearchScreen = () => { size={64} color={currentTheme.colors.lightGray} /> - + No results found - + Try different keywords or check your spelling @@ -713,110 +634,48 @@ const SearchScreen = () => { showsVerticalScrollIndicator={false} > {!query.trim() && renderRecentSearches()} + {movieResults.length > 0 && ( - + Movies ({movieResults.length}) ( - - )} + renderItem={renderHorizontalItem} keyExtractor={item => `movie-${item.id}`} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.horizontalListContent} - extraData={refreshFlag} /> )} + {seriesResults.length > 0 && ( - + TV Shows ({seriesResults.length}) ( - - )} + renderItem={renderHorizontalItem} keyExtractor={item => `series-${item.id}`} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.horizontalListContent} - extraData={refreshFlag} /> )} + )} - {/* DropUpMenu integration for search results */} - {selectedItem && ( - setMenuVisible(false)} - item={selectedItem} - isSaved={isSaved} - isWatched={isWatched} - onOptionSelect={async (option: string) => { - if (!selectedItem) return; - switch (option) { - case 'share': { - let url = ''; - if (selectedItem.id) { - url = `https://www.imdb.com/title/${selectedItem.id}/`; - } - const message = `${selectedItem.name}\n${url}`; - Share.share({ message, url, title: selectedItem.name }); - break; - } - case 'library': { - if (isSaved) { - await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); - setIsSaved(false); - } else { - await catalogService.addToLibrary(selectedItem); - setIsSaved(true); - } - break; - } - case 'watched': { - const key = `watched:${selectedItem.type}:${selectedItem.id}`; - const newWatched = !isWatched; - await AsyncStorage.setItem(key, newWatched ? 'true' : 'false'); - setIsWatched(newWatched); - break; - } - default: - break; - } - }} - /> - )} ); @@ -1095,24 +954,6 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', }, - watchedIndicator: { - position: 'absolute', - top: 8, - right: 8, - borderRadius: 12, - padding: 2, - zIndex: 2, - backgroundColor: 'transparent', - }, - libraryBadge: { - position: 'absolute', - top: 8, - right: 36, - borderRadius: 8, - padding: 4, - zIndex: 2, - backgroundColor: 'transparent', - }, }); export default SearchScreen; \ No newline at end of file diff --git a/src/screens/TMDBSettingsScreen.tsx b/src/screens/TMDBSettingsScreen.tsx index b6bc446..b505a85 100644 --- a/src/screens/TMDBSettingsScreen.tsx +++ b/src/screens/TMDBSettingsScreen.tsx @@ -17,7 +17,6 @@ import { Image, KeyboardAvoidingView, TouchableWithoutFeedback, - Modal, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; @@ -51,8 +50,6 @@ 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, @@ -287,431 +284,165 @@ const TMDBSettingsScreen = () => { - - {/* Metadata Enrichment Section */} - - - - Metadata Enrichment + + + Enrich Metadata with TMDb + When enabled, the app augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback. Disable to strictly use addon metadata only. - - Enhance your content metadata with TMDb data for better details and information. - - - - - Enable Enrichment - - Augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback. - - - 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)'} - /> + 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)'} + /> + + + + Use Custom TMDb API Key + + 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. + - - {settings.enrichMetadataWithTMDB && ( - <> - - - - - Localized Text - - Fetch titles and descriptions in your preferred language from TMDb. - - - 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)'} - /> - - - {settings.useTmdbLocalizedMetadata && ( - <> - - - - - Language - - Current: {(settings.tmdbLanguagePreference || 'en').toUpperCase()} - - - setLanguagePickerVisible(true)} - style={[styles.languageButton, { backgroundColor: currentTheme.colors.primary }]} - > - Change - - - - )} - - )} + - {/* API Configuration Section */} - - - - API Configuration - - - Configure your TMDb API access for enhanced functionality. - - - - - Custom API Key - - Use your own TMDb API key for better performance and dedicated rate limits. - - - - - - {useCustomKey && ( - <> - - - {/* API Key Status */} - - - - {isKeySet ? "Custom API key active" : "API key required"} + {useCustomKey && ( + <> + + + + + {isKeySet ? "API Key Active" : "API Key Required"} + + + {isKeySet + ? "Your custom TMDb API key is set and active." + : "Add your TMDb API key below."} + - {/* API Key Input */} - - - { - 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)} - /> - - - - - - - - Save - - - {isKeySet && ( - - Clear - - )} - - - {testResult && ( - - - - {testResult.message} - - - )} - - + API Key + + { + 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)} + /> + - - - How to get a TMDb API key? - + - - )} - {!useCustomKey && ( - - + + + Save API Key + + + {isKeySet && ( + + Clear + + )} + + + {testResult && ( + + + + {testResult.message} + + + )} + + + + + How to get a TMDb API key? + + + + + + - Currently using built-in API key. Consider using your own key for better performance. + 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. - )} - + + )} - {/* Language Picker Modal */} - setLanguagePickerVisible(false)} - > - setLanguagePickerVisible(false)}> - - - - {/* Header */} - - - Choose Language - Select your preferred language for TMDb content - - - {/* Search Section */} - - - - - {languageSearch.length > 0 && ( - setLanguageSearch('')} style={styles.searchClearButton}> - - - )} - - - - {/* Popular Languages */} - {languageSearch.length === 0 && ( - - Popular - - {[ - { code: 'en', label: 'EN' }, - { code: 'ar', label: 'AR' }, - { code: 'es', label: 'ES' }, - { code: 'fr', label: 'FR' }, - { code: 'de', label: 'DE' }, - { code: 'tr', label: 'TR' }, - ].map(({ code, label }) => ( - { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }} - style={[ - styles.popularChip, - settings.tmdbLanguagePreference === code && styles.selectedChip, - { - backgroundColor: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.elevation1, - borderColor: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : 'rgba(255,255,255,0.1)', - } - ]} - > - - {label} - - - ))} - - - )} - - {/* All Languages */} - - 0 && styles.searchResultsTitle, - { color: languageSearch.length > 0 ? currentTheme.colors.text : currentTheme.colors.mediumEmphasis } - ]}> - {languageSearch.length > 0 ? 'Search Results' : 'All Languages'} - - - - {(() => { - const languages = [ - { code: 'en', label: 'English', native: 'English' }, - { code: 'ar', label: 'العربية', native: 'Arabic' }, - { code: 'es', label: 'Español', native: 'Spanish' }, - { code: 'fr', label: 'Français', native: 'French' }, - { code: 'de', label: 'Deutsch', native: 'German' }, - { code: 'it', label: 'Italiano', native: 'Italian' }, - { code: 'pt', label: 'Português', native: 'Portuguese' }, - { code: 'ru', label: 'Русский', native: 'Russian' }, - { code: 'tr', label: 'Türkçe', native: 'Turkish' }, - { code: 'ja', label: '日本語', native: 'Japanese' }, - { code: 'ko', label: '한국어', native: 'Korean' }, - { code: 'zh', label: '中文', native: 'Chinese' }, - { code: 'hi', label: 'हिन्दी', native: 'Hindi' }, - { code: 'he', label: 'עברית', native: 'Hebrew' }, - { code: 'id', label: 'Bahasa Indonesia', native: 'Indonesian' }, - { code: 'nl', label: 'Nederlands', native: 'Dutch' }, - { code: 'sv', label: 'Svenska', native: 'Swedish' }, - { code: 'no', label: 'Norsk', native: 'Norwegian' }, - { code: 'da', label: 'Dansk', native: 'Danish' }, - { code: 'fi', label: 'Suomi', native: 'Finnish' }, - { code: 'pl', label: 'Polski', native: 'Polish' }, - { code: 'cs', label: 'Čeština', native: 'Czech' }, - { code: 'ro', label: 'Română', native: 'Romanian' }, - { code: 'uk', label: 'Українська', native: 'Ukrainian' }, - { code: 'vi', label: 'Tiếng Việt', native: 'Vietnamese' }, - { code: 'th', label: 'ไทย', native: 'Thai' }, - ]; - - const filteredLanguages = languages.filter(({ label, code, native }) => - (languageSearch || '').length === 0 || - label.toLowerCase().includes(languageSearch.toLowerCase()) || - native.toLowerCase().includes(languageSearch.toLowerCase()) || - code.toLowerCase().includes(languageSearch.toLowerCase()) - ); - - return ( - <> - {filteredLanguages.map(({ code, label, native }) => ( - { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }} - style={[ - styles.languageItem, - settings.tmdbLanguagePreference === code && styles.selectedLanguageItem - ]} - activeOpacity={0.7} - > - - - - {native} - - - {label} • {code.toUpperCase()} - - - {settings.tmdbLanguagePreference === code && ( - - - - )} - - - ))} - {languageSearch.length > 0 && filteredLanguages.length === 0 && ( - - - - No languages found for "{languageSearch}" - - setLanguageSearch('')} - style={[styles.clearSearchButton, { backgroundColor: currentTheme.colors.elevation1 }]} - > - Clear search - - - )} - - ); - })()} - - - - {/* Footer Actions */} - - setLanguagePickerVisible(false)} - style={styles.cancelButton} - > - Cancel - - setLanguagePickerVisible(false)} - style={[styles.doneButton, { backgroundColor: currentTheme.colors.primary }]} - > - Done - - - - - - - + {!useCustomKey && ( + + + + 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. + + + )} { + async getTVShowDetails(tmdbId: number): Promise { try { const response = await axios.get(`${BASE_URL}/tv/${tmdbId}`, { headers: await this.getHeaders(), params: await this.getParams({ - language, + language: 'en-US', 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, language: string = 'en-US'): Promise { + async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string): Promise { try { const response = await axios.get(`${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}`, { headers: await this.getHeaders(), params: await this.getParams({ - language, + language: 'en-US', }), }); @@ -292,8 +292,7 @@ export class TMDBService { async getEpisodeDetails( tmdbId: number, seasonNumber: number, - episodeNumber: number, - language: string = 'en-US' + episodeNumber: number ): Promise { try { const response = await axios.get( @@ -301,7 +300,7 @@ export class TMDBService { { headers: await this.getHeaders(), params: await this.getParams({ - language, + language: 'en-US', append_to_response: 'credits' // Include guest stars and crew for episode context }), } @@ -547,14 +546,14 @@ export class TMDBService { } } - async getRecommendations(type: 'movie' | 'tv', tmdbId: string, language: string = 'en-US'): Promise { + async getRecommendations(type: 'movie' | 'tv', tmdbId: string): Promise { if (!this.apiKey) { return []; } try { const response = await axios.get(`${BASE_URL}/${type}/${tmdbId}/recommendations`, { headers: await this.getHeaders(), - params: await this.getParams({ language }) + params: await this.getParams({ language: 'en-US' }) }); return response.data.results || []; } catch (error) { @@ -582,12 +581,12 @@ export class TMDBService { /** * Get movie details by TMDB ID */ - async getMovieDetails(movieId: string, language: string = 'en'): Promise { + async getMovieDetails(movieId: string): Promise { try { const response = await axios.get(`${BASE_URL}/movie/${movieId}`, { headers: await this.getHeaders(), params: await this.getParams({ - language, + language: 'en-US', append_to_response: 'external_ids,credits,keywords,release_dates' // Include release dates for accurate availability }), });