From 1948abc9225136e2fc6c326a0dae82314a0bee20 Mon Sep 17 00:00:00 2001 From: Nayif Noushad Date: Thu, 17 Apr 2025 23:36:01 +0530 Subject: [PATCH] Refactor App structure to include GenreProvider; update dependencies in package.json and package-lock.json; enhance UI components with animations and improved styles; implement TMDB API key management; optimize metadata loading and caching; add new settings for catalog management. --- App.tsx | 27 +- ... Image 2025-04-17 at 03.45.56_f37ab22f.jpg | Bin 0 -> 49075 bytes assets/rating-icons/Metacritic.png | Bin 0 -> 5468 bytes assets/rating-icons/Metacritic.svg | 5 + assets/rating-icons/RottenTomatoes.svg | 20 + assets/rating-icons/audienscore.png | Bin 0 -> 5239 bytes assets/rating-icons/imdb.png | Bin 0 -> 3709 bytes assets/rating-icons/letterboxd.svg | 34 + assets/rating-icons/tmdb.svg | 1 + assets/rating-icons/trakt.svg | 1 + package-lock.json | 52 +- package.json | 3 +- plan.md | 83 ++ src/components/metadata/CastSection.tsx | 4 +- src/components/metadata/RatingsSection.tsx | 314 ++++++ src/components/metadata/SeriesContent.tsx | 63 +- src/contexts/GenreContext.tsx | 80 ++ src/hooks/useFeaturedContent.ts | 227 +++++ src/hooks/useHomeCatalogs.ts | 87 ++ src/hooks/useMDBListRatings.ts | 49 + src/hooks/useMetadata.ts | 225 ++++- src/hooks/useSettings.ts | 40 +- src/navigation/AppNavigator.tsx | 184 +++- src/screens/AddonsScreen.tsx | 895 ++++++++++-------- src/screens/CatalogScreen.tsx | 117 ++- src/screens/CatalogSettingsScreen.tsx | 339 +++++-- src/screens/DiscoverScreen.tsx | 561 ++++++----- src/screens/HeroCatalogsScreen.tsx | 318 +++++++ src/screens/HomeScreen.tsx | 577 +++++------ src/screens/HomeScreenSettings.tsx | 472 +++++++++ src/screens/LibraryScreen.tsx | 265 +++--- src/screens/MDBListSettingsScreen.tsx | 824 ++++++++++++++++ src/screens/MetadataScreen.tsx | 504 ++++++---- src/screens/SettingsScreen.tsx | 537 ++++++----- src/screens/TMDBSettingsScreen.tsx | 621 ++++++++++++ src/services/mdblistService.ts | 182 ++++ src/services/tmdbService.ts | 335 ++++++- src/temp_settings_screen.tsx | 0 src/types/images.d.ts | 10 + 39 files changed, 6234 insertions(+), 1822 deletions(-) create mode 100644 assets/WhatsApp Image 2025-04-17 at 03.45.56_f37ab22f.jpg create mode 100644 assets/rating-icons/Metacritic.png create mode 100644 assets/rating-icons/Metacritic.svg create mode 100644 assets/rating-icons/RottenTomatoes.svg create mode 100644 assets/rating-icons/audienscore.png create mode 100644 assets/rating-icons/imdb.png create mode 100644 assets/rating-icons/letterboxd.svg create mode 100644 assets/rating-icons/tmdb.svg create mode 100644 assets/rating-icons/trakt.svg create mode 100644 plan.md create mode 100644 src/components/metadata/RatingsSection.tsx create mode 100644 src/contexts/GenreContext.tsx create mode 100644 src/hooks/useFeaturedContent.ts create mode 100644 src/hooks/useHomeCatalogs.ts create mode 100644 src/hooks/useMDBListRatings.ts create mode 100644 src/screens/HeroCatalogsScreen.tsx create mode 100644 src/screens/HomeScreenSettings.tsx create mode 100644 src/screens/MDBListSettingsScreen.tsx create mode 100644 src/screens/TMDBSettingsScreen.tsx create mode 100644 src/services/mdblistService.ts create mode 100644 src/temp_settings_screen.tsx create mode 100644 src/types/images.d.ts diff --git a/App.tsx b/App.tsx index 86b07237..24c504df 100644 --- a/App.tsx +++ b/App.tsx @@ -20,6 +20,7 @@ import AppNavigator, { } from './src/navigation/AppNavigator'; import 'react-native-reanimated'; import { CatalogProvider } from './src/contexts/CatalogContext'; +import { GenreProvider } from './src/contexts/GenreContext'; function App(): React.JSX.Element { // Always use dark mode @@ -27,18 +28,20 @@ function App(): React.JSX.Element { return ( - - - - - - - - - - + + + + + + + + + + + + ); } diff --git a/assets/WhatsApp Image 2025-04-17 at 03.45.56_f37ab22f.jpg b/assets/WhatsApp Image 2025-04-17 at 03.45.56_f37ab22f.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4ffb7f56e7f8edc6621a60a8e92b3cf9699549a3 GIT binary patch literal 49075 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

Mvg00ie^)C6$pufT=?%4FuoDg<4;F>IYT! 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 J1c1Mm{t4_bl%N0r literal 0 HcmV?d00001 diff --git a/assets/rating-icons/Metacritic.png b/assets/rating-icons/Metacritic.png new file mode 100644 index 0000000000000000000000000000000000000000..4a39e08581ac9ad18f746cc604533ff3d531f74d GIT binary patch literal 5468 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 DeF}$z literal 0 HcmV?d00001 diff --git a/assets/rating-icons/Metacritic.svg b/assets/rating-icons/Metacritic.svg new file mode 100644 index 00000000..d037d6f8 --- /dev/null +++ b/assets/rating-icons/Metacritic.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/rating-icons/RottenTomatoes.svg b/assets/rating-icons/RottenTomatoes.svg new file mode 100644 index 00000000..977253a9 --- /dev/null +++ b/assets/rating-icons/RottenTomatoes.svg @@ -0,0 +1,20 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/assets/rating-icons/audienscore.png b/assets/rating-icons/audienscore.png new file mode 100644 index 0000000000000000000000000000000000000000..7f68f8d573d2bae53fe40657f3e8d4e0aa08169a GIT binary patch literal 5239 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 literal 0 HcmV?d00001 diff --git a/assets/rating-icons/imdb.png b/assets/rating-icons/imdb.png new file mode 100644 index 0000000000000000000000000000000000000000..716db221b360040abe78bbd5b89fd0842a82d1d7 GIT binary patch literal 3709 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 + + + letterboxd-decal-dots-neg-rgb + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/rating-icons/tmdb.svg b/assets/rating-icons/tmdb.svg new file mode 100644 index 00000000..62a66055 --- /dev/null +++ b/assets/rating-icons/tmdb.svg @@ -0,0 +1 @@ +Asset 4 \ No newline at end of file diff --git a/assets/rating-icons/trakt.svg b/assets/rating-icons/trakt.svg new file mode 100644 index 00000000..ad8a155a --- /dev/null +++ b/assets/rating-icons/trakt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 125a6e96..f019a514 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "^14.1.0", + "@gorhom/bottom-sheet": "^5.1.2", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/blur": "^4.4.1", "@react-native-community/slider": "^4.5.6", @@ -45,7 +46,7 @@ "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", - "react-native-svg": "^15.8.0", + "react-native-svg": "^15.11.2", "react-native-video": "^6.12.0", "react-native-web": "~0.19.13", "subsrt": "^1.1.1" @@ -2881,6 +2882,45 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@gorhom/bottom-sheet": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.1.2.tgz", + "integrity": "sha512-5np8oL2krqAsVKLRE4YmtkZkyZeFiitoki72bEpVhZb8SRTNuAEeSbP3noq5srKpcRsboCr7uI+xmMyrWUd9kw==", + "license": "MIT", + "dependencies": { + "@gorhom/portal": "1.0.14", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-native": "*", + "react": "*", + "react-native": "*", + "react-native-gesture-handler": ">=2.16.1", + "react-native-reanimated": ">=3.16.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-native": { + "optional": true + } + } + }, + "node_modules/@gorhom/portal": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz", + "integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@ide/backoff": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", @@ -4466,7 +4506,7 @@ "version": "0.72.8", "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz", "integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@react-native/virtualized-lists": "^0.72.4", @@ -4487,7 +4527,7 @@ "version": "0.72.8", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz", "integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "invariant": "^2.2.4", @@ -10731,9 +10771,9 @@ } }, "node_modules/react-native-svg": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.8.0.tgz", - "integrity": "sha512-KHJzKpgOjwj1qeZzsBjxNdoIgv2zNCO9fVcoq2TEhTRsVV5DGTZ9JzUZwybd7q4giT/H3RdtqC3u44dWdO0Ffw==", + "version": "15.11.2", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.11.2.tgz", + "integrity": "sha512-+YfF72IbWQUKzCIydlijV1fLuBsQNGMT6Da2kFlo1sh+LE3BIm/2Q7AR1zAAR6L0BFLi1WaQPLfFUC9bNZpOmw==", "license": "MIT", "dependencies": { "css-select": "^5.1.0", diff --git a/package.json b/package.json index 10472c65..61ad751c 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "^14.1.0", + "@gorhom/bottom-sheet": "^5.1.2", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/blur": "^4.4.1", "@react-native-community/slider": "^4.5.6", @@ -46,7 +47,7 @@ "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", - "react-native-svg": "^15.8.0", + "react-native-svg": "^15.11.2", "react-native-video": "^6.12.0", "react-native-web": "~0.19.13", "subsrt": "^1.1.1" diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..f82ed142 --- /dev/null +++ b/plan.md @@ -0,0 +1,83 @@ +# HomeScreen Analysis and Improvement Plan + +This document outlines the analysis of the `HomeScreen.tsx` component and suggests potential improvements. + +## Analysis + +**Strengths:** + +1. **Component Structure:** Good use of breaking down UI into smaller, reusable components (`ContentItem`, `DropUpMenu`, `SkeletonCatalog`, `SkeletonFeatured`, `ThisWeekSection`, `ContinueWatchingSection`). +2. **Performance Optimizations:** + * Uses `FlatList` for horizontal catalogs with optimizations (`initialNumToRender`, `maxToRenderPerBatch`, `windowSize`, `removeClippedSubviews`, `getItemLayout`). + * Uses `expo-image` for optimized image loading, caching, and prefetching (`ExpoImage.prefetch`). Includes loading/error states per image. + * Leverages `useCallback` to memoize event handlers and functions. + * Uses `react-native-reanimated` and `react-native-gesture-handler` for performant animations/gestures. + * Parallel initial data loading (`Promise.all`). + * Uses `AbortController` to cancel stale fetch requests. +3. **User Experience:** + * Skeleton loaders (`SkeletonFeatured`, `SkeletonCatalog`). + * Pull-to-refresh (`RefreshControl`). + * Interactive `DropUpMenu` with smooth animations and gesture dismissal. + * Haptics feedback (`Haptics.impactAsync`). + * Reactive library status updates (`catalogService.subscribeToLibraryUpdates`). + * Screen focus events refresh "Continue Watching". + * Graceful handling of empty catalog states. +4. **Code Quality:** + * Uses TypeScript with interfaces. + * Separation of concerns via services (`catalogService`, `tmdbService`, `storageService`, `logger`). + * Basic error handling and logging. + +## Areas for Potential Improvement & Suggestions + +1. **Component Complexity (`HomeScreen`):** + * The main component is large and manages significant state/effects. + * **Suggestion:** Extract data fetching and related state into custom hooks (e.g., `useFeaturedContent`, `useHomeCatalogs`) to simplify `HomeScreen`. + * *Example Hook Structure:* + ```typescript + // hooks/useHomeCatalogs.ts + function useHomeCatalogs() { + const [catalogs, setCatalogs] = useState([]); + const [loading, setLoading] = useState(true); + // ... fetch logic from loadCatalogs ... + return { catalogs, loading, reloadCatalogs: loadCatalogs }; + } + ``` + +2. **Outer `FlatList` for Catalogs:** + * Using `FlatList` with `scrollEnabled={false}` disables its virtualization benefits. + * **Suggestion:** If the number of catalogs can grow large, this might impact performance. For a small, fixed number of catalogs, rendering directly in the `ScrollView` using `.map()` might be simpler. If virtualization is needed for many catalogs, revisit the structure (potentially enabling scroll on the outer `FlatList`, which can be complex with nested scrolling). + +3. **Hardcoded Values:** + * `GENRE_MAP`: TMDB genres can change. + * **Suggestion:** Fetch genre lists from the TMDB API (`/genre/movie/list`, `/genre/tv/list`) periodically and cache them (e.g., in context or async storage). + * `SAMPLE_CATEGORIES`: Ensure replacement if dynamic categories are needed. + +4. **Image Preloading Strategy:** + * `preloadImages` currently tries to preload posters, banners, and logos for *all* fetched featured items. + * **Suggestion:** If the trending list is long, this is bandwidth-intensive. Consider preloading only for the *initially selected* `featuredContent` or the first few items in the `allFeaturedContent` array to optimize resource usage. + +5. **Error Handling & Retries:** + * The `maxRetries` variable is defined but not used. + * **Suggestion:** Implement retry logic (e.g., with exponential backoff) in `catch` blocks for `loadCatalogs` and `loadFeaturedContent`, or remove the unused variable. Enhance user feedback on errors beyond console logs (e.g., Toast messages). + +6. **Type Safety (`StyleSheet.create`):** + * Styles use `StyleSheet.create`. + * **Suggestion:** Define a specific interface for styles using `ViewStyle`, `TextStyle`, `ImageStyle` from `react-native` for better type safety and autocompletion. + ```typescript + import { ViewStyle, TextStyle, ImageStyle } from 'react-native'; + + interface Styles { + container: ViewStyle; + // ... other styles + } + + const styles = StyleSheet.create({ ... }); + ``` + +7. **Featured Content Interaction:** + * The "Info" button fetches `stremioId` asynchronously. + * **Suggestion:** Add a loading indicator (e.g., disable button + `ActivityIndicator`) during the `getStremioId` call for better UX feedback. + +8. **Featured Content Rotation:** + * Auto-rotation is fixed at 15 seconds. + * **Suggestion (Minor UX):** Consider adding visual indicators (e.g., dots) for featured items, allow manual swiping, and pause the auto-rotation timer on user interaction. \ No newline at end of file diff --git a/src/components/metadata/CastSection.tsx b/src/components/metadata/CastSection.tsx index 950c827f..8c9e3e67 100644 --- a/src/components/metadata/CastSection.tsx +++ b/src/components/metadata/CastSection.tsx @@ -53,10 +53,10 @@ export const CastSection: React.FC = ({ onPress={() => onSelectCastMember(member)} > - {member.profile_path && tmdbService.getImageUrl(member.profile_path, 'w185') ? ( + {member.profile_path ? ( = ({ imdbId, type }) => { + const { ratings, loading, error } = useMDBListRatings(imdbId, type); + const [enabledProviders, setEnabledProviders] = useState>({}); + const [isMDBEnabled, setIsMDBEnabled] = useState(true); + const fadeAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + loadProviderSettings(); + checkMDBListEnabled(); + }, []); + + const checkMDBListEnabled = async () => { + try { + const enabled = await isMDBListEnabled(); + setIsMDBEnabled(enabled); + logger.log('[RatingsSection] MDBList enabled:', enabled); + } catch (error) { + logger.error('[RatingsSection] Failed to check if MDBList is enabled:', error); + setIsMDBEnabled(true); // Default to enabled + } + }; + + const loadProviderSettings = async () => { + try { + const savedSettings = await AsyncStorage.getItem(RATING_PROVIDERS_STORAGE_KEY); + if (savedSettings) { + setEnabledProviders(JSON.parse(savedSettings)); + } else { + // Default all providers to enabled + const defaultSettings = Object.keys(RATING_PROVIDERS).reduce((acc, key) => { + acc[key] = true; + return acc; + }, {} as Record); + setEnabledProviders(defaultSettings); + } + } catch (error) { + logger.error('[RatingsSection] Failed to load provider settings:', error); + } + }; + + useEffect(() => { + logger.log(`[RatingsSection] Mounted for ${type}:`, imdbId); + return () => { + logger.log(`[RatingsSection] Unmounted for ${type}:`, imdbId); + }; + }, [imdbId, type]); + + useEffect(() => { + if (error) { + logger.error('[RatingsSection] Error state:', error); + } + }, [error]); + + useEffect(() => { + if (ratings) { + logger.log('[RatingsSection] Received ratings:', ratings); + } + }, [ratings]); + + useEffect(() => { + if (ratings && Object.keys(ratings).length > 0) { + // Start fade-in animation when ratings are loaded + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }).start(); + } + }, [ratings, fadeAnim]); + + // If MDBList is disabled, don't show anything + if (!isMDBEnabled) { + logger.log('[RatingsSection] MDBList is disabled, not showing ratings'); + return null; + } + + if (loading) { + logger.log('[RatingsSection] Loading state'); + return ( + + + + ); + } + + if (error || !ratings || Object.keys(ratings).length === 0) { + logger.log('[RatingsSection] No ratings to display'); + return null; + } + + logger.log('[RatingsSection] Rendering ratings:', Object.keys(ratings).length); + + // Define the order and icons/colors for the ratings + const ratingConfig = { + imdb: { + icon: require('../../../assets/rating-icons/imdb.png'), + isImage: true, + color: '#F5C518', + prefix: '', + suffix: '', + transform: (value: number) => value.toFixed(1) + }, + tmdb: { + icon: TMDBIcon, + isImage: false, + color: '#01B4E4', + prefix: '', + suffix: '', + transform: (value: number) => value.toFixed(0) + }, + trakt: { + icon: TraktIcon, + isImage: false, + color: '#ED1C24', + prefix: '', + suffix: '', + transform: (value: number) => value.toFixed(0) + }, + letterboxd: { + icon: LetterboxdIcon, + isImage: false, + color: '#00E054', + prefix: '', + suffix: '', + transform: (value: number) => value.toFixed(1) + }, + tomatoes: { + icon: RottenTomatoesIcon, + isImage: false, + color: '#FA320A', + prefix: '', + suffix: '%', + transform: (value: number) => Math.round(value).toString() + }, + audience: { + icon: AudienceScoreIcon, + isImage: true, + color: '#FA320A', + prefix: '', + suffix: '%', + transform: (value: number) => Math.round(value).toString() + }, + metacritic: { + icon: MetacriticIcon, + isImage: true, + color: '#FFCC33', + prefix: '', + suffix: '', + transform: (value: number) => Math.round(value).toString() + } + }; + + // Priority: IMDB, TMDB, Tomatoes, Metacritic + const priorityOrder = ['imdb', 'tmdb', 'tomatoes', 'metacritic', 'trakt', 'letterboxd', 'audience']; + const displayRatings = priorityOrder + .filter(source => + source in ratings && + ratings[source as keyof typeof ratings] !== undefined && + (enabledProviders[source] ?? true) // Show by default if setting not found + ) + .map(source => [source, ratings[source as keyof typeof ratings]!]); + + return ( + + {displayRatings.map(([source, value]) => { + const config = ratingConfig[source as keyof typeof ratingConfig]; + const numericValue = typeof value === 'string' ? parseFloat(value) : value; + const displayValue = config.transform(numericValue); + + // Get a short display name for the rating source + const getSourceLabel = (src: string): string => { + switch(src) { + case 'imdb': return 'IMDb'; + case 'tmdb': return 'TMDB'; + case 'tomatoes': return 'RT'; + case 'audience': return 'Aud'; + case 'metacritic': return 'Meta'; + case 'letterboxd': return 'LBXD'; + case 'trakt': return 'Trakt'; + default: return src; + } + }; + + return ( + + {config.isImage ? ( + + ) : ( + + )} + + {displayValue}{config.suffix} + + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginTop: 8, + marginBottom: 16, + paddingHorizontal: 12, + gap: 4, + }, + loadingContainer: { + alignItems: 'center', + justifyContent: 'center', + height: 40, + marginVertical: 16, + }, + ratingItem: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.4)', + paddingVertical: 3, + paddingHorizontal: 4, + borderRadius: 4, + }, + ratingIcon: { + width: 16, + height: 16, + marginRight: 3, + alignSelf: 'center', + }, + ratingValue: { + fontSize: 13, + fontWeight: 'bold', + }, + ratingLabel: { + fontSize: 11, + opacity: 0.9, + }, +}); \ No newline at end of file diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 245366ac..311a5013 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -7,6 +7,7 @@ import { Episode } from '../../types/metadata'; import { tmdbService } from '../../services/tmdbService'; import { storageService } from '../../services/storageService'; import { useFocusEffect } from '@react-navigation/native'; +import Animated, { FadeIn } from 'react-native-reanimated'; interface SeriesContentProps { episodes: Episode[]; @@ -246,27 +247,49 @@ export const SeriesContent: React.FC = ({ return ( - {renderSeasonSelector()} - - - {episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'} - - - - {isTablet ? ( - - {episodes.map(episode => renderEpisodeCard(episode))} - - ) : ( - episodes.map(episode => renderEpisodeCard(episode)) - )} - + {renderSeasonSelector()} + + + + + {episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'} + + + + {isTablet ? ( + + {episodes.map((episode, index) => ( + + {renderEpisodeCard(episode)} + + ))} + + ) : ( + episodes.map((episode, index) => ( + + {renderEpisodeCard(episode)} + + )) + )} + + ); }; diff --git a/src/contexts/GenreContext.tsx b/src/contexts/GenreContext.tsx new file mode 100644 index 00000000..61f4efc0 --- /dev/null +++ b/src/contexts/GenreContext.tsx @@ -0,0 +1,80 @@ +import React, { createContext, useState, useEffect, useContext, ReactNode, useMemo } from 'react'; +import { tmdbService } from '../services/tmdbService'; +import { logger } from '../utils/logger'; + +// Define the shape of the genre map and context value +export type GenreMap = { [key: number]: string }; + +interface GenreContextType { + genreMap: GenreMap; + loadingGenres: boolean; +} + +// Create the context with a default value +const GenreContext = createContext({ + genreMap: {}, + loadingGenres: true, +}); + +// Custom hook to use the GenreContext +export const useGenres = () => useContext(GenreContext); + +// Define props for the provider +interface GenreProviderProps { + children: ReactNode; +} + +// Create the provider component +export const GenreProvider: React.FC = ({ children }) => { + const [genreMap, setGenreMap] = useState({}); + const [loadingGenres, setLoadingGenres] = useState(true); + + useEffect(() => { + const fetchAndSetGenres = async () => { + setLoadingGenres(true); + try { + // Fetch both movie and TV genres in parallel + const [movieGenres, tvGenres] = await Promise.all([ + tmdbService.getMovieGenres(), + tmdbService.getTvGenres(), + ]); + + // Combine genres into a single map, TV genres overwrite movie genres in case of ID collision (unlikely but possible) + const combinedMap: GenreMap = {}; + movieGenres.forEach(genre => { + combinedMap[genre.id] = genre.name; + }); + tvGenres.forEach(genre => { + combinedMap[genre.id] = genre.name; + }); + + setGenreMap(combinedMap); + logger.info('Successfully fetched and combined genres.'); + } catch (error) { + logger.error('Failed to fetch genres for GenreProvider:', error); + // Keep the genreMap empty or potentially set some default? + setGenreMap({}); + } finally { + setLoadingGenres(false); + } + }; + + fetchAndSetGenres(); + + // Add logic here for periodic refetching or caching if needed + // For now, it fetches only once on mount + + }, []); // Empty dependency array ensures this runs only once on mount + + // Memoize the context value to prevent unnecessary re-renders + const value = useMemo(() => ({ + genreMap, + loadingGenres, + }), [genreMap, loadingGenres]); + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/src/hooks/useFeaturedContent.ts b/src/hooks/useFeaturedContent.ts new file mode 100644 index 00000000..72c8727d --- /dev/null +++ b/src/hooks/useFeaturedContent.ts @@ -0,0 +1,227 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { StreamingContent, catalogService } from '../services/catalogService'; +import { tmdbService } from '../services/tmdbService'; +import { logger } from '../utils/logger'; +import * as Haptics from 'expo-haptics'; +import { useGenres } from '../contexts/GenreContext'; +import { useSettings, settingsEmitter } from './useSettings'; + +export function useFeaturedContent() { + const [featuredContent, setFeaturedContent] = useState(null); + const [allFeaturedContent, setAllFeaturedContent] = useState([]); + const [isSaved, setIsSaved] = useState(false); + const [loading, setLoading] = useState(true); + const currentIndexRef = useRef(0); + const abortControllerRef = useRef(null); + const { settings } = useSettings(); + const [contentSource, setContentSource] = useState<'tmdb' | 'catalogs'>(settings.featuredContentSource); + const [selectedCatalogs, setSelectedCatalogs] = useState(settings.selectedHeroCatalogs || []); + + const { genreMap, loadingGenres } = useGenres(); + + // Update local state when settings change + useEffect(() => { + setContentSource(settings.featuredContentSource); + setSelectedCatalogs(settings.selectedHeroCatalogs || []); + }, [settings]); + + const cleanup = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }, []); + + const loadFeaturedContent = useCallback(async () => { + setLoading(true); + cleanup(); + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; + + try { + let formattedContent: StreamingContent[] = []; + + if (contentSource === 'tmdb') { + // Load from TMDB trending + const trendingResults = await tmdbService.getTrending('movie', 'day'); + + if (signal.aborted) return; + + if (trendingResults.length > 0) { + // First convert items to StreamingContent objects + const preFormattedContent = trendingResults + .filter(item => item.title || item.name) + .map(item => { + const yearString = (item.release_date || item.first_air_date)?.substring(0, 4); + return { + id: `tmdb:${item.id}`, + type: 'movie', + name: item.title || item.name || 'Unknown Title', + poster: tmdbService.getImageUrl(item.poster_path) || '', + banner: tmdbService.getImageUrl(item.backdrop_path) || '', + logo: undefined, // Will be populated below + description: item.overview || '', + year: yearString ? parseInt(yearString, 10) : undefined, + genres: item.genre_ids.map(id => + loadingGenres ? '...' : (genreMap[id] || `ID:${id}`) + ), + inLibrary: false, + }; + }); + + // Then fetch logos for each item + formattedContent = await Promise.all( + preFormattedContent.map(async (item) => { + try { + if (item.id.startsWith('tmdb:')) { + const tmdbId = item.id.split(':')[1]; + const logoUrl = await tmdbService.getContentLogo('movie', tmdbId); + if (logoUrl) { + return { + ...item, + logo: logoUrl + }; + } + } + return item; + } catch (error) { + logger.error(`Failed to fetch logo for ${item.name}:`, error); + return item; + } + }) + ); + } + } else { + // Load from installed catalogs + const catalogs = await catalogService.getHomeCatalogs(); + + if (signal.aborted) return; + + // Filter catalogs based on user selection if any catalogs are selected + const filteredCatalogs = selectedCatalogs && selectedCatalogs.length > 0 + ? catalogs.filter(catalog => { + const catalogId = `${catalog.addon}:${catalog.type}:${catalog.id}`; + console.log(`Checking catalog: ${catalogId}, selected: ${selectedCatalogs.includes(catalogId)}`); + return selectedCatalogs.includes(catalogId); + }) + : catalogs; // Use all catalogs if none specifically selected + + console.log(`Original catalogs: ${catalogs.length}, Filtered catalogs: ${filteredCatalogs.length}`); + + // Flatten all catalog items into a single array, filter out items without posters + const allItems = filteredCatalogs.flatMap(catalog => catalog.items) + .filter(item => item.poster) + .filter((item, index, self) => + // Remove duplicates based on ID + index === self.findIndex(t => t.id === item.id) + ); + + // Sort by popular, newest, etc. (possibly enhanced later) + formattedContent = allItems.sort(() => Math.random() - 0.5).slice(0, 10); + } + + if (signal.aborted) return; + + setAllFeaturedContent(formattedContent); + + if (formattedContent.length > 0) { + setFeaturedContent(formattedContent[0]); + currentIndexRef.current = 0; + } else { + setFeaturedContent(null); + } + } catch (error) { + if (signal.aborted) { + logger.info('Featured content fetch aborted'); + } else { + logger.error('Failed to load featured content:', error); + } + setFeaturedContent(null); + setAllFeaturedContent([]); + } finally { + if (!signal.aborted) { + setLoading(false); + } + } + }, [cleanup, genreMap, loadingGenres, contentSource, selectedCatalogs]); + + // Load featured content initially and when content source changes + useEffect(() => { + // Force a full refresh to get updated logos + if (contentSource === 'tmdb') { + setAllFeaturedContent([]); + setFeaturedContent(null); + } + loadFeaturedContent(); + }, [loadFeaturedContent, contentSource, selectedCatalogs]); + + useEffect(() => { + if (featuredContent) { + let isMounted = true; + const checkLibrary = async () => { + const items = await catalogService.getLibraryItems(); + if (isMounted) { + setIsSaved(items.some(item => item.id === featuredContent.id)); + } + }; + checkLibrary(); + return () => { isMounted = false; }; + } + }, [featuredContent]); + + useEffect(() => { + const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => { + if (featuredContent) { + setIsSaved(items.some(item => item.id === featuredContent.id)); + } + }); + return () => unsubscribe(); + }, [featuredContent]); + + useEffect(() => { + if (allFeaturedContent.length <= 1) return; + + const rotateContent = () => { + currentIndexRef.current = (currentIndexRef.current + 1) % allFeaturedContent.length; + if (allFeaturedContent[currentIndexRef.current]) { + setFeaturedContent(allFeaturedContent[currentIndexRef.current]); + } + }; + + const intervalId = setInterval(rotateContent, 15000); + + return () => clearInterval(intervalId); + }, [allFeaturedContent]); + + useEffect(() => { + return () => cleanup(); + }, [cleanup]); + + const handleSaveToLibrary = useCallback(async () => { + if (!featuredContent) return; + + try { + const currentSavedStatus = isSaved; + setIsSaved(!currentSavedStatus); + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + + if (currentSavedStatus) { + await catalogService.removeFromLibrary(featuredContent.type, featuredContent.id); + } else { + const itemToAdd = { ...featuredContent, inLibrary: true }; + await catalogService.addToLibrary(itemToAdd); + } + } catch (error) { + logger.error('Error updating library:', error); + setIsSaved(prev => !prev); + } + }, [featuredContent, isSaved]); + + return { + featuredContent, + loading, + isSaved, + handleSaveToLibrary, + refreshFeatured: loadFeaturedContent + }; +} \ No newline at end of file diff --git a/src/hooks/useHomeCatalogs.ts b/src/hooks/useHomeCatalogs.ts new file mode 100644 index 00000000..60811a90 --- /dev/null +++ b/src/hooks/useHomeCatalogs.ts @@ -0,0 +1,87 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { CatalogContent, catalogService } from '../services/catalogService'; +import { logger } from '../utils/logger'; +import { useCatalogContext } from '../contexts/CatalogContext'; + +export function useHomeCatalogs() { + const [catalogs, setCatalogs] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const abortControllerRef = useRef(null); + const { lastUpdate } = useCatalogContext(); + + const cleanup = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }, []); + + const loadCatalogs = useCallback(async (isRefresh = false) => { + if (!isRefresh) { + setLoading(true); + } else { + setRefreshing(true); + } + + cleanup(); + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; + + try { + const homeCatalogs = await catalogService.getHomeCatalogs(); + + if (signal.aborted) return; + + if (!homeCatalogs?.length) { + logger.warn('No home catalogs found.'); + setCatalogs([]); // Ensure catalogs is empty if none found + return; + } + + const uniqueCatalogsMap = new Map(); + homeCatalogs.forEach(catalog => { + const contentKey = catalog.items.map(item => item.id).sort().join(','); + if (!uniqueCatalogsMap.has(contentKey)) { + uniqueCatalogsMap.set(contentKey, catalog); + } + }); + + if (signal.aborted) return; + + const uniqueCatalogs = Array.from(uniqueCatalogsMap.values()); + setCatalogs(uniqueCatalogs); + + } catch (error) { + if (signal.aborted) { + logger.info('Catalog fetch aborted'); + } else { + logger.error('Error in loadCatalogs:', error); + } + setCatalogs([]); // Clear catalogs on error + } finally { + if (!signal.aborted) { + setLoading(false); + setRefreshing(false); + } + } + }, [cleanup]); + + // Initial load and reload on lastUpdate change + useEffect(() => { + loadCatalogs(); + }, [loadCatalogs, lastUpdate]); + + // Cleanup on unmount + useEffect(() => { + return () => { + cleanup(); + }; + }, [cleanup]); + + const refreshCatalogs = useCallback(() => { + return loadCatalogs(true); + }, [loadCatalogs]); + + return { catalogs, loading, refreshing, refreshCatalogs }; +} \ No newline at end of file diff --git a/src/hooks/useMDBListRatings.ts b/src/hooks/useMDBListRatings.ts new file mode 100644 index 00000000..46bcc868 --- /dev/null +++ b/src/hooks/useMDBListRatings.ts @@ -0,0 +1,49 @@ +import { useState, useEffect } from 'react'; +import { mdblistService, MDBListRatings } from '../services/mdblistService'; +import { logger } from '../utils/logger'; +import { isMDBListEnabled } from '../screens/MDBListSettingsScreen'; + +export const useMDBListRatings = (imdbId: string, mediaType: 'movie' | 'show') => { + const [ratings, setRatings] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRatings = async () => { + if (!imdbId) { + logger.warn('[useMDBListRatings] No IMDB ID provided'); + return; + } + + // Check if MDBList is enabled before proceeding + const enabled = await isMDBListEnabled(); + if (!enabled) { + logger.log('[useMDBListRatings] MDBList is disabled, not fetching ratings'); + setRatings(null); + setLoading(false); + return; + } + + logger.log(`[useMDBListRatings] Starting to fetch ratings for ${mediaType}:`, imdbId); + setLoading(true); + setError(null); + + try { + const data = await mdblistService.getRatings(imdbId, mediaType); + logger.log('[useMDBListRatings] Received ratings:', data); + setRatings(data); + } catch (err) { + const errorMessage = 'Failed to fetch ratings'; + logger.error('[useMDBListRatings] Error:', err); + setError(errorMessage); + } finally { + setLoading(false); + logger.log('[useMDBListRatings] Finished fetching ratings'); + } + }; + + fetchRatings(); + }, [imdbId, mediaType]); + + return { ratings, loading, error }; +}; \ No newline at end of file diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 6e4ad48b..2932339d 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -206,8 +206,34 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = }; const loadCast = async () => { + setLoadingCast(true); try { - setLoadingCast(true); + // Handle TMDB IDs + let metadataId = id; + let metadataType = type; + + if (id.startsWith('tmdb:')) { + const extractedTmdbId = id.split(':')[1]; + logger.log('[loadCast] Using extracted TMDB ID:', extractedTmdbId); + + // For TMDB IDs, we'll use the TMDB API directly + const castData = await tmdbService.getCredits(parseInt(extractedTmdbId), type); + if (castData && castData.cast) { + const formattedCast = castData.cast.map((actor: any) => ({ + id: actor.id, + name: actor.name, + character: actor.character, + profile_path: actor.profile_path + })); + setCast(formattedCast); + setLoadingCast(false); + return formattedCast; + } + setLoadingCast(false); + return []; + } + + // Continue with the existing logic for non-TMDB IDs const cachedCast = cacheService.getCast(id, type); if (cachedCast) { setCast(cachedCast); @@ -277,12 +303,172 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = return; } + // Handle TMDB-specific IDs + let actualId = id; + if (id.startsWith('tmdb:')) { + const tmdbId = id.split(':')[1]; + // For TMDB IDs, we need to handle metadata differently + if (type === 'movie') { + logger.log('Fetching movie details from TMDB for:', tmdbId); + const movieDetails = await tmdbService.getMovieDetails(tmdbId); + if (movieDetails) { + const imdbId = movieDetails.imdb_id || movieDetails.external_ids?.imdb_id; + if (imdbId) { + // Use the imdbId for compatibility with the rest of the app + actualId = imdbId; + // Also store the TMDB ID for later use + setTmdbId(parseInt(tmdbId)); + } else { + // If no IMDb ID, directly call loadTMDBMovie (create this function if needed) + const formattedMovie: StreamingContent = { + id: `tmdb:${tmdbId}`, + type: 'movie', + name: movieDetails.title, + poster: tmdbService.getImageUrl(movieDetails.poster_path) || '', + banner: tmdbService.getImageUrl(movieDetails.backdrop_path) || '', + description: movieDetails.overview || '', + year: movieDetails.release_date ? parseInt(movieDetails.release_date.substring(0, 4)) : undefined, + genres: movieDetails.genres?.map((g: { name: string }) => g.name) || [], + inLibrary: false, + }; + + // Fetch credits to get director and crew information + try { + const credits = await tmdbService.getCredits(parseInt(tmdbId), 'movie'); + if (credits && credits.crew) { + // Extract directors + const directors = credits.crew + .filter((person: any) => person.job === 'Director') + .map((person: any) => person.name); + + // Extract creators/writers + const writers = credits.crew + .filter((person: any) => ['Writer', 'Screenplay'].includes(person.job)) + .map((person: any) => person.name); + + // Add to formatted movie + if (directors.length > 0) { + (formattedMovie as any).directors = directors; + (formattedMovie as StreamingContent & { director: string }).director = directors.join(', '); + } + + if (writers.length > 0) { + (formattedMovie as any).creators = writers; + (formattedMovie as StreamingContent & { writer: string }).writer = writers.join(', '); + } + } + } catch (error) { + logger.error('Failed to fetch credits for movie:', error); + } + + // Fetch movie logo from TMDB + try { + const logoUrl = await tmdbService.getMovieImages(tmdbId); + if (logoUrl) { + formattedMovie.logo = logoUrl; + logger.log(`Successfully fetched logo for movie ${tmdbId} from TMDB`); + } + } catch (error) { + logger.error('Failed to fetch logo from TMDB:', error); + // Continue with execution, logo is optional + } + + setMetadata(formattedMovie); + cacheService.setMetadata(id, type, formattedMovie); + const isInLib = catalogService.getLibraryItems().some(item => item.id === id); + setInLibrary(isInLib); + setLoading(false); + return; + } + } + } else if (type === 'series') { + // Handle TV shows with TMDB IDs + logger.log('Fetching TV show details from TMDB for:', tmdbId); + try { + const showDetails = await tmdbService.getTVShowDetails(parseInt(tmdbId)); + if (showDetails) { + // Get external IDs to check for IMDb ID + const externalIds = await tmdbService.getShowExternalIds(parseInt(tmdbId)); + const imdbId = externalIds?.imdb_id; + + if (imdbId) { + // Use the imdbId for compatibility with the rest of the app + actualId = imdbId; + // Also store the TMDB ID for later use + setTmdbId(parseInt(tmdbId)); + } else { + // If no IMDb ID, create formatted show from TMDB data + const formattedShow: StreamingContent = { + id: `tmdb:${tmdbId}`, + type: 'series', + name: showDetails.name, + poster: tmdbService.getImageUrl(showDetails.poster_path) || '', + banner: tmdbService.getImageUrl(showDetails.backdrop_path) || '', + description: showDetails.overview || '', + year: showDetails.first_air_date ? parseInt(showDetails.first_air_date.substring(0, 4)) : undefined, + genres: showDetails.genres?.map((g: { name: string }) => g.name) || [], + inLibrary: false, + }; + + // Fetch credits to get creators + try { + const credits = await tmdbService.getCredits(parseInt(tmdbId), 'series'); + if (credits && credits.crew) { + // Extract creators + const creators = credits.crew + .filter((person: any) => + person.job === 'Creator' || + person.job === 'Series Creator' || + person.department === 'Production' || + person.job === 'Executive Producer' + ) + .map((person: any) => person.name); + + if (creators.length > 0) { + (formattedShow as any).creators = creators.slice(0, 3); + } + } + } catch (error) { + logger.error('Failed to fetch credits for TV show:', error); + } + + // Fetch TV show logo from TMDB + try { + const logoUrl = await tmdbService.getTvShowImages(tmdbId); + if (logoUrl) { + formattedShow.logo = logoUrl; + logger.log(`Successfully fetched logo for TV show ${tmdbId} from TMDB`); + } + } catch (error) { + logger.error('Failed to fetch logo from TMDB:', error); + // Continue with execution, logo is optional + } + + setMetadata(formattedShow); + cacheService.setMetadata(id, type, formattedShow); + + // Load series data (episodes) + setTmdbId(parseInt(tmdbId)); + loadSeriesData().catch(console.error); + + const isInLib = catalogService.getLibraryItems().some(item => item.id === id); + setInLibrary(isInLib); + setLoading(false); + return; + } + } + } catch (error) { + logger.error('Failed to fetch TV show details from TMDB:', error); + } + } + } + // Load all data in parallel const [content, castData] = await Promise.allSettled([ // Load content with timeout and retry withRetry(async () => { const result = await withTimeout( - catalogService.getContentDetails(type, id), + catalogService.getContentDetails(type, actualId), API_TIMEOUT ); return result; @@ -298,6 +484,41 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = setInLibrary(isInLib); cacheService.setMetadata(id, type, content.value); + // Fetch and add logo from TMDB + let finalMetadata = { ...content.value }; + try { + // Get TMDB ID if not already set + const contentTmdbId = await tmdbService.extractTMDBIdFromStremioId(id); + if (contentTmdbId) { + // Determine content type for TMDB API (movie or tv) + const tmdbType = type === 'series' ? 'tv' : 'movie'; + // Fetch logo from TMDB + const logoUrl = await tmdbService.getContentLogo(tmdbType, contentTmdbId); + if (logoUrl) { + // Update metadata with logo + finalMetadata.logo = logoUrl; + logger.log(`[useMetadata] Successfully fetched and set logo from TMDB for ${id}`); + } else { + // If TMDB has no logo, ensure logo property is null/undefined + finalMetadata.logo = undefined; + logger.log(`[useMetadata] No logo found on TMDB for ${id}. Setting logo to undefined.`); + } + } else { + // If we couldn't get a TMDB ID, ensure logo is null/undefined + finalMetadata.logo = undefined; + logger.log(`[useMetadata] Could not determine TMDB ID for ${id}. Setting logo to undefined.`); + } + } catch (error) { + logger.error(`[useMetadata] Error fetching logo from TMDB for ${id}:`, error); + // Ensure logo is null/undefined on error + finalMetadata.logo = undefined; + } + + // Set the final metadata state + setMetadata(finalMetadata); + // Update cache with final metadata (including potentially nulled logo) + cacheService.setMetadata(id, type, finalMetadata); + if (type === 'series') { // Load series data in parallel with other data loadSeriesData().catch(console.error); diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 09515e37..56595865 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -1,6 +1,25 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; +// Simple event emitter for settings changes +class SettingsEventEmitter { + private listeners: Array<() => void> = []; + + addListener(listener: () => void) { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter(l => l !== listener); + }; + } + + emit() { + this.listeners.forEach(listener => listener()); + } +} + +// Singleton instance for app-wide access +export const settingsEmitter = new SettingsEventEmitter(); + export interface AppSettings { enableDarkMode: boolean; enableNotifications: boolean; @@ -9,6 +28,9 @@ export interface AppSettings { enableBackgroundPlayback: boolean; cacheLimit: number; useExternalPlayer: boolean; + showHeroSection: boolean; + featuredContentSource: 'tmdb' | 'catalogs'; + selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section } export const DEFAULT_SETTINGS: AppSettings = { @@ -19,6 +41,9 @@ export const DEFAULT_SETTINGS: AppSettings = { enableBackgroundPlayback: false, cacheLimit: 1024, useExternalPlayer: false, + showHeroSection: true, + featuredContentSource: 'tmdb', + selectedHeroCatalogs: [], // Empty array means all catalogs are selected }; const SETTINGS_STORAGE_KEY = 'app_settings'; @@ -28,6 +53,13 @@ export const useSettings = () => { useEffect(() => { loadSettings(); + + // Subscribe to settings changes + const unsubscribe = settingsEmitter.addListener(() => { + loadSettings(); + }); + + return unsubscribe; }, []); const loadSettings = async () => { @@ -41,7 +73,7 @@ export const useSettings = () => { } }; - const updateSetting = async ( + const updateSetting = useCallback(async ( key: K, value: AppSettings[K] ) => { @@ -49,10 +81,12 @@ export const useSettings = () => { try { await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings)); setSettings(newSettings); + // Notify all subscribers that settings have changed + settingsEmitter.emit(); } catch (error) { console.error('Failed to save settings:', error); } - }; + }, [settings]); return { settings, diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 508d8767..551410ed 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -8,6 +8,7 @@ import type { MD3Theme } from 'react-native-paper'; import type { BottomTabBarProps } from '@react-navigation/bottom-tabs'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; +import { BlurView } from 'expo-blur'; import { colors } from '../styles/colors'; import { NuvioHeader } from '../components/NuvioHeader'; import { Stream } from '../types/streams'; @@ -27,6 +28,10 @@ import CatalogSettingsScreen from '../screens/CatalogSettingsScreen'; import StreamsScreen from '../screens/StreamsScreen'; import CalendarScreen from '../screens/CalendarScreen'; import NotificationSettingsScreen from '../screens/NotificationSettingsScreen'; +import MDBListSettingsScreen from '../screens/MDBListSettingsScreen'; +import TMDBSettingsScreen from '../screens/TMDBSettingsScreen'; +import HomeScreenSettings from '../screens/HomeScreenSettings'; +import HeroCatalogsScreen from '../screens/HeroCatalogsScreen'; // Stack navigator types export type RootStackParamList = { @@ -76,6 +81,10 @@ export type RootStackParamList = { Addons: undefined; CatalogSettings: undefined; NotificationSettings: undefined; + MDBListSettings: undefined; + TMDBSettings: undefined; + HomeScreenSettings: undefined; + HeroCatalogs: undefined; }; export type RootStackNavigationProp = NativeStackNavigationProp; @@ -85,7 +94,6 @@ export type MainTabParamList = { Home: undefined; Discover: undefined; Library: undefined; - Addons: undefined; Settings: undefined; }; @@ -320,27 +328,46 @@ const MainTabs = () => { bottom: 0, left: 0, right: 0, - height: 75, + height: 85, backgroundColor: 'transparent', + overflow: 'hidden', }}> - + {Platform.OS === 'ios' ? ( + + ) : ( + + )} { case 'Library': iconName = 'play-box-multiple'; break; - case 'Addons': - iconName = 'puzzle'; - break; case 'Settings': iconName = 'cog'; break; @@ -442,9 +466,6 @@ const MainTabs = () => { case 'Library': iconName = 'play-box-multiple'; break; - case 'Addons': - iconName = 'puzzle'; - break; case 'Settings': iconName = 'cog'; break; @@ -459,8 +480,8 @@ const MainTabs = () => { backgroundColor: 'transparent', borderTopWidth: 0, elevation: 0, - height: 75, - paddingBottom: 10, + height: 85, + paddingBottom: 20, paddingTop: 12, }, tabBarLabelStyle: { @@ -469,20 +490,38 @@ const MainTabs = () => { marginTop: 0, }, tabBarBackground: () => ( - + Platform.OS === 'ios' ? ( + + ) : ( + + ) ), header: () => route.name === 'Home' ? : null, headerShown: route.name === 'Home', @@ -509,13 +548,6 @@ const MainTabs = () => { tabBarLabel: 'Library' }} /> - { name="CatalogSettings" component={CatalogSettingsScreen as any} /> + + { name="NotificationSettings" component={NotificationSettingsScreen as any} /> + + diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index 79fc8fa6..e6c28824 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -16,7 +16,8 @@ import { Image, Dimensions, ScrollView, - useColorScheme + useColorScheme, + Switch } from 'react-native'; import { stremioService, Manifest } from '../services/stremioService'; import { MaterialIcons } from '@expo/vector-icons'; @@ -27,6 +28,8 @@ import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { BlurView } from 'expo-blur'; // Extend Manifest type to include logo interface ExtendedManifest extends Manifest { @@ -41,13 +44,14 @@ const AddonsScreen = () => { const navigation = useNavigation>(); const [addons, setAddons] = useState([]); const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(''); - const [installing, setInstalling] = useState(false); - const [showAddModal, setShowAddModal] = useState(false); const [addonUrl, setAddonUrl] = useState(''); const [addonDetails, setAddonDetails] = useState(null); const [showConfirmModal, setShowConfirmModal] = useState(false); - const isDarkMode = useColorScheme() === 'dark'; + const [installing, setInstalling] = useState(false); + const [catalogCount, setCatalogCount] = useState(0); + const [activeAddons, setActiveAddons] = useState(0); + // Force dark mode + const isDarkMode = true; useEffect(() => { loadAddons(); @@ -58,6 +62,27 @@ const AddonsScreen = () => { setLoading(true); const installedAddons = await stremioService.getInstalledAddonsAsync(); setAddons(installedAddons); + setActiveAddons(installedAddons.length); + + // Count catalogs + let totalCatalogs = 0; + installedAddons.forEach(addon => { + if (addon.catalogs && addon.catalogs.length > 0) { + totalCatalogs += addon.catalogs.length; + } + }); + + // Get catalog settings to determine enabled count + const catalogSettingsJson = await AsyncStorage.getItem('catalog_settings'); + if (catalogSettingsJson) { + const catalogSettings = JSON.parse(catalogSettingsJson); + const disabledCount = Object.entries(catalogSettings) + .filter(([key, value]) => key !== '_lastUpdate' && value === false) + .length; + setCatalogCount(totalCatalogs - disabledCount); + } else { + setCatalogCount(totalCatalogs); + } } catch (error) { logger.error('Failed to load addons:', error); Alert.alert('Error', 'Failed to load addons'); @@ -66,7 +91,7 @@ const AddonsScreen = () => { } }; - const handleInstallAddon = async () => { + const handleAddAddon = async () => { if (!addonUrl) { Alert.alert('Error', 'Please enter an addon URL'); return; @@ -77,7 +102,6 @@ const AddonsScreen = () => { // First fetch the addon manifest const manifest = await stremioService.getManifest(addonUrl); setAddonDetails(manifest); - setShowAddModal(false); setShowConfirmModal(true); } catch (error) { logger.error('Failed to fetch addon details:', error); @@ -106,9 +130,23 @@ const AddonsScreen = () => { } }; - const handleConfigureAddon = (addon: ExtendedManifest) => { - // TODO: Implement addon configuration - Alert.alert('Configure', `Configure ${addon.name}`); + const handleToggleAddon = (addon: ExtendedManifest, enabled: boolean) => { + // Logic to enable/disable an addon + Alert.alert( + enabled ? 'Disable Addon' : 'Enable Addon', + `Are you sure you want to ${enabled ? 'disable' : 'enable'} ${addon.name}?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: enabled ? 'Disable' : 'Enable', + style: enabled ? 'destructive' : 'default', + onPress: () => { + // TODO: Implement actual toggle functionality + Alert.alert('Success', `${addon.name} ${enabled ? 'disabled' : 'enabled'}`); + }, + }, + ] + ); }; const handleRemoveAddon = (addon: ExtendedManifest) => { @@ -134,154 +172,150 @@ const AddonsScreen = () => { const description = item.description || ''; // @ts-ignore - some addons might have logo property even though it's not in the type const logo = item.logo || null; + + // Format the types into a simple category text + const categoryText = types.length > 0 + ? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ') + : 'No categories'; return ( - - - - {logo ? ( - - ) : ( - - - - )} - - - + + + {logo ? ( + + ) : ( + + + + )} + {item.name} - - {types.join(', ')} - - - {description} - + + v{item.version || '1.0.0'} + • + {categoryText} + + handleToggleAddon(item, !value)} + trackColor={{ false: colors.elevation1, true: colors.primary }} + thumbColor={colors.white} + ios_backgroundColor={colors.elevation1} + /> - - - handleConfigureAddon(item)} - > - - - - handleRemoveAddon(item)} - > - Uninstall - - + + + {description.length > 100 ? description.substring(0, 100) + '...' : description} + ); }; + const StatsCard = ({ value, label }: { value: number; label: string }) => ( + + {value} + {label} + + ); + return ( - + + {/* Header */} - - - Addons - - + navigation.goBack()} + > + + Settings + - - - - - - + + Addons + {loading ? ( ) : ( - item.id} - contentContainerStyle={styles.addonsList} - ListEmptyComponent={() => ( - - - No addons installed - - )} - /> - )} - - {/* Add Addon FAB */} - setShowAddModal(true)} - > - - - - {/* Add Addon URL Modal */} - setShowAddModal(false)} - > - - - Add New Addon - - - setShowAddModal(false)} + {/* Overview Section */} + + OVERVIEW + + + + + + + + + + {/* Add Addon Section */} + + ADD NEW ADDON + + + - Cancel - - - {installing ? ( - - ) : ( - - Next - - )} + + {installing ? 'Loading...' : 'Add Addon'} + - - + + {/* Installed Addons Section */} + + INSTALLED ADDONS + + {addons.length === 0 ? ( + + + No addons installed + + ) : ( + addons.map((addon, index) => { + const isLast = index === addons.length - 1; + return ( + + {renderAddonItem({ item: addon })} + + ); + }) + )} + + + + )} {/* Addon Details Confirmation Modal */} { setAddonDetails(null); }} > - - + + {addonDetails && ( <> - - {/* @ts-ignore - some addons might have logo property even though it's not in the type */} - {addonDetails.logo ? ( - - ) : ( - - - - )} - {addonDetails.name} - Version {addonDetails.version} - - - - - Description - - {addonDetails.description || 'No description available'} - - - Supported Types - - {(addonDetails.types || []).map((type, index) => ( - - {type} - - ))} - - - {addonDetails.catalogs && addonDetails.catalogs.length > 0 && ( - <> - Catalogs - - {addonDetails.catalogs.map((catalog, index) => ( - - {catalog.type} - - ))} - - - )} - - - - + + Install Addon { setShowConfirmModal(false); setAddonDetails(null); }} > - Cancel + + + + + + + {/* @ts-ignore */} + {addonDetails.logo ? ( + + ) : ( + + + + )} + {addonDetails.name} + v{addonDetails.version || '1.0.0'} + + + + Description + + {addonDetails.description || 'No description available'} + + + + {addonDetails.types && addonDetails.types.length > 0 && ( + + Supported Types + + {addonDetails.types.map((type, index) => ( + + {type} + + ))} + + + )} + + {addonDetails.catalogs && addonDetails.catalogs.length > 0 && ( + + Catalogs + + {addonDetails.catalogs.map((catalog, index) => ( + + + {catalog.type} - {catalog.id} + + + ))} + + + )} + + + + { + setShowConfirmModal(false); + setAddonDetails(null); + }} + > + Cancel {installing ? ( - + ) : ( - Install + Install )} )} - + ); @@ -382,295 +438,322 @@ const styles = StyleSheet.create({ backgroundColor: colors.darkBackground, }, header: { - paddingHorizontal: 16, - paddingVertical: 12, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 4, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.1)', - backgroundColor: colors.darkBackground, - }, - headerContent: { flexDirection: 'row', alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + backText: { + fontSize: 17, + fontWeight: '400', + color: colors.primary, }, headerTitle: { - fontSize: 32, - fontWeight: '800', - letterSpacing: 0.5, + fontSize: 34, + fontWeight: '700', color: colors.white, + paddingHorizontal: 16, + paddingBottom: 16, + paddingTop: 8, }, - searchContainer: { + scrollView: { + flex: 1, + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 13, + fontWeight: '600', + color: colors.mediumGray, + marginHorizontal: 16, + marginBottom: 8, + letterSpacing: 0.5, + textTransform: 'uppercase', + }, + statsContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginHorizontal: 16, + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + statsCard: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + statsDivider: { + width: 1, + height: '80%', + backgroundColor: 'rgba(150, 150, 150, 0.2)', + alignSelf: 'center', + }, + statsValue: { + fontSize: 24, + fontWeight: 'bold', + color: colors.white, + marginBottom: 4, + }, + statsLabel: { + fontSize: 13, + color: colors.mediumGray, + }, + addAddonContainer: { + marginHorizontal: 16, + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + addonInput: { + backgroundColor: colors.elevation1, + borderRadius: 8, + padding: 12, + color: colors.white, + marginBottom: 16, + fontSize: 15, + }, + addButton: { + backgroundColor: colors.primary, + borderRadius: 8, + padding: 12, + alignItems: 'center', + }, + addButtonText: { + color: colors.white, + fontWeight: '600', + fontSize: 16, + }, + addonList: { + paddingHorizontal: 16, + }, + emptyContainer: { + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 32, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + emptyText: { + marginTop: 8, + color: colors.mediumGray, + fontSize: 15, + }, + addonItem: { + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + addonHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + addonIcon: { + width: 36, + height: 36, + borderRadius: 8, + backgroundColor: colors.elevation3, + }, + addonIconPlaceholder: { + width: 36, + height: 36, + borderRadius: 8, + backgroundColor: colors.elevation3, + justifyContent: 'center', + alignItems: 'center', + }, + addonTitleContainer: { + flex: 1, + marginLeft: 12, + marginRight: 16, + }, + addonName: { + fontSize: 17, + fontWeight: '600', + color: colors.white, + marginBottom: 2, + }, + addonMetaContainer: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.elevation1, - margin: 16, - padding: 12, - borderRadius: 8, }, - searchInput: { + addonVersion: { + fontSize: 13, + color: colors.mediumGray, + }, + addonDot: { + fontSize: 13, + color: colors.mediumGray, + marginHorizontal: 4, + }, + addonCategory: { + fontSize: 13, + color: colors.mediumGray, flex: 1, - marginLeft: 8, - color: colors.text, - fontSize: 16, + }, + addonDescription: { + fontSize: 14, + color: colors.mediumEmphasis, + marginTop: 6, + marginBottom: 4, + lineHeight: 20, + marginLeft: 48, // Align with title, accounting for icon width }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', }, - addonsList: { - padding: 16, - }, - addonItem: { - backgroundColor: colors.elevation1, - borderRadius: 12, - marginBottom: 16, - padding: 16, - }, - addonContent: { - flexDirection: 'row', - marginBottom: 16, - }, - addonIconContainer: { - width: 48, - height: 48, - marginRight: 16, - }, - addonIcon: { - width: '100%', - height: '100%', - borderRadius: 8, - }, - placeholderIcon: { - width: '100%', - height: '100%', - backgroundColor: colors.elevation2, - borderRadius: 8, - justifyContent: 'center', - alignItems: 'center', - }, - addonInfo: { - flex: 1, - }, - addonName: { - color: colors.text, - fontSize: 18, - fontWeight: 'bold', - marginBottom: 4, - }, - addonType: { - color: colors.mediumGray, - fontSize: 14, - marginBottom: 4, - }, - addonDescription: { - color: colors.mediumEmphasis, - fontSize: 14, - lineHeight: 20, - marginBottom: 12, - }, - addonActions: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - borderTopWidth: 1, - borderTopColor: colors.elevation2, - paddingTop: 16, - }, - configButton: { - padding: 8, - }, - uninstallButton: { - backgroundColor: 'transparent', - paddingVertical: 8, - paddingHorizontal: 16, - borderRadius: 20, - borderWidth: 1, - borderColor: colors.elevation2, - }, - uninstallText: { - color: colors.text, - fontSize: 14, - }, - emptyContainer: { - alignItems: 'center', - justifyContent: 'center', - padding: 32, - }, - emptyText: { - marginTop: 16, - fontSize: 16, - color: colors.mediumGray, - textAlign: 'center', - }, - fab: { - position: 'absolute', - right: 16, - bottom: 90, - width: 56, - height: 56, - borderRadius: 28, - backgroundColor: colors.primary, - justifyContent: 'center', - alignItems: 'center', - elevation: 8, - shadowColor: colors.black, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.30, - shadowRadius: 4.65, - }, modalContainer: { flex: 1, - backgroundColor: colors.darkBackground, justifyContent: 'center', alignItems: 'center', }, modalContent: { - backgroundColor: colors.elevation1, - borderRadius: 12, - padding: 20, + backgroundColor: colors.elevation2, + borderRadius: 14, width: '85%', - maxWidth: 360, + maxHeight: '85%', + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 5, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.elevation3, }, modalTitle: { - color: colors.text, - fontSize: 20, + fontSize: 17, fontWeight: 'bold', - marginBottom: 16, + color: colors.white, }, - modalInput: { - backgroundColor: colors.elevation2, - borderRadius: 8, - padding: 12, - color: colors.text, - marginBottom: 24, + modalScrollContent: { + maxHeight: 400, }, - modalActions: { - flexDirection: 'row', - justifyContent: 'flex-end', - }, - modalButton: { - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - marginLeft: 8, - }, - modalButtonPrimary: { - backgroundColor: colors.primary, - }, - modalButtonText: { - color: colors.mediumGray, - fontSize: 14, - fontWeight: 'bold', - }, - modalButtonTextPrimary: { - color: colors.text, - }, - confirmModalContent: { - width: '85%', - maxWidth: 360, - maxHeight: '80%', - padding: 0, - borderRadius: 16, - overflow: 'hidden', - backgroundColor: colors.darkBackground, - }, - addonHeader: { + addonDetailHeader: { alignItems: 'center', - padding: 20, + padding: 24, borderBottomWidth: 1, - borderBottomColor: colors.elevation1, - backgroundColor: colors.elevation2, - width: '100%', + borderBottomColor: colors.elevation3, }, addonLogo: { width: 64, height: 64, - marginBottom: 12, borderRadius: 12, - backgroundColor: colors.elevation1, + marginBottom: 16, + backgroundColor: colors.elevation3, }, - placeholderLogo: { + addonLogoPlaceholder: { width: 64, height: 64, borderRadius: 12, - backgroundColor: colors.elevation1, + backgroundColor: colors.elevation3, justifyContent: 'center', alignItems: 'center', - marginBottom: 12, + marginBottom: 16, }, - addonTitle: { + addonDetailName: { fontSize: 20, - fontWeight: '700', - color: colors.text, + fontWeight: 'bold', + color: colors.white, marginBottom: 4, textAlign: 'center', }, - addonVersion: { - fontSize: 13, - color: colors.textMuted, - marginBottom: 0, + addonDetailVersion: { + fontSize: 14, + color: colors.mediumGray, }, - addonDetailsSection: { - padding: 20, + addonDetailSection: { + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.elevation3, }, - sectionTitle: { - fontSize: 15, + addonDetailSectionTitle: { + fontSize: 16, fontWeight: '600', - color: colors.text, + color: colors.white, marginBottom: 8, - marginTop: 12, }, - typeContainer: { + addonDetailDescription: { + fontSize: 15, + color: colors.mediumEmphasis, + lineHeight: 20, + }, + addonDetailChips: { flexDirection: 'row', flexWrap: 'wrap', - gap: 6, - marginBottom: 12, - width: '100%', + gap: 8, }, - typeChip: { - backgroundColor: colors.elevation2, - paddingHorizontal: 10, - paddingVertical: 4, + addonDetailChip: { + backgroundColor: colors.elevation3, borderRadius: 12, - borderWidth: 1, - borderColor: colors.elevation3, + paddingHorizontal: 8, + paddingVertical: 4, }, - typeText: { - color: colors.text, + addonDetailChipText: { fontSize: 13, + color: colors.white, }, - confirmActions: { + modalActions: { flexDirection: 'row', justifyContent: 'flex-end', - padding: 12, - gap: 8, + padding: 16, borderTopWidth: 1, - borderTopColor: colors.elevation1, - backgroundColor: colors.elevation2, - width: '100%', + borderTopColor: colors.elevation3, }, - confirmButton: { + modalButton: { + paddingVertical: 8, paddingHorizontal: 16, - paddingVertical: 10, borderRadius: 8, - minWidth: 90, + minWidth: 80, alignItems: 'center', }, cancelButton: { backgroundColor: colors.elevation3, + marginRight: 8, }, installButton: { backgroundColor: colors.primary, }, - confirmButtonText: { - color: colors.text, - fontSize: 16, + modalButtonText: { + color: colors.white, fontWeight: '600', }, - scrollContent: { - flexGrow: 1, - }, }); export default AddonsScreen; \ No newline at end of file diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 43ad0f18..cefcbfa1 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -10,6 +10,7 @@ import { StatusBar, RefreshControl, Dimensions, + Platform, } from 'react-native'; import { RouteProp } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; @@ -17,6 +18,7 @@ import { RootStackParamList } from '../navigation/AppNavigator'; import { Meta, stremioService } from '../services/stremioService'; import { colors } from '../styles'; import { Image } from 'expo-image'; +import { MaterialIcons } from '@expo/vector-icons'; import { logger } from '../utils/logger'; type CatalogScreenProps = { @@ -24,7 +26,7 @@ type CatalogScreenProps = { navigation: StackNavigationProp; }; -// Consistent spacing variables +// Constants for layout const SPACING = { xs: 4, sm: 8, @@ -33,11 +35,13 @@ const SPACING = { xl: 24, }; +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + // Screen dimensions and grid layout const { width } = Dimensions.get('window'); const NUM_COLUMNS = 3; const ITEM_MARGIN = SPACING.sm; -const ITEM_WIDTH = (width - (SPACING.md * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS; +const ITEM_WIDTH = (width - (SPACING.lg * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS; const CatalogScreen: React.FC = ({ route, navigation }) => { const { addonId, type, id, name, genreFilter } = route.params; @@ -47,7 +51,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(null); - // Force dark mode instead of using color scheme + // Force dark mode const isDarkMode = true; const loadItems = useCallback(async (pageNum: number, shouldRefresh: boolean = false) => { @@ -160,9 +164,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { useEffect(() => { loadItems(1); - // Set the header title - navigation.setOptions({ title: name || `${type} catalog` }); - }, [loadItems, navigation, name, type]); + }, [loadItems]); const handleRefresh = useCallback(() => { setPage(1); @@ -185,7 +187,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { activeOpacity={0.7} > = ({ route, navigation }) => { const renderEmptyState = () => ( + - No content found for the selected genre + No content found = ({ route, navigation }) => { const renderErrorState = () => ( + {error} @@ -238,13 +242,24 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { const renderLoadingState = () => ( + Loading content... ); if (loading && items.length === 0) { return ( - + + + navigation.goBack()} + > + + Back + + + {name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} {renderLoadingState()} ); @@ -253,7 +268,17 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { if (error && items.length === 0) { return ( - + + + navigation.goBack()} + > + + Back + + + {name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} {renderErrorState()} ); @@ -261,7 +286,18 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { return ( - + + + navigation.goBack()} + > + + Back + + + {name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} + {items.length > 0 ? ( = ({ route, navigation }) => { } contentContainerStyle={styles.list} columnWrapperStyle={styles.columnWrapper} + showsVerticalScrollIndicator={false} /> ) : renderEmptyState()} @@ -298,29 +335,60 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.darkBackground, }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + backText: { + fontSize: 17, + fontWeight: '400', + color: colors.primary, + }, + headerTitle: { + fontSize: 34, + fontWeight: '700', + color: colors.white, + paddingHorizontal: 16, + paddingBottom: 16, + paddingTop: 8, + }, list: { - padding: SPACING.md, + padding: SPACING.lg, + paddingTop: SPACING.sm, }, columnWrapper: { justifyContent: 'space-between', }, item: { width: ITEM_WIDTH, - marginBottom: SPACING.md, - borderRadius: 8, + marginBottom: SPACING.lg, + borderRadius: 12, overflow: 'hidden', + backgroundColor: colors.elevation2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, }, poster: { width: '100%', aspectRatio: 2/3, - borderRadius: 8, - backgroundColor: colors.transparentLight, + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + backgroundColor: colors.elevation3, }, itemContent: { - padding: SPACING.xs, + padding: SPACING.sm, }, title: { - marginTop: SPACING.xs, fontSize: 14, fontWeight: '600', color: colors.white, @@ -329,7 +397,7 @@ const styles = StyleSheet.create({ releaseInfo: { fontSize: 12, marginTop: SPACING.xs, - color: colors.lightGray, + color: colors.mediumGray, }, footer: { padding: SPACING.lg, @@ -358,14 +426,21 @@ const styles = StyleSheet.create({ color: colors.white, fontSize: 16, textAlign: 'center', - marginBottom: SPACING.md, + marginTop: SPACING.md, + marginBottom: SPACING.sm, }, errorText: { color: colors.white, fontSize: 16, textAlign: 'center', - marginBottom: SPACING.md, + marginTop: SPACING.md, + marginBottom: SPACING.sm, }, + loadingText: { + color: colors.white, + fontSize: 16, + marginTop: SPACING.lg, + } }); export default CatalogScreen; \ No newline at end of file diff --git a/src/screens/CatalogSettingsScreen.tsx b/src/screens/CatalogSettingsScreen.tsx index 485f939b..a22630fa 100644 --- a/src/screens/CatalogSettingsScreen.tsx +++ b/src/screens/CatalogSettingsScreen.tsx @@ -7,6 +7,9 @@ import { Switch, ActivityIndicator, TouchableOpacity, + SafeAreaView, + StatusBar, + Platform, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useNavigation } from '@react-navigation/native'; @@ -29,13 +32,25 @@ interface CatalogSettingsStorage { _lastUpdate: number; } +interface GroupedCatalogs { + [addonId: string]: { + name: string; + catalogs: CatalogSetting[]; + expanded: boolean; + enabledCount: number; + }; +} + const CATALOG_SETTINGS_KEY = 'catalog_settings'; +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const CatalogSettingsScreen = () => { const [loading, setLoading] = useState(true); const [settings, setSettings] = useState([]); + const [groupedSettings, setGroupedSettings] = useState({}); const navigation = useNavigation(); const { refreshCatalogs } = useCatalogContext(); + const isDarkMode = true; // Force dark mode // Load saved settings and available catalogs const loadSettings = useCallback(async () => { @@ -61,37 +76,17 @@ const CatalogSettingsScreen = () => { const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`; // Format catalog name - let displayName = catalog.name; + let displayName = catalog.name || catalog.id; - // Clean up the name and ensure type is included - const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; + // If catalog is a movie or series catalog, make that clear + const catalogType = catalog.type === 'movie' ? 'Movies' : catalog.type === 'series' ? 'TV Shows' : catalog.type.charAt(0).toUpperCase() + catalog.type.slice(1); - // Remove duplicate words (case-insensitive) - const words = displayName.split(' '); - const uniqueWords = []; - const seenWords = new Set(); - - for (const word of words) { - const lowerWord = word.toLowerCase(); - if (!seenWords.has(lowerWord)) { - uniqueWords.push(word); // Keep original case - seenWords.add(lowerWord); - } - } - displayName = uniqueWords.join(' '); - - // Add content type if not present (case-insensitive) - if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { - displayName = `${displayName} ${contentType}`; - } - - // Create unique catalog setting uniqueCatalogs.set(settingKey, { addonId: addon.id, catalogId: catalog.id, type: catalog.type, - name: `${addon.name} - ${displayName}`, - enabled: savedCatalogs[settingKey] ?? true // Enable by default + name: displayName, + enabled: savedCatalogs[settingKey] !== undefined ? savedCatalogs[settingKey] : true // Enable by default }); }); @@ -100,18 +95,30 @@ const CatalogSettingsScreen = () => { } }); - // Sort catalogs by addon name and then by catalog name - const sortedCatalogs = availableCatalogs.sort((a, b) => { - const [addonNameA] = a.name.split(' - '); - const [addonNameB] = b.name.split(' - '); + // Group settings by addon name + const grouped: GroupedCatalogs = {}; + + availableCatalogs.forEach(setting => { + const addon = addons.find(a => a.id === setting.addonId); + if (!addon) return; - if (addonNameA !== addonNameB) { - return addonNameA.localeCompare(addonNameB); + if (!grouped[setting.addonId]) { + grouped[setting.addonId] = { + name: addon.name, + catalogs: [], + expanded: true, // Start expanded + enabledCount: 0 + }; + } + + grouped[setting.addonId].catalogs.push(setting); + if (setting.enabled) { + grouped[setting.addonId].enabledCount++; } - return a.name.localeCompare(b.name); }); - setSettings(sortedCatalogs); + setSettings(availableCatalogs); + setGroupedSettings(grouped); } catch (error) { logger.error('Failed to load catalog settings:', error); } finally { @@ -137,85 +144,158 @@ const CatalogSettingsScreen = () => { }; // Toggle individual catalog - const toggleCatalog = (setting: CatalogSetting) => { - const newSettings = settings.map(s => { - if (s.addonId === setting.addonId && - s.type === setting.type && - s.catalogId === setting.catalogId) { - return { ...s, enabled: !s.enabled }; - } - return s; - }); + const toggleCatalog = (addonId: string, index: number) => { + const newSettings = [...settings]; + const catalogsForAddon = groupedSettings[addonId].catalogs; + const setting = catalogsForAddon[index]; + + const updatedSetting = { + ...setting, + enabled: !setting.enabled + }; + + // Update the setting in the flat list + const flatIndex = newSettings.findIndex(s => + s.addonId === setting.addonId && + s.type === setting.type && + s.catalogId === setting.catalogId + ); + + if (flatIndex !== -1) { + newSettings[flatIndex] = updatedSetting; + } + + // Update the grouped settings + const newGroupedSettings = { ...groupedSettings }; + newGroupedSettings[addonId].catalogs[index] = updatedSetting; + newGroupedSettings[addonId].enabledCount += updatedSetting.enabled ? 1 : -1; + setSettings(newSettings); + setGroupedSettings(newGroupedSettings); saveSettings(newSettings); }; + // Toggle expansion of a group + const toggleExpansion = (addonId: string) => { + setGroupedSettings(prev => ({ + ...prev, + [addonId]: { + ...prev[addonId], + expanded: !prev[addonId].expanded + } + })); + }; + useEffect(() => { loadSettings(); }, [loadSettings]); - // Group settings by addon - const groupedSettings: { [key: string]: CatalogSetting[] } = {}; - settings.forEach(setting => { - if (!groupedSettings[setting.addonId]) { - groupedSettings[setting.addonId] = []; - } - groupedSettings[setting.addonId].push(setting); - }); - if (loading) { return ( - - - + + + + navigation.goBack()} + > + + Settings + + + Catalogs + + + + ); } return ( - + + navigation.goBack()} > - + + Settings - Catalog Settings + Catalogs - - - Choose which catalogs to show on your home screen. Changes will take effect immediately. - - - {Object.entries(groupedSettings).map(([addonId, addonCatalogs]) => ( + + {Object.entries(groupedSettings).map(([addonId, group]) => ( - {addonCatalogs[0].name.split(' - ')[0]} + {group.name.toUpperCase()} - {addonCatalogs.map((setting) => ( - - - {setting.name.split(' - ')[1]} - - toggleCatalog(setting)} - trackColor={{ false: colors.mediumGray, true: colors.primary }} - /> - - ))} + + + toggleExpansion(addonId)} + activeOpacity={0.7} + > + Catalogs + + + {group.enabledCount} of {group.catalogs.length} enabled + + + + + + {group.expanded && group.catalogs.map((setting, index) => ( + + + + {setting.name} + + + {setting.type.charAt(0).toUpperCase() + setting.type.slice(1)} + + + toggleCatalog(addonId, index)} + trackColor={{ false: '#505050', true: colors.primary }} + thumbColor={Platform.OS === 'android' ? colors.white : undefined} + ios_backgroundColor="#505050" + /> + + ))} + ))} + + + ORGANIZATION + + + Reorder Sections + + + + Customize Names + + + + - + ); }; const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.background, + backgroundColor: colors.darkBackground, }, loadingContainer: { flex: 1, @@ -225,35 +305,77 @@ const styles = StyleSheet.create({ header: { flexDirection: 'row', alignItems: 'center', - padding: 16, - borderBottomWidth: 1, - borderBottomColor: colors.border, + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, }, backButton: { - marginRight: 16, + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + backText: { + fontSize: 17, + fontWeight: '400', + color: colors.primary, }, headerTitle: { - fontSize: 20, - fontWeight: 'bold', - color: colors.text, + fontSize: 34, + fontWeight: '700', + color: colors.white, + paddingHorizontal: 16, + paddingBottom: 16, + paddingTop: 8, }, scrollView: { flex: 1, }, - description: { - padding: 16, - fontSize: 14, - color: colors.mediumGray, + scrollContent: { + paddingBottom: 32, }, addonSection: { marginBottom: 24, }, addonTitle: { - fontSize: 18, - fontWeight: 'bold', - color: colors.text, - paddingHorizontal: 16, + fontSize: 13, + fontWeight: '600', + color: colors.mediumGray, + marginHorizontal: 16, marginBottom: 8, + letterSpacing: 0.8, + }, + card: { + marginHorizontal: 16, + borderRadius: 12, + overflow: 'hidden', + backgroundColor: colors.elevation2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + groupHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 0.5, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }, + groupTitle: { + fontSize: 17, + fontWeight: '600', + color: colors.white, + }, + groupHeaderRight: { + flexDirection: 'row', + alignItems: 'center', + }, + enabledCount: { + fontSize: 15, + color: colors.mediumGray, + marginRight: 8, }, catalogItem: { flexDirection: 'row', @@ -261,14 +383,33 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingVertical: 12, paddingHorizontal: 16, - borderBottomWidth: 1, - borderBottomColor: colors.border, + borderBottomWidth: 0.5, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }, + catalogInfo: { + flex: 1, }, catalogName: { - fontSize: 16, - color: colors.text, - flex: 1, - marginRight: 16, + fontSize: 15, + color: colors.white, + marginBottom: 2, + }, + catalogType: { + fontSize: 13, + color: colors.mediumGray, + }, + organizationItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 0.5, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }, + organizationItemText: { + fontSize: 17, + color: colors.white, }, }); diff --git a/src/screens/DiscoverScreen.tsx b/src/screens/DiscoverScreen.tsx index ceb096ef..f732dd90 100644 --- a/src/screens/DiscoverScreen.tsx +++ b/src/screens/DiscoverScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { View, Text, @@ -11,6 +11,7 @@ import { Dimensions, ScrollView, Platform, + Animated, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -18,10 +19,11 @@ import { MaterialIcons } from '@expo/vector-icons'; import { colors } from '../styles'; import { catalogService, StreamingContent, CatalogContent } from '../services/catalogService'; import { Image } from 'expo-image'; -import Animated, { FadeIn, FadeOut, SlideInRight, Layout } from 'react-native-reanimated'; +import { FadeIn, FadeOut, SlideInRight, Layout } from 'react-native-reanimated'; import { LinearGradient } from 'expo-linear-gradient'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; +import { BlurView } from 'expo-blur'; interface Category { id: string; @@ -65,28 +67,207 @@ const COMMON_GENRES = [ const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; -const DiscoverScreen = () => { - const navigation = useNavigation>(); - const [selectedCategory, setSelectedCategory] = useState(CATEGORIES[0]); - const [selectedGenre, setSelectedGenre] = useState('All'); - const [catalogs, setCatalogs] = useState([]); - const [allContent, setAllContent] = useState([]); - const [loading, setLoading] = useState(true); - const { width } = Dimensions.get('window'); - const itemWidth = (width - 60) / 4; // 4 items per row with spacing +// Memoized child components +const CategoryButton = React.memo(({ + category, + isSelected, + onPress +}: { + category: Category; + isSelected: boolean; + onPress: () => void; +}) => { + const styles = useStyles(); + return ( + + + + {category.name} + + + ); +}); - const styles = StyleSheet.create({ +const GenreButton = React.memo(({ + genre, + isSelected, + onPress +}: { + genre: string; + isSelected: boolean; + onPress: () => void; +}) => { + const styles = useStyles(); + return ( + + + {genre} + + + ); +}); + +const ContentItem = React.memo(({ + item, + onPress +}: { + item: StreamingContent; + onPress: () => void; +}) => { + const styles = useStyles(); + const { width } = Dimensions.get('window'); + const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing + + return ( + + + + + + {item.name} + + {item.year && ( + {item.year} + )} + + + + ); +}); + +const CatalogSection = React.memo(({ + catalog, + selectedCategory, + navigation +}: { + catalog: GenreCatalog; + selectedCategory: Category; + navigation: NavigationProp; +}) => { + const styles = useStyles(); + const { width } = Dimensions.get('window'); + const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing + + // Only display the first 3 items in the row + const displayItems = useMemo(() => + catalog.items.slice(0, 3), + [catalog.items] + ); + + const handleContentPress = useCallback((item: StreamingContent) => { + navigation.navigate('Metadata', { id: item.id, type: item.type }); + }, [navigation]); + + const renderItem = useCallback(({ item }: { item: StreamingContent }) => ( + handleContentPress(item)} + /> + ), [handleContentPress]); + + const handleSeeMorePress = useCallback(() => { + navigation.navigate('Catalog', { + id: 'discover', + type: selectedCategory.type, + name: `${catalog.genre} ${selectedCategory.name}`, + genreFilter: catalog.genre + }); + }, [navigation, selectedCategory, catalog.genre]); + + const keyExtractor = useCallback((item: StreamingContent) => item.id, []); + const ItemSeparator = useCallback(() => , []); + + return ( + + + + {catalog.genre} + + + + See All + + + + + + + ); +}); + +// Extract styles into a hook for better performance with dimensions +const useStyles = () => { + const { width } = Dimensions.get('window'); + + return StyleSheet.create({ container: { flex: 1, backgroundColor: colors.darkBackground, }, header: { - paddingHorizontal: 16, - paddingVertical: 12, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 4, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.1)', - backgroundColor: colors.darkBackground, + paddingHorizontal: 20, + paddingVertical: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16, }, headerContent: { flexDirection: 'row', @@ -96,66 +277,88 @@ const DiscoverScreen = () => { headerTitle: { fontSize: 32, fontWeight: '800', - letterSpacing: 0.5, color: colors.white, + letterSpacing: 0.3, }, searchButton: { - padding: 4, - marginLeft: 16, + padding: 10, + borderRadius: 24, + backgroundColor: 'rgba(255,255,255,0.08)', }, categoryContainer: { - paddingVertical: 12, + paddingVertical: 20, borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.1)', + borderBottomColor: 'rgba(255,255,255,0.05)', }, categoriesContent: { flexDirection: 'row', justifyContent: 'center', - paddingHorizontal: 12, - gap: 12, + paddingHorizontal: 20, + gap: 16, }, categoryButton: { paddingHorizontal: 20, - paddingVertical: 12, - marginHorizontal: 4, - borderRadius: 16, - borderWidth: 1, - borderColor: colors.lightGray, - backgroundColor: 'transparent', + paddingVertical: 14, + borderRadius: 24, + backgroundColor: 'rgba(255,255,255,0.05)', flexDirection: 'row', alignItems: 'center', - gap: 8, + gap: 10, + flex: 1, + maxWidth: 160, + justifyContent: 'center', + shadowColor: colors.black, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 4, }, - categoryIcon: { - marginRight: 4, + selectedCategoryButton: { + backgroundColor: colors.primary, }, categoryText: { color: colors.mediumGray, - fontWeight: '500', - fontSize: 15, + fontWeight: '600', + fontSize: 16, + }, + selectedCategoryText: { + color: colors.white, + fontWeight: '700', }, genreContainer: { - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.1)', + paddingTop: 20, + paddingBottom: 12, + zIndex: 10, }, genresScrollView: { - paddingHorizontal: 16, + paddingHorizontal: 20, + paddingBottom: 8, }, genreButton: { - paddingHorizontal: 16, - paddingVertical: 8, - marginRight: 8, - borderRadius: 16, - borderWidth: 1, - borderColor: colors.lightGray, - backgroundColor: 'transparent', + paddingHorizontal: 18, + paddingVertical: 10, + marginRight: 12, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.05)', + shadowColor: colors.black, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + overflow: 'hidden', + }, + selectedGenreButton: { + backgroundColor: colors.primary, }, genreText: { color: colors.mediumGray, fontWeight: '500', fontSize: 14, }, + selectedGenreText: { + color: colors.white, + fontWeight: '600', + }, loadingContainer: { flex: 1, justifyContent: 'center', @@ -165,34 +368,36 @@ const DiscoverScreen = () => { paddingVertical: 8, }, catalogContainer: { - marginBottom: 24, + marginBottom: 32, }, catalogHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - paddingHorizontal: 16, - marginBottom: 12, + paddingHorizontal: 20, + marginBottom: 16, }, - titleContainer: { + catalogTitleContainer: { flexDirection: 'column', }, + catalogTitleBar: { + width: 32, + height: 3, + backgroundColor: colors.primary, + marginTop: 6, + borderRadius: 2, + }, catalogTitle: { - fontSize: 18, + fontSize: 20, fontWeight: '700', color: colors.white, - marginBottom: 2, - }, - titleUnderline: { - height: 2, - width: 40, - backgroundColor: colors.primary, - borderRadius: 2, }, seeAllButton: { flexDirection: 'row', alignItems: 'center', gap: 4, + paddingVertical: 6, + paddingHorizontal: 4, }, seeAllText: { color: colors.primary, @@ -200,18 +405,17 @@ const DiscoverScreen = () => { fontSize: 14, }, contentItem: { - width: itemWidth, - marginHorizontal: 5, + marginHorizontal: 0, }, posterContainer: { - borderRadius: 8, + borderRadius: 16, overflow: 'hidden', - backgroundColor: colors.transparentLight, - elevation: 4, + backgroundColor: 'rgba(255,255,255,0.03)', + elevation: 5, shadowColor: colors.black, - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.25, - shadowRadius: 4, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, }, poster: { aspectRatio: 2/3, @@ -222,21 +426,23 @@ const DiscoverScreen = () => { bottom: 0, left: 0, right: 0, - padding: 8, + padding: 16, justifyContent: 'flex-end', + height: '45%', }, contentTitle: { - fontSize: 12, - fontWeight: '600', + fontSize: 15, + fontWeight: '700', color: colors.white, - marginBottom: 2, + marginBottom: 4, textShadowColor: 'rgba(0, 0, 0, 0.75)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2, + letterSpacing: 0.3, }, contentYear: { - fontSize: 10, - color: colors.mediumGray, + fontSize: 12, + color: 'rgba(255,255,255,0.7)', textShadowColor: 'rgba(0, 0, 0, 0.75)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2, @@ -245,15 +451,27 @@ const DiscoverScreen = () => { flex: 1, justifyContent: 'center', alignItems: 'center', - paddingTop: 100, + paddingTop: 80, }, emptyText: { color: colors.mediumGray, fontSize: 16, - fontWeight: '500', + textAlign: 'center', + paddingHorizontal: 32, }, }); +}; +const DiscoverScreen = () => { + const navigation = useNavigation>(); + const [selectedCategory, setSelectedCategory] = useState(CATEGORIES[0]); + const [selectedGenre, setSelectedGenre] = useState('All'); + const [catalogs, setCatalogs] = useState([]); + const [allContent, setAllContent] = useState([]); + const [loading, setLoading] = useState(true); + const styles = useStyles(); + + // Load content when category or genre changes useEffect(() => { loadContent(selectedCategory, selectedGenre); }, [selectedCategory, selectedGenre]); @@ -316,204 +534,97 @@ const DiscoverScreen = () => { } }; - const handleCategoryPress = (category: Category) => { + const handleCategoryPress = useCallback((category: Category) => { if (category.id !== selectedCategory.id) { setSelectedCategory(category); setSelectedGenre('All'); // Reset to All when changing category } - }; + }, [selectedCategory]); - const handleGenrePress = (genre: string) => { + const handleGenrePress = useCallback((genre: string) => { if (genre !== selectedGenre) { setSelectedGenre(genre); } - }; - - const handleSearchPress = () => { - // @ts-ignore - We'll fix navigation types later - navigation.navigate('Search'); - }; - - const renderCategory = ({ item }: { item: Category }) => { - const isSelected = selectedCategory.id === item.id; - return ( - handleCategoryPress(item)} - > - - - {item.name} - - - ); - }; - - const renderGenre = useCallback((genre: string) => { - const isSelected = selectedGenre === genre; - return ( - handleGenrePress(genre)} - > - - {genre} - - - ); }, [selectedGenre]); - const renderContentItem = useCallback(({ item }: { item: StreamingContent }) => { - return ( - { - navigation.navigate('Metadata', { id: item.id, type: item.type }); - }} - > - - - - - {item.name} - - {item.year && ( - {item.year} - )} - - - - ); + const handleSearchPress = useCallback(() => { + navigation.navigate('Search'); }, [navigation]); - const renderCatalog = useCallback(({ item }: { item: GenreCatalog }) => { - // Only display the first 4 items in the row - const displayItems = item.items.slice(0, 4); - - return ( - - - - {item.genre} - - - { - // Navigate to catalog view with genre filter - navigation.navigate('Catalog', { - id: 'discover', - type: selectedCategory.type, - name: `${item.genre} ${selectedCategory.name}`, - genreFilter: item.genre - }); - }} - style={styles.seeAllButton} - > - See More - - - - - item.id} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={{ paddingHorizontal: 11 }} - snapToInterval={itemWidth + 10} - decelerationRate="fast" - snapToAlignment="start" - ItemSeparatorComponent={() => } - /> - - ); - }, [navigation, selectedCategory]); + // Memoize rendering functions + const renderCatalogItem = useCallback(({ item }: { item: GenreCatalog }) => ( + + ), [selectedCategory, navigation]); + + // Memoize list key extractor + const catalogKeyExtractor = useCallback((item: GenreCatalog) => item.genre, []); return ( + {/* Header Section */} - - Discover - + Discover + {/* Categories Section */} {CATEGORIES.map((category) => ( - - {renderCategory({ item: category })} - + handleCategoryPress(category)} + /> ))} + {/* Genres Section */} - {COMMON_GENRES.map(genre => renderGenre(genre))} + {COMMON_GENRES.map(genre => ( + handleGenrePress(genre)} + /> + ))} + {/* Content Section */} {loading ? ( @@ -521,12 +632,14 @@ const DiscoverScreen = () => { ) : catalogs.length > 0 ? ( item.genre} + renderItem={renderCatalogItem} + keyExtractor={catalogKeyExtractor} contentContainerStyle={styles.catalogsContainer} showsVerticalScrollIndicator={false} initialNumToRender={3} maxToRenderPerBatch={3} + windowSize={5} + removeClippedSubviews={Platform.OS === 'android'} /> ) : ( @@ -540,4 +653,4 @@ const DiscoverScreen = () => { ); }; -export default DiscoverScreen; \ No newline at end of file +export default React.memo(DiscoverScreen); \ No newline at end of file diff --git a/src/screens/HeroCatalogsScreen.tsx b/src/screens/HeroCatalogsScreen.tsx new file mode 100644 index 00000000..258ae948 --- /dev/null +++ b/src/screens/HeroCatalogsScreen.tsx @@ -0,0 +1,318 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Switch, + ScrollView, + SafeAreaView, + StatusBar, + Platform, + useColorScheme, + ActivityIndicator, + Alert, +} from 'react-native'; +import { useSettings } from '../hooks/useSettings'; +import { useNavigation } from '@react-navigation/native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { colors } from '../styles/colors'; +import { catalogService, StreamingAddon } from '../services/catalogService'; + +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +interface CatalogItem { + id: string; // Combined ID in format: addonId:type:catalogId + name: string; + addonName: string; + type: string; +} + +const HeroCatalogsScreen: React.FC = () => { + const { settings, updateSetting } = useSettings(); + const systemColorScheme = useColorScheme(); + const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode; + const navigation = useNavigation(); + const [loading, setLoading] = useState(true); + const [catalogs, setCatalogs] = useState([]); + const [selectedCatalogs, setSelectedCatalogs] = useState(settings.selectedHeroCatalogs || []); + + const handleBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + // Load all available catalogs + useEffect(() => { + const loadCatalogs = async () => { + setLoading(true); + try { + const addons = await catalogService.getAllAddons(); + const catalogItems: CatalogItem[] = []; + + addons.forEach(addon => { + if (addon.catalogs && addon.catalogs.length > 0) { + addon.catalogs.forEach(catalog => { + catalogItems.push({ + id: `${addon.id}:${catalog.type}:${catalog.id}`, + name: catalog.name, + addonName: addon.name, + type: catalog.type, + }); + }); + } + }); + + setCatalogs(catalogItems); + } catch (error) { + console.error('Failed to load catalogs:', error); + Alert.alert('Error', 'Failed to load catalogs'); + } finally { + setLoading(false); + } + }; + + loadCatalogs(); + }, []); + + const handleSelectAll = useCallback(() => { + setSelectedCatalogs(catalogs.map(catalog => catalog.id)); + }, [catalogs]); + + const handleSelectNone = useCallback(() => { + setSelectedCatalogs([]); + }, []); + + const handleSave = useCallback(() => { + updateSetting('selectedHeroCatalogs', selectedCatalogs); + navigation.goBack(); + }, [navigation, selectedCatalogs, updateSetting]); + + const toggleCatalog = useCallback((catalogId: string) => { + setSelectedCatalogs(prev => { + if (prev.includes(catalogId)) { + return prev.filter(id => id !== catalogId); + } else { + return [...prev, catalogId]; + } + }); + }, []); + + // Group catalogs by addon + const catalogsByAddon: Record = {}; + catalogs.forEach(catalog => { + if (!catalogsByAddon[catalog.addonName]) { + catalogsByAddon[catalog.addonName] = []; + } + catalogsByAddon[catalog.addonName].push(catalog); + }); + + return ( + + + + + + + + Hero Section Catalogs + + + + {loading ? ( + + + + Loading catalogs... + + + ) : ( + <> + + + Select All + + + Clear All + + + Save + + + + + + Select which catalogs to display in the hero section. If none are selected, all catalogs will be used. + + + + + {Object.entries(catalogsByAddon).map(([addonName, addonCatalogs]) => ( + + + {addonName} + + + {addonCatalogs.map(catalog => ( + toggleCatalog(catalog.id)} + > + + + {catalog.name} + + + {catalog.type === 'movie' ? 'Movies' : 'TV Shows'} + + + + + ))} + + + ))} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 8, + }, + backButton: { + marginRight: 16, + padding: 4, + }, + headerTitle: { + fontSize: 22, + fontWeight: '700', + letterSpacing: 0.5, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 16, + fontSize: 16, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 32, + }, + actionBar: { + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 12, + justifyContent: 'space-between', + }, + actionButton: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 8, + marginRight: 8, + }, + actionButtonText: { + fontSize: 14, + fontWeight: '600', + }, + saveButton: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + }, + saveButtonText: { + color: colors.white, + fontSize: 14, + fontWeight: '600', + }, + infoCard: { + marginHorizontal: 16, + marginBottom: 16, + padding: 12, + borderRadius: 8, + backgroundColor: 'rgba(0, 0, 0, 0.05)', + }, + infoText: { + fontSize: 14, + }, + addonSection: { + marginBottom: 16, + }, + addonName: { + fontSize: 16, + fontWeight: '700', + marginHorizontal: 16, + marginBottom: 8, + }, + catalogsContainer: { + marginHorizontal: 16, + borderRadius: 12, + overflow: 'hidden', + }, + catalogItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 1, + }, + catalogInfo: { + flex: 1, + }, + catalogName: { + fontSize: 16, + fontWeight: '500', + }, + catalogType: { + fontSize: 14, + marginTop: 2, + }, +}); + +export default HeroCatalogsScreen; \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index dd5d7652..aef55096 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -52,6 +52,9 @@ import * as Haptics from 'expo-haptics'; import { tmdbService } from '../services/tmdbService'; import { logger } from '../utils/logger'; import { storageService } from '../services/storageService'; +import { useHomeCatalogs } from '../hooks/useHomeCatalogs'; +import { useFeaturedContent } from '../hooks/useFeaturedContent'; +import { useSettings, settingsEmitter } from '../hooks/useSettings'; // Define interfaces for our data interface Category { @@ -119,6 +122,8 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) const menuStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], + borderTopLeftRadius: 24, + borderTopRightRadius: 24, })); const menuOptions = [ @@ -193,7 +198,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) { source={{ uri: localItem.poster }} style={styles.poster} contentFit="cover" - transition={200} + transition={300} cachePolicy="memory-disk" recyclingKey={`poster-${localItem.id}`} onLoadStart={() => { @@ -303,12 +308,12 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { )} {isWatched && ( - + )} {localItem.inLibrary && ( - + )} @@ -333,114 +338,75 @@ const SAMPLE_CATEGORIES: Category[] = [ const SkeletonCatalog = () => ( - - - + + - - {[1, 2, 3, 4].map((_, index) => ( - - ))} - ); const SkeletonFeatured = () => ( - - - - - - - {[1, 2, 3].map((_, index) => ( - - ))} - - - - - - - - - + + + Loading featured content... ); -// Add genre mapping -const GENRE_MAP: { [key: number]: string } = { - 28: 'Action', - 12: 'Adventure', - 16: 'Animation', - 35: 'Comedy', - 80: 'Crime', - 99: 'Documentary', - 18: 'Drama', - 10751: 'Family', - 14: 'Fantasy', - 36: 'History', - 27: 'Horror', - 10402: 'Music', - 9648: 'Mystery', - 10749: 'Romance', - 878: 'Sci-Fi', - 10770: 'TV Movie', - 53: 'Thriller', - 10752: 'War', - 37: 'Western' -}; - const HomeScreen = () => { const navigation = useNavigation>(); const isDarkMode = useColorScheme() === 'dark'; - const [refreshing, setRefreshing] = useState(false); - const [loading, setLoading] = useState(true); - const [selectedCategory, setSelectedCategory] = useState('movie'); - const [featuredContent, setFeaturedContent] = useState(null); - const [allFeaturedContent, setAllFeaturedContent] = useState([]); - const [catalogs, setCatalogs] = useState([]); - const [imagesPreloaded, setImagesPreloaded] = useState(false); - const [loadingImages, setLoadingImages] = useState(true); - const maxRetries = 3; - const { lastUpdate } = useCatalogContext(); - const [isSaved, setIsSaved] = useState(false); - const abortControllerRef = useRef(null); - const currentIndexRef = useRef(0); const continueWatchingRef = useRef<{ refresh: () => Promise }>(null); + const { settings } = useSettings(); + const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection); + const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource); + const refreshTimeoutRef = useRef(null); - // Add auto-rotation effect + const { + catalogs, + loading: catalogsLoading, + refreshing: catalogsRefreshing, + refreshCatalogs + } = useHomeCatalogs(); + + const { + featuredContent, + loading: featuredLoading, + isSaved, + handleSaveToLibrary, + refreshFeatured + } = useFeaturedContent(); + + // Only count feature section as loading if it's enabled in settings + const isLoading = (showHeroSection ? featuredLoading : false) || catalogsLoading; + const isRefreshing = catalogsRefreshing; + + // React to settings changes useEffect(() => { - if (allFeaturedContent.length === 0) return; + setShowHeroSection(settings.showHeroSection); + setFeaturedContentSource(settings.featuredContentSource); + }, [settings]); - const rotateContent = () => { - currentIndexRef.current = (currentIndexRef.current + 1) % allFeaturedContent.length; - setFeaturedContent(allFeaturedContent[currentIndexRef.current]); - }; - - const intervalId = setInterval(rotateContent, 15000); // 15 seconds - - return () => { - clearInterval(intervalId); - }; - }, [allFeaturedContent]); - - // Cleanup function for ongoing operations - const cleanup = useCallback(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; + // If featured content source changes, refresh featured content with debouncing + useEffect(() => { + if (showHeroSection) { + // Clear any existing timeout + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } + + // Set a new timeout to debounce the refresh + refreshTimeoutRef.current = setTimeout(() => { + refreshFeatured(); + refreshTimeoutRef.current = null; + }, 300); } - }, []); - - // Cleanup on unmount - useEffect(() => { + + // Cleanup the timeout on unmount return () => { - cleanup(); + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } }; - }, [cleanup]); + }, [featuredContentSource, showHeroSection, refreshFeatured]); useEffect(() => { StatusBar.setTranslucent(true); @@ -451,11 +417,8 @@ const HomeScreen = () => { }; }, []); - // Pre-warm the metadata screen useEffect(() => { - // Pre-warm the navigation navigation.addListener('beforeRemove', () => {}); - return () => { navigation.removeListener('beforeRemove', () => {}); }; @@ -465,7 +428,6 @@ const HomeScreen = () => { if (!content.length) return; try { - setLoadingImages(true); const imagePromises = content.map(item => { const imagesToLoad = [ item.poster, @@ -481,167 +443,30 @@ const HomeScreen = () => { }); await Promise.all(imagePromises); - setImagesPreloaded(true); } catch (error) { console.error('Error preloading images:', error); - } finally { - setLoadingImages(false); } }, []); - const loadFeaturedContent = useCallback(async () => { + const handleRefresh = useCallback(async () => { try { - const trendingResults = await tmdbService.getTrending('movie', 'day'); + const refreshTasks = [ + refreshCatalogs(), + continueWatchingRef.current?.refresh(), + ]; - if (trendingResults.length > 0) { - const formattedContent: StreamingContent[] = trendingResults - .filter(item => item.title || item.name) // Filter out items without a name - .map(item => { - const yearString = (item.release_date || item.first_air_date)?.substring(0, 4); - return { - id: `tmdb:${item.id}`, - type: 'movie', - name: item.title || item.name || 'Unknown Title', - poster: tmdbService.getImageUrl(item.poster_path) || '', - banner: tmdbService.getImageUrl(item.backdrop_path) || '', - logo: item.external_ids?.imdb_id ? `https://images.metahub.space/logo/medium/${item.external_ids.imdb_id}/img` : undefined, - description: item.overview || '', - year: yearString ? parseInt(yearString, 10) : undefined, - genres: item.genre_ids.map(id => GENRE_MAP[id] || id.toString()), - inLibrary: false, - }; - }); - - setAllFeaturedContent(formattedContent); - // Randomly select a featured item - const randomIndex = Math.floor(Math.random() * formattedContent.length); - setFeaturedContent(formattedContent[randomIndex]); + // Only refresh featured content if hero section is enabled + if (showHeroSection) { + refreshTasks.push(refreshFeatured()); } - } catch (error) { - logger.error('Failed to load featured content:', error); - } - }, []); - - const loadCatalogs = useCallback(async () => { - // Create new abort controller for this load operation - cleanup(); - abortControllerRef.current = new AbortController(); - const signal = abortControllerRef.current.signal; - - try { - // Load catalogs from service - const homeCatalogs = await catalogService.getHomeCatalogs(); - if (signal.aborted) return; - - // If no catalogs found, wait and retry - if (!homeCatalogs?.length) { - console.log('No catalogs found'); - return; - } - - // Create a map to store unique catalogs by their content - const uniqueCatalogsMap = new Map(); - - homeCatalogs.forEach(catalog => { - const contentKey = catalog.items.map(item => item.id).sort().join(','); - if (!uniqueCatalogsMap.has(contentKey)) { - uniqueCatalogsMap.set(contentKey, catalog); - } - }); - - if (signal.aborted) return; - - const uniqueCatalogs = Array.from(uniqueCatalogsMap.values()); - setCatalogs(uniqueCatalogs); - - return; + await Promise.all(refreshTasks); } catch (error) { - console.error('Error in loadCatalogs:', error); - } finally { - if (!signal.aborted) { - setLoading(false); - setRefreshing(false); - } - } - }, [maxRetries, cleanup]); - - // Update loadInitialData to remove continue watching loading - const loadInitialData = async () => { - setLoading(true); - try { - await Promise.all([ - loadFeaturedContent(), - loadCatalogs(), - ]); - } catch (error) { - logger.error('Error loading initial data:', error); - } finally { - setLoading(false); - } - }; - - // Add back the useEffect for loadInitialData - useEffect(() => { - loadInitialData(); - }, [loadFeaturedContent, loadCatalogs, lastUpdate]); - - // Update handleRefresh to remove continue watching loading - const handleRefresh = useCallback(() => { - setRefreshing(true); - Promise.all([ - loadFeaturedContent(), - loadCatalogs(), - ]).catch(error => { logger.error('Error during refresh:', error); - }).finally(() => { - setRefreshing(false); - }); - }, [loadFeaturedContent, loadCatalogs]); - - // Check if content is in library - useEffect(() => { - if (featuredContent) { - const checkLibrary = async () => { - const items = await catalogService.getLibraryItems(); - setIsSaved(items.some(item => item.id === featuredContent.id)); - }; - checkLibrary(); } - }, [featuredContent]); - - // Subscribe to library updates - useEffect(() => { - const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => { - if (featuredContent) { - setIsSaved(items.some(item => item.id === featuredContent.id)); - } - }); - - return () => unsubscribe(); - }, [featuredContent]); - - const handleSaveToLibrary = useCallback(async () => { - if (!featuredContent) return; - - try { - if (isSaved) { - await catalogService.removeFromLibrary(featuredContent.type, featuredContent.id); - } else { - await catalogService.addToLibrary(featuredContent); - } - await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } catch (error) { - console.error('Error updating library:', error); - } - }, [featuredContent, isSaved]); - - const handleCategoryChange = (categoryId: string) => { - setSelectedCategory(categoryId); - }; + }, [refreshFeatured, refreshCatalogs, showHeroSection]); const handleContentPress = useCallback((id: string, type: string) => { - // Immediate navigation without any delays navigation.navigate('Metadata', { id, type }); }, [navigation]); @@ -659,22 +484,18 @@ const HomeScreen = () => { }); }, [featuredContent, navigation]); - // Add a function to refresh the Continue Watching section const refreshContinueWatching = useCallback(() => { if (continueWatchingRef.current) { continueWatchingRef.current.refresh(); } }, []); - // Update the event listener for video playback completion useEffect(() => { const handlePlaybackComplete = () => { refreshContinueWatching(); }; - // Listen for playback complete events const unsubscribe = navigation.addListener('focus', () => { - // When returning to HomeScreen, refresh Continue Watching refreshContinueWatching(); }); @@ -690,8 +511,15 @@ const HomeScreen = () => { return ( { + if (featuredContent) { + navigation.navigate('Metadata', { + id: featuredContent.id, + type: featuredContent.type + }); + } + }} style={styles.featuredContainer} > { - + {featuredContent.logo ? ( { {featuredContent.name} )} - {featuredContent.genres?.slice(0, 3).map((genre, index) => ( - {genre} + {featuredContent.genres?.slice(0, 3).map((genre, index, array) => ( + + {genre} + {index < array.length - 1 && ( + • + )} + ))} @@ -758,15 +591,10 @@ const HomeScreen = () => { style={styles.infoButton} onPress={async () => { if (featuredContent) { - // Convert TMDB ID to Stremio ID - const tmdbId = featuredContent.id.replace('tmdb:', ''); - const stremioId = await catalogService.getStremioId(featuredContent.type, tmdbId); - if (stremioId) { - navigation.navigate('Metadata', { - id: stremioId, - type: featuredContent.type - }); - } + navigation.navigate('Metadata', { + id: featuredContent.id, + type: featuredContent.type + }); } }} > @@ -781,18 +609,25 @@ const HomeScreen = () => { ); }; - const renderContentItem = useCallback(({ item }: { item: StreamingContent }) => { + const renderContentItem = useCallback(({ item, index }: { item: StreamingContent, index: number }) => { return ( - + + + ); }, [handleContentPress]); const renderCatalog = ({ item }: { item: CatalogContent }) => { return ( - + {item.name} @@ -820,30 +655,30 @@ const HomeScreen = () => { renderContentItem({ item, index })} keyExtractor={(item) => `${item.id}-${item.type}`} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.catalogList} - snapToInterval={POSTER_WIDTH + 10} + snapToInterval={POSTER_WIDTH + 12} decelerationRate="fast" snapToAlignment="start" - ItemSeparatorComponent={() => } + ItemSeparatorComponent={() => } initialNumToRender={4} maxToRenderPerBatch={4} windowSize={5} removeClippedSubviews={Platform.OS === 'android'} getItemLayout={(data, index) => ({ - length: POSTER_WIDTH + 10, - offset: (POSTER_WIDTH + 10) * index, + length: POSTER_WIDTH + 12, + offset: (POSTER_WIDTH + 12) * index, index, })} /> - + ); }; - if (loading && !refreshing) { + if (isLoading && !isRefreshing) { return ( { backgroundColor="transparent" translucent /> - - - {[1, 2, 3].map((_, index) => ( - - ))} - + + + Loading your content... + ); } @@ -873,38 +703,48 @@ const HomeScreen = () => { /> + } contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false} > - {/* Featured Content */} - {renderFeaturedContent()} + {showHeroSection && renderFeaturedContent()} - {/* This Week Section */} - + + + - {/* Continue Watching Section */} - + + + - {/* Catalogs */} {catalogs.length > 0 ? ( - `${item.addon}-${item.id}-${index}`} - scrollEnabled={false} - removeClippedSubviews={false} - initialNumToRender={3} - maxToRenderPerBatch={3} - windowSize={5} - /> + catalogs.map((catalog, index) => ( + + {renderCatalog({ item: catalog })} + + )) ) : ( - - - No content available. Pull down to refresh. - - + !catalogsLoading && ( + + + + No content available + + navigation.navigate('Settings')} + > + + Add Catalogs + + + ) )} @@ -912,7 +752,7 @@ const HomeScreen = () => { }; const { width, height } = Dimensions.get('window'); -const POSTER_WIDTH = (width - 40) / 2.7; +const POSTER_WIDTH = (width - 50) / 3; const styles = StyleSheet.create({ container: { @@ -920,7 +760,7 @@ const styles = StyleSheet.create({ backgroundColor: colors.darkBackground, }, scrollContent: { - paddingBottom: 32, + paddingBottom: 40, }, loadingContainer: { flex: 1, @@ -929,11 +769,10 @@ const styles = StyleSheet.create({ }, featuredContainer: { width: '100%', - height: height * 0.65, - marginTop: 0, - marginBottom: 0, + height: height * 0.6, + marginTop: Platform.OS === 'ios' ? 85 : 75, + marginBottom: 8, position: 'relative', - paddingTop: 56, }, featuredBanner: { width: '100%', @@ -950,7 +789,7 @@ const styles = StyleSheet.create({ alignItems: 'center', flex: 1, justifyContent: 'flex-end', - gap: 8, + gap: 12, }, featuredLogo: { width: width * 0.7, @@ -972,21 +811,22 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - marginBottom: 0, + marginBottom: 16, flexWrap: 'wrap', gap: 4, }, genreText: { color: colors.white, - fontSize: 13, + fontSize: 14, fontWeight: '500', opacity: 0.9, }, genreDot: { color: colors.white, - fontSize: 13, - marginHorizontal: 4, + fontSize: 14, + fontWeight: '500', opacity: 0.6, + marginHorizontal: 4, }, featuredButtons: { flexDirection: 'row', @@ -994,16 +834,16 @@ const styles = StyleSheet.create({ justifyContent: 'space-evenly', width: '100%', flex: 1, - maxHeight: 60, - paddingTop: 12, + maxHeight: 65, + paddingTop: 16, }, playButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 14, - paddingHorizontal: 24, - borderRadius: 100, + paddingHorizontal: 32, + borderRadius: 30, backgroundColor: colors.white, elevation: 4, shadowColor: '#000', @@ -1019,8 +859,8 @@ const styles = StyleSheet.create({ alignItems: 'center', padding: 0, gap: 6, - width: 40, - height: 41, + width: 44, + height: 44, flex: null, }, infoButton: { @@ -1029,8 +869,8 @@ const styles = StyleSheet.create({ alignItems: 'center', padding: 0, gap: 4, - width: 40, - height: 39, + width: 44, + height: 44, flex: null, }, playButtonText: { @@ -1052,14 +892,14 @@ const styles = StyleSheet.create({ catalogContainer: { marginBottom: 24, paddingTop: 0, - marginTop: 12, + marginTop: 16, }, catalogHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, - marginBottom: 8, + marginBottom: 12, }, titleContainer: { position: 'relative', @@ -1096,14 +936,14 @@ const styles = StyleSheet.create({ }, catalogList: { paddingHorizontal: 16, - paddingBottom: 8, - paddingTop: 4, + paddingBottom: 12, + paddingTop: 6, }, contentItem: { width: POSTER_WIDTH, aspectRatio: 2/3, margin: 0, - borderRadius: 12, + borderRadius: 16, overflow: 'hidden', position: 'relative', elevation: 8, @@ -1112,12 +952,12 @@ const styles = StyleSheet.create({ shadowOpacity: 0.3, shadowRadius: 8, borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', + borderColor: 'rgba(255,255,255,0.08)', }, poster: { width: '100%', height: '100%', - borderRadius: 12, + borderRadius: 16, }, imdbLogo: { width: 35, @@ -1147,7 +987,7 @@ const styles = StyleSheet.create({ }, skeletonBox: { backgroundColor: colors.elevation2, - borderRadius: 12, + borderRadius: 16, overflow: 'hidden', }, skeletonFeatured: { @@ -1161,12 +1001,12 @@ const styles = StyleSheet.create({ skeletonPoster: { backgroundColor: colors.elevation1, marginHorizontal: 4, - borderRadius: 12, + borderRadius: 16, }, contentItemContainer: { width: '100%', height: '100%', - borderRadius: 12, + borderRadius: 16, overflow: 'hidden', position: 'relative', }, @@ -1197,11 +1037,11 @@ const styles = StyleSheet.create({ borderRadius: 2, alignSelf: 'center', marginTop: 12, - marginBottom: 8, + marginBottom: 10, }, menuContainer: { - borderTopLeftRadius: 16, - borderTopRightRadius: 16, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, paddingBottom: Platform.select({ ios: 40, android: 24 }), ...Platform.select({ ios: { @@ -1224,7 +1064,7 @@ const styles = StyleSheet.create({ menuPoster: { width: 60, height: 90, - borderRadius: 8, + borderRadius: 12, }, menuTitleContainer: { flex: 1, @@ -1280,7 +1120,7 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center', - borderRadius: 12, + borderRadius: 16, }, featuredImage: { width: '100%', @@ -1289,6 +1129,8 @@ const styles = StyleSheet.create({ featuredContentContainer: { flex: 1, justifyContent: 'flex-end', + paddingHorizontal: 16, + paddingBottom: 20, }, featuredTitleText: { color: colors.highEmphasis, @@ -1301,6 +1143,51 @@ const styles = StyleSheet.create({ textAlign: 'center', paddingHorizontal: 16, }, + addCatalogButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.primary, + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 30, + marginTop: 16, + elevation: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 3, + }, + addCatalogButtonText: { + color: colors.white, + fontSize: 14, + fontWeight: '600', + marginLeft: 8, + }, + loadingMainContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingBottom: 40, + }, + loadingText: { + color: colors.textMuted, + marginTop: 12, + fontSize: 14, + }, + loadingPlaceholder: { + height: 200, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.elevation1, + borderRadius: 12, + marginHorizontal: 16, + }, + featuredLoadingContainer: { + height: height * 0.4, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.elevation1, + }, }); export default HomeScreen; \ No newline at end of file diff --git a/src/screens/HomeScreenSettings.tsx b/src/screens/HomeScreenSettings.tsx new file mode 100644 index 00000000..acbd34fd --- /dev/null +++ b/src/screens/HomeScreenSettings.tsx @@ -0,0 +1,472 @@ +import React, { useCallback, useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Switch, + ScrollView, + SafeAreaView, + StatusBar, + Platform, + useColorScheme, + Animated +} from 'react-native'; +import { useSettings } from '../hooks/useSettings'; +import { useNavigation } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { colors } from '../styles/colors'; +import { RootStackParamList } from '../navigation/AppNavigator'; + +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +interface SettingsCardProps { + children: React.ReactNode; + isDarkMode: boolean; +} + +const SettingsCard: React.FC = ({ children, isDarkMode }) => ( + + {children} + +); + +// Restrict icon names to those available in MaterialIcons +type MaterialIconName = React.ComponentProps['name']; + +interface SettingItemProps { + title: string; + description?: string; + icon: MaterialIconName; + renderControl: () => React.ReactNode; + isLast?: boolean; + onPress?: () => void; + isDarkMode: boolean; +} + +const SettingItem: React.FC = ({ + title, + description, + icon, + renderControl, + isLast = false, + onPress, + isDarkMode +}) => { + return ( + + + + + + + + {title} + + {description && ( + + {description} + + )} + + + + {renderControl()} + + + ); +}; + +const SectionHeader: React.FC<{ title: string; isDarkMode: boolean }> = ({ title, isDarkMode }) => ( + + + {title} + + +); + +const HomeScreenSettings: React.FC = () => { + const { settings, updateSetting } = useSettings(); + const systemColorScheme = useColorScheme(); + const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode; + const navigation = useNavigation>(); + const [showSavedIndicator, setShowSavedIndicator] = useState(false); + const fadeAnim = React.useRef(new Animated.Value(0)).current; + + const handleBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + // Fade in/out animation for the "Changes saved" indicator + useEffect(() => { + if (showSavedIndicator) { + Animated.sequence([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true + }), + Animated.delay(1000), + Animated.timing(fadeAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true + }) + ]).start(() => setShowSavedIndicator(false)); + } + }, [showSavedIndicator, fadeAnim]); + + const handleUpdateSetting = useCallback(( + key: K, + value: typeof settings[K] + ) => { + updateSetting(key, value); + setShowSavedIndicator(true); + }, [updateSetting]); + + const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => ( + + ); + + // Radio button component for content source selection + const RadioOption = ({ selected, onPress, label }: { selected: boolean, onPress: () => void, label: string }) => ( + + + + {selected && } + + + {label} + + + + ); + + // Format selected catalogs text + const getSelectedCatalogsText = useCallback(() => { + if (!settings.selectedHeroCatalogs || settings.selectedHeroCatalogs.length === 0) { + return "All catalogs"; + } else { + return `${settings.selectedHeroCatalogs.length} selected`; + } + }, [settings.selectedHeroCatalogs]); + + const ChevronRight = () => ( + + ); + + return ( + + + + + + + + Home Screen Settings + + + + {/* Saved indicator */} + + + Changes Applied + + + + + + ( + handleUpdateSetting('showHeroSection', value)} + /> + )} + /> + } + /> + {settings.featuredContentSource === 'catalogs' && ( + navigation.navigate('HeroCatalogs')} + isLast={true} + /> + )} + {settings.featuredContentSource !== 'catalogs' && ( + // Placeholder to maintain layout + )} + + + {settings.showHeroSection && ( + <> + + handleUpdateSetting('featuredContentSource', 'tmdb')} + label="TMDB Trending Movies" + /> + + + Featured content will be sourced from TMDB's trending movies API. This provides a variety of popular and recent content, even if not available in your catalogs. + + + + + + handleUpdateSetting('featuredContentSource', 'catalogs')} + label="Installed Catalogs" + /> + + + Featured content will be sourced from your enabled catalogs. This ensures that featured content is available to stream from your installed add-ons. + + + + + )} + + + + + These settings control how content is displayed on your Home screen. Changes are applied immediately without requiring an app restart. + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 8, + }, + backButton: { + marginRight: 16, + padding: 4, + }, + headerTitle: { + fontSize: 22, + fontWeight: '700', + letterSpacing: 0.5, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 32, + }, + sectionHeader: { + paddingHorizontal: 16, + paddingTop: 20, + paddingBottom: 8, + }, + sectionHeaderText: { + fontSize: 12, + fontWeight: '600', + letterSpacing: 0.8, + }, + card: { + marginHorizontal: 16, + borderRadius: 12, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + settingItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 0.5, + minHeight: 44, + }, + settingItemBorder: { + // Border styling handled directly in the component with borderBottomWidth + }, + settingIconContainer: { + marginRight: 12, + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + }, + settingContent: { + flex: 1, + marginRight: 8, + }, + settingTitleRow: { + flexDirection: 'column', + justifyContent: 'center', + gap: 4, + }, + settingTitle: { + fontSize: 16, + fontWeight: '500', + }, + settingDescription: { + fontSize: 14, + opacity: 0.7, + }, + settingControl: { + justifyContent: 'center', + alignItems: 'center', + paddingLeft: 12, + }, + radioCardContainer: { + marginHorizontal: 16, + marginVertical: 8, + borderRadius: 12, + backgroundColor: colors.elevation1, + overflow: 'hidden', + }, + radioOption: { + padding: 16, + }, + radioContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + radio: { + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 2, + marginRight: 10, + justifyContent: 'center', + alignItems: 'center', + }, + radioInner: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: colors.primary, + }, + radioLabel: { + fontSize: 16, + fontWeight: '500', + }, + radioDescription: { + paddingHorizontal: 16, + paddingBottom: 16, + paddingTop: 0, + }, + radioDescriptionText: { + fontSize: 14, + lineHeight: 20, + }, + infoCard: { + marginHorizontal: 16, + marginTop: 8, + padding: 16, + borderRadius: 12, + }, + infoText: { + fontSize: 14, + lineHeight: 20, + }, + savedIndicator: { + position: 'absolute', + top: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 60 : 90, + alignSelf: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 24, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + elevation: 5, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + }, + savedIndicatorText: { + color: '#FFFFFF', + marginLeft: 6, + fontWeight: '600', + }, +}); + +export default HomeScreenSettings; \ No newline at end of file diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 22570c4b..93422eda 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -19,6 +19,7 @@ import { MaterialIcons } from '@expo/vector-icons'; import { colors } from '../styles'; import { Image } from 'expo-image'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { LinearGradient } from 'expo-linear-gradient'; import { catalogService } from '../services/catalogService'; import type { StreamingContent } from '../services/catalogService'; import { RootStackParamList } from '../navigation/AppNavigator'; @@ -81,7 +82,7 @@ const SkeletonLoader = () => { return ( {[...Array(6)].map((_, index) => ( - + {renderSkeletonItem()} ))} @@ -135,13 +136,32 @@ const LibraryScreen = () => { navigation.navigate('Metadata', { id: item.id, type: item.type })} + activeOpacity={0.7} > + + + {item.name} + + {item.lastWatched && ( + + {item.lastWatched} + + )} + + {item.progress !== undefined && item.progress < 1 && ( { @@ -164,17 +184,6 @@ const LibraryScreen = () => { )} - - {item.name} - - {item.lastWatched && ( - - {item.lastWatched} - - )} ); @@ -185,25 +194,21 @@ const LibraryScreen = () => { style={[ styles.filterButton, isActive && styles.filterButtonActive, - { - borderColor: isDarkMode ? 'rgba(255,255,255,0.3)' : colors.border, - backgroundColor: isDarkMode && !isActive ? 'rgba(255,255,255,0.15)' : 'transparent' - } ]} onPress={() => setFilter(filterType)} + activeOpacity={0.7} > {label} @@ -212,10 +217,11 @@ const LibraryScreen = () => { }; return ( - + @@ -236,21 +242,21 @@ const LibraryScreen = () => { - - Your library is empty - - - Add items to your library by marking them as favorites + Your library is empty + + Add content to your library to keep track of what you're watching + navigation.navigate('Discover')} + activeOpacity={0.7} + > + Explore Content + ) : ( { renderItem={renderItem} keyExtractor={item => item.id} numColumns={2} - contentContainerStyle={styles.listContent} + contentContainerStyle={styles.listContainer} showsVerticalScrollIndicator={false} + columnWrapperStyle={styles.columnWrapper} + initialNumToRender={6} + maxToRenderPerBatch={6} + windowSize={5} + removeClippedSubviews={Platform.OS === 'android'} /> )} @@ -269,14 +280,12 @@ const LibraryScreen = () => { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.darkBackground, }, header: { - paddingHorizontal: 16, - paddingVertical: 12, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 4, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.1)', - backgroundColor: colors.darkBackground, + paddingHorizontal: 20, + paddingVertical: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16, }, headerContent: { flexDirection: 'row', @@ -287,90 +296,94 @@ const styles = StyleSheet.create({ fontSize: 32, fontWeight: '800', color: colors.white, - letterSpacing: 0.5, + letterSpacing: 0.3, }, filtersContainer: { flexDirection: 'row', paddingHorizontal: 16, - paddingVertical: 12, - gap: 12, - backgroundColor: colors.black, + paddingBottom: 16, + paddingTop: 8, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.05)', + zIndex: 10, }, filterButton: { flexDirection: 'row', alignItems: 'center', + paddingVertical: 10, paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - borderWidth: 1, - borderColor: colors.darkGray, - backgroundColor: 'transparent', - gap: 6, - minWidth: 100, - justifyContent: 'center', + marginHorizontal: 4, + borderRadius: 24, + backgroundColor: 'rgba(255,255,255,0.05)', + shadowColor: colors.black, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, }, filterButtonActive: { - backgroundColor: colors.primary + '20', - borderColor: colors.primary, + backgroundColor: colors.primary, }, filterIcon: { - marginRight: 2, + marginRight: 8, }, filterText: { - fontSize: 14, + fontSize: 15, fontWeight: '500', + color: colors.mediumGray, }, filterTextActive: { - color: colors.primary, fontWeight: '600', + color: colors.white, }, - listContent: { - paddingHorizontal: 8, + listContainer: { + paddingHorizontal: 12, + paddingVertical: 16, + }, + columnWrapper: { + justifyContent: 'space-between', + marginBottom: 16, + }, + skeletonContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + paddingHorizontal: 12, paddingTop: 16, - paddingBottom: 32, - alignItems: 'flex-start', + justifyContent: 'space-between', }, itemContainer: { - marginHorizontal: 8, - marginBottom: 24, + marginBottom: 16, }, posterContainer: { - position: 'relative', - borderRadius: 12, + borderRadius: 16, overflow: 'hidden', + backgroundColor: 'rgba(255,255,255,0.03)', aspectRatio: 2/3, - marginBottom: 8, - backgroundColor: colors.darkBackground, - elevation: 4, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 3.84, + elevation: 5, + shadowColor: colors.black, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, }, poster: { width: '100%', height: '100%', }, - itemTitle: { - fontSize: 14, - fontWeight: '600', - marginBottom: 4, - lineHeight: 20, - }, - lastWatched: { - fontSize: 12, - lineHeight: 16, - opacity: 0.7, + posterGradient: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + padding: 16, + justifyContent: 'flex-end', + height: '45%', }, progressBarContainer: { position: 'absolute', bottom: 0, left: 0, right: 0, - height: 3, + height: 4, backgroundColor: 'rgba(0,0,0,0.5)', }, progressBar: { @@ -379,9 +392,9 @@ const styles = StyleSheet.create({ }, badgeContainer: { position: 'absolute', - top: 8, - right: 8, - backgroundColor: 'rgba(0,0,0,0.75)', + top: 10, + right: 10, + backgroundColor: 'rgba(0,0,0,0.7)', borderRadius: 12, paddingHorizontal: 8, paddingVertical: 4, @@ -390,9 +403,31 @@ const styles = StyleSheet.create({ }, badgeText: { color: colors.white, - fontSize: 12, + fontSize: 10, fontWeight: '600', }, + itemTitle: { + fontSize: 15, + fontWeight: '700', + color: colors.white, + marginBottom: 4, + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + letterSpacing: 0.3, + }, + lastWatched: { + fontSize: 12, + color: 'rgba(255,255,255,0.7)', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + skeletonTitle: { + height: 14, + marginTop: 8, + borderRadius: 4, + }, emptyContainer: { flex: 1, justifyContent: 'center', @@ -400,30 +435,34 @@ const styles = StyleSheet.create({ paddingHorizontal: 32, }, emptyText: { - fontSize: 18, - fontWeight: 'bold', + fontSize: 20, + fontWeight: '700', + color: colors.white, marginTop: 16, marginBottom: 8, - textAlign: 'center', }, emptySubtext: { - fontSize: 14, + fontSize: 15, + color: colors.mediumGray, textAlign: 'center', - lineHeight: 20, - opacity: 0.7, + marginBottom: 24, }, - skeletonContainer: { - padding: 16, - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'space-between', - }, - skeletonTitle: { - height: 20, - borderRadius: 4, - marginTop: 8, - width: '80%', + exploreButton: { + backgroundColor: colors.primary, + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 24, + elevation: 3, + shadowColor: colors.black, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, }, + exploreButtonText: { + color: colors.white, + fontSize: 16, + fontWeight: '600', + } }); export default LibraryScreen; \ No newline at end of file diff --git a/src/screens/MDBListSettingsScreen.tsx b/src/screens/MDBListSettingsScreen.tsx new file mode 100644 index 00000000..73dc1f38 --- /dev/null +++ b/src/screens/MDBListSettingsScreen.tsx @@ -0,0 +1,824 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + TextInput, + SafeAreaView, + StatusBar, + Platform, + Alert, + ActivityIndicator, + Linking, + ScrollView, + Keyboard, + Clipboard, + Switch, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { colors } from '../styles/colors'; +import { logger } from '../utils/logger'; +import { RATING_PROVIDERS } from '../components/metadata/RatingsSection'; + +export const MDBLIST_API_KEY_STORAGE_KEY = 'mdblist_api_key'; +export const RATING_PROVIDERS_STORAGE_KEY = 'rating_providers_config'; +export const MDBLIST_ENABLED_STORAGE_KEY = 'mdblist_enabled'; +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +// Function to check if MDBList is enabled +export const isMDBListEnabled = async (): Promise => { + try { + const enabledSetting = await AsyncStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY); + return enabledSetting === null || enabledSetting === 'true'; + } catch (error) { + logger.error('[MDBList] Error checking if MDBList is enabled:', error); + return true; // Default to enabled if there's an error + } +}; + +// Function to get MDBList API key if enabled +export const getMDBListAPIKey = async (): Promise => { + try { + const isEnabled = await isMDBListEnabled(); + if (!isEnabled) { + logger.log('[MDBList] MDBList is disabled, not retrieving API key'); + return null; + } + + return await AsyncStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY); + } catch (error) { + logger.error('[MDBList] Error retrieving API key:', error); + return null; + } +}; + +const MDBListSettingsScreen = () => { + const navigation = useNavigation(); + const [apiKey, setApiKey] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isKeySet, setIsKeySet] = useState(false); + const [isMdbListEnabled, setIsMdbListEnabled] = useState(true); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + const [isInputFocused, setIsInputFocused] = useState(false); + const [enabledProviders, setEnabledProviders] = useState>({}); + const apiKeyInputRef = useRef(null); + + useEffect(() => { + logger.log('[MDBListSettingsScreen] Component mounted'); + loadApiKey(); + loadProviderSettings(); + loadMdbListEnabledSetting(); + return () => { + logger.log('[MDBListSettingsScreen] Component unmounted'); + }; + }, []); + + const loadMdbListEnabledSetting = async () => { + logger.log('[MDBListSettingsScreen] Loading MDBList enabled setting'); + try { + const savedSetting = await AsyncStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY); + if (savedSetting !== null) { + setIsMdbListEnabled(savedSetting === 'true'); + logger.log('[MDBListSettingsScreen] MDBList enabled setting:', savedSetting === 'true'); + } else { + // Default to enabled if no setting found + setIsMdbListEnabled(true); + await AsyncStorage.setItem(MDBLIST_ENABLED_STORAGE_KEY, 'true'); + logger.log('[MDBListSettingsScreen] MDBList enabled setting not found, defaulting to true'); + } + } catch (error) { + logger.error('[MDBListSettingsScreen] Failed to load MDBList enabled setting:', error); + setIsMdbListEnabled(true); + } + }; + + const toggleMdbListEnabled = async () => { + logger.log('[MDBListSettingsScreen] Toggling MDBList enabled setting'); + try { + const newValue = !isMdbListEnabled; + setIsMdbListEnabled(newValue); + await AsyncStorage.setItem(MDBLIST_ENABLED_STORAGE_KEY, newValue.toString()); + logger.log('[MDBListSettingsScreen] MDBList enabled set to:', newValue); + } catch (error) { + logger.error('[MDBListSettingsScreen] Failed to save MDBList enabled setting:', error); + } + }; + + const loadApiKey = async () => { + logger.log('[MDBListSettingsScreen] Loading API key from storage'); + try { + const savedKey = await AsyncStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY); + logger.log('[MDBListSettingsScreen] API key status:', savedKey ? 'Found' : 'Not found'); + if (savedKey) { + setApiKey(savedKey); + setIsKeySet(true); + } else { + setIsKeySet(false); + } + } catch (error) { + logger.error('[MDBListSettingsScreen] Failed to load API key:', error); + setIsKeySet(false); + } finally { + setIsLoading(false); + logger.log('[MDBListSettingsScreen] Finished loading API key'); + } + }; + + const loadProviderSettings = async () => { + try { + const savedSettings = await AsyncStorage.getItem(RATING_PROVIDERS_STORAGE_KEY); + if (savedSettings) { + setEnabledProviders(JSON.parse(savedSettings)); + } else { + // Default all providers to enabled + const defaultSettings = Object.keys(RATING_PROVIDERS).reduce((acc, key) => { + acc[key] = true; + return acc; + }, {} as Record); + setEnabledProviders(defaultSettings); + await AsyncStorage.setItem(RATING_PROVIDERS_STORAGE_KEY, JSON.stringify(defaultSettings)); + } + } catch (error) { + logger.error('[MDBListSettingsScreen] Failed to load provider settings:', error); + } + }; + + const toggleProvider = async (providerId: string) => { + try { + const newSettings = { + ...enabledProviders, + [providerId]: !enabledProviders[providerId] + }; + setEnabledProviders(newSettings); + await AsyncStorage.setItem(RATING_PROVIDERS_STORAGE_KEY, JSON.stringify(newSettings)); + } catch (error) { + logger.error('[MDBListSettingsScreen] Failed to save provider settings:', error); + } + }; + + const saveApiKey = async () => { + logger.log('[MDBListSettingsScreen] Starting API key save'); + Keyboard.dismiss(); + + try { + const trimmedKey = apiKey.trim(); + if (!trimmedKey) { + logger.warn('[MDBListSettingsScreen] Empty API key provided'); + setTestResult({ success: false, message: 'API Key cannot be empty.' }); + return; + } + + logger.log('[MDBListSettingsScreen] Saving API key'); + await AsyncStorage.setItem(MDBLIST_API_KEY_STORAGE_KEY, trimmedKey); + setIsKeySet(true); + setTestResult({ success: true, message: 'API key saved successfully.' }); + logger.log('[MDBListSettingsScreen] API key saved successfully'); + + } catch (error) { + logger.error('[MDBListSettingsScreen] Error saving API key:', error); + setTestResult({ + success: false, + message: 'An error occurred while saving. Please try again.' + }); + } + }; + + const clearApiKey = async () => { + logger.log('[MDBListSettingsScreen] Clear API key requested'); + Alert.alert( + 'Clear API Key', + 'Are you sure you want to remove the saved API key?', + [ + { + text: 'Cancel', + style: 'cancel', + onPress: () => logger.log('[MDBListSettingsScreen] Clear API key cancelled') + }, + { + text: 'Clear', + style: 'destructive', + onPress: async () => { + logger.log('[MDBListSettingsScreen] Proceeding with API key clear'); + try { + await AsyncStorage.removeItem(MDBLIST_API_KEY_STORAGE_KEY); + setApiKey(''); + setIsKeySet(false); + setTestResult(null); + logger.log('[MDBListSettingsScreen] API key cleared successfully'); + } catch (error) { + logger.error('[MDBListSettingsScreen] Failed to clear API key:', error); + Alert.alert('Error', 'Failed to clear API key'); + } + } + } + ] + ); + }; + + const pasteFromClipboard = async () => { + logger.log('[MDBListSettingsScreen] Attempting to paste from clipboard'); + try { + const clipboardContent = await Clipboard.getString(); + if (clipboardContent) { + logger.log('[MDBListSettingsScreen] Content pasted from clipboard'); + setApiKey(clipboardContent); + setTestResult(null); + } else { + logger.warn('[MDBListSettingsScreen] No content in clipboard'); + } + } catch (error) { + logger.error('[MDBListSettingsScreen] Error pasting from clipboard:', error); + } + }; + + const openMDBListWebsite = () => { + logger.log('[MDBListSettingsScreen] Opening MDBList website'); + Linking.openURL('https://mdblist.com/settings').catch(error => { + logger.error('[MDBListSettingsScreen] Error opening website:', error); + }); + }; + + if (isLoading) { + return ( + + + + + Loading Settings... + + + ); + } + + return ( + + + + navigation.goBack()} + > + + Settings + + + Rating Sources + + + + + + + {!isMdbListEnabled + ? "MDBList Disabled" + : isKeySet + ? "API Key Active" + : "API Key Required"} + + + {!isMdbListEnabled + ? "MDBList functionality is currently disabled." + : isKeySet + ? "Ratings from MDBList are enabled." + : "Add your key below to enable ratings."} + + + + + + + + Enable MDBList + + Turn on/off all MDBList functionality + + + + + + + + API Key + + { + setApiKey(text); + if (testResult) setTestResult(null); + }} + placeholder="Paste your MDBList API key" + placeholderTextColor={!isMdbListEnabled ? colors.darkGray : colors.mediumGray} + autoCapitalize="none" + autoCorrect={false} + spellCheck={false} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + editable={isMdbListEnabled} + /> + + + + + + {testResult && ( + + + + {testResult.message} + + + )} + + + + + Save + + + {isKeySet && ( + + + + Clear Key + + + )} + + + + + Rating Providers + + Choose which ratings to display in the app + + {Object.entries(RATING_PROVIDERS).map(([id, provider]) => ( + + + + {provider.name} + + + toggleProvider(id)} + trackColor={{ false: colors.elevation1, true: colors.primary + '50' }} + thumbColor={enabledProviders[id] ? colors.primary : colors.mediumGray} + disabled={!isMdbListEnabled} + /> + + ))} + + + + + + + How to get an API key + + + + + + 1. + + + Log in on the MDBList website. + + + + + 2. + + + Go to Settings {'>'} API section. + + + + + 3. + + + Generate a new key and copy it. + + + + + + + Go to MDBList + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + backText: { + fontSize: 17, + fontWeight: '400', + color: colors.primary, + marginLeft: 0, + }, + headerTitle: { + fontSize: 34, + fontWeight: '700', + color: colors.white, + paddingHorizontal: 16, + paddingBottom: 16, + paddingTop: 8, + }, + content: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 12, + paddingTop: 10, + paddingBottom: 20, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.darkBackground, + }, + loadingText: { + marginTop: 12, + fontSize: 15, + color: colors.mediumGray, + }, + card: { + backgroundColor: colors.elevation2, + borderRadius: 10, + padding: 12, + marginBottom: 16, + }, + statusCard: { + backgroundColor: colors.elevation1, + borderRadius: 10, + paddingVertical: 12, + paddingHorizontal: 16, + marginBottom: 16, + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: colors.border, + }, + infoCard: { + backgroundColor: colors.elevation1, + borderRadius: 10, + padding: 12, + }, + statusIcon: { + marginRight: 12, + }, + statusTextContainer: { + flex: 1, + }, + statusTitle: { + fontSize: 16, + fontWeight: '600', + color: colors.white, + marginBottom: 2, + }, + statusDescription: { + fontSize: 13, + color: colors.mediumGray, + lineHeight: 18, + }, + sectionTitle: { + fontSize: 15, + fontWeight: '600', + color: colors.lightGray, + marginBottom: 10, + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.elevation2, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + }, + input: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 10, + color: colors.white, + fontSize: 15, + }, + inputFocused: { + borderColor: colors.primary, + }, + pasteButton: { + padding: 8, + marginRight: 2, + }, + testResultContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 10, + borderRadius: 6, + marginTop: 10, + borderWidth: 1, + }, + testResultSuccess: { + backgroundColor: colors.success + '15', + borderColor: colors.success + '40', + }, + testResultError: { + backgroundColor: colors.error + '15', + borderColor: colors.error + '40', + }, + testResultText: { + marginLeft: 8, + fontSize: 13, + flex: 1, + }, + buttonContainer: { + marginTop: 12, + gap: 10, + }, + buttonIcon: { + marginRight: 6, + }, + saveButton: { + backgroundColor: colors.primary, + borderRadius: 8, + paddingVertical: 12, + paddingHorizontal: 12, + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + }, + saveButtonDisabled: { + backgroundColor: colors.elevation2, + opacity: 0.8, + }, + saveButtonText: { + color: colors.white, + fontSize: 15, + fontWeight: '600', + }, + clearButton: { + backgroundColor: 'transparent', + borderRadius: 8, + borderWidth: 1, + borderColor: colors.error + '40', + paddingVertical: 12, + paddingHorizontal: 12, + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + }, + clearButtonDisabled: { + borderColor: colors.border, + }, + clearButtonText: { + color: colors.error, + fontSize: 15, + fontWeight: '600', + }, + clearButtonTextDisabled: { + color: colors.darkGray, + }, + infoHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + infoHeaderText: { + fontSize: 15, + fontWeight: '600', + color: colors.white, + marginLeft: 8, + }, + infoSteps: { + marginBottom: 12, + gap: 6, + }, + infoStep: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + infoStepNumber: { + fontSize: 13, + color: colors.mediumGray, + width: 20, + }, + infoStepText: { + color: colors.mediumGray, + fontSize: 13, + flex: 1, + lineHeight: 18, + }, + boldText: { + fontWeight: '600', + color: colors.lightGray, + }, + websiteButton: { + backgroundColor: colors.primary + '20', + borderRadius: 8, + paddingVertical: 12, + paddingHorizontal: 12, + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + marginTop: 12, + }, + websiteButtonText: { + color: colors.primary, + fontSize: 15, + fontWeight: '600', + }, + websiteButtonDisabled: { + backgroundColor: colors.elevation1, + }, + websiteButtonTextDisabled: { + color: colors.darkGray, + }, + sectionDescription: { + fontSize: 13, + color: colors.mediumGray, + marginBottom: 12, + }, + providerItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + providerInfo: { + flex: 1, + }, + providerName: { + fontSize: 15, + color: colors.white, + fontWeight: '500', + }, + masterToggleContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 4, + }, + masterToggleInfo: { + flex: 1, + }, + masterToggleTitle: { + fontSize: 15, + color: colors.white, + fontWeight: '600', + }, + masterToggleDescription: { + fontSize: 13, + color: colors.mediumGray, + marginTop: 2, + }, + disabledCard: { + opacity: 0.7, + }, + disabledInput: { + borderColor: colors.border, + backgroundColor: colors.elevation1, + }, + disabledText: { + color: colors.darkGray, + }, + disabledBoldText: { + color: colors.darkGray, + }, + darkGray: { + color: colors.darkGray || '#555555', + }, +}); + +export default MDBListSettingsScreen; \ No newline at end of file diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index bf2f2ecf..76d47a21 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useCallback, useEffect } from 'react'; +import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { View, Text, @@ -20,10 +20,11 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Image } from 'expo-image'; import { colors } from '../styles/colors'; import { useMetadata } from '../hooks/useMetadata'; -import { CastSection } from '../components/metadata/CastSection'; -import { SeriesContent } from '../components/metadata/SeriesContent'; -import { MovieContent } from '../components/metadata/MovieContent'; -import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'; +import { CastSection as OriginalCastSection } from '../components/metadata/CastSection'; +import { SeriesContent as OriginalSeriesContent } from '../components/metadata/SeriesContent'; +import { MovieContent as OriginalMovieContent } from '../components/metadata/MovieContent'; +import { MoreLikeThisSection as OriginalMoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'; +import { RatingsSection as OriginalRatingsSection } from '../components/metadata/RatingsSection'; import { StreamingContent } from '../services/catalogService'; import { GroupedStreams } from '../types/streams'; import { TMDBEpisode } from '../services/tmdbService'; @@ -40,6 +41,7 @@ import Animated, { withSpring, FadeIn, runOnJS, + Layout, } from 'react-native-reanimated'; import { RouteProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -47,9 +49,17 @@ import { RootStackParamList } from '../navigation/AppNavigator'; import { TMDBService } from '../services/tmdbService'; import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; +import { useGenres } from '../contexts/GenreContext'; const { width, height } = Dimensions.get('window'); +// Memoize child components +const CastSection = React.memo(OriginalCastSection); +const SeriesContent = React.memo(OriginalSeriesContent); +const MovieContent = React.memo(OriginalMovieContent); +const MoreLikeThisSection = React.memo(OriginalMoreLikeThisSection); +const RatingsSection = React.memo(OriginalRatingsSection); + // Animation configs const springConfig = { damping: 20, @@ -60,6 +70,116 @@ const springConfig = { // Add debug log for storageService logger.log('[MetadataScreen] StorageService instance:', storageService); +// Memoized ActionButtons Component +const ActionButtons = React.memo(({ + handleShowStreams, + toggleLibrary, + inLibrary, + type, + id, + navigation, + playButtonText +}: { + handleShowStreams: () => void; + toggleLibrary: () => void; + inLibrary: boolean; + type: 'movie' | 'series'; + id: string; + navigation: NavigationProp; + playButtonText: string; +}) => ( + + + + + {playButtonText} + + + + + + + {inLibrary ? 'Saved' : 'Save'} + + + + {type === 'series' && ( + { + const tmdb = TMDBService.getInstance(); + const tmdbId = await tmdb.extractTMDBIdFromStremioId(id); + if (tmdbId) { + navigation.navigate('ShowRatings', { showId: tmdbId }); + } else { + logger.error('Could not find TMDB ID for show'); + } + }} + > + + + )} + +)); + +// Memoized WatchProgress Component +const WatchProgressDisplay = React.memo(({ + watchProgress, + type, + getEpisodeDetails, + animatedStyle +}: { + watchProgress: { currentTime: number; duration: number; lastUpdated: number; episodeId?: string } | null; + type: 'movie' | 'series'; + getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; + animatedStyle: any; +}) => { + if (!watchProgress || watchProgress.duration === 0) { + return null; + } + + const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; + const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString(); + let episodeInfo = ''; + + if (type === 'series' && watchProgress.episodeId) { + const details = getEpisodeDetails(watchProgress.episodeId); + if (details) { + episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`; + } + } + + return ( + + + + + + {progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} • Last watched on {formattedTime} + + + ); +}); + const MetadataScreen = () => { const route = useRoute, string>>(); const navigation = useNavigation>(); @@ -84,6 +204,9 @@ const MetadataScreen = () => { setMetadata, } = useMetadata({ id, type }); + // Get genres from context + const { genreMap, loadingGenres } = useGenres(); + const contentRef = useRef(null); const [lastScrollTop, setLastScrollTop] = useState(0); const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false); @@ -106,17 +229,40 @@ const MetadataScreen = () => { const watchProgressOpacity = useSharedValue(0); const watchProgressScaleY = useSharedValue(0); - // Add new animated value for logo scale - const logoScale = useSharedValue(0); - - // Add new animated value for creator fade-in - const creatorOpacity = useSharedValue(0); + // Add animated value for logo + const logoOpacity = useSharedValue(0); // Debug log for route params // logger.log('[MetadataScreen] Component mounted with route params:', { id, type, episodeId }); + // Fetch logo immediately for TMDB content + useEffect(() => { + if (metadata && id.startsWith('tmdb:')) { + const fetchLogo = async () => { + try { + const tmdbId = id.split(':')[1]; + const tmdbType = type === 'series' ? 'tv' : 'movie'; + const logoUrl = await TMDBService.getInstance().getContentLogo(tmdbType, tmdbId); + + if (logoUrl) { + // Update metadata with logo + setMetadata(prevMetadata => ({ + ...prevMetadata!, + logo: logoUrl + })); + logger.log(`Successfully fetched logo for ${type} ${tmdbId} from TMDB on MetadataScreen`); + } + } catch (error) { + logger.error('Failed to fetch logo in MetadataScreen:', error); + } + }; + + fetchLogo(); + } + }, [id, type, metadata, setMetadata]); + // Function to get episode details from episodeId - const getEpisodeDetails = useCallback((episodeId: string) => { + const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => { // Try to parse from format "seriesId:season:episode" const parts = episodeId.split(':'); if (parts.length === 3) { @@ -274,7 +420,7 @@ const MetadataScreen = () => { logger.error('[MetadataScreen] Error loading watch progress:', error); setWatchProgress(null); } - }, [id, type, episodeId, episodes]); + }, [id, type, episodeId, episodes, getEpisodeDetails]); // Initial load useEffect(() => { @@ -328,7 +474,7 @@ const MetadataScreen = () => { damping: 18 }); } - }, [watchProgress]); + }, [watchProgress, watchProgressOpacity, watchProgressScaleY]); // Add animated style for watch progress const watchProgressAnimatedStyle = useAnimatedStyle(() => { @@ -351,123 +497,33 @@ const MetadataScreen = () => { // Add animated style for logo const logoAnimatedStyle = useAnimatedStyle(() => { return { - transform: [{ scale: logoScale.value }], + opacity: logoOpacity.value, + transform: [{ scale: interpolate( + logoOpacity.value, + [0, 1], + [0.95, 1], + Extrapolate.CLAMP + ) }], }; }); - // Effect to animate logo scale when logo URI is available + // Effect to animate logo when it's available useEffect(() => { if (metadata?.logo) { - logoScale.value = withSpring(1, { - damping: 18, - stiffness: 120, - mass: 0.5 + logoOpacity.value = withTiming(1, { + duration: 500, + easing: Easing.out(Easing.ease) }); } else { - // Optional: Reset scale if logo disappears? - // logoScale.value = withTiming(0, { duration: 100 }); + logoOpacity.value = withTiming(0, { + duration: 200, + easing: Easing.in(Easing.ease) + }); } - }, [metadata?.logo]); + }, [metadata?.logo, logoOpacity]); - // Add animated style for creator fade-in - const creatorFadeInStyle = useAnimatedStyle(() => { - return { - opacity: creatorOpacity.value, - }; - }); - - // Effect to fade in creator section when data is available - useEffect(() => { - const hasCreators = metadata?.directors?.length || metadata?.creators?.length; - creatorOpacity.value = withTiming(hasCreators ? 1 : 0, { - duration: 300, // Adjust duration as needed - easing: Easing.out(Easing.quad), // Use an easing function - }); - }, [metadata?.directors, metadata?.creators]); - - // Update the watch progress render function - const renderWatchProgress = () => { - if (!watchProgress || watchProgress.duration === 0) { - return null; - } - - const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; - const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString(); - let episodeInfo = ''; - - if (type === 'series' && watchProgress.episodeId) { - const details = getEpisodeDetails(watchProgress.episodeId); - if (details) { - episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`; - } - } - - return ( - - - - - - {progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} • Last watched on {formattedTime} - - - ); - }; - - // Update the action buttons section - const ActionButtons = () => ( - - - 0 ? "play-circle-outline" : "play-arrow"} - size={24} - color="#000" - /> - - {getPlayButtonText()} - - - - - - - {inLibrary ? 'Saved' : 'Save'} - - - - {type === 'series' && ( - { - const tmdb = TMDBService.getInstance(); - const tmdbId = await tmdb.extractTMDBIdFromStremioId(id); - if (tmdbId) { - navigation.navigate('ShowRatings', { showId: tmdbId }); - } else { - logger.error('Could not find TMDB ID for show'); - } - }} - > - - - )} - - ); + // Update the watch progress render function - Now uses WatchProgressDisplay component + // const renderWatchProgress = () => { ... }; // Removed old inline function // Handler functions const handleShowStreams = useCallback(() => { @@ -500,18 +556,19 @@ const MetadataScreen = () => { navigation.navigate('Streams', { id, type, episodeId }); }, [navigation, id, type, episodes, episodeId, watchProgress]); - const handleSelectCastMember = (castMember: any) => { - logger.log('Cast member selected:', castMember); - }; + const handleSelectCastMember = useCallback((castMember: any) => { + // Potentially navigate to a cast member screen or show details + logger.log('Cast member selected:', castMember); + }, []); // Empty dependency array as it doesn't depend on component state/props currently - const handleEpisodeSelect = (episode: Episode) => { + const handleEpisodeSelect = useCallback((episode: Episode) => { const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`; navigation.navigate('Streams', { id, type, episodeId }); - }; + }, [navigation, id, type]); // Added dependencies // Animated styles const containerAnimatedStyle = useAnimatedStyle(() => ({ @@ -613,6 +670,31 @@ const MetadataScreen = () => { navigation.goBack(); }, [navigation]); + // Function to render genres (updated to handle string array and use useMemo) + const renderGenres = useMemo(() => { + if (!metadata?.genres || !Array.isArray(metadata.genres) || metadata.genres.length === 0) { + return null; + } + + // Since metadata.genres is string[], we display them directly + const genresToDisplay: string[] = metadata.genres as string[]; + + return ( + + {genresToDisplay.slice(0, 4).map((genreName, index, array) => ( + // Use React.Fragment to avoid extra View wrappers + + {genreName} + {/* Add dot separator */} + {index < array.length - 1 && ( + • + )} + + ))} + + ); + }, [metadata?.genres]); // Dependency on metadata.genres + if (loading) { return ( { locations={[0, 0.4, 0.65, 0.8, 0.9, 1]} style={styles.heroGradient} > - + {/* Title */} - {metadata.logo ? ( - - + + + {metadata.logo ? ( + + ) : ( + {metadata.name} + )} - ) : ( - {metadata.name} - )} + {/* Watch Progress */} - {renderWatchProgress()} + {/* Genre Tags */} - {metadata.genres && metadata.genres.length > 0 && ( - - {metadata.genres.slice(0, 3).map((genre, index, array) => ( - - {genre} - {index < array.length - 1 && ( - • - )} - - ))} - - )} + {renderGenres} {/* Action Buttons */} - - + + @@ -789,12 +876,18 @@ const MetadataScreen = () => { )} + {/* Add RatingsSection right under the main metadata */} + {id && ( + + )} + {/* Creator/Director Info */} {metadata.directors && metadata.directors.length > 0 && ( @@ -812,11 +905,29 @@ const MetadataScreen = () => { {/* Description */} {metadata.description && ( - - - {`${metadata.description}`} + + setIsFullDescriptionOpen(!isFullDescriptionOpen)} + activeOpacity={0.7} + > + + {metadata.description} - + + + {isFullDescriptionOpen ? 'Show Less' : 'Show More'} + + + + + )} {/* Cast Section */} @@ -940,22 +1051,33 @@ const styles = StyleSheet.create({ genreContainer: { flexDirection: 'row', flexWrap: 'wrap', - alignItems: 'center', justifyContent: 'center', - marginBottom: 12, - width: '100%', + alignItems: 'center', + marginTop: 8, + marginBottom: 16, + gap: 4, }, genreText: { - color: colors.highEmphasis, - fontSize: 14, + color: colors.text, + fontSize: 12, fontWeight: '500', - opacity: 0.8, }, genreDot: { - color: colors.highEmphasis, - fontSize: 14, - marginHorizontal: 8, + color: colors.text, + fontSize: 12, + fontWeight: '500', opacity: 0.6, + marginHorizontal: 4, + }, + logoContainer: { + alignItems: 'center', + justifyContent: 'center', + width: '100%', + }, + titleLogoContainer: { + alignItems: 'center', + justifyContent: 'center', + width: '100%', }, titleLogo: { width: width * 0.65, @@ -963,7 +1085,7 @@ const styles = StyleSheet.create({ marginBottom: 0, alignSelf: 'center', }, - titleText: { + heroTitle: { color: colors.highEmphasis, fontSize: 28, fontWeight: '900', @@ -1015,18 +1137,13 @@ const styles = StyleSheet.create({ showMoreButton: { flexDirection: 'row', alignItems: 'center', - marginTop: 10, - backgroundColor: colors.elevation1, - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 16, - alignSelf: 'flex-start', + marginTop: 8, + paddingVertical: 4, }, showMoreText: { - color: colors.highEmphasis, + color: colors.textMuted, fontSize: 14, marginRight: 4, - fontWeight: '500', }, actionButtons: { flexDirection: 'row', @@ -1084,37 +1201,6 @@ const styles = StyleSheet.create({ fontWeight: '600', fontSize: 16, }, - fullDescriptionContainer: { - flex: 1, - backgroundColor: colors.darkBackground, - }, - fullDescriptionHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 16, - paddingHorizontal: 24, - borderBottomWidth: 1, - borderBottomColor: colors.elevation1, - position: 'relative', - }, - fullDescriptionTitle: { - fontSize: 18, - fontWeight: '600', - color: colors.text, - }, - fullDescriptionCloseButton: { - position: 'absolute', - left: 16, - padding: 8, - }, - fullDescriptionContent: { - flex: 1, - padding: 24, - }, - fullDescriptionText: { - color: colors.text, - }, creatorContainer: { marginBottom: 2, paddingHorizontal: 16, diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 9034e33e..2d5e7266 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import { View, Text, @@ -14,6 +14,7 @@ import { Dimensions, Pressable } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; @@ -21,14 +22,30 @@ import { colors } from '../styles/colors'; import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings'; import { RootStackParamList } from '../navigation/AppNavigator'; import { stremioService } from '../services/stremioService'; +import { useCatalogContext } from '../contexts/CatalogContext'; const { width } = Dimensions.get('window'); const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; +// Card component for iOS Fluent design style +interface SettingsCardProps { + children: React.ReactNode; + isDarkMode: boolean; +} + +const SettingsCard: React.FC = ({ children, isDarkMode }) => ( + + {children} + +); + interface SettingItemProps { title: string; - description: string; + description?: string; icon: string; renderControl: () => React.ReactNode; isLast?: boolean; @@ -46,48 +63,110 @@ const SettingItem: React.FC = ({ isDarkMode }) => { return ( - - - - - - + + + + + {title} - - {description} - + {description && ( + + {description} + + )} - - {renderControl()} - - - + + + {renderControl()} + + ); }; +const SectionHeader: React.FC<{ title: string; isDarkMode: boolean }> = ({ title, isDarkMode }) => ( + + + {title} + + +); + const SettingsScreen: React.FC = () => { const { settings, updateSetting } = useSettings(); const systemColorScheme = useColorScheme(); const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode; const navigation = useNavigation>(); + const { lastUpdate } = useCatalogContext(); + + // States for dynamic content + const [addonCount, setAddonCount] = useState(0); + const [catalogCount, setCatalogCount] = useState(0); + const [mdblistKeySet, setMdblistKeySet] = useState(false); + + const loadData = useCallback(async () => { + try { + // Load addon count and get their catalogs + const addons = await stremioService.getInstalledAddonsAsync(); + setAddonCount(addons.length); + + // Count total available catalogs + let totalCatalogs = 0; + addons.forEach(addon => { + if (addon.catalogs && addon.catalogs.length > 0) { + totalCatalogs += addon.catalogs.length; + } + }); + + // Load saved catalog settings + const catalogSettingsJson = await AsyncStorage.getItem('catalog_settings'); + if (catalogSettingsJson) { + const catalogSettings = JSON.parse(catalogSettingsJson); + // Filter out _lastUpdate key and count only explicitly disabled catalogs + const disabledCount = Object.entries(catalogSettings) + .filter(([key, value]) => key !== '_lastUpdate' && value === false) + .length; + // Since catalogs are enabled by default, subtract disabled ones from total + setCatalogCount(totalCatalogs - disabledCount); + } else { + // If no settings saved, all catalogs are enabled by default + setCatalogCount(totalCatalogs); + } + + // Check MDBList API key status + const mdblistKey = await AsyncStorage.getItem('mdblist_api_key'); + setMdblistKeySet(!!mdblistKey); + } catch (error) { + console.error('Error loading settings data:', error); + } + }, []); + + // Load data initially and when catalogs are updated + useEffect(() => { + loadData(); + }, [loadData, lastUpdate]); + + // Add focus listener to reload data when screen comes into focus + useEffect(() => { + const unsubscribe = navigation.addListener('focus', () => { + loadData(); + }); + + return unsubscribe; + }, [navigation, loadData]); const handleResetSettings = useCallback(() => { Alert.alert( @@ -108,205 +187,143 @@ const SettingsScreen: React.FC = () => { ); }, [updateSetting]); - const renderSectionHeader = (title: string) => ( - - - {title} - - - ); - const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => ( + ); + + const ChevronRight = () => ( + ); return ( - - - - Settings - - + + + Settings + - {renderSectionHeader('Playback')} - ( - updateSetting('useExternalPlayer', value)} - /> - )} - /> + + + Alert.alert('Trakt', 'Trakt integration coming soon')} + /> + + - {renderSectionHeader('Content')} - ( - - Configure - - )} - onPress={() => navigation.navigate('CatalogSettings')} - /> - ( - - )} - onPress={() => navigation.navigate('Calendar')} - /> - ( - - )} - onPress={() => navigation.navigate('NotificationSettings')} - /> + + + navigation.navigate('Addons')} + /> + navigation.navigate('CatalogSettings')} + /> + navigation.navigate('HomeScreenSettings')} + /> + + navigation.navigate('MDBListSettings')} + /> + navigation.navigate('TMDBSettings')} + /> + + + - {renderSectionHeader('Advanced')} - ( - - )} - onPress={() => navigation.navigate('Addons')} - /> - ( - - Check - - )} - onPress={() => { - // Check if the addon is installed - const installedAddons = stremioService.getInstalledAddons(); - const tmdbAddon = installedAddons.find(addon => addon.id === 'org.tmdbembedapi'); - - if (tmdbAddon) { - // Addon is installed, check its configuration - Alert.alert( - 'TMDB Embed Streams Addon', - `Addon is installed:\n\nName: ${tmdbAddon.name}\nID: ${tmdbAddon.id}\nURL: ${tmdbAddon.url}\n\nResources: ${JSON.stringify(tmdbAddon.resources)}\n\nTypes: ${JSON.stringify(tmdbAddon.types)}`, - [ - { - text: 'Reinstall', - onPress: async () => { - try { - // Remove and reinstall the addon - stremioService.removeAddon('org.tmdbembedapi'); - await stremioService.installAddon('https://http-addon-production.up.railway.app/manifest.json'); - Alert.alert('Success', 'Addon was reinstalled successfully'); - } catch (error) { - Alert.alert('Error', `Failed to reinstall addon: ${error}`); - } - } - }, - { text: 'Close', style: 'cancel' } - ] - ); - } else { - // Addon is not installed, offer to install it - Alert.alert( - 'TMDB Embed Streams Addon', - 'Addon is not installed. Would you like to install it now?', - [ - { - text: 'Install', - onPress: async () => { - try { - await stremioService.installAddon('https://http-addon-production.up.railway.app/manifest.json'); - Alert.alert('Success', 'Addon was installed successfully'); - } catch (error) { - Alert.alert('Error', `Failed to install addon: ${error}`); - } - } - }, - { text: 'Cancel', style: 'cancel' } - ] - ); - } - }} - /> - ( - - Reset - - )} - isLast={true} - onPress={handleResetSettings} - /> - - {renderSectionHeader('About')} - null} - isLast={true} - /> + + + + + ); @@ -319,21 +336,12 @@ const styles = StyleSheet.create({ header: { paddingHorizontal: 16, paddingVertical: 12, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 4, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.1)', - backgroundColor: colors.darkBackground, - }, - headerContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 8, }, headerTitle: { - fontSize: 32, - fontWeight: '800', + fontSize: 34, + fontWeight: '700', letterSpacing: 0.5, - color: colors.white, }, scrollView: { flex: 1, @@ -342,84 +350,69 @@ const styles = StyleSheet.create({ paddingBottom: 32, }, sectionHeader: { - padding: 16, + paddingHorizontal: 16, + paddingTop: 20, paddingBottom: 8, }, sectionHeaderText: { - fontSize: 13, + fontSize: 12, fontWeight: '600', - textTransform: 'uppercase', - letterSpacing: 1, + letterSpacing: 0.8, + }, + card: { + marginHorizontal: 16, + borderRadius: 12, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, }, settingItem: { - marginHorizontal: 16, - marginVertical: 4, - borderRadius: 16, - overflow: Platform.OS === 'android' ? 'hidden' : 'visible', - }, - settingItemBorder: { - marginBottom: 8, - }, - settingTouchable: { flexDirection: 'row', alignItems: 'center', - paddingVertical: 16, + paddingVertical: 8, paddingHorizontal: 16, + borderBottomWidth: 0.5, + minHeight: 44, + }, + settingItemBorder: { + // Border styling handled directly in the component with borderBottomWidth }, settingIconContainer: { - marginRight: 16, - width: 40, - height: 40, - borderRadius: 20, + marginRight: 12, + width: 24, + height: 24, alignItems: 'center', justifyContent: 'center', }, settingContent: { flex: 1, - marginRight: 16, + marginRight: 8, + }, + settingTitleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: 8, }, settingTitle: { - fontSize: 16, - fontWeight: '600', - marginBottom: 4, - letterSpacing: 0.15, + fontSize: 15, + fontWeight: '400', + flex: 1, }, settingDescription: { fontSize: 14, - lineHeight: 20, - letterSpacing: 0.25, + opacity: 0.7, + textAlign: 'right', + flexShrink: 1, + maxWidth: '60%', }, settingControl: { justifyContent: 'center', alignItems: 'center', - minWidth: 50, - }, - selectButton: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 8, - }, - selectButtonText: { - fontWeight: '600', - marginRight: 4, - fontSize: 14, - letterSpacing: 0.25, - }, - actionButton: { - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 8, - }, - actionButtonText: { - color: colors.white, - fontWeight: '600', - fontSize: 14, - letterSpacing: 0.5, - }, - chevronIcon: { - opacity: 0.8, + paddingLeft: 8, }, }); diff --git a/src/screens/TMDBSettingsScreen.tsx b/src/screens/TMDBSettingsScreen.tsx new file mode 100644 index 00000000..c07c562a --- /dev/null +++ b/src/screens/TMDBSettingsScreen.tsx @@ -0,0 +1,621 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + TextInput, + SafeAreaView, + StatusBar, + Platform, + Alert, + ActivityIndicator, + Linking, + ScrollView, + Keyboard, + Clipboard, + Switch, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { colors } from '../styles/colors'; +import { logger } from '../utils/logger'; + +const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key'; +const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key'; +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +const TMDBSettingsScreen = () => { + const navigation = useNavigation(); + const [apiKey, setApiKey] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isKeySet, setIsKeySet] = useState(false); + const [useCustomKey, setUseCustomKey] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + const [isInputFocused, setIsInputFocused] = useState(false); + const apiKeyInputRef = useRef(null); + + useEffect(() => { + logger.log('[TMDBSettingsScreen] Component mounted'); + loadSettings(); + return () => { + logger.log('[TMDBSettingsScreen] Component unmounted'); + }; + }, []); + + const loadSettings = async () => { + logger.log('[TMDBSettingsScreen] Loading settings from storage'); + try { + const [savedKey, savedUseCustomKey] = await Promise.all([ + AsyncStorage.getItem(TMDB_API_KEY_STORAGE_KEY), + AsyncStorage.getItem(USE_CUSTOM_TMDB_API_KEY) + ]); + + logger.log('[TMDBSettingsScreen] API key status:', savedKey ? 'Found' : 'Not found'); + logger.log('[TMDBSettingsScreen] Use custom API setting:', savedUseCustomKey); + + if (savedKey) { + setApiKey(savedKey); + setIsKeySet(true); + } else { + setIsKeySet(false); + } + + setUseCustomKey(savedUseCustomKey === 'true'); + } catch (error) { + logger.error('[TMDBSettingsScreen] Failed to load settings:', error); + setIsKeySet(false); + setUseCustomKey(false); + } finally { + setIsLoading(false); + logger.log('[TMDBSettingsScreen] Finished loading settings'); + } + }; + + const saveApiKey = async () => { + logger.log('[TMDBSettingsScreen] Starting API key save'); + Keyboard.dismiss(); + + try { + const trimmedKey = apiKey.trim(); + if (!trimmedKey) { + logger.warn('[TMDBSettingsScreen] Empty API key provided'); + setTestResult({ success: false, message: 'API Key cannot be empty.' }); + return; + } + + // Test the API key to make sure it works + if (await testApiKey(trimmedKey)) { + logger.log('[TMDBSettingsScreen] API key test successful, saving key'); + await AsyncStorage.setItem(TMDB_API_KEY_STORAGE_KEY, trimmedKey); + await AsyncStorage.setItem(USE_CUSTOM_TMDB_API_KEY, 'true'); + setIsKeySet(true); + setUseCustomKey(true); + setTestResult({ success: true, message: 'API key verified and saved successfully.' }); + logger.log('[TMDBSettingsScreen] API key saved successfully'); + } else { + logger.warn('[TMDBSettingsScreen] API key test failed'); + setTestResult({ success: false, message: 'Invalid API key. Please check and try again.' }); + } + } catch (error) { + logger.error('[TMDBSettingsScreen] Error saving API key:', error); + setTestResult({ + success: false, + message: 'An error occurred while saving. Please try again.' + }); + } + }; + + const testApiKey = async (key: string): Promise => { + try { + // Simple API call to test the key + const response = await fetch( + 'https://api.themoviedb.org/3/configuration', + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${key}`, + 'Content-Type': 'application/json', + } + } + ); + return response.ok; + } catch (error) { + logger.error('[TMDBSettingsScreen] API key test error:', error); + return false; + } + }; + + const clearApiKey = async () => { + logger.log('[TMDBSettingsScreen] Clear API key requested'); + Alert.alert( + 'Clear API Key', + 'Are you sure you want to remove your custom API key and revert to the default?', + [ + { + text: 'Cancel', + style: 'cancel', + onPress: () => logger.log('[TMDBSettingsScreen] Clear API key cancelled') + }, + { + text: 'Clear', + style: 'destructive', + onPress: async () => { + logger.log('[TMDBSettingsScreen] Proceeding with API key clear'); + try { + await AsyncStorage.removeItem(TMDB_API_KEY_STORAGE_KEY); + await AsyncStorage.setItem(USE_CUSTOM_TMDB_API_KEY, 'false'); + setApiKey(''); + setIsKeySet(false); + setUseCustomKey(false); + setTestResult(null); + logger.log('[TMDBSettingsScreen] API key cleared successfully'); + } catch (error) { + logger.error('[TMDBSettingsScreen] Failed to clear API key:', error); + Alert.alert('Error', 'Failed to clear API key'); + } + } + } + ] + ); + }; + + const toggleUseCustomKey = async (value: boolean) => { + logger.log('[TMDBSettingsScreen] Toggle use custom key:', value); + try { + await AsyncStorage.setItem(USE_CUSTOM_TMDB_API_KEY, value ? 'true' : 'false'); + setUseCustomKey(value); + + if (!value) { + // If switching to built-in key, show confirmation + logger.log('[TMDBSettingsScreen] Switching to built-in API key'); + setTestResult({ + success: true, + message: 'Now using the built-in TMDb API key.' + }); + } else if (apiKey && isKeySet) { + // If switching to custom key and we have a key + logger.log('[TMDBSettingsScreen] Switching to custom API key'); + setTestResult({ + success: true, + message: 'Now using your custom TMDb API key.' + }); + } else { + // If switching to custom key but don't have a key yet + logger.log('[TMDBSettingsScreen] No custom key available yet'); + setTestResult({ + success: false, + message: 'Please enter and save your custom TMDb API key.' + }); + } + } catch (error) { + logger.error('[TMDBSettingsScreen] Failed to toggle custom key setting:', error); + } + }; + + const pasteFromClipboard = async () => { + logger.log('[TMDBSettingsScreen] Attempting to paste from clipboard'); + try { + const clipboardContent = await Clipboard.getString(); + if (clipboardContent) { + logger.log('[TMDBSettingsScreen] Content pasted from clipboard'); + setApiKey(clipboardContent); + setTestResult(null); + } else { + logger.warn('[TMDBSettingsScreen] No content in clipboard'); + } + } catch (error) { + logger.error('[TMDBSettingsScreen] Error pasting from clipboard:', error); + } + }; + + const openTMDBWebsite = () => { + logger.log('[TMDBSettingsScreen] Opening TMDb website'); + Linking.openURL('https://www.themoviedb.org/settings/api').catch(error => { + logger.error('[TMDBSettingsScreen] Error opening website:', error); + }); + }; + + if (isLoading) { + return ( + + + + + Loading Settings... + + + ); + } + + return ( + + + + navigation.goBack()} + > + + Settings + + + TMDb Settings + + + + + 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. + + + + {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 (v4 auth)" + placeholderTextColor={colors.mediumGray} + 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 (v4 auth token), 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. + + + + )} + + {!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. + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 12, + fontSize: 16, + color: colors.white, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16, + paddingBottom: 8, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + }, + backText: { + color: colors.primary, + fontSize: 16, + fontWeight: '500', + }, + headerTitle: { + fontSize: 28, + fontWeight: 'bold', + color: colors.white, + marginHorizontal: 16, + marginBottom: 16, + }, + content: { + flex: 1, + }, + scrollContent: { + paddingBottom: 40, + }, + switchCard: { + backgroundColor: colors.elevation2, + borderRadius: 12, + marginHorizontal: 16, + marginBottom: 16, + padding: 16, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + }, + switchRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + switchLabel: { + fontSize: 16, + fontWeight: '500', + color: colors.white, + }, + switchDescription: { + fontSize: 14, + color: colors.mediumEmphasis, + lineHeight: 20, + }, + statusCard: { + flexDirection: 'row', + backgroundColor: colors.elevation2, + borderRadius: 12, + marginHorizontal: 16, + marginBottom: 16, + padding: 16, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + }, + statusIcon: { + marginRight: 12, + }, + statusTextContainer: { + flex: 1, + }, + statusTitle: { + fontSize: 16, + fontWeight: '500', + color: colors.white, + marginBottom: 4, + }, + statusDescription: { + fontSize: 14, + color: colors.mediumEmphasis, + }, + card: { + backgroundColor: colors.elevation2, + borderRadius: 12, + marginHorizontal: 16, + marginBottom: 16, + padding: 16, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '500', + color: colors.white, + marginBottom: 16, + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + input: { + flex: 1, + backgroundColor: colors.elevation1, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + color: colors.white, + fontSize: 15, + borderWidth: 1, + borderColor: 'transparent', + }, + inputFocused: { + borderColor: colors.primary, + }, + pasteButton: { + position: 'absolute', + right: 8, + padding: 8, + }, + buttonRow: { + flexDirection: 'row', + marginBottom: 16, + }, + button: { + backgroundColor: colors.primary, + borderRadius: 8, + paddingVertical: 12, + paddingHorizontal: 20, + alignItems: 'center', + flex: 1, + marginRight: 8, + }, + clearButton: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: colors.error, + marginRight: 0, + marginLeft: 8, + flex: 0, + }, + buttonText: { + color: colors.white, + fontWeight: '500', + fontSize: 15, + }, + clearButtonText: { + color: colors.error, + }, + resultMessage: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 8, + padding: 12, + marginBottom: 16, + }, + successMessage: { + backgroundColor: colors.success + '1A', // 10% opacity + }, + errorMessage: { + backgroundColor: colors.error + '1A', // 10% opacity + }, + resultIcon: { + marginRight: 8, + }, + resultText: { + fontSize: 14, + flex: 1, + }, + successText: { + color: colors.success, + }, + errorText: { + color: colors.error, + }, + helpLink: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 8, + }, + helpIcon: { + marginRight: 6, + }, + helpText: { + color: colors.primary, + fontSize: 14, + }, + infoCard: { + backgroundColor: colors.elevation1, + borderRadius: 12, + marginHorizontal: 16, + marginBottom: 16, + padding: 16, + flexDirection: 'row', + alignItems: 'flex-start', + }, + infoIcon: { + marginRight: 12, + marginTop: 2, + }, + infoText: { + color: colors.mediumEmphasis, + fontSize: 14, + flex: 1, + lineHeight: 20, + }, +}); + +export default TMDBSettingsScreen; \ No newline at end of file diff --git a/src/services/mdblistService.ts b/src/services/mdblistService.ts new file mode 100644 index 00000000..cef6ae87 --- /dev/null +++ b/src/services/mdblistService.ts @@ -0,0 +1,182 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { logger } from '../utils/logger'; +import { + MDBLIST_API_KEY_STORAGE_KEY, + MDBLIST_ENABLED_STORAGE_KEY, + isMDBListEnabled +} from '../screens/MDBListSettingsScreen'; + +export interface MDBListRatings { + trakt?: number; + imdb?: number; + tmdb?: number; + letterboxd?: number; + tomatoes?: number; + audience?: number; + metacritic?: number; +} + +export class MDBListService { + private static instance: MDBListService; + private apiKey: string | null = null; + private enabled: boolean = true; + + private constructor() { + logger.log('[MDBListService] Service initialized'); + } + + static getInstance(): MDBListService { + if (!MDBListService.instance) { + MDBListService.instance = new MDBListService(); + } + return MDBListService.instance; + } + + async initialize(): Promise { + try { + // First check if MDBList is enabled + const enabledSetting = await AsyncStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY); + this.enabled = enabledSetting === null || enabledSetting === 'true'; + logger.log('[MDBListService] MDBList enabled:', this.enabled); + + if (!this.enabled) { + logger.log('[MDBListService] MDBList is disabled, skipping API key loading'); + this.apiKey = null; + return; + } + + this.apiKey = await AsyncStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY); + logger.log('[MDBListService] Initialized with API key:', this.apiKey ? 'Present' : 'Not found'); + } catch (error) { + logger.error('[MDBListService] Failed to load settings:', error); + this.apiKey = null; + this.enabled = true; // Default to enabled on error + } + } + + async getRatings(imdbId: string, mediaType: 'movie' | 'show'): Promise { + logger.log(`[MDBListService] Fetching ratings for ${mediaType} with IMDB ID:`, imdbId); + + // Check if MDBList is enabled before doing anything else + if (!this.enabled) { + // Try to refresh enabled status in case it was changed + try { + const enabledSetting = await AsyncStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY); + this.enabled = enabledSetting === null || enabledSetting === 'true'; + } catch (error) { + // Ignore error and keep current state + } + + if (!this.enabled) { + logger.log('[MDBListService] MDBList is disabled, not fetching ratings'); + return null; + } + } + + if (!this.apiKey) { + logger.log('[MDBListService] No API key found, attempting to initialize'); + await this.initialize(); + if (!this.apiKey || !this.enabled) { + const reason = !this.enabled ? 'MDBList is disabled' : 'No API key found'; + logger.warn(`[MDBListService] ${reason}`); + return null; + } + } + + try { + const ratings: MDBListRatings = {}; + const ratingTypes = ['trakt', 'imdb', 'tmdb', 'letterboxd', 'tomatoes', 'audience', 'metacritic']; + logger.log(`[MDBListService] Starting to fetch ${ratingTypes.length} different rating types in parallel`); + + const formattedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + if (!/^tt\d+$/.test(formattedImdbId)) { + logger.error('[MDBListService] Invalid IMDB ID format:', formattedImdbId); + return null; + } + logger.log(`[MDBListService] Using formatted IMDB ID:`, formattedImdbId); + + // Create an array of fetch promises + const fetchPromises = ratingTypes.map(async (ratingType) => { + try { + // API Key in URL query parameter + const url = `https://api.mdblist.com/rating/${mediaType}/${ratingType}?apikey=${this.apiKey}`; + logger.log(`[MDBListService] Fetching ${ratingType} rating from:`, url); + + // Body contains only ids and provider + const body = { + ids: [formattedImdbId], + provider: 'imdb' + }; + + logger.log(`[MDBListService] Request body:`, body); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body) + }); + + logger.log(`[MDBListService] ${ratingType} response status:`, response.status); + + if (response.ok) { + const data = await response.json(); + logger.log(`[MDBListService] ${ratingType} response data:`, data); + + if (data.ratings?.[0]?.rating) { + ratings[ratingType as keyof MDBListRatings] = data.ratings[0].rating; + logger.log(`[MDBListService] Added ${ratingType} rating:`, data.ratings[0].rating); + return { type: ratingType, rating: data.ratings[0].rating }; + } else { + logger.warn(`[MDBListService] No ${ratingType} rating found in response`); + return null; + } + } else { + // Log specific error for invalid API key + if (response.status === 403) { + const errorText = await response.text(); + try { + const errorJson = JSON.parse(errorText); + if (errorJson.error === "Invalid API key") { + logger.error('[MDBListService] API Key rejected by server:', this.apiKey); + } else { + logger.warn(`[MDBListService] 403 Forbidden, but not invalid key error:`, errorJson); + } + } catch (parseError) { + logger.warn(`[MDBListService] 403 Forbidden, non-JSON response:`, errorText); + } + } else { + logger.warn(`[MDBListService] Failed to fetch ${ratingType} rating. Status:`, response.status); + const errorText = await response.text(); + logger.warn(`[MDBListService] Error response:`, errorText); + } + return null; + } + } catch (error) { + logger.error(`[MDBListService] Error fetching ${ratingType} rating:`, error); + return null; + } + }); + + // Execute all fetch promises in parallel + const results = await Promise.all(fetchPromises); + + // Process results + results.forEach(result => { + if (result) { + ratings[result.type as keyof MDBListRatings] = result.rating; + } + }); + + const ratingCount = Object.keys(ratings).length; + logger.log(`[MDBListService] Fetched ${ratingCount} ratings successfully:`, ratings); + return ratingCount > 0 ? ratings : null; + } catch (error) { + logger.error('[MDBListService] Error fetching MDBList ratings:', error); + return null; + } + } +} + +export const mdblistService = MDBListService.getInstance(); diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index 9cb071d9..bc49ce03 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -1,9 +1,12 @@ import axios from 'axios'; import { logger } from '../utils/logger'; +import AsyncStorage from '@react-native-async-storage/async-storage'; // TMDB API configuration -const API_KEY = 'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0MzljNDc4YTc3MWYzNWMwNTAyMmY5ZmVhYmNjYTAxYyIsIm5iZiI6MTcwOTkxMTEzNS4xNCwic3ViIjoiNjVlYjJjNWYzODlkYTEwMTYyZDgyOWU0Iiwic2NvcGVzIjpbImFwaV9yZWFkIl0sInZlcnNpb24iOjF9.gosBVl1wYUbePOeB9WieHn8bY9x938-GSGmlXZK_UVM'; +const DEFAULT_API_KEY = '439c478a771f35c05022f9feabcca01c'; const BASE_URL = 'https://api.themoviedb.org/3'; +const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key'; +const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key'; // Types for TMDB responses export interface TMDBEpisode { @@ -40,6 +43,7 @@ export interface TMDBShow { last_air_date: string; number_of_seasons: number; number_of_episodes: number; + genres?: { id: number; name: string }[]; seasons: { id: number; name: string; @@ -69,8 +73,13 @@ export interface TMDBTrendingResult { export class TMDBService { private static instance: TMDBService; private static ratingCache: Map = new Map(); + private apiKey: string = DEFAULT_API_KEY; + private useCustomKey: boolean = false; + private apiKeyLoaded: boolean = false; - private constructor() {} + private constructor() { + this.loadApiKey(); + } static getInstance(): TMDBService { if (!TMDBService.instance) { @@ -79,13 +88,54 @@ export class TMDBService { return TMDBService.instance; } - private getHeaders() { + private async loadApiKey() { + try { + const [savedKey, savedUseCustomKey] = await Promise.all([ + AsyncStorage.getItem(TMDB_API_KEY_STORAGE_KEY), + AsyncStorage.getItem(USE_CUSTOM_TMDB_API_KEY) + ]); + + this.useCustomKey = savedUseCustomKey === 'true'; + + if (this.useCustomKey && savedKey) { + this.apiKey = savedKey; + logger.log('Using custom TMDb API key'); + } else { + this.apiKey = DEFAULT_API_KEY; + logger.log('Using default TMDb API key'); + } + + this.apiKeyLoaded = true; + } catch (error) { + logger.error('Failed to load TMDb API key from storage, using default:', error); + this.apiKey = DEFAULT_API_KEY; + this.apiKeyLoaded = true; + } + } + + private async getHeaders() { + // Ensure API key is loaded before returning headers + if (!this.apiKeyLoaded) { + await this.loadApiKey(); + } + return { - Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }; } + private async getParams(additionalParams = {}) { + // Ensure API key is loaded before returning params + if (!this.apiKeyLoaded) { + await this.loadApiKey(); + } + + return { + api_key: this.apiKey, + ...additionalParams + }; + } + private generateRatingCacheKey(showName: string, seasonNumber: number, episodeNumber: number): string { return `${showName.toLowerCase()}_s${seasonNumber}_e${episodeNumber}`; } @@ -96,13 +146,13 @@ export class TMDBService { async searchTVShow(query: string): Promise { try { const response = await axios.get(`${BASE_URL}/search/tv`, { - headers: this.getHeaders(), - params: { + headers: await this.getHeaders(), + params: await this.getParams({ query, include_adult: false, language: 'en-US', page: 1, - }, + }), }); return response.data.results; } catch (error) { @@ -117,10 +167,10 @@ export class TMDBService { async getTVShowDetails(tmdbId: number): Promise { try { const response = await axios.get(`${BASE_URL}/tv/${tmdbId}`, { - headers: this.getHeaders(), - params: { + headers: await this.getHeaders(), + params: await this.getParams({ language: 'en-US', - }, + }), }); return response.data; } catch (error) { @@ -141,7 +191,8 @@ export class TMDBService { const response = await axios.get( `${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}/episode/${episodeNumber}/external_ids`, { - headers: this.getHeaders(), + headers: await this.getHeaders(), + params: await this.getParams(), } ); return response.data; @@ -195,10 +246,10 @@ export class TMDBService { async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string): Promise { try { const response = await axios.get(`${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}`, { - headers: this.getHeaders(), - params: { + headers: await this.getHeaders(), + params: await this.getParams({ language: 'en-US', - }, + }), }); const season = response.data; @@ -254,10 +305,10 @@ export class TMDBService { const response = await axios.get( `${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}/episode/${episodeNumber}`, { - headers: this.getHeaders(), - params: { + headers: await this.getHeaders(), + params: await this.getParams({ language: 'en-US', - }, + }), } ); return response.data; @@ -295,11 +346,11 @@ export class TMDBService { const baseImdbId = imdbId.split(':')[0]; const response = await axios.get(`${BASE_URL}/find/${baseImdbId}`, { - headers: this.getHeaders(), - params: { + headers: await this.getHeaders(), + params: await this.getParams({ external_source: 'imdb_id', language: 'en-US', - }, + }), }); // Check TV results first @@ -402,10 +453,10 @@ export class TMDBService { async getCredits(tmdbId: number, type: string) { try { const response = await axios.get(`${BASE_URL}/${type === 'series' ? 'tv' : 'movie'}/${tmdbId}/credits`, { - headers: this.getHeaders(), - params: { + headers: await this.getHeaders(), + params: await this.getParams({ language: 'en-US', - }, + }), }); return { cast: response.data.cast || [], @@ -420,10 +471,10 @@ export class TMDBService { async getPersonDetails(personId: number) { try { const response = await axios.get(`${BASE_URL}/person/${personId}`, { - headers: this.getHeaders(), - params: { + headers: await this.getHeaders(), + params: await this.getParams({ language: 'en-US', - }, + }), }); return response.data; } catch (error) { @@ -440,7 +491,8 @@ export class TMDBService { const response = await axios.get( `${BASE_URL}/tv/${tmdbId}/external_ids`, { - headers: this.getHeaders(), + headers: await this.getHeaders(), + params: await this.getParams(), } ); return response.data; @@ -451,14 +503,14 @@ export class TMDBService { } async getRecommendations(type: 'movie' | 'tv', tmdbId: string): Promise { - if (!API_KEY) { + if (!this.apiKey) { logger.error('TMDB API key not set'); return []; } try { const response = await axios.get(`${BASE_URL}/${type}/${tmdbId}/recommendations`, { - headers: this.getHeaders(), - params: { language: 'en-US' } + headers: await this.getHeaders(), + params: await this.getParams({ language: 'en-US' }) }); return response.data.results || []; } catch (error) { @@ -470,13 +522,13 @@ export class TMDBService { async searchMulti(query: string): Promise { try { const response = await axios.get(`${BASE_URL}/search/multi`, { - headers: this.getHeaders(), - params: { + headers: await this.getHeaders(), + params: await this.getParams({ query, include_adult: false, language: 'en-US', page: 1, - }, + }), }); return response.data.results; } catch (error) { @@ -485,25 +537,189 @@ export class TMDBService { } } + /** + * Get movie details by TMDB ID + */ async getMovieDetails(movieId: string): Promise { try { const response = await axios.get(`${BASE_URL}/movie/${movieId}`, { - headers: this.getHeaders(), - params: { language: 'en-US' } + headers: await this.getHeaders(), + params: await this.getParams({ + language: 'en-US', + append_to_response: 'external_ids' // Append external IDs + }), }); return response.data; } catch (error) { - logger.error('Error fetching movie details:', error); + logger.error('Failed to get movie details:', error); return null; } } + /** + * Get movie images (logos, posters, backdrops) by TMDB ID + */ + async getMovieImages(movieId: number | string): Promise { + try { + const response = await axios.get(`${BASE_URL}/movie/${movieId}/images`, { + headers: await this.getHeaders(), + params: await this.getParams({ + include_image_language: 'en,null' + }), + }); + + const images = response.data; + if (images && images.logos && images.logos.length > 0) { + // First prioritize English SVG logos + const enSvgLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.svg') && + logo.iso_639_1 === 'en' + ); + if (enSvgLogo) { + return this.getImageUrl(enSvgLogo.file_path); + } + + // Then English PNG logos + const enPngLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.png') && + logo.iso_639_1 === 'en' + ); + if (enPngLogo) { + return this.getImageUrl(enPngLogo.file_path); + } + + // Then any English logo + const enLogo = images.logos.find((logo: any) => + logo.iso_639_1 === 'en' + ); + if (enLogo) { + return this.getImageUrl(enLogo.file_path); + } + + // Fallback to any SVG logo + const svgLogo = images.logos.find((logo: any) => + logo.file_path && logo.file_path.endsWith('.svg') + ); + if (svgLogo) { + return this.getImageUrl(svgLogo.file_path); + } + + // Then any PNG logo + const pngLogo = images.logos.find((logo: any) => + logo.file_path && logo.file_path.endsWith('.png') + ); + if (pngLogo) { + return this.getImageUrl(pngLogo.file_path); + } + + // Last resort: any logo + return this.getImageUrl(images.logos[0].file_path); + } + + return null; // No logos found + } catch (error) { + // Log error but don't throw, just return null if fetching images fails + logger.error(`Failed to get movie images for ID ${movieId}:`, error); + return null; + } + } + + /** + * Get TV show images (logos, posters, backdrops) by TMDB ID + */ + async getTvShowImages(showId: number | string): Promise { + try { + const response = await axios.get(`${BASE_URL}/tv/${showId}/images`, { + headers: await this.getHeaders(), + params: await this.getParams({ + include_image_language: 'en,null' + }), + }); + + const images = response.data; + if (images && images.logos && images.logos.length > 0) { + // First prioritize English SVG logos + const enSvgLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.svg') && + logo.iso_639_1 === 'en' + ); + if (enSvgLogo) { + return this.getImageUrl(enSvgLogo.file_path); + } + + // Then English PNG logos + const enPngLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.png') && + logo.iso_639_1 === 'en' + ); + if (enPngLogo) { + return this.getImageUrl(enPngLogo.file_path); + } + + // Then any English logo + const enLogo = images.logos.find((logo: any) => + logo.iso_639_1 === 'en' + ); + if (enLogo) { + return this.getImageUrl(enLogo.file_path); + } + + // Fallback to any SVG logo + const svgLogo = images.logos.find((logo: any) => + logo.file_path && logo.file_path.endsWith('.svg') + ); + if (svgLogo) { + return this.getImageUrl(svgLogo.file_path); + } + + // Then any PNG logo + const pngLogo = images.logos.find((logo: any) => + logo.file_path && logo.file_path.endsWith('.png') + ); + if (pngLogo) { + return this.getImageUrl(pngLogo.file_path); + } + + // Last resort: any logo + return this.getImageUrl(images.logos[0].file_path); + } + + return null; // No logos found + } catch (error) { + // Log error but don't throw, just return null if fetching images fails + logger.error(`Failed to get TV show images for ID ${showId}:`, error); + return null; + } + } + + /** + * Get content logo based on type (movie or TV show) + */ + async getContentLogo(type: 'movie' | 'tv', id: number | string): Promise { + try { + return type === 'movie' + ? await this.getMovieImages(id) + : await this.getTvShowImages(id); + } catch (error) { + logger.error(`Failed to get content logo for ${type} ID ${id}:`, error); + return null; + } + } + + /** + * Get content certification rating + */ async getCertification(type: string, id: number): Promise { try { // Different endpoints for movies and TV shows const endpoint = type === 'movie' ? 'movie' : 'tv'; const response = await axios.get(`${BASE_URL}/${endpoint}/${id}/release_dates`, { - headers: this.getHeaders() + headers: await this.getHeaders(), + params: await this.getParams() }); if (response.data && response.data.results) { @@ -537,10 +753,10 @@ export class TMDBService { async getTrending(type: 'movie' | 'tv', timeWindow: 'day' | 'week'): Promise { try { const response = await axios.get(`${BASE_URL}/trending/${type}/${timeWindow}`, { - headers: this.getHeaders(), - params: { + headers: await this.getHeaders(), + params: await this.getParams({ language: 'en-US', - }, + }), }); // Get external IDs for each trending item @@ -551,7 +767,8 @@ export class TMDBService { const externalIdsResponse = await axios.get( `${BASE_URL}/${type}/${item.id}/external_ids`, { - headers: this.getHeaders(), + headers: await this.getHeaders(), + params: await this.getParams(), } ); return { @@ -571,6 +788,42 @@ export class TMDBService { return []; } } + + /** + * Get the list of official movie genres from TMDB + */ + async getMovieGenres(): Promise<{ id: number; name: string }[]> { + try { + const response = await axios.get(`${BASE_URL}/genre/movie/list`, { + headers: await this.getHeaders(), + params: await this.getParams({ + language: 'en-US', + }), + }); + return response.data.genres || []; + } catch (error) { + logger.error('Failed to fetch movie genres:', error); + return []; + } + } + + /** + * Get the list of official TV genres from TMDB + */ + async getTvGenres(): Promise<{ id: number; name: string }[]> { + try { + const response = await axios.get(`${BASE_URL}/genre/tv/list`, { + headers: await this.getHeaders(), + params: await this.getParams({ + language: 'en-US', + }), + }); + return response.data.genres || []; + } catch (error) { + logger.error('Failed to fetch TV genres:', error); + return []; + } + } } export const tmdbService = TMDBService.getInstance(); diff --git a/src/temp_settings_screen.tsx b/src/temp_settings_screen.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/types/images.d.ts b/src/types/images.d.ts new file mode 100644 index 00000000..258dadc4 --- /dev/null +++ b/src/types/images.d.ts @@ -0,0 +1,10 @@ +declare module '*.png' { + const content: any; + export default content; +} + +declare module '*.svg' { + import { SvgProps } from 'react-native-svg'; + const content: React.FC; + export default content; +} \ No newline at end of file