Merge pull request #1 from nayifleo1/ios

Ios
This commit is contained in:
Nayif Noushad 2025-04-22 15:51:01 +05:30 committed by GitHub
commit 20d0fbc00b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 3985 additions and 2322 deletions

23
App.tsx
View file

@ -21,6 +21,7 @@ import AppNavigator, {
import 'react-native-reanimated';
import { CatalogProvider } from './src/contexts/CatalogContext';
import { GenreProvider } from './src/contexts/GenreContext';
import { TraktProvider } from './src/contexts/TraktContext';
function App(): React.JSX.Element {
// Always use dark mode
@ -30,16 +31,18 @@ function App(): React.JSX.Element {
<GestureHandlerRootView style={{ flex: 1 }}>
<GenreProvider>
<CatalogProvider>
<PaperProvider theme={CustomDarkTheme}>
<NavigationContainer theme={CustomNavigationDarkTheme}>
<View style={[styles.container, { backgroundColor: '#000000' }]}>
<StatusBar
style="light"
/>
<AppNavigator />
</View>
</NavigationContainer>
</PaperProvider>
<TraktProvider>
<PaperProvider theme={CustomDarkTheme}>
<NavigationContainer theme={CustomNavigationDarkTheme}>
<View style={[styles.container, { backgroundColor: '#000000' }]}>
<StatusBar
style="light"
/>
<AppNavigator />
</View>
</NavigationContainer>
</PaperProvider>
</TraktProvider>
</CatalogProvider>
</GenreProvider>
</GestureHandlerRootView>

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) [Year] [Copyright Holder Name]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

158
README.md
View file

@ -1,91 +1,119 @@
# 🎬 NUVIO Streaming App 🎬
# Nuvio - Streaming App
A modern, beautiful streaming application built with React Native and Expo.
Nuvio is an Open-Source cross-platform streaming application built with React Native and Expo, allowing users to browse, discover, and watch video content.
## ✨ Features
- 🚀 Fast, responsive UI with smooth animations
- 🎥 Stream movies and TV shows with ease
- 🔍 Powerful search functionality
- 📚 Organize your media in a personal library
- 📱 Cross-platform (iOS and Android)
- 🌙 Beautiful dark mode interface
- 🧩 Add-on system for extensibility
- 📅 Calendar for upcoming releases
- 📺 Video player with quality selection
* **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).
## 📱 Screenshots
## 📸 Screenshots
*Coming soon!*
| Home | Discover | Search |
| :----------------------------------------- | :----------------------------------------- | :--------------------------------------- |
| ![Home](src/assets/home.jpg) | ![Discover](src/assets/discover.jpg) | ![Search](src/assets/search.jpg) |
| **Metadata** | **Seasons & Episodes** | **Rating** |
| ![Metadata](src/assets/metadascreen.jpg) | ![Seasons](src/assets/seasonandepisode.jpg)| ![Rating](src/assets/ratingscreen.jpg) |
## 🛠️ Tech Stack
## 🚀 Tech Stack
- React Native
- Expo
- TypeScript
- React Navigation
- React Native Paper
- Expo Linear Gradient
- Expo Vector Icons
* **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
## 📋 Requirements
## 🛠️ Setup & Running
- Node.js 14+
- Expo CLI
- Android Studio (for Android development)
- Xcode (for iOS development, macOS only)
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).
## 🚀 Getting Started
2. **Clone the repository:**
```bash
git clone https://github.com/nayifleo1/NuvioExpo.git
cd nuvio
```
1. Clone the repository:
```bash
git clone https://github.com/nayifleo1/NuvioExpo.git
cd NuvioExpo
```
3. **Install dependencies:**
```bash
npm install
# or
yarn install
```
2. Install dependencies:
```bash
npm install
```
4. **Run the application:**
3. Start the development server:
```bash
npx expo start
```
* **For Expo Go (Development):**
```bash
npm start
# or
yarn start
```
Scan the QR code with the Expo Go app on your iOS or Android device.
4. Run on your preferred platform:
- Press `a` for Android
- Press `i` for iOS (requires macOS)
- Scan the QR code with Expo Go on your device
* **For Native Android Build/Emulator:**
```bash
npm run android
# or
yarn android
```
## 🌟 Key Components
- **Home Screen**: Discover trending and recommended content
- **Discover Screen**: Browse through categories and genres
- **Library Screen**: Access your saved and watched content
- **Add-ons Screen**: Manage streaming sources
- **Settings Screen**: Customize your experience
## 🔮 Future Plans
- ⚡ Performance optimizations
- 🔐 User authentication
- 💾 Offline viewing
- 📢 Push notifications for new content
- 🌐 Multi-language support
* **For Native iOS Build/Simulator:**
```bash
npm run ios
# or
yarn ios
```
## 🤝 Contributing
Contributions, issues, and feature requests are welcome!
Contributions are welcome! If you'd like to contribute, please follow these general steps:
## 📝 License
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).
This project is licensed under the MIT License - see the LICENSE file for details.
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
Special thanks to all the open-source libraries and tools that made this project possible.
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
Built with ❤️ by the NUVIO team
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.

16
android/.gitignore vendored
View file

@ -1,16 +0,0 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle

View file

@ -1,176 +0,0 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/
def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'org.webkit:android-jsc:+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'com.nuvio.app'
defaultConfig {
applicationId 'com.nuvio.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
}
}
packagingOptions {
jniLibs {
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${reactAndroidLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}

Binary file not shown.

View file

@ -1,14 +0,0 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:

View file

@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View file

@ -1,32 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="com.nuvio.app"/>
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -1,61 +0,0 @@
package com.nuvio.app
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
super.onCreate(null)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
}

View file

@ -1,57 +0,0 @@
package com.nuvio.app
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> {
val packages = PackageList(this).packages
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
return packages
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}
)
override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
SoLoader.init(this, OpenSourceMergedSoMapping)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
}
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

View file

@ -1,6 +0,0 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>

View file

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1 +0,0 @@
<resources/>

View file

@ -1,6 +0,0 @@
<resources>
<color name="splashscreen_background">#ffffff</color>
<color name="iconBackground">#ffffff</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#ffffff</color>
</resources>

View file

@ -1,6 +0,0 @@
<resources>
<string name="app_name">Nuvio</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="expo_system_ui_user_interface_style" translatable="false">light</string>
</resources>

View file

@ -1,17 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:textColor">@android:color/black</item>
<item name="android:editTextStyle">@style/ResetEditText</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#ffffff</item>
</style>
<style name="ResetEditText" parent="@android:style/Widget.EditText">
<item name="android:padding">0dp</item>
<item name="android:textColorHint">#c8c8c8</item>
<item name="android:textColor">@android:color/black</item>
</style>
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
</style>
</resources>

View file

@ -1,41 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext {
buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24')
compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35')
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
ndkVersion = "26.1.10909125"
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
apply plugin: "com.facebook.react.rootproject"
allprojects {
repositories {
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android'))
}
maven {
// Android JSC is installed from npm
url(new File(['node', '--print', "require.resolve('jsc-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), '../dist'))
}
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}

View file

@ -1,56 +0,0 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase)
expo.webp.enabled=true
# Enable animated webp support (~3.4 MB increase)
# Disabled by default because iOS doesn't support animated webp
expo.webp.animated=false
# Enable network inspector
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false

Binary file not shown.

View file

@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
android/gradlew vendored
View file

@ -1,252 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored
View file

@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -1,38 +0,0 @@
pluginManagement {
includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().toString())
}
plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
def command = [
'node',
'--no-warnings',
'--eval',
'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
'react-native-config',
'--json',
'--platform',
'android'
].toList()
ex.autolinkLibrariesFromCommand(command)
}
}
rootProject.name = 'Nuvio'
dependencyResolutionManagement {
versionCatalogs {
reactAndroidLibs {
from(files(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../gradle/libs.versions.toml")))
}
}
}
apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle");
useExpoModules()
include ':app'
includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile())

2
assets/trakt-logo.png Normal file
View file

@ -0,0 +1,2 @@
// This is a placeholder for a binary PNG file
// Replace this file with an actual Trakt logo image

30
ios/.gitignore vendored
View file

@ -1,30 +0,0 @@
# OSX
#
.DS_Store
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
project.xcworkspace
.xcode.env.local
# Bundle artifacts
*.jsbundle
# CocoaPods
/Pods/

View file

@ -1,11 +0,0 @@
# This `.xcode.env` file is versioned and is used to source the environment
# used when running script phases inside Xcode.
# To customize your local environment, you can create an `.xcode.env.local`
# file that is not versioned.
# NODE_BINARY variable contains the PATH to the node executable.
#
# Customize the NODE_BINARY variable here.
# For example, to use nvm with brew, add the following line
# . "$(brew --prefix nvm)/nvm.sh" --no-use
export NODE_BINARY=$(command -v node)

View file

@ -1,471 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 46;
objects = {
/* Begin PBXBuildFile section */
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
96905EF65AED1B983A6B3ABC /* libPods-Nuvio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Nuvio.a */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
53F59A3E794F4B87B221D4C3 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC1EB3A22DCB47A0A12A0CAF /* noop-file.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* Nuvio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Nuvio.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Nuvio/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Nuvio/AppDelegate.mm; sourceTree = "<group>"; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Nuvio/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Nuvio/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Nuvio/main.m; sourceTree = "<group>"; };
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Nuvio.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Nuvio.a"; sourceTree = BUILT_PRODUCTS_DIR; };
6C2E3173556A471DD304B334 /* Pods-Nuvio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.debug.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.debug.xcconfig"; sourceTree = "<group>"; };
7A4D352CD337FB3A3BF06240 /* Pods-Nuvio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.release.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.release.xcconfig"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Nuvio/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Nuvio/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
DC1EB3A22DCB47A0A12A0CAF /* noop-file.swift */ = {isa = PBXFileReference; name = "noop-file.swift"; path = "Nuvio/noop-file.swift"; sourceTree = "<group>"; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; };
DD9A8A1855A34F10923D3C37 /* Nuvio-Bridging-Header.h */ = {isa = PBXFileReference; name = "Nuvio-Bridging-Header.h"; path = "Nuvio/Nuvio-Bridging-Header.h"; sourceTree = "<group>"; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; explicitFileType = undefined; includeInIndex = 0; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
96905EF65AED1B983A6B3ABC /* libPods-Nuvio.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
13B07FAE1A68108700A75B9A /* Nuvio */ = {
isa = PBXGroup;
children = (
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FAF1A68108700A75B9A /* AppDelegate.h */,
13B07FB01A68108700A75B9A /* AppDelegate.mm */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
DC1EB3A22DCB47A0A12A0CAF /* noop-file.swift */,
DD9A8A1855A34F10923D3C37 /* Nuvio-Bridging-Header.h */,
);
name = Nuvio;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Nuvio.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
);
name = Libraries;
sourceTree = "<group>";
};
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
13B07FAE1A68108700A75B9A /* Nuvio */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
D65327D7A22EEC0BE12398D9 /* Pods */,
D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */,
);
indentWidth = 2;
sourceTree = "<group>";
tabWidth = 2;
usesTabs = 0;
};
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* Nuvio.app */,
);
name = Products;
sourceTree = "<group>";
};
92DBD88DE9BF7D494EA9DA96 /* Nuvio */ = {
isa = PBXGroup;
children = (
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */,
);
name = Nuvio;
sourceTree = "<group>";
};
BB2F792B24A3F905000567C9 /* Supporting */ = {
isa = PBXGroup;
children = (
BB2F792C24A3F905000567C9 /* Expo.plist */,
);
name = Supporting;
path = Nuvio/Supporting;
sourceTree = "<group>";
};
D65327D7A22EEC0BE12398D9 /* Pods */ = {
isa = PBXGroup;
children = (
6C2E3173556A471DD304B334 /* Pods-Nuvio.debug.xcconfig */,
7A4D352CD337FB3A3BF06240 /* Pods-Nuvio.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
92DBD88DE9BF7D494EA9DA96 /* Nuvio */,
);
name = ExpoModulesProviders;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13B07F861A680F5B00A75B9A /* Nuvio */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Nuvio" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
);
name = Nuvio;
productName = Nuvio;
productReference = 13B07F961A680F5B00A75B9A /* Nuvio.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1130;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Nuvio" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* Nuvio */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
8A293A5AD8A6486B86EDC6E7 /* Nuvio-Bridging-Header.h in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Bundle React Native code and images";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Nuvio-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Nuvio/Pods-Nuvio-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Nuvio/Pods-Nuvio-resources.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
13B07F871A680F5B00A75B9A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
53F59A3E794F4B87B221D4C3 /* noop-file.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-Nuvio.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
);
INFOPLIST_FILE = Nuvio/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
PRODUCT_NAME = "Nuvio";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
TARGETED_DEVICE_FAMILY = "1,2";
SWIFT_OBJC_BRIDGING_HEADER = Nuvio/Nuvio-Bridging-Header.h;
CODE_SIGN_ENTITLEMENTS = Nuvio/Nuvio.entitlements;
};
name = Debug;
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-Nuvio.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = Nuvio/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
PRODUCT_NAME = "Nuvio";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
TARGETED_DEVICE_FAMILY = "1,2";
SWIFT_OBJC_BRIDGING_HEADER = Nuvio/Nuvio-Bridging-Header.h;
CODE_SIGN_ENTITLEMENTS = Nuvio/Nuvio.entitlements;
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = "\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
};
name = Debug;
};
83CBBA211A601CBA00E9B192 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = "\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Nuvio" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Nuvio" */ = {
isa = XCConfigurationList;
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
}

View file

@ -1,88 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1130"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Nuvio.app"
BlueprintName = "Nuvio"
ReferencedContainer = "container:Nuvio.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "NuvioTests.xctest"
BlueprintName = "NuvioTests"
ReferencedContainer = "container:Nuvio.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Nuvio.app"
BlueprintName = "Nuvio"
ReferencedContainer = "container:Nuvio.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Nuvio.app"
BlueprintName = "Nuvio"
ReferencedContainer = "container:Nuvio.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -1,7 +0,0 @@
#import <RCTAppDelegate.h>
#import <UIKit/UIKit.h>
#import <Expo/Expo.h>
@interface AppDelegate : EXAppDelegateWrapper
@end

View file

@ -1,62 +0,0 @@
#import "AppDelegate.h"
#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
self.moduleName = @"main";
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
return [self bundleURL];
}
- (NSURL *)bundleURL
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"];
#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}
// Linking API
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options];
}
// Universal Links
- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler];
return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result;
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
return [super application:application didFailToRegisterForRemoteNotificationsWithError:error];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler];
}
@end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 907 KiB

View file

@ -1,14 +0,0 @@
{
"images": [
{
"filename": "App-Icon-1024x1024@1x.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

View file

@ -1,6 +0,0 @@
{
"info" : {
"version" : 1,
"author" : "expo"
}
}

View file

@ -1,20 +0,0 @@
{
"colors": [
{
"color": {
"components": {
"alpha": "1.000",
"blue": "1.00000000000000",
"green": "1.00000000000000",
"red": "1.00000000000000"
},
"color-space": "srgb"
},
"idiom": "universal"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

View file

@ -1,23 +0,0 @@
{
"images": [
{
"idiom": "universal",
"filename": "image.png",
"scale": "1x"
},
{
"idiom": "universal",
"filename": "image@2x.png",
"scale": "2x"
},
{
"idiom": "universal",
"filename": "image@3x.png",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

View file

@ -1,74 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nuvio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.nuvio.app</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Light</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View file

@ -1,3 +0,0 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>

View file

@ -1,44 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<scene sceneID="EXPO-SCENE-1">
<objects>
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
<view key="view" userInteractionEnabled="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="EXPO-ContainerView" userLabel="ContainerView">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLogo" image="SplashScreenLogo" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
<rect key="frame" x="0" y="0" width="414" height="736"/>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
<constraints>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="83fcb9b545b870ba44c24f0feeb116490c499c52"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="leading" secondItem="EXPO-ContainerView" secondAttribute="leading" id="61d16215e44b98e39d0a2c74fdbfaaa22601b12c"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="trailing" secondItem="EXPO-ContainerView" secondAttribute="trailing" id="f934da460e9ab5acae3ad9987d5b676a108796c1"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="bottom" secondItem="EXPO-ContainerView" secondAttribute="bottom" id="d6a0be88096b36fb132659aa90203d39139deda9"/>
</constraints>
<color key="backgroundColor" name="SplashScreenBackground"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="0.0" y="0.0"/>
</scene>
</scenes>
<resources>
<image name="SplashScreenLogo" width="414" height="736"/>
<namedColor name="SplashScreenBackground">
<color alpha="1.000" blue="1.00000000000000" green="1.00000000000000" red="1.00000000000000" customColorSpace="sRGB" colorSpace="custom"/>
</namedColor>
</resources>
</document>

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesEnabled</key>
<false/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
</dict>
</plist>

View file

@ -1,10 +0,0 @@
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

View file

@ -1,4 +0,0 @@
//
// @generated
// A blank Swift file must be created for native modules with Swift files to work correctly.
//

View file

@ -1,66 +0,0 @@
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
ENV['RCT_NEW_ARCH_ENABLED'] = podfile_properties['newArchEnabled'] == 'true' ? '1' : '0'
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
install! 'cocoapods',
:deterministic_uuids => false
prepare_react_native_project!
target 'Nuvio' do
use_expo_modules!
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];
else
config_command = [
'node',
'--no-warnings',
'--eval',
'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
'react-native-config',
'--json',
'--platform',
'ios'
]
end
config = use_native_modules!(config_command)
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
# An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/..",
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
)
post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
)
# This is necessary for Xcode 14, because it signs resource bundles by default
# when building for devices.
installer.target_installation_results.pod_target_installation_results
.each do |pod_name, target_installation_result|
target_installation_result.resource_bundle_targets.each do |resource_bundle_target|
resource_bundle_target.build_configurations.each do |config|
config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
end
end
end
end
end

View file

@ -1,5 +0,0 @@
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"newArchEnabled": "true"
}

142
package-lock.json generated
View file

@ -14,6 +14,8 @@
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/slider": "^4.5.6",
"@react-native-masked-view/masked-view": "github:react-native-masked-view/masked-view",
"@react-native-picker/picker": "^2.11.0",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.10",
@ -23,7 +25,9 @@
"@types/react-native-video": "^5.0.20",
"axios": "^1.8.4",
"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-file-system": "^18.0.12",
"expo-haptics": "~14.0.1",
@ -31,13 +35,16 @@
"expo-intent-launcher": "~12.0.2",
"expo-linear-gradient": "~14.0.2",
"expo-notifications": "~0.29.14",
"expo-random": "^14.0.1",
"expo-screen-orientation": "~8.0.4",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "^4.0.9",
"expo-web-browser": "^14.0.2",
"lodash": "^4.17.21",
"react": "18.3.1",
"react-native": "0.76.9",
"react-native-awesome-slider": "^2.9.0",
"react-native-draggable-flatlist": "^4.0.2",
"react-native-gesture-handler": "~2.20.2",
"react-native-immersive-mode": "^2.0.2",
"react-native-modal": "^14.0.0-rc.1",
@ -47,6 +54,7 @@
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "^15.11.2",
"react-native-tab-view": "^4.0.10",
"react-native-video": "^6.12.0",
"react-native-web": "~0.19.13",
"subsrt": "^1.1.1"
@ -3332,6 +3340,28 @@
"integrity": "sha512-UhLPFeqx0YfPLrEz8ffT3uqAyXWu6iqFjohNsbp4cOU7hnJwg2RXtDnYHoHMr7MOkZDVdlLMdrSrAuzY6KGqrg==",
"license": "MIT"
},
"node_modules/@react-native-masked-view/masked-view": {
"version": "0.3.2",
"resolved": "git+ssh://git@github.com/react-native-masked-view/masked-view.git#14df52650be2441fbf6f2a0308cc54a62e68820c",
"license": "MIT",
"peerDependencies": {
"react": ">=16",
"react-native": ">=0.57"
}
},
"node_modules/@react-native-picker/picker": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.0.tgz",
"integrity": "sha512-QuZU6gbxmOID5zZgd/H90NgBnbJ3VV6qVzp6c7/dDrmWdX8S0X5YFYgDcQFjE3dRen9wB9FWnj2VVdPU64adSg==",
"license": "MIT",
"workspaces": [
"example"
],
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.76.9",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz",
@ -6457,6 +6487,12 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/exec-async": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz",
@ -6629,6 +6665,24 @@
"react-native": "*"
}
},
"node_modules/expo-auth-session": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-6.0.3.tgz",
"integrity": "sha512-s7LmmMPiiY1NXrlcXkc4+09Hlfw9X1CpaQOCDkwfQEodG1uCYGQi/WImTnDzw5YDkWI79uC8F1mB8EIerilkDA==",
"license": "MIT",
"dependencies": {
"expo-application": "~6.0.2",
"expo-constants": "~17.0.5",
"expo-crypto": "~14.0.2",
"expo-linking": "~7.0.5",
"expo-web-browser": "~14.0.2",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-blur": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-14.0.3.tgz",
@ -6654,6 +6708,18 @@
"react-native": "*"
}
},
"node_modules/expo-crypto": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-14.0.2.tgz",
"integrity": "sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "18.0.12",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz",
@ -6736,6 +6802,20 @@
"react-native": "*"
}
},
"node_modules/expo-linking": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz",
"integrity": "sha512-3KptlJtcYDPWohk0MfJU75MJFh2ybavbtcSd84zEPfw9s1q3hjimw3sXnH03ZxP54kiEWldvKmmnGcVffBDB1g==",
"license": "MIT",
"dependencies": {
"expo-constants": "~17.0.5",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.0.8.tgz",
@ -6820,6 +6900,19 @@
"react-native": "*"
}
},
"node_modules/expo-random": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/expo-random/-/expo-random-14.0.1.tgz",
"integrity": "sha512-gX2mtR9o+WelX21YizXUCD/y+a4ZL+RDthDmFkHxaYbdzjSYTn8u/igoje/l3WEO+/RYspmqUFa8w/ckNbt6Vg==",
"deprecated": "This package is now deprecated in favor of expo-crypto, which provides the same functionality. To migrate, replace all imports from expo-random with imports from expo-crypto.",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-screen-orientation": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-8.0.4.tgz",
@ -6866,6 +6959,16 @@
"integrity": "sha512-FRjRvs7RgsXjkbGSOjYSxhX5V70c0IzA/jy3HXeYpATMwD9fOR1DbveLW497QGsVdCa0vThbJUtR8rIzAfpHQA==",
"license": "MIT"
},
"node_modules/expo-web-browser": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-14.0.2.tgz",
"integrity": "sha512-Hncv2yojhTpHbP6SGWARBFdl7P6wBHc1O8IKaNsH0a/IEakq887o1eRhLxZ5IwztPQyRDhpqHdgJ+BjWolOnwA==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/exponential-backoff": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz",
@ -10617,6 +10720,20 @@
"react-native-reanimated": ">=3.0.0"
}
},
"node_modules/react-native-draggable-flatlist": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.2.tgz",
"integrity": "sha512-6WN/o3WIRIJdcrQtwGju5vtXjfK8KTFtXds10QIT00MP9SxEFt5VoX7QW+JC22Rpk651cNScOVm+WKs7vDV0iw==",
"license": "MIT",
"dependencies": {
"@babel/preset-typescript": "^7.17.12"
},
"peerDependencies": {
"react-native": ">=0.64.0",
"react-native-gesture-handler": ">=2.0.0",
"react-native-reanimated": ">=2.8.0"
}
},
"node_modules/react-native-gesture-handler": {
"version": "2.20.2",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz",
@ -10671,6 +10788,17 @@
}
}
},
"node_modules/react-native-pager-view": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.7.0.tgz",
"integrity": "sha512-sutxKiMqBuQrEyt4mLaLNzy8taIC7IuYpxfcwQBXfSYBSSpAa0qE9G1FXlP/iXqTSlFgBXyK7BESsl9umOjECQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-paper": {
"version": "5.13.1",
"resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.13.1.tgz",
@ -10802,6 +10930,20 @@
"react-native-svg": ">=12.0.0"
}
},
"node_modules/react-native-tab-view": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/react-native-tab-view/-/react-native-tab-view-4.0.10.tgz",
"integrity": "sha512-KU1ovavUURfKffqNn7F2jwgQ0tUSa2WosnHSztVYArCr22HP2nR7xHrd8DddFL4uenaT9KGXlNgx1IUPGUdZSw==",
"license": "MIT",
"dependencies": {
"use-latest-callback": "^0.2.1"
},
"peerDependencies": {
"react": ">= 18.2.0",
"react-native": "*",
"react-native-pager-view": ">= 6.0.0"
}
},
"node_modules/react-native-vector-icons": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.2.0.tgz",

View file

@ -15,6 +15,8 @@
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/slider": "^4.5.6",
"@react-native-masked-view/masked-view": "github:react-native-masked-view/masked-view",
"@react-native-picker/picker": "^2.11.0",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.10",
@ -24,7 +26,9 @@
"@types/react-native-video": "^5.0.20",
"axios": "^1.8.4",
"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-file-system": "^18.0.12",
"expo-haptics": "~14.0.1",
@ -32,13 +36,16 @@
"expo-intent-launcher": "~12.0.2",
"expo-linear-gradient": "~14.0.2",
"expo-notifications": "~0.29.14",
"expo-random": "^14.0.1",
"expo-screen-orientation": "~8.0.4",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "^4.0.9",
"expo-web-browser": "^14.0.2",
"lodash": "^4.17.21",
"react": "18.3.1",
"react-native": "0.76.9",
"react-native-awesome-slider": "^2.9.0",
"react-native-draggable-flatlist": "^4.0.2",
"react-native-gesture-handler": "~2.20.2",
"react-native-immersive-mode": "^2.0.2",
"react-native-modal": "^14.0.0-rc.1",
@ -48,6 +55,7 @@
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "^15.11.2",
"react-native-tab-view": "^4.0.10",
"react-native-video": "^6.12.0",
"react-native-web": "~0.19.13",
"subsrt": "^1.1.1"

BIN
src/assets/discover.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
src/assets/home.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
src/assets/metadascreen.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

BIN
src/assets/ratingscreen.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
src/assets/search.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native';
import { Image } from 'expo-image';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
@ -37,6 +37,9 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
const isTablet = width > 768;
const isDarkMode = useColorScheme() === 'dark';
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number } }>({});
// Add ref for the season selector ScrollView
const seasonScrollViewRef = useRef<ScrollView | null>(null);
const loadEpisodesProgress = async () => {
if (!metadata?.id) return;
@ -70,6 +73,25 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
}, [episodes, metadata?.id])
);
// Add effect to scroll to selected season
useEffect(() => {
if (selectedSeason && seasonScrollViewRef.current && Object.keys(groupedEpisodes).length > 0) {
// Find the index of the selected season
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
const selectedIndex = seasons.findIndex(season => season === selectedSeason);
if (selectedIndex !== -1) {
// Wait a small amount of time for layout to be ready
setTimeout(() => {
seasonScrollViewRef.current?.scrollTo({
x: selectedIndex * 116, // 100px width + 16px margin
animated: true
});
}, 300);
}
}
}, [selectedSeason, groupedEpisodes]);
if (loadingSeasons) {
return (
<View style={styles.centeredContainer}>
@ -99,6 +121,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<View style={styles.seasonSelectorWrapper}>
<Text style={styles.seasonSelectorTitle}>Seasons</Text>
<ScrollView
ref={seasonScrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.seasonSelectorContainer}

View file

@ -1,5 +1,7 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { StreamingContent } from '../services/catalogService';
import { addonEmitter, ADDON_EVENTS } from '../services/stremioService';
import { logger } from '../utils/logger';
interface CatalogContextType {
lastUpdate: number;
@ -25,8 +27,29 @@ export const CatalogProvider: React.FC<{ children: React.ReactNode }> = ({ child
const refreshCatalogs = useCallback(() => {
setLastUpdate(Date.now());
logger.info('Refreshing catalogs, timestamp:', Date.now());
}, []);
// Listen for addon changes to update catalog data
useEffect(() => {
const handleAddonChange = () => {
logger.info('Addon changed, triggering catalog refresh');
refreshCatalogs();
};
// Subscribe to all addon events to refresh catalogs
addonEmitter.on(ADDON_EVENTS.ORDER_CHANGED, handleAddonChange);
addonEmitter.on(ADDON_EVENTS.ADDON_ADDED, handleAddonChange);
addonEmitter.on(ADDON_EVENTS.ADDON_REMOVED, handleAddonChange);
return () => {
// Clean up event listeners
addonEmitter.off(ADDON_EVENTS.ORDER_CHANGED, handleAddonChange);
addonEmitter.off(ADDON_EVENTS.ADDON_ADDED, handleAddonChange);
addonEmitter.off(ADDON_EVENTS.ADDON_REMOVED, handleAddonChange);
};
}, [refreshCatalogs]);
const addToLibrary = useCallback((content: StreamingContent) => {
setLibraryItems(prev => [...prev, content]);
}, []);

View file

@ -0,0 +1,37 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { useTraktIntegration } from '../hooks/useTraktIntegration';
import { TraktUser, TraktWatchedItem } from '../services/traktService';
interface TraktContextProps {
isAuthenticated: boolean;
isLoading: boolean;
userProfile: TraktUser | null;
watchedMovies: TraktWatchedItem[];
watchedShows: TraktWatchedItem[];
checkAuthStatus: () => Promise<void>;
loadWatchedItems: () => Promise<void>;
isMovieWatched: (imdbId: string) => Promise<boolean>;
isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise<boolean>;
markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise<boolean>;
markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise<boolean>;
}
const TraktContext = createContext<TraktContextProps | undefined>(undefined);
export function TraktProvider({ children }: { children: ReactNode }) {
const traktIntegration = useTraktIntegration();
return (
<TraktContext.Provider value={traktIntegration}>
{children}
</TraktContext.Provider>
);
}
export function useTraktContext() {
const context = useContext(TraktContext);
if (context === undefined) {
throw new Error('useTraktContext must be used within a TraktProvider');
}
return context;
}

View file

@ -0,0 +1,57 @@
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { logger } from '../utils/logger';
const CATALOG_CUSTOM_NAMES_KEY = 'catalog_custom_names';
interface CustomNamesCache {
names: { [key: string]: string };
lastUpdate: number;
}
// Simple in-memory cache to avoid repeated AsyncStorage reads within the same session
let cache: CustomNamesCache | null = null;
export function useCustomCatalogNames() {
const [customNames, setCustomNames] = useState<{ [key: string]: string } | null>(cache?.names || null);
const [isLoading, setIsLoading] = useState(!cache); // Only loading if cache is empty
const loadCustomNames = useCallback(async () => {
// Check if cache is recent enough (e.g., within last 5 minutes) - adjust as needed
const now = Date.now();
if (cache && (now - cache.lastUpdate < 5 * 60 * 1000)) {
if (!customNames) setCustomNames(cache.names); // Ensure state is updated if cache existed
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const savedCustomNamesJson = await AsyncStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
const loadedNames = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
setCustomNames(loadedNames);
// Update cache
cache = { names: loadedNames, lastUpdate: now };
} catch (error) {
logger.error('Failed to load custom catalog names:', error);
setCustomNames({}); // Set to empty object on error to avoid breaking lookups
} finally {
setIsLoading(false);
}
}, []); // Removed customNames dependency to prevent re-running loop
useEffect(() => {
loadCustomNames();
}, [loadCustomNames]); // Load on mount and if load function changes
const getCustomName = useCallback((addonId: string, type: string, catalogId: string, originalName: string): string => {
if (isLoading || !customNames) {
// Return original name while loading or if loading failed
return originalName;
}
const key = `${addonId}:${type}:${catalogId}`;
return customNames[key] || originalName;
}, [customNames, isLoading]);
return { getCustomName, isLoadingCustomNames: isLoading, refreshCustomNames: loadCustomNames };
}

View file

@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import { CatalogContent, catalogService } from '../services/catalogService';
import { logger } from '../utils/logger';
import { useCatalogContext } from '../contexts/CatalogContext';
import { addonEmitter, ADDON_EVENTS } from '../services/stremioService';
export function useHomeCatalogs() {
const [catalogs, setCatalogs] = useState<CatalogContent[]>([]);
@ -72,6 +73,33 @@ export function useHomeCatalogs() {
loadCatalogs();
}, [loadCatalogs, lastUpdate]);
// Subscribe to addon events to refresh catalogs when addons change
useEffect(() => {
// Handler for addon order changes
const handleAddonOrderChange = () => {
logger.info('Addon order changed, refreshing catalogs');
loadCatalogs();
};
// Handler for addon added/removed
const handleAddonChange = () => {
logger.info('Addon added or removed, refreshing catalogs');
loadCatalogs();
};
// Subscribe to addon events
addonEmitter.on(ADDON_EVENTS.ORDER_CHANGED, handleAddonOrderChange);
addonEmitter.on(ADDON_EVENTS.ADDON_ADDED, handleAddonChange);
addonEmitter.on(ADDON_EVENTS.ADDON_REMOVED, handleAddonChange);
// Cleanup on unmount
return () => {
addonEmitter.off(ADDON_EVENTS.ORDER_CHANGED, handleAddonOrderChange);
addonEmitter.off(ADDON_EVENTS.ADDON_ADDED, handleAddonChange);
addonEmitter.off(ADDON_EVENTS.ADDON_REMOVED, handleAddonChange);
};
}, [loadCatalogs]);
// Cleanup on unmount
useEffect(() => {
return () => {

View file

@ -7,6 +7,7 @@ import { cacheService } from '../services/cacheService';
import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
import { TMDBService } from '../services/tmdbService';
import { logger } from '../utils/logger';
import { usePersistentSeasons } from './usePersistentSeasons';
// Constants for timeouts and retries
const API_TIMEOUT = 10000; // 10 seconds
@ -86,6 +87,7 @@ interface UseMetadataReturn {
recommendations: StreamingContent[];
loadingRecommendations: boolean;
setMetadata: React.Dispatch<React.SetStateAction<StreamingContent | null>>;
imdbId: string | null;
}
export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn => {
@ -110,6 +112,10 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
const [loadAttempts, setLoadAttempts] = useState(0);
const [recommendations, setRecommendations] = useState<StreamingContent[]>([]);
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
const [imdbId, setImdbId] = useState<string | null>(null);
// Add hook for persistent seasons
const { getSeason, saveSeason } = usePersistentSeasons();
const processStremioSource = async (type: string, id: string, isEpisode = false) => {
const sourceStartTime = Date.now();
@ -316,6 +322,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
if (imdbId) {
// Use the imdbId for compatibility with the rest of the app
actualId = imdbId;
setImdbId(imdbId);
// Also store the TMDB ID for later use
setTmdbId(parseInt(tmdbId));
} else {
@ -394,6 +401,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
if (imdbId) {
// Use the imdbId for compatibility with the rest of the app
actualId = imdbId;
setImdbId(imdbId);
// Also store the TMDB ID for later use
setTmdbId(parseInt(tmdbId));
} else {
@ -471,6 +479,10 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
catalogService.getContentDetails(type, actualId),
API_TIMEOUT
);
// Store the actual ID used (could be IMDB)
if (actualId.startsWith('tt')) {
setImdbId(actualId);
}
return result;
}),
// Start loading cast immediately in parallel
@ -567,10 +579,17 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
setGroupedEpisodes(transformedEpisodes);
// Get the first available season as fallback
const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number));
const initialEpisodes = transformedEpisodes[firstSeason] || [];
setSelectedSeason(firstSeason);
setEpisodes(initialEpisodes);
// Get saved season from persistence, fallback to first season if not found
const persistedSeason = getSeason(id, firstSeason);
// Set the selected season from persistence
setSelectedSeason(persistedSeason);
// Set episodes for the selected season
setEpisodes(transformedEpisodes[persistedSeason] || []);
}
} catch (error) {
console.error('Failed to load episodes:', error);
@ -950,9 +969,14 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
const handleSeasonChange = useCallback((seasonNumber: number) => {
if (selectedSeason === seasonNumber) return;
// Update local state
setSelectedSeason(seasonNumber);
setEpisodes(groupedEpisodes[seasonNumber] || []);
}, [selectedSeason, groupedEpisodes]);
// Persist the selection
saveSeason(id, seasonNumber);
}, [selectedSeason, groupedEpisodes, saveSeason, id]);
const toggleLibrary = useCallback(() => {
if (!metadata) return;
@ -1096,5 +1120,6 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
recommendations,
loadingRecommendations,
setMetadata,
imdbId,
};
};

View file

@ -0,0 +1,85 @@
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { logger } from '../utils/logger';
const SEASONS_STORAGE_KEY = 'selected_seasons';
interface SeasonsCache {
seasons: { [seriesId: string]: number };
lastUpdate: number;
}
// Simple in-memory cache to avoid repeated AsyncStorage reads within the same session
let cache: SeasonsCache | null = null;
export function usePersistentSeasons() {
const [selectedSeasons, setSelectedSeasons] = useState<{ [seriesId: string]: number } | null>(cache?.seasons || null);
const [isLoading, setIsLoading] = useState(!cache); // Only loading if cache is empty
const loadSelectedSeasons = useCallback(async () => {
// Check if cache is recent enough (within last 5 minutes)
const now = Date.now();
if (cache && (now - cache.lastUpdate < 5 * 60 * 1000)) {
if (!selectedSeasons) setSelectedSeasons(cache.seasons); // Ensure state is updated if cache existed
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const savedSeasonsJson = await AsyncStorage.getItem(SEASONS_STORAGE_KEY);
const loadedSeasons = savedSeasonsJson ? JSON.parse(savedSeasonsJson) : {};
setSelectedSeasons(loadedSeasons);
// Update cache
cache = { seasons: loadedSeasons, lastUpdate: now };
} catch (error) {
logger.error('Failed to load persistent seasons:', error);
setSelectedSeasons({}); // Set to empty object on error
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadSelectedSeasons();
}, [loadSelectedSeasons]);
const saveSeason = useCallback(async (seriesId: string, seasonNumber: number) => {
if (!selectedSeasons) return;
try {
const updatedSeasons = {
...selectedSeasons,
[seriesId]: seasonNumber
};
// Update the cache
cache = {
seasons: updatedSeasons,
lastUpdate: Date.now()
};
// Update state
setSelectedSeasons(updatedSeasons);
// Save to AsyncStorage
await AsyncStorage.setItem(SEASONS_STORAGE_KEY, JSON.stringify(updatedSeasons));
} catch (error) {
logger.error('Failed to save selected season:', error);
}
}, [selectedSeasons]);
const getSeason = useCallback((seriesId: string, defaultSeason: number = 1): number => {
if (isLoading || !selectedSeasons) {
return defaultSeason;
}
return selectedSeasons[seriesId] || defaultSeason;
}, [selectedSeasons, isLoading]);
return {
getSeason,
saveSeason,
isLoadingSeasons: isLoading,
refreshSeasons: loadSelectedSeasons
};
}

View file

@ -28,6 +28,7 @@ export interface AppSettings {
enableBackgroundPlayback: boolean;
cacheLimit: number;
useExternalPlayer: boolean;
preferredPlayer: 'internal' | 'vlc' | 'infuse' | 'outplayer' | 'vidhub' | 'external';
showHeroSection: boolean;
featuredContentSource: 'tmdb' | 'catalogs';
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
@ -41,6 +42,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
enableBackgroundPlayback: false,
cacheLimit: 1024,
useExternalPlayer: false,
preferredPlayer: 'internal',
showHeroSection: true,
featuredContentSource: 'tmdb',
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
@ -75,14 +77,17 @@ export const useSettings = () => {
const updateSetting = useCallback(async <K extends keyof AppSettings>(
key: K,
value: AppSettings[K]
value: AppSettings[K],
emitEvent: boolean = true
) => {
const newSettings = { ...settings, [key]: value };
try {
await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings));
setSettings(newSettings);
// Notify all subscribers that settings have changed
settingsEmitter.emit();
// Notify all subscribers that settings have changed (if requested)
if (emitEvent) {
settingsEmitter.emit();
}
} catch (error) {
console.error('Failed to save settings:', error);
}

View file

@ -0,0 +1,146 @@
import { useState, useEffect, useCallback } from 'react';
import { traktService, TraktUser, TraktWatchedItem } from '../services/traktService';
import { logger } from '../utils/logger';
export function useTraktIntegration() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
const [watchedMovies, setWatchedMovies] = useState<TraktWatchedItem[]>([]);
const [watchedShows, setWatchedShows] = useState<TraktWatchedItem[]>([]);
// Check authentication status
const checkAuthStatus = useCallback(async () => {
setIsLoading(true);
try {
const authenticated = await traktService.isAuthenticated();
setIsAuthenticated(authenticated);
if (authenticated) {
const profile = await traktService.getUserProfile();
setUserProfile(profile);
} else {
setUserProfile(null);
}
} catch (error) {
logger.error('[useTraktIntegration] Error checking auth status:', error);
} finally {
setIsLoading(false);
}
}, []);
// Load watched items
const loadWatchedItems = useCallback(async () => {
if (!isAuthenticated) return;
setIsLoading(true);
try {
const [movies, shows] = await Promise.all([
traktService.getWatchedMovies(),
traktService.getWatchedShows()
]);
setWatchedMovies(movies);
setWatchedShows(shows);
} catch (error) {
logger.error('[useTraktIntegration] Error loading watched items:', error);
} finally {
setIsLoading(false);
}
}, [isAuthenticated]);
// Check if a movie is watched
const isMovieWatched = useCallback(async (imdbId: string): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
return await traktService.isMovieWatched(imdbId);
} catch (error) {
logger.error('[useTraktIntegration] Error checking if movie is watched:', error);
return false;
}
}, [isAuthenticated]);
// Check if an episode is watched
const isEpisodeWatched = useCallback(async (
imdbId: string,
season: number,
episode: number
): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
return await traktService.isEpisodeWatched(imdbId, season, episode);
} catch (error) {
logger.error('[useTraktIntegration] Error checking if episode is watched:', error);
return false;
}
}, [isAuthenticated]);
// Mark a movie as watched
const markMovieAsWatched = useCallback(async (
imdbId: string,
watchedAt: Date = new Date()
): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const result = await traktService.addToWatchedMovies(imdbId, watchedAt);
if (result) {
// Refresh watched movies list
await loadWatchedItems();
}
return result;
} catch (error) {
logger.error('[useTraktIntegration] Error marking movie as watched:', error);
return false;
}
}, [isAuthenticated, loadWatchedItems]);
// Mark an episode as watched
const markEpisodeAsWatched = useCallback(async (
imdbId: string,
season: number,
episode: number,
watchedAt: Date = new Date()
): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const result = await traktService.addToWatchedEpisodes(imdbId, season, episode, watchedAt);
if (result) {
// Refresh watched shows list
await loadWatchedItems();
}
return result;
} catch (error) {
logger.error('[useTraktIntegration] Error marking episode as watched:', error);
return false;
}
}, [isAuthenticated, loadWatchedItems]);
// Initialize and check auth status
useEffect(() => {
checkAuthStatus();
}, [checkAuthStatus]);
// Load watched items when authenticated
useEffect(() => {
if (isAuthenticated) {
loadWatchedItems();
}
}, [isAuthenticated, loadWatchedItems]);
return {
isAuthenticated,
isLoading,
userProfile,
watchedMovies,
watchedShows,
checkAuthStatus,
loadWatchedItems,
isMovieWatched,
isEpisodeWatched,
markMovieAsWatched,
markEpisodeAsWatched
};
}

View file

@ -32,6 +32,8 @@ import MDBListSettingsScreen from '../screens/MDBListSettingsScreen';
import TMDBSettingsScreen from '../screens/TMDBSettingsScreen';
import HomeScreenSettings from '../screens/HomeScreenSettings';
import HeroCatalogsScreen from '../screens/HeroCatalogsScreen';
import TraktSettingsScreen from '../screens/TraktSettingsScreen';
import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
// Stack navigator types
export type RootStackParamList = {
@ -85,6 +87,8 @@ export type RootStackParamList = {
TMDBSettings: undefined;
HomeScreenSettings: undefined;
HeroCatalogs: undefined;
TraktSettings: undefined;
PlayerSettings: undefined;
};
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
@ -651,12 +655,12 @@ const AppNavigator = () => {
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
...(Platform.OS === 'ios' && { presentation: 'modal' }),
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: colors.darkBackground,
backgroundColor: 'transparent',
},
}}
/>
@ -698,6 +702,36 @@ const AppNavigator = () => {
},
}}
/>
<Stack.Screen
name="TraktSettings"
component={TraktSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: colors.darkBackground,
},
}}
/>
<Stack.Screen
name="PlayerSettings"
component={PlayerSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: colors.darkBackground,
},
}}
/>
</Stack.Navigator>
</PaperProvider>
</>

View file

@ -17,7 +17,8 @@ import {
Dimensions,
ScrollView,
useColorScheme,
Switch
Switch,
Linking
} from 'react-native';
import { stremioService, Manifest } from '../services/stremioService';
import { MaterialIcons } from '@expo/vector-icons';
@ -30,10 +31,23 @@ import { RootStackParamList } from '../navigation/AppNavigator';
import { logger } from '../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { BlurView } from 'expo-blur';
import axios from 'axios';
// Extend Manifest type to include logo
// Extend Manifest type to include logo only (remove disabled status)
interface ExtendedManifest extends Manifest {
logo?: string;
transport?: string;
behaviorHints?: {
configurable?: boolean;
configurationRequired?: boolean;
configurationURL?: string;
};
}
// Interface for Community Addon structure from the JSON URL
interface CommunityAddon {
transportUrl: string;
manifest: ExtendedManifest;
}
const { width } = Dimensions.get('window');
@ -49,20 +63,27 @@ const AddonsScreen = () => {
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [installing, setInstalling] = useState(false);
const [catalogCount, setCatalogCount] = useState(0);
const [activeAddons, setActiveAddons] = useState(0);
// Add state for reorder mode
const [reorderMode, setReorderMode] = useState(false);
// Force dark mode
const isDarkMode = true;
// State for community addons
const [communityAddons, setCommunityAddons] = useState<CommunityAddon[]>([]);
const [communityLoading, setCommunityLoading] = useState(true);
const [communityError, setCommunityError] = useState<string | null>(null);
useEffect(() => {
loadAddons();
loadCommunityAddons();
}, []);
const loadAddons = async () => {
try {
setLoading(true);
// Use the regular method without disabled state
const installedAddons = await stremioService.getInstalledAddonsAsync();
setAddons(installedAddons);
setActiveAddons(installedAddons.length);
setAddons(installedAddons as ExtendedManifest[]);
// Count catalogs
let totalCatalogs = 0;
@ -91,28 +112,46 @@ const AddonsScreen = () => {
}
};
const handleAddAddon = async () => {
if (!addonUrl) {
Alert.alert('Error', 'Please enter an addon URL');
// Function to load community addons
const loadCommunityAddons = async () => {
setCommunityLoading(true);
setCommunityError(null);
try {
const response = await axios.get<CommunityAddon[]>('https://stremio-addons.com/catalog.json');
// Filter out addons without a manifest or transportUrl (basic validation)
const validAddons = response.data.filter(addon => addon.manifest && addon.transportUrl);
setCommunityAddons(validAddons);
} catch (error) {
logger.error('Failed to load community addons:', error);
setCommunityError('Failed to load community addons. Please try again later.');
} finally {
setCommunityLoading(false);
}
};
const handleAddAddon = async (url?: string) => {
const urlToInstall = url || addonUrl;
if (!urlToInstall) {
Alert.alert('Error', 'Please enter an addon URL or select a community addon');
return;
}
try {
setInstalling(true);
// First fetch the addon manifest
const manifest = await stremioService.getManifest(addonUrl);
const manifest = await stremioService.getManifest(urlToInstall);
setAddonDetails(manifest);
setAddonUrl(urlToInstall);
setShowConfirmModal(true);
} catch (error) {
logger.error('Failed to fetch addon details:', error);
Alert.alert('Error', 'Failed to fetch addon details');
Alert.alert('Error', `Failed to fetch addon details from ${urlToInstall}`);
} finally {
setInstalling(false);
}
};
const confirmInstallAddon = async () => {
if (!addonDetails) return;
if (!addonDetails || !addonUrl) return;
try {
setInstalling(true);
@ -130,28 +169,28 @@ const AddonsScreen = () => {
}
};
const handleToggleAddon = (addon: ExtendedManifest, enabled: boolean) => {
// Logic to enable/disable an addon
Alert.alert(
enabled ? 'Disable Addon' : 'Enable Addon',
`Are you sure you want to ${enabled ? 'disable' : 'enable'} ${addon.name}?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: enabled ? 'Disable' : 'Enable',
style: enabled ? 'destructive' : 'default',
onPress: () => {
// TODO: Implement actual toggle functionality
Alert.alert('Success', `${addon.name} ${enabled ? 'disabled' : 'enabled'}`);
},
},
]
);
const refreshAddons = async () => {
loadAddons();
loadCommunityAddons();
};
const moveAddonUp = (addon: ExtendedManifest) => {
if (stremioService.moveAddonUp(addon.id)) {
// Refresh the list to reflect the new order
loadAddons();
}
};
const moveAddonDown = (addon: ExtendedManifest) => {
if (stremioService.moveAddonDown(addon.id)) {
// Refresh the list to reflect the new order
loadAddons();
}
};
const handleRemoveAddon = (addon: ExtendedManifest) => {
Alert.alert(
'Uninstall',
'Uninstall Addon',
`Are you sure you want to uninstall ${addon.name}?`,
[
{ text: 'Cancel', style: 'cancel' },
@ -160,26 +199,188 @@ const AddonsScreen = () => {
style: 'destructive',
onPress: () => {
stremioService.removeAddon(addon.id);
loadAddons();
// Remove from addons list
setAddons(prev => prev.filter(a => a.id !== addon.id));
},
},
]
);
};
const renderAddonItem = ({ item }: { item: ExtendedManifest }) => {
// Add function to handle configuration
const handleConfigureAddon = (addon: ExtendedManifest, transportUrl?: string) => {
// Try different ways to get the configuration URL
let configUrl = '';
// Debug log the addon data to help troubleshoot
logger.info(`Configure addon: ${addon.name}, ID: ${addon.id}`);
if (transportUrl) {
logger.info(`TransportUrl provided: ${transportUrl}`);
}
// First check if the addon has a configurationURL directly
if (addon.behaviorHints?.configurationURL) {
configUrl = addon.behaviorHints.configurationURL;
logger.info(`Using configurationURL from behaviorHints: ${configUrl}`);
}
// If a transport URL was provided directly (for community addons)
else if (transportUrl) {
// Remove any trailing filename like manifest.json
const baseUrl = transportUrl.replace(/\/[^\/]+\.json$/, '/');
configUrl = `${baseUrl}configure`;
logger.info(`Using transportUrl to create config URL: ${configUrl}`);
}
// If the addon has a url property (this is set during installation)
else if (addon.url) {
configUrl = `${addon.url}configure`;
logger.info(`Using addon.url property: ${configUrl}`);
}
// For com.stremio.*.addon format (common format for installed addons)
else if (addon.id && addon.id.match(/^com\.stremio\.(.*?)\.addon$/)) {
// Extract the domain part
const match = addon.id.match(/^com\.stremio\.(.*?)\.addon$/);
if (match && match[1]) {
// Construct URL from the domain part of the ID
const addonName = match[1];
// For torrentio specifically, use known URL
if (addonName === 'torrentio') {
configUrl = 'https://torrentio.strem.fun/configure';
logger.info(`Special case for torrentio: ${configUrl}`);
} else {
// Try to construct a reasonable URL for other addons
configUrl = `https://${addonName}.strem.fun/configure`;
logger.info(`Constructed URL from addon name: ${configUrl}`);
}
}
}
// If the ID is a URL, use that as the base (common for installed addons)
else if (addon.id && addon.id.startsWith('http')) {
// Get base URL from addon id (remove manifest.json or any trailing file)
const baseUrl = addon.id.replace(/\/[^\/]+\.json$/, '/');
configUrl = `${baseUrl}configure`;
logger.info(`Using addon.id as HTTP URL: ${configUrl}`);
}
// If the ID uses stremio:// protocol but contains http URL (common format)
else if (addon.id && (addon.id.includes('https://') || addon.id.includes('http://'))) {
// Extract the HTTP URL using a more flexible regex
const match = addon.id.match(/(https?:\/\/[^\/]+)(\/[^\s]*)?/);
if (match) {
// Use the domain and path if available, otherwise just domain with /configure
const domain = match[1];
const path = match[2] ? match[2].replace(/\/[^\/]+\.json$/, '/') : '/';
configUrl = `${domain}${path}configure`;
logger.info(`Extracted HTTP URL from stremio:// format: ${configUrl}`);
}
}
// Special case for common addon format like stremio://addon.stremio.com/...
if (!configUrl && addon.id && addon.id.startsWith('stremio://')) {
// Try to convert stremio://domain.com/... to https://domain.com/...
const domainMatch = addon.id.match(/stremio:\/\/([^\/]+)(\/[^\s]*)?/);
if (domainMatch) {
const domain = domainMatch[1];
const path = domainMatch[2] ? domainMatch[2].replace(/\/[^\/]+\.json$/, '/') : '/';
configUrl = `https://${domain}${path}configure`;
logger.info(`Converted stremio:// protocol to https:// for config URL: ${configUrl}`);
}
}
// Use transport property if available (some addons include this)
if (!configUrl && addon.transport && typeof addon.transport === 'string' && addon.transport.includes('http')) {
const baseUrl = addon.transport.replace(/\/[^\/]+\.json$/, '/');
configUrl = `${baseUrl}configure`;
logger.info(`Using addon.transport for config URL: ${configUrl}`);
}
// Get the URL from manifest's originalUrl if available
if (!configUrl && (addon as any).originalUrl) {
const baseUrl = (addon as any).originalUrl.replace(/\/[^\/]+\.json$/, '/');
configUrl = `${baseUrl}configure`;
logger.info(`Using originalUrl property: ${configUrl}`);
}
// If we couldn't determine a config URL, show an error
if (!configUrl) {
logger.error(`Failed to determine config URL for addon: ${addon.name}, ID: ${addon.id}`);
Alert.alert(
'Configuration Unavailable',
'Could not determine configuration URL for this addon.',
[{ text: 'OK' }]
);
return;
}
// Log the URL being opened
logger.info(`Opening configuration for addon: ${addon.name} at URL: ${configUrl}`);
// Check if the URL can be opened
Linking.canOpenURL(configUrl).then(supported => {
if (supported) {
Linking.openURL(configUrl);
} else {
logger.error(`URL cannot be opened: ${configUrl}`);
Alert.alert(
'Cannot Open Configuration',
`The configuration URL (${configUrl}) cannot be opened. The addon may not have a configuration page.`,
[{ text: 'OK' }]
);
}
}).catch(err => {
logger.error(`Error checking if URL can be opened: ${configUrl}`, err);
Alert.alert('Error', 'Could not open configuration page.');
});
};
const toggleReorderMode = () => {
setReorderMode(!reorderMode);
};
const renderAddonItem = ({ item, index }: { item: ExtendedManifest, index: number }) => {
const types = item.types || [];
const description = item.description || '';
// @ts-ignore - some addons might have logo property even though it's not in the type
const logo = item.logo || null;
// Check if addon is configurable
const isConfigurable = item.behaviorHints?.configurable === true;
// Format the types into a simple category text
const categoryText = types.length > 0
? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
: 'No categories';
const isFirstItem = index === 0;
const isLastItem = index === addons.length - 1;
return (
<View>
<View style={styles.addonItem}>
{reorderMode && (
<View style={styles.reorderButtons}>
<TouchableOpacity
style={[styles.reorderButton, isFirstItem && styles.disabledButton]}
onPress={() => moveAddonUp(item)}
disabled={isFirstItem}
>
<MaterialIcons
name="arrow-upward"
size={20}
color={isFirstItem ? colors.mediumGray : colors.white}
/>
</TouchableOpacity>
<TouchableOpacity
style={[styles.reorderButton, isLastItem && styles.disabledButton]}
onPress={() => moveAddonDown(item)}
disabled={isLastItem}
>
<MaterialIcons
name="arrow-downward"
size={20}
color={isLastItem ? colors.mediumGray : colors.white}
/>
</TouchableOpacity>
</View>
)}
<View style={styles.addonHeader}>
{logo ? (
<ExpoImage
@ -200,13 +401,30 @@ const AddonsScreen = () => {
<Text style={styles.addonCategory}>{categoryText}</Text>
</View>
</View>
<Switch
value={true} // Default to enabled
onValueChange={(value) => handleToggleAddon(item, !value)}
trackColor={{ false: colors.elevation1, true: colors.primary }}
thumbColor={colors.white}
ios_backgroundColor={colors.elevation1}
/>
<View style={styles.addonActions}>
{!reorderMode ? (
<>
{isConfigurable && (
<TouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(item, item.transport)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.deleteButton}
onPress={() => handleRemoveAddon(item)}
>
<MaterialIcons name="delete" size={20} color={colors.error} />
</TouchableOpacity>
</>
) : (
<View style={styles.priorityBadge}>
<Text style={styles.priorityText}>#{index + 1}</Text>
</View>
)}
</View>
</View>
<Text style={styles.addonDescription}>
@ -216,6 +434,66 @@ const AddonsScreen = () => {
);
};
// Function to render community addon items
const renderCommunityAddonItem = ({ item }: { item: CommunityAddon }) => {
const { manifest, transportUrl } = item;
const types = manifest.types || [];
const description = manifest.description || 'No description provided.';
// @ts-ignore - logo might exist
const logo = manifest.logo || null;
const categoryText = types.length > 0
? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
: 'General';
// Check if addon is configurable
const isConfigurable = manifest.behaviorHints?.configurable === true;
return (
<View style={styles.communityAddonItem}>
{logo ? (
<ExpoImage
source={{ uri: logo }}
style={styles.communityAddonIcon}
contentFit="contain"
/>
) : (
<View style={styles.communityAddonIconPlaceholder}>
<MaterialIcons name="extension" size={22} color={colors.darkGray} />
</View>
)}
<View style={styles.communityAddonDetails}>
<Text style={styles.communityAddonName}>{manifest.name}</Text>
<Text style={styles.communityAddonDesc} numberOfLines={2}>{description}</Text>
<View style={styles.communityAddonMetaContainer}>
<Text style={styles.communityAddonVersion}>v{manifest.version || 'N/A'}</Text>
<Text style={styles.communityAddonDot}></Text>
<Text style={styles.communityAddonCategory}>{categoryText}</Text>
</View>
</View>
<View style={styles.addonActionButtons}>
{isConfigurable && (
<TouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(manifest, transportUrl)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.installButton, installing && { opacity: 0.6 }]}
onPress={() => handleAddAddon(transportUrl)}
disabled={installing}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</View>
</View>
);
};
const StatsCard = ({ value, label }: { value: number; label: string }) => (
<View style={styles.statsCard}>
<Text style={styles.statsValue}>{value}</Text>
@ -236,9 +514,48 @@ const AddonsScreen = () => {
<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</Text>
<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}>
@ -256,40 +573,44 @@ const AddonsScreen = () => {
<View style={styles.statsContainer}>
<StatsCard value={addons.length} label="Addons" />
<View style={styles.statsDivider} />
<StatsCard value={activeAddons} label="Active" />
<StatsCard value={addons.length} label="Active" />
<View style={styles.statsDivider} />
<StatsCard value={catalogCount} label="Catalogs" />
</View>
</View>
{/* Add Addon Section */}
<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>
{/* 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>
</View>
)}
{/* Installed Addons Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>INSTALLED ADDONS</Text>
<Text style={styles.sectionTitle}>
{reorderMode ? "DRAG ADDONS TO REORDER" : "INSTALLED ADDONS"}
</Text>
<View style={styles.addonList}>
{addons.length === 0 ? (
<View style={styles.emptyContainer}>
@ -297,20 +618,103 @@ const AddonsScreen = () => {
<Text style={styles.emptyText}>No addons installed</Text>
</View>
) : (
addons.map((addon, index) => {
const isLast = index === addons.length - 1;
return (
<View
key={addon.id}
style={[
styles.addonItem,
{ marginBottom: isLast ? 32 : 16 }
]}
>
{renderAddonItem({ item: addon })}
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>
@ -440,9 +844,76 @@ const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
},
headerButton: {
padding: 8,
marginLeft: 8,
},
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,
},
priorityBadge: {
backgroundColor: colors.primary,
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 3,
},
priorityText: {
color: colors.white,
fontSize: 12,
fontWeight: 'bold',
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
@ -569,6 +1040,7 @@ const styles = StyleSheet.create({
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
marginBottom: 16,
},
addonHeader: {
flexDirection: 'row',
@ -748,12 +1220,119 @@ const styles = StyleSheet.create({
marginRight: 8,
},
installButton: {
backgroundColor: colors.primary,
backgroundColor: colors.success,
borderRadius: 6,
padding: 8,
justifyContent: 'center',
alignItems: 'center',
},
modalButtonText: {
color: colors.white,
fontWeight: '600',
},
addonActions: {
flexDirection: 'row',
alignItems: 'center',
},
deleteButton: {
padding: 6,
},
configButton: {
padding: 6,
marginRight: 8,
},
communityAddonsList: {
paddingHorizontal: 20,
},
communityAddonItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.card,
borderRadius: 8,
padding: 15,
marginBottom: 10,
},
communityAddonIcon: {
width: 40,
height: 40,
borderRadius: 6,
marginRight: 15,
},
communityAddonIconPlaceholder: {
width: 40,
height: 40,
borderRadius: 6,
marginRight: 15,
backgroundColor: colors.darkGray,
justifyContent: 'center',
alignItems: 'center',
},
communityAddonDetails: {
flex: 1,
marginRight: 10,
},
communityAddonName: {
fontSize: 16,
fontWeight: '600',
color: colors.white,
marginBottom: 3,
},
communityAddonDesc: {
fontSize: 13,
color: colors.lightGray,
marginBottom: 5,
opacity: 0.9,
},
communityAddonMetaContainer: {
flexDirection: 'row',
alignItems: 'center',
opacity: 0.8,
},
communityAddonVersion: {
fontSize: 12,
color: colors.lightGray,
},
communityAddonDot: {
fontSize: 12,
color: colors.lightGray,
marginHorizontal: 5,
},
communityAddonCategory: {
fontSize: 12,
color: colors.lightGray,
flexShrink: 1,
},
separator: {
height: 10,
},
sectionSeparator: {
height: 1,
backgroundColor: colors.border,
marginHorizontal: 20,
marginVertical: 20,
},
emptyMessage: {
textAlign: 'center',
color: colors.mediumGray,
marginTop: 20,
fontSize: 16,
paddingHorizontal: 20,
},
errorMessage: {
textAlign: 'center',
color: colors.error,
marginTop: 20,
fontSize: 16,
paddingHorizontal: 20,
},
loader: {
marginTop: 30,
alignSelf: 'center',
},
addonActionButtons: {
flexDirection: 'row',
alignItems: 'center',
},
});
export default AddonsScreen;

View file

@ -20,6 +20,8 @@ import { colors } from '../styles';
import { Image } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { logger } from '../utils/logger';
import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames';
import { catalogService, DataSource, StreamingContent } from '../services/catalogService';
type CatalogScreenProps = {
route: RouteProp<RootStackParamList, 'Catalog'>;
@ -44,16 +46,29 @@ const ITEM_MARGIN = SPACING.sm;
const ITEM_WIDTH = (width - (SPACING.lg * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS;
const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const { addonId, type, id, name, genreFilter } = route.params;
const { addonId, type, id, name: originalName, genreFilter } = route.params;
const [items, setItems] = useState<Meta[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
// Force dark mode
const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS);
const isDarkMode = true;
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
const displayName = getCustomName(addonId || '', type || '', id || '', originalName || '');
// Add effect to get data source preference when component mounts
useEffect(() => {
const getDataSourcePreference = async () => {
const preference = await catalogService.getDataSourcePreference();
setDataSource(preference);
};
getDataSourcePreference();
}, []);
const loadItems = useCallback(async (pageNum: number, shouldRefresh: boolean = false) => {
try {
if (shouldRefresh) {
@ -64,6 +79,73 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
setError(null);
// Process the genre filter - ignore "All" and clean up the value
let effectiveGenreFilter = genreFilter;
if (effectiveGenreFilter === 'All') {
effectiveGenreFilter = undefined;
logger.log('Genre "All" detected, removing genre filter');
} else if (effectiveGenreFilter) {
// Clean up the genre filter
effectiveGenreFilter = effectiveGenreFilter.trim();
logger.log(`Using cleaned genre filter: "${effectiveGenreFilter}"`);
}
// Check if using TMDB as data source and not requesting a specific addon
if (dataSource === DataSource.TMDB && !addonId) {
logger.log('Using TMDB data source for CatalogScreen');
try {
const catalogs = await catalogService.getCatalogByType(type, effectiveGenreFilter);
if (catalogs && catalogs.length > 0) {
// Flatten all items from all catalogs
const allItems: StreamingContent[] = [];
catalogs.forEach(catalog => {
allItems.push(...catalog.items);
});
// Convert StreamingContent to Meta format
const metaItems: Meta[] = allItems.map(item => ({
id: item.id,
type: item.type,
name: item.name,
poster: item.poster,
background: item.banner,
logo: item.logo,
description: item.description,
releaseInfo: item.year?.toString() || '',
imdbRating: item.imdbRating,
year: item.year,
genres: item.genres || [],
runtime: item.runtime,
certification: item.certification,
}));
// Remove duplicates
const uniqueItems = metaItems.filter((item, index, self) =>
index === self.findIndex((t) => t.id === item.id)
);
setItems(uniqueItems);
setHasMore(false); // TMDB already returns a full set
setLoading(false);
setRefreshing(false);
return;
} else {
setError("No content found for the selected filters");
setItems([]);
setLoading(false);
setRefreshing(false);
return;
}
} catch (error) {
logger.error('Failed to get TMDB catalog:', error);
setError('Failed to load content from TMDB');
setItems([]);
setLoading(false);
setRefreshing(false);
return;
}
}
// Use this flag to track if we found and processed any items
let foundItems = false;
let allItems: Meta[] = [];
@ -80,7 +162,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
}
// Create filters array for genre filtering if provided
const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : [];
const filters = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : [];
// Load items from the catalog
const newItems = await stremioService.getCatalog(addon, type, id, pageNum, filters);
@ -96,12 +178,15 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
} else {
setItems(prev => [...prev, ...newItems]);
}
} else if (genreFilter) {
} else if (effectiveGenreFilter) {
// Get all addons that have catalogs of the specified type
const typeManifests = manifests.filter(manifest =>
manifest.catalogs && manifest.catalogs.some(catalog => catalog.type === type)
);
// Add debug logging for genre filter
logger.log(`Using genre filter: "${effectiveGenreFilter}" for type: ${type}`);
// For each addon, try to get content with the genre filter
for (const manifest of typeManifests) {
try {
@ -111,12 +196,46 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
// For each catalog, try to get content
for (const catalog of typeCatalogs) {
try {
const filters = [{ title: 'genre', value: genreFilter }];
const filters = [{ title: 'genre', value: effectiveGenreFilter }];
// Debug logging for each catalog request
logger.log(`Requesting from ${manifest.name}, catalog ${catalog.id} with genre "${effectiveGenreFilter}"`);
const catalogItems = await stremioService.getCatalog(manifest, type, catalog.id, pageNum, filters);
if (catalogItems && catalogItems.length > 0) {
allItems = [...allItems, ...catalogItems];
foundItems = true;
// Log first few items' genres to debug
const sampleItems = catalogItems.slice(0, 3);
sampleItems.forEach(item => {
logger.log(`Item "${item.name}" has genres: ${JSON.stringify(item.genres)}`);
});
// Filter items client-side to ensure they contain the requested genre
// Some addons might not properly filter by genre on the server
let filteredItems = catalogItems;
if (effectiveGenreFilter) {
const normalizedGenreFilter = effectiveGenreFilter.toLowerCase().trim();
filteredItems = catalogItems.filter(item => {
// Skip items without genres
if (!item.genres || !Array.isArray(item.genres)) {
return false;
}
// Check for genre match (exact or substring)
return item.genres.some(genre => {
const normalizedGenre = genre.toLowerCase().trim();
return normalizedGenre === normalizedGenreFilter ||
normalizedGenre.includes(normalizedGenreFilter) ||
normalizedGenreFilter.includes(normalizedGenre);
});
});
logger.log(`Filtered ${catalogItems.length} items to ${filteredItems.length} matching genre "${effectiveGenreFilter}"`);
}
allItems = [...allItems, ...filteredItems];
foundItems = filteredItems.length > 0;
}
} catch (error) {
logger.log(`Failed to load items from ${manifest.name} catalog ${catalog.id}:`, error);
@ -160,7 +279,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
setLoading(false);
setRefreshing(false);
}
}, [addonId, type, id, genreFilter]);
}, [addonId, type, id, genreFilter, dataSource]);
useEffect(() => {
loadItems(1);
@ -246,7 +365,9 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
</View>
);
if (loading && items.length === 0) {
const isScreenLoading = loading || isLoadingCustomNames;
if (isScreenLoading && items.length === 0) {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
@ -259,7 +380,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
<Text style={styles.headerTitle}>{displayName || originalName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{renderLoadingState()}
</SafeAreaView>
);
@ -278,7 +399,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{renderErrorState()}
</SafeAreaView>
);
@ -296,7 +417,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{name || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{items.length > 0 ? (
<FlatList

View file

@ -10,6 +10,11 @@ import {
SafeAreaView,
StatusBar,
Platform,
Modal,
TextInput,
Pressable,
Button,
Alert,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native';
@ -18,6 +23,7 @@ import { stremioService } from '../services/stremioService';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { useCatalogContext } from '../contexts/CatalogContext';
import { logger } from '../utils/logger';
import { BlurView } from 'expo-blur';
interface CatalogSetting {
addonId: string;
@ -25,6 +31,7 @@ interface CatalogSetting {
type: string;
name: string;
enabled: boolean;
customName?: string;
}
interface CatalogSettingsStorage {
@ -42,6 +49,7 @@ interface GroupedCatalogs {
}
const CATALOG_SETTINGS_KEY = 'catalog_settings';
const CATALOG_CUSTOM_NAMES_KEY = 'catalog_custom_names';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const CatalogSettingsScreen = () => {
@ -52,6 +60,11 @@ const CatalogSettingsScreen = () => {
const { refreshCatalogs } = useCatalogContext();
const isDarkMode = true; // Force dark mode
// Modal State
const [isRenameModalVisible, setIsRenameModalVisible] = useState(false);
const [catalogToRename, setCatalogToRename] = useState<CatalogSetting | null>(null);
const [currentRenameValue, setCurrentRenameValue] = useState('');
// Load saved settings and available catalogs
const loadSettings = useCallback(async () => {
try {
@ -61,24 +74,22 @@ const CatalogSettingsScreen = () => {
const addons = await stremioService.getInstalledAddonsAsync();
const availableCatalogs: CatalogSetting[] = [];
// Get saved settings
const savedSettings = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY);
const savedCatalogs: { [key: string]: boolean } = savedSettings ? JSON.parse(savedSettings) : {};
// Get saved enable/disable settings
const savedSettingsJson = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY);
const savedEnabledSettings: { [key: string]: boolean } = savedSettingsJson ? JSON.parse(savedSettingsJson) : {};
// Get saved custom names
const savedCustomNamesJson = await AsyncStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
const savedCustomNames: { [key: string]: string } = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
// Process each addon's catalogs
addons.forEach(addon => {
if (addon.catalogs && addon.catalogs.length > 0) {
// Create a map to store unique catalogs by their type and id
const uniqueCatalogs = new Map<string, CatalogSetting>();
addon.catalogs.forEach(catalog => {
// Create a unique key that includes addon id, type, and catalog id
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
// Format catalog name
let displayName = catalog.name || catalog.id;
// If catalog is a movie or series catalog, make that clear
const catalogType = catalog.type === 'movie' ? 'Movies' : catalog.type === 'series' ? 'TV Shows' : catalog.type.charAt(0).toUpperCase() + catalog.type.slice(1);
uniqueCatalogs.set(settingKey, {
@ -86,18 +97,17 @@ const CatalogSettingsScreen = () => {
catalogId: catalog.id,
type: catalog.type,
name: displayName,
enabled: savedCatalogs[settingKey] !== undefined ? savedCatalogs[settingKey] : true // Enable by default
enabled: savedEnabledSettings[settingKey] !== undefined ? savedEnabledSettings[settingKey] : true,
customName: savedCustomNames[settingKey]
});
});
// Add unique catalogs to the available catalogs array
availableCatalogs.push(...uniqueCatalogs.values());
}
});
// Group settings by addon name
const grouped: GroupedCatalogs = {};
availableCatalogs.forEach(setting => {
const addon = addons.find(a => a.id === setting.addonId);
if (!addon) return;
@ -106,7 +116,7 @@ const CatalogSettingsScreen = () => {
grouped[setting.addonId] = {
name: addon.name,
catalogs: [],
expanded: true, // Start expanded
expanded: true,
enabledCount: 0
};
}
@ -126,8 +136,8 @@ const CatalogSettingsScreen = () => {
}
}, []);
// Save settings when they change
const saveSettings = async (newSettings: CatalogSetting[]) => {
// Save settings when they change (ENABLE/DISABLE ONLY)
const saveEnabledSettings = async (newSettings: CatalogSetting[]) => {
try {
const settingsObj: CatalogSettingsStorage = {
_lastUpdate: Date.now()
@ -139,11 +149,11 @@ const CatalogSettingsScreen = () => {
await AsyncStorage.setItem(CATALOG_SETTINGS_KEY, JSON.stringify(settingsObj));
refreshCatalogs(); // Trigger catalog refresh after saving settings
} catch (error) {
logger.error('Failed to save catalog settings:', error);
logger.error('Failed to save catalog enabled settings:', error);
}
};
// Toggle individual catalog
// Toggle individual catalog enabled state
const toggleCatalog = (addonId: string, index: number) => {
const newSettings = [...settings];
const catalogsForAddon = groupedSettings[addonId].catalogs;
@ -154,7 +164,6 @@ const CatalogSettingsScreen = () => {
enabled: !setting.enabled
};
// Update the setting in the flat list
const flatIndex = newSettings.findIndex(s =>
s.addonId === setting.addonId &&
s.type === setting.type &&
@ -165,14 +174,13 @@ const CatalogSettingsScreen = () => {
newSettings[flatIndex] = updatedSetting;
}
// Update the grouped settings
const newGroupedSettings = { ...groupedSettings };
newGroupedSettings[addonId].catalogs[index] = updatedSetting;
newGroupedSettings[addonId].enabledCount += updatedSetting.enabled ? 1 : -1;
setSettings(newSettings);
setGroupedSettings(newGroupedSettings);
saveSettings(newSettings);
saveEnabledSettings(newSettings); // Use specific save function
};
// Toggle expansion of a group
@ -186,6 +194,47 @@ const CatalogSettingsScreen = () => {
}));
};
// Handle long press on catalog item
const handleLongPress = (setting: CatalogSetting) => {
setCatalogToRename(setting);
setCurrentRenameValue(setting.customName || setting.name);
setIsRenameModalVisible(true);
};
// Handle saving the renamed catalog
const handleSaveRename = async () => {
if (!catalogToRename || !currentRenameValue) return;
const settingKey = `${catalogToRename.addonId}:${catalogToRename.type}:${catalogToRename.catalogId}`;
try {
const savedCustomNamesJson = await AsyncStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
const customNames: { [key: string]: string } = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
const trimmedNewName = currentRenameValue.trim();
if (trimmedNewName === catalogToRename.name || trimmedNewName === '') {
delete customNames[settingKey];
} else {
customNames[settingKey] = trimmedNewName;
}
await AsyncStorage.setItem(CATALOG_CUSTOM_NAMES_KEY, JSON.stringify(customNames));
// --- Reload settings to reflect the change ---
await loadSettings();
// --- No need to manually update local state anymore ---
} catch (error) {
logger.error('Failed to save custom catalog name:', error);
Alert.alert('Error', 'Could not save the custom name.'); // Inform user
} finally {
setIsRenameModalVisible(false);
setCatalogToRename(null);
setCurrentRenameValue('');
}
};
useEffect(() => {
loadSettings();
}, [loadSettings]);
@ -252,10 +301,17 @@ const CatalogSettingsScreen = () => {
</TouchableOpacity>
{group.expanded && group.catalogs.map((setting, index) => (
<View key={`${setting.addonId}:${setting.type}:${setting.catalogId}`} style={styles.catalogItem}>
<Pressable
key={`${setting.addonId}:${setting.type}:${setting.catalogId}`}
onLongPress={() => handleLongPress(setting)} // Added long press handler
style={({ pressed }) => [
styles.catalogItem,
pressed && styles.catalogItemPressed, // Optional pressed style
]}
>
<View style={styles.catalogInfo}>
<Text style={styles.catalogName}>
{setting.name}
{setting.customName || setting.name} {/* Display custom or default name */}
</Text>
<Text style={styles.catalogType}>
{setting.type.charAt(0).toUpperCase() + setting.type.slice(1)}
@ -268,26 +324,68 @@ const CatalogSettingsScreen = () => {
thumbColor={Platform.OS === 'android' ? colors.white : undefined}
ios_backgroundColor="#505050"
/>
</View>
</Pressable>
))}
</View>
</View>
))}
<View style={styles.addonSection}>
<Text style={styles.addonTitle}>ORGANIZATION</Text>
<View style={styles.card}>
<TouchableOpacity style={styles.organizationItem}>
<Text style={styles.organizationItemText}>Reorder Sections</Text>
<MaterialIcons name="chevron-right" size={24} color={colors.mediumGray} />
</TouchableOpacity>
<TouchableOpacity style={styles.organizationItem}>
<Text style={styles.organizationItemText}>Customize Names</Text>
<MaterialIcons name="chevron-right" size={24} color={colors.mediumGray} />
</TouchableOpacity>
</View>
</View>
</ScrollView>
{/* Rename Modal */}
<Modal
animationType="fade"
transparent={true}
visible={isRenameModalVisible}
onRequestClose={() => {
setIsRenameModalVisible(false);
setCatalogToRename(null);
}}
>
{Platform.OS === 'ios' ? (
<Pressable style={styles.modalOverlay} onPress={() => setIsRenameModalVisible(false)}>
<BlurView
style={styles.modalContent}
intensity={90}
tint="default"
>
<Pressable onPress={(e) => e.stopPropagation()}>
<Text style={styles.modalTitle}>Rename Catalog</Text>
<TextInput
style={styles.modalInput}
value={currentRenameValue}
onChangeText={setCurrentRenameValue}
placeholder="Enter new catalog name"
placeholderTextColor={colors.mediumGray}
autoFocus={true}
/>
<View style={styles.modalButtons}>
<Button title="Cancel" onPress={() => setIsRenameModalVisible(false)} color={colors.mediumGray} />
<Button title="Save" onPress={handleSaveRename} color={colors.primary} />
</View>
</Pressable>
</BlurView>
</Pressable>
) : (
<Pressable style={styles.modalOverlay} onPress={() => setIsRenameModalVisible(false)}>
<Pressable style={styles.modalContent} onPress={(e) => e.stopPropagation()}>
<Text style={styles.modalTitle}>Rename Catalog</Text>
<TextInput
style={styles.modalInput}
value={currentRenameValue}
onChangeText={setCurrentRenameValue}
placeholder="Enter new catalog name"
placeholderTextColor={colors.mediumGray}
autoFocus={true}
/>
<View style={styles.modalButtons}>
<Button title="Cancel" onPress={() => setIsRenameModalVisible(false)} color={colors.mediumGray} />
<Button title="Save" onPress={handleSaveRename} color={colors.primary} />
</View>
</Pressable>
</Pressable>
)}
</Modal>
</SafeAreaView>
);
};
@ -385,9 +483,14 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
borderBottomWidth: 0.5,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
// Ensure last item doesn't have border if needed (check logic)
},
catalogItemPressed: {
backgroundColor: 'rgba(255, 255, 255, 0.05)', // Subtle feedback for press
},
catalogInfo: {
flex: 1,
marginRight: 8, // Add space before switch
},
catalogName: {
fontSize: 15,
@ -398,18 +501,47 @@ const styles = StyleSheet.create({
fontSize: 13,
color: colors.mediumGray,
},
organizationItem: {
flexDirection: 'row',
justifyContent: 'space-between',
// Modal Styles
modalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 0.5,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
},
organizationItemText: {
fontSize: 17,
modalContent: {
backgroundColor: Platform.OS === 'ios' ? undefined : colors.elevation3,
borderRadius: 14,
padding: 20,
width: '85%',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 10,
elevation: 10,
overflow: 'hidden',
},
modalTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.white,
marginBottom: 15,
textAlign: 'center',
},
modalInput: {
backgroundColor: colors.elevation1, // Darker input background
color: colors.white,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
marginBottom: 20,
borderWidth: 1,
borderColor: colors.border,
},
modalButtons: {
flexDirection: 'row',
justifyContent: 'space-around', // Adjust as needed (e.g., 'flex-end')
},
});

View file

@ -189,7 +189,6 @@ const CatalogSection = React.memo(({
const { width } = Dimensions.get('window');
const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing
// Only display the first 3 items in the row
const displayItems = useMemo(() =>
catalog.items.slice(0, 3),
[catalog.items]
@ -207,13 +206,31 @@ const CatalogSection = React.memo(({
), [handleContentPress]);
const handleSeeMorePress = useCallback(() => {
// Get addon/catalog info from the first item (assuming homogeneity)
const firstItem = catalog.items[0];
if (!firstItem) return; // Should not happen if section exists
// We need addonId and catalogId. These aren't directly on StreamingContent.
// We might need to fetch this or adjust the GenreCatalog structure.
// FOR NOW: Assuming CatalogScreen can handle potentially missing addonId/catalogId
// OR: We could pass the *genre* as the name and let CatalogScreen figure it out?
// Let's pass the necessary info if available, assuming StreamingContent might have it
// (Requires checking StreamingContent interface or how it's populated)
// --- TEMPORARY/PLACEHOLDER ---
// Ideally, GenreCatalog should contain addonId/catalogId for the group.
// If not, CatalogScreen needs modification or we fetch IDs here.
// Let's stick to passing genre and type for now, CatalogScreen logic might suffice?
navigation.navigate('Catalog', {
id: 'discover',
// Don't pass an addonId since we want to filter by genre across all addons
id: catalog.genre,
type: selectedCategory.type,
name: `${catalog.genre} ${selectedCategory.name}`,
genreFilter: catalog.genre
genreFilter: catalog.genre // This will trigger the genre-based filtering logic in CatalogScreen
});
}, [navigation, selectedCategory, catalog.genre]);
// --- END TEMPORARY ---
}, [navigation, selectedCategory, catalog.genre, catalog.items]);
const keyExtractor = useCallback((item: StreamingContent) => item.id, []);
const ItemSeparator = useCallback(() => <View style={{ width: 16 }} />, []);

View file

@ -18,6 +18,7 @@ import { useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../styles/colors';
import { catalogService, StreamingAddon } from '../services/catalogService';
import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
@ -36,6 +37,7 @@ const HeroCatalogsScreen: React.FC = () => {
const [loading, setLoading] = useState(true);
const [catalogs, setCatalogs] = useState<CatalogItem[]>([]);
const [selectedCatalogs, setSelectedCatalogs] = useState<string[]>(settings.selectedHeroCatalogs || []);
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
const handleBack = useCallback(() => {
navigation.goBack();
@ -125,7 +127,7 @@ const HeroCatalogsScreen: React.FC = () => {
</Text>
</View>
{loading ? (
{loading || isLoadingCustomNames ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
@ -175,30 +177,35 @@ const HeroCatalogsScreen: React.FC = () => {
styles.catalogsContainer,
{ backgroundColor: isDarkMode ? colors.elevation1 : colors.white }
]}>
{addonCatalogs.map(catalog => (
<TouchableOpacity
key={catalog.id}
style={[
styles.catalogItem,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }
]}
onPress={() => toggleCatalog(catalog.id)}
>
<View style={styles.catalogInfo}>
<Text style={[styles.catalogName, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
{catalog.name}
</Text>
<Text style={[styles.catalogType, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
{catalog.type === 'movie' ? 'Movies' : 'TV Shows'}
</Text>
</View>
<MaterialIcons
name={selectedCatalogs.includes(catalog.id) ? "check-box" : "check-box-outline-blank"}
size={24}
color={selectedCatalogs.includes(catalog.id) ? colors.primary : isDarkMode ? colors.mediumEmphasis : colors.textMutedDark}
/>
</TouchableOpacity>
))}
{addonCatalogs.map(catalog => {
const [addonId, type, catalogId] = catalog.id.split(':');
const displayName = getCustomName(addonId, type, catalogId, catalog.name);
return (
<TouchableOpacity
key={catalog.id}
style={[
styles.catalogItem,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }
]}
onPress={() => toggleCatalog(catalog.id)}
>
<View style={styles.catalogInfo}>
<Text style={[styles.catalogName, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
{displayName}
</Text>
<Text style={[styles.catalogType, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
{catalog.type === 'movie' ? 'Movies' : 'TV Shows'}
</Text>
</View>
<MaterialIcons
name={selectedCatalogs.includes(catalog.id) ? "check-box" : "check-box-outline-blank"}
size={24}
color={selectedCatalogs.includes(catalog.id) ? colors.primary : isDarkMode ? colors.mediumEmphasis : colors.textMutedDark}
/>
</TouchableOpacity>
);
})}
</View>
</View>
))}

View file

@ -142,7 +142,7 @@ const ActionButtons = React.memo(({
}
}}
>
<MaterialIcons name="star-rate" size={24} color="#fff" />
<MaterialIcons name="assessment" size={24} color="#fff" />
</TouchableOpacity>
)}
</Animated.View>
@ -214,6 +214,7 @@ const MetadataScreen = () => {
recommendations,
loadingRecommendations,
setMetadata,
imdbId,
} = useMetadata({ id, type });
// Get genres from context
@ -258,7 +259,7 @@ const MetadataScreen = () => {
// Fetch logo immediately for TMDB content
useEffect(() => {
if (metadata && id.startsWith('tmdb:')) {
if (metadata && id.startsWith('tmdb:') && !metadata.logo) {
const fetchLogo = async () => {
try {
const tmdbId = id.split(':')[1];
@ -984,9 +985,9 @@ const MetadataScreen = () => {
</View>
{/* Add RatingsSection right under the main metadata */}
{id && (
{imdbId && (
<RatingsSection
imdbId={id}
imdbId={imdbId}
type={type === 'series' ? 'show' : 'movie'}
/>
)}

View file

@ -0,0 +1,313 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
SafeAreaView,
Platform,
useColorScheme,
TouchableOpacity,
StatusBar,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useSettings, AppSettings } from '../hooks/useSettings';
import { colors } from '../styles/colors';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
interface SettingItemProps {
title: string;
description?: string;
icon: string;
isDarkMode: boolean;
isSelected: boolean;
onPress: () => void;
isLast?: boolean;
}
const SettingItem: React.FC<SettingItemProps> = ({
title,
description,
icon,
isDarkMode,
isSelected,
onPress,
isLast,
}) => (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={[
styles.settingItem,
!isLast && styles.settingItemBorder,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' },
]}
>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }
]}>
<MaterialIcons
name={icon}
size={20}
color={colors.primary}
/>
</View>
<View style={styles.settingText}>
<Text
style={[
styles.settingTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark },
]}
>
{title}
</Text>
{description && (
<Text
style={[
styles.settingDescription,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark },
]}
>
{description}
</Text>
)}
</View>
{isSelected && (
<MaterialIcons
name="check"
size={24}
color={colors.primary}
style={styles.checkIcon}
/>
)}
</View>
</TouchableOpacity>
);
const PlayerSettingsScreen: React.FC = () => {
const { settings, updateSetting } = useSettings();
const systemColorScheme = useColorScheme();
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
const navigation = useNavigation();
const playerOptions = [
{
id: 'internal',
title: 'Built-in Player',
description: 'Use the app\'s default video player',
icon: 'play-circle-outline',
},
...(Platform.OS === 'ios' ? [
{
id: 'vlc',
title: 'VLC',
description: 'Open streams in VLC media player',
icon: 'video-library',
},
{
id: 'infuse',
title: 'Infuse',
description: 'Open streams in Infuse player',
icon: 'smart-display',
},
{
id: 'outplayer',
title: 'OutPlayer',
description: 'Open streams in OutPlayer',
icon: 'slideshow',
},
{
id: 'vidhub',
title: 'VidHub',
description: 'Open streams in VidHub player',
icon: 'ondemand-video',
},
] : [
{
id: 'external',
title: 'External Player',
description: 'Open streams in your preferred video player',
icon: 'open-in-new',
},
]),
];
const handleBack = () => {
navigation.goBack();
};
return (
<SafeAreaView
style={[
styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' },
]}
>
<StatusBar
translucent
backgroundColor="transparent"
barStyle={isDarkMode ? "light-content" : "dark-content"}
/>
<View style={styles.header}>
<TouchableOpacity
onPress={handleBack}
style={styles.backButton}
activeOpacity={0.7}
>
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark}
/>
</TouchableOpacity>
<Text
style={[
styles.headerTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark },
]}
>
Video Player
</Text>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark },
]}
>
PLAYER SELECTION
</Text>
<View
style={[
styles.card,
{
backgroundColor: isDarkMode
? colors.elevation2
: colors.white,
},
]}
>
{playerOptions.map((option, index) => (
<SettingItem
key={option.id}
title={option.title}
description={option.description}
icon={option.icon}
isDarkMode={isDarkMode}
isSelected={
Platform.OS === 'ios'
? settings.preferredPlayer === option.id
: settings.useExternalPlayer === (option.id === 'external')
}
onPress={() => {
if (Platform.OS === 'ios') {
updateSetting('preferredPlayer', option.id as AppSettings['preferredPlayer'], false);
} else {
updateSetting('useExternalPlayer', option.id === 'external', false);
}
}}
isLast={index === playerOptions.length - 1}
/>
))}
</View>
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16,
paddingBottom: 8,
},
backButton: {
padding: 8,
marginRight: 16,
borderRadius: 20,
},
headerTitle: {
fontSize: 20,
fontWeight: '600',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 24,
},
section: {
paddingHorizontal: 16,
paddingTop: 24,
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
marginBottom: 8,
paddingHorizontal: 4,
letterSpacing: 0.5,
},
card: {
borderRadius: 12,
overflow: 'hidden',
marginBottom: 24,
shadowColor: 'rgba(0,0,0,0.1)',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.5,
shadowRadius: 4,
elevation: 3,
},
settingItem: {
paddingVertical: 16,
paddingHorizontal: 16,
},
settingItemBorder: {
borderBottomWidth: 1,
},
settingContent: {
flexDirection: 'row',
alignItems: 'center',
},
settingIconContainer: {
width: 36,
height: 36,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
marginRight: 16,
},
settingText: {
flex: 1,
},
settingTitle: {
fontSize: 16,
fontWeight: '500',
marginBottom: 2,
},
settingDescription: {
fontSize: 14,
marginTop: 2,
},
checkIcon: {
marginLeft: 16,
},
});
export default PlayerSettingsScreen;

View file

@ -12,34 +12,48 @@ import {
Alert,
Platform,
Dimensions,
Pressable
Image
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { Picker } from '@react-native-picker/picker';
import { colors } from '../styles/colors';
import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings';
import { RootStackParamList } from '../navigation/AppNavigator';
import { stremioService } from '../services/stremioService';
import { useCatalogContext } from '../contexts/CatalogContext';
import { useTraktContext } from '../contexts/TraktContext';
import { catalogService, DataSource } from '../services/catalogService';
const { width } = Dimensions.get('window');
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Card component for iOS Fluent design style
// Card component with modern style
interface SettingsCardProps {
children: React.ReactNode;
isDarkMode: boolean;
title?: string;
}
const SettingsCard: React.FC<SettingsCardProps> = ({ children, isDarkMode }) => (
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
]}>
{children}
const SettingsCard: React.FC<SettingsCardProps> = ({ children, isDarkMode, title }) => (
<View style={[styles.cardContainer]}>
{title && (
<Text style={[
styles.cardTitle,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
{title.toUpperCase()}
</Text>
)}
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
]}>
{children}
</View>
</View>
);
@ -51,6 +65,7 @@ interface SettingItemProps {
isLast?: boolean;
onPress?: () => void;
isDarkMode: boolean;
badge?: string | number;
}
const SettingItem: React.FC<SettingItemProps> = ({
@ -60,7 +75,8 @@ const SettingItem: React.FC<SettingItemProps> = ({
renderControl,
isLast = false,
onPress,
isDarkMode
isDarkMode,
badge
}) => {
return (
<TouchableOpacity
@ -72,11 +88,14 @@ const SettingItem: React.FC<SettingItemProps> = ({
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }
]}
>
<View style={styles.settingIconContainer}>
<MaterialIcons name={icon} size={22} color={colors.primary} />
<View style={[
styles.settingIconContainer,
{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }
]}>
<MaterialIcons name={icon} size={20} color={colors.primary} />
</View>
<View style={styles.settingContent}>
<View style={styles.settingTitleRow}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
{title}
</Text>
@ -86,6 +105,11 @@ const SettingItem: React.FC<SettingItemProps> = ({
</Text>
)}
</View>
{badge && (
<View style={[styles.badge, { backgroundColor: colors.primary }]}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
)}
</View>
<View style={styles.settingControl}>
{renderControl()}
@ -94,28 +118,19 @@ const SettingItem: React.FC<SettingItemProps> = ({
);
};
const SectionHeader: React.FC<{ title: string; isDarkMode: boolean }> = ({ title, isDarkMode }) => (
<View style={styles.sectionHeader}>
<Text style={[
styles.sectionHeaderText,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
{title}
</Text>
</View>
);
const SettingsScreen: React.FC = () => {
const { settings, updateSetting } = useSettings();
const systemColorScheme = useColorScheme();
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { lastUpdate } = useCatalogContext();
const { isAuthenticated, userProfile } = useTraktContext();
// States for dynamic content
const [addonCount, setAddonCount] = useState<number>(0);
const [catalogCount, setCatalogCount] = useState<number>(0);
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
const [discoverDataSource, setDiscoverDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS);
const loadData = useCallback(async () => {
try {
@ -149,6 +164,10 @@ const SettingsScreen: React.FC = () => {
// Check MDBList API key status
const mdblistKey = await AsyncStorage.getItem('mdblist_api_key');
setMdblistKeySet(!!mdblistKey);
// Get discover data source preference
const dataSource = await catalogService.getDataSourcePreference();
setDiscoverDataSource(dataSource);
} catch (error) {
console.error('Error loading settings data:', error);
}
@ -200,11 +219,18 @@ const SettingsScreen: React.FC = () => {
const ChevronRight = () => (
<MaterialIcons
name="chevron-right"
size={24}
size={22}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/>
);
// Handle data source change
const handleDiscoverDataSourceChange = useCallback(async (value: string) => {
const dataSource = value as DataSource;
setDiscoverDataSource(dataSource);
await catalogService.setDataSourcePreference(dataSource);
}, []);
return (
<SafeAreaView style={[
styles.container,
@ -215,68 +241,77 @@ const SettingsScreen: React.FC = () => {
<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}
>
<SectionHeader title="USER & ACCOUNT" isDarkMode={isDarkMode} />
<SettingsCard isDarkMode={isDarkMode}>
<SettingsCard isDarkMode={isDarkMode} title="User & Account">
<SettingItem
title="Trakt"
description="Not Connected"
description={isAuthenticated ? `Connected as ${userProfile?.username || 'User'}` : "Not Connected"}
icon="person"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => Alert.alert('Trakt', 'Trakt integration coming soon')}
/>
<SettingItem
title="iCloud Sync"
description="Enabled"
icon="cloud"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('TraktSettings')}
isLast={true}
/>
</SettingsCard>
<SectionHeader title="CONTENT" isDarkMode={isDarkMode} />
<SettingsCard isDarkMode={isDarkMode}>
<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={addonCount + " installed"}
description="Manage your installed addons"
icon="extension"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('Addons')}
badge={addonCount}
/>
<SettingItem
title="Catalogs"
description={`${catalogCount} ${catalogCount === 1 ? 'catalog' : 'catalogs'} enabled`}
description="Configure content sources"
icon="view-list"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('CatalogSettings')}
badge={catalogCount}
/>
<SettingItem
title="Home Screen"
description="Customize home layout and content"
description="Customize layout and content"
icon="home"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('HomeScreenSettings')}
/>
<SettingItem
title="Folders"
description="0 created"
icon="folder"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
/>
<SettingItem
title="Ratings Source"
description={mdblistKeySet ? "MDBList API Configured" : "MDBList API Not Set"}
description={mdblistKeySet ? "MDBList API Configured" : "Configure MDBList API"}
icon="info-outline"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
@ -289,41 +324,71 @@ const SettingsScreen: React.FC = () => {
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('TMDBSettings')}
/>
<SettingItem
title="Resource Filters"
icon="tune"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
/>
<SettingItem
title="AI Features"
description="Not Connected"
icon="auto-awesome"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
isLast={true}
/>
</SettingsCard>
<SectionHeader title="PLAYBACK" isDarkMode={isDarkMode} />
<SettingsCard isDarkMode={isDarkMode}>
<SettingsCard isDarkMode={isDarkMode} title="Playback">
<SettingItem
title="Video Player"
description="Infuse"
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}
/>
<SettingItem
title="Auto-Filtering"
description="Disabled"
icon="tune"
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>
</SafeAreaView>
);
@ -337,82 +402,150 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 12,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
headerTitle: {
fontSize: 34,
fontSize: 32,
fontWeight: '700',
letterSpacing: 0.5,
},
resetButton: {
paddingVertical: 6,
paddingHorizontal: 12,
},
resetButtonText: {
fontSize: 15,
fontWeight: '600',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 32,
},
sectionHeader: {
paddingHorizontal: 16,
paddingTop: 20,
paddingBottom: 8,
cardContainer: {
marginBottom: 20,
},
sectionHeaderText: {
fontSize: 12,
cardTitle: {
fontSize: 13,
fontWeight: '600',
letterSpacing: 0.8,
marginLeft: 16,
marginBottom: 8,
},
card: {
marginHorizontal: 16,
borderRadius: 12,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
elevation: 3,
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 0.5,
minHeight: 44,
minHeight: 58,
},
settingItemBorder: {
// Border styling handled directly in the component with borderBottomWidth
},
settingIconContainer: {
marginRight: 12,
width: 24,
height: 24,
marginRight: 16,
width: 36,
height: 36,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
settingContent: {
flex: 1,
marginRight: 8,
flexDirection: 'row',
alignItems: 'center',
},
settingTextContainer: {
flex: 1,
},
settingTitleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8,
},
settingTitle: {
fontSize: 15,
fontWeight: '400',
flex: 1,
fontSize: 16,
fontWeight: '500',
marginBottom: 3,
},
settingDescription: {
fontSize: 14,
opacity: 0.7,
textAlign: 'right',
flexShrink: 1,
maxWidth: '60%',
opacity: 0.8,
},
settingControl: {
justifyContent: 'center',
alignItems: 'center',
paddingLeft: 8,
paddingLeft: 12,
},
badge: {
height: 22,
minWidth: 22,
borderRadius: 11,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 6,
marginRight: 8,
},
badgeText: {
color: 'white',
fontSize: 12,
fontWeight: '600',
},
versionContainer: {
alignItems: 'center',
justifyContent: 'center',
marginTop: 10,
marginBottom: 20,
},
versionText: {
fontSize: 14,
},
pickerContainer: {
flex: 1,
},
picker: {
flex: 1,
},
selectorContainer: {
flexDirection: 'row',
borderRadius: 8,
overflow: 'hidden',
height: 36,
width: 160,
marginRight: 8,
},
selectorButton: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 12,
backgroundColor: 'rgba(255,255,255,0.08)',
},
selectorButtonActive: {
backgroundColor: colors.primary,
},
selectorText: {
fontSize: 14,
fontWeight: '500',
color: colors.mediumEmphasis,
},
selectorTextActive: {
color: colors.white,
fontWeight: '600',
},
});

View file

@ -11,6 +11,7 @@ import {
StatusBar,
} from 'react-native';
import { Image } from 'expo-image';
import { BlurView } from 'expo-blur';
import { colors } from '../styles';
import { TMDBService, TMDBShow as Show, TMDBSeason, TMDBEpisode } from '../services/tmdbService';
import { RouteProp } from '@react-navigation/native';
@ -367,7 +368,14 @@ const ShowRatingsScreen = ({ route }: Props) => {
}
return (
<View style={[styles.container, { backgroundColor: colors.black }]}>
<View style={[styles.container, { backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.black }]}>
{Platform.OS === 'ios' && (
<BlurView
style={StyleSheet.absoluteFill}
tint="dark"
intensity={60}
/>
)}
<StatusBar
translucent
backgroundColor="transparent"
@ -528,7 +536,6 @@ const ShowRatingsScreen = ({ route }: Props) => {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.black,
},
scrollView: {
flex: 1,
@ -548,7 +555,7 @@ const styles = StyleSheet.create({
showInfo: {
flexDirection: 'row',
marginBottom: 12,
backgroundColor: colors.darkBackground,
backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground,
borderRadius: 8,
padding: 8,
},
@ -641,7 +648,7 @@ const styles = StyleSheet.create({
lineHeight: 16,
},
legend: {
backgroundColor: colors.darkBackground,
backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground,
borderRadius: 8,
padding: 8,
marginBottom: 12,
@ -693,7 +700,7 @@ const styles = StyleSheet.create({
flex: 1,
},
ratingsGrid: {
backgroundColor: colors.darkBackground,
backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.darkBackground,
borderRadius: 8,
padding: 8,
},

View file

@ -12,7 +12,8 @@ import {
ScrollView,
StatusBar,
Alert,
Dimensions
Dimensions,
Linking
} from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { RouteProp } from '@react-navigation/native';
@ -343,6 +344,22 @@ export const StreamsScreen = () => {
);
}, [selectedEpisode, groupedEpisodes, id]);
const navigateToPlayer = useCallback((stream: Stream) => {
navigation.navigate('Player', {
uri: stream.url,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
season: type === 'series' ? currentEpisode?.season_number : undefined,
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
year: metadata?.year,
streamProvider: stream.name,
id,
type,
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
});
}, [metadata, type, currentEpisode, navigation, id, selectedEpisode]);
// Update handleStreamPress
const handleStreamPress = useCallback(async (stream: Stream) => {
try {
@ -350,63 +367,164 @@ export const StreamsScreen = () => {
logger.log('handleStreamPress called with stream:', {
url: stream.url,
behaviorHints: stream.behaviorHints,
useExternalPlayer: settings.useExternalPlayer
useExternalPlayer: settings.useExternalPlayer,
preferredPlayer: settings.preferredPlayer
});
// Check if external player is enabled in settings
if (settings.useExternalPlayer) {
logger.log('Using external player for URL:', stream.url);
// Use VideoPlayerService to launch external player
const videoPlayerService = VideoPlayerService;
const launched = await videoPlayerService.playVideo(stream.url, {
useExternalPlayer: true,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
episodeNumber: type === 'series' ? `S${currentEpisode?.season_number}E${currentEpisode?.episode_number}` : undefined,
releaseDate: metadata?.year?.toString(),
});
if (!launched) {
logger.log('External player launch failed, falling back to built-in player');
navigation.navigate('Player', {
uri: stream.url,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
season: type === 'series' ? currentEpisode?.season_number : undefined,
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
year: metadata?.year,
streamProvider: stream.name,
id,
type,
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
});
// For iOS, try to open with the preferred external player
if (Platform.OS === 'ios' && settings.preferredPlayer !== 'internal') {
try {
// Format the URL for the selected player
const streamUrl = encodeURIComponent(stream.url);
let externalPlayerUrls: string[] = [];
// Configure URL formats based on the selected player
switch (settings.preferredPlayer) {
case 'vlc':
externalPlayerUrls = [
`vlc://${stream.url}`,
`vlc-x-callback://x-callback-url/stream?url=${streamUrl}`,
`vlc://${streamUrl}`
];
break;
case 'outplayer':
externalPlayerUrls = [
`outplayer://${stream.url}`,
`outplayer://${streamUrl}`,
`outplayer://play?url=${streamUrl}`,
`outplayer://stream?url=${streamUrl}`,
`outplayer://play/browser?url=${streamUrl}`
];
break;
case 'infuse':
externalPlayerUrls = [
`infuse://x-callback-url/play?url=${streamUrl}`,
`infuse://play?url=${streamUrl}`,
`infuse://${streamUrl}`
];
break;
case 'vidhub':
externalPlayerUrls = [
`vidhub://play?url=${streamUrl}`,
`vidhub://${streamUrl}`
];
break;
default:
// If no matching player or the setting is somehow invalid, use internal player
navigateToPlayer(stream);
return;
}
console.log(`Attempting to open stream in ${settings.preferredPlayer}`);
// Try each URL format in sequence
const tryNextUrl = (index: number) => {
if (index >= externalPlayerUrls.length) {
console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`);
// Try direct URL as last resort
Linking.openURL(stream.url)
.then(() => console.log('Opened with direct URL'))
.catch(() => {
console.log('Direct URL failed, falling back to built-in player');
navigateToPlayer(stream);
});
return;
}
const url = externalPlayerUrls[index];
console.log(`Trying ${settings.preferredPlayer} URL format ${index + 1}: ${url}`);
Linking.openURL(url)
.then(() => console.log(`Successfully opened stream with ${settings.preferredPlayer} format ${index + 1}`))
.catch(err => {
console.log(`Format ${index + 1} failed: ${err.message}`, err);
tryNextUrl(index + 1);
});
};
// Start with the first URL format
tryNextUrl(0);
} catch (error) {
console.error(`Error with ${settings.preferredPlayer}:`, error);
// Fallback to the built-in player
navigateToPlayer(stream);
}
} else {
// Use built-in player
navigation.navigate('Player', {
uri: stream.url,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
season: type === 'series' ? currentEpisode?.season_number : undefined,
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
year: metadata?.year,
streamProvider: stream.name,
id,
type,
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
});
}
// For Android with external player preference
else if (Platform.OS === 'android' && settings.useExternalPlayer) {
try {
console.log('Opening stream with Android native app chooser');
// For Android, determine if the URL is a direct http/https URL or a magnet link
const isMagnet = stream.url.startsWith('magnet:');
if (isMagnet) {
// For magnet links, open directly which will trigger the torrent app chooser
console.log('Opening magnet link directly');
Linking.openURL(stream.url)
.then(() => console.log('Successfully opened magnet link'))
.catch(err => {
console.error('Failed to open magnet link:', err);
// No good fallback for magnet links
navigateToPlayer(stream);
});
} else {
// For direct video URLs, use the S.Browser.ACTION_VIEW approach
// This is a more reliable way to force Android to show all video apps
// Strip query parameters if they exist as they can cause issues with some apps
let cleanUrl = stream.url;
if (cleanUrl.includes('?')) {
cleanUrl = cleanUrl.split('?')[0];
}
// Create an Android intent URL that forces the chooser
// Set component=null to ensure chooser is shown
// Set action=android.intent.action.VIEW to open the content
const intentUrl = `intent:${cleanUrl}#Intent;action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;component=;type=video/*;launchFlags=0x10000000;end`;
console.log(`Using intent URL: ${intentUrl}`);
Linking.openURL(intentUrl)
.then(() => console.log('Successfully opened with intent URL'))
.catch(err => {
console.error('Failed to open with intent URL:', err);
// First fallback: Try direct URL with regular Linking API
console.log('Trying plain URL as fallback');
Linking.openURL(stream.url)
.then(() => console.log('Opened with direct URL'))
.catch(directErr => {
console.error('Failed to open direct URL:', directErr);
// Final fallback: Use built-in player
console.log('All external player attempts failed, using built-in player');
navigateToPlayer(stream);
});
});
}
} catch (error) {
console.error('Error with external player:', error);
// Fallback to the built-in player
navigateToPlayer(stream);
}
}
else {
// For internal player or if other options failed, use the built-in player
navigateToPlayer(stream);
}
}
} catch (error) {
logger.error('Stream error:', error);
Alert.alert(
'Playback Error',
error instanceof Error ? error.message : 'An error occurred while playing the video'
);
console.error('Error in handleStreamPress:', error);
// Final fallback: Use built-in player
navigateToPlayer(stream);
}
}, [metadata, type, currentEpisode, navigation, settings.useExternalPlayer]);
}, [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer]);
const filterItems = useMemo(() => {
const installedAddons = stremioService.getInstalledAddons();

View file

@ -0,0 +1,485 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
Alert,
Image,
SafeAreaView,
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 MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { traktService, TraktUser } from '../services/traktService';
import { colors } from '../styles/colors';
import { useSettings } from '../hooks/useSettings';
import { logger } from '../utils/logger';
import TraktIcon from '../../assets/rating-icons/trakt.svg';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// For use with deep linking
const redirectUri = makeRedirectUri({
scheme: 'stremioexpo',
path: 'auth/trakt',
});
const TraktSettingsScreen: React.FC = () => {
const { settings } = useSettings();
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);
const checkAuthStatus = useCallback(async () => {
setIsLoading(true);
try {
const authenticated = await traktService.isAuthenticated();
setIsAuthenticated(authenticated);
if (authenticated) {
const profile = await traktService.getUserProfile();
setUserProfile(profile);
}
} catch (error) {
logger.error('[TraktSettingsScreen] Error checking auth status:', error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
checkAuthStatus();
}, [checkAuthStatus]);
// Handle deep linking when returning from Trakt authorization
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);
}
}
};
// 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.');
}
};
const handleSignOut = async () => {
Alert.alert(
'Sign Out',
'Are you sure you want to sign out of your Trakt account?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Sign Out',
style: 'destructive',
onPress: async () => {
setIsLoading(true);
try {
await traktService.logout();
setIsAuthenticated(false);
setUserProfile(null);
} catch (error) {
logger.error('[TraktSettingsScreen] Error signing out:', error);
Alert.alert('Error', 'Failed to sign out of Trakt.');
} finally {
setIsLoading(false);
}
}
}
]
);
};
return (
<SafeAreaView style={[
styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.header}>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
>
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark}
/>
</TouchableOpacity>
<Text style={[
styles.headerTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
Trakt Settings
</Text>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
]}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : isAuthenticated && userProfile ? (
<View style={styles.profileContainer}>
<View style={styles.profileHeader}>
{userProfile.avatar ? (
<Image
source={{ uri: userProfile.avatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatarPlaceholder, { backgroundColor: colors.primary }]}>
<Text style={styles.avatarText}>
{userProfile.name?.charAt(0) || userProfile.username.charAt(0)}
</Text>
</View>
)}
<View style={styles.profileInfo}>
<Text style={[
styles.profileName,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
{userProfile.name || userProfile.username}
</Text>
<Text style={[
styles.profileUsername,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
@{userProfile.username}
</Text>
{userProfile.vip && (
<View style={styles.vipBadge}>
<Text style={styles.vipText}>VIP</Text>
</View>
)}
</View>
</View>
<View style={styles.statsContainer}>
<Text style={[
styles.joinedDate,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
Joined {new Date(userProfile.joined_at).toLocaleDateString()}
</Text>
</View>
<TouchableOpacity
style={[
styles.button,
styles.signOutButton,
{ backgroundColor: isDarkMode ? 'rgba(255,59,48,0.1)' : 'rgba(255,59,48,0.08)' }
]}
onPress={handleSignOut}
>
<Text style={[styles.buttonText, { color: '#FF3B30' }]}>
Sign Out
</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.signInContainer}>
<TraktIcon
width={120}
height={120}
style={styles.traktLogo}
/>
<Text style={[
styles.signInTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
Connect with Trakt
</Text>
<Text style={[
styles.signInDescription,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
Sync your watch history, watchlist, and collection with Trakt.tv
</Text>
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: isDarkMode ? colors.primary : colors.primary }
]}
onPress={handleSignIn}
disabled={isAuthenticating}
>
{isAuthenticating ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text style={styles.buttonText}>
Sign In with Trakt
</Text>
)}
</TouchableOpacity>
</View>
)}
</View>
{isAuthenticated && (
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
]}>
<View style={styles.settingsSection}>
<Text style={[
styles.sectionTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
Sync Settings
</Text>
<View style={styles.settingItem}>
<Text style={[
styles.settingLabel,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
Auto-sync playback progress
</Text>
<Text style={[
styles.settingDescription,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
Coming soon
</Text>
</View>
<View style={styles.settingItem}>
<Text style={[
styles.settingLabel,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark }
]}>
Import watched history
</Text>
<Text style={[
styles.settingDescription,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
Coming soon
</Text>
</View>
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: isDarkMode ? 'rgba(120,120,128,0.2)' : 'rgba(120,120,128,0.1)' }
]}
disabled={true}
>
<Text style={[
styles.buttonText,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
Sync Now (Coming Soon)
</Text>
</TouchableOpacity>
</View>
</View>
)}
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16,
},
backButton: {
padding: 4,
},
headerTitle: {
fontSize: 22,
fontWeight: '600',
marginLeft: 16,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 32,
},
card: {
borderRadius: 12,
overflow: 'hidden',
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
loadingContainer: {
padding: 40,
alignItems: 'center',
justifyContent: 'center',
},
signInContainer: {
padding: 24,
alignItems: 'center',
},
traktLogo: {
width: 120,
height: 120,
marginBottom: 20,
},
signInTitle: {
fontSize: 20,
fontWeight: '600',
marginBottom: 8,
textAlign: 'center',
},
signInDescription: {
fontSize: 15,
textAlign: 'center',
marginBottom: 24,
paddingHorizontal: 20,
},
button: {
width: '100%',
height: 44,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
},
signOutButton: {
marginTop: 20,
},
buttonText: {
fontSize: 16,
fontWeight: '500',
color: 'white',
},
profileContainer: {
padding: 20,
},
profileHeader: {
flexDirection: 'row',
alignItems: 'center',
},
avatar: {
width: 64,
height: 64,
borderRadius: 32,
},
avatarPlaceholder: {
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
fontSize: 26,
fontWeight: 'bold',
color: 'white',
},
profileInfo: {
marginLeft: 16,
flex: 1,
},
profileName: {
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
profileUsername: {
fontSize: 14,
},
vipBadge: {
marginTop: 4,
paddingHorizontal: 8,
paddingVertical: 2,
backgroundColor: '#FFD700',
borderRadius: 4,
alignSelf: 'flex-start',
},
vipText: {
fontSize: 10,
fontWeight: 'bold',
color: '#000',
},
statsContainer: {
marginTop: 16,
paddingTop: 16,
borderTopWidth: 0.5,
borderTopColor: 'rgba(150,150,150,0.2)',
},
joinedDate: {
fontSize: 14,
},
settingsSection: {
padding: 20,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 16,
},
settingItem: {
marginBottom: 16,
},
settingLabel: {
fontSize: 15,
fontWeight: '500',
marginBottom: 4,
},
settingDescription: {
fontSize: 14,
},
});
export default TraktSettingsScreen;

View file

@ -3,6 +3,16 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import axios from 'axios';
import { TMDBService } from './tmdbService';
import { logger } from '../utils/logger';
import { getCatalogDisplayName } from '../utils/catalogNameUtils';
// Add a constant for storing the data source preference
const DATA_SOURCE_KEY = 'discover_data_source';
// Define data source types
export enum DataSource {
STREMIO_ADDONS = 'stremio_addons',
TMDB = 'tmdb',
}
export interface StreamingAddon {
id: string;
@ -55,6 +65,8 @@ export interface CatalogContent {
items: StreamingContent[];
}
const CATALOG_SETTINGS_KEY = 'catalog_settings';
class CatalogService {
private static instance: CatalogService;
private readonly LIBRARY_KEY = 'stremio-library';
@ -137,43 +149,38 @@ class CatalogService {
const addons = await this.getAllAddons();
const catalogs: CatalogContent[] = [];
// Get saved catalog settings
const savedSettings = await AsyncStorage.getItem('catalog_settings');
const catalogSettings: { [key: string]: boolean } = savedSettings ? JSON.parse(savedSettings) : {};
// Load enabled/disabled settings
const catalogSettingsJson = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY);
const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
// Get featured catalogs
// Process addons in order (they're already returned in order from getAllAddons)
for (const addon of addons) {
if (addon.catalogs && addon.catalogs.length > 0) {
// For each catalog, check if it's enabled in settings
if (addon.catalogs) {
for (const catalog of addon.catalogs) {
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
// If setting doesn't exist, default to true for backward compatibility
const isEnabled = catalogSettings[settingKey] ?? true;
if (isEnabled) {
try {
// Get the items for this catalog
const addonManifest = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifest.find(a => a.id === addon.id);
if (!manifest) continue;
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
if (metas && metas.length > 0) {
// Convert Meta to StreamingContent
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
// Format the catalog name
let displayName = catalog.name;
// Get potentially custom display name
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
// Remove duplicate words and clean up the name (case-insensitive)
const words = displayName.split(' ');
const uniqueWords = [];
const seenWords = new Set();
for (const word of words) {
const lowerWord = word.toLowerCase();
if (!seenWords.has(lowerWord)) {
uniqueWords.push(word); // Keep original case
uniqueWords.push(word);
seenWords.add(lowerWord);
}
}
@ -194,7 +201,7 @@ class CatalogService {
});
}
} catch (error) {
logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error);
logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error);
}
}
}
@ -205,10 +212,18 @@ class CatalogService {
}
async getCatalogByType(type: string, genreFilter?: string): Promise<CatalogContent[]> {
// Get the data source preference (default to Stremio addons)
const dataSourcePreference = await this.getDataSourcePreference();
// If TMDB is selected as the data source, use TMDB API
if (dataSourcePreference === DataSource.TMDB) {
return this.getCatalogByTypeFromTMDB(type, genreFilter);
}
// Otherwise use the original Stremio addons method
const addons = await this.getAllAddons();
const catalogs: CatalogContent[] = [];
// Filter addons with catalogs of the specified type
const typeAddons = addons.filter(addon =>
addon.catalogs && addon.catalogs.some(catalog => catalog.type === type)
);
@ -222,18 +237,20 @@ class CatalogService {
const manifest = addonManifest.find(a => a.id === addon.id);
if (!manifest) continue;
// Apply genre filter if provided
const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (metas && metas.length > 0) {
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
// Get potentially custom display name
const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
catalogs.push({
addon: addon.id,
type,
id: catalog.id,
name: catalog.name,
name: displayName,
genre: genreFilter,
items
});
@ -247,6 +264,148 @@ class CatalogService {
return catalogs;
}
/**
* Get catalog content from TMDB by type and genre
*/
private async getCatalogByTypeFromTMDB(type: string, genreFilter?: string): Promise<CatalogContent[]> {
const tmdbService = TMDBService.getInstance();
const catalogs: CatalogContent[] = [];
try {
// Map Stremio content type to TMDB content type
const tmdbType = type === 'movie' ? 'movie' : 'tv';
// If no genre filter or All is selected, get multiple catalogs
if (!genreFilter || genreFilter === 'All') {
// Get trending
const trendingItems = await tmdbService.getTrending(tmdbType, 'week');
const trendingItemsPromises = trendingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const trendingStreamingItems = await Promise.all(trendingItemsPromises);
catalogs.push({
addon: 'tmdb',
type,
id: 'trending',
name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
items: trendingStreamingItems
});
// Get popular
const popularItems = await tmdbService.getPopular(tmdbType, 1);
const popularItemsPromises = popularItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const popularStreamingItems = await Promise.all(popularItemsPromises);
catalogs.push({
addon: 'tmdb',
type,
id: 'popular',
name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
items: popularStreamingItems
});
// Get upcoming/on air
const upcomingItems = await tmdbService.getUpcoming(tmdbType, 1);
const upcomingItemsPromises = upcomingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const upcomingStreamingItems = await Promise.all(upcomingItemsPromises);
catalogs.push({
addon: 'tmdb',
type,
id: 'upcoming',
name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows',
items: upcomingStreamingItems
});
} else {
// Get content by genre
const genreItems = await tmdbService.discoverByGenre(tmdbType, genreFilter);
const streamingItemsPromises = genreItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const streamingItems = await Promise.all(streamingItemsPromises);
catalogs.push({
addon: 'tmdb',
type,
id: 'discover',
name: `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
genre: genreFilter,
items: streamingItems
});
}
} catch (error) {
logger.error(`Failed to get catalog from TMDB for type ${type}, genre ${genreFilter}:`, error);
}
return catalogs;
}
/**
* Convert TMDB trending/discover result to StreamingContent format
*/
private async convertTMDBToStreamingContent(item: any, type: 'movie' | 'tv'): Promise<StreamingContent> {
const id = item.external_ids?.imdb_id || `tmdb:${item.id}`;
const name = type === 'movie' ? item.title : item.name;
const posterPath = item.poster_path;
// Get genres from genre_ids
let genres: string[] = [];
if (item.genre_ids && item.genre_ids.length > 0) {
try {
const tmdbService = TMDBService.getInstance();
const genreLists = type === 'movie'
? await tmdbService.getMovieGenres()
: await tmdbService.getTvGenres();
const genreIds: number[] = item.genre_ids;
genres = genreIds
.map(genreId => {
const genre = genreLists.find(g => g.id === genreId);
return genre ? genre.name : null;
})
.filter(Boolean) as string[];
} catch (error) {
logger.error('Failed to get genres for TMDB content:', error);
}
}
return {
id,
type: type === 'movie' ? 'movie' : 'series',
name: name || 'Unknown',
poster: posterPath ? `https://image.tmdb.org/t/p/w500${posterPath}` : 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
posterShape: 'poster',
banner: item.backdrop_path ? `https://image.tmdb.org/t/p/original${item.backdrop_path}` : undefined,
year: type === 'movie'
? (item.release_date ? new Date(item.release_date).getFullYear() : undefined)
: (item.first_air_date ? new Date(item.first_air_date).getFullYear() : undefined),
description: item.overview,
genres,
inLibrary: this.library[`${type === 'movie' ? 'movie' : 'series'}:${id}`] !== undefined,
};
}
/**
* Get the current data source preference
*/
async getDataSourcePreference(): Promise<DataSource> {
try {
const dataSource = await AsyncStorage.getItem(DATA_SOURCE_KEY);
return dataSource as DataSource || DataSource.STREMIO_ADDONS;
} catch (error) {
logger.error('Failed to get data source preference:', error);
return DataSource.STREMIO_ADDONS;
}
}
/**
* Set the data source preference
*/
async setDataSourcePreference(dataSource: DataSource): Promise<void> {
try {
await AsyncStorage.setItem(DATA_SOURCE_KEY, dataSource);
} catch (error) {
logger.error('Failed to set data source preference:', error);
}
}
async getContentDetails(type: string, id: string): Promise<StreamingContent | null> {
try {
// Try up to 3 times with increasing delays

Some files were not shown because too many files have changed in this diff Show more