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 00000000..4ffb7f56 Binary files /dev/null and b/assets/WhatsApp Image 2025-04-17 at 03.45.56_f37ab22f.jpg differ diff --git a/assets/rating-icons/Metacritic.png b/assets/rating-icons/Metacritic.png new file mode 100644 index 00000000..4a39e085 Binary files /dev/null and b/assets/rating-icons/Metacritic.png differ 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 00000000..7f68f8d5 Binary files /dev/null and b/assets/rating-icons/audienscore.png differ diff --git a/assets/rating-icons/imdb.png b/assets/rating-icons/imdb.png new file mode 100644 index 00000000..716db221 Binary files /dev/null and b/assets/rating-icons/imdb.png differ diff --git a/assets/rating-icons/letterboxd.svg b/assets/rating-icons/letterboxd.svg new file mode 100644 index 00000000..841adf65 --- /dev/null +++ b/assets/rating-icons/letterboxd.svg @@ -0,0 +1,34 @@ + + + + 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