diff --git a/App.tsx b/App.tsx index c4be52e3..d6d16800 100644 --- a/App.tsx +++ b/App.tsx @@ -14,6 +14,7 @@ import { NavigationContainer } from '@react-navigation/native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { StatusBar } from 'expo-status-bar'; import { Provider as PaperProvider } from 'react-native-paper'; +import { enableScreens } from 'react-native-screens'; import AppNavigator, { CustomNavigationDarkTheme, CustomDarkTheme @@ -23,6 +24,9 @@ import { CatalogProvider } from './src/contexts/CatalogContext'; import { GenreProvider } from './src/contexts/GenreContext'; import { TraktProvider } from './src/contexts/TraktContext'; +// This fixes many navigation layout issues by using native screen containers +enableScreens(true); + function App(): React.JSX.Element { // Always use dark mode const isDarkMode = true; @@ -33,7 +37,11 @@ function App(): React.JSX.Element { - + - Nuvio Logo + Nuvio Logo

-# Nuvio - Streaming App +# Nuvio -Nuvio is an Open-Source cross-platform streaming application built with React Native and Expo, allowing users to browse, discover, and watch video content. +An app I built with React Native/Expo for browsing and watching movies & shows. It uses Stremio-compatible addons to find streaming sources. -## ✨ Features +Built for iOS and Android. -* **Home Screen:** Customizable dashboard featuring highlighted content, continue watching section, and access to various content catalogs. -* **Content Discovery:** Explore trending, popular, or categorized movies and TV shows. -* **Detailed Metadata:** Access comprehensive information for content, including descriptions, cast, crew, and ratings. -* **Catalog Browsing:** Navigate through specific genres, curated lists, or addon-provided catalogs. -* **Video Playback:** Integrated video player for watching content. -* **Stream Selection:** Choose from available video streams provided by configured sources/addons. -* **Search Functionality:** Search for specific movies, TV shows, or other content. -* **Personal Library:** Manage a collection of favorite movies and shows. -* **Trakt.tv Integration:** Sync watch history, collection, and watch progress with your Trakt account. -* **Addon Management:** Install, manage, and reorder addons compatible with the Stremio addon protocol to source content streams and catalogs. -* **Release Calendar:** View upcoming movie releases or TV show episode air dates. -* **Extensive Settings:** - * Player customization (e.g., subtitle preferences). - * Content source configuration (TMDB API keys, MDBList URLs). - * Catalog management and visibility. - * Trakt account connection. - * Notification preferences. - * Home screen layout adjustments. -* **Optimized & Interactive UI:** Smooth browsing with skeleton loaders, pull-to-refresh, performant lists, haptic feedback, and action menus. -* **Cross-Platform:** Runs on iOS and Android (highly optimized for iOS; Android performance is generally good). +## Key Features ✨ + +* **Home Screen:** Highlights new content, your watch history, and content categories. +* **Discover:** Browse trending and popular movies & TV shows. +* **Details:** Displays detailed info (descriptions, cast, ratings). +* **Video Player:** Integrated player that remembers playback progress. +* **Stream Finding:** Finds available streams using Stremio addons. +* **Search:** Quickly find specific movies or shows. +* **Trakt Sync:** Option to connect your Trakt.tv account. +* **Addon Management:** Add and manage your Stremio addons. +* **UI:** Focuses on a clean, interactive user experience. ## šŸ“ø Screenshots @@ -37,87 +28,25 @@ Nuvio is an Open-Source cross-platform streaming application built with React Na | **Metadata** | **Seasons & Episodes** | **Rating** | | ![Metadata](src/assets/metadascreen.jpg) | ![Seasons](src/assets/seasonandepisode.jpg)| ![Rating](src/assets/ratingscreen.jpg) | -## šŸš€ Tech Stack +## Wanna run it? šŸš€ -* **Framework:** React Native (v0.76.9) with Expo (SDK 52) -* **Language:** TypeScript -* **Navigation:** React Navigation (v7) -* **Video Playback:** `react-native-video` -* **UI Components:** `react-native-paper`, `@gorhom/bottom-sheet`, `@shopify/flash-list` -* **State Management/Async:** Context API, `axios` -* **Animations & Gestures:** `react-native-reanimated`, `react-native-gesture-handler` -* **Data Sources (Inferred):** TMDB (The Movie Database), potentially Stremio-related services +1. You'll need Node.js, npm/yarn, and the Expo Go app (or native build tools like Android Studio/Xcode). +2. `git clone https://github.com/nayifleo1/NuvioExpo.git` +3. `cd NuvioExpo` +4. `npm install` or `yarn install` +5. `npx expo start` (Easiest way: Scan QR code with Expo Go app) + * Or `npx expo run:android` / `npx expo run:ios` for native builds. -## šŸ› ļø Setup & Running +## Found a bug or have an idea? šŸ› -1. **Prerequisites:** - * Node.js (LTS recommended) - * npm or yarn - * Expo Go app on your device/simulator (for development) or setup for native builds (Android Studio/Xcode). +Great! Please open an [Issue on GitHub](https://github.com/nayifleo1/NuvioExpo/issues). Describe the problem or your suggestion. -2. **Clone the repository:** - ```bash - git clone https://github.com/nayifleo1/NuvioExpo.git - cd nuvio - ``` +## Want to contribute? šŸ¤ -3. **Install dependencies:** - ```bash - npm install - # or - yarn install - ``` +Contributions are welcome! Fork the repository, make your changes, and submit a Pull Request. -4. **Run the application:** +--- - * **For Expo Go (Development):** - ```bash - npx expo start - # or - yarn dlx expo start - ``` - Scan the QR code with the Expo Go app on your iOS or Android device. +Built with help from the communities and tools behind React Native, Expo, TMDB, Trakt, and the Stremio addon system. - * **For Native Android Build/Emulator:** - ```bash - npx expo run:android - # or - yarn dlx expo run:android - ``` - - * **For Native iOS Build/Simulator:** - ```bash - npx expo run:ios - # or - yarn dlx expo run:ios - ``` - -## šŸ¤ Contributing - -Contributions are welcome! If you'd like to contribute, please follow these general steps: - -1. Fork the repository. -2. Create a new branch for your feature or bug fix (`git checkout -b feature/your-feature-name` or `bugfix/issue-number`). -3. Make your changes and commit them with descriptive messages. -4. Push your branch to your fork (`git push origin feature/your-feature-name`). -5. Open a Pull Request to the main repository's `main` or `develop` branch (please check which branch is used for development). - -Please ensure your code follows the project's coding style and includes tests where applicable. - -## šŸ› Reporting Issues - -If you encounter any bugs or have suggestions, please open an issue on the GitHub repository. Provide as much detail as possible, including: - -* Steps to reproduce the issue. -* Expected behavior. -* Actual behavior. -* Screenshots or logs, if helpful. -* Your environment (OS, device, app version). - -## šŸ™ Acknowledgements - -Huge thanks to the Stremio team for their pioneering work in the streaming space and for creating their addon protocol/system. As an indie developer, their approach has been a major source of inspiration. This project utilizes compatibility with the Stremio addon ecosystem to source content. - -## šŸ“„ License - -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. \ No newline at end of file +*Happy Streaming!* \ No newline at end of file diff --git a/app.json b/app.json index a5b08661..07c328dc 100644 --- a/app.json +++ b/app.json @@ -6,6 +6,7 @@ "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "light", + "scheme": "stremioexpo", "newArchEnabled": true, "splash": { "image": "./assets/splash-icon.png", @@ -17,9 +18,26 @@ "infoPlist": { "NSAppTransportSecurity": { "NSAllowsArbitraryLoads": true - } + }, + "NSBonjourServices": [ + "_http._tcp" + ], + "NSLocalNetworkUsageDescription": "App uses the local network to discover and connect to devices.", + "NSMicrophoneUsageDescription": "This app does not require microphone access.", + "UIBackgroundModes": ["audio"], + "LSSupportsOpeningDocumentsInPlace": true, + "UIFileSharingEnabled": true }, - "bundleIdentifier": "com.nuvio.app" + "bundleIdentifier": "com.nuvio.app", + "associatedDomains": [], + "documentTypes": [ + { + "name": "Matroska Video", + "role": "viewer", + "utis": ["org.matroska.mkv"], + "extensions": ["mkv"] + } + ] }, "android": { "adaptiveIcon": { diff --git a/assets/WhatsApp Image 2025-04-22 at 18.46.30_ed1e0602.jpg b/assets/WhatsApp Image 2025-04-22 at 18.46.30_ed1e0602.jpg new file mode 100644 index 00000000..5b4107f2 Binary files /dev/null and b/assets/WhatsApp Image 2025-04-22 at 18.46.30_ed1e0602.jpg differ diff --git a/assets/WhatsApp Image 2025-04-24 at 09.47.46_1d72ceb0.jpg b/assets/WhatsApp Image 2025-04-24 at 09.47.46_1d72ceb0.jpg new file mode 100644 index 00000000..69457cce Binary files /dev/null and b/assets/WhatsApp Image 2025-04-24 at 09.47.46_1d72ceb0.jpg differ diff --git a/package-lock.json b/package-lock.json index 5acc3d71..f3f5b58f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", "axios": "^1.8.4", + "base64-js": "^1.5.1", "date-fns": "^4.1.0", "eventemitter3": "^5.0.1", "expo": "~52.0.43", @@ -40,7 +41,7 @@ "expo-screen-orientation": "~8.0.4", "expo-status-bar": "~2.0.1", "expo-system-ui": "^4.0.9", - "expo-web-browser": "^14.0.2", + "expo-web-browser": "~14.0.2", "lodash": "^4.17.21", "react": "18.3.1", "react-native": "0.76.9", @@ -56,6 +57,7 @@ "react-native-screens": "~4.4.0", "react-native-svg": "^15.11.2", "react-native-tab-view": "^4.0.10", + "react-native-url-polyfill": "^2.0.0", "react-native-video": "^6.12.0", "react-native-web": "~0.19.13", "subsrt": "^1.1.1" @@ -11047,6 +11049,18 @@ "react-native-pager-view": ">= 6.0.0" } }, + "node_modules/react-native-url-polyfill": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz", + "integrity": "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==", + "license": "MIT", + "dependencies": { + "whatwg-url-without-unicode": "8.0.0-3" + }, + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/react-native-vector-icons": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.2.0.tgz", diff --git a/package.json b/package.json index 9011b3bf..d78bd87d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", - "web": "expo start --web" + "web": "expo start --web", + "postinstall": "node patch-package.js" }, "dependencies": { "@expo/metro-runtime": "~4.0.1", @@ -25,11 +26,13 @@ "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", "axios": "^1.8.4", + "base64-js": "^1.5.1", "date-fns": "^4.1.0", "eventemitter3": "^5.0.1", "expo": "~52.0.43", "expo-auth-session": "^6.0.3", "expo-blur": "^14.0.3", + "expo-dev-client": "~5.0.20", "expo-file-system": "^18.0.12", "expo-haptics": "~14.0.1", "expo-image": "~2.0.7", @@ -40,7 +43,7 @@ "expo-screen-orientation": "~8.0.4", "expo-status-bar": "~2.0.1", "expo-system-ui": "^4.0.9", - "expo-web-browser": "^14.0.2", + "expo-web-browser": "~14.0.2", "lodash": "^4.17.21", "react": "18.3.1", "react-native": "0.76.9", @@ -56,10 +59,10 @@ "react-native-screens": "~4.4.0", "react-native-svg": "^15.11.2", "react-native-tab-view": "^4.0.10", + "react-native-url-polyfill": "^2.0.0", "react-native-video": "^6.12.0", "react-native-web": "~0.19.13", - "subsrt": "^1.1.1", - "expo-dev-client": "~5.0.20" + "subsrt": "^1.1.1" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/patch-package.js b/patch-package.js new file mode 100644 index 00000000..6998999b --- /dev/null +++ b/patch-package.js @@ -0,0 +1,42 @@ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// Directory containing patches +const patchesDir = path.join(__dirname, 'src/patches'); + +// Check if the directory exists +if (!fs.existsSync(patchesDir)) { + console.error(`Patches directory not found: ${patchesDir}`); + process.exit(1); +} + +// Get all patch files +const patches = fs.readdirSync(patchesDir).filter(file => file.endsWith('.patch')); + +if (patches.length === 0) { + console.log('No patch files found.'); + process.exit(0); +} + +console.log(`Found ${patches.length} patch files.`); + +// Apply each patch +patches.forEach(patchFile => { + const patchPath = path.join(patchesDir, patchFile); + console.log(`Applying patch: ${patchFile}`); + + try { + // Use the patch command to apply the patch file + execSync(`patch -p1 < ${patchPath}`, { + stdio: 'inherit', + cwd: process.cwd() + }); + console.log(`āœ… Successfully applied patch: ${patchFile}`); + } catch (error) { + console.error(`āŒ Failed to apply patch ${patchFile}:`, error.message); + // Continue with other patches even if one fails + } +}); + +console.log('Patch process completed.'); \ No newline at end of file diff --git a/src/assets/Desktop (1).png b/src/assets/Desktop (1).png new file mode 100644 index 00000000..f99fcab3 Binary files /dev/null and b/src/assets/Desktop (1).png differ diff --git a/src/assets/app-icon.png b/src/assets/app-icon.png deleted file mode 100644 index 0692e313..00000000 Binary files a/src/assets/app-icon.png and /dev/null differ diff --git a/src/assets/home.jpg b/src/assets/home.jpg index 5c11c74a..5b4107f2 100644 Binary files a/src/assets/home.jpg and b/src/assets/home.jpg differ diff --git a/src/components/NuvioHeader.tsx b/src/components/NuvioHeader.tsx index bbaf0797..b2478aff 100644 --- a/src/components/NuvioHeader.tsx +++ b/src/components/NuvioHeader.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { View, TouchableOpacity, Platform, StyleSheet, Image } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors } from '../styles/colors'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useRoute } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/AppNavigator'; import { BlurView as ExpoBlurView } from 'expo-blur'; @@ -13,6 +13,12 @@ type NavigationProp = NativeStackNavigationProp; export const NuvioHeader = () => { const navigation = useNavigation(); + const route = useRoute(); + + // Only render the header if the current route is 'Home' + if (route.name !== 'Home') { + return null; + } // Determine if running in Expo Go const isExpoGo = Constants.executionEnvironment === ExecutionEnvironment.StoreClient; diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 43ec13cf..683de09a 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -28,11 +28,16 @@ interface ContinueWatchingItem extends StreamingContent { episodeTitle?: string; } +// Define the ref interface +interface ContinueWatchingRef { + refresh: () => Promise; +} + const { width } = Dimensions.get('window'); const POSTER_WIDTH = (width - 40) / 2.7; -// Create a proper imperative handle with React.forwardRef -const ContinueWatchingSection = React.forwardRef<{ refresh: () => Promise }>((props, ref) => { +// Create a proper imperative handle with React.forwardRef and updated type +const ContinueWatchingSection = React.forwardRef((props, ref) => { const navigation = useNavigation>(); const [continueWatchingItems, setContinueWatchingItems] = useState([]); const [loading, setLoading] = useState(true); @@ -188,7 +193,11 @@ const ContinueWatchingSection = React.forwardRef<{ refresh: () => Promise // Properly expose the refresh method React.useImperativeHandle(ref, () => ({ - refresh: loadContinueWatching + refresh: async () => { + await loadContinueWatching(); + // Return whether there are items to help parent determine visibility + return continueWatchingItems.length > 0; + } })); const handleContentPress = useCallback((id: string, type: string) => { @@ -362,6 +371,15 @@ const styles = StyleSheet.create({ height: '100%', backgroundColor: colors.primary, }, + emptyContainer: { + paddingHorizontal: 16, + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + color: colors.textMuted, + fontSize: 14, + }, }); export default React.memo(ContinueWatchingSection); \ No newline at end of file diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 6bc3db7b..d464c311 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -134,21 +134,16 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = // For now, just log the error. } else if (streams && addonId && addonName) { logger.log(`āœ… [${logPrefix}:${sourceName}] Received ${streams.length} streams from ${addonName} (${addonId}) after ${processTime}ms`); + if (streams.length > 0) { - const streamsWithAddon = streams.map(stream => ({ - ...stream, - name: stream.name || stream.title || 'Unnamed Stream', - addonId: addonId, - addonName: addonName - })); - + // Use the streams directly as they are already processed by stremioService const updateState = (prevState: GroupedStreams): GroupedStreams => { logger.log(`šŸ”„ [${logPrefix}:${sourceName}] Updating state for addon ${addonName} (${addonId})`); return { ...prevState, [addonId]: { addonName: addonName, - streams: streamsWithAddon + streams: streams // Use the received streams directly } }; }; @@ -577,9 +572,12 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = const loadStreams = async () => { const startTime = Date.now(); try { - console.log('šŸš€ [loadStreams] START - Loading movie streams for:', id); + console.log('šŸš€ [loadStreams] START - Loading streams for:', id); updateLoadingState(); + // Always clear streams first to ensure we don't show stale data + setGroupedStreams({}); + // Get TMDB ID for external sources first before starting parallel requests console.log('šŸ” [loadStreams] Getting TMDB ID for:', id); let tmdbId; @@ -598,70 +596,18 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = console.log('šŸ”„ [loadStreams] Starting stream requests'); - const fetchPromises = []; - - // Start Stremio request using the new callback method - // We don't push this promise anymore, as results are handled by callback + // Start Stremio request using the callback method processStremioSource(type, id, false); - // Start Source 1 request if we have a TMDB ID - if (tmdbId) { - const source1Promise = processExternalSource('source1', (async () => { - try { - const streams = await fetchExternalStreams( - `https://nice-month-production.up.railway.app/embedsu/${tmdbId}`, - 'Source 1' - ); - - if (streams.length > 0) { - return { - 'source_1': { - addonName: 'Source 1', - streams - } - }; - } - return {}; - } catch (error) { - console.error('āŒ [loadStreams:source1] Error fetching Source 1 streams:', error); - return {}; - } - })(), false); - fetchPromises.push(source1Promise); - } + // No external sources are used anymore + const fetchPromises: Promise[] = []; - // Start Source 2 request if we have a TMDB ID - if (tmdbId) { - const source2Promise = processExternalSource('source2', (async () => { - try { - const streams = await fetchExternalStreams( - `https://vidsrc-api-js-phz6.onrender.com/embedsu/${tmdbId}`, - 'Source 2' - ); - - if (streams.length > 0) { - return { - 'source_2': { - addonName: 'Source 2', - streams - } - }; - } - return {}; - } catch (error) { - console.error('āŒ [loadStreams:source2] Error fetching Source 2 streams:', error); - return {}; - } - })(), false); - fetchPromises.push(source2Promise); - } - - // Wait only for external promises now + // Wait only for external promises now (none in this case) const results = await Promise.allSettled(fetchPromises); const totalTime = Date.now() - startTime; console.log(`āœ… [loadStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); - const sourceTypes = ['source1', 'source2']; // Removed 'stremio' + const sourceTypes: string[] = []; // No external sources results.forEach((result, index) => { const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; console.log(`šŸ“Š [loadStreams:${source}] Status: ${result.status}`); @@ -729,72 +675,19 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = console.log('šŸ”„ [loadEpisodeStreams] Starting stream requests'); - const fetchPromises = []; + const fetchPromises: Promise[] = []; - // Start Stremio request using the new callback method - // We don't push this promise anymore + // Start Stremio request using the callback method processStremioSource('series', episodeId, true); - // Start Source 1 request if we have a TMDB ID - if (tmdbId) { - const source1Promise = processExternalSource('source1', (async () => { - try { - const streams = await fetchExternalStreams( - `https://nice-month-production.up.railway.app/embedsu/${tmdbId}${episodeQuery}`, - 'Source 1', - true - ); - - if (streams.length > 0) { - return { - 'source_1': { - addonName: 'Source 1', - streams - } - }; - } - return {}; - } catch (error) { - console.error('āŒ [loadEpisodeStreams:source1] Error fetching Source 1 streams:', error); - return {}; - } - })(), true); - fetchPromises.push(source1Promise); - } + // No external sources are used anymore - // Start Source 2 request if we have a TMDB ID - if (tmdbId) { - const source2Promise = processExternalSource('source2', (async () => { - try { - const streams = await fetchExternalStreams( - `https://vidsrc-api-js-phz6.onrender.com/embedsu/${tmdbId}${episodeQuery}`, - 'Source 2', - true - ); - - if (streams.length > 0) { - return { - 'source_2': { - addonName: 'Source 2', - streams - } - }; - } - return {}; - } catch (error) { - console.error('āŒ [loadEpisodeStreams:source2] Error fetching Source 2 streams:', error); - return {}; - } - })(), true); - fetchPromises.push(source2Promise); - } - - // Wait only for external promises now + // Wait only for external promises now (none in this case) const results = await Promise.allSettled(fetchPromises); const totalTime = Date.now() - startTime; console.log(`āœ… [loadEpisodeStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); - const sourceTypes = ['source1', 'source2']; // Removed 'stremio' + const sourceTypes: string[] = []; // No external sources results.forEach((result, index) => { const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; console.log(`šŸ“Š [loadEpisodeStreams:${source}] Status: ${result.status}`); @@ -834,97 +727,6 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = } }; - const fetchExternalStreams = async (url: string, sourceName: string, isEpisode = false) => { - try { - console.log(`\n🌐 [${sourceName}] Starting fetch request...`); - console.log(`šŸ“ URL: ${url}`); - - // Add proper headers to ensure we get JSON response - const headers = { - 'Accept': 'application/json', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' - }; - console.log('šŸ“‹ Request Headers:', headers); - - // Make the fetch request - console.log(`ā³ [${sourceName}] Making fetch request...`); - const response = await fetch(url, { headers }); - console.log(`āœ… [${sourceName}] Response received`); - console.log(`šŸ“Š Status: ${response.status} ${response.statusText}`); - console.log(`šŸ”¤ Content-Type:`, response.headers.get('content-type')); - - // Check if response is ok - if (!response.ok) { - console.error(`āŒ [${sourceName}] HTTP error: ${response.status}`); - console.error(`šŸ“ Status Text: ${response.statusText}`); - throw new Error(`HTTP error! status: ${response.status}`); - } - - // Try to parse JSON - console.log(`šŸ“‘ [${sourceName}] Reading response body...`); - const text = await response.text(); - console.log(`šŸ“„ [${sourceName}] Response body (first 300 chars):`, text.substring(0, 300)); - - let data; - try { - console.log(`šŸ”„ [${sourceName}] Parsing JSON...`); - data = JSON.parse(text); - console.log(`āœ… [${sourceName}] JSON parsed successfully`); - } catch (e) { - console.error(`āŒ [${sourceName}] JSON parse error:`, e); - console.error(`šŸ“ [${sourceName}] Raw response:`, text.substring(0, 200)); - throw new Error('Invalid JSON response'); - } - - // Transform the response - console.log(`šŸ”„ [${sourceName}] Processing sources...`); - if (data && data.sources && Array.isArray(data.sources)) { - console.log(`šŸ“¦ [${sourceName}] Found ${data.sources.length} source(s)`); - - const transformedStreams = []; - for (const source of data.sources) { - console.log(`\nšŸ“‚ [${sourceName}] Processing source:`, source); - - if (source.files && Array.isArray(source.files)) { - console.log(`šŸ“ [${sourceName}] Found ${source.files.length} file(s) in source`); - - for (const file of source.files) { - console.log(`šŸŽ„ [${sourceName}] Processing file:`, file); - const stream = { - url: file.file, - title: `${sourceName} - ${file.quality || 'Unknown'}`, - name: `${sourceName} - ${file.quality || 'Unknown'}`, - behaviorHints: { - notWebReady: false, - headers: source.headers || {} - } - }; - console.log(`✨ [${sourceName}] Created stream:`, stream); - transformedStreams.push(stream); - } - } else { - console.log(`āš ļø [${sourceName}] No files array found in source or invalid format`); - } - } - - console.log(`\nšŸŽ‰ [${sourceName}] Successfully processed ${transformedStreams.length} stream(s)`); - return transformedStreams; - } - - console.log(`āš ļø [${sourceName}] No valid sources found in response`); - return []; - } catch (error) { - console.error(`\nāŒ [${sourceName}] Error fetching streams:`, error); - console.error(`šŸ“ URL: ${url}`); - if (error instanceof Error) { - console.error(`šŸ’„ Error name: ${error.name}`); - console.error(`šŸ’„ Error message: ${error.message}`); - console.error(`šŸ’„ Stack trace: ${error.stack}`); - } - return []; - } - }; - const handleSeasonChange = useCallback((seasonNumber: number) => { if (selectedSeason === seasonNumber) return; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index b1cda313..896d15c4 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react'; import { NavigationContainer, DefaultTheme as NavigationDefaultTheme, DarkTheme as NavigationDarkTheme, Theme, NavigationProp } from '@react-navigation/native'; import { createNativeStackNavigator, NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack'; import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs'; -import { useColorScheme, Platform, Animated, StatusBar, TouchableOpacity, View, Text } from 'react-native'; +import { useColorScheme, Platform, Animated, StatusBar, TouchableOpacity, View, Text, AppState } from 'react-native'; import { PaperProvider, MD3DarkTheme, MD3LightTheme, adaptNavigationTheme } from 'react-native-paper'; import type { MD3Theme } from 'react-native-paper'; import type { BottomTabBarProps } from '@react-navigation/bottom-tabs'; @@ -12,6 +12,7 @@ import { BlurView } from 'expo-blur'; import { colors } from '../styles/colors'; import { NuvioHeader } from '../components/NuvioHeader'; import { Stream } from '../types/streams'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; // Import screens with their proper types import HomeScreen from '../screens/HomeScreen'; @@ -320,6 +321,65 @@ const TabIcon = React.memo(({ focused, color, iconName }: { ); }); +// Update the TabScreenWrapper component with fixed layout dimensions +const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) => { + // Force consistent status bar settings + useEffect(() => { + const applyStatusBarConfig = () => { + StatusBar.setBarStyle('light-content'); + StatusBar.setTranslucent(true); + StatusBar.setBackgroundColor('transparent'); + }; + + applyStatusBarConfig(); + + // Apply status bar config on every focus + const subscription = Platform.OS === 'android' + ? AppState.addEventListener('change', (state) => { + if (state === 'active') { + applyStatusBarConfig(); + } + }) + : { remove: () => {} }; + + return () => { + subscription.remove(); + }; + }, []); + + return ( + + {/* Reserve consistent space for the header area on all screens */} + + {children} + + ); +}; + +// Add this component to wrap each screen in the tab navigator +const WrappedScreen: React.FC<{Screen: React.ComponentType}> = ({ Screen }) => { + return ( + + + + ); +}; + // Tab Navigator const MainTabs = () => { // Always use dark mode @@ -454,112 +514,138 @@ const MainTabs = () => { }; return ( - ({ - tabBarIcon: ({ focused, color, size }) => { - let iconName: IconNameType = 'home'; - - switch (route.name) { - case 'Home': - iconName = 'home'; - break; - case 'Discover': - iconName = 'compass'; - break; - case 'Library': - iconName = 'play-box-multiple'; - break; - case 'Settings': - iconName = 'cog'; - break; - } - - return ; - }, - tabBarActiveTintColor: colors.primary, - tabBarInactiveTintColor: '#FFFFFF', - tabBarStyle: { - position: 'absolute', - backgroundColor: 'transparent', - borderTopWidth: 0, - elevation: 0, - height: 85, - paddingBottom: 20, - paddingTop: 12, - }, - tabBarLabelStyle: { - fontSize: 12, - fontWeight: '600', - marginTop: 0, - }, - tabBarBackground: () => ( - Platform.OS === 'ios' ? ( - - ) : ( - - ) - ), - header: () => route.name === 'Home' ? : null, - headerShown: route.name === 'Home', - })} - > - + {/* Common StatusBar for all tabs */} + - - - - + + ({ + tabBarIcon: ({ focused, color, size }) => { + let iconName: IconNameType = 'home'; + + switch (route.name) { + case 'Home': + iconName = 'home'; + break; + case 'Discover': + iconName = 'compass'; + break; + case 'Library': + iconName = 'play-box-multiple'; + break; + case 'Settings': + iconName = 'cog'; + break; + } + + return ; + }, + tabBarActiveTintColor: colors.primary, + tabBarInactiveTintColor: '#FFFFFF', + tabBarStyle: { + position: 'absolute', + backgroundColor: 'transparent', + borderTopWidth: 0, + elevation: 0, + height: 85, + paddingBottom: 20, + paddingTop: 12, + }, + tabBarLabelStyle: { + fontSize: 12, + fontWeight: '600', + marginTop: 0, + }, + // Completely disable animations between tabs for better performance + animationEnabled: false, + // Keep all screens mounted and active + lazy: false, + freezeOnBlur: false, + detachPreviousScreen: false, + // Configure how the screen renders + detachInactiveScreens: false, + tabBarBackground: () => ( + Platform.OS === 'ios' ? ( + + ) : ( + + ) + ), + header: () => route.name === 'Home' ? : null, + headerShown: route.name === 'Home', + // Add fixed screen styling to help with consistency + contentStyle: { + backgroundColor: colors.darkBackground, + }, + })} + // Global configuration for the tab navigator + detachInactiveScreens={false} + > + + + + + + ); }; @@ -569,7 +655,7 @@ const AppNavigator = () => { const isDarkMode = true; return ( - <> + { { />
- + ); }; diff --git a/src/patches/react-native-video+6.12.0.patch b/src/patches/react-native-video+6.12.0.patch new file mode 100644 index 00000000..2ac9ccc3 --- /dev/null +++ b/src/patches/react-native-video+6.12.0.patch @@ -0,0 +1,44 @@ +diff --git a/node_modules/react-native-video/ios/Video/RCTVideo.m b/node_modules/react-native-video/ios/Video/RCTVideo.m +index 79d88de..a28a21e 100644 +--- a/node_modules/react-native-video/ios/Video/RCTVideo.m ++++ b/node_modules/react-native-video/ios/Video/RCTVideo.m +@@ -1023,7 +1023,9 @@ static NSString *const statusKeyPath = @"status"; + + /* The player used to render the video */ + AVPlayer *_player; +- AVPlayerLayer *_playerLayer; ++ // Use strong reference instead of weak to prevent deallocation issues ++ __strong AVPlayerLayer *_playerLayer; ++ + NSURL *_videoURL; + + /* IOS < 10 seek optimization */ +@@ -1084,7 +1086,16 @@ - (void)removeFromSuperview + + _player = nil; + _playerItem = nil; +- _playerLayer = nil; ++ ++ // Properly clean up the player layer ++ if (_playerLayer) { ++ [_playerLayer removeFromSuperlayer]; ++ // Set animation keys to nil before releasing to avoid crashes ++ [_playerLayer removeAllAnimations]; ++ _playerLayer = nil; ++ } ++ ++ [[NSNotificationCenter defaultCenter] removeObserver:self]; + } + + #pragma mark - App lifecycle handlers +@@ -1116,7 +1127,8 @@ - (void)applicationDidEnterBackground:(NSNotification *)notification + + - (void)applicationWillEnterForeground:(NSNotification *)notification + { +- if (_playInBackground || _playWhenInactive || _paused) return; ++ // Resume playback even if originally playing in background ++ if (_paused) return; + + [_player play]; + [_player setRate:_rate]; + } \ No newline at end of file diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index 0d1f0a83..4af15cdc 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -63,7 +63,9 @@ const AddonsScreen = () => { const [showConfirmModal, setShowConfirmModal] = useState(false); const [installing, setInstalling] = useState(false); const [catalogCount, setCatalogCount] = useState(0); + // Add state for reorder mode const [reorderMode, setReorderMode] = useState(false); + // Force dark mode const isDarkMode = true; // State for community addons @@ -500,124 +502,333 @@ const AddonsScreen = () => { ); return ( - - + + + {/* Header */} - Addons - - - - - - - - - - - - handleAddAddon()} - disabled={!addonUrl || installing} + style={styles.backButton} + onPress={() => navigation.goBack()} > - {installing ? ( - - ) : ( - - )} + + Settings + + + {/* Reorder Mode Toggle Button */} + + + + + {/* Refresh Button */} + + + + - + + + Addons + {reorderMode && (Reorder Mode)} + + + {reorderMode && ( + + + + Addons at the top have higher priority when loading content + + + )} + {loading ? ( - Loading addons... ) : ( - item.id} - style={styles.list} - contentContainerStyle={styles.listContent} - ListEmptyComponent={() => ( - - - No addons installed - Add an addon using the URL field above + + {/* Overview Section */} + + OVERVIEW + + + + + + + + + + {/* Hide Add Addon Section in reorder mode */} + {!reorderMode && ( + + ADD NEW ADDON + + + handleAddAddon()} + disabled={installing || !addonUrl} + > + + {installing ? 'Loading...' : 'Add Addon'} + + + )} - /> + + {/* Installed Addons Section */} + + + {reorderMode ? "DRAG ADDONS TO REORDER" : "INSTALLED ADDONS"} + + + {addons.length === 0 ? ( + + + No addons installed + + ) : ( + addons.map((addon, index) => ( + + {renderAddonItem({ item: addon, index })} + + )) + )} + + + + {/* Separator */} + + + {/* Community Addons Section */} + + COMMUNITY ADDONS + + {communityLoading ? ( + + + + ) : communityError ? ( + + + {communityError} + + ) : communityAddons.length === 0 ? ( + + + No community addons available + + ) : ( + communityAddons.map((item, index) => ( + + + + {item.manifest.logo ? ( + + ) : ( + + + + )} + + {item.manifest.name} + + v{item.manifest.version || 'N/A'} + • + + {item.manifest.types && item.manifest.types.length > 0 + ? item.manifest.types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ') + : 'General'} + + + + + {item.manifest.behaviorHints?.configurable && ( + handleConfigureAddon(item.manifest, item.transportUrl)} + > + + + )} + handleAddAddon(item.transportUrl)} + disabled={installing} + > + {installing ? ( + + ) : ( + + )} + + + + + + {item.manifest.description + ? (item.manifest.description.length > 100 + ? item.manifest.description.substring(0, 100) + '...' + : item.manifest.description) + : 'No description provided.'} + + + + )) + )} + + + )} - {/* Community Addons Section */} - - Community Addons - {communityLoading ? ( - - - Loading community addons... - - ) : communityError ? ( - - - {communityError} - - ) : ( - item.manifest.id} - horizontal - showsHorizontalScrollIndicator={false} - style={styles.communityList} - contentContainerStyle={styles.communityListContent} - /> - )} - - - {/* Confirmation Modal */} + {/* Addon Details Confirmation Modal */} setShowConfirmModal(false)} + onRequestClose={() => { + setShowConfirmModal(false); + setAddonDetails(null); + }} > - + - Install Addon {addonDetails && ( <> - {addonDetails.name} - {addonDetails.description} + + Install Addon + { + setShowConfirmModal(false); + setAddonDetails(null); + }} + > + + + + + + + {/* @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 + )} + + )} - - setShowConfirmModal(false)} - > - Cancel - - - Install - - @@ -628,156 +839,197 @@ const AddonsScreen = () => { const styles = StyleSheet.create({ container: { flex: 1, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT : 0, + backgroundColor: colors.darkBackground, }, header: { flexDirection: 'row', + alignItems: 'center', justifyContent: 'space-between', - alignItems: 'center', paddingHorizontal: 16, - paddingVertical: 12, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, }, - title: { - fontSize: 24, - fontWeight: 'bold', - color: colors.white, - }, - statsContainer: { + headerActions: { flexDirection: 'row', - padding: 16, - justifyContent: 'space-around', - }, - searchContainer: { - flexDirection: 'row', - paddingHorizontal: 16, - paddingBottom: 16, alignItems: 'center', }, - searchInput: { - flex: 1, - height: 40, - backgroundColor: colors.darkGray, - borderRadius: 8, - paddingHorizontal: 12, - marginRight: 8, - color: colors.white, + headerButton: { + padding: 8, + marginLeft: 8, }, - addButton: { - width: 40, - height: 40, - backgroundColor: colors.primary, + activeHeaderButton: { + backgroundColor: 'rgba(45, 156, 219, 0.2)', + borderRadius: 6, + }, + reorderModeText: { + color: colors.primary, + fontSize: 18, + fontWeight: '400', + }, + reorderInfoBanner: { + backgroundColor: 'rgba(45, 156, 219, 0.15)', + paddingHorizontal: 16, + paddingVertical: 10, + marginHorizontal: 16, borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + reorderInfoText: { + color: colors.white, + fontSize: 14, + marginLeft: 8, + }, + reorderButtons: { + position: 'absolute', + left: -12, + top: '50%', + marginTop: -40, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + zIndex: 10, + }, + reorderButton: { + backgroundColor: colors.elevation3, + width: 30, + height: 30, + borderRadius: 15, justifyContent: 'center', alignItems: 'center', + marginVertical: 4, }, disabledButton: { opacity: 0.5, + backgroundColor: colors.elevation2, }, - list: { - flex: 1, - }, - listContent: { - paddingHorizontal: 16, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - loadingText: { - marginTop: 12, - color: colors.mediumGray, - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingVertical: 32, - }, - emptyText: { - fontSize: 16, - color: colors.mediumGray, - marginTop: 16, - }, - emptySubtext: { - fontSize: 14, - color: colors.mediumGray, - marginTop: 8, - }, - modalOverlay: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.5)', - }, - modalContent: { - backgroundColor: colors.darkGray, + priorityBadge: { + backgroundColor: colors.primary, borderRadius: 12, - padding: 24, - width: '80%', - maxWidth: 400, + paddingHorizontal: 8, + paddingVertical: 3, }, - modalTitle: { - fontSize: 20, + priorityText: { + color: colors.white, + fontSize: 12, fontWeight: 'bold', - color: colors.white, - marginBottom: 16, }, - modalAddonName: { - fontSize: 16, - color: colors.white, - marginBottom: 8, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, }, - modalAddonDesc: { - fontSize: 14, - color: colors.mediumGray, + backText: { + fontSize: 17, + fontWeight: '400', + color: colors.primary, + }, + headerTitle: { + fontSize: 34, + fontWeight: '700', + color: colors.white, + paddingHorizontal: 16, + paddingBottom: 16, + paddingTop: 8, + }, + scrollView: { + flex: 1, + }, + section: { marginBottom: 24, }, - modalButtons: { - flexDirection: 'row', - justifyContent: 'flex-end', - }, - modalButton: { - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 6, - marginLeft: 12, - }, - modalButtonText: { - color: colors.white, - fontSize: 14, - fontWeight: '500', - }, - cancelButton: { - backgroundColor: colors.mediumGray, - }, - confirmButton: { - backgroundColor: colors.primary, - }, - communitySection: { - paddingTop: 16, - }, sectionTitle: { - fontSize: 18, + 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, - paddingHorizontal: 16, - marginBottom: 12, + marginBottom: 4, }, - communityList: { - height: 160, + statsLabel: { + fontSize: 13, + color: colors.mediumGray, }, - communityListContent: { - paddingHorizontal: 16, - }, - errorContainer: { - flexDirection: 'row', - alignItems: 'center', + addAddonContainer: { + marginHorizontal: 16, + backgroundColor: colors.elevation2, + borderRadius: 12, padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, }, - errorText: { - color: colors.error, - marginLeft: 8, + 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, @@ -847,29 +1099,136 @@ const styles = StyleSheet.create({ lineHeight: 20, marginLeft: 48, // Align with title, accounting for icon width }, - reorderButton: { - padding: 8, - }, - reorderButtons: { - position: 'absolute', - left: -12, - top: '50%', - marginTop: -40, - flexDirection: 'column', - alignItems: 'center', + loadingContainer: { + flex: 1, justifyContent: 'center', - zIndex: 10, + alignItems: 'center', }, - priorityBadge: { - backgroundColor: colors.primary, + modalContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + modalContent: { + backgroundColor: colors.elevation2, + borderRadius: 14, + width: '85%', + 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: { + fontSize: 17, + fontWeight: 'bold', + color: colors.white, + }, + modalScrollContent: { + maxHeight: 400, + }, + addonDetailHeader: { + alignItems: 'center', + padding: 24, + borderBottomWidth: 1, + borderBottomColor: colors.elevation3, + }, + addonLogo: { + width: 64, + height: 64, + borderRadius: 12, + marginBottom: 16, + backgroundColor: colors.elevation3, + }, + addonLogoPlaceholder: { + width: 64, + height: 64, + borderRadius: 12, + backgroundColor: colors.elevation3, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + addonDetailName: { + fontSize: 20, + fontWeight: 'bold', + color: colors.white, + marginBottom: 4, + textAlign: 'center', + }, + addonDetailVersion: { + fontSize: 14, + color: colors.mediumGray, + }, + addonDetailSection: { + padding: 16, + borderBottomWidth: 1, + borderBottomColor: colors.elevation3, + }, + addonDetailSectionTitle: { + fontSize: 16, + fontWeight: '600', + color: colors.white, + marginBottom: 8, + }, + addonDetailDescription: { + fontSize: 15, + color: colors.mediumEmphasis, + lineHeight: 20, + }, + addonDetailChips: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + addonDetailChip: { + backgroundColor: colors.elevation3, borderRadius: 12, paddingHorizontal: 8, - paddingVertical: 3, + paddingVertical: 4, }, - priorityText: { + addonDetailChipText: { + fontSize: 13, color: colors.white, - fontSize: 12, - fontWeight: 'bold', + }, + modalActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + padding: 16, + borderTopWidth: 1, + borderTopColor: colors.elevation3, + }, + modalButton: { + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 8, + minWidth: 80, + alignItems: 'center', + }, + cancelButton: { + backgroundColor: colors.elevation3, + marginRight: 8, + }, + installButton: { + backgroundColor: colors.success, + borderRadius: 6, + padding: 8, + justifyContent: 'center', + alignItems: 'center', + }, + modalButtonText: { + color: colors.white, + fontWeight: '600', }, addonActions: { flexDirection: 'row', @@ -882,6 +1241,9 @@ const styles = StyleSheet.create({ padding: 6, marginRight: 8, }, + communityAddonsList: { + paddingHorizontal: 20, + }, communityAddonItem: { flexDirection: 'row', alignItems: 'center', @@ -971,30 +1333,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', }, - installButton: { - backgroundColor: colors.success, - borderRadius: 6, - padding: 8, - justifyContent: 'center', - alignItems: 'center', - }, - statsCard: { - backgroundColor: colors.darkGray, - borderRadius: 8, - padding: 12, - alignItems: 'center', - minWidth: 100, - }, - statsValue: { - fontSize: 24, - fontWeight: 'bold', - color: colors.white, - marginBottom: 4, - }, - statsLabel: { - fontSize: 13, - color: colors.mediumGray, - }, }); export default AddonsScreen; \ No newline at end of file diff --git a/src/screens/DiscoverScreen.tsx b/src/screens/DiscoverScreen.tsx index 9672ef2e..008d01a9 100644 --- a/src/screens/DiscoverScreen.tsx +++ b/src/screens/DiscoverScreen.tsx @@ -24,6 +24,7 @@ import { LinearGradient } from 'expo-linear-gradient'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; import { BlurView } from 'expo-blur'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; interface Category { id: string; @@ -281,10 +282,24 @@ const useStyles = () => { flex: 1, backgroundColor: colors.darkBackground, }, + headerBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: colors.darkBackground, + zIndex: 1, + }, + contentContainer: { + flex: 1, + backgroundColor: colors.darkBackground, + }, header: { paddingHorizontal: 20, - paddingVertical: 16, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16, + justifyContent: 'flex-end', + paddingBottom: 8, + backgroundColor: 'transparent', + zIndex: 2, }, headerContent: { flexDirection: 'row', @@ -487,6 +502,24 @@ const DiscoverScreen = () => { const [allContent, setAllContent] = useState([]); const [loading, setLoading] = useState(true); const styles = useStyles(); + const insets = useSafeAreaInsets(); + + // Force consistent status bar settings + useEffect(() => { + const applyStatusBarConfig = () => { + StatusBar.setBarStyle('light-content'); + if (Platform.OS === 'android') { + StatusBar.setTranslucent(true); + StatusBar.setBackgroundColor('transparent'); + } + }; + + applyStatusBarConfig(); + + // Re-apply on focus + const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); + return unsubscribe; + }, [navigation]); // Load content when category or genre changes useEffect(() => { @@ -580,17 +613,18 @@ const DiscoverScreen = () => { // Memoize list key extractor const catalogKeyExtractor = useCallback((item: GenreCatalog) => item.genre, []); + const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; + const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; + const headerHeight = headerBaseHeight + topSpacing; + return ( - - + + {/* Fixed position header background to prevent shifts */} + - {/* Header Section */} - + {/* Header Section with proper top spacing */} + Discover { - {/* Categories Section */} - - - {CATEGORIES.map((category) => ( - handleCategoryPress(category)} - /> - ))} + {/* Rest of the content */} + + {/* Categories Section */} + + + {CATEGORIES.map((category) => ( + handleCategoryPress(category)} + /> + ))} + + + {/* Genres Section */} + + + {COMMON_GENRES.map(genre => ( + handleGenrePress(genre)} + /> + ))} + + + + {/* Content Section */} + {loading ? ( + + + + ) : catalogs.length > 0 ? ( + + ) : ( + + + No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'} + + + )} - - {/* Genres Section */} - - - {COMMON_GENRES.map(genre => ( - handleGenrePress(genre)} - /> - ))} - - - - {/* Content Section */} - {loading ? ( - - - - ) : catalogs.length > 0 ? ( - - ) : ( - - - No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'} - - - )} - + ); }; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index aef55096..436eaf20 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -18,7 +18,7 @@ import { Modal, Pressable } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService'; @@ -74,6 +74,10 @@ interface DropUpMenuProps { onOptionSelect: (option: string) => void; } +interface ContinueWatchingRef { + refresh: () => Promise; +} + const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => { const translateY = useSharedValue(300); const opacity = useSharedValue(0); @@ -354,11 +358,12 @@ const SkeletonFeatured = () => ( const HomeScreen = () => { const navigation = useNavigation>(); const isDarkMode = useColorScheme() === 'dark'; - const continueWatchingRef = useRef<{ refresh: () => Promise }>(null); + const continueWatchingRef = useRef(null); const { settings } = useSettings(); const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection); const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource); const refreshTimeoutRef = useRef(null); + const [hasContinueWatching, setHasContinueWatching] = useState(false); const { catalogs, @@ -408,9 +413,25 @@ const HomeScreen = () => { }; }, [featuredContentSource, showHeroSection, refreshFeatured]); - useEffect(() => { + useFocusEffect( + useCallback(() => { + const statusBarConfig = () => { + StatusBar.setBarStyle("light-content"); StatusBar.setTranslucent(true); StatusBar.setBackgroundColor('transparent'); + }; + + statusBarConfig(); + + return () => { + // Don't change StatusBar settings when unfocusing to prevent layout shifts + // Only set these when component unmounts completely + }; + }, []) + ); + + useEffect(() => { + // Only run cleanup when component unmounts completely, not on unfocus return () => { StatusBar.setTranslucent(false); StatusBar.setBackgroundColor(colors.darkBackground); @@ -484,9 +505,10 @@ const HomeScreen = () => { }); }, [featuredContent, navigation]); - const refreshContinueWatching = useCallback(() => { + const refreshContinueWatching = useCallback(async () => { if (continueWatchingRef.current) { - continueWatchingRef.current.refresh(); + const hasContent = await continueWatchingRef.current.refresh(); + setHasContinueWatching(hasContent); } }, []); @@ -695,7 +717,7 @@ const HomeScreen = () => { } return ( - + { colors={[colors.primary, colors.secondary]} /> } - contentContainerStyle={styles.scrollContent} + contentContainerStyle={[ + styles.scrollContent, + { paddingTop: Platform.OS === 'ios' ? 0 : 0 } + ]} showsVerticalScrollIndicator={false} > {showHeroSection && renderFeaturedContent()} @@ -719,9 +744,11 @@ const HomeScreen = () => { + {hasContinueWatching && ( + )} {catalogs.length > 0 ? ( catalogs.map((catalog, index) => ( @@ -747,7 +774,7 @@ const HomeScreen = () => { ) )} - + ); }; @@ -770,7 +797,7 @@ const styles = StyleSheet.create({ featuredContainer: { width: '100%', height: height * 0.6, - marginTop: Platform.OS === 'ios' ? 85 : 75, + marginTop: Platform.OS === 'ios' ? 0 : 0, marginBottom: 8, position: 'relative', }, diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 93422eda..e8b3581f 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -24,6 +24,7 @@ import { catalogService } from '../services/catalogService'; import type { StreamingContent } from '../services/catalogService'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; // Types interface LibraryItem extends StreamingContent { @@ -97,6 +98,24 @@ const LibraryScreen = () => { const [loading, setLoading] = useState(true); const [libraryItems, setLibraryItems] = useState([]); const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all'); + const insets = useSafeAreaInsets(); + + // Force consistent status bar settings + useEffect(() => { + const applyStatusBarConfig = () => { + StatusBar.setBarStyle('light-content'); + if (Platform.OS === 'android') { + StatusBar.setTranslucent(true); + StatusBar.setBackgroundColor('transparent'); + } + }; + + applyStatusBarConfig(); + + // Re-apply on focus + const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); + return unsubscribe; + }, [navigation]); useEffect(() => { const loadLibrary = async () => { @@ -216,64 +235,71 @@ const LibraryScreen = () => { ); }; + const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; + const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; + const headerHeight = headerBaseHeight + topSpacing; + return ( - - + + {/* Fixed position header background to prevent shifts */} + - - - Library + + {/* Header Section with proper top spacing */} + + + Library + + + + {/* Content Container */} + + + {renderFilter('all', 'All', 'apps')} + {renderFilter('movies', 'Movies', 'movie')} + {renderFilter('series', 'TV Shows', 'live-tv')} + + + {loading ? ( + + ) : filteredItems.length === 0 ? ( + + + 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 + + + ) : ( + item.id} + numColumns={2} + contentContainerStyle={styles.listContainer} + showsVerticalScrollIndicator={false} + columnWrapperStyle={styles.columnWrapper} + initialNumToRender={6} + maxToRenderPerBatch={6} + windowSize={5} + removeClippedSubviews={Platform.OS === 'android'} + /> + )} - - - {renderFilter('all', 'All', 'apps')} - {renderFilter('movies', 'Movies', 'movie')} - {renderFilter('series', 'TV Shows', 'live-tv')} - - - {loading ? ( - - ) : filteredItems.length === 0 ? ( - - - 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 - - - ) : ( - item.id} - numColumns={2} - contentContainerStyle={styles.listContainer} - showsVerticalScrollIndicator={false} - columnWrapperStyle={styles.columnWrapper} - initialNumToRender={6} - maxToRenderPerBatch={6} - windowSize={5} - removeClippedSubviews={Platform.OS === 'android'} - /> - )} - + ); }; @@ -282,10 +308,24 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.darkBackground, }, + headerBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: colors.darkBackground, + zIndex: 1, + }, + contentContainer: { + flex: 1, + backgroundColor: colors.darkBackground, + }, header: { paddingHorizontal: 20, - paddingVertical: 16, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16, + justifyContent: 'flex-end', + paddingBottom: 8, + backgroundColor: 'transparent', + zIndex: 2, }, headerContent: { flexDirection: 'row', diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 9fc5ec6f..75fa51a3 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -12,12 +12,17 @@ import { Dimensions, Platform, TouchableWithoutFeedback, + NativeSyntheticEvent, + NativeScrollEvent, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { Image } from 'expo-image'; +import { BlurView as ExpoBlurView } from 'expo-blur'; +import { BlurView as CommunityBlurView } from '@react-native-community/blur'; +import * as Haptics from 'expo-haptics'; import { colors } from '../styles/colors'; import { useMetadata } from '../hooks/useMetadata'; import { CastSection as OriginalCastSection } from '../components/metadata/CastSection'; @@ -42,6 +47,7 @@ import Animated, { FadeIn, runOnJS, Layout, + useAnimatedScrollHandler, } from 'react-native-reanimated'; import { RouteProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -99,54 +105,62 @@ const ActionButtons = React.memo(({ navigation: NavigationProp; playButtonText: string; animatedStyle: any; -}) => ( - - - - - {playButtonText} - - +}) => { + // Add wrapper for play button with haptic feedback + const handlePlay = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + handleShowStreams(); + }; - - - - {inLibrary ? 'Saved' : 'Save'} - - - - {type === 'series' && ( + return ( + { - 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'); - } - }} + style={[styles.actionButton, styles.playButton]} + onPress={handlePlay} > - + + + {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(({ @@ -220,10 +234,14 @@ const MetadataScreen = () => { // Get genres from context const { genreMap, loadingGenres } = useGenres(); - const contentRef = useRef(null); + // Update the ref type to be compatible with Animated.ScrollView + const contentRef = useRef(null); const [lastScrollTop, setLastScrollTop] = useState(0); const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false); + // Get safe area insets + const { top: safeAreaTop } = useSafeAreaInsets(); + // Animation values const screenScale = useSharedValue(0.92); const screenOpacity = useSharedValue(0); @@ -246,6 +264,32 @@ const MetadataScreen = () => { episodeId?: string; } | null>(null); + // Add wrapper for toggleLibrary that includes haptic feedback + const handleToggleLibrary = useCallback(() => { + // Trigger appropriate haptic feedback based on action + if (inLibrary) { + // Removed from library - light impact + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } else { + // Added to library - success feedback + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } + + // Call the original toggleLibrary function + toggleLibrary(); + }, [inLibrary, toggleLibrary]); + + // Add wrapper for season change with distinctive haptic feedback + const handleSeasonChangeWithHaptics = useCallback((seasonNumber: number) => { + // Change to Light impact for a more subtle feedback + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + + // Wait a tiny bit before changing season, making the feedback more noticeable + setTimeout(() => { + handleSeasonChange(seasonNumber); + }, 10); + }, [handleSeasonChange]); + // Add new animated value for watch progress const watchProgressOpacity = useSharedValue(0); const watchProgressScaleY = useSharedValue(0); @@ -254,6 +298,19 @@ const MetadataScreen = () => { const logoOpacity = useSharedValue(0); const logoScale = useSharedValue(0.9); + // Add shared value for parallax effect + const scrollY = useSharedValue(0); + + // Create a dampened scroll value for smoother parallax + const dampedScrollY = useSharedValue(0); + + // Add shared value for floating header opacity + const headerOpacity = useSharedValue(0); + + // Add values for animated header elements + const headerElementsY = useSharedValue(-10); + const headerElementsOpacity = useSharedValue(0); + // Debug log for route params // logger.log('[MetadataScreen] Component mounted with route params:', { id, type, episodeId }); @@ -628,13 +685,15 @@ const MetadataScreen = () => { }, []); // Empty dependency array as it doesn't depend on component state/props currently const handleEpisodeSelect = useCallback((episode: Episode) => { + // Removed haptic feedback + const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`; navigation.navigate('Streams', { id, type, episodeId }); - }, [navigation, id, type]); // Added dependencies + }, [navigation, id, type]); // Animated styles const containerAnimatedStyle = useAnimatedStyle(() => ({ @@ -643,14 +702,6 @@ const MetadataScreen = () => { opacity: screenOpacity.value })); - const heroAnimatedStyle = useAnimatedStyle(() => ({ - width: '100%', - height: heroHeight.value, - backgroundColor: colors.black, - transform: [{ scale: heroScale.value }], - opacity: heroOpacity.value - })); - const contentAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateY: contentTranslateY.value }], opacity: interpolate( @@ -847,6 +898,83 @@ const MetadataScreen = () => { )); }, [metadata?.genres]); // Dependency on metadata.genres + // Update the heroAnimatedStyle for parallax effect + const heroAnimatedStyle = useAnimatedStyle(() => ({ + width: '100%', + height: heroHeight.value, + backgroundColor: colors.black, + transform: [{ scale: heroScale.value }], + opacity: heroOpacity.value, + })); + + // Replace direct onScroll with useAnimatedScrollHandler + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + const rawScrollY = event.contentOffset.y; + scrollY.value = rawScrollY; + + // Apply spring-like damping for smoother transitions + dampedScrollY.value = withTiming(rawScrollY, { + duration: 300, + easing: Easing.bezier(0.16, 1, 0.3, 1), // Custom spring-like curve + }); + + // Update header opacity based on scroll position + const headerThreshold = height * 0.5 - safeAreaTop - 70; // Hero height - inset - buffer + if (rawScrollY > headerThreshold) { + headerOpacity.value = withTiming(1, { duration: 200 }); + headerElementsY.value = withTiming(0, { duration: 300 }); + headerElementsOpacity.value = withTiming(1, { duration: 450 }); + } else { + headerOpacity.value = withTiming(0, { duration: 150 }); + headerElementsY.value = withTiming(-10, { duration: 200 }); + headerElementsOpacity.value = withTiming(0, { duration: 200 }); + } + }, + }); + + // Add a new animated style for the parallax image + const parallaxImageStyle = useAnimatedStyle(() => { + // Use dampedScrollY instead of direct scrollY for smoother effect + return { + width: '100%', + height: '120%', // Increase height for more movement range + top: '-10%', // Start image slightly higher to allow more upward movement + transform: [ + { + translateY: interpolate( + dampedScrollY.value, + [0, 100, 300], + [20, -20, -60], // Start with a lower position, then move up + Extrapolate.CLAMP + ) + }, + { + scale: interpolate( + dampedScrollY.value, + [0, 150, 300], + [1.1, 1.02, 0.95], // More dramatic scale changes + Extrapolate.CLAMP + ) + } + ], + }; + }); + + // Add animated style for floating header + const headerAnimatedStyle = useAnimatedStyle(() => ({ + opacity: headerOpacity.value, + transform: [ + { translateY: interpolate(headerOpacity.value, [0, 1], [-20, 0], Extrapolate.CLAMP) } + ] + })); + + // Add animated style for header elements + const headerElementsStyle = useAnimatedStyle(() => ({ + opacity: headerElementsOpacity.value, + transform: [{ translateY: headerElementsY.value }] + })); + if (loading) { return ( { animated={true} /> - + {Platform.OS === 'ios' ? ( + + + + + + + + {metadata.logo ? ( + + ) : ( + {metadata.name} + )} + + + + + + + + ) : ( + + + + + + + + + {metadata.logo ? ( + + ) : ( + {metadata.name} + )} + + + + + + + + )} + {Platform.OS === 'ios' && } + + + { - // setLastScrollTop(e.nativeEvent.contentOffset.y); // Remove unused onScroll handler logic - }} - scrollEventThrottle={16} + onScroll={scrollHandler} + scrollEventThrottle={16} // Back to standard value > {/* Hero Section */} - + + {/* Use Animated.Image directly instead of ImageBackground with imageStyle */} + { {/* Action Buttons */} { /> - + {/* Main Content */} @@ -1108,7 +1325,7 @@ const MetadataScreen = () => { episodes={episodes} selectedSeason={selectedSeason} loadingSeasons={loadingSeasons} - onSeasonChange={handleSeasonChange} + onSeasonChange={handleSeasonChangeWithHaptics} onSelectEpisode={handleEpisodeSelect} groupedEpisodes={groupedEpisodes} metadata={metadata} @@ -1117,7 +1334,7 @@ const MetadataScreen = () => { )} - + ); @@ -1171,13 +1388,11 @@ const styles = StyleSheet.create({ fontWeight: '600', }, backButton: { - flexDirection: 'row', + width: 40, + height: 40, alignItems: 'center', justifyContent: 'center', - paddingHorizontal: 24, - paddingVertical: 12, - borderRadius: 24, - borderWidth: 1, + borderRadius: 20, }, backButtonText: { fontSize: 16, @@ -1189,11 +1404,12 @@ const styles = StyleSheet.create({ backgroundColor: colors.black, overflow: 'hidden', }, - heroImage: { - width: '100%', - height: '100%', - top: '0%', - transform: [{ scale: 1 }], + absoluteFill: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, }, heroGradient: { flex: 1, @@ -1409,6 +1625,64 @@ const styles = StyleSheet.create({ opacity: 0.9, letterSpacing: 0.2 }, + floatingHeader: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 10, + overflow: 'hidden', + elevation: 4, // for Android shadow + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 3, + }, + blurContainer: { + width: '100%', + }, + floatingHeaderContent: { + height: 56, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + }, + headerBottomBorder: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 0.5, + backgroundColor: 'rgba(255,255,255,0.15)', + }, + headerTitleContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 10, + }, + headerRightPlaceholder: { + width: 40, // same width as back button for symmetry + }, + headerActionButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 20, + }, + floatingHeaderLogo: { + height: 42, + width: width * 0.6, + maxWidth: 240, + }, + floatingHeaderTitle: { + color: colors.highEmphasis, + fontSize: 18, + fontWeight: '700', + textAlign: 'center', + }, }); export default MetadataScreen; \ No newline at end of file diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 3b61c4ae..47a5757b 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -26,6 +26,7 @@ import { stremioService } from '../services/stremioService'; import { useCatalogContext } from '../contexts/CatalogContext'; import { useTraktContext } from '../contexts/TraktContext'; import { catalogService, DataSource } from '../services/catalogService'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; const { width } = Dimensions.get('window'); @@ -125,6 +126,7 @@ const SettingsScreen: React.FC = () => { const navigation = useNavigation>(); const { lastUpdate } = useCatalogContext(); const { isAuthenticated, userProfile } = useTraktContext(); + const insets = useSafeAreaInsets(); // States for dynamic content const [addonCount, setAddonCount] = useState(0); @@ -132,6 +134,23 @@ const SettingsScreen: React.FC = () => { const [mdblistKeySet, setMdblistKeySet] = useState(false); const [discoverDataSource, setDiscoverDataSource] = useState(DataSource.STREMIO_ADDONS); + // Force consistent status bar settings + useEffect(() => { + const applyStatusBarConfig = () => { + StatusBar.setBarStyle('light-content'); + if (Platform.OS === 'android') { + StatusBar.setTranslucent(true); + StatusBar.setBackgroundColor('transparent'); + } + }; + + applyStatusBarConfig(); + + // Re-apply on focus + const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); + return unsubscribe; + }, [navigation]); + const loadData = useCallback(async () => { try { // Load addon count and get their catalogs @@ -231,166 +250,182 @@ const SettingsScreen: React.FC = () => { await catalogService.setDataSourcePreference(dataSource); }, []); + const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; + const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; + const headerHeight = headerBaseHeight + topSpacing; + return ( - - - - - Settings - - - Reset - - - - - navigation.navigate('TraktSettings')} - isLast={true} - /> - - - - navigation.navigate('Calendar')} - isDarkMode={isDarkMode} - /> - navigation.navigate('NotificationSettings')} - isDarkMode={isDarkMode} - isLast={true} - /> - - - - navigation.navigate('Addons')} - badge={addonCount} - /> - navigation.navigate('CatalogSettings')} - badge={catalogCount} - /> - navigation.navigate('HomeScreenSettings')} - /> - navigation.navigate('MDBListSettings')} - /> - navigation.navigate('TMDBSettings')} - isLast={true} - /> - - - - navigation.navigate('PlayerSettings')} - isLast={true} - /> - - - - ( - - handleDiscoverDataSourceChange(DataSource.STREMIO_ADDONS)} - > - Addons - - handleDiscoverDataSourceChange(DataSource.TMDB)} - > - TMDB - - - )} - /> - - - - - Version 1.0.0 + {/* Fixed position header background to prevent shifts */} + + + + {/* Header Section with proper top spacing */} + + + Settings + + Reset + - - + + {/* Content Container */} + + + + navigation.navigate('TraktSettings')} + isLast={true} + /> + + + + navigation.navigate('Calendar')} + isDarkMode={isDarkMode} + /> + navigation.navigate('NotificationSettings')} + isDarkMode={isDarkMode} + isLast={true} + /> + + + + navigation.navigate('Addons')} + badge={addonCount} + /> + navigation.navigate('CatalogSettings')} + badge={catalogCount} + /> + navigation.navigate('HomeScreenSettings')} + /> + navigation.navigate('MDBListSettings')} + /> + navigation.navigate('TMDBSettings')} + isLast={true} + /> + + + + navigation.navigate('PlayerSettings')} + isLast={true} + /> + + + + ( + + handleDiscoverDataSourceChange(DataSource.STREMIO_ADDONS)} + > + Addons + + handleDiscoverDataSourceChange(DataSource.TMDB)} + > + TMDB + + + )} + /> + + + + + Version 1.0.0 + + + + + + ); }; @@ -398,34 +433,51 @@ const styles = StyleSheet.create({ container: { flex: 1, }, + headerBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 1, + }, + contentContainer: { + flex: 1, + zIndex: 1, + width: '100%', + }, header: { - paddingHorizontal: 16, - paddingVertical: 12, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 8, + paddingHorizontal: 20, flexDirection: 'row', justifyContent: 'space-between', - alignItems: 'center', + alignItems: 'flex-end', + paddingBottom: 8, + backgroundColor: 'transparent', + zIndex: 2, }, headerTitle: { fontSize: 32, - fontWeight: '700', - letterSpacing: 0.5, + fontWeight: '800', + letterSpacing: 0.3, }, resetButton: { - paddingVertical: 6, + paddingVertical: 8, paddingHorizontal: 12, }, resetButtonText: { - fontSize: 15, + fontSize: 16, fontWeight: '600', }, scrollView: { flex: 1, + width: '100%', }, scrollContent: { + flexGrow: 1, + width: '100%', paddingBottom: 32, }, cardContainer: { + width: '100%', marginBottom: 20, }, cardTitle: { @@ -444,6 +496,7 @@ const styles = StyleSheet.create({ shadowOpacity: 0.1, shadowRadius: 4, elevation: 3, + width: undefined, // Let it fill the container width }, settingItem: { flexDirection: 'row', @@ -452,6 +505,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, borderBottomWidth: 0.5, minHeight: 58, + width: '100%', }, settingItemBorder: { // Border styling handled directly in the component with borderBottomWidth diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index faf82843..3f0f27b8 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -542,14 +542,12 @@ export const StreamsScreen = () => { if (indexB !== -1) return 1; return 0; }) - .filter(provider => provider !== 'source_1' && provider !== 'source_2') // Filter out source_1 and source_2 .map(provider => { const addonInfo = streams[provider]; const installedAddon = installedAddons.find(addon => addon.id === provider); let displayName = provider; - if (provider === 'external_sources') displayName = 'External Sources'; - else if (installedAddon) displayName = installedAddon.name; + if (installedAddon) displayName = installedAddon.name; else if (addonInfo?.addonName) displayName = addonInfo.addonName; return { id: provider, name: displayName }; @@ -561,10 +559,15 @@ export const StreamsScreen = () => { const streams = type === 'series' ? episodeStreams : groupedStreams; const installedAddons = stremioService.getInstalledAddons(); - return Object.entries(streams) + // Filter streams by selected provider - only if not "all" + const filteredEntries = Object.entries(streams) .filter(([addonId]) => { - // Filter out source_1 and source_2 - return addonId !== 'source_1' && addonId !== 'source_2'; + // If "all" is selected, show all providers + if (selectedProvider === 'all') { + return true; + } + // Otherwise only show the selected provider + return addonId === selectedProvider; }) .sort(([addonIdA], [addonIdB]) => { const indexA = installedAddons.findIndex(addon => addon.id === addonIdA); @@ -580,6 +583,8 @@ export const StreamsScreen = () => { addonId, data: streams })); + + return filteredEntries; }, [selectedProvider, type, episodeStreams, groupedStreams]); const episodeImage = useMemo(() => { diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index cf89f567..8c87af76 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -11,11 +11,9 @@ import { ScrollView, StatusBar, Platform, - Linking } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import * as WebBrowser from 'expo-web-browser'; -import { makeRedirectUri } from 'expo-auth-session'; +import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import { traktService, TraktUser } from '../services/traktService'; import { colors } from '../styles/colors'; @@ -25,6 +23,13 @@ import TraktIcon from '../../assets/rating-icons/trakt.svg'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; +// Trakt configuration +const TRAKT_CLIENT_ID = 'd7271f7dd57d8aeff63e99408610091a6b1ceac3b3a541d1031a48f429b7942c'; +const discovery = { + authorizationEndpoint: 'https://trakt.tv/oauth/authorize', + tokenEndpoint: 'https://api.trakt.tv/oauth/token', +}; + // For use with deep linking const redirectUri = makeRedirectUri({ scheme: 'stremioexpo', @@ -36,7 +41,6 @@ const TraktSettingsScreen: React.FC = () => { const isDarkMode = settings.enableDarkMode; const navigation = useNavigation(); const [isLoading, setIsLoading] = useState(true); - const [isAuthenticating, setIsAuthenticating] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false); const [userProfile, setUserProfile] = useState(null); @@ -49,6 +53,8 @@ const TraktSettingsScreen: React.FC = () => { if (authenticated) { const profile = await traktService.getUserProfile(); setUserProfile(profile); + } else { + setUserProfile(null); } } catch (error) { logger.error('[TraktSettingsScreen] Error checking auth status:', error); @@ -61,45 +67,58 @@ const TraktSettingsScreen: React.FC = () => { checkAuthStatus(); }, [checkAuthStatus]); - // Handle deep linking when returning from Trakt authorization + // Setup expo-auth-session hook with PKCE + const [request, response, promptAsync] = useAuthRequest( + { + clientId: TRAKT_CLIENT_ID, + scopes: [], + redirectUri: redirectUri, + responseType: ResponseType.Code, + usePKCE: true, + codeChallengeMethod: CodeChallengeMethod.S256, + }, + discovery + ); + + const [isExchangingCode, setIsExchangingCode] = useState(false); + + // Handle the response from the auth request useEffect(() => { - const handleRedirect = async (event: { url: string }) => { - const { url } = event; - if (url.includes('auth/trakt')) { - setIsAuthenticating(true); - try { - const code = url.split('code=')[1].split('&')[0]; - const success = await traktService.exchangeCodeForToken(code); - if (success) { - checkAuthStatus(); - } else { - Alert.alert('Authentication Error', 'Failed to complete authentication with Trakt.'); - } - } catch (error) { - logger.error('[TraktSettingsScreen] Authentication error:', error); - Alert.alert('Authentication Error', 'An error occurred during authentication.'); - } finally { - setIsAuthenticating(false); - } + if (response) { + setIsExchangingCode(true); + if (response.type === 'success' && request?.codeVerifier) { + const { code } = response.params; + logger.log('[TraktSettingsScreen] Auth code received:', code); + traktService.exchangeCodeForToken(code, request.codeVerifier) + .then(success => { + if (success) { + logger.log('[TraktSettingsScreen] Token exchange successful'); + checkAuthStatus(); + } else { + logger.error('[TraktSettingsScreen] Token exchange failed'); + Alert.alert('Authentication Error', 'Failed to complete authentication with Trakt.'); + } + }) + .catch(error => { + logger.error('[TraktSettingsScreen] Token exchange error:', error); + Alert.alert('Authentication Error', 'An error occurred during authentication.'); + }) + .finally(() => { + setIsExchangingCode(false); + }); + } else if (response.type === 'error') { + logger.error('[TraktSettingsScreen] Authentication error:', response.error); + Alert.alert('Authentication Error', response.error?.message || 'An error occurred during authentication.'); + setIsExchangingCode(false); + } else { + logger.log('[TraktSettingsScreen] Auth response type:', response.type); + setIsExchangingCode(false); } - }; - - // Add event listener for deep linking - const subscription = Linking.addEventListener('url', handleRedirect); - - return () => { - subscription.remove(); - }; - }, [checkAuthStatus]); - - const handleSignIn = async () => { - try { - const authUrl = traktService.getAuthUrl(); - await WebBrowser.openAuthSessionAsync(authUrl, redirectUri); - } catch (error) { - logger.error('[TraktSettingsScreen] Error opening auth session:', error); - Alert.alert('Authentication Error', 'Could not open Trakt authentication page.'); } + }, [response, checkAuthStatus, request?.codeVerifier]); + + const handleSignIn = () => { + promptAsync(); // Trigger the authentication flow }; const handleSignOut = async () => { @@ -249,9 +268,9 @@ const TraktSettingsScreen: React.FC = () => { { backgroundColor: isDarkMode ? colors.primary : colors.primary } ]} onPress={handleSignIn} - disabled={isAuthenticating} + disabled={!request || isExchangingCode} // Disable while waiting for response or exchanging code > - {isAuthenticating ? ( + {isExchangingCode ? ( ) : ( diff --git a/src/screens/VideoPlayer.tsx b/src/screens/VideoPlayer.tsx index 596e171e..7aa4b474 100644 --- a/src/screens/VideoPlayer.tsx +++ b/src/screens/VideoPlayer.tsx @@ -633,6 +633,12 @@ const VideoPlayer: React.FC = () => { `); }; + // Add onError handler + const handleError = (error: any) => { + logger.error('[VideoPlayer] Playback Error:', error); + // Optionally, you could show an error message to the user here + }; + return ( { onTextTracks={onTextTracks} onBuffer={onBuffer} onLoadStart={onLoadStart} + onError={handleError} /> {/* Slider Container with buffer indicator */} diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index bad89ff2..ab28bf68 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -552,27 +552,41 @@ class StremioService { // Find addons that provide streams and sort them by installation order const streamAddons = addons .filter(addon => { - if (!addon.resources) { - logger.log(`āš ļø [getStreams] Addon ${addon.id} has no resources`); + if (!addon.resources || !Array.isArray(addon.resources)) { + logger.log(`āš ļø [getStreams] Addon ${addon.id} has no valid resources array`); return false; } // Log the detailed resources structure for debugging - // logger.log(`šŸ“‹ [getStreams] Checking addon ${addon.id} resources:`, JSON.stringify(addon.resources)); // Verbose, uncomment if needed + logger.log(`šŸ“‹ [getStreams] Checking addon ${addon.id} resources:`, JSON.stringify(addon.resources)); - // Check if the addon has a stream resource for this type - const hasStreamResource = addon.resources.some( - resource => { - const result = resource.name === 'stream' && resource.types && resource.types.includes(type); - // logger.log(`šŸ”Ž [getStreams] Addon ${addon.id} resource ${resource.name}: supports ${type}? ${result}`); // Verbose - return result; + let hasStreamResource = false; + + // Iterate through the resources array, checking each element + for (const resource of addon.resources) { + // Check if the current element is a ResourceObject + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as ResourceObject; + if (typedResource.name === 'stream' && + Array.isArray(typedResource.types) && + typedResource.types.includes(type)) { + hasStreamResource = true; + break; // Found the stream resource object, no need to check further + } + } + // Check if the element is the simple string "stream" AND the addon has a top-level types array + else if (typeof resource === 'string' && resource === 'stream' && addon.types) { + if (Array.isArray(addon.types) && addon.types.includes(type)) { + hasStreamResource = true; + break; // Found the simple stream resource string and type support + } } - ); + } if (!hasStreamResource) { - // logger.log(`āŒ [getStreams] Addon ${addon.id} does not support streaming ${type}`); // Verbose + logger.log(`āŒ [getStreams] Addon ${addon.id} does not support streaming ${type}`); } else { - // logger.log(`āœ… [getStreams] Addon ${addon.id} supports streaming ${type}`); // Verbose + logger.log(`āœ… [getStreams] Addon ${addon.id} supports streaming ${type}`); } return hasStreamResource; @@ -728,39 +742,81 @@ class StremioService { private processStreams(streams: any[], addon: Manifest): Stream[] { return streams .filter(stream => { - const isTorrentioStream = stream.infoHash && stream.fileIdx !== undefined; - return stream && (stream.url || isTorrentioStream) && (stream.title || stream.name); + // Basic filtering - ensure there's a way to play (URL or infoHash) and identify (title/name) + const hasPlayableLink = !!(stream.url || stream.infoHash); + const hasIdentifier = !!(stream.title || stream.name); + return stream && hasPlayableLink && hasIdentifier; }) .map(stream => { - const isDirectStreamingUrl = this.isDirectStreamingUrl(stream.url); const streamUrl = this.getStreamUrl(stream); + const isDirectStreamingUrl = this.isDirectStreamingUrl(streamUrl); const isMagnetStream = streamUrl?.startsWith('magnet:'); - // Keep original stream data exactly as provided by the addon - return { - ...stream, - url: streamUrl, + // Determine the best title: Prioritize description if it seems detailed, + // otherwise fall back to title or name. + let displayTitle = stream.title || stream.name || 'Unnamed Stream'; + if (stream.description && stream.description.includes('\n') && stream.description.length > (stream.title?.length || 0)) { + // If description exists, contains newlines (likely formatted metadata), + // and is longer than the title, prefer it. + displayTitle = stream.description; + } + + // Use the original name field for the primary identifier if available + const name = stream.name || stream.title || 'Unnamed Stream'; + + // Extract size: Prefer behaviorHints.videoSize, fallback to top-level size + const sizeInBytes = stream.behaviorHints?.videoSize || stream.size || undefined; + + // Consolidate behavior hints, prioritizing specific data extraction + let behaviorHints: Stream['behaviorHints'] = { + ...(stream.behaviorHints || {}), // Start with existing hints + notWebReady: !isDirectStreamingUrl, + isMagnetStream, + // Addon Info addonName: addon.name, addonId: addon.id, - // Preserve original stream metadata - name: stream.name, - title: stream.title, - behaviorHints: { - ...stream.behaviorHints, - notWebReady: !isDirectStreamingUrl, - isMagnetStream, - ...(isMagnetStream && { - infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1], - fileIdx: stream.fileIdx, - magnetUrl: streamUrl, - type: 'torrent', - sources: stream.sources || [], - seeders: stream.seeders, - size: stream.size, - title: stream.title, - }) - } + // Extracted data (provide defaults or undefined) + cached: stream.behaviorHints?.cached || undefined, // For RD/AD detection + filename: stream.behaviorHints?.filename || undefined, // Filename if available + bingeGroup: stream.behaviorHints?.bingeGroup || undefined, + // Add size here if extracted + size: sizeInBytes, }; + + // Specific handling for magnet/torrent streams to extract more details + if (isMagnetStream) { + behaviorHints = { + ...behaviorHints, + infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1], + fileIdx: stream.fileIdx, + magnetUrl: streamUrl, + type: 'torrent', + sources: stream.sources || [], + seeders: stream.seeders, // Explicitly map seeders if present + size: sizeInBytes || stream.seeders, // Use extracted size, fallback for torrents + title: stream.title, // Torrent title might be different + }; + } + + // Explicitly construct the final Stream object + const processedStream: Stream = { + url: streamUrl, + name: name, // Use the original name/title for primary ID + title: displayTitle, // Use the potentially more detailed title from description + addonName: addon.name, + addonId: addon.id, + // Map other potential top-level fields if they exist + description: stream.description || undefined, // Keep original description too + infoHash: stream.infoHash || undefined, + fileIdx: stream.fileIdx, + size: sizeInBytes, // Assign the extracted size + isFree: stream.isFree, + isDebrid: !!(stream.behaviorHints?.cached), // Map debrid status more reliably + // Assign the consolidated behaviorHints + behaviorHints: behaviorHints, + }; + + return processedStream; }); } diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 3030040e..64a7f6af 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -125,7 +125,7 @@ export class TraktService { /** * Exchange the authorization code for an access token */ - public async exchangeCodeForToken(code: string): Promise { + public async exchangeCodeForToken(code: string, codeVerifier: string): Promise { await this.ensureInitialized(); try { @@ -139,11 +139,14 @@ export class TraktService { client_id: TRAKT_CLIENT_ID, client_secret: TRAKT_CLIENT_SECRET, redirect_uri: TRAKT_REDIRECT_URI, - grant_type: 'authorization_code' + grant_type: 'authorization_code', + code_verifier: codeVerifier }) }); if (!response.ok) { + const errorBody = await response.text(); + logger.error('[TraktService] Token exchange error response:', errorBody); throw new Error(`Failed to exchange code: ${response.status}`); }