From b49514f52bc32fded48d3dc9c7bff6ddd55208d1 Mon Sep 17 00:00:00 2001 From: CrissZollo Date: Mon, 29 Sep 2025 20:36:41 +0200 Subject: [PATCH 01/10] Update npm packages --- package-lock.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f36b7ee..67a48ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,8 @@ "react-native-url-polyfill": "^2.0.0", "react-native-video": "^6.12.0", "react-native-web": "~0.19.13", - "react-native-wheel-color-picker": "^1.3.1" + "react-native-wheel-color-picker": "^1.3.1", + "undici": "^7.16.0" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -15012,8 +15013,6 @@ "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" } -- 2.45.2 From 7fa4d20da009776a08605ce12e581008c923a499 Mon Sep 17 00:00:00 2001 From: CrissZollo Date: Mon, 29 Sep 2025 21:16:05 +0200 Subject: [PATCH 02/10] Fix for mark as watched disappearing on restart --- src/components/home/ContentItem.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index a63dd2c..86eb776 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -6,6 +6,7 @@ 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; @@ -70,6 +71,12 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe }); return () => unsubscribe(); }, [item.id, item.type]); + + // Load watched state from AsyncStorage when item changes + useEffect(() => { + AsyncStorage.getItem(`watched_${item.id}`).then(val => setIsWatched(val === 'true')); + }, [item.id]); + const [menuVisible, setMenuVisible] = useState(false); const [isWatched, setIsWatched] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); @@ -111,9 +118,15 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe catalogService.addToLibrary(item); } break; - case 'watched': - setIsWatched(prev => !prev); + case 'watched': { + setIsWatched(prevWatched => { + const newWatched = !prevWatched; + AsyncStorage.setItem(`watched_${item.id}`, newWatched ? 'true' : 'false'); + return newWatched; + }); + setMenuVisible(false); break; + } case 'playlist': break; case 'share': -- 2.45.2 From 35ace0214a6bfa30a7eb69f680edc8f915fb8161 Mon Sep 17 00:00:00 2001 From: CrissZollo Date: Mon, 29 Sep 2025 22:10:03 +0200 Subject: [PATCH 03/10] Added the longpress menu for the library menu --- src/components/home/ContentItem.tsx | 20 +++- src/components/home/DropUpMenu.tsx | 11 ++- src/screens/HomeScreen.tsx | 15 ++- src/screens/LibraryScreen.tsx | 136 ++++++++++++++++++++++++++-- 4 files changed, 168 insertions(+), 14 deletions(-) diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 86eb776..9e491e4 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -1,4 +1,6 @@ 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 } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; @@ -74,8 +76,13 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe // Load watched state from AsyncStorage when item changes useEffect(() => { - AsyncStorage.getItem(`watched_${item.id}`).then(val => setIsWatched(val === 'true')); - }, [item.id]); + 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); @@ -114,14 +121,21 @@ 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.id}`, newWatched ? 'true' : 'false'); + 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); diff --git a/src/components/home/DropUpMenu.tsx b/src/components/home/DropUpMenu.tsx index d84ca62..d75725c 100644 --- a/src/components/home/DropUpMenu.tsx +++ b/src/components/home/DropUpMenu.tsx @@ -92,10 +92,10 @@ 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; - const menuOptions = [ + let menuOptions = [ { - icon: isSaved ? 'bookmark' : 'bookmark-border', - label: isSaved ? 'Remove from Library' : 'Add to Library', + icon: 'bookmark', + label: 'Remove from Library', action: 'library' }, { @@ -115,6 +115,11 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is } ]; + // If used in LibraryScreen, only show 'Remove from Library' + if (isSavedProp === true) { + menuOptions = menuOptions.filter(opt => opt.action !== 'library' || opt.label === 'Remove from Library'); + } + const backgroundColor = isDarkMode ? '#1A1A1A' : '#FFFFFF'; return ( diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 293aed7..854e526 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1337,4 +1337,17 @@ const styles = StyleSheet.create({ }, }); -export default React.memo(HomeScreen); \ No newline at end of file +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 diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 71a6848..71d238a 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -1,4 +1,9 @@ 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, @@ -40,6 +45,7 @@ interface LibraryItem extends StreamingContent { imdbId?: string; traktId: number; images?: TraktImages; + watched?: boolean; } interface TraktDisplayItem { @@ -205,6 +211,9 @@ 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(); @@ -267,7 +276,22 @@ const LibraryScreen = () => { setLoading(true); try { const items = await catalogService.getLibraryItems(); - setLibraryItems(items as LibraryItem[]); + // 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); } catch (error) { logger.error('Failed to load library:', error); } finally { @@ -278,14 +302,37 @@ const LibraryScreen = () => { loadLibrary(); // Subscribe to library updates - const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => { - setLibraryItems(items as LibraryItem[]); + 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); }); + // 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'; @@ -348,17 +395,25 @@ 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} @@ -932,6 +987,65 @@ const LibraryScreen = () => { {showTraktContent ? renderTraktContent() : renderContent()} + + {/* DropUpMenu integration */} + {selectedItem && ( + setMenuVisible(false)} + item={selectedItem} + isWatched={!!selectedItem.watched} + onOptionSelect={async (option) => { + if (!selectedItem) return; + if (option === 'share') { + let url = ''; + if (selectedItem.imdbId) { + url = `https://www.imdb.com/title/${selectedItem.imdbId}/`; + } else if (selectedItem.traktId) { + url = `https://trakt.tv/${selectedItem.type === 'movie' ? 'movies' : 'shows'}/${selectedItem.traktId}`; + } else { + url = selectedItem.poster || ''; + } + try { + await Share.share({ + message: `${selectedItem.name}\n${url}`, + url, + title: selectedItem.name, + }); + } catch (error) { + toast('Failed to share', { duration: 1200 }); + } + } else if (option === 'library') { + try { + // Always remove from library in LibraryScreen + 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 }); + } + } else if (option === '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 }); + } + } + }} + isSaved={!!selectedItem.inLibrary} + /> + )} ); }; @@ -947,6 +1061,14 @@ const styles = StyleSheet.create({ right: 0, zIndex: 1, }, + watchedIndicator: { + position: 'absolute', + top: 8, + right: 8, + borderRadius: 12, + padding: 2, + zIndex: 2, + }, contentContainer: { flex: 1, }, -- 2.45.2 From 04f6a0b6be6b012ff6818b09f6cdc371c7dc0392 Mon Sep 17 00:00:00 2001 From: CrissZollo Date: Mon, 29 Sep 2025 22:35:54 +0200 Subject: [PATCH 04/10] Fixes for adding/removing watched/library from library/home --- src/components/home/ContentItem.tsx | 11 +++++-- src/components/home/DropUpMenu.tsx | 6 ++-- src/screens/LibraryScreen.tsx | 47 ++++++++++++++--------------- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 9e491e4..e7d44c4 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -1,7 +1,7 @@ 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 } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Animated, Share } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../../contexts/ThemeContext'; @@ -143,8 +143,15 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe } case 'playlist': break; - case 'share': + 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 }); break; + } } }, [item, inLibrary]); diff --git a/src/components/home/DropUpMenu.tsx b/src/components/home/DropUpMenu.tsx index d75725c..391630a 100644 --- a/src/components/home/DropUpMenu.tsx +++ b/src/components/home/DropUpMenu.tsx @@ -95,7 +95,7 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is let menuOptions = [ { icon: 'bookmark', - label: 'Remove from Library', + label: isSaved ? 'Remove from Library' : 'Add to Library', action: 'library' }, { @@ -115,9 +115,9 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is } ]; - // If used in LibraryScreen, only show 'Remove from Library' + // If used in LibraryScreen, only show 'Remove from Library' if item is in library if (isSavedProp === true) { - menuOptions = menuOptions.filter(opt => opt.action !== 'library' || opt.label === 'Remove from Library'); + menuOptions = menuOptions.filter(opt => opt.action !== 'library' || isSaved); } const backgroundColor = isDarkMode ? '#1A1A1A' : '#FFFFFF'; diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 71d238a..7e7d230 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -995,29 +995,12 @@ const LibraryScreen = () => { onClose={() => setMenuVisible(false)} item={selectedItem} isWatched={!!selectedItem.watched} + isSaved={true} // Since this is from library, it's always saved onOptionSelect={async (option) => { if (!selectedItem) return; - if (option === 'share') { - let url = ''; - if (selectedItem.imdbId) { - url = `https://www.imdb.com/title/${selectedItem.imdbId}/`; - } else if (selectedItem.traktId) { - url = `https://trakt.tv/${selectedItem.type === 'movie' ? 'movies' : 'shows'}/${selectedItem.traktId}`; - } else { - url = selectedItem.poster || ''; - } + switch (option) { + case 'library': { try { - await Share.share({ - message: `${selectedItem.name}\n${url}`, - url, - title: selectedItem.name, - }); - } catch (error) { - toast('Failed to share', { duration: 1200 }); - } - } else if (option === 'library') { - try { - // Always remove from library in LibraryScreen 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))); @@ -1025,7 +1008,9 @@ const LibraryScreen = () => { } catch (error) { toast('Failed to update Library', { duration: 1200 }); } - } else if (option === 'watched') { + break; + } + case 'watched': { try { // Use AsyncStorage to store watched status by key const key = `watched:${selectedItem.type}:${selectedItem.id}`; @@ -1034,16 +1019,28 @@ const LibraryScreen = () => { 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 + 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; } }} - isSaved={!!selectedItem.inLibrary} /> )} -- 2.45.2 From b3b6bdee4ee316c23c46bd96ee447c8247cff749 Mon Sep 17 00:00:00 2001 From: CrissZollo Date: Mon, 29 Sep 2025 23:06:57 +0200 Subject: [PATCH 05/10] Added long press on search items. --- src/screens/SearchScreen.tsx | 199 +++++++++++++++++++++++++++++++---- 1 file changed, 179 insertions(+), 20 deletions(-) diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 0daad60..e6b9568 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -24,6 +24,8 @@ import { MaterialIcons } from '@expo/vector-icons'; import { catalogService, StreamingContent } from '../services/catalogService'; import { Image } from 'expo-image'; import debounce from 'lodash/debounce'; +import { DropUpMenu } from '../components/home/DropUpMenu'; +import { DeviceEventEmitter, Share } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import Animated, { FadeIn, @@ -207,7 +209,26 @@ const SearchScreen = () => { 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); @@ -441,35 +462,85 @@ const SearchScreen = () => { ); }; - const renderHorizontalItem = ({ item, index }: { item: StreamingContent, index: number }) => { + 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]); 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} @@ -482,7 +553,7 @@ const SearchScreen = () => { {item.name} {item.year && ( - + {item.year} )} @@ -506,6 +577,17 @@ 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 */} @@ -580,7 +660,6 @@ const SearchScreen = () => { - {/* Content Container */} {searching ? ( @@ -600,10 +679,10 @@ const SearchScreen = () => { size={64} color={currentTheme.colors.lightGray} /> - + Keep typing... - + Type at least 2 characters to search @@ -617,10 +696,10 @@ const SearchScreen = () => { size={64} color={currentTheme.colors.lightGray} /> - + No results found - + Try different keywords or check your spelling @@ -634,48 +713,110 @@ const SearchScreen = () => { showsVerticalScrollIndicator={false} > {!query.trim() && renderRecentSearches()} - {movieResults.length > 0 && ( - + Movies ({movieResults.length}) ( + + )} keyExtractor={item => `movie-${item.id}`} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.horizontalListContent} + extraData={refreshFlag} /> )} - {seriesResults.length > 0 && ( - + TV Shows ({seriesResults.length}) ( + + )} 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; + } + }} + /> + )} ); @@ -954,6 +1095,24 @@ 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 -- 2.45.2 From 7ca74b3b0d8a55dd0e677f5fbb32bc1202e0f741 Mon Sep 17 00:00:00 2001 From: CrissZollo Date: Tue, 30 Sep 2025 19:29:42 +0200 Subject: [PATCH 06/10] Adding packages and updates --- package-lock.json | 16 +++------------- package.json | 4 ++-- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67a48ca..9d3c2b2 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.11.0", + "axios": "^1.12.2", "axios-cookiejar-support": "^6.0.4", "cheerio-without-node-native": "^0.20.2", "crypto-js": "^4.2.0", @@ -74,8 +74,7 @@ "react-native-url-polyfill": "^2.0.0", "react-native-video": "^6.12.0", "react-native-web": "~0.19.13", - "react-native-wheel-color-picker": "^1.3.1", - "undici": "^7.16.0" + "react-native-wheel-color-picker": "^1.3.1" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -15008,15 +15007,6 @@ "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", - "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 99c31ee..8a91332 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.11.0", + "axios": "^1.12.2", "axios-cookiejar-support": "^6.0.4", "cheerio-without-node-native": "^0.20.2", "crypto-js": "^4.2.0", -- 2.45.2 From 2da663ecd1873d66e38d9c9f156fa7bda7077c69 Mon Sep 17 00:00:00 2001 From: CrissZollo Date: Tue, 30 Sep 2025 20:51:59 +0200 Subject: [PATCH 07/10] Hid the 'Add to playlist' for action menu. --- src/components/home/DropUpMenu.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/home/DropUpMenu.tsx b/src/components/home/DropUpMenu.tsx index 391630a..bbe9c04 100644 --- a/src/components/home/DropUpMenu.tsx +++ b/src/components/home/DropUpMenu.tsx @@ -103,11 +103,13 @@ 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', -- 2.45.2 From 3220e91f1c855dcc1d97d02b09c10b075366d0fd Mon Sep 17 00:00:00 2001 From: tapframe Date: Wed, 1 Oct 2025 00:41:34 +0530 Subject: [PATCH 08/10] Added TMDB Multilang Support --- src/hooks/useMetadata.ts | 109 +++++- src/hooks/useSettings.ts | 2 + src/screens/LogoSourceSettings.tsx | 39 +- src/screens/TMDBSettingsScreen.tsx | 565 ++++++++++++++++++----------- src/services/tmdbService.ts | 21 +- 5 files changed, 501 insertions(+), 235 deletions(-) diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 185a1ae..0790fb4 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 } = useSettings(); + const { settings, isLoaded: settingsLoaded } = useSettings(); const [metadata, setMetadata] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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(() => { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index f782b5b..030d8ae 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -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'; diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 2f5b5d0..70c1a3e 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -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(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 @@ -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 = () => { Example: {renderLogoExample(tmdbLogo, tmdbBanner, loadingLogos)} + + {`Preview language: ${(previewLanguage || '').toUpperCase() || 'N/A'}${isPreviewFallback ? ' (fallback)' : ''}`} + {selectedShow.name} logo from TMDB {/* TMDB Language Selector */} - {uniqueTmdbLanguages.length > 1 && ( + {true && ( Logo Language - 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). { 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([...uniqueTmdbLanguages, ...COMMON_TMDB_LANGUAGES])).map((langCode) => ( { 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 = () => { - - - - 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. + {/* Metadata Enrichment Section */} + + + + Metadata Enrichment - 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. - - - - + + Enhance your content metadata with TMDb data for better details and information. + - {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 - - { - 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 API Key - - - {isKeySet && ( - - Clear - - )} - - - {testResult && ( - - - - {testResult.message} - - - )} - - - - - How to get a TMDb API key? - - - - - - - - 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. + + + Enable Enrichment + + Augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback. - - )} - - {!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. - + 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)'} + /> - )} + + {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"} + + + + {/* 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} + + + )} + + + + + How to get a TMDb API key? + + + + + )} + + {!useCustomKey && ( + + + + Currently using built-in API key. Consider using your own key for better performance. + + + )} + + + {/* Language Picker Modal */} + setLanguagePickerVisible(false)} + > + + + + Select Language + + + + + setLanguageSearch('')} style={{ marginLeft: 8, paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, backgroundColor: currentTheme.colors.elevation1 }}> + Clear + + + {/* Most used quick chips */} + + {['en','ar','es','fr','de','tr'].map(code => ( + { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }} style={{ paddingHorizontal: 10, paddingVertical: 6, backgroundColor: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.elevation1, borderRadius: 999, marginRight: 8 }}> + {code.toUpperCase()} + + ))} + + + {[ + { 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 }) => ( + { 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} + > + + + {label} ({code.toUpperCase()}) + + {settings.tmdbLanguagePreference === code && ( + + )} + + + ))} + + setLanguagePickerVisible(false)} style={{ marginTop: 12, paddingVertical: 12, alignItems: 'center', borderRadius: 10, backgroundColor: currentTheme.colors.primary }}> + Done + + + + { + async getTVShowDetails(tmdbId: number, language: string = 'en'): Promise { 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 { + async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string, language: string = 'en-US'): Promise { 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 { 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 { + async getRecommendations(type: 'movie' | 'tv', tmdbId: string, language: string = 'en-US'): 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: '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 { + async getMovieDetails(movieId: string, language: string = 'en'): Promise { 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 }), }); -- 2.45.2 From 7de8a868690b06891e41baf110be8db3efc98fa7 Mon Sep 17 00:00:00 2001 From: tapframe Date: Wed, 1 Oct 2025 00:52:03 +0530 Subject: [PATCH 09/10] UI changes --- src/screens/TMDBSettingsScreen.tsx | 480 +++++++++++++++++++++++------ 1 file changed, 392 insertions(+), 88 deletions(-) diff --git a/src/screens/TMDBSettingsScreen.tsx b/src/screens/TMDBSettingsScreen.tsx index e0e0c74..b6bc446 100644 --- a/src/screens/TMDBSettingsScreen.tsx +++ b/src/screens/TMDBSettingsScreen.tsx @@ -506,91 +506,211 @@ const TMDBSettingsScreen = () => { setLanguagePickerVisible(false)} > - - - - Select Language - - - - - setLanguageSearch('')} style={{ marginLeft: 8, paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, backgroundColor: currentTheme.colors.elevation1 }}> - Clear - - - {/* Most used quick chips */} - - {['en','ar','es','fr','de','tr'].map(code => ( - { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }} style={{ paddingHorizontal: 10, paddingVertical: 6, backgroundColor: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.elevation1, borderRadius: 999, marginRight: 8 }}> - {code.toUpperCase()} - - ))} - - - {[ - { 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 }) => ( - { 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} - > - - - {label} ({code.toUpperCase()}) - - {settings.tmdbLanguagePreference === code && ( - + setLanguagePickerVisible(false)}> + + + + {/* Header */} + + + Choose Language + Select your preferred language for TMDb content + + + {/* Search Section */} + + + + + {languageSearch.length > 0 && ( + setLanguageSearch('')} style={styles.searchClearButton}> + + )} - - ))} - - setLanguagePickerVisible(false)} style={{ marginTop: 12, paddingVertical: 12, alignItems: 'center', borderRadius: 10, backgroundColor: currentTheme.colors.primary }}> - Done - + + + {/* 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 + + + + - + Date: Tue, 30 Sep 2025 21:32:00 +0200 Subject: [PATCH 10/10] Small fixes to timeline time --- src/components/metadata/MetadataDetails.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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 && ( -- 2.45.2