Ios #3

Merged
tapframe merged 14 commits from ios into main 2025-05-02 11:56:30 +00:00
27 changed files with 1985 additions and 1150 deletions

10
App.tsx
View file

@ -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 {
<CatalogProvider>
<TraktProvider>
<PaperProvider theme={CustomDarkTheme}>
<NavigationContainer theme={CustomNavigationDarkTheme}>
<NavigationContainer
theme={CustomNavigationDarkTheme}
// Disable automatic linking which can cause layout issues
linking={undefined}
>
<View style={[styles.container, { backgroundColor: '#000000' }]}>
<StatusBar
style="light"

129
README.md
View file

@ -1,33 +1,24 @@
<p align="center">
<img src="assets/titlelogo.png" alt="Nuvio Logo" width="400"/>
<img src="assets/titlelogo.png" alt="Nuvio Logo" width="300"/>
</p>
# 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.
*Happy Streaming!*

View file

@ -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": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

16
package-lock.json generated
View file

@ -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",

View file

@ -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",

42
patch-package.js Normal file
View file

@ -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.');

BIN
src/assets/Desktop (1).png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 162 KiB

View file

@ -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<RootStackParamList>;
export const NuvioHeader = () => {
const navigation = useNavigation<NavigationProp>();
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;

View file

@ -28,11 +28,16 @@ interface ContinueWatchingItem extends StreamingContent {
episodeTitle?: string;
}
// Define the ref interface
interface ContinueWatchingRef {
refresh: () => Promise<boolean>;
}
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<void> }>((props, ref) => {
// Create a proper imperative handle with React.forwardRef and updated type
const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const [continueWatchingItems, setContinueWatchingItems] = useState<ContinueWatchingItem[]>([]);
const [loading, setLoading] = useState(true);
@ -188,7 +193,11 @@ const ContinueWatchingSection = React.forwardRef<{ refresh: () => Promise<void>
// 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);

View file

@ -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<any>[] = [];
// 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<any>[] = [];
// 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;

View file

@ -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 (
<View style={{
flex: 1,
backgroundColor: colors.darkBackground,
// Lock the layout to prevent shifts
position: 'relative',
overflow: 'hidden'
}}>
{/* Reserve consistent space for the header area on all screens */}
<View style={{
height: Platform.OS === 'android' ? 80 : 60,
width: '100%',
backgroundColor: colors.darkBackground,
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: -1
}} />
{children}
</View>
);
};
// Add this component to wrap each screen in the tab navigator
const WrappedScreen: React.FC<{Screen: React.ComponentType<any>}> = ({ Screen }) => {
return (
<TabScreenWrapper>
<Screen />
</TabScreenWrapper>
);
};
// Tab Navigator
const MainTabs = () => {
// Always use dark mode
@ -454,112 +514,138 @@ const MainTabs = () => {
};
return (
<Tab.Navigator
tabBar={renderTabBar}
screenOptions={({ route }) => ({
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 <TabIcon focused={focused} color={color} iconName={iconName} />;
},
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' ? (
<BlurView
tint="dark"
intensity={75}
style={{
position: 'absolute',
height: '100%',
width: '100%',
borderTopColor: 'rgba(255,255,255,0.2)',
borderTopWidth: 0.5,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 3,
}}
/>
) : (
<LinearGradient
colors={[
'rgba(0, 0, 0, 0)',
'rgba(0, 0, 0, 0.65)',
'rgba(0, 0, 0, 0.85)',
'rgba(0, 0, 0, 0.98)',
]}
locations={[0, 0.2, 0.4, 0.8]}
style={{
position: 'absolute',
height: '100%',
width: '100%',
}}
/>
)
),
header: () => route.name === 'Home' ? <NuvioHeader /> : null,
headerShown: route.name === 'Home',
})}
>
<Tab.Screen
name="Home"
component={HomeScreen as any}
options={{
tabBarLabel: 'Home',
}}
<View style={{ flex: 1, backgroundColor: colors.darkBackground }}>
{/* Common StatusBar for all tabs */}
<StatusBar
translucent
barStyle="light-content"
backgroundColor="transparent"
/>
<Tab.Screen
name="Discover"
component={DiscoverScreen as any}
options={{
tabBarLabel: 'Discover'
}}
/>
<Tab.Screen
name="Library"
component={LibraryScreen as any}
options={{
tabBarLabel: 'Library'
}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen as any}
options={{
tabBarLabel: 'Settings'
}}
/>
</Tab.Navigator>
<Tab.Navigator
tabBar={renderTabBar}
screenOptions={({ route }) => ({
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 <TabIcon focused={focused} color={color} iconName={iconName} />;
},
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' ? (
<BlurView
tint="dark"
intensity={75}
style={{
position: 'absolute',
height: '100%',
width: '100%',
borderTopColor: 'rgba(255,255,255,0.2)',
borderTopWidth: 0.5,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 3,
}}
/>
) : (
<LinearGradient
colors={[
'rgba(0, 0, 0, 0)',
'rgba(0, 0, 0, 0.65)',
'rgba(0, 0, 0, 0.85)',
'rgba(0, 0, 0, 0.98)',
]}
locations={[0, 0.2, 0.4, 0.8]}
style={{
position: 'absolute',
height: '100%',
width: '100%',
}}
/>
)
),
header: () => route.name === 'Home' ? <NuvioHeader /> : 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}
>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{
tabBarLabel: 'Home',
}}
/>
<Tab.Screen
name="Discover"
component={DiscoverScreen}
options={{
tabBarLabel: 'Discover',
headerShown: false
}}
/>
<Tab.Screen
name="Library"
component={LibraryScreen}
options={{
tabBarLabel: 'Library',
headerShown: false
}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{
tabBarLabel: 'Settings',
headerShown: false
}}
/>
</Tab.Navigator>
</View>
);
};
@ -569,7 +655,7 @@ const AppNavigator = () => {
const isDarkMode = true;
return (
<>
<SafeAreaProvider>
<StatusBar
translucent
backgroundColor="transparent"
@ -579,7 +665,12 @@ const AppNavigator = () => {
<Stack.Navigator
screenOptions={{
headerShown: false,
animation: Platform.OS === 'android' ? 'fade_from_bottom' : 'default',
// Disable animations for smoother transitions
animation: 'none',
// Ensure content is not popping in and out
contentStyle: {
backgroundColor: colors.darkBackground,
}
}}
>
<Stack.Screen
@ -734,7 +825,7 @@ const AppNavigator = () => {
/>
</Stack.Navigator>
</PaperProvider>
</>
</SafeAreaProvider>
);
};

View file

@ -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];
}

View file

@ -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 (
<SafeAreaView style={[styles.container, { backgroundColor: isDarkMode ? colors.background : colors.white }]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Addons</Text>
<TouchableOpacity onPress={toggleReorderMode} style={styles.reorderButton}>
<MaterialIcons
name={reorderMode ? "done" : "reorder"}
size={24}
color={colors.primary}
/>
</TouchableOpacity>
</View>
<View style={styles.statsContainer}>
<StatsCard value={addons.length} label="Installed" />
<StatsCard value={catalogCount} label="Catalogs" />
</View>
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
placeholder="Enter addon URL..."
placeholderTextColor={colors.mediumGray}
value={addonUrl}
onChangeText={setAddonUrl}
/>
<TouchableOpacity
style={[styles.addButton, !addonUrl && styles.disabledButton]}
onPress={() => handleAddAddon()}
disabled={!addonUrl || installing}
style={styles.backButton}
onPress={() => navigation.goBack()}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<MaterialIcons name="add" size={24} color={colors.white} />
)}
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
<View style={styles.headerActions}>
{/* Reorder Mode Toggle Button */}
<TouchableOpacity
style={[styles.headerButton, reorderMode && styles.activeHeaderButton]}
onPress={toggleReorderMode}
>
<MaterialIcons
name="swap-vert"
size={24}
color={reorderMode ? colors.primary : colors.white}
/>
</TouchableOpacity>
{/* Refresh Button */}
<TouchableOpacity
style={styles.headerButton}
onPress={refreshAddons}
disabled={loading}
>
<MaterialIcons
name="refresh"
size={24}
color={loading ? colors.mediumGray : colors.white}
/>
</TouchableOpacity>
</View>
</View>
<Text style={styles.headerTitle}>
Addons
{reorderMode && <Text style={styles.reorderModeText}> (Reorder Mode)</Text>}
</Text>
{reorderMode && (
<View style={styles.reorderInfoBanner}>
<MaterialIcons name="info-outline" size={18} color={colors.primary} />
<Text style={styles.reorderInfoText}>
Addons at the top have higher priority when loading content
</Text>
</View>
)}
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading addons...</Text>
</View>
) : (
<FlatList
data={addons}
renderItem={renderAddonItem}
keyExtractor={(item) => item.id}
style={styles.list}
contentContainerStyle={styles.listContent}
ListEmptyComponent={() => (
<View style={styles.emptyContainer}>
<MaterialIcons name="extension-off" size={48} color={colors.mediumGray} />
<Text style={styles.emptyText}>No addons installed</Text>
<Text style={styles.emptySubtext}>Add an addon using the URL field above</Text>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
{/* Overview Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>OVERVIEW</Text>
<View style={styles.statsContainer}>
<StatsCard value={addons.length} label="Addons" />
<View style={styles.statsDivider} />
<StatsCard value={addons.length} label="Active" />
<View style={styles.statsDivider} />
<StatsCard value={catalogCount} label="Catalogs" />
</View>
</View>
{/* Hide Add Addon Section in reorder mode */}
{!reorderMode && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>ADD NEW ADDON</Text>
<View style={styles.addAddonContainer}>
<TextInput
style={styles.addonInput}
placeholder="Addon URL"
placeholderTextColor={colors.mediumGray}
value={addonUrl}
onChangeText={setAddonUrl}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={[styles.addButton, {opacity: installing || !addonUrl ? 0.6 : 1}]}
onPress={() => handleAddAddon()}
disabled={installing || !addonUrl}
>
<Text style={styles.addButtonText}>
{installing ? 'Loading...' : 'Add Addon'}
</Text>
</TouchableOpacity>
</View>
</View>
)}
/>
{/* Installed Addons Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{reorderMode ? "DRAG ADDONS TO REORDER" : "INSTALLED ADDONS"}
</Text>
<View style={styles.addonList}>
{addons.length === 0 ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="extension-off" size={32} color={colors.mediumGray} />
<Text style={styles.emptyText}>No addons installed</Text>
</View>
) : (
addons.map((addon, index) => (
<View
key={addon.id}
style={{ marginBottom: index === addons.length - 1 ? 32 : 0 }}
>
{renderAddonItem({ item: addon, index })}
</View>
))
)}
</View>
</View>
{/* Separator */}
<View style={styles.sectionSeparator} />
{/* Community Addons Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>COMMUNITY ADDONS</Text>
<View style={styles.addonList}>
{communityLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : communityError ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="error-outline" size={32} color={colors.error} />
<Text style={styles.emptyText}>{communityError}</Text>
</View>
) : communityAddons.length === 0 ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="extension-off" size={32} color={colors.mediumGray} />
<Text style={styles.emptyText}>No community addons available</Text>
</View>
) : (
communityAddons.map((item, index) => (
<View
key={item.transportUrl}
style={{ marginBottom: index === communityAddons.length - 1 ? 32 : 16 }}
>
<View style={styles.addonItem}>
<View style={styles.addonHeader}>
{item.manifest.logo ? (
<ExpoImage
source={{ uri: item.manifest.logo }}
style={styles.addonIcon}
contentFit="contain"
/>
) : (
<View style={styles.addonIconPlaceholder}>
<MaterialIcons name="extension" size={22} color={colors.mediumGray} />
</View>
)}
<View style={styles.addonTitleContainer}>
<Text style={styles.addonName}>{item.manifest.name}</Text>
<View style={styles.addonMetaContainer}>
<Text style={styles.addonVersion}>v{item.manifest.version || 'N/A'}</Text>
<Text style={styles.addonDot}></Text>
<Text style={styles.addonCategory}>
{item.manifest.types && item.manifest.types.length > 0
? item.manifest.types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
: 'General'}
</Text>
</View>
</View>
<View style={styles.addonActions}>
{item.manifest.behaviorHints?.configurable && (
<TouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(item.manifest, item.transportUrl)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.installButton, installing && { opacity: 0.6 }]}
onPress={() => handleAddAddon(item.transportUrl)}
disabled={installing}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</View>
</View>
<Text style={styles.addonDescription}>
{item.manifest.description
? (item.manifest.description.length > 100
? item.manifest.description.substring(0, 100) + '...'
: item.manifest.description)
: 'No description provided.'}
</Text>
</View>
</View>
))
)}
</View>
</View>
</ScrollView>
)}
{/* Community Addons Section */}
<View style={styles.communitySection}>
<Text style={styles.sectionTitle}>Community Addons</Text>
{communityLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.loadingText}>Loading community addons...</Text>
</View>
) : communityError ? (
<View style={styles.errorContainer}>
<MaterialIcons name="error-outline" size={24} color={colors.error} />
<Text style={styles.errorText}>{communityError}</Text>
</View>
) : (
<FlatList
data={communityAddons}
renderItem={renderCommunityAddonItem}
keyExtractor={(item) => item.manifest.id}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.communityList}
contentContainerStyle={styles.communityListContent}
/>
)}
</View>
{/* Confirmation Modal */}
{/* Addon Details Confirmation Modal */}
<Modal
visible={showConfirmModal}
transparent
animationType="fade"
onRequestClose={() => setShowConfirmModal(false)}
onRequestClose={() => {
setShowConfirmModal(false);
setAddonDetails(null);
}}
>
<BlurView intensity={100} style={styles.modalOverlay}>
<BlurView intensity={80} style={styles.modalContainer} tint="dark">
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Install Addon</Text>
{addonDetails && (
<>
<Text style={styles.modalAddonName}>{addonDetails.name}</Text>
<Text style={styles.modalAddonDesc}>{addonDetails.description}</Text>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Install Addon</Text>
<TouchableOpacity
onPress={() => {
setShowConfirmModal(false);
setAddonDetails(null);
}}
>
<MaterialIcons name="close" size={24} color={colors.white} />
</TouchableOpacity>
</View>
<ScrollView
style={styles.modalScrollContent}
showsVerticalScrollIndicator={false}
bounces={true}
>
<View style={styles.addonDetailHeader}>
{/* @ts-ignore */}
{addonDetails.logo ? (
<ExpoImage
source={{ uri: addonDetails.logo }}
style={styles.addonLogo}
contentFit="contain"
/>
) : (
<View style={styles.addonLogoPlaceholder}>
<MaterialIcons name="extension" size={40} color={colors.mediumGray} />
</View>
)}
<Text style={styles.addonDetailName}>{addonDetails.name}</Text>
<Text style={styles.addonDetailVersion}>v{addonDetails.version || '1.0.0'}</Text>
</View>
<View style={styles.addonDetailSection}>
<Text style={styles.addonDetailSectionTitle}>Description</Text>
<Text style={styles.addonDetailDescription}>
{addonDetails.description || 'No description available'}
</Text>
</View>
{addonDetails.types && addonDetails.types.length > 0 && (
<View style={styles.addonDetailSection}>
<Text style={styles.addonDetailSectionTitle}>Supported Types</Text>
<View style={styles.addonDetailChips}>
{addonDetails.types.map((type, index) => (
<View key={index} style={styles.addonDetailChip}>
<Text style={styles.addonDetailChipText}>{type}</Text>
</View>
))}
</View>
</View>
)}
{addonDetails.catalogs && addonDetails.catalogs.length > 0 && (
<View style={styles.addonDetailSection}>
<Text style={styles.addonDetailSectionTitle}>Catalogs</Text>
<View style={styles.addonDetailChips}>
{addonDetails.catalogs.map((catalog, index) => (
<View key={index} style={styles.addonDetailChip}>
<Text style={styles.addonDetailChipText}>
{catalog.type} - {catalog.id}
</Text>
</View>
))}
</View>
</View>
)}
</ScrollView>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalButton, styles.cancelButton]}
onPress={() => {
setShowConfirmModal(false);
setAddonDetails(null);
}}
>
<Text style={styles.modalButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, styles.installButton]}
onPress={confirmInstallAddon}
disabled={installing}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<Text style={styles.modalButtonText}>Install</Text>
)}
</TouchableOpacity>
</View>
</>
)}
<View style={styles.modalButtons}>
<TouchableOpacity
style={[styles.modalButton, styles.cancelButton]}
onPress={() => setShowConfirmModal(false)}
>
<Text style={styles.modalButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, styles.confirmButton]}
onPress={confirmInstallAddon}
>
<Text style={styles.modalButtonText}>Install</Text>
</TouchableOpacity>
</View>
</View>
</BlurView>
</Modal>
@ -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;

View file

@ -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<StreamingContent[]>([]);
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 (
<SafeAreaView style={styles.container}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
translucent
/>
<View style={styles.container}>
{/* Fixed position header background to prevent shifts */}
<View style={[styles.headerBackground, { height: headerHeight }]} />
<View style={{ flex: 1 }}>
{/* Header Section */}
<View style={styles.header}>
{/* Header Section with proper top spacing */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<View style={styles.headerContent}>
<Text style={styles.headerTitle}>Discover</Text>
<TouchableOpacity
@ -607,66 +641,69 @@ const DiscoverScreen = () => {
</View>
</View>
{/* Categories Section */}
<View style={styles.categoryContainer}>
<View style={styles.categoriesContent}>
{CATEGORIES.map((category) => (
<CategoryButton
key={category.id}
category={category}
isSelected={selectedCategory.id === category.id}
onPress={() => handleCategoryPress(category)}
/>
))}
{/* Rest of the content */}
<View style={styles.contentContainer}>
{/* Categories Section */}
<View style={styles.categoryContainer}>
<View style={styles.categoriesContent}>
{CATEGORIES.map((category) => (
<CategoryButton
key={category.id}
category={category}
isSelected={selectedCategory.id === category.id}
onPress={() => handleCategoryPress(category)}
/>
))}
</View>
</View>
{/* Genres Section */}
<View style={styles.genreContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.genresScrollView}
decelerationRate="fast"
snapToInterval={10}
>
{COMMON_GENRES.map(genre => (
<GenreButton
key={genre}
genre={genre}
isSelected={selectedGenre === genre}
onPress={() => handleGenrePress(genre)}
/>
))}
</ScrollView>
</View>
{/* Content Section */}
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : catalogs.length > 0 ? (
<FlatList
data={catalogs}
renderItem={renderCatalogItem}
keyExtractor={catalogKeyExtractor}
contentContainerStyle={styles.catalogsContainer}
showsVerticalScrollIndicator={false}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
) : (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'}
</Text>
</View>
)}
</View>
{/* Genres Section */}
<View style={styles.genreContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.genresScrollView}
decelerationRate="fast"
snapToInterval={10}
>
{COMMON_GENRES.map(genre => (
<GenreButton
key={genre}
genre={genre}
isSelected={selectedGenre === genre}
onPress={() => handleGenrePress(genre)}
/>
))}
</ScrollView>
</View>
{/* Content Section */}
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : catalogs.length > 0 ? (
<FlatList
data={catalogs}
renderItem={renderCatalogItem}
keyExtractor={catalogKeyExtractor}
contentContainerStyle={styles.catalogsContainer}
showsVerticalScrollIndicator={false}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
) : (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'}
</Text>
</View>
)}
</View>
</SafeAreaView>
</View>
);
};

View file

@ -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<boolean>;
}
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<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark';
const continueWatchingRef = useRef<{ refresh: () => Promise<void> }>(null);
const continueWatchingRef = useRef<ContinueWatchingRef>(null);
const { settings } = useSettings();
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
<View style={[styles.container]}>
<SafeAreaView style={[styles.container]}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
@ -710,7 +732,10 @@ const HomeScreen = () => {
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 = () => {
<ThisWeekSection />
</Animated.View>
{hasContinueWatching && (
<Animated.View entering={FadeIn.duration(400).delay(250)}>
<ContinueWatchingSection ref={continueWatchingRef} />
</Animated.View>
)}
{catalogs.length > 0 ? (
catalogs.map((catalog, index) => (
@ -747,7 +774,7 @@ const HomeScreen = () => {
)
)}
</ScrollView>
</View>
</SafeAreaView>
);
};
@ -770,7 +797,7 @@ const styles = StyleSheet.create<any>({
featuredContainer: {
width: '100%',
height: height * 0.6,
marginTop: Platform.OS === 'ios' ? 85 : 75,
marginTop: Platform.OS === 'ios' ? 0 : 0,
marginBottom: 8,
position: 'relative',
},

View file

@ -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<LibraryItem[]>([]);
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 (
<SafeAreaView style={styles.container}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
translucent
/>
<View style={styles.container}>
{/* Fixed position header background to prevent shifts */}
<View style={[styles.headerBackground, { height: headerHeight }]} />
<View style={styles.header}>
<View style={styles.headerContent}>
<Text style={styles.headerTitle}>Library</Text>
<View style={{ flex: 1 }}>
{/* Header Section with proper top spacing */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<View style={styles.headerContent}>
<Text style={styles.headerTitle}>Library</Text>
</View>
</View>
{/* Content Container */}
<View style={styles.contentContainer}>
<View style={styles.filtersContainer}>
{renderFilter('all', 'All', 'apps')}
{renderFilter('movies', 'Movies', 'movie')}
{renderFilter('series', 'TV Shows', 'live-tv')}
</View>
{loading ? (
<SkeletonLoader />
) : filteredItems.length === 0 ? (
<View style={styles.emptyContainer}>
<MaterialIcons
name="video-library"
size={80}
color={colors.mediumGray}
style={{ opacity: 0.7 }}
/>
<Text style={styles.emptyText}>Your library is empty</Text>
<Text style={styles.emptySubtext}>
Add content to your library to keep track of what you're watching
</Text>
<TouchableOpacity
style={styles.exploreButton}
onPress={() => navigation.navigate('Discover')}
activeOpacity={0.7}
>
<Text style={styles.exploreButtonText}>Explore Content</Text>
</TouchableOpacity>
</View>
) : (
<FlatList
data={filteredItems}
renderItem={renderItem}
keyExtractor={item => item.id}
numColumns={2}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
columnWrapperStyle={styles.columnWrapper}
initialNumToRender={6}
maxToRenderPerBatch={6}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
)}
</View>
</View>
<View style={styles.filtersContainer}>
{renderFilter('all', 'All', 'apps')}
{renderFilter('movies', 'Movies', 'movie')}
{renderFilter('series', 'TV Shows', 'live-tv')}
</View>
{loading ? (
<SkeletonLoader />
) : filteredItems.length === 0 ? (
<View style={styles.emptyContainer}>
<MaterialIcons
name="video-library"
size={80}
color={colors.mediumGray}
style={{ opacity: 0.7 }}
/>
<Text style={styles.emptyText}>Your library is empty</Text>
<Text style={styles.emptySubtext}>
Add content to your library to keep track of what you're watching
</Text>
<TouchableOpacity
style={styles.exploreButton}
onPress={() => navigation.navigate('Discover')}
activeOpacity={0.7}
>
<Text style={styles.exploreButtonText}>Explore Content</Text>
</TouchableOpacity>
</View>
) : (
<FlatList
data={filteredItems}
renderItem={renderItem}
keyExtractor={item => item.id}
numColumns={2}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
columnWrapperStyle={styles.columnWrapper}
initialNumToRender={6}
maxToRenderPerBatch={6}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
)}
</SafeAreaView>
</View>
);
};
@ -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',

View file

@ -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<RootStackParamList>;
playButtonText: string;
animatedStyle: any;
}) => (
<Animated.View style={[styles.actionButtons, animatedStyle]}>
<TouchableOpacity
style={[styles.actionButton, styles.playButton]}
onPress={handleShowStreams}
>
<MaterialIcons
name={playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow"}
size={24}
color="#000"
/>
<Text style={styles.playButtonText}>
{playButtonText}
</Text>
</TouchableOpacity>
}) => {
// Add wrapper for play button with haptic feedback
const handlePlay = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
handleShowStreams();
};
<TouchableOpacity
style={[styles.actionButton, styles.infoButton]}
onPress={toggleLibrary}
>
<MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={24}
color="#fff"
/>
<Text style={styles.infoButtonText}>
{inLibrary ? 'Saved' : 'Save'}
</Text>
</TouchableOpacity>
{type === 'series' && (
return (
<Animated.View style={[styles.actionButtons, animatedStyle]}>
<TouchableOpacity
style={[styles.iconButton]}
onPress={async () => {
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}
>
<MaterialIcons name="assessment" size={24} color="#fff" />
<MaterialIcons
name={playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow"}
size={24}
color="#000"
/>
<Text style={styles.playButtonText}>
{playButtonText}
</Text>
</TouchableOpacity>
)}
</Animated.View>
));
<TouchableOpacity
style={[styles.actionButton, styles.infoButton]}
onPress={toggleLibrary}
>
<MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={24}
color="#fff"
/>
<Text style={styles.infoButtonText}>
{inLibrary ? 'Saved' : 'Save'}
</Text>
</TouchableOpacity>
{type === 'series' && (
<TouchableOpacity
style={[styles.iconButton]}
onPress={async () => {
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');
}
}}
>
<MaterialIcons name="assessment" size={24} color="#fff" />
</TouchableOpacity>
)}
</Animated.View>
);
});
// Memoized WatchProgress Component
const WatchProgressDisplay = React.memo(({
@ -220,10 +234,14 @@ const MetadataScreen = () => {
// Get genres from context
const { genreMap, loadingGenres } = useGenres();
const contentRef = useRef<ScrollView>(null);
// Update the ref type to be compatible with Animated.ScrollView
const contentRef = useRef<Animated.ScrollView>(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 (
<SafeAreaView
@ -931,23 +1059,112 @@ const MetadataScreen = () => {
animated={true}
/>
<Animated.View style={containerAnimatedStyle}>
<ScrollView
{/* Floating Header */}
<Animated.View style={[styles.floatingHeader, headerAnimatedStyle]}>
{Platform.OS === 'ios' ? (
<ExpoBlurView
intensity={50}
tint="dark"
style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}
>
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
<TouchableOpacity
style={styles.backButton}
onPress={handleBack}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons name="arrow-back" size={24} color={colors.highEmphasis} />
</TouchableOpacity>
<View style={styles.headerTitleContainer}>
{metadata.logo ? (
<Image
source={{ uri: metadata.logo }}
style={styles.floatingHeaderLogo}
contentFit="contain"
transition={150}
/>
) : (
<Text style={styles.floatingHeaderTitle} numberOfLines={1}>{metadata.name}</Text>
)}
</View>
<TouchableOpacity
style={styles.headerActionButton}
onPress={handleToggleLibrary}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={22}
color={colors.highEmphasis}
/>
</TouchableOpacity>
</Animated.View>
</ExpoBlurView>
) : (
<View style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}>
<CommunityBlurView
style={styles.absoluteFill}
blurType="dark"
blurAmount={15}
reducedTransparencyFallbackColor="rgba(20, 20, 20, 0.9)"
/>
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
<TouchableOpacity
style={styles.backButton}
onPress={handleBack}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons name="arrow-back" size={24} color={colors.highEmphasis} />
</TouchableOpacity>
<View style={styles.headerTitleContainer}>
{metadata.logo ? (
<Image
source={{ uri: metadata.logo }}
style={styles.floatingHeaderLogo}
contentFit="contain"
transition={150}
/>
) : (
<Text style={styles.floatingHeaderTitle} numberOfLines={1}>{metadata.name}</Text>
)}
</View>
<TouchableOpacity
style={styles.headerActionButton}
onPress={handleToggleLibrary}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialIcons
name={inLibrary ? 'bookmark' : 'bookmark-border'}
size={22}
color={colors.highEmphasis}
/>
</TouchableOpacity>
</Animated.View>
</View>
)}
{Platform.OS === 'ios' && <View style={styles.headerBottomBorder} />}
</Animated.View>
<Animated.ScrollView
ref={contentRef}
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={(e) => {
// setLastScrollTop(e.nativeEvent.contentOffset.y); // Remove unused onScroll handler logic
}}
scrollEventThrottle={16}
onScroll={scrollHandler}
scrollEventThrottle={16} // Back to standard value
>
{/* Hero Section */}
<Animated.View style={heroAnimatedStyle}>
<ImageBackground
source={{ uri: metadata.banner || metadata.poster }}
style={styles.heroSection}
imageStyle={styles.heroImage}
resizeMode="cover"
>
<View style={styles.heroSection}>
{/* Use Animated.Image directly instead of ImageBackground with imageStyle */}
<Animated.Image
source={{ uri: metadata.banner || metadata.poster }}
style={[styles.absoluteFill, parallaxImageStyle]}
resizeMode="cover"
/>
<LinearGradient
colors={[
`${colors.darkBackground}00`,
@ -995,7 +1212,7 @@ const MetadataScreen = () => {
{/* Action Buttons */}
<ActionButtons
handleShowStreams={handleShowStreams}
toggleLibrary={toggleLibrary}
toggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
type={type as 'movie' | 'series'}
id={id}
@ -1005,7 +1222,7 @@ const MetadataScreen = () => {
/>
</View>
</LinearGradient>
</ImageBackground>
</View>
</Animated.View>
{/* 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 = () => {
<MovieContent metadata={metadata} />
)}
</Animated.View>
</ScrollView>
</Animated.ScrollView>
</Animated.View>
</SafeAreaView>
);
@ -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;

View file

@ -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<NavigationProp<RootStackParamList>>();
const { lastUpdate } = useCatalogContext();
const { isAuthenticated, userProfile } = useTraktContext();
const insets = useSafeAreaInsets();
// States for dynamic content
const [addonCount, setAddonCount] = useState<number>(0);
@ -132,6 +134,23 @@ const SettingsScreen: React.FC = () => {
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
const [discoverDataSource, setDiscoverDataSource] = useState<DataSource>(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 (
<SafeAreaView style={[
<View style={[
styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.header}>
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Settings
</Text>
<TouchableOpacity onPress={handleResetSettings} style={styles.resetButton}>
<Text style={[styles.resetButtonText, {color: colors.primary}]}>Reset</Text>
</TouchableOpacity>
</View>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<SettingsCard isDarkMode={isDarkMode} title="User & Account">
<SettingItem
title="Trakt"
description={isAuthenticated ? `Connected as ${userProfile?.username || 'User'}` : "Not Connected"}
icon="person"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('TraktSettings')}
isLast={true}
/>
</SettingsCard>
<SettingsCard isDarkMode={isDarkMode} title="Features">
<SettingItem
title="Calendar"
description="Manage your show calendar settings"
icon="calendar-today"
renderControl={ChevronRight}
onPress={() => navigation.navigate('Calendar')}
isDarkMode={isDarkMode}
/>
<SettingItem
title="Notifications"
description="Configure episode notifications and reminders"
icon="notifications"
renderControl={ChevronRight}
onPress={() => navigation.navigate('NotificationSettings')}
isDarkMode={isDarkMode}
isLast={true}
/>
</SettingsCard>
<SettingsCard isDarkMode={isDarkMode} title="Content">
<SettingItem
title="Addons"
description="Manage your installed addons"
icon="extension"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('Addons')}
badge={addonCount}
/>
<SettingItem
title="Catalogs"
description="Configure content sources"
icon="view-list"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('CatalogSettings')}
badge={catalogCount}
/>
<SettingItem
title="Home Screen"
description="Customize layout and content"
icon="home"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('HomeScreenSettings')}
/>
<SettingItem
title="Ratings Source"
description={mdblistKeySet ? "MDBList API Configured" : "Configure MDBList API"}
icon="info-outline"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('MDBListSettings')}
/>
<SettingItem
title="TMDB"
description="API & Metadata Settings"
icon="movie-filter"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('TMDBSettings')}
isLast={true}
/>
</SettingsCard>
<SettingsCard isDarkMode={isDarkMode} title="Playback">
<SettingItem
title="Video Player"
description={Platform.OS === 'ios'
? (settings.preferredPlayer === 'internal'
? 'Built-in Player'
: settings.preferredPlayer
? settings.preferredPlayer.toUpperCase()
: 'Built-in Player')
: (settings.useExternalPlayer ? 'External Player' : 'Built-in Player')
}
icon="play-arrow"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('PlayerSettings')}
isLast={true}
/>
</SettingsCard>
<SettingsCard isDarkMode={isDarkMode} title="Discover">
<SettingItem
title="Content Source"
description="Choose where to get content for the Discover screen"
icon="explore"
isDarkMode={isDarkMode}
renderControl={() => (
<View style={styles.selectorContainer}>
<TouchableOpacity
style={[
styles.selectorButton,
discoverDataSource === DataSource.STREMIO_ADDONS && styles.selectorButtonActive
]}
onPress={() => handleDiscoverDataSourceChange(DataSource.STREMIO_ADDONS)}
>
<Text style={[
styles.selectorText,
discoverDataSource === DataSource.STREMIO_ADDONS && styles.selectorTextActive
]}>Addons</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.selectorButton,
discoverDataSource === DataSource.TMDB && styles.selectorButtonActive
]}
onPress={() => handleDiscoverDataSourceChange(DataSource.TMDB)}
>
<Text style={[
styles.selectorText,
discoverDataSource === DataSource.TMDB && styles.selectorTextActive
]}>TMDB</Text>
</TouchableOpacity>
</View>
)}
/>
</SettingsCard>
<View style={styles.versionContainer}>
<Text style={[styles.versionText, {color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark}]}>
Version 1.0.0
{/* Fixed position header background to prevent shifts */}
<View style={[
styles.headerBackground,
{ height: headerHeight, backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
]} />
<View style={{ flex: 1 }}>
{/* Header Section with proper top spacing */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Settings
</Text>
<TouchableOpacity onPress={handleResetSettings} style={styles.resetButton}>
<Text style={[styles.resetButtonText, {color: colors.primary}]}>Reset</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
{/* Content Container */}
<View style={styles.contentContainer}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<SettingsCard isDarkMode={isDarkMode} title="User & Account">
<SettingItem
title="Trakt"
description={isAuthenticated ? `Connected as ${userProfile?.username || 'User'}` : "Not Connected"}
icon="person"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('TraktSettings')}
isLast={true}
/>
</SettingsCard>
<SettingsCard isDarkMode={isDarkMode} title="Features">
<SettingItem
title="Calendar"
description="Manage your show calendar settings"
icon="calendar-today"
renderControl={ChevronRight}
onPress={() => navigation.navigate('Calendar')}
isDarkMode={isDarkMode}
/>
<SettingItem
title="Notifications"
description="Configure episode notifications and reminders"
icon="notifications"
renderControl={ChevronRight}
onPress={() => navigation.navigate('NotificationSettings')}
isDarkMode={isDarkMode}
isLast={true}
/>
</SettingsCard>
<SettingsCard isDarkMode={isDarkMode} title="Content">
<SettingItem
title="Addons"
description="Manage your installed addons"
icon="extension"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('Addons')}
badge={addonCount}
/>
<SettingItem
title="Catalogs"
description="Configure content sources"
icon="view-list"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('CatalogSettings')}
badge={catalogCount}
/>
<SettingItem
title="Home Screen"
description="Customize layout and content"
icon="home"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('HomeScreenSettings')}
/>
<SettingItem
title="Ratings Source"
description={mdblistKeySet ? "MDBList API Configured" : "Configure MDBList API"}
icon="info-outline"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('MDBListSettings')}
/>
<SettingItem
title="TMDB"
description="API & Metadata Settings"
icon="movie-filter"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('TMDBSettings')}
isLast={true}
/>
</SettingsCard>
<SettingsCard isDarkMode={isDarkMode} title="Playback">
<SettingItem
title="Video Player"
description={Platform.OS === 'ios'
? (settings.preferredPlayer === 'internal'
? 'Built-in Player'
: settings.preferredPlayer
? settings.preferredPlayer.toUpperCase()
: 'Built-in Player')
: (settings.useExternalPlayer ? 'External Player' : 'Built-in Player')
}
icon="play-arrow"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('PlayerSettings')}
isLast={true}
/>
</SettingsCard>
<SettingsCard isDarkMode={isDarkMode} title="Discover">
<SettingItem
title="Content Source"
description="Choose where to get content for the Discover screen"
icon="explore"
isDarkMode={isDarkMode}
renderControl={() => (
<View style={styles.selectorContainer}>
<TouchableOpacity
style={[
styles.selectorButton,
discoverDataSource === DataSource.STREMIO_ADDONS && styles.selectorButtonActive
]}
onPress={() => handleDiscoverDataSourceChange(DataSource.STREMIO_ADDONS)}
>
<Text style={[
styles.selectorText,
discoverDataSource === DataSource.STREMIO_ADDONS && styles.selectorTextActive
]}>Addons</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.selectorButton,
discoverDataSource === DataSource.TMDB && styles.selectorButtonActive
]}
onPress={() => handleDiscoverDataSourceChange(DataSource.TMDB)}
>
<Text style={[
styles.selectorText,
discoverDataSource === DataSource.TMDB && styles.selectorTextActive
]}>TMDB</Text>
</TouchableOpacity>
</View>
)}
/>
</SettingsCard>
<View style={styles.versionContainer}>
<Text style={[styles.versionText, {color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark}]}>
Version 1.0.0
</Text>
</View>
</ScrollView>
</View>
</View>
</View>
);
};
@ -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

View file

@ -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(() => {

View file

@ -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<TraktUser | null>(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 ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text style={styles.buttonText}>

View file

@ -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 (
<View style={styles.container}>
<TouchableOpacity
@ -659,6 +665,7 @@ const VideoPlayer: React.FC = () => {
onTextTracks={onTextTracks}
onBuffer={onBuffer}
onLoadStart={onLoadStart}
onError={handleError}
/>
{/* Slider Container with buffer indicator */}

View file

@ -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;
});
}

View file

@ -125,7 +125,7 @@ export class TraktService {
/**
* Exchange the authorization code for an access token
*/
public async exchangeCodeForToken(code: string): Promise<boolean> {
public async exchangeCodeForToken(code: string, codeVerifier: string): Promise<boolean> {
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}`);
}