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 @@
+
+
+
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 @@
+
+
\ 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 @@
+
\ 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