Ios #3
27 changed files with 1985 additions and 1150 deletions
10
App.tsx
10
App.tsx
|
|
@ -14,6 +14,7 @@ import { NavigationContainer } from '@react-navigation/native';
|
|||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Provider as PaperProvider } from 'react-native-paper';
|
||||
import { enableScreens } from 'react-native-screens';
|
||||
import AppNavigator, {
|
||||
CustomNavigationDarkTheme,
|
||||
CustomDarkTheme
|
||||
|
|
@ -23,6 +24,9 @@ import { CatalogProvider } from './src/contexts/CatalogContext';
|
|||
import { GenreProvider } from './src/contexts/GenreContext';
|
||||
import { TraktProvider } from './src/contexts/TraktContext';
|
||||
|
||||
// This fixes many navigation layout issues by using native screen containers
|
||||
enableScreens(true);
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
// Always use dark mode
|
||||
const isDarkMode = true;
|
||||
|
|
@ -33,7 +37,11 @@ function App(): React.JSX.Element {
|
|||
<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
129
README.md
|
|
@ -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** |
|
||||
|  | |  |
|
||||
|
||||
## 🚀 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!*
|
||||
22
app.json
22
app.json
|
|
@ -6,6 +6,7 @@
|
|||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"scheme": "stremioexpo",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
|
|
@ -17,9 +18,26 @@
|
|||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
}
|
||||
},
|
||||
"NSBonjourServices": [
|
||||
"_http._tcp"
|
||||
],
|
||||
"NSLocalNetworkUsageDescription": "App uses the local network to discover and connect to devices.",
|
||||
"NSMicrophoneUsageDescription": "This app does not require microphone access.",
|
||||
"UIBackgroundModes": ["audio"],
|
||||
"LSSupportsOpeningDocumentsInPlace": true,
|
||||
"UIFileSharingEnabled": true
|
||||
},
|
||||
"bundleIdentifier": "com.nuvio.app"
|
||||
"bundleIdentifier": "com.nuvio.app",
|
||||
"associatedDomains": [],
|
||||
"documentTypes": [
|
||||
{
|
||||
"name": "Matroska Video",
|
||||
"role": "viewer",
|
||||
"utis": ["org.matroska.mkv"],
|
||||
"extensions": ["mkv"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
|
|
|
|||
BIN
assets/WhatsApp Image 2025-04-22 at 18.46.30_ed1e0602.jpg
Normal file
BIN
assets/WhatsApp Image 2025-04-22 at 18.46.30_ed1e0602.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
BIN
assets/WhatsApp Image 2025-04-24 at 09.47.46_1d72ceb0.jpg
Normal file
BIN
assets/WhatsApp Image 2025-04-24 at 09.47.46_1d72ceb0.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -6,7 +6,8 @@
|
|||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
"web": "expo start --web",
|
||||
"postinstall": "node patch-package.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~4.0.1",
|
||||
|
|
@ -25,11 +26,13 @@
|
|||
"@types/lodash": "^4.17.16",
|
||||
"@types/react-native-video": "^5.0.20",
|
||||
"axios": "^1.8.4",
|
||||
"base64-js": "^1.5.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "~52.0.43",
|
||||
"expo-auth-session": "^6.0.3",
|
||||
"expo-blur": "^14.0.3",
|
||||
"expo-dev-client": "~5.0.20",
|
||||
"expo-file-system": "^18.0.12",
|
||||
"expo-haptics": "~14.0.1",
|
||||
"expo-image": "~2.0.7",
|
||||
|
|
@ -40,7 +43,7 @@
|
|||
"expo-screen-orientation": "~8.0.4",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"expo-system-ui": "^4.0.9",
|
||||
"expo-web-browser": "^14.0.2",
|
||||
"expo-web-browser": "~14.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
|
|
@ -56,10 +59,10 @@
|
|||
"react-native-screens": "~4.4.0",
|
||||
"react-native-svg": "^15.11.2",
|
||||
"react-native-tab-view": "^4.0.10",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-video": "^6.12.0",
|
||||
"react-native-web": "~0.19.13",
|
||||
"subsrt": "^1.1.1",
|
||||
"expo-dev-client": "~5.0.20"
|
||||
"subsrt": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
|
|
|
|||
42
patch-package.js
Normal file
42
patch-package.js
Normal 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
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 |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
44
src/patches/react-native-video+6.12.0.patch
Normal file
44
src/patches/react-native-video+6.12.0.patch
Normal 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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue