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 - 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** |
|  | |  |
-## š 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}`);
}