Initial commit: NUVIO Expo app with navigation and header components
52
App.tsx
|
|
@ -1,20 +1,52 @@
|
||||||
import { StatusBar } from 'expo-status-bar';
|
/**
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
* Sample React Native App
|
||||||
|
* https://github.com/facebook/react-native
|
||||||
|
*
|
||||||
|
* @format
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
StyleSheet
|
||||||
|
} from 'react-native';
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { Provider as PaperProvider } from 'react-native-paper';
|
||||||
|
import AppNavigator, {
|
||||||
|
CustomNavigationDarkTheme,
|
||||||
|
CustomDarkTheme
|
||||||
|
} from './src/navigation/AppNavigator';
|
||||||
|
import 'react-native-reanimated';
|
||||||
|
import { CatalogProvider } from './src/contexts/CatalogContext';
|
||||||
|
|
||||||
|
function App(): React.JSX.Element {
|
||||||
|
// Always use dark mode
|
||||||
|
const isDarkMode = true;
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<Text>Open up App.tsx to start working on your app!</Text>
|
<CatalogProvider>
|
||||||
<StatusBar style="auto" />
|
<PaperProvider theme={CustomDarkTheme}>
|
||||||
</View>
|
<NavigationContainer theme={CustomNavigationDarkTheme}>
|
||||||
|
<View style={[styles.container, { backgroundColor: '#000000' }]}>
|
||||||
|
<StatusBar
|
||||||
|
style="light"
|
||||||
|
/>
|
||||||
|
<AppNavigator />
|
||||||
|
</View>
|
||||||
|
</NavigationContainer>
|
||||||
|
</PaperProvider>
|
||||||
|
</CatalogProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#fff',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
|
||||||
16
android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# OSX
|
||||||
|
#
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Android/IntelliJ
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
*.hprof
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Bundle artifacts
|
||||||
|
*.jsbundle
|
||||||
176
android/app/build.gradle
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
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.stremio.expo'
|
||||||
|
defaultConfig {
|
||||||
|
applicationId 'com.stremio.expo'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
android/app/debug.keystore
Normal file
14
android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# 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:
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<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>
|
||||||
34
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
|
<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">
|
||||||
|
<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.stremio.expo"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
65
android/app/src/main/java/com/stremio/expo/MainActivity.kt
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
package com.stremio.expo
|
||||||
|
import expo.modules.splashscreen.SplashScreenManager
|
||||||
|
|
||||||
|
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);
|
||||||
|
// @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
|
||||||
|
SplashScreenManager.registerOnActivity(this)
|
||||||
|
// @generated end expo-splashscreen
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package com.stremio.expo
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
android/app/src/main/res/drawable-hdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
android/app/src/main/res/drawable-mdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
|
|
@ -0,0 +1,6 @@
|
||||||
|
<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>
|
||||||
37
android/app/src/main/res/drawable/rn_edit_text_material.xml
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?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>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?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>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?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>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 24 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
1
android/app/src/main/res/values-night/colors.xml
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<resources/>
|
||||||
6
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<resources>
|
||||||
|
<color name="splashscreen_background">#ffffff</color>
|
||||||
|
<color name="iconBackground">#ffffff</color>
|
||||||
|
<color name="colorPrimary">#023c69</color>
|
||||||
|
<color name="colorPrimaryDark">#ffffff</color>
|
||||||
|
</resources>
|
||||||
6
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">stremio-expo</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>
|
||||||
19
android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<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="Theme.SplashScreen">
|
||||||
|
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
||||||
|
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
|
||||||
|
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
41
android/build.gradle
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// 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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
56
android/gradle.properties
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# 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
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
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
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
#!/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
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
@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
|
||||||
1164
android/hs_err_pid14924.log
Normal file
10257
android/replay_pid14924.log
Normal file
38
android/settings.gradle
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
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 = 'stremio-expo'
|
||||||
|
|
||||||
|
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())
|
||||||
21
app.json
|
|
@ -3,7 +3,7 @@
|
||||||
"name": "stremio-expo",
|
"name": "stremio-expo",
|
||||||
"slug": "stremio-expo",
|
"slug": "stremio-expo",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "default",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
|
|
@ -13,16 +13,31 @@
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"infoPlist": {
|
||||||
|
"NSAppTransportSecurity": {
|
||||||
|
"NSAllowsArbitraryLoads": true
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
}
|
},
|
||||||
|
"permissions": [
|
||||||
|
"INTERNET",
|
||||||
|
"WAKE_LOCK"
|
||||||
|
],
|
||||||
|
"package": "com.stremio.expo"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "7a199ed7-d3dc-46df-b39e-2ddf68089981"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
assets/audio/profile-selected.mp3
Normal file
BIN
assets/fonts/SpaceMono-Regular.ttf
Normal file
BIN
assets/fonts/arialic.ttf
Normal file
BIN
assets/gifs/demo.gif
Normal file
|
After Width: | Height: | Size: 26 MiB |
BIN
assets/images/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/images/replace-these/coming-soon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
assets/images/replace-these/download-netflix-icon.png
Normal file
|
After Width: | Height: | Size: 774 B |
BIN
assets/images/replace-these/download-netflix-transparent.png
Normal file
|
After Width: | Height: | Size: 420 B |
BIN
assets/images/replace-these/everyone-watching.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/images/replace-these/new-netflix-outline.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
assets/images/replace-these/new-netflix.png
Normal file
|
After Width: | Height: | Size: 429 B |
BIN
assets/images/replace-these/top10.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
assets/images/splash.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
9
babel.config.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: [
|
||||||
|
'react-native-reanimated/plugin',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
27
eas.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.2.2",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"distribution": "store",
|
||||||
|
"android": {
|
||||||
|
"buildType": "app-bundle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
1330
package-lock.json
generated
36
package.json
|
|
@ -4,19 +4,49 @@
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^14.1.0",
|
||||||
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||||
|
"@react-navigation/native": "^7.1.6",
|
||||||
|
"@react-navigation/native-stack": "^7.3.10",
|
||||||
|
"@react-navigation/stack": "^7.2.10",
|
||||||
|
"@shopify/flash-list": "1.7.3",
|
||||||
|
"@types/lodash": "^4.17.16",
|
||||||
|
"@types/react-native-video": "^5.0.20",
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~52.0.43",
|
"expo": "~52.0.43",
|
||||||
|
"expo-av": "^15.0.2",
|
||||||
|
"expo-blur": "^14.0.3",
|
||||||
|
"expo-image": "~2.0.7",
|
||||||
|
"expo-linear-gradient": "~14.0.2",
|
||||||
|
"expo-notifications": "~0.29.14",
|
||||||
|
"expo-screen-orientation": "~8.0.4",
|
||||||
|
"expo-sharing": "^13.0.1",
|
||||||
|
"expo-splash-screen": "^0.29.22",
|
||||||
"expo-status-bar": "~2.0.1",
|
"expo-status-bar": "~2.0.1",
|
||||||
|
"expo-system-ui": "^4.0.9",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-native": "0.76.9"
|
"react-native": "0.76.9",
|
||||||
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
|
"react-native-immersive-mode": "^2.0.2",
|
||||||
|
"react-native-paper": "^5.13.1",
|
||||||
|
"react-native-reanimated": "~3.16.1",
|
||||||
|
"react-native-safe-area-context": "4.12.0",
|
||||||
|
"react-native-screens": "~4.4.0",
|
||||||
|
"react-native-video": "^6.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
"@types/react": "~18.3.12",
|
"@types/react": "~18.3.12",
|
||||||
|
"@types/react-native": "^0.72.8",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
|
|
|
||||||
BIN
src/assets/dolbyvision.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
64
src/components/NuvioHeader.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, Platform, StyleSheet } from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { colors } from '../styles/colors';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import type { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
|
||||||
|
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
|
interface NuvioHeaderProps {
|
||||||
|
routeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NuvioHeader: React.FC<NuvioHeaderProps> = ({ routeName }) => {
|
||||||
|
const navigation = useNavigation<NavigationProp>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.contentContainer}>
|
||||||
|
<Text style={styles.title}>NUVIO</Text>
|
||||||
|
{routeName === 'Home' && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.searchButton}
|
||||||
|
onPress={() => navigation.navigate('Search')}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="magnify"
|
||||||
|
size={28}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
height: Platform.OS === 'ios' ? 96 : 80,
|
||||||
|
paddingTop: Platform.OS === 'ios' ? 48 : 32,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '900',
|
||||||
|
color: colors.white,
|
||||||
|
letterSpacing: 1,
|
||||||
|
fontFamily: Platform.OS === 'ios' ? 'System' : 'sans-serif-black',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginLeft: Platform.OS === 'ios' ? -4 : -8,
|
||||||
|
},
|
||||||
|
searchButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 16,
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
317
src/components/calendar/CalendarSection.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
Dimensions
|
||||||
|
} from 'react-native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
addMonths,
|
||||||
|
subMonths,
|
||||||
|
startOfMonth,
|
||||||
|
endOfMonth,
|
||||||
|
isSameMonth,
|
||||||
|
isSameDay,
|
||||||
|
getDay,
|
||||||
|
isToday,
|
||||||
|
parseISO
|
||||||
|
} from 'date-fns';
|
||||||
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const COLUMN_COUNT = 7; // 7 days in a week
|
||||||
|
const DAY_ITEM_SIZE = width / 9; // Slightly smaller than 1/7 to fit all days
|
||||||
|
|
||||||
|
interface CalendarEpisode {
|
||||||
|
id: string;
|
||||||
|
releaseDate: string;
|
||||||
|
// Other properties can be included but aren't needed for the calendar
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayItemProps {
|
||||||
|
date: Date;
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
isToday: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
hasEvents: boolean;
|
||||||
|
onPress: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarSectionProps {
|
||||||
|
episodes?: CalendarEpisode[];
|
||||||
|
onSelectDate?: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DayItem = ({
|
||||||
|
date,
|
||||||
|
isCurrentMonth,
|
||||||
|
isToday: today,
|
||||||
|
isSelected,
|
||||||
|
hasEvents,
|
||||||
|
onPress
|
||||||
|
}: DayItemProps) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.dayItem,
|
||||||
|
today && styles.todayItem,
|
||||||
|
isSelected && styles.selectedItem,
|
||||||
|
hasEvents && styles.dayWithEvents
|
||||||
|
]}
|
||||||
|
onPress={() => onPress(date)}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.dayText,
|
||||||
|
!isCurrentMonth && styles.otherMonthDay,
|
||||||
|
today && styles.todayText,
|
||||||
|
isSelected && styles.selectedDayText
|
||||||
|
]}>
|
||||||
|
{date.getDate()}
|
||||||
|
</Text>
|
||||||
|
{hasEvents && (
|
||||||
|
<View style={styles.eventIndicator} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CalendarSection: React.FC<CalendarSectionProps> = ({
|
||||||
|
episodes = [],
|
||||||
|
onSelectDate
|
||||||
|
}) => {
|
||||||
|
console.log(`[CalendarSection] Rendering with ${episodes.length} episodes`);
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
|
// Map of dates with episodes
|
||||||
|
const [datesWithEpisodes, setDatesWithEpisodes] = useState<{ [key: string]: boolean }>({});
|
||||||
|
|
||||||
|
// Process episodes to identify dates with content
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(`[CalendarSection] Processing ${episodes.length} episodes for calendar dots`);
|
||||||
|
const dateMap: { [key: string]: boolean } = {};
|
||||||
|
|
||||||
|
episodes.forEach(episode => {
|
||||||
|
if (episode.releaseDate) {
|
||||||
|
const releaseDate = parseISO(episode.releaseDate);
|
||||||
|
const dateKey = format(releaseDate, 'yyyy-MM-dd');
|
||||||
|
dateMap[dateKey] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[CalendarSection] Found ${Object.keys(dateMap).length} unique dates with episodes`);
|
||||||
|
setDatesWithEpisodes(dateMap);
|
||||||
|
}, [episodes]);
|
||||||
|
|
||||||
|
const goToPreviousMonth = () => {
|
||||||
|
setCurrentDate(prevDate => subMonths(prevDate, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNextMonth = () => {
|
||||||
|
setCurrentDate(prevDate => addMonths(prevDate, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDayPress = (date: Date) => {
|
||||||
|
setSelectedDate(date);
|
||||||
|
if (onSelectDate) {
|
||||||
|
onSelectDate(date);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate days for the current month view
|
||||||
|
const generateDaysForMonth = () => {
|
||||||
|
const monthStart = startOfMonth(currentDate);
|
||||||
|
const monthEnd = endOfMonth(currentDate);
|
||||||
|
const startDate = new Date(monthStart);
|
||||||
|
|
||||||
|
// Adjust the start date to the beginning of the week
|
||||||
|
const dayOfWeek = getDay(startDate);
|
||||||
|
startDate.setDate(startDate.getDate() - dayOfWeek);
|
||||||
|
|
||||||
|
// Ensure we have 6 complete weeks in our view
|
||||||
|
const endDate = new Date(monthEnd);
|
||||||
|
const lastDayOfWeek = getDay(endDate);
|
||||||
|
if (lastDayOfWeek < 6) {
|
||||||
|
endDate.setDate(endDate.getDate() + (6 - lastDayOfWeek));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get dates for a complete 6-week calendar
|
||||||
|
const totalDaysNeeded = 42; // 6 weeks × 7 days
|
||||||
|
const daysInView = [];
|
||||||
|
|
||||||
|
let currentDateInView = new Date(startDate);
|
||||||
|
for (let i = 0; i < totalDaysNeeded; i++) {
|
||||||
|
daysInView.push(new Date(currentDateInView));
|
||||||
|
currentDateInView.setDate(currentDateInView.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return daysInView;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayItems = generateDaysForMonth();
|
||||||
|
|
||||||
|
// Break days into rows (6 rows of 7 days each)
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 0; i < dayItems.length; i += COLUMN_COUNT) {
|
||||||
|
rows.push(dayItems.slice(i, i + COLUMN_COUNT));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get weekday names for header
|
||||||
|
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeIn.duration(300)} style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={goToPreviousMonth} style={styles.headerButton}>
|
||||||
|
<MaterialIcons name="chevron-left" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={styles.monthTitle}>
|
||||||
|
{format(currentDate, 'MMMM yyyy')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={goToNextMonth} style={styles.headerButton}>
|
||||||
|
<MaterialIcons name="chevron-right" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.weekHeader}>
|
||||||
|
{weekDays.map((day, index) => (
|
||||||
|
<View key={index} style={styles.weekHeaderItem}>
|
||||||
|
<Text style={styles.weekDayText}>{day}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.calendarGrid}>
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<View key={rowIndex} style={styles.row}>
|
||||||
|
{row.map((date, cellIndex) => {
|
||||||
|
const isCurrentMonthDay = isSameMonth(date, currentDate);
|
||||||
|
const isSelectedToday = isToday(date);
|
||||||
|
const isDateSelected = isSameDay(date, selectedDate);
|
||||||
|
|
||||||
|
// Check if this date has episodes
|
||||||
|
const dateKey = format(date, 'yyyy-MM-dd');
|
||||||
|
const hasEvents = datesWithEpisodes[dateKey] || false;
|
||||||
|
|
||||||
|
// Log every 7 days to avoid console spam
|
||||||
|
if (cellIndex === 0 && rowIndex === 0) {
|
||||||
|
console.log(`[CalendarSection] Sample date check - ${dateKey}: hasEvents=${hasEvents}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayItem
|
||||||
|
key={cellIndex}
|
||||||
|
date={date}
|
||||||
|
isCurrentMonth={isCurrentMonthDay}
|
||||||
|
isToday={isSelectedToday}
|
||||||
|
isSelected={isDateSelected}
|
||||||
|
hasEvents={hasEvents}
|
||||||
|
onPress={handleDayPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
headerButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
monthTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
weekHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
padding: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
weekHeaderItem: {
|
||||||
|
width: DAY_ITEM_SIZE,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
weekDayText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.lightGray,
|
||||||
|
},
|
||||||
|
calendarGrid: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
dayItem: {
|
||||||
|
width: DAY_ITEM_SIZE,
|
||||||
|
height: DAY_ITEM_SIZE,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: DAY_ITEM_SIZE / 2,
|
||||||
|
},
|
||||||
|
dayText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
otherMonthDay: {
|
||||||
|
color: colors.lightGray + '80', // 50% opacity
|
||||||
|
},
|
||||||
|
todayItem: {
|
||||||
|
backgroundColor: colors.primary + '30', // 30% opacity
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
selectedItem: {
|
||||||
|
backgroundColor: colors.primary + '60', // 60% opacity
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
todayText: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
selectedDayText: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
dayWithEvents: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
eventIndicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 4,
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
});
|
||||||
409
src/components/home/ThisWeekSection.tsx
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions
|
||||||
|
} from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
import { stremioService } from '../../services/stremioService';
|
||||||
|
import { tmdbService } from '../../services/tmdbService';
|
||||||
|
import { useLibrary } from '../../hooks/useLibrary';
|
||||||
|
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
|
import { parseISO, isThisWeek, format, isAfter, isBefore } from 'date-fns';
|
||||||
|
import Animated, { FadeIn, FadeInRight } from 'react-native-reanimated';
|
||||||
|
import { catalogService } from '../../services/catalogService';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const ITEM_WIDTH = width * 0.85;
|
||||||
|
const ITEM_HEIGHT = 180;
|
||||||
|
|
||||||
|
interface ThisWeekEpisode {
|
||||||
|
id: string;
|
||||||
|
seriesId: string;
|
||||||
|
seriesName: string;
|
||||||
|
title: string;
|
||||||
|
poster: string;
|
||||||
|
releaseDate: string;
|
||||||
|
season: number;
|
||||||
|
episode: number;
|
||||||
|
isReleased: boolean;
|
||||||
|
overview: string;
|
||||||
|
vote_average: number;
|
||||||
|
still_path: string | null;
|
||||||
|
season_poster_path: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThisWeekSection = () => {
|
||||||
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
const { libraryItems, loading: libraryLoading } = useLibrary();
|
||||||
|
const [episodes, setEpisodes] = useState<ThisWeekEpisode[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchThisWeekEpisodes = useCallback(async () => {
|
||||||
|
if (libraryItems.length === 0) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seriesItems = libraryItems.filter(item => item.type === 'series');
|
||||||
|
let allEpisodes: ThisWeekEpisode[] = [];
|
||||||
|
|
||||||
|
for (const series of seriesItems) {
|
||||||
|
try {
|
||||||
|
const metadata = await stremioService.getMetaDetails(series.type, series.id);
|
||||||
|
|
||||||
|
if (metadata?.videos) {
|
||||||
|
// Get TMDB ID for additional metadata
|
||||||
|
const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id);
|
||||||
|
let tmdbEpisodes: { [key: string]: any } = {};
|
||||||
|
|
||||||
|
if (tmdbId) {
|
||||||
|
const allTMDBEpisodes = await tmdbService.getAllEpisodes(tmdbId);
|
||||||
|
// Flatten episodes into a map for easy lookup
|
||||||
|
Object.values(allTMDBEpisodes).forEach(seasonEpisodes => {
|
||||||
|
seasonEpisodes.forEach(episode => {
|
||||||
|
const key = `${episode.season_number}:${episode.episode_number}`;
|
||||||
|
tmdbEpisodes[key] = episode;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const thisWeekEpisodes = metadata.videos
|
||||||
|
.filter(video => {
|
||||||
|
if (!video.released) return false;
|
||||||
|
const releaseDate = parseISO(video.released);
|
||||||
|
return isThisWeek(releaseDate);
|
||||||
|
})
|
||||||
|
.map(video => {
|
||||||
|
const releaseDate = parseISO(video.released);
|
||||||
|
const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: video.id,
|
||||||
|
seriesId: series.id,
|
||||||
|
seriesName: series.name || metadata.name,
|
||||||
|
title: tmdbEpisode.name || video.title || `Episode ${video.episode}`,
|
||||||
|
poster: series.poster || metadata.poster || '',
|
||||||
|
releaseDate: video.released,
|
||||||
|
season: video.season || 0,
|
||||||
|
episode: video.episode || 0,
|
||||||
|
isReleased: isBefore(releaseDate, new Date()),
|
||||||
|
overview: tmdbEpisode.overview || '',
|
||||||
|
vote_average: tmdbEpisode.vote_average || 0,
|
||||||
|
still_path: tmdbEpisode.still_path || null,
|
||||||
|
season_poster_path: tmdbEpisode.season_poster_path || null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
allEpisodes = [...allEpisodes, ...thisWeekEpisodes];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching episodes for ${series.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort episodes by release date
|
||||||
|
allEpisodes.sort((a, b) => {
|
||||||
|
return new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
setEpisodes(allEpisodes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching this week episodes:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [libraryItems]);
|
||||||
|
|
||||||
|
// Subscribe to library updates
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = catalogService.subscribeToLibraryUpdates(() => {
|
||||||
|
console.log('[ThisWeekSection] Library updated, refreshing episodes');
|
||||||
|
fetchThisWeekEpisodes();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [fetchThisWeekEpisodes]);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
if (!libraryLoading) {
|
||||||
|
fetchThisWeekEpisodes();
|
||||||
|
}
|
||||||
|
}, [libraryLoading, fetchThisWeekEpisodes]);
|
||||||
|
|
||||||
|
const handleEpisodePress = (episode: ThisWeekEpisode) => {
|
||||||
|
// For upcoming episodes, go to the metadata screen
|
||||||
|
if (!episode.isReleased) {
|
||||||
|
navigation.navigate('Metadata', {
|
||||||
|
id: episode.seriesId,
|
||||||
|
type: 'series'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For released episodes, go to the streams screen
|
||||||
|
const episodeId = `${episode.seriesId}:${episode.season}:${episode.episode}`;
|
||||||
|
navigation.navigate('Streams', {
|
||||||
|
id: episode.seriesId,
|
||||||
|
type: 'series',
|
||||||
|
episodeId
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewAll = () => {
|
||||||
|
navigation.navigate('Calendar' as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => {
|
||||||
|
const releaseDate = parseISO(item.releaseDate);
|
||||||
|
const formattedDate = format(releaseDate, 'E, MMM d');
|
||||||
|
const isReleased = item.isReleased;
|
||||||
|
|
||||||
|
// Use episode still image if available, fallback to series poster
|
||||||
|
const imageUrl = item.still_path ?
|
||||||
|
tmdbService.getImageUrl(item.still_path) :
|
||||||
|
(item.season_poster_path ?
|
||||||
|
tmdbService.getImageUrl(item.season_poster_path) :
|
||||||
|
item.poster);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInRight.delay(index * 100).duration(400)}
|
||||||
|
style={styles.episodeItemContainer}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.episodeItem}
|
||||||
|
onPress={() => handleEpisodePress(item)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={styles.poster}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LinearGradient
|
||||||
|
colors={['transparent', 'rgba(0,0,0,0.8)', 'rgba(0,0,0,0.9)']}
|
||||||
|
style={styles.gradient}
|
||||||
|
>
|
||||||
|
<View style={styles.badgeContainer}>
|
||||||
|
<View style={[
|
||||||
|
styles.badge,
|
||||||
|
isReleased ? styles.releasedBadge : styles.upcomingBadge
|
||||||
|
]}>
|
||||||
|
<MaterialIcons
|
||||||
|
name={isReleased ? "check-circle" : "event"}
|
||||||
|
size={12}
|
||||||
|
color={isReleased ? "#ffffff" : "#ffffff"}
|
||||||
|
/>
|
||||||
|
<Text style={styles.badgeText}>
|
||||||
|
{isReleased ? 'Released' : 'Coming Soon'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.vote_average > 0 && (
|
||||||
|
<View style={styles.ratingBadge}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="star"
|
||||||
|
size={12}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
|
<Text style={styles.ratingText}>
|
||||||
|
{item.vote_average.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.seriesName} numberOfLines={1}>
|
||||||
|
{item.seriesName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.episodeTitle} numberOfLines={2}>
|
||||||
|
S{item.season}:E{item.episode} - {item.title}
|
||||||
|
</Text>
|
||||||
|
{item.overview ? (
|
||||||
|
<Text style={styles.overview} numberOfLines={2}>
|
||||||
|
{item.overview}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text style={styles.releaseDate}>
|
||||||
|
{formattedDate}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeIn.duration(300)} style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>This Week</Text>
|
||||||
|
<TouchableOpacity onPress={handleViewAll} style={styles.viewAllButton}>
|
||||||
|
<Text style={styles.viewAllText}>View All</Text>
|
||||||
|
<MaterialIcons name="chevron-right" size={18} color={colors.lightGray} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={episodes}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={renderEpisodeItem}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
snapToInterval={ITEM_WIDTH + 12}
|
||||||
|
decelerationRate="fast"
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginVertical: 16,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
viewAllButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
viewAllText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.lightGray,
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
padding: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
episodeItemContainer: {
|
||||||
|
width: ITEM_WIDTH,
|
||||||
|
height: ITEM_HEIGHT,
|
||||||
|
marginHorizontal: 6,
|
||||||
|
},
|
||||||
|
episodeItem: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
gradient: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: '70%',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
badgeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
releasedBadge: {
|
||||||
|
backgroundColor: colors.success + 'CC', // 80% opacity
|
||||||
|
},
|
||||||
|
upcomingBadge: {
|
||||||
|
backgroundColor: colors.primary + 'CC', // 80% opacity
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
ratingBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
ratingText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
seriesName: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
episodeTitle: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 6,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
releaseDate: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
});
|
||||||
139
src/components/metadata/CastSection.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
import { Cast } from '../../types/metadata';
|
||||||
|
import { tmdbService } from '../../services/tmdbService';
|
||||||
|
|
||||||
|
interface CastSectionProps {
|
||||||
|
cast: Cast[];
|
||||||
|
loadingCast: boolean;
|
||||||
|
onSelectCastMember: (member: Cast) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CastSection: React.FC<CastSectionProps> = ({
|
||||||
|
cast,
|
||||||
|
loadingCast,
|
||||||
|
onSelectCastMember,
|
||||||
|
}) => {
|
||||||
|
if (loadingCast) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cast.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.castSection}>
|
||||||
|
<Text style={styles.sectionTitle}>Cast</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.castScrollContainer}
|
||||||
|
contentContainerStyle={styles.castContainer}
|
||||||
|
snapToAlignment="start"
|
||||||
|
>
|
||||||
|
{cast.map((member) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={member.id}
|
||||||
|
style={styles.castMember}
|
||||||
|
onPress={() => onSelectCastMember(member)}
|
||||||
|
>
|
||||||
|
<View style={styles.castImageContainer}>
|
||||||
|
{member.profile_path && tmdbService.getImageUrl(member.profile_path, 'w185') ? (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: tmdbService.getImageUrl(member.profile_path, 'w185')!
|
||||||
|
}}
|
||||||
|
style={styles.castImage}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MaterialIcons
|
||||||
|
name="person"
|
||||||
|
size={40}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.castName} numberOfLines={1}>{member.name}</Text>
|
||||||
|
<Text style={styles.castCharacter} numberOfLines={2}>{member.character}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
loadingContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
castSection: {
|
||||||
|
marginTop: 0,
|
||||||
|
paddingLeft: 0,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: colors.highEmphasis,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '800',
|
||||||
|
marginBottom: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
castScrollContainer: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
castContainer: {
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
castMember: {
|
||||||
|
width: 100,
|
||||||
|
marginRight: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
castImageContainer: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: colors.elevation2,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
castImage: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
},
|
||||||
|
castName: {
|
||||||
|
color: colors.highEmphasis,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
castCharacter: {
|
||||||
|
color: colors.mediumEmphasis,
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
140
src/components/metadata/MoreLikeThisSection.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { useNavigation, StackActions } from '@react-navigation/native';
|
||||||
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
|
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
|
import { StreamingContent } from '../../types/metadata';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
import { TMDBService } from '../../services/tmdbService';
|
||||||
|
import { catalogService } from '../../services/catalogService';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const POSTER_WIDTH = (width - 48) / 3.5; // Adjust number for desired items visible
|
||||||
|
const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
|
||||||
|
|
||||||
|
interface MoreLikeThisSectionProps {
|
||||||
|
recommendations: StreamingContent[];
|
||||||
|
loadingRecommendations: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
|
||||||
|
recommendations,
|
||||||
|
loadingRecommendations
|
||||||
|
}) => {
|
||||||
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
|
||||||
|
const handleItemPress = async (item: StreamingContent) => {
|
||||||
|
try {
|
||||||
|
// Extract TMDB ID from the tmdb:123456 format
|
||||||
|
const tmdbId = item.id.replace('tmdb:', '');
|
||||||
|
|
||||||
|
// Get Stremio ID using catalogService
|
||||||
|
const stremioId = await catalogService.getStremioId(item.type, tmdbId);
|
||||||
|
|
||||||
|
if (stremioId) {
|
||||||
|
navigation.dispatch(
|
||||||
|
StackActions.push('Metadata', {
|
||||||
|
id: stremioId,
|
||||||
|
type: item.type
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('Could not find Stremio ID for TMDB ID:', tmdbId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error navigating to recommendation:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: StreamingContent }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.itemContainer}
|
||||||
|
onPress={() => handleItemPress(item)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.poster }}
|
||||||
|
style={styles.poster}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
<Text style={styles.title} numberOfLines={2}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loadingRecommendations) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recommendations || recommendations.length === 0) {
|
||||||
|
return null; // Don't render anything if there are no recommendations
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.sectionTitle}>More Like This</Text>
|
||||||
|
<FlatList
|
||||||
|
data={recommendations}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.listContentContainer}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginTop: 28,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.highEmphasis,
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
listContentContainer: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingRight: 48, // Ensure last item has padding
|
||||||
|
},
|
||||||
|
itemContainer: {
|
||||||
|
marginRight: 12,
|
||||||
|
width: POSTER_WIDTH,
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
width: POSTER_WIDTH,
|
||||||
|
height: POSTER_HEIGHT,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: colors.elevation1,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.mediumEmphasis,
|
||||||
|
fontWeight: '500',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
height: POSTER_HEIGHT + 40, // Approximate height to prevent layout shifts
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
66
src/components/metadata/MovieContent.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
import { StreamingContent } from '../../types/metadata';
|
||||||
|
|
||||||
|
interface MovieContentProps {
|
||||||
|
metadata: StreamingContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => {
|
||||||
|
const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0;
|
||||||
|
const castDisplay = hasCast ? (metadata.cast as string[]).slice(0, 5).join(', ') : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Additional metadata */}
|
||||||
|
<View style={styles.additionalInfo}>
|
||||||
|
{metadata.director && (
|
||||||
|
<View style={styles.metadataRow}>
|
||||||
|
<Text style={styles.metadataLabel}>Director:</Text>
|
||||||
|
<Text style={styles.metadataValue}>{metadata.director}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metadata.writer && (
|
||||||
|
<View style={styles.metadataRow}>
|
||||||
|
<Text style={styles.metadataLabel}>Writer:</Text>
|
||||||
|
<Text style={styles.metadataValue}>{metadata.writer}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasCast && (
|
||||||
|
<View style={styles.metadataRow}>
|
||||||
|
<Text style={styles.metadataLabel}>Cast:</Text>
|
||||||
|
<Text style={styles.metadataValue}>{castDisplay}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 24,
|
||||||
|
},
|
||||||
|
additionalInfo: {
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
metadataRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
metadataLabel: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 15,
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
|
metadataValue: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 15,
|
||||||
|
flex: 1,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
});
|
||||||
401
src/components/metadata/SeriesContent.tsx
Normal file
|
|
@ -0,0 +1,401 @@
|
||||||
|
import React 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';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
import { Episode } from '../../types/metadata';
|
||||||
|
import { tmdbService } from '../../services/tmdbService';
|
||||||
|
|
||||||
|
interface SeriesContentProps {
|
||||||
|
episodes: Episode[];
|
||||||
|
selectedSeason: number;
|
||||||
|
loadingSeasons: boolean;
|
||||||
|
onSeasonChange: (season: number) => void;
|
||||||
|
onSelectEpisode: (episode: Episode) => void;
|
||||||
|
groupedEpisodes?: { [seasonNumber: number]: Episode[] };
|
||||||
|
metadata?: { poster?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add placeholder constant at the top
|
||||||
|
const DEFAULT_PLACEHOLDER = 'https://via.placeholder.com/300x450/1a1a1a/666666?text=No+Image';
|
||||||
|
const EPISODE_PLACEHOLDER = 'https://via.placeholder.com/500x280/1a1a1a/666666?text=No+Preview';
|
||||||
|
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
|
||||||
|
|
||||||
|
export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
|
episodes,
|
||||||
|
selectedSeason,
|
||||||
|
loadingSeasons,
|
||||||
|
onSeasonChange,
|
||||||
|
onSelectEpisode,
|
||||||
|
groupedEpisodes = {},
|
||||||
|
metadata
|
||||||
|
}) => {
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const isTablet = width > 768;
|
||||||
|
const isDarkMode = useColorScheme() === 'dark';
|
||||||
|
|
||||||
|
if (loadingSeasons) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centeredContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={styles.centeredText}>Loading episodes...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centeredContainer}>
|
||||||
|
<MaterialIcons name="error-outline" size={48} color="#666" />
|
||||||
|
<Text style={styles.centeredText}>No episodes available</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSeasonSelector = () => {
|
||||||
|
if (!groupedEpisodes || Object.keys(groupedEpisodes).length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.seasonSelectorWrapper}>
|
||||||
|
<Text style={styles.seasonSelectorTitle}>Seasons</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.seasonSelectorContainer}
|
||||||
|
contentContainerStyle={styles.seasonSelectorContent}
|
||||||
|
>
|
||||||
|
{seasons.map(season => {
|
||||||
|
const seasonEpisodes = groupedEpisodes[season] || [];
|
||||||
|
let seasonPoster = DEFAULT_PLACEHOLDER;
|
||||||
|
if (seasonEpisodes[0]?.season_poster_path) {
|
||||||
|
const tmdbUrl = tmdbService.getImageUrl(seasonEpisodes[0].season_poster_path, 'w500');
|
||||||
|
if (tmdbUrl) seasonPoster = tmdbUrl;
|
||||||
|
} else if (metadata?.poster) {
|
||||||
|
seasonPoster = metadata.poster;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={season}
|
||||||
|
style={[
|
||||||
|
styles.seasonButton,
|
||||||
|
selectedSeason === season && styles.selectedSeasonButton
|
||||||
|
]}
|
||||||
|
onPress={() => onSeasonChange(season)}
|
||||||
|
>
|
||||||
|
<View style={styles.seasonPosterContainer}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: seasonPoster }}
|
||||||
|
style={styles.seasonPoster}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
{selectedSeason === season && (
|
||||||
|
<View style={styles.selectedSeasonIndicator} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.seasonButtonText,
|
||||||
|
selectedSeason === season && styles.selectedSeasonButtonText
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Season {season}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEpisodeCard = (episode: Episode) => {
|
||||||
|
let episodeImage = EPISODE_PLACEHOLDER;
|
||||||
|
if (episode.still_path) {
|
||||||
|
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
|
||||||
|
if (tmdbUrl) episodeImage = tmdbUrl;
|
||||||
|
} else if (metadata?.poster) {
|
||||||
|
episodeImage = metadata.poster;
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : '';
|
||||||
|
const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : '';
|
||||||
|
const episodeString = seasonNumber && episodeNumber ? `S${seasonNumber.padStart(2, '0')}E${episodeNumber.padStart(2, '0')}` : '';
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={episode.id}
|
||||||
|
style={[styles.episodeCard, isTablet && styles.episodeCardTablet]}
|
||||||
|
onPress={() => onSelectEpisode(episode)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.episodeImageContainer}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: episodeImage }}
|
||||||
|
style={styles.episodeImage}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
<View style={styles.episodeNumberBadge}>
|
||||||
|
<Text style={styles.episodeNumberText}>{episodeString}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.episodeInfo}>
|
||||||
|
<View style={styles.episodeHeader}>
|
||||||
|
<Text style={styles.episodeTitle} numberOfLines={2}>
|
||||||
|
{episode.name}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.episodeMetadata}>
|
||||||
|
{episode.vote_average > 0 && (
|
||||||
|
<View style={styles.ratingContainer}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: TMDB_LOGO }}
|
||||||
|
style={styles.tmdbLogo}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
<Text style={styles.ratingText}>
|
||||||
|
{episode.vote_average.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{episode.air_date && (
|
||||||
|
<Text style={styles.airDateText}>
|
||||||
|
{formatDate(episode.air_date)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.episodeOverview} numberOfLines={2}>
|
||||||
|
{episode.overview || 'No description available'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{renderSeasonSelector()}
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.episodeList}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.episodeListContent,
|
||||||
|
isTablet && styles.episodeListContentTablet
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isTablet ? (
|
||||||
|
<View style={styles.episodeGrid}>
|
||||||
|
{episodes.map(episode => renderEpisodeCard(episode))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
episodes.map(episode => renderEpisodeCard(episode))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
centeredContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
centeredText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.textMuted,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 16,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
episodeList: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
episodeListContent: {
|
||||||
|
paddingBottom: 20,
|
||||||
|
},
|
||||||
|
episodeListContentTablet: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
episodeGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
episodeCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
elevation: 8,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.1)',
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
episodeCardTablet: {
|
||||||
|
width: '48%',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
episodeImageContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
episodeImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
transform: [{ scale: 1.02 }],
|
||||||
|
},
|
||||||
|
episodeNumberBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.85)',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.2)',
|
||||||
|
},
|
||||||
|
episodeNumberText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
episodeInfo: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 12,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
episodeHeader: {
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
episodeTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.text,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
episodeMetadata: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
ratingContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
tmdbLogo: {
|
||||||
|
width: 20,
|
||||||
|
height: 14,
|
||||||
|
},
|
||||||
|
ratingText: {
|
||||||
|
color: '#01b4e4',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
airDateText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
episodeOverview: {
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
seasonSelectorWrapper: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
seasonSelectorTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 12,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
seasonSelectorContainer: {
|
||||||
|
flexGrow: 0,
|
||||||
|
},
|
||||||
|
seasonSelectorContent: {
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
seasonButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 16,
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
selectedSeasonButton: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
seasonPosterContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
width: 100,
|
||||||
|
height: 150,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
seasonPoster: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
selectedSeasonIndicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
seasonButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
selectedSeasonButtonText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
});
|
||||||
27
src/contexts/CatalogContext.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface CatalogContextType {
|
||||||
|
lastUpdate: number;
|
||||||
|
refreshCatalogs: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CatalogContext = createContext<CatalogContextType>({
|
||||||
|
lastUpdate: Date.now(),
|
||||||
|
refreshCatalogs: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useCatalogContext = () => useContext(CatalogContext);
|
||||||
|
|
||||||
|
export const CatalogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [lastUpdate, setLastUpdate] = useState(Date.now());
|
||||||
|
|
||||||
|
const refreshCatalogs = useCallback(() => {
|
||||||
|
setLastUpdate(Date.now());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CatalogContext.Provider value={{ lastUpdate, refreshCatalogs }}>
|
||||||
|
{children}
|
||||||
|
</CatalogContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
95
src/hooks/useLibrary.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { StreamingContent } from '../types/metadata';
|
||||||
|
|
||||||
|
const LIBRARY_STORAGE_KEY = 'stremio-library';
|
||||||
|
|
||||||
|
export const useLibrary = () => {
|
||||||
|
const [libraryItems, setLibraryItems] = useState<StreamingContent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Load library items from storage
|
||||||
|
const loadLibraryItems = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const storedItems = await AsyncStorage.getItem(LIBRARY_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (storedItems) {
|
||||||
|
const parsedItems = JSON.parse(storedItems);
|
||||||
|
// Handle both array and object formats
|
||||||
|
if (Array.isArray(parsedItems)) {
|
||||||
|
setLibraryItems(parsedItems);
|
||||||
|
} else if (typeof parsedItems === 'object') {
|
||||||
|
// Convert object format to array format
|
||||||
|
setLibraryItems(Object.values(parsedItems));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading library items:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save library items to storage
|
||||||
|
const saveLibraryItems = useCallback(async (items: StreamingContent[]) => {
|
||||||
|
try {
|
||||||
|
// Convert array to object format for compatibility with CatalogService
|
||||||
|
const itemsObject = items.reduce((acc, item) => {
|
||||||
|
acc[`${item.type}:${item.id}`] = item;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, StreamingContent>);
|
||||||
|
|
||||||
|
await AsyncStorage.setItem(LIBRARY_STORAGE_KEY, JSON.stringify(itemsObject));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving library items:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add item to library
|
||||||
|
const addToLibrary = useCallback(async (item: StreamingContent) => {
|
||||||
|
const updatedItems = [...libraryItems, { ...item, inLibrary: true }];
|
||||||
|
setLibraryItems(updatedItems);
|
||||||
|
await saveLibraryItems(updatedItems);
|
||||||
|
return true;
|
||||||
|
}, [libraryItems, saveLibraryItems]);
|
||||||
|
|
||||||
|
// Remove item from library
|
||||||
|
const removeFromLibrary = useCallback(async (id: string) => {
|
||||||
|
const updatedItems = libraryItems.filter(item => item.id !== id);
|
||||||
|
setLibraryItems(updatedItems);
|
||||||
|
await saveLibraryItems(updatedItems);
|
||||||
|
return true;
|
||||||
|
}, [libraryItems, saveLibraryItems]);
|
||||||
|
|
||||||
|
// Toggle item in library
|
||||||
|
const toggleLibrary = useCallback(async (item: StreamingContent) => {
|
||||||
|
const exists = libraryItems.some(i => i.id === item.id);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
return await removeFromLibrary(item.id);
|
||||||
|
} else {
|
||||||
|
return await addToLibrary(item);
|
||||||
|
}
|
||||||
|
}, [libraryItems, addToLibrary, removeFromLibrary]);
|
||||||
|
|
||||||
|
// Check if item is in library
|
||||||
|
const isInLibrary = useCallback((id: string) => {
|
||||||
|
return libraryItems.some(item => item.id === id);
|
||||||
|
}, [libraryItems]);
|
||||||
|
|
||||||
|
// Load library items on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadLibraryItems();
|
||||||
|
}, [loadLibraryItems]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
libraryItems,
|
||||||
|
loading,
|
||||||
|
addToLibrary,
|
||||||
|
removeFromLibrary,
|
||||||
|
toggleLibrary,
|
||||||
|
isInLibrary,
|
||||||
|
loadLibraryItems
|
||||||
|
};
|
||||||
|
};
|
||||||
866
src/hooks/useMetadata.ts
Normal file
|
|
@ -0,0 +1,866 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { StreamingContent } from '../services/catalogService';
|
||||||
|
import { catalogService } from '../services/catalogService';
|
||||||
|
import { stremioService } from '../services/stremioService';
|
||||||
|
import { tmdbService } from '../services/tmdbService';
|
||||||
|
import { cacheService } from '../services/cacheService';
|
||||||
|
import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
|
||||||
|
import { TMDBService } from '../services/tmdbService';
|
||||||
|
|
||||||
|
// Constants for timeouts and retries
|
||||||
|
const API_TIMEOUT = 10000; // 10 seconds
|
||||||
|
const MAX_RETRIES = 2;
|
||||||
|
const RETRY_DELAY = 1000; // 1 second
|
||||||
|
|
||||||
|
// Utility function to add timeout to promises
|
||||||
|
const withTimeout = <T>(promise: Promise<T>, timeout: number, fallback?: T): Promise<T> => {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((resolve, reject) =>
|
||||||
|
setTimeout(() => fallback ? resolve(fallback) : reject(new Error('Request timed out')), timeout)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function for parallel loading with fallback
|
||||||
|
const loadWithFallback = async <T>(
|
||||||
|
loadFn: () => Promise<T>,
|
||||||
|
fallback: T,
|
||||||
|
timeout: number = API_TIMEOUT
|
||||||
|
): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await withTimeout(loadFn(), timeout, fallback);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Loading failed, using fallback:', error);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to retry failed requests
|
||||||
|
const withRetry = async <T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
retries = MAX_RETRIES,
|
||||||
|
delay = RETRY_DELAY
|
||||||
|
): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
if (retries === 0) throw error;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
return withRetry(fn, retries - 1, delay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseMetadataProps {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseMetadataReturn {
|
||||||
|
metadata: StreamingContent | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
cast: Cast[];
|
||||||
|
loadingCast: boolean;
|
||||||
|
episodes: Episode[];
|
||||||
|
groupedEpisodes: GroupedEpisodes;
|
||||||
|
selectedSeason: number;
|
||||||
|
tmdbId: number | null;
|
||||||
|
loadingSeasons: boolean;
|
||||||
|
groupedStreams: GroupedStreams;
|
||||||
|
loadingStreams: boolean;
|
||||||
|
episodeStreams: GroupedStreams;
|
||||||
|
loadingEpisodeStreams: boolean;
|
||||||
|
preloadedStreams: GroupedStreams;
|
||||||
|
preloadedEpisodeStreams: { [episodeId: string]: GroupedStreams };
|
||||||
|
selectedEpisode: string | null;
|
||||||
|
inLibrary: boolean;
|
||||||
|
loadMetadata: () => Promise<void>;
|
||||||
|
loadStreams: () => Promise<void>;
|
||||||
|
loadEpisodeStreams: (episodeId: string) => Promise<void>;
|
||||||
|
handleSeasonChange: (seasonNumber: number) => void;
|
||||||
|
toggleLibrary: () => void;
|
||||||
|
setSelectedEpisode: (episodeId: string | null) => void;
|
||||||
|
setEpisodeStreams: (streams: GroupedStreams) => void;
|
||||||
|
recommendations: StreamingContent[];
|
||||||
|
loadingRecommendations: boolean;
|
||||||
|
setMetadata: React.Dispatch<React.SetStateAction<StreamingContent | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn => {
|
||||||
|
const [metadata, setMetadata] = useState<StreamingContent | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [cast, setCast] = useState<Cast[]>([]);
|
||||||
|
const [loadingCast, setLoadingCast] = useState(false);
|
||||||
|
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
||||||
|
const [groupedEpisodes, setGroupedEpisodes] = useState<GroupedEpisodes>({});
|
||||||
|
const [selectedSeason, setSelectedSeason] = useState<number>(1);
|
||||||
|
const [tmdbId, setTmdbId] = useState<number | null>(null);
|
||||||
|
const [loadingSeasons, setLoadingSeasons] = useState(false);
|
||||||
|
const [groupedStreams, setGroupedStreams] = useState<GroupedStreams>({});
|
||||||
|
const [loadingStreams, setLoadingStreams] = useState(false);
|
||||||
|
const [episodeStreams, setEpisodeStreams] = useState<GroupedStreams>({});
|
||||||
|
const [loadingEpisodeStreams, setLoadingEpisodeStreams] = useState(false);
|
||||||
|
const [preloadedStreams, setPreloadedStreams] = useState<GroupedStreams>({});
|
||||||
|
const [preloadedEpisodeStreams, setPreloadedEpisodeStreams] = useState<{ [episodeId: string]: GroupedStreams }>({});
|
||||||
|
const [selectedEpisode, setSelectedEpisode] = useState<string | null>(null);
|
||||||
|
const [inLibrary, setInLibrary] = useState(false);
|
||||||
|
const [loadAttempts, setLoadAttempts] = useState(0);
|
||||||
|
const [recommendations, setRecommendations] = useState<StreamingContent[]>([]);
|
||||||
|
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
|
||||||
|
|
||||||
|
const processStreamSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => {
|
||||||
|
const sourceStartTime = Date.now();
|
||||||
|
const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams';
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`🔍 [${logPrefix}:${sourceType}] Starting fetch`);
|
||||||
|
const result = await promise;
|
||||||
|
console.log(`✅ [${logPrefix}:${sourceType}] Completed in ${Date.now() - sourceStartTime}ms`);
|
||||||
|
|
||||||
|
// If we have results, update immediately
|
||||||
|
if (Object.keys(result).length > 0) {
|
||||||
|
// Calculate total streams for logging
|
||||||
|
const totalStreams = Object.values(result).reduce((acc, group: any) => {
|
||||||
|
return acc + (group.streams?.length || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
console.log(`📦 [${logPrefix}:${sourceType}] Found ${totalStreams} streams`);
|
||||||
|
|
||||||
|
// Update state for this source
|
||||||
|
if (isEpisode) {
|
||||||
|
setEpisodeStreams(prev => {
|
||||||
|
const newState = {...prev, ...result};
|
||||||
|
console.log(`🔄 [${logPrefix}:${sourceType}] Updating state with ${Object.keys(result).length} providers`);
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setGroupedStreams(prev => {
|
||||||
|
const newState = {...prev, ...result};
|
||||||
|
console.log(`🔄 [${logPrefix}:${sourceType}] Updating state with ${Object.keys(result).length} providers`);
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ [${logPrefix}:${sourceType}] No streams found`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ [${logPrefix}:${sourceType}] Error:`, error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCast = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingCast(true);
|
||||||
|
const cachedCast = cacheService.getCast(id, type);
|
||||||
|
if (cachedCast) {
|
||||||
|
setCast(cachedCast);
|
||||||
|
setLoadingCast(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load cast in parallel with a fallback to empty array
|
||||||
|
const castLoadingPromise = loadWithFallback(async () => {
|
||||||
|
const tmdbId = await withTimeout(
|
||||||
|
tmdbService.findTMDBIdByIMDB(id),
|
||||||
|
API_TIMEOUT
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tmdbId) {
|
||||||
|
const castData = await withTimeout(
|
||||||
|
tmdbService.getCredits(tmdbId, type),
|
||||||
|
API_TIMEOUT,
|
||||||
|
{ cast: [], crew: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (castData.cast && castData.cast.length > 0) {
|
||||||
|
setCast(castData.cast);
|
||||||
|
cacheService.setCast(id, type, castData.cast);
|
||||||
|
return castData.cast;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
await castLoadingPromise;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load cast:', error);
|
||||||
|
setCast([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingCast(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMetadata = async () => {
|
||||||
|
try {
|
||||||
|
if (loadAttempts >= MAX_RETRIES) {
|
||||||
|
setError('Failed to load content after multiple attempts');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setLoadAttempts(prev => prev + 1);
|
||||||
|
|
||||||
|
// Check metadata screen cache
|
||||||
|
const cachedScreen = cacheService.getMetadataScreen(id, type);
|
||||||
|
if (cachedScreen) {
|
||||||
|
setMetadata(cachedScreen.metadata);
|
||||||
|
setCast(cachedScreen.cast);
|
||||||
|
if (type === 'series' && cachedScreen.episodes) {
|
||||||
|
setGroupedEpisodes(cachedScreen.episodes.groupedEpisodes);
|
||||||
|
setEpisodes(cachedScreen.episodes.currentEpisodes);
|
||||||
|
setSelectedSeason(cachedScreen.episodes.selectedSeason);
|
||||||
|
setTmdbId(cachedScreen.tmdbId);
|
||||||
|
}
|
||||||
|
// Check if item is in library
|
||||||
|
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
|
||||||
|
setInLibrary(isInLib);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all data in parallel
|
||||||
|
const [content, castData] = await Promise.allSettled([
|
||||||
|
// Load content with timeout and retry
|
||||||
|
withRetry(async () => {
|
||||||
|
const result = await withTimeout(
|
||||||
|
catalogService.getContentDetails(type, id),
|
||||||
|
API_TIMEOUT
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
// Start loading cast immediately in parallel
|
||||||
|
loadCast()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (content.status === 'fulfilled' && content.value) {
|
||||||
|
setMetadata(content.value);
|
||||||
|
// Check if item is in library
|
||||||
|
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
|
||||||
|
setInLibrary(isInLib);
|
||||||
|
cacheService.setMetadata(id, type, content.value);
|
||||||
|
|
||||||
|
if (type === 'series') {
|
||||||
|
// Load series data in parallel with other data
|
||||||
|
loadSeriesData().catch(console.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Content not found');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load metadata:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to load content';
|
||||||
|
setError(errorMessage);
|
||||||
|
|
||||||
|
// Clear any stale data
|
||||||
|
setMetadata(null);
|
||||||
|
setCast([]);
|
||||||
|
setGroupedEpisodes({});
|
||||||
|
setEpisodes([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSeriesData = async () => {
|
||||||
|
setLoadingSeasons(true);
|
||||||
|
try {
|
||||||
|
const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id);
|
||||||
|
if (tmdbIdResult) {
|
||||||
|
setTmdbId(tmdbIdResult);
|
||||||
|
|
||||||
|
const [allEpisodes, showDetails] = await Promise.all([
|
||||||
|
tmdbService.getAllEpisodes(tmdbIdResult),
|
||||||
|
tmdbService.getTVShowDetails(tmdbIdResult)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const transformedEpisodes: GroupedEpisodes = {};
|
||||||
|
Object.entries(allEpisodes).forEach(([season, episodes]) => {
|
||||||
|
const seasonInfo = showDetails?.seasons?.find(s => s.season_number === parseInt(season));
|
||||||
|
const seasonPosterPath = seasonInfo?.poster_path;
|
||||||
|
|
||||||
|
transformedEpisodes[parseInt(season)] = episodes.map(episode => ({
|
||||||
|
...episode,
|
||||||
|
episodeString: `S${episode.season_number.toString().padStart(2, '0')}E${episode.episode_number.toString().padStart(2, '0')}`,
|
||||||
|
season_poster_path: seasonPosterPath || null
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
setGroupedEpisodes(transformedEpisodes);
|
||||||
|
|
||||||
|
const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number));
|
||||||
|
const initialEpisodes = transformedEpisodes[firstSeason] || [];
|
||||||
|
setSelectedSeason(firstSeason);
|
||||||
|
setEpisodes(initialEpisodes);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load episodes:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingSeasons(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to indicate that streams are loading without blocking UI
|
||||||
|
const updateLoadingState = () => {
|
||||||
|
// We set this to true initially, but we'll show results as they come in
|
||||||
|
setLoadingStreams(true);
|
||||||
|
// Also clear previous streams
|
||||||
|
setGroupedStreams({});
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to indicate that episode streams are loading without blocking UI
|
||||||
|
const updateEpisodeLoadingState = () => {
|
||||||
|
// We set this to true initially, but we'll show results as they come in
|
||||||
|
setLoadingEpisodeStreams(true);
|
||||||
|
// Also clear previous streams
|
||||||
|
setEpisodeStreams({});
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStreams = async () => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
console.log('🚀 [loadStreams] START - Loading movie streams for:', id);
|
||||||
|
updateLoadingState();
|
||||||
|
|
||||||
|
// Get TMDB ID for external sources first before starting parallel requests
|
||||||
|
console.log('🔍 [loadStreams] Getting TMDB ID for:', id);
|
||||||
|
let tmdbId;
|
||||||
|
if (id.startsWith('tmdb:')) {
|
||||||
|
tmdbId = id.split(':')[1];
|
||||||
|
console.log('✅ [loadStreams] Using TMDB ID from ID:', tmdbId);
|
||||||
|
} else if (id.startsWith('tt')) {
|
||||||
|
// This is an IMDB ID
|
||||||
|
console.log('📝 [loadStreams] Converting IMDB ID to TMDB ID...');
|
||||||
|
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
|
||||||
|
console.log('✅ [loadStreams] Converted to TMDB ID:', tmdbId);
|
||||||
|
} else {
|
||||||
|
tmdbId = id;
|
||||||
|
console.log('ℹ️ [loadStreams] Using ID as TMDB ID:', tmdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔄 [loadStreams] Starting parallel stream requests');
|
||||||
|
|
||||||
|
// Create an array to store all fetching promises
|
||||||
|
const fetchPromises = [];
|
||||||
|
|
||||||
|
// Start Stremio request
|
||||||
|
const stremioPromise = processStreamSource('stremio', (async () => {
|
||||||
|
const newGroupedStreams: GroupedStreams = {};
|
||||||
|
try {
|
||||||
|
const responses = await stremioService.getStreams(type, id);
|
||||||
|
responses.forEach(response => {
|
||||||
|
const addonId = response.addon;
|
||||||
|
if (addonId && response.streams.length > 0) {
|
||||||
|
const streamsWithAddon = response.streams.map(stream => ({
|
||||||
|
...stream,
|
||||||
|
name: stream.name || stream.title || 'Unnamed Stream',
|
||||||
|
addonId: response.addon,
|
||||||
|
addonName: response.addonName
|
||||||
|
}));
|
||||||
|
|
||||||
|
newGroupedStreams[addonId] = {
|
||||||
|
addonName: response.addonName,
|
||||||
|
streams: streamsWithAddon
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return newGroupedStreams;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [loadStreams:stremio] Error fetching Stremio streams:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})(), false);
|
||||||
|
fetchPromises.push(stremioPromise);
|
||||||
|
|
||||||
|
// Start Source 1 request if we have a TMDB ID
|
||||||
|
if (tmdbId) {
|
||||||
|
const source1Promise = processStreamSource('source1', (async () => {
|
||||||
|
try {
|
||||||
|
const streams = await fetchExternalStreams(
|
||||||
|
`https://nice-month-production.up.railway.app/embedsu/${tmdbId}`,
|
||||||
|
'Source 1'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (streams.length > 0) {
|
||||||
|
return {
|
||||||
|
'source_1': {
|
||||||
|
addonName: 'Source 1',
|
||||||
|
streams
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [loadStreams:source1] Error fetching Source 1 streams:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})(), false);
|
||||||
|
fetchPromises.push(source1Promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Source 2 request if we have a TMDB ID
|
||||||
|
if (tmdbId) {
|
||||||
|
const source2Promise = processStreamSource('source2', (async () => {
|
||||||
|
try {
|
||||||
|
const streams = await fetchExternalStreams(
|
||||||
|
`https://vidsrc-api-js-phz6.onrender.com/embedsu/${tmdbId}`,
|
||||||
|
'Source 2'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (streams.length > 0) {
|
||||||
|
return {
|
||||||
|
'source_2': {
|
||||||
|
addonName: 'Source 2',
|
||||||
|
streams
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [loadStreams:source2] Error fetching Source 2 streams:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})(), false);
|
||||||
|
fetchPromises.push(source2Promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all promises to complete - but we already showed results as they arrived
|
||||||
|
const results = await Promise.allSettled(fetchPromises);
|
||||||
|
const totalTime = Date.now() - startTime;
|
||||||
|
console.log(`✅ [loadStreams] All requests completed in ${totalTime}ms`);
|
||||||
|
|
||||||
|
const sourceTypes = ['stremio', 'source1', 'source2'];
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
|
||||||
|
console.log(`📊 [loadStreams:${source}] Status: ${result.status}`);
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
console.error(`❌ [loadStreams:${source}] Error:`, result.reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🧮 [loadStreams] Summary:');
|
||||||
|
console.log(' Total time:', totalTime + 'ms');
|
||||||
|
|
||||||
|
// Log the final states
|
||||||
|
console.log('📦 [loadStreams] Final streams count:',
|
||||||
|
Object.keys(groupedStreams).length > 0 ?
|
||||||
|
Object.values(groupedStreams).reduce((acc, group: any) => acc + group.streams.length, 0) :
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cache the final streams state
|
||||||
|
setGroupedStreams(prev => {
|
||||||
|
cacheService.setStreams(id, type, prev);
|
||||||
|
setPreloadedStreams(prev);
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [loadStreams] Failed to load streams:', error);
|
||||||
|
setError('Failed to load streams');
|
||||||
|
} finally {
|
||||||
|
const endTime = Date.now() - startTime;
|
||||||
|
console.log(`🏁 [loadStreams] FINISHED in ${endTime}ms`);
|
||||||
|
setLoadingStreams(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadEpisodeStreams = async (episodeId: string) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
|
||||||
|
updateEpisodeLoadingState();
|
||||||
|
|
||||||
|
// Get TMDB ID for external sources first before starting parallel requests
|
||||||
|
console.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id);
|
||||||
|
let tmdbId;
|
||||||
|
if (id.startsWith('tmdb:')) {
|
||||||
|
tmdbId = id.split(':')[1];
|
||||||
|
console.log('✅ [loadEpisodeStreams] Using TMDB ID from ID:', tmdbId);
|
||||||
|
} else if (id.startsWith('tt')) {
|
||||||
|
// This is an IMDB ID
|
||||||
|
console.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...');
|
||||||
|
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
|
||||||
|
console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
|
||||||
|
} else {
|
||||||
|
tmdbId = id;
|
||||||
|
console.log('ℹ️ [loadEpisodeStreams] Using ID as TMDB ID:', tmdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract episode info from the episodeId
|
||||||
|
const [, season, episode] = episodeId.split(':');
|
||||||
|
const episodeQuery = `?s=${season}&e=${episode}`;
|
||||||
|
console.log(`ℹ️ [loadEpisodeStreams] Episode query: ${episodeQuery}`);
|
||||||
|
|
||||||
|
console.log('🔄 [loadEpisodeStreams] Starting parallel stream requests');
|
||||||
|
|
||||||
|
// Create an array to store all fetching promises
|
||||||
|
const fetchPromises = [];
|
||||||
|
|
||||||
|
// Start Stremio request
|
||||||
|
const stremioPromise = processStreamSource('stremio', (async () => {
|
||||||
|
const newGroupedStreams: GroupedStreams = {};
|
||||||
|
try {
|
||||||
|
const responses = await stremioService.getStreams('series', episodeId);
|
||||||
|
responses.forEach(response => {
|
||||||
|
const addonId = response.addon;
|
||||||
|
if (addonId && response.streams.length > 0) {
|
||||||
|
const streamsWithAddon = response.streams.map(stream => ({
|
||||||
|
...stream,
|
||||||
|
name: stream.name || stream.title || 'Unnamed Stream',
|
||||||
|
addonId: response.addon,
|
||||||
|
addonName: response.addonName
|
||||||
|
}));
|
||||||
|
|
||||||
|
newGroupedStreams[addonId] = {
|
||||||
|
addonName: response.addonName,
|
||||||
|
streams: streamsWithAddon
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return newGroupedStreams;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [loadEpisodeStreams:stremio] Error fetching Stremio streams:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})(), true);
|
||||||
|
fetchPromises.push(stremioPromise);
|
||||||
|
|
||||||
|
// Start Source 1 request if we have a TMDB ID
|
||||||
|
if (tmdbId) {
|
||||||
|
const source1Promise = processStreamSource('source1', (async () => {
|
||||||
|
try {
|
||||||
|
const streams = await fetchExternalStreams(
|
||||||
|
`https://nice-month-production.up.railway.app/embedsu/${tmdbId}${episodeQuery}`,
|
||||||
|
'Source 1',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (streams.length > 0) {
|
||||||
|
return {
|
||||||
|
'source_1': {
|
||||||
|
addonName: 'Source 1',
|
||||||
|
streams
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [loadEpisodeStreams:source1] Error fetching Source 1 streams:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})(), true);
|
||||||
|
fetchPromises.push(source1Promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Source 2 request if we have a TMDB ID
|
||||||
|
if (tmdbId) {
|
||||||
|
const source2Promise = processStreamSource('source2', (async () => {
|
||||||
|
try {
|
||||||
|
const streams = await fetchExternalStreams(
|
||||||
|
`https://vidsrc-api-js-phz6.onrender.com/embedsu/${tmdbId}${episodeQuery}`,
|
||||||
|
'Source 2',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (streams.length > 0) {
|
||||||
|
return {
|
||||||
|
'source_2': {
|
||||||
|
addonName: 'Source 2',
|
||||||
|
streams
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [loadEpisodeStreams:source2] Error fetching Source 2 streams:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})(), true);
|
||||||
|
fetchPromises.push(source2Promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all promises to complete - but we already showed results as they arrived
|
||||||
|
const results = await Promise.allSettled(fetchPromises);
|
||||||
|
const totalTime = Date.now() - startTime;
|
||||||
|
console.log(`✅ [loadEpisodeStreams] All requests completed in ${totalTime}ms`);
|
||||||
|
|
||||||
|
const sourceTypes = ['stremio', 'source1', 'source2'];
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
|
||||||
|
console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`);
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
console.error(`❌ [loadEpisodeStreams:${source}] Error:`, result.reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🧮 [loadEpisodeStreams] Summary:');
|
||||||
|
console.log(' Total time:', totalTime + 'ms');
|
||||||
|
|
||||||
|
// Log the final states
|
||||||
|
console.log('📦 [loadEpisodeStreams] Final streams count:',
|
||||||
|
Object.keys(episodeStreams).length > 0 ?
|
||||||
|
Object.values(episodeStreams).reduce((acc, group: any) => acc + group.streams.length, 0) :
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cache the final streams state
|
||||||
|
setEpisodeStreams(prev => {
|
||||||
|
// Cache episode streams
|
||||||
|
setPreloadedEpisodeStreams(currentPreloaded => ({
|
||||||
|
...currentPreloaded,
|
||||||
|
[episodeId]: prev
|
||||||
|
}));
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error);
|
||||||
|
setError('Failed to load episode streams');
|
||||||
|
} finally {
|
||||||
|
const totalTime = Date.now() - startTime;
|
||||||
|
console.log(`🏁 [loadEpisodeStreams] FINISHED in ${totalTime}ms`);
|
||||||
|
setLoadingEpisodeStreams(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchExternalStreams = async (url: string, sourceName: string, isEpisode = false) => {
|
||||||
|
try {
|
||||||
|
console.log(`\n🌐 [${sourceName}] Starting fetch request...`);
|
||||||
|
console.log(`📍 URL: ${url}`);
|
||||||
|
|
||||||
|
// Add proper headers to ensure we get JSON response
|
||||||
|
const headers = {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||||
|
};
|
||||||
|
console.log('📋 Request Headers:', headers);
|
||||||
|
|
||||||
|
// Make the fetch request
|
||||||
|
console.log(`⏳ [${sourceName}] Making fetch request...`);
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
console.log(`✅ [${sourceName}] Response received`);
|
||||||
|
console.log(`📊 Status: ${response.status} ${response.statusText}`);
|
||||||
|
console.log(`🔤 Content-Type:`, response.headers.get('content-type'));
|
||||||
|
|
||||||
|
// Check if response is ok
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`❌ [${sourceName}] HTTP error: ${response.status}`);
|
||||||
|
console.error(`📝 Status Text: ${response.statusText}`);
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse JSON
|
||||||
|
console.log(`📑 [${sourceName}] Reading response body...`);
|
||||||
|
const text = await response.text();
|
||||||
|
console.log(`📄 [${sourceName}] Response body (first 300 chars):`, text.substring(0, 300));
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
console.log(`🔄 [${sourceName}] Parsing JSON...`);
|
||||||
|
data = JSON.parse(text);
|
||||||
|
console.log(`✅ [${sourceName}] JSON parsed successfully`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`❌ [${sourceName}] JSON parse error:`, e);
|
||||||
|
console.error(`📝 [${sourceName}] Raw response:`, text.substring(0, 200));
|
||||||
|
throw new Error('Invalid JSON response');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform the response
|
||||||
|
console.log(`🔄 [${sourceName}] Processing sources...`);
|
||||||
|
if (data && data.sources && Array.isArray(data.sources)) {
|
||||||
|
console.log(`📦 [${sourceName}] Found ${data.sources.length} source(s)`);
|
||||||
|
|
||||||
|
const transformedStreams = [];
|
||||||
|
for (const source of data.sources) {
|
||||||
|
console.log(`\n📂 [${sourceName}] Processing source:`, source);
|
||||||
|
|
||||||
|
if (source.files && Array.isArray(source.files)) {
|
||||||
|
console.log(`📁 [${sourceName}] Found ${source.files.length} file(s) in source`);
|
||||||
|
|
||||||
|
for (const file of source.files) {
|
||||||
|
console.log(`🎥 [${sourceName}] Processing file:`, file);
|
||||||
|
const stream = {
|
||||||
|
url: file.file,
|
||||||
|
title: `${sourceName} - ${file.quality || 'Unknown'}`,
|
||||||
|
name: `${sourceName} - ${file.quality || 'Unknown'}`,
|
||||||
|
behaviorHints: {
|
||||||
|
notWebReady: false,
|
||||||
|
headers: source.headers || {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
console.log(`✨ [${sourceName}] Created stream:`, stream);
|
||||||
|
transformedStreams.push(stream);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ [${sourceName}] No files array found in source or invalid format`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 [${sourceName}] Successfully processed ${transformedStreams.length} stream(s)`);
|
||||||
|
return transformedStreams;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`⚠️ [${sourceName}] No valid sources found in response`);
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\n❌ [${sourceName}] Error fetching streams:`, error);
|
||||||
|
console.error(`📍 URL: ${url}`);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error(`💥 Error name: ${error.name}`);
|
||||||
|
console.error(`💥 Error message: ${error.message}`);
|
||||||
|
console.error(`💥 Stack trace: ${error.stack}`);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeasonChange = useCallback((seasonNumber: number) => {
|
||||||
|
if (selectedSeason === seasonNumber) return;
|
||||||
|
setSelectedSeason(seasonNumber);
|
||||||
|
setEpisodes(groupedEpisodes[seasonNumber] || []);
|
||||||
|
}, [selectedSeason, groupedEpisodes]);
|
||||||
|
|
||||||
|
const toggleLibrary = useCallback(() => {
|
||||||
|
if (!metadata) return;
|
||||||
|
|
||||||
|
if (inLibrary) {
|
||||||
|
catalogService.removeFromLibrary(type, id);
|
||||||
|
} else {
|
||||||
|
catalogService.addToLibrary(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInLibrary(!inLibrary);
|
||||||
|
}, [metadata, inLibrary, type, id]);
|
||||||
|
|
||||||
|
// Reset load attempts when id or type changes
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadAttempts(0);
|
||||||
|
}, [id, type]);
|
||||||
|
|
||||||
|
// Auto-retry on error with delay
|
||||||
|
useEffect(() => {
|
||||||
|
if (error && loadAttempts < MAX_RETRIES) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
loadMetadata();
|
||||||
|
}, RETRY_DELAY * (loadAttempts + 1));
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [error, loadAttempts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMetadata();
|
||||||
|
}, [id, type]);
|
||||||
|
|
||||||
|
const loadRecommendations = useCallback(async () => {
|
||||||
|
if (!tmdbId) return;
|
||||||
|
|
||||||
|
setLoadingRecommendations(true);
|
||||||
|
try {
|
||||||
|
const tmdbService = TMDBService.getInstance();
|
||||||
|
const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId));
|
||||||
|
|
||||||
|
// Convert TMDB results to StreamingContent format (simplified)
|
||||||
|
const formattedRecommendations: StreamingContent[] = results.map((item: any) => ({
|
||||||
|
id: `tmdb:${item.id}`,
|
||||||
|
type: type === 'movie' ? 'movie' : 'series',
|
||||||
|
name: item.title || item.name || 'Untitled',
|
||||||
|
poster: tmdbService.getImageUrl(item.poster_path) || 'https://via.placeholder.com/300x450', // Provide fallback
|
||||||
|
year: (item.release_date || item.first_air_date)?.substring(0, 4) || 'N/A', // Ensure string and provide fallback
|
||||||
|
}));
|
||||||
|
|
||||||
|
setRecommendations(formattedRecommendations);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load recommendations:', error);
|
||||||
|
setRecommendations([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingRecommendations(false);
|
||||||
|
}
|
||||||
|
}, [tmdbId, type]);
|
||||||
|
|
||||||
|
// Fetch TMDB ID if needed and then recommendations
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTmdbIdAndRecommendations = async () => {
|
||||||
|
if (metadata && !tmdbId) {
|
||||||
|
try {
|
||||||
|
const tmdbService = TMDBService.getInstance();
|
||||||
|
const fetchedTmdbId = await tmdbService.extractTMDBIdFromStremioId(id);
|
||||||
|
if (fetchedTmdbId) {
|
||||||
|
setTmdbId(fetchedTmdbId);
|
||||||
|
} else {
|
||||||
|
console.warn('Could not determine TMDB ID for recommendations.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching TMDB ID:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTmdbIdAndRecommendations();
|
||||||
|
}, [metadata, id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tmdbId) {
|
||||||
|
loadRecommendations();
|
||||||
|
// Reset recommendations when tmdbId changes
|
||||||
|
return () => {
|
||||||
|
setRecommendations([]);
|
||||||
|
setLoadingRecommendations(true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [tmdbId, loadRecommendations]);
|
||||||
|
|
||||||
|
// Reset tmdbId when id changes
|
||||||
|
useEffect(() => {
|
||||||
|
setTmdbId(null);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// Subscribe to library updates
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
|
||||||
|
const isInLib = libraryItems.some(item => item.id === id);
|
||||||
|
setInLibrary(isInLib);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
cast,
|
||||||
|
loadingCast,
|
||||||
|
episodes,
|
||||||
|
groupedEpisodes,
|
||||||
|
selectedSeason,
|
||||||
|
tmdbId,
|
||||||
|
loadingSeasons,
|
||||||
|
groupedStreams,
|
||||||
|
loadingStreams,
|
||||||
|
episodeStreams,
|
||||||
|
loadingEpisodeStreams,
|
||||||
|
preloadedStreams,
|
||||||
|
preloadedEpisodeStreams,
|
||||||
|
selectedEpisode,
|
||||||
|
inLibrary,
|
||||||
|
loadMetadata,
|
||||||
|
loadStreams,
|
||||||
|
loadEpisodeStreams,
|
||||||
|
handleSeasonChange,
|
||||||
|
toggleLibrary,
|
||||||
|
setSelectedEpisode,
|
||||||
|
setEpisodeStreams,
|
||||||
|
recommendations,
|
||||||
|
loadingRecommendations,
|
||||||
|
setMetadata,
|
||||||
|
};
|
||||||
|
};
|
||||||
6
src/hooks/useNavigation.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||||
|
import { RootStackParamList } from '../types/navigation';
|
||||||
|
|
||||||
|
export const useRootNavigation = () => {
|
||||||
|
return useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
};
|
||||||
63
src/hooks/useSettings.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
enableDarkMode: boolean;
|
||||||
|
enableNotifications: boolean;
|
||||||
|
streamQuality: 'auto' | 'low' | 'medium' | 'high';
|
||||||
|
enableSubtitles: boolean;
|
||||||
|
enableBackgroundPlayback: boolean;
|
||||||
|
cacheLimit: number;
|
||||||
|
useExternalPlayer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
enableDarkMode: true,
|
||||||
|
enableNotifications: true,
|
||||||
|
streamQuality: 'auto',
|
||||||
|
enableSubtitles: true,
|
||||||
|
enableBackgroundPlayback: false,
|
||||||
|
cacheLimit: 1024,
|
||||||
|
useExternalPlayer: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SETTINGS_STORAGE_KEY = 'app_settings';
|
||||||
|
|
||||||
|
export const useSettings = () => {
|
||||||
|
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
const storedSettings = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||||
|
if (storedSettings) {
|
||||||
|
setSettings(JSON.parse(storedSettings));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load settings:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSetting = async <K extends keyof AppSettings>(
|
||||||
|
key: K,
|
||||||
|
value: AppSettings[K]
|
||||||
|
) => {
|
||||||
|
const newSettings = { ...settings, [key]: value };
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings));
|
||||||
|
setSettings(newSettings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save settings:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
updateSetting,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSettings;
|
||||||
1
src/modules/TorrentPlayer.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
574
src/navigation/AppNavigator.tsx
Normal file
|
|
@ -0,0 +1,574 @@
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { NavigationContainer, DefaultTheme as NavigationDefaultTheme, DarkTheme as NavigationDarkTheme, Theme, NavigationProp } from '@react-navigation/native';
|
||||||
|
import { createNativeStackNavigator, NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||||
|
import { useColorScheme, Platform, Animated, StatusBar, TouchableOpacity, View, Text } from 'react-native';
|
||||||
|
import { PaperProvider, MD3DarkTheme, MD3LightTheme, adaptNavigationTheme } from 'react-native-paper';
|
||||||
|
import type { MD3Theme } from 'react-native-paper';
|
||||||
|
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { colors } from '../styles/colors';
|
||||||
|
import { NuvioHeader } from '../components/NuvioHeader';
|
||||||
|
|
||||||
|
// Import screens with their proper types
|
||||||
|
import HomeScreen from '../screens/HomeScreen';
|
||||||
|
import DiscoverScreen from '../screens/DiscoverScreen';
|
||||||
|
import LibraryScreen from '../screens/LibraryScreen';
|
||||||
|
import SettingsScreen from '../screens/SettingsScreen';
|
||||||
|
import MetadataScreen from '../screens/MetadataScreen';
|
||||||
|
import VideoPlayer from '../screens/VideoPlayer';
|
||||||
|
import CatalogScreen from '../screens/CatalogScreen';
|
||||||
|
import AddonsScreen from '../screens/AddonsScreen';
|
||||||
|
import SearchScreen from '../screens/SearchScreen';
|
||||||
|
import ShowRatingsScreen from '../screens/ShowRatingsScreen';
|
||||||
|
import CatalogSettingsScreen from '../screens/CatalogSettingsScreen';
|
||||||
|
import StreamsScreen from '../screens/StreamsScreen';
|
||||||
|
import CalendarScreen from '../screens/CalendarScreen';
|
||||||
|
import NotificationSettingsScreen from '../screens/NotificationSettingsScreen';
|
||||||
|
|
||||||
|
// Stack navigator types
|
||||||
|
export type RootStackParamList = {
|
||||||
|
MainTabs: undefined;
|
||||||
|
Metadata: { id: string; type: string };
|
||||||
|
Streams: { id: string; type: string; episodeId?: string };
|
||||||
|
Player: { uri: string; title?: string; season?: number; episode?: number; episodeTitle?: string; quality?: string; year?: number; streamProvider?: string };
|
||||||
|
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };
|
||||||
|
Addons: undefined;
|
||||||
|
Search: undefined;
|
||||||
|
ShowRatings: { showId: number };
|
||||||
|
CatalogSettings: undefined;
|
||||||
|
Calendar: undefined;
|
||||||
|
NotificationSettings: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
|
// Tab navigator types
|
||||||
|
export type MainTabParamList = {
|
||||||
|
Home: undefined;
|
||||||
|
Discover: undefined;
|
||||||
|
Library: undefined;
|
||||||
|
Addons: undefined;
|
||||||
|
Settings: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom fonts that satisfy both theme types
|
||||||
|
const fonts = {
|
||||||
|
regular: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '400' as const,
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
fontFamily: 'sans-serif-medium',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
},
|
||||||
|
bold: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '700' as const,
|
||||||
|
},
|
||||||
|
heavy: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '900' as const,
|
||||||
|
},
|
||||||
|
// MD3 specific fonts
|
||||||
|
displayLarge: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '400' as const,
|
||||||
|
letterSpacing: 0,
|
||||||
|
lineHeight: 64,
|
||||||
|
fontSize: 57,
|
||||||
|
},
|
||||||
|
displayMedium: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '400' as const,
|
||||||
|
letterSpacing: 0,
|
||||||
|
lineHeight: 52,
|
||||||
|
fontSize: 45,
|
||||||
|
},
|
||||||
|
displaySmall: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '400' as const,
|
||||||
|
letterSpacing: 0,
|
||||||
|
lineHeight: 44,
|
||||||
|
fontSize: 36,
|
||||||
|
},
|
||||||
|
headlineLarge: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '400' as const,
|
||||||
|
letterSpacing: 0,
|
||||||
|
lineHeight: 40,
|
||||||
|
fontSize: 32,
|
||||||
|
},
|
||||||
|
headlineMedium: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '400' as const,
|
||||||
|
letterSpacing: 0,
|
||||||
|
lineHeight: 36,
|
||||||
|
fontSize: 28,
|
||||||
|
},
|
||||||
|
headlineSmall: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '400' as const,
|
||||||
|
letterSpacing: 0,
|
||||||
|
lineHeight: 32,
|
||||||
|
fontSize: 24,
|
||||||
|
},
|
||||||
|
titleLarge: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '400' as const,
|
||||||
|
letterSpacing: 0,
|
||||||
|
lineHeight: 28,
|
||||||
|
fontSize: 22,
|
||||||
|
},
|
||||||
|
titleMedium: {
|
||||||
|
fontFamily: 'sans-serif-medium',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
letterSpacing: 0.15,
|
||||||
|
lineHeight: 24,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
titleSmall: {
|
||||||
|
fontFamily: 'sans-serif-medium',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
labelLarge: {
|
||||||
|
fontFamily: 'sans-serif-medium',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
labelMedium: {
|
||||||
|
fontFamily: 'sans-serif-medium',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
lineHeight: 16,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
labelSmall: {
|
||||||
|
fontFamily: 'sans-serif-medium',
|
||||||
|
fontWeight: '500' as const,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
lineHeight: 16,
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
bodyLarge: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '400' as const,
|
||||||
|
letterSpacing: 0.15,
|
||||||
|
lineHeight: 24,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
bodyMedium: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '400' as const,
|
||||||
|
letterSpacing: 0.25,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
bodySmall: {
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontWeight: '400' as const,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
lineHeight: 16,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Create navigators
|
||||||
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||||
|
|
||||||
|
// Create custom paper themes
|
||||||
|
export const CustomLightTheme: MD3Theme = {
|
||||||
|
...MD3LightTheme,
|
||||||
|
colors: {
|
||||||
|
...MD3LightTheme.colors,
|
||||||
|
primary: colors.primary,
|
||||||
|
},
|
||||||
|
fonts: MD3LightTheme.fonts,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomDarkTheme: MD3Theme = {
|
||||||
|
...MD3DarkTheme,
|
||||||
|
colors: {
|
||||||
|
...MD3DarkTheme.colors,
|
||||||
|
primary: colors.primary,
|
||||||
|
},
|
||||||
|
fonts: MD3DarkTheme.fonts,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create custom navigation theme
|
||||||
|
const { LightTheme, DarkTheme } = adaptNavigationTheme({
|
||||||
|
reactNavigationLight: NavigationDefaultTheme,
|
||||||
|
reactNavigationDark: NavigationDarkTheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add fonts to navigation themes
|
||||||
|
export const CustomNavigationLightTheme: Theme = {
|
||||||
|
...LightTheme,
|
||||||
|
colors: {
|
||||||
|
...LightTheme.colors,
|
||||||
|
background: colors.white,
|
||||||
|
card: colors.white,
|
||||||
|
text: colors.textDark,
|
||||||
|
border: colors.border,
|
||||||
|
},
|
||||||
|
fonts,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomNavigationDarkTheme: Theme = {
|
||||||
|
...DarkTheme,
|
||||||
|
colors: {
|
||||||
|
...DarkTheme.colors,
|
||||||
|
background: colors.darkBackground,
|
||||||
|
card: colors.darkBackground,
|
||||||
|
text: colors.text,
|
||||||
|
border: colors.border,
|
||||||
|
},
|
||||||
|
fonts,
|
||||||
|
};
|
||||||
|
|
||||||
|
type IconNameType = 'home' | 'home-outline' | 'compass' | 'compass-outline' |
|
||||||
|
'play-box-multiple' | 'play-box-multiple-outline' |
|
||||||
|
'puzzle' | 'puzzle-outline' |
|
||||||
|
'cog' | 'cog-outline';
|
||||||
|
|
||||||
|
// Add TabIcon component
|
||||||
|
const TabIcon = React.memo(({ focused, color, iconName }: {
|
||||||
|
focused: boolean;
|
||||||
|
color: string;
|
||||||
|
iconName: IconNameType;
|
||||||
|
}) => {
|
||||||
|
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.spring(scaleAnim, {
|
||||||
|
toValue: focused ? 1.1 : 1,
|
||||||
|
useNativeDriver: true,
|
||||||
|
friction: 8,
|
||||||
|
tension: 100
|
||||||
|
}).start();
|
||||||
|
}, [focused]);
|
||||||
|
|
||||||
|
const finalIconName = focused ? iconName : `${iconName}-outline` as IconNameType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={{
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transform: [{ scale: scaleAnim }]
|
||||||
|
}}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={finalIconName}
|
||||||
|
size={24}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab Navigator
|
||||||
|
const MainTabs = () => {
|
||||||
|
// Always use dark mode
|
||||||
|
const isDarkMode = true;
|
||||||
|
|
||||||
|
const renderTabBar = (props: BottomTabBarProps) => {
|
||||||
|
return (
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 75,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
}}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[
|
||||||
|
'rgba(0, 0, 0, 0)',
|
||||||
|
'rgba(0, 0, 0, 0.65)',
|
||||||
|
'rgba(0, 0, 0, 0.85)',
|
||||||
|
'rgba(0, 0, 0, 0.98)',
|
||||||
|
]}
|
||||||
|
locations={[0, 0.2, 0.4, 0.8]}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
paddingBottom: 10,
|
||||||
|
paddingTop: 12,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', paddingTop: 4 }}>
|
||||||
|
{props.state.routes.map((route, index) => {
|
||||||
|
const { options } = props.descriptors[route.key];
|
||||||
|
const label =
|
||||||
|
options.tabBarLabel !== undefined
|
||||||
|
? options.tabBarLabel
|
||||||
|
: options.title !== undefined
|
||||||
|
? options.title
|
||||||
|
: route.name;
|
||||||
|
|
||||||
|
const isFocused = props.state.index === index;
|
||||||
|
|
||||||
|
const onPress = () => {
|
||||||
|
const event = props.navigation.emit({
|
||||||
|
type: 'tabPress',
|
||||||
|
target: route.key,
|
||||||
|
canPreventDefault: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isFocused && !event.defaultPrevented) {
|
||||||
|
props.navigation.navigate(route.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let iconName: IconNameType = 'home';
|
||||||
|
switch (route.name) {
|
||||||
|
case 'Home':
|
||||||
|
iconName = 'home';
|
||||||
|
break;
|
||||||
|
case 'Discover':
|
||||||
|
iconName = 'compass';
|
||||||
|
break;
|
||||||
|
case 'Library':
|
||||||
|
iconName = 'play-box-multiple';
|
||||||
|
break;
|
||||||
|
case 'Addons':
|
||||||
|
iconName = 'puzzle';
|
||||||
|
break;
|
||||||
|
case 'Settings':
|
||||||
|
iconName = 'cog';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={route.key}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={onPress}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabIcon
|
||||||
|
focused={isFocused}
|
||||||
|
color={isFocused ? colors.primary : '#FFFFFF'}
|
||||||
|
iconName={iconName}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 4,
|
||||||
|
color: isFocused ? colors.primary : '#FFFFFF',
|
||||||
|
opacity: isFocused ? 1 : 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeof label === 'string' ? label : ''}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab.Navigator
|
||||||
|
tabBar={renderTabBar}
|
||||||
|
screenOptions={({ route }) => ({
|
||||||
|
tabBarIcon: ({ focused, color, size }) => {
|
||||||
|
let iconName: IconNameType = 'home';
|
||||||
|
|
||||||
|
switch (route.name) {
|
||||||
|
case 'Home':
|
||||||
|
iconName = 'home';
|
||||||
|
break;
|
||||||
|
case 'Discover':
|
||||||
|
iconName = 'compass';
|
||||||
|
break;
|
||||||
|
case 'Library':
|
||||||
|
iconName = 'play-box-multiple';
|
||||||
|
break;
|
||||||
|
case 'Addons':
|
||||||
|
iconName = 'puzzle';
|
||||||
|
break;
|
||||||
|
case 'Settings':
|
||||||
|
iconName = 'cog';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TabIcon focused={focused} color={color} iconName={iconName} />;
|
||||||
|
},
|
||||||
|
tabBarActiveTintColor: colors.primary,
|
||||||
|
tabBarInactiveTintColor: '#FFFFFF',
|
||||||
|
tabBarStyle: {
|
||||||
|
position: 'absolute',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderTopWidth: 0,
|
||||||
|
elevation: 0,
|
||||||
|
height: 75,
|
||||||
|
paddingBottom: 10,
|
||||||
|
paddingTop: 12,
|
||||||
|
},
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 0,
|
||||||
|
},
|
||||||
|
tabBarBackground: () => (
|
||||||
|
<LinearGradient
|
||||||
|
colors={[
|
||||||
|
'rgba(0, 0, 0, 0)',
|
||||||
|
'rgba(0, 0, 0, 0.65)',
|
||||||
|
'rgba(0, 0, 0, 0.85)',
|
||||||
|
'rgba(0, 0, 0, 0.98)',
|
||||||
|
]}
|
||||||
|
locations={[0, 0.2, 0.4, 0.8]}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
header: () => <NuvioHeader routeName={route.name} />,
|
||||||
|
headerShown: true,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Home"
|
||||||
|
component={HomeScreen as any}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: 'Home',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Discover"
|
||||||
|
component={DiscoverScreen as any}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: 'Discover'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Library"
|
||||||
|
component={LibraryScreen as any}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: 'Library'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Addons"
|
||||||
|
component={AddonsScreen as any}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: 'Addons'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Settings"
|
||||||
|
component={SettingsScreen as any}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: 'Settings'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stack Navigator
|
||||||
|
const AppNavigator = () => {
|
||||||
|
// Always use dark mode
|
||||||
|
const isDarkMode = true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StatusBar
|
||||||
|
translucent
|
||||||
|
backgroundColor="transparent"
|
||||||
|
barStyle="light-content"
|
||||||
|
/>
|
||||||
|
<PaperProvider theme={CustomDarkTheme}>
|
||||||
|
<Stack.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
animation: Platform.OS === 'android' ? 'fade_from_bottom' : 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="MainTabs"
|
||||||
|
component={MainTabs as any}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Metadata"
|
||||||
|
component={MetadataScreen as any}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Streams"
|
||||||
|
component={StreamsScreen as any}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Player"
|
||||||
|
component={VideoPlayer as any}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Catalog"
|
||||||
|
component={CatalogScreen as any}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Addons"
|
||||||
|
component={AddonsScreen as any}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Search"
|
||||||
|
component={SearchScreen as any}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="CatalogSettings"
|
||||||
|
component={CatalogSettingsScreen as any}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="ShowRatings"
|
||||||
|
component={ShowRatingsScreen}
|
||||||
|
options={{
|
||||||
|
animation: 'fade',
|
||||||
|
animationDuration: 200,
|
||||||
|
presentation: 'card',
|
||||||
|
gestureEnabled: true,
|
||||||
|
gestureDirection: 'horizontal',
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Calendar"
|
||||||
|
component={CalendarScreen as any}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="NotificationSettings"
|
||||||
|
component={NotificationSettingsScreen as any}
|
||||||
|
/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
</PaperProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppNavigator;
|
||||||
639
src/screens/AddonsScreen.tsx
Normal file
|
|
@ -0,0 +1,639 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
FlatList,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
SafeAreaView,
|
||||||
|
StatusBar,
|
||||||
|
Modal,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Image,
|
||||||
|
Dimensions,
|
||||||
|
ScrollView,
|
||||||
|
useColorScheme
|
||||||
|
} from 'react-native';
|
||||||
|
import { stremioService, Manifest } from '../services/stremioService';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { colors } from '../styles';
|
||||||
|
import { Image as ExpoImage } from 'expo-image';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
|
||||||
|
// Extend Manifest type to include logo
|
||||||
|
interface ExtendedManifest extends Manifest {
|
||||||
|
logo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
const AddonsScreen = () => {
|
||||||
|
const [addons, setAddons] = useState<ExtendedManifest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [addonUrl, setAddonUrl] = useState('');
|
||||||
|
const [addonDetails, setAddonDetails] = useState<ExtendedManifest | null>(null);
|
||||||
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
|
const isDarkMode = useColorScheme() === 'dark';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAddons();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadAddons = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const installedAddons = await stremioService.getInstalledAddonsAsync();
|
||||||
|
setAddons(installedAddons);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load addons:', error);
|
||||||
|
Alert.alert('Error', 'Failed to load addons');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallAddon = async () => {
|
||||||
|
if (!addonUrl) {
|
||||||
|
Alert.alert('Error', 'Please enter an addon URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setInstalling(true);
|
||||||
|
// First fetch the addon manifest
|
||||||
|
const manifest = await stremioService.getManifest(addonUrl);
|
||||||
|
setAddonDetails(manifest);
|
||||||
|
setShowAddModal(false);
|
||||||
|
setShowConfirmModal(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch addon details:', error);
|
||||||
|
Alert.alert('Error', 'Failed to fetch addon details');
|
||||||
|
} finally {
|
||||||
|
setInstalling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmInstallAddon = async () => {
|
||||||
|
if (!addonDetails) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setInstalling(true);
|
||||||
|
await stremioService.installAddon(addonUrl);
|
||||||
|
setAddonUrl('');
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setAddonDetails(null);
|
||||||
|
loadAddons();
|
||||||
|
Alert.alert('Success', 'Addon installed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to install addon:', error);
|
||||||
|
Alert.alert('Error', 'Failed to install addon');
|
||||||
|
} finally {
|
||||||
|
setInstalling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigureAddon = (addon: ExtendedManifest) => {
|
||||||
|
// TODO: Implement addon configuration
|
||||||
|
Alert.alert('Configure', `Configure ${addon.name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAddon = (addon: ExtendedManifest) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Uninstall',
|
||||||
|
`Are you sure you want to uninstall ${addon.name}?`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Uninstall',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
stremioService.removeAddon(addon.id);
|
||||||
|
loadAddons();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAddonItem = ({ item }: { item: ExtendedManifest }) => {
|
||||||
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.addonItem}>
|
||||||
|
<View style={styles.addonContent}>
|
||||||
|
<View style={styles.addonIconContainer}>
|
||||||
|
{logo ? (
|
||||||
|
<ExpoImage
|
||||||
|
source={{ uri: logo }}
|
||||||
|
style={styles.addonIcon}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.placeholderIcon}>
|
||||||
|
<MaterialIcons name="extension" size={32} color={colors.mediumGray} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.addonInfo}>
|
||||||
|
<Text style={styles.addonName}>{item.name}</Text>
|
||||||
|
<Text style={styles.addonType}>
|
||||||
|
{types.join(', ')}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.addonDescription} numberOfLines={2}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.addonActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.configButton}
|
||||||
|
onPress={() => handleConfigureAddon(item)}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="settings" size={24} color={colors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.uninstallButton}
|
||||||
|
onPress={() => handleRemoveAddon(item)}
|
||||||
|
>
|
||||||
|
<Text style={styles.uninstallText}>Uninstall</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} />
|
||||||
|
|
||||||
|
<View style={styles.searchContainer}>
|
||||||
|
<MaterialIcons name="search" size={24} color={colors.mediumGray} />
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="You can search anything..."
|
||||||
|
placeholderTextColor={colors.mediumGray}
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={addons}
|
||||||
|
renderItem={renderAddonItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={styles.addonsList}
|
||||||
|
ListEmptyComponent={() => (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialIcons name="extension-off" size={48} color={colors.mediumGray} />
|
||||||
|
<Text style={styles.emptyText}>No addons installed</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Addon FAB */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.fab}
|
||||||
|
onPress={() => setShowAddModal(true)}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="add" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Add Addon URL Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showAddModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowAddModal(false)}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={styles.modalContainer}
|
||||||
|
>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<Text style={styles.modalTitle}>Add New Addon</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.modalInput}
|
||||||
|
placeholder="Enter addon URL..."
|
||||||
|
placeholderTextColor={colors.mediumGray}
|
||||||
|
value={addonUrl}
|
||||||
|
onChangeText={setAddonUrl}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalButton}
|
||||||
|
onPress={() => setShowAddModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalButtonText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalButton, styles.modalButtonPrimary]}
|
||||||
|
onPress={handleInstallAddon}
|
||||||
|
disabled={installing}
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.modalButtonText, styles.modalButtonTextPrimary]}>
|
||||||
|
Next
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Addon Details Confirmation Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showConfirmModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => {
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setAddonDetails(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
|
<View style={[styles.modalContent, styles.confirmModalContent]}>
|
||||||
|
{addonDetails && (
|
||||||
|
<>
|
||||||
|
<View style={styles.addonHeader}>
|
||||||
|
{/* @ts-ignore - some addons might have logo property even though it's not in the type */}
|
||||||
|
{addonDetails.logo ? (
|
||||||
|
<ExpoImage
|
||||||
|
source={{ uri: addonDetails.logo }}
|
||||||
|
style={styles.addonLogo}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.placeholderLogo}>
|
||||||
|
<MaterialIcons name="extension" size={48} color={colors.mediumGray} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.addonTitle}>{addonDetails.name}</Text>
|
||||||
|
<Text style={styles.addonVersion}>Version {addonDetails.version}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||||
|
<View style={styles.addonDetailsSection}>
|
||||||
|
<Text style={styles.sectionTitle}>Description</Text>
|
||||||
|
<Text style={styles.addonDescription}>
|
||||||
|
{addonDetails.description || 'No description available'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>Supported Types</Text>
|
||||||
|
<View style={styles.typeContainer}>
|
||||||
|
{(addonDetails.types || []).map((type, index) => (
|
||||||
|
<View key={index} style={styles.typeChip}>
|
||||||
|
<Text style={styles.typeText}>{type}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{addonDetails.catalogs && addonDetails.catalogs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.sectionTitle}>Catalogs</Text>
|
||||||
|
<View style={styles.typeContainer}>
|
||||||
|
{addonDetails.catalogs.map((catalog, index) => (
|
||||||
|
<View key={index} style={styles.typeChip}>
|
||||||
|
<Text style={styles.typeText}>{catalog.type}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={styles.confirmActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.confirmButton, styles.cancelButton]}
|
||||||
|
onPress={() => {
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setAddonDetails(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.confirmButtonText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.confirmButton, styles.installButton]}
|
||||||
|
onPress={confirmInstallAddon}
|
||||||
|
disabled={installing}
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.confirmButtonText}>Install</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.elevation1,
|
||||||
|
margin: 16,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 8,
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
addonsList: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
addonItem: {
|
||||||
|
backgroundColor: colors.elevation1,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
addonContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
addonIconContainer: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
addonIcon: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
placeholderIcon: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: colors.elevation2,
|
||||||
|
borderRadius: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
addonInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
addonName: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
addonType: {
|
||||||
|
color: colors.mediumGray,
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
addonDescription: {
|
||||||
|
color: colors.mediumEmphasis,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
addonActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.elevation2,
|
||||||
|
paddingTop: 16,
|
||||||
|
},
|
||||||
|
configButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
uninstallButton: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.elevation2,
|
||||||
|
},
|
||||||
|
uninstallText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 32,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.mediumGray,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
fab: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 16,
|
||||||
|
bottom: 90,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
elevation: 8,
|
||||||
|
shadowColor: colors.black,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.30,
|
||||||
|
shadowRadius: 4.65,
|
||||||
|
},
|
||||||
|
modalContainer: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
backgroundColor: colors.elevation1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
width: '85%',
|
||||||
|
maxWidth: 360,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
modalInput: {
|
||||||
|
backgroundColor: colors.elevation2,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
modalButton: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
modalButtonPrimary: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
modalButtonText: {
|
||||||
|
color: colors.mediumGray,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
modalButtonTextPrimary: {
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
confirmModalContent: {
|
||||||
|
width: '85%',
|
||||||
|
maxWidth: 360,
|
||||||
|
maxHeight: '80%',
|
||||||
|
padding: 0,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
addonHeader: {
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.elevation1,
|
||||||
|
backgroundColor: colors.elevation2,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
addonLogo: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.elevation1,
|
||||||
|
},
|
||||||
|
placeholderLogo: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.elevation1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
addonTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
addonVersion: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
addonDetailsSection: {
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 8,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
typeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 12,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
typeChip: {
|
||||||
|
backgroundColor: colors.elevation2,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.elevation3,
|
||||||
|
},
|
||||||
|
typeText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
confirmActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.elevation1,
|
||||||
|
backgroundColor: colors.elevation2,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
confirmButton: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
minWidth: 90,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: colors.elevation3,
|
||||||
|
},
|
||||||
|
installButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
confirmButtonText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AddonsScreen;
|
||||||
710
src/screens/CalendarScreen.tsx
Normal file
|
|
@ -0,0 +1,710 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
SafeAreaView,
|
||||||
|
StatusBar,
|
||||||
|
useColorScheme,
|
||||||
|
Dimensions,
|
||||||
|
SectionList
|
||||||
|
} from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { colors } from '../styles/colors';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
import { stremioService } from '../services/stremioService';
|
||||||
|
import { useLibrary } from '../hooks/useLibrary';
|
||||||
|
import { format, parseISO, isThisWeek, isAfter, startOfToday, addWeeks, isBefore, isSameDay } from 'date-fns';
|
||||||
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||||
|
import { CalendarSection } from '../components/calendar/CalendarSection';
|
||||||
|
import { tmdbService } from '../services/tmdbService';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface CalendarEpisode {
|
||||||
|
id: string;
|
||||||
|
seriesId: string;
|
||||||
|
title: string;
|
||||||
|
seriesName: string;
|
||||||
|
poster: string;
|
||||||
|
releaseDate: string;
|
||||||
|
season: number;
|
||||||
|
episode: number;
|
||||||
|
overview: string;
|
||||||
|
vote_average: number;
|
||||||
|
still_path: string | null;
|
||||||
|
season_poster_path: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarSection {
|
||||||
|
title: string;
|
||||||
|
data: CalendarEpisode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarScreen = () => {
|
||||||
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
const { libraryItems, loading: libraryLoading } = useLibrary();
|
||||||
|
console.log(`[Calendar] Initial load - Library has ${libraryItems?.length || 0} items, loading: ${libraryLoading}`);
|
||||||
|
const [calendarData, setCalendarData] = useState<CalendarSection[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||||
|
const [filteredEpisodes, setFilteredEpisodes] = useState<CalendarEpisode[]>([]);
|
||||||
|
|
||||||
|
const fetchCalendarData = useCallback(async () => {
|
||||||
|
console.log("[Calendar] Starting to fetch calendar data");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Filter for only series in library
|
||||||
|
const seriesItems = libraryItems.filter(item => item.type === 'series');
|
||||||
|
console.log(`[Calendar] Library items: ${libraryItems.length}, Series items: ${seriesItems.length}`);
|
||||||
|
|
||||||
|
let allEpisodes: CalendarEpisode[] = [];
|
||||||
|
let seriesWithoutEpisodes: CalendarEpisode[] = [];
|
||||||
|
|
||||||
|
// For each series, fetch upcoming episodes
|
||||||
|
for (const series of seriesItems) {
|
||||||
|
try {
|
||||||
|
console.log(`[Calendar] Fetching episodes for series: ${series.name} (${series.id})`);
|
||||||
|
const metadata = await stremioService.getMetaDetails(series.type, series.id);
|
||||||
|
console.log(`[Calendar] Metadata fetched:`, metadata ? 'success' : 'null');
|
||||||
|
|
||||||
|
if (metadata?.videos && metadata.videos.length > 0) {
|
||||||
|
console.log(`[Calendar] Series ${series.name} has ${metadata.videos.length} videos`);
|
||||||
|
// Filter for upcoming episodes or recently released
|
||||||
|
const today = startOfToday();
|
||||||
|
const fourWeeksLater = addWeeks(today, 4);
|
||||||
|
const twoWeeksAgo = addWeeks(today, -2);
|
||||||
|
|
||||||
|
// Get TMDB ID for additional metadata
|
||||||
|
const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id);
|
||||||
|
let tmdbEpisodes: { [key: string]: any } = {};
|
||||||
|
|
||||||
|
if (tmdbId) {
|
||||||
|
const allTMDBEpisodes = await tmdbService.getAllEpisodes(tmdbId);
|
||||||
|
// Flatten episodes into a map for easy lookup
|
||||||
|
Object.values(allTMDBEpisodes).forEach(seasonEpisodes => {
|
||||||
|
seasonEpisodes.forEach(episode => {
|
||||||
|
const key = `${episode.season_number}:${episode.episode_number}`;
|
||||||
|
tmdbEpisodes[key] = episode;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcomingEpisodes = metadata.videos
|
||||||
|
.filter(video => {
|
||||||
|
if (!video.released) return false;
|
||||||
|
const releaseDate = parseISO(video.released);
|
||||||
|
return isBefore(releaseDate, fourWeeksLater) && isAfter(releaseDate, twoWeeksAgo);
|
||||||
|
})
|
||||||
|
.map(video => {
|
||||||
|
const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {};
|
||||||
|
return {
|
||||||
|
id: video.id,
|
||||||
|
seriesId: series.id,
|
||||||
|
title: tmdbEpisode.name || video.title || `Episode ${video.episode}`,
|
||||||
|
seriesName: series.name || metadata.name,
|
||||||
|
poster: series.poster || metadata.poster || '',
|
||||||
|
releaseDate: video.released,
|
||||||
|
season: video.season || 0,
|
||||||
|
episode: video.episode || 0,
|
||||||
|
overview: tmdbEpisode.overview || '',
|
||||||
|
vote_average: tmdbEpisode.vote_average || 0,
|
||||||
|
still_path: tmdbEpisode.still_path || null,
|
||||||
|
season_poster_path: tmdbEpisode.season_poster_path || null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (upcomingEpisodes.length > 0) {
|
||||||
|
allEpisodes = [...allEpisodes, ...upcomingEpisodes];
|
||||||
|
} else {
|
||||||
|
// Add to series without episode dates
|
||||||
|
seriesWithoutEpisodes.push({
|
||||||
|
id: series.id,
|
||||||
|
seriesId: series.id,
|
||||||
|
title: 'No upcoming episodes',
|
||||||
|
seriesName: series.name || (metadata?.name || ''),
|
||||||
|
poster: series.poster || (metadata?.poster || ''),
|
||||||
|
releaseDate: '',
|
||||||
|
season: 0,
|
||||||
|
episode: 0,
|
||||||
|
overview: '',
|
||||||
|
vote_average: 0,
|
||||||
|
still_path: null,
|
||||||
|
season_poster_path: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add to series without episode dates
|
||||||
|
seriesWithoutEpisodes.push({
|
||||||
|
id: series.id,
|
||||||
|
seriesId: series.id,
|
||||||
|
title: 'No upcoming episodes',
|
||||||
|
seriesName: series.name || (metadata?.name || ''),
|
||||||
|
poster: series.poster || (metadata?.poster || ''),
|
||||||
|
releaseDate: '',
|
||||||
|
season: 0,
|
||||||
|
episode: 0,
|
||||||
|
overview: '',
|
||||||
|
vote_average: 0,
|
||||||
|
still_path: null,
|
||||||
|
season_poster_path: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching episodes for ${series.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort episodes by release date
|
||||||
|
allEpisodes.sort((a, b) => {
|
||||||
|
return new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group episodes into sections
|
||||||
|
const thisWeekEpisodes = allEpisodes.filter(
|
||||||
|
episode => isThisWeek(parseISO(episode.releaseDate))
|
||||||
|
);
|
||||||
|
|
||||||
|
const upcomingEpisodes = allEpisodes.filter(
|
||||||
|
episode => isAfter(parseISO(episode.releaseDate), new Date()) &&
|
||||||
|
!isThisWeek(parseISO(episode.releaseDate))
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentEpisodes = allEpisodes.filter(
|
||||||
|
episode => isBefore(parseISO(episode.releaseDate), new Date()) &&
|
||||||
|
!isThisWeek(parseISO(episode.releaseDate))
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[Calendar] Episodes summary: All episodes: ${allEpisodes.length}, This Week: ${thisWeekEpisodes.length}, Upcoming: ${upcomingEpisodes.length}, Recent: ${recentEpisodes.length}, No Schedule: ${seriesWithoutEpisodes.length}`);
|
||||||
|
|
||||||
|
const sections: CalendarSection[] = [];
|
||||||
|
|
||||||
|
if (thisWeekEpisodes.length > 0) {
|
||||||
|
sections.push({ title: 'This Week', data: thisWeekEpisodes });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upcomingEpisodes.length > 0) {
|
||||||
|
sections.push({ title: 'Upcoming', data: upcomingEpisodes });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentEpisodes.length > 0) {
|
||||||
|
sections.push({ title: 'Recently Released', data: recentEpisodes });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesWithoutEpisodes.length > 0) {
|
||||||
|
sections.push({ title: 'Series with No Scheduled Episodes', data: seriesWithoutEpisodes });
|
||||||
|
}
|
||||||
|
|
||||||
|
setCalendarData(sections);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching calendar data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [libraryItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (libraryItems.length > 0 && !libraryLoading) {
|
||||||
|
console.log(`[Calendar] Library loaded with ${libraryItems.length} items, fetching calendar data`);
|
||||||
|
fetchCalendarData();
|
||||||
|
} else if (!libraryLoading) {
|
||||||
|
console.log(`[Calendar] Library loaded but empty (${libraryItems.length} items)`);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [libraryItems, libraryLoading, fetchCalendarData]);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchCalendarData();
|
||||||
|
}, [fetchCalendarData]);
|
||||||
|
|
||||||
|
const handleSeriesPress = useCallback((seriesId: string) => {
|
||||||
|
navigation.navigate('Metadata', {
|
||||||
|
id: seriesId,
|
||||||
|
type: 'series'
|
||||||
|
});
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
const handleEpisodePress = useCallback((episode: CalendarEpisode) => {
|
||||||
|
// For series without episode dates, just go to the series page
|
||||||
|
if (!episode.releaseDate) {
|
||||||
|
handleSeriesPress(episode.seriesId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For episodes with dates, go to the stream screen
|
||||||
|
const episodeId = `${episode.seriesId}:${episode.season}:${episode.episode}`;
|
||||||
|
navigation.navigate('Streams', {
|
||||||
|
id: episode.seriesId,
|
||||||
|
type: 'series',
|
||||||
|
episodeId
|
||||||
|
});
|
||||||
|
}, [navigation, handleSeriesPress]);
|
||||||
|
|
||||||
|
const renderEpisodeItem = ({ item }: { item: CalendarEpisode }) => {
|
||||||
|
const hasReleaseDate = !!item.releaseDate;
|
||||||
|
const releaseDate = hasReleaseDate ? parseISO(item.releaseDate) : null;
|
||||||
|
const formattedDate = releaseDate ? format(releaseDate, 'MMM d, yyyy') : '';
|
||||||
|
const isFuture = releaseDate ? isAfter(releaseDate, new Date()) : false;
|
||||||
|
|
||||||
|
// Use episode still image if available, fallback to series poster
|
||||||
|
const imageUrl = item.still_path ?
|
||||||
|
tmdbService.getImageUrl(item.still_path) :
|
||||||
|
(item.season_poster_path ?
|
||||||
|
tmdbService.getImageUrl(item.season_poster_path) :
|
||||||
|
item.poster);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeIn.duration(300).delay(100)}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.episodeItem}
|
||||||
|
onPress={() => handleEpisodePress(item)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleSeriesPress(item.seriesId)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={styles.poster}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.episodeDetails}>
|
||||||
|
<Text style={styles.seriesName} numberOfLines={1}>
|
||||||
|
{item.seriesName}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{hasReleaseDate ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.episodeTitle} numberOfLines={2}>
|
||||||
|
S{item.season}:E{item.episode} - {item.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{item.overview ? (
|
||||||
|
<Text style={styles.overview} numberOfLines={2}>
|
||||||
|
{item.overview}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.metadataContainer}>
|
||||||
|
<View style={styles.dateContainer}>
|
||||||
|
<MaterialIcons
|
||||||
|
name={isFuture ? "event" : "event-available"}
|
||||||
|
size={16}
|
||||||
|
color={colors.lightGray}
|
||||||
|
/>
|
||||||
|
<Text style={styles.date}>{formattedDate}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.vote_average > 0 && (
|
||||||
|
<View style={styles.ratingContainer}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="star"
|
||||||
|
size={16}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
|
<Text style={styles.rating}>
|
||||||
|
{item.vote_average.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.noEpisodesText}>
|
||||||
|
No scheduled episodes
|
||||||
|
</Text>
|
||||||
|
<View style={styles.dateContainer}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="event-busy"
|
||||||
|
size={16}
|
||||||
|
color={colors.lightGray}
|
||||||
|
/>
|
||||||
|
<Text style={styles.date}>Check back later</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSectionHeader = ({ section }: { section: CalendarSection }) => (
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process all episodes once data is loaded
|
||||||
|
const allEpisodes = calendarData.reduce((acc, section) =>
|
||||||
|
[...acc, ...section.data], [] as CalendarEpisode[]);
|
||||||
|
|
||||||
|
// Log when rendering with relevant state info
|
||||||
|
console.log(`[Calendar] Rendering: loading=${loading}, calendarData sections=${calendarData.length}, allEpisodes=${allEpisodes.length}`);
|
||||||
|
|
||||||
|
// Handle date selection from calendar
|
||||||
|
const handleDateSelect = useCallback((date: Date) => {
|
||||||
|
console.log(`[Calendar] Date selected: ${format(date, 'yyyy-MM-dd')}`);
|
||||||
|
setSelectedDate(date);
|
||||||
|
|
||||||
|
// Filter episodes for the selected date
|
||||||
|
const filtered = allEpisodes.filter(episode => {
|
||||||
|
if (!episode.releaseDate) return false;
|
||||||
|
const episodeDate = parseISO(episode.releaseDate);
|
||||||
|
return isSameDay(episodeDate, date);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Calendar] Filtered episodes for selected date: ${filtered.length}`);
|
||||||
|
setFilteredEpisodes(filtered);
|
||||||
|
}, [allEpisodes]);
|
||||||
|
|
||||||
|
// Reset date filter
|
||||||
|
const clearDateFilter = useCallback(() => {
|
||||||
|
console.log(`[Calendar] Clearing date filter`);
|
||||||
|
setSelectedDate(null);
|
||||||
|
setFilteredEpisodes([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (libraryItems.length === 0 && !libraryLoading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Calendar</Text>
|
||||||
|
<View style={{ width: 40 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.emptyLibraryContainer}>
|
||||||
|
<MaterialIcons name="video-library" size={64} color={colors.lightGray} />
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
Your library is empty
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.emptySubtext}>
|
||||||
|
Add series to your library to see their upcoming episodes in the calendar
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.discoverButton}
|
||||||
|
onPress={() => navigation.navigate('MainTabs')}
|
||||||
|
>
|
||||||
|
<Text style={styles.discoverButtonText}>
|
||||||
|
Return to Home
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !refreshing) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={styles.loadingText}>Loading calendar...</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Calendar</Text>
|
||||||
|
<View style={{ width: 40 }} /> {/* Empty view for balance */}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{selectedDate && filteredEpisodes.length > 0 && (
|
||||||
|
<View style={styles.filterInfoContainer}>
|
||||||
|
<Text style={styles.filterInfoText}>
|
||||||
|
Showing episodes for {format(selectedDate, 'MMMM d, yyyy')}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={clearDateFilter} style={styles.clearFilterButton}>
|
||||||
|
<MaterialIcons name="close" size={18} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CalendarSection
|
||||||
|
episodes={allEpisodes}
|
||||||
|
onSelectDate={handleDateSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedDate && filteredEpisodes.length > 0 ? (
|
||||||
|
<FlatList
|
||||||
|
data={filteredEpisodes}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={renderEpisodeItem}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
colors={[colors.primary]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : selectedDate && filteredEpisodes.length === 0 ? (
|
||||||
|
<View style={styles.emptyFilterContainer}>
|
||||||
|
<MaterialIcons name="event-busy" size={48} color={colors.lightGray} />
|
||||||
|
<Text style={styles.emptyFilterText}>
|
||||||
|
No episodes for {format(selectedDate, 'MMMM d, yyyy')}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.clearFilterButtonLarge}
|
||||||
|
onPress={clearDateFilter}
|
||||||
|
>
|
||||||
|
<Text style={styles.clearFilterButtonText}>
|
||||||
|
Show All Episodes
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : calendarData.length > 0 ? (
|
||||||
|
<SectionList
|
||||||
|
sections={calendarData}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={renderEpisodeItem}
|
||||||
|
renderSectionHeader={renderSectionHeader}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
colors={[colors.primary]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialIcons name="calendar-today" size={64} color={colors.lightGray} />
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
No upcoming episodes found
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.emptySubtext}>
|
||||||
|
Add series to your library to see their upcoming episodes here
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingBottom: 20,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: colors.text,
|
||||||
|
marginTop: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
episodeItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
padding: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border + '20',
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
width: 120,
|
||||||
|
height: 68,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
episodeDetails: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 12,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
seriesName: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
episodeTitle: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
metadataContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
dateContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 14,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
ratingContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 14,
|
||||||
|
marginLeft: 4,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginTop: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
filterInfoContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
filterInfoText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
clearFilterButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
emptyFilterContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
emptyFilterText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginTop: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
clearFilterButtonLarge: {
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
clearFilterButtonText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
emptyLibraryContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
discoverButton: {
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
discoverButtonText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
noEpisodesText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CalendarScreen;
|
||||||
330
src/screens/CatalogScreen.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
SafeAreaView,
|
||||||
|
StatusBar,
|
||||||
|
RefreshControl,
|
||||||
|
Dimensions,
|
||||||
|
} from 'react-native';
|
||||||
|
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
import { Meta, stremioService } from '../services/stremioService';
|
||||||
|
import { colors } from '../styles';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
|
||||||
|
type CatalogScreenProps = {
|
||||||
|
route: RouteProp<RootStackParamList, 'Catalog'>;
|
||||||
|
navigation: StackNavigationProp<RootStackParamList, 'Catalog'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const NUM_COLUMNS = 3;
|
||||||
|
const ITEM_WIDTH = width / NUM_COLUMNS - 20;
|
||||||
|
|
||||||
|
const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
|
const { addonId, type, id, name, 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 instead of using color scheme
|
||||||
|
const isDarkMode = true;
|
||||||
|
|
||||||
|
const loadItems = useCallback(async (pageNum: number, shouldRefresh: boolean = false) => {
|
||||||
|
try {
|
||||||
|
if (shouldRefresh) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else if (pageNum === 1) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Use this flag to track if we found and processed any items
|
||||||
|
let foundItems = false;
|
||||||
|
let allItems: Meta[] = [];
|
||||||
|
|
||||||
|
// Get all installed addon manifests directly
|
||||||
|
const manifests = await stremioService.getInstalledAddonsAsync();
|
||||||
|
|
||||||
|
if (addonId) {
|
||||||
|
// If addon ID is provided, find the specific addon
|
||||||
|
const addon = manifests.find(a => a.id === addonId);
|
||||||
|
|
||||||
|
if (!addon) {
|
||||||
|
throw new Error(`Addon ${addonId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create filters array for genre filtering if provided
|
||||||
|
const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : [];
|
||||||
|
|
||||||
|
// Load items from the catalog
|
||||||
|
const newItems = await stremioService.getCatalog(addon, type, id, pageNum, filters);
|
||||||
|
|
||||||
|
if (newItems.length === 0) {
|
||||||
|
setHasMore(false);
|
||||||
|
} else {
|
||||||
|
foundItems = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRefresh || pageNum === 1) {
|
||||||
|
setItems(newItems);
|
||||||
|
} else {
|
||||||
|
setItems(prev => [...prev, ...newItems]);
|
||||||
|
}
|
||||||
|
} else if (genreFilter) {
|
||||||
|
// Get all addons that have catalogs of the specified type
|
||||||
|
const typeManifests = manifests.filter(manifest =>
|
||||||
|
manifest.catalogs && manifest.catalogs.some(catalog => catalog.type === type)
|
||||||
|
);
|
||||||
|
|
||||||
|
// For each addon, try to get content with the genre filter
|
||||||
|
for (const manifest of typeManifests) {
|
||||||
|
try {
|
||||||
|
// Find catalogs of this type
|
||||||
|
const typeCatalogs = manifest.catalogs?.filter(catalog => catalog.type === type) || [];
|
||||||
|
|
||||||
|
// For each catalog, try to get content
|
||||||
|
for (const catalog of typeCatalogs) {
|
||||||
|
try {
|
||||||
|
const filters = [{ title: 'genre', value: genreFilter }];
|
||||||
|
const catalogItems = await stremioService.getCatalog(manifest, type, catalog.id, pageNum, filters);
|
||||||
|
|
||||||
|
if (catalogItems && catalogItems.length > 0) {
|
||||||
|
allItems = [...allItems, ...catalogItems];
|
||||||
|
foundItems = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Failed to load items from ${manifest.name} catalog ${catalog.id}:`, error);
|
||||||
|
// Continue with other catalogs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Failed to process addon ${manifest.name}:`, error);
|
||||||
|
// Continue with other addons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicates by ID
|
||||||
|
const uniqueItems = allItems.filter((item, index, self) =>
|
||||||
|
index === self.findIndex((t) => t.id === item.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uniqueItems.length === 0) {
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRefresh || pageNum === 1) {
|
||||||
|
setItems(uniqueItems);
|
||||||
|
} else {
|
||||||
|
// Add new items while avoiding duplicates
|
||||||
|
setItems(prev => {
|
||||||
|
const prevIds = new Set(prev.map(item => item.id));
|
||||||
|
const newItems = uniqueItems.filter(item => !prevIds.has(item.id));
|
||||||
|
return [...prev, ...newItems];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundItems) {
|
||||||
|
setError("No content found for the selected filters");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load catalog items');
|
||||||
|
console.error('Failed to load catalog:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [addonId, type, id, genreFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadItems(1);
|
||||||
|
// Set the header title
|
||||||
|
navigation.setOptions({ title: name || `${type} catalog` });
|
||||||
|
}, [loadItems, navigation, name, type]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
setPage(1);
|
||||||
|
loadItems(1, true);
|
||||||
|
}, [loadItems]);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
if (!loading && hasMore) {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
setPage(nextPage);
|
||||||
|
loadItems(nextPage);
|
||||||
|
}
|
||||||
|
}, [loading, hasMore, page, loadItems]);
|
||||||
|
|
||||||
|
const renderItem = useCallback(({ item }: { item: Meta }) => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.item}
|
||||||
|
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image' }}
|
||||||
|
style={styles.poster}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={styles.title}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
{item.releaseInfo && (
|
||||||
|
<Text
|
||||||
|
style={styles.releaseInfo}
|
||||||
|
>
|
||||||
|
{item.releaseInfo}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
if (loading && items.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.centered]}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && items.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.centered]}>
|
||||||
|
<Text style={{ color: colors.white }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.retryButton}
|
||||||
|
onPress={() => loadItems(1)}
|
||||||
|
>
|
||||||
|
<Text style={styles.retryText}>Retry</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[
|
||||||
|
styles.container,
|
||||||
|
{ backgroundColor: colors.darkBackground }
|
||||||
|
]}>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} />
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(item) => `${item.id}-${item.type}`}
|
||||||
|
numColumns={NUM_COLUMNS}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
colors={[colors.primary]}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleLoadMore}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListFooterComponent={
|
||||||
|
loading && items.length > 0 ? (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<Text style={{ color: colors.white, fontSize: 16, marginBottom: 10 }}>
|
||||||
|
No content found for the selected genre
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.retryButton}
|
||||||
|
onPress={handleRefresh}
|
||||||
|
>
|
||||||
|
<Text style={styles.retryText}>Try Again</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.white,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
width: ITEM_WIDTH,
|
||||||
|
margin: 5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: 2/3,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: colors.transparentLight,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
marginTop: 5,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: colors.white,
|
||||||
|
},
|
||||||
|
releaseInfo: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
color: colors.lightGray,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
padding: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: 15,
|
||||||
|
padding: 10,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 5,
|
||||||
|
},
|
||||||
|
retryText: {
|
||||||
|
color: colors.white,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
centered: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CatalogScreen;
|
||||||
274
src/screens/CatalogSettingsScreen.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
ActivityIndicator,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from 'react-native';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { colors } from '../styles';
|
||||||
|
import { stremioService } from '../services/stremioService';
|
||||||
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
import { useCatalogContext } from '../contexts/CatalogContext';
|
||||||
|
|
||||||
|
interface CatalogSetting {
|
||||||
|
addonId: string;
|
||||||
|
catalogId: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogSettingsStorage {
|
||||||
|
[key: string]: boolean | number;
|
||||||
|
_lastUpdate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
||||||
|
|
||||||
|
const CatalogSettingsScreen = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [settings, setSettings] = useState<CatalogSetting[]>([]);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { refreshCatalogs } = useCatalogContext();
|
||||||
|
|
||||||
|
// Load saved settings and available catalogs
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Get installed addons and their catalogs
|
||||||
|
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) : {};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Clean up the name and ensure type is included
|
||||||
|
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
|
||||||
|
|
||||||
|
// Remove duplicate words (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
|
||||||
|
seenWords.add(lowerWord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
displayName = uniqueWords.join(' ');
|
||||||
|
|
||||||
|
// Add content type if not present (case-insensitive)
|
||||||
|
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
|
||||||
|
displayName = `${displayName} ${contentType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create unique catalog setting
|
||||||
|
uniqueCatalogs.set(settingKey, {
|
||||||
|
addonId: addon.id,
|
||||||
|
catalogId: catalog.id,
|
||||||
|
type: catalog.type,
|
||||||
|
name: `${addon.name} - ${displayName}`,
|
||||||
|
enabled: savedCatalogs[settingKey] ?? true // Enable by default
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add unique catalogs to the available catalogs array
|
||||||
|
availableCatalogs.push(...uniqueCatalogs.values());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort catalogs by addon name and then by catalog name
|
||||||
|
const sortedCatalogs = availableCatalogs.sort((a, b) => {
|
||||||
|
const [addonNameA] = a.name.split(' - ');
|
||||||
|
const [addonNameB] = b.name.split(' - ');
|
||||||
|
|
||||||
|
if (addonNameA !== addonNameB) {
|
||||||
|
return addonNameA.localeCompare(addonNameB);
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
setSettings(sortedCatalogs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load catalog settings:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save settings when they change
|
||||||
|
const saveSettings = async (newSettings: CatalogSetting[]) => {
|
||||||
|
try {
|
||||||
|
const settingsObj: CatalogSettingsStorage = {
|
||||||
|
_lastUpdate: Date.now()
|
||||||
|
};
|
||||||
|
newSettings.forEach(setting => {
|
||||||
|
const key = `${setting.addonId}:${setting.type}:${setting.catalogId}`;
|
||||||
|
settingsObj[key] = setting.enabled;
|
||||||
|
});
|
||||||
|
await AsyncStorage.setItem(CATALOG_SETTINGS_KEY, JSON.stringify(settingsObj));
|
||||||
|
refreshCatalogs(); // Trigger catalog refresh after saving settings
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save catalog settings:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle individual catalog
|
||||||
|
const toggleCatalog = (setting: CatalogSetting) => {
|
||||||
|
const newSettings = settings.map(s => {
|
||||||
|
if (s.addonId === setting.addonId &&
|
||||||
|
s.type === setting.type &&
|
||||||
|
s.catalogId === setting.catalogId) {
|
||||||
|
return { ...s, enabled: !s.enabled };
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
setSettings(newSettings);
|
||||||
|
saveSettings(newSettings);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, [loadSettings]);
|
||||||
|
|
||||||
|
// Group settings by addon
|
||||||
|
const groupedSettings: { [key: string]: CatalogSetting[] } = {};
|
||||||
|
settings.forEach(setting => {
|
||||||
|
if (!groupedSettings[setting.addonId]) {
|
||||||
|
groupedSettings[setting.addonId] = [];
|
||||||
|
}
|
||||||
|
groupedSettings[setting.addonId].push(setting);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Catalog Settings</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.scrollView}>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
Choose which catalogs to show on your home screen. Changes will take effect immediately.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{Object.entries(groupedSettings).map(([addonId, addonCatalogs]) => (
|
||||||
|
<View key={addonId} style={styles.addonSection}>
|
||||||
|
<Text style={styles.addonTitle}>
|
||||||
|
{addonCatalogs[0].name.split(' - ')[0]}
|
||||||
|
</Text>
|
||||||
|
{addonCatalogs.map((setting) => (
|
||||||
|
<View key={`${setting.addonId}:${setting.type}:${setting.catalogId}`} style={styles.catalogItem}>
|
||||||
|
<Text style={styles.catalogName}>
|
||||||
|
{setting.name.split(' - ')[1]}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
value={setting.enabled}
|
||||||
|
onValueChange={() => toggleCatalog(setting)}
|
||||||
|
trackColor={{ false: colors.mediumGray, true: colors.primary }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
padding: 16,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.mediumGray,
|
||||||
|
},
|
||||||
|
addonSection: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
addonTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
catalogItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
catalogName: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CatalogSettingsScreen;
|
||||||
546
src/screens/DiscoverScreen.tsx
Normal file
|
|
@ -0,0 +1,546 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
SafeAreaView,
|
||||||
|
StatusBar,
|
||||||
|
Dimensions,
|
||||||
|
ScrollView,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { colors } from '../styles';
|
||||||
|
import { catalogService, StreamingContent, CatalogContent } from '../services/catalogService';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import Animated, { FadeIn, FadeOut, SlideInRight, Layout } from 'react-native-reanimated';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'movie' | 'series' | 'channel' | 'tv';
|
||||||
|
icon: keyof typeof MaterialIcons.glyphMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenreCatalog {
|
||||||
|
genre: string;
|
||||||
|
items: StreamingContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES: Category[] = [
|
||||||
|
{ id: 'movie', name: 'Movies', type: 'movie', icon: 'local-movies' },
|
||||||
|
{ id: 'series', name: 'TV Shows', type: 'series', icon: 'live-tv' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Common genres for movies and TV shows
|
||||||
|
const COMMON_GENRES = [
|
||||||
|
'All',
|
||||||
|
'Action',
|
||||||
|
'Adventure',
|
||||||
|
'Animation',
|
||||||
|
'Comedy',
|
||||||
|
'Crime',
|
||||||
|
'Documentary',
|
||||||
|
'Drama',
|
||||||
|
'Family',
|
||||||
|
'Fantasy',
|
||||||
|
'History',
|
||||||
|
'Horror',
|
||||||
|
'Music',
|
||||||
|
'Mystery',
|
||||||
|
'Romance',
|
||||||
|
'Science Fiction',
|
||||||
|
'Thriller',
|
||||||
|
'War',
|
||||||
|
'Western'
|
||||||
|
];
|
||||||
|
|
||||||
|
const DiscoverScreen = () => {
|
||||||
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<Category>(CATEGORIES[0]);
|
||||||
|
const [selectedGenre, setSelectedGenre] = useState<string>('All');
|
||||||
|
const [catalogs, setCatalogs] = useState<GenreCatalog[]>([]);
|
||||||
|
const [allContent, setAllContent] = useState<StreamingContent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const itemWidth = (width - 60) / 4; // 4 items per row with spacing
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
headerContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '800',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: colors.white,
|
||||||
|
},
|
||||||
|
searchButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
searchIconContainer: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.transparentLight,
|
||||||
|
},
|
||||||
|
categoryContainer: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
categoriesContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
categoryButton: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 12,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.lightGray,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
categoryIcon: {
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
categoryText: {
|
||||||
|
color: colors.mediumGray,
|
||||||
|
fontWeight: '500',
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
genreContainer: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
genresScrollView: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
genreButton: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
marginRight: 8,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.lightGray,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
genreText: {
|
||||||
|
color: colors.mediumGray,
|
||||||
|
fontWeight: '500',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
catalogsContainer: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
catalogContainer: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
catalogHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
titleContainer: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
catalogTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.white,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
titleUnderline: {
|
||||||
|
height: 2,
|
||||||
|
width: 40,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
seeAllButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
seeAllText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
contentItem: {
|
||||||
|
width: itemWidth,
|
||||||
|
marginHorizontal: 5,
|
||||||
|
},
|
||||||
|
posterContainer: {
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: colors.transparentLight,
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: colors.black,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 4,
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
aspectRatio: 2/3,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
posterGradient: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: 8,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
contentTitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.white,
|
||||||
|
marginBottom: 2,
|
||||||
|
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 2,
|
||||||
|
},
|
||||||
|
contentYear: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: colors.mediumGray,
|
||||||
|
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 2,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 100,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: colors.mediumGray,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadContent(selectedCategory, selectedGenre);
|
||||||
|
}, [selectedCategory, selectedGenre]);
|
||||||
|
|
||||||
|
const loadContent = async (category: Category, genre: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// If genre is 'All', don't apply genre filter
|
||||||
|
const genreFilter = genre === 'All' ? undefined : genre;
|
||||||
|
const fetchedCatalogs = await catalogService.getCatalogByType(category.type, genreFilter);
|
||||||
|
|
||||||
|
// Collect all content items
|
||||||
|
const content: StreamingContent[] = [];
|
||||||
|
fetchedCatalogs.forEach(catalog => {
|
||||||
|
content.push(...catalog.items);
|
||||||
|
});
|
||||||
|
|
||||||
|
setAllContent(content);
|
||||||
|
|
||||||
|
if (genre === 'All') {
|
||||||
|
// Group by genres when "All" is selected
|
||||||
|
const genreCatalogs: GenreCatalog[] = [];
|
||||||
|
|
||||||
|
// Get all genres from content
|
||||||
|
const genresSet = new Set<string>();
|
||||||
|
content.forEach(item => {
|
||||||
|
if (item.genres && item.genres.length > 0) {
|
||||||
|
item.genres.forEach(g => genresSet.add(g));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create catalogs for each genre
|
||||||
|
genresSet.forEach(g => {
|
||||||
|
const genreItems = content.filter(item =>
|
||||||
|
item.genres && item.genres.includes(g)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (genreItems.length > 0) {
|
||||||
|
genreCatalogs.push({
|
||||||
|
genre: g,
|
||||||
|
items: genreItems
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by number of items
|
||||||
|
genreCatalogs.sort((a, b) => b.items.length - a.items.length);
|
||||||
|
|
||||||
|
setCatalogs(genreCatalogs);
|
||||||
|
} else {
|
||||||
|
// When a specific genre is selected, show as a single catalog
|
||||||
|
setCatalogs([{ genre, items: content }]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load content:', error);
|
||||||
|
setCatalogs([]);
|
||||||
|
setAllContent([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryPress = (category: Category) => {
|
||||||
|
if (category.id !== selectedCategory.id) {
|
||||||
|
setSelectedCategory(category);
|
||||||
|
setSelectedGenre('All'); // Reset to All when changing category
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenrePress = (genre: string) => {
|
||||||
|
if (genre !== selectedGenre) {
|
||||||
|
setSelectedGenre(genre);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchPress = () => {
|
||||||
|
// @ts-ignore - We'll fix navigation types later
|
||||||
|
navigation.navigate('Search');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCategory = ({ item }: { item: Category }) => {
|
||||||
|
const isSelected = selectedCategory.id === item.id;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.categoryButton,
|
||||||
|
isSelected && {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
transform: [{ scale: 1.05 }],
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onPress={() => handleCategoryPress(item)}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={item.icon}
|
||||||
|
size={24}
|
||||||
|
color={isSelected ? colors.white : colors.mediumGray}
|
||||||
|
style={styles.categoryIcon}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.categoryText,
|
||||||
|
isSelected && { color: colors.white, fontWeight: '600' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGenre = useCallback((genre: string) => {
|
||||||
|
const isSelected = selectedGenre === genre;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={genre}
|
||||||
|
style={[
|
||||||
|
styles.genreButton,
|
||||||
|
isSelected && {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderColor: colors.primary
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onPress={() => handleGenrePress(genre)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.genreText,
|
||||||
|
isSelected && { color: colors.white, fontWeight: '600' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{genre}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}, [selectedGenre]);
|
||||||
|
|
||||||
|
const renderContentItem = useCallback(({ item }: { item: StreamingContent }) => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.contentItem}
|
||||||
|
onPress={() => {
|
||||||
|
navigation.navigate('Metadata', { id: item.id, type: item.type });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.posterContainer}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
||||||
|
style={styles.poster}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['transparent', 'rgba(0,0,0,0.8)']}
|
||||||
|
style={styles.posterGradient}
|
||||||
|
>
|
||||||
|
<Text style={styles.contentTitle} numberOfLines={2}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
{item.year && (
|
||||||
|
<Text style={styles.contentYear}>{item.year}</Text>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
const renderCatalog = useCallback(({ item }: { item: GenreCatalog }) => {
|
||||||
|
// Only display the first 4 items in the row
|
||||||
|
const displayItems = item.items.slice(0, 4);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.catalogContainer}>
|
||||||
|
<View style={styles.catalogHeader}>
|
||||||
|
<View style={styles.titleContainer}>
|
||||||
|
<Text style={styles.catalogTitle}>{item.genre}</Text>
|
||||||
|
<View style={styles.titleUnderline} />
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
// Navigate to catalog view with genre filter
|
||||||
|
navigation.navigate('Catalog', {
|
||||||
|
id: 'discover',
|
||||||
|
type: selectedCategory.type,
|
||||||
|
name: `${item.genre} ${selectedCategory.name}`,
|
||||||
|
genreFilter: item.genre
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={styles.seeAllButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.seeAllText}>See More</Text>
|
||||||
|
<MaterialIcons name="arrow-forward" color={colors.primary} size={16} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={displayItems}
|
||||||
|
renderItem={renderContentItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 11 }}
|
||||||
|
snapToInterval={itemWidth + 10}
|
||||||
|
decelerationRate="fast"
|
||||||
|
snapToAlignment="start"
|
||||||
|
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [navigation, selectedCategory]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar
|
||||||
|
barStyle="light-content"
|
||||||
|
backgroundColor={colors.darkBackground}
|
||||||
|
translucent
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerContent}>
|
||||||
|
<Text style={styles.headerTitle}>
|
||||||
|
Discover
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSearchPress}
|
||||||
|
style={styles.searchButton}
|
||||||
|
>
|
||||||
|
<View style={styles.searchIconContainer}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="search"
|
||||||
|
size={24}
|
||||||
|
color={colors.white}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.categoryContainer}>
|
||||||
|
<View style={styles.categoriesContent}>
|
||||||
|
{CATEGORIES.map((category) => (
|
||||||
|
<View key={category.id}>
|
||||||
|
{renderCategory({ item: category })}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.genreContainer}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.genresScrollView}
|
||||||
|
>
|
||||||
|
{COMMON_GENRES.map(genre => renderGenre(genre))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
) : catalogs.length > 0 ? (
|
||||||
|
<FlatList
|
||||||
|
data={catalogs}
|
||||||
|
renderItem={renderCatalog}
|
||||||
|
keyExtractor={(item) => item.genre}
|
||||||
|
contentContainerStyle={styles.catalogsContainer}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
initialNumToRender={3}
|
||||||
|
maxToRenderPerBatch={3}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverScreen;
|
||||||
1167
src/screens/HomeScreen.tsx
Normal file
416
src/screens/LibraryScreen.tsx
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
useColorScheme,
|
||||||
|
useWindowDimensions,
|
||||||
|
SafeAreaView,
|
||||||
|
StatusBar,
|
||||||
|
Animated as RNAnimated,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { colors } from '../styles';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||||
|
import { catalogService } from '../services/catalogService';
|
||||||
|
import type { StreamingContent } from '../services/catalogService';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface LibraryItem extends StreamingContent {
|
||||||
|
progress?: number;
|
||||||
|
lastWatched?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkeletonLoader = () => {
|
||||||
|
const pulseAnim = React.useRef(new RNAnimated.Value(0)).current;
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const itemWidth = (width - 48) / 2;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const pulse = RNAnimated.loop(
|
||||||
|
RNAnimated.sequence([
|
||||||
|
RNAnimated.timing(pulseAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
RNAnimated.timing(pulseAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
pulse.start();
|
||||||
|
return () => pulse.stop();
|
||||||
|
}, [pulseAnim]);
|
||||||
|
|
||||||
|
const opacity = pulseAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0.3, 0.7],
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderSkeletonItem = () => (
|
||||||
|
<View style={[styles.itemContainer, { width: itemWidth }]}>
|
||||||
|
<RNAnimated.View
|
||||||
|
style={[
|
||||||
|
styles.posterContainer,
|
||||||
|
{ opacity, backgroundColor: colors.darkBackground }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<RNAnimated.View
|
||||||
|
style={[
|
||||||
|
styles.skeletonTitle,
|
||||||
|
{ opacity, backgroundColor: colors.darkBackground }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.skeletonContainer}>
|
||||||
|
{[...Array(6)].map((_, index) => (
|
||||||
|
<View key={index} style={{ width: itemWidth }}>
|
||||||
|
{renderSkeletonItem()}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LibraryScreen = () => {
|
||||||
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
const isDarkMode = useColorScheme() === 'dark';
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]);
|
||||||
|
const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLibrary = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const items = await catalogService.getLibraryItems();
|
||||||
|
setLibraryItems(items);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load library:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLibrary();
|
||||||
|
|
||||||
|
// Subscribe to library updates
|
||||||
|
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
|
||||||
|
setLibraryItems(items);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredItems = libraryItems.filter(item => {
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
if (filter === 'movies') return item.type === 'movie';
|
||||||
|
if (filter === 'series') return item.type === 'series';
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemWidth = (width - 48) / 2; // 2 items per row with padding
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: LibraryItem }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.itemContainer, { width: itemWidth }]}
|
||||||
|
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
|
||||||
|
>
|
||||||
|
<View style={styles.posterContainer}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
||||||
|
style={styles.poster}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
{item.progress !== undefined && item.progress < 1 && (
|
||||||
|
<View style={styles.progressBarContainer}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressBar,
|
||||||
|
{ width: `${item.progress * 100}%` }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{item.type === 'series' && (
|
||||||
|
<View style={styles.badgeContainer}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="live-tv"
|
||||||
|
size={12}
|
||||||
|
color={colors.white}
|
||||||
|
style={{ marginRight: 4 }}
|
||||||
|
/>
|
||||||
|
<Text style={styles.badgeText}>Series</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[styles.itemTitle, { color: isDarkMode ? colors.white : colors.black }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
{item.lastWatched && (
|
||||||
|
<Text style={[styles.lastWatched, { color: isDarkMode ? colors.lightGray : colors.mediumGray }]}>
|
||||||
|
{item.lastWatched}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFilter = (filterType: 'all' | 'movies' | 'series', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => {
|
||||||
|
const isActive = filter === filterType;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.filterButton,
|
||||||
|
isActive && styles.filterButtonActive,
|
||||||
|
{
|
||||||
|
borderColor: isDarkMode ? 'rgba(255,255,255,0.3)' : colors.border,
|
||||||
|
backgroundColor: isDarkMode && !isActive ? 'rgba(255,255,255,0.15)' : 'transparent'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onPress={() => setFilter(filterType)}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={iconName}
|
||||||
|
size={20}
|
||||||
|
color={isActive ? colors.primary : (isDarkMode ? colors.white : colors.mediumGray)}
|
||||||
|
style={styles.filterIcon}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: isActive ? '600' : '500',
|
||||||
|
color: isActive ? colors.primary : colors.white
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.container, { backgroundColor: colors.black }]}>
|
||||||
|
<StatusBar
|
||||||
|
barStyle="light-content"
|
||||||
|
backgroundColor={colors.black}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Library</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.filtersContainer}>
|
||||||
|
{renderFilter('all', 'All', 'apps')}
|
||||||
|
{renderFilter('movies', 'Movies', 'movie')}
|
||||||
|
{renderFilter('series', 'TV Shows', 'live-tv')}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<SkeletonLoader />
|
||||||
|
) : filteredItems.length === 0 ? (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="video-library"
|
||||||
|
size={64}
|
||||||
|
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
||||||
|
/>
|
||||||
|
<Text style={[
|
||||||
|
styles.emptyText,
|
||||||
|
{ color: isDarkMode ? colors.white : colors.black }
|
||||||
|
]}>
|
||||||
|
Your library is empty
|
||||||
|
</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.emptySubtext,
|
||||||
|
{ color: isDarkMode ? colors.lightGray : colors.mediumGray }
|
||||||
|
]}>
|
||||||
|
Add items to your library by marking them as favorites
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={filteredItems}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
numColumns={2}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
backgroundColor: colors.black,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: colors.white,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
filtersContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: colors.black,
|
||||||
|
},
|
||||||
|
filterButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.darkGray,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
gap: 6,
|
||||||
|
minWidth: 100,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
filterButtonActive: {
|
||||||
|
backgroundColor: colors.primary + '20',
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
filterIcon: {
|
||||||
|
marginRight: 2,
|
||||||
|
},
|
||||||
|
filterText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
filterTextActive: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 32,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
itemContainer: {
|
||||||
|
marginHorizontal: 8,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
posterContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
aspectRatio: 2/3,
|
||||||
|
marginBottom: 8,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
itemTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
lastWatched: {
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
progressBarContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
badgeContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.75)',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
skeletonContainer: {
|
||||||
|
padding: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
skeletonTitle: {
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginTop: 8,
|
||||||
|
width: '80%',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default LibraryScreen;
|
||||||
874
src/screens/MetadataScreen.tsx
Normal file
|
|
@ -0,0 +1,874 @@
|
||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
useColorScheme,
|
||||||
|
StatusBar,
|
||||||
|
ImageBackground,
|
||||||
|
Dimensions,
|
||||||
|
Platform,
|
||||||
|
TouchableWithoutFeedback,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { colors } from '../styles/colors';
|
||||||
|
import { useMetadata } from '../hooks/useMetadata';
|
||||||
|
import { CastSection } from '../components/metadata/CastSection';
|
||||||
|
import { SeriesContent } from '../components/metadata/SeriesContent';
|
||||||
|
import { MovieContent } from '../components/metadata/MovieContent';
|
||||||
|
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
|
||||||
|
import { RouteParams, Episode } from '../types/metadata';
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
withTiming,
|
||||||
|
useSharedValue,
|
||||||
|
Easing,
|
||||||
|
FadeInDown,
|
||||||
|
interpolate,
|
||||||
|
Extrapolate,
|
||||||
|
withSpring,
|
||||||
|
FadeIn,
|
||||||
|
runOnJS,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { RouteProp } from '@react-navigation/native';
|
||||||
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
import { TMDBService } from '../services/tmdbService';
|
||||||
|
|
||||||
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
|
// Animation configs
|
||||||
|
const springConfig = {
|
||||||
|
damping: 15,
|
||||||
|
mass: 1,
|
||||||
|
stiffness: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetadataScreen = () => {
|
||||||
|
const route = useRoute<RouteProp<Record<string, RouteParams>, string>>();
|
||||||
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
const { id, type } = route.params;
|
||||||
|
|
||||||
|
const {
|
||||||
|
metadata,
|
||||||
|
loading,
|
||||||
|
error: metadataError,
|
||||||
|
cast,
|
||||||
|
loadingCast,
|
||||||
|
episodes,
|
||||||
|
selectedSeason,
|
||||||
|
loadingSeasons,
|
||||||
|
loadMetadata,
|
||||||
|
handleSeasonChange,
|
||||||
|
toggleLibrary,
|
||||||
|
inLibrary,
|
||||||
|
groupedEpisodes,
|
||||||
|
recommendations,
|
||||||
|
loadingRecommendations,
|
||||||
|
setMetadata,
|
||||||
|
} = useMetadata({ id, type });
|
||||||
|
|
||||||
|
const [showFullDescription, setShowFullDescription] = useState(false);
|
||||||
|
const contentRef = useRef<ScrollView>(null);
|
||||||
|
const [lastScrollTop, setLastScrollTop] = useState(0);
|
||||||
|
const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false);
|
||||||
|
const fullDescriptionAnimation = useSharedValue(0);
|
||||||
|
const [textTruncated, setTextTruncated] = useState(false);
|
||||||
|
|
||||||
|
// Animation values
|
||||||
|
const screenScale = useSharedValue(0.8);
|
||||||
|
const screenOpacity = useSharedValue(0);
|
||||||
|
const heroHeight = useSharedValue(height * 0.75);
|
||||||
|
const contentTranslateY = useSharedValue(50);
|
||||||
|
|
||||||
|
// Handler functions
|
||||||
|
const handleShowStreams = useCallback(() => {
|
||||||
|
if (type === 'series' && episodes.length > 0) {
|
||||||
|
const firstEpisode = episodes[0];
|
||||||
|
const episodeId = firstEpisode.stremioId || `${id}:${firstEpisode.season_number}:${firstEpisode.episode_number}`;
|
||||||
|
navigation.navigate('Streams', {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
episodeId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
navigation.navigate('Streams', {
|
||||||
|
id,
|
||||||
|
type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [navigation, id, type, episodes]);
|
||||||
|
|
||||||
|
const handleSelectCastMember = (castMember: any) => {
|
||||||
|
// TODO: Implement cast member selection
|
||||||
|
console.log('Cast member selected:', castMember);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEpisodeSelect = (episode: Episode) => {
|
||||||
|
const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`;
|
||||||
|
navigation.navigate('Streams', {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
episodeId
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenFullDescription = useCallback(() => {
|
||||||
|
setIsFullDescriptionOpen(true);
|
||||||
|
fullDescriptionAnimation.value = withTiming(1, {
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.bezier(0.33, 0.01, 0, 1),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseFullDescription = useCallback(() => {
|
||||||
|
fullDescriptionAnimation.value = withTiming(0, {
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.bezier(0.33, 0.01, 0, 1),
|
||||||
|
}, () => {
|
||||||
|
runOnJS(setIsFullDescriptionOpen)(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fullDescriptionStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
opacity: fullDescriptionAnimation.value,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: interpolate(
|
||||||
|
fullDescriptionAnimation.value,
|
||||||
|
[0, 1],
|
||||||
|
[height, 0],
|
||||||
|
Extrapolate.CLAMP
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animated styles
|
||||||
|
const containerAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
flex: 1,
|
||||||
|
transform: [{ scale: screenScale.value }],
|
||||||
|
opacity: screenOpacity.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
const heroAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
width: '100%',
|
||||||
|
height: heroHeight.value,
|
||||||
|
backgroundColor: colors.black
|
||||||
|
}));
|
||||||
|
|
||||||
|
const contentAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: contentTranslateY.value }],
|
||||||
|
opacity: interpolate(
|
||||||
|
contentTranslateY.value,
|
||||||
|
[50, 0],
|
||||||
|
[0, 1],
|
||||||
|
Extrapolate.CLAMP
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Debug logs for director/creator data
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (metadata && metadata.id) {
|
||||||
|
const fetchCrewData = async () => {
|
||||||
|
try {
|
||||||
|
const tmdb = TMDBService.getInstance();
|
||||||
|
const tmdbId = await tmdb.extractTMDBIdFromStremioId(id);
|
||||||
|
|
||||||
|
if (tmdbId) {
|
||||||
|
const credits = await tmdb.getCredits(tmdbId, type);
|
||||||
|
console.log("Credits data structure:", JSON.stringify(credits).substring(0, 300));
|
||||||
|
|
||||||
|
// Extract directors for movies
|
||||||
|
if (type === 'movie' && credits.crew) {
|
||||||
|
const directors = credits.crew
|
||||||
|
.filter((person: { job: string }) => person.job === 'Director')
|
||||||
|
.map((director: { name: string }) => director.name);
|
||||||
|
|
||||||
|
if (directors.length > 0 && metadata) {
|
||||||
|
// Update metadata with directors
|
||||||
|
setMetadata({
|
||||||
|
...metadata,
|
||||||
|
directors
|
||||||
|
});
|
||||||
|
console.log("Updated directors:", directors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract creators for TV shows
|
||||||
|
if (type === 'series' && credits.crew) {
|
||||||
|
const creators = credits.crew
|
||||||
|
.filter((person: { job?: string; department?: string }) =>
|
||||||
|
person.job === 'Creator' ||
|
||||||
|
person.job === 'Series Creator' ||
|
||||||
|
person.department === 'Production' ||
|
||||||
|
person.job === 'Executive Producer'
|
||||||
|
)
|
||||||
|
.map((creator: { name: string }) => creator.name);
|
||||||
|
|
||||||
|
if (creators.length > 0 && metadata) {
|
||||||
|
// Update metadata with creators
|
||||||
|
setMetadata({
|
||||||
|
...metadata,
|
||||||
|
creators: creators.slice(0, 3) // Limit to first 3 creators
|
||||||
|
});
|
||||||
|
console.log("Updated creators:", creators.slice(0, 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching crew data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCrewData();
|
||||||
|
}
|
||||||
|
}, [metadata?.id, id, type, setMetadata]);
|
||||||
|
|
||||||
|
// Start entrance animation
|
||||||
|
React.useEffect(() => {
|
||||||
|
screenScale.value = withSpring(1, springConfig);
|
||||||
|
screenOpacity.value = withSpring(1, springConfig);
|
||||||
|
heroHeight.value = withSpring(height * 0.75, springConfig);
|
||||||
|
contentTranslateY.value = withSpring(0, springConfig);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
// Use goBack() which will return to the previous screen in the navigation stack
|
||||||
|
// This will work for both cases:
|
||||||
|
// 1. Coming from Calendar/ThisWeek - goes back to them
|
||||||
|
// 2. Coming from StreamsScreen - goes back to Calendar/ThisWeek
|
||||||
|
navigation.goBack();
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView
|
||||||
|
style={[styles.container, { backgroundColor: colors.darkBackground }]}
|
||||||
|
edges={['bottom']}
|
||||||
|
>
|
||||||
|
<StatusBar
|
||||||
|
translucent={true}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
barStyle="light-content"
|
||||||
|
/>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={[styles.loadingText, { color: colors.lightGray }]}>
|
||||||
|
Loading content...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadataError || !metadata) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView
|
||||||
|
style={[styles.container, { backgroundColor: colors.darkBackground }]}
|
||||||
|
edges={['bottom']}
|
||||||
|
>
|
||||||
|
<StatusBar
|
||||||
|
translucent={true}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
barStyle="light-content"
|
||||||
|
/>
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="error-outline"
|
||||||
|
size={64}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.errorText, { color: colors.text }]}>
|
||||||
|
{metadataError || 'Content not found'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.retryButton,
|
||||||
|
{ backgroundColor: colors.primary }
|
||||||
|
]}
|
||||||
|
onPress={loadMetadata}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="refresh"
|
||||||
|
size={20}
|
||||||
|
color={colors.white}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text style={styles.retryButtonText}>Try Again</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.backButton,
|
||||||
|
{ borderColor: colors.primary }
|
||||||
|
]}
|
||||||
|
onPress={handleBack}
|
||||||
|
>
|
||||||
|
<Text style={[styles.backButtonText, { color: colors.primary }]}>
|
||||||
|
Go Back
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView
|
||||||
|
style={[styles.container, { backgroundColor: colors.darkBackground }]}
|
||||||
|
edges={['bottom']}
|
||||||
|
>
|
||||||
|
<StatusBar
|
||||||
|
translucent={true}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
barStyle="light-content"
|
||||||
|
/>
|
||||||
|
<Animated.View style={containerAnimatedStyle}>
|
||||||
|
<ScrollView
|
||||||
|
ref={contentRef}
|
||||||
|
style={styles.scrollView}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onScroll={(e) => {
|
||||||
|
setLastScrollTop(e.nativeEvent.contentOffset.y);
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
>
|
||||||
|
{/* Hero Section */}
|
||||||
|
<Animated.View style={heroAnimatedStyle}>
|
||||||
|
<ImageBackground
|
||||||
|
source={{ uri: metadata.banner || metadata.poster }}
|
||||||
|
style={styles.heroSection}
|
||||||
|
imageStyle={styles.heroImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[
|
||||||
|
`${colors.darkBackground}00`,
|
||||||
|
`${colors.darkBackground}15`,
|
||||||
|
`${colors.darkBackground}40`,
|
||||||
|
`${colors.darkBackground}B3`,
|
||||||
|
`${colors.darkBackground}E6`,
|
||||||
|
colors.darkBackground
|
||||||
|
]}
|
||||||
|
locations={[0, 0.3, 0.5, 0.7, 0.85, 1]}
|
||||||
|
style={styles.heroGradient}
|
||||||
|
>
|
||||||
|
<Animated.View entering={FadeInDown.delay(200).springify()} style={styles.heroContent}>
|
||||||
|
{/* Title */}
|
||||||
|
{metadata.logo ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: metadata.logo }}
|
||||||
|
style={styles.titleLogo}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.titleText}>{metadata.name}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Genre Tags */}
|
||||||
|
{metadata.genres && metadata.genres.length > 0 && (
|
||||||
|
<View style={styles.genreContainer}>
|
||||||
|
{metadata.genres.slice(0, 3).map((genre, index, array) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<Text style={styles.genreText}>{genre}</Text>
|
||||||
|
{index < array.length - 1 && (
|
||||||
|
<Text style={styles.genreDot}>•</Text>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<View style={styles.actionButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.playButton]}
|
||||||
|
onPress={handleShowStreams}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="play-arrow" size={24} color="#000" />
|
||||||
|
<Text style={styles.playButtonText}>Play</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.infoButton]}
|
||||||
|
onPress={toggleLibrary}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={inLibrary ? 'bookmark' : 'bookmark-border'}
|
||||||
|
size={24}
|
||||||
|
color="#fff"
|
||||||
|
/>
|
||||||
|
<Text style={styles.infoButtonText}>
|
||||||
|
{inLibrary ? 'Saved' : 'Save'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{type === 'series' && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.iconButton]}
|
||||||
|
onPress={async () => {
|
||||||
|
const tmdb = TMDBService.getInstance();
|
||||||
|
const tmdbId = await tmdb.extractTMDBIdFromStremioId(id);
|
||||||
|
if (tmdbId) {
|
||||||
|
navigation.navigate('ShowRatings', { showId: tmdbId });
|
||||||
|
} else {
|
||||||
|
// TODO: Show error toast
|
||||||
|
console.error('Could not find TMDB ID for show');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="star-rate" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</LinearGradient>
|
||||||
|
</ImageBackground>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<Animated.View style={contentAnimatedStyle}>
|
||||||
|
{/* Meta Info */}
|
||||||
|
<View style={styles.metaInfo}>
|
||||||
|
{metadata.year && (
|
||||||
|
<View style={styles.metaChip}>
|
||||||
|
<Text style={styles.metaChipText}>{metadata.year}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{metadata.runtime && (
|
||||||
|
<View style={styles.metaChip}>
|
||||||
|
<Text style={styles.metaChipText}>{metadata.runtime}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{metadata.imdbRating && (
|
||||||
|
<View style={styles.ratingContainer}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png' }}
|
||||||
|
style={styles.imdbLogo}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
<Text style={styles.ratingText}>{metadata.imdbRating}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Creator/Director Info */}
|
||||||
|
{((metadata.directors && metadata.directors.length > 0) || (metadata.creators && metadata.creators.length > 0)) && (
|
||||||
|
<View style={styles.creatorContainer}>
|
||||||
|
{metadata.directors && metadata.directors.length > 0 && (
|
||||||
|
<View style={styles.creatorSection}>
|
||||||
|
<Text style={styles.creatorLabel}>Director{metadata.directors.length > 1 ? 's' : ''}:</Text>
|
||||||
|
<Text style={styles.creatorText}>{metadata.directors.join(', ')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{metadata.creators && metadata.creators.length > 0 && (
|
||||||
|
<View style={styles.creatorSection}>
|
||||||
|
<Text style={styles.creatorLabel}>Creator{metadata.creators.length > 1 ? 's' : ''}:</Text>
|
||||||
|
<Text style={styles.creatorText}>{metadata.creators.join(', ')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{metadata.description && (
|
||||||
|
<View style={styles.descriptionContainer}>
|
||||||
|
<Text
|
||||||
|
style={styles.description}
|
||||||
|
numberOfLines={showFullDescription ? undefined : 3}
|
||||||
|
onTextLayout={({ nativeEvent: { lines } }) => {
|
||||||
|
if (!showFullDescription) {
|
||||||
|
setTextTruncated(lines.length > 3);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`${metadata.description}`}
|
||||||
|
</Text>
|
||||||
|
{textTruncated && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowFullDescription(!showFullDescription)}
|
||||||
|
style={styles.showMoreButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.showMoreText}>
|
||||||
|
{showFullDescription ? 'See less' : 'See more'}
|
||||||
|
</Text>
|
||||||
|
<MaterialIcons
|
||||||
|
name={showFullDescription ? 'keyboard-arrow-up' : 'keyboard-arrow-down'}
|
||||||
|
size={20}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cast Section */}
|
||||||
|
<CastSection
|
||||||
|
cast={cast}
|
||||||
|
loadingCast={loadingCast}
|
||||||
|
onSelectCastMember={handleSelectCastMember}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* More Like This Section - Only for movies */}
|
||||||
|
{type === 'movie' && (
|
||||||
|
<MoreLikeThisSection
|
||||||
|
recommendations={recommendations}
|
||||||
|
loadingRecommendations={loadingRecommendations}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Type-specific content */}
|
||||||
|
{type === 'series' ? (
|
||||||
|
<SeriesContent
|
||||||
|
episodes={episodes}
|
||||||
|
selectedSeason={selectedSeason}
|
||||||
|
loadingSeasons={loadingSeasons}
|
||||||
|
onSeasonChange={handleSeasonChange}
|
||||||
|
onSelectEpisode={handleEpisodeSelect}
|
||||||
|
groupedEpisodes={groupedEpisodes}
|
||||||
|
metadata={metadata}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MovieContent metadata={metadata} />
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Full Description Modal */}
|
||||||
|
{isFullDescriptionOpen && (
|
||||||
|
<Animated.View style={fullDescriptionStyle}>
|
||||||
|
<SafeAreaView style={styles.fullDescriptionContainer}>
|
||||||
|
<View style={styles.fullDescriptionHeader}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleCloseFullDescription}
|
||||||
|
style={styles.fullDescriptionCloseButton}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="close" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.fullDescriptionTitle}>About</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.fullDescriptionContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<Text style={styles.fullDescriptionText}>
|
||||||
|
{metadata?.description}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: Platform.OS === 'ios' ? 0 : StatusBar.currentHeight || 0,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 32,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 24,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
color: colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
backButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
heroSection: {
|
||||||
|
width: '100%',
|
||||||
|
height: height * 0.75,
|
||||||
|
backgroundColor: colors.black,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
heroImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '105%',
|
||||||
|
top: '-2.5%',
|
||||||
|
transform: [{ scale: 1 }],
|
||||||
|
},
|
||||||
|
heroGradient: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
paddingBottom: 24,
|
||||||
|
},
|
||||||
|
heroContent: {
|
||||||
|
padding: 24,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
genreContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
genreText: {
|
||||||
|
color: colors.highEmphasis,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
genreDot: {
|
||||||
|
color: colors.highEmphasis,
|
||||||
|
fontSize: 14,
|
||||||
|
marginHorizontal: 8,
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
titleLogo: {
|
||||||
|
width: width * 0.65,
|
||||||
|
height: 90,
|
||||||
|
marginBottom: 0,
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
titleText: {
|
||||||
|
color: colors.highEmphasis,
|
||||||
|
fontSize: 34,
|
||||||
|
fontWeight: '900',
|
||||||
|
marginBottom: 16,
|
||||||
|
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||||
|
textShadowOffset: { width: 0, height: 2 },
|
||||||
|
textShadowRadius: 4,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
metaInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 16,
|
||||||
|
},
|
||||||
|
metaChip: {
|
||||||
|
backgroundColor: colors.elevation3,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
metaChipText: {
|
||||||
|
color: colors.highEmphasis,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
ratingContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.elevation3,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
imdbLogo: {
|
||||||
|
width: 40,
|
||||||
|
height: 20,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
ratingText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
descriptionContainer: {
|
||||||
|
marginBottom: 28,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
color: colors.mediumEmphasis,
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
showMoreButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 10,
|
||||||
|
backgroundColor: colors.elevation1,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
showMoreText: {
|
||||||
|
color: colors.highEmphasis,
|
||||||
|
fontSize: 14,
|
||||||
|
marginRight: 4,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
actionButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: -16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 100,
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 4,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
playButton: {
|
||||||
|
backgroundColor: colors.white,
|
||||||
|
},
|
||||||
|
infoButton: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff',
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 4,
|
||||||
|
},
|
||||||
|
playButtonText: {
|
||||||
|
color: '#000',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
infoButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
marginLeft: 8,
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
fullDescriptionContainer: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
fullDescriptionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.elevation1,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
fullDescriptionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
fullDescriptionCloseButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 16,
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
fullDescriptionContent: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
fullDescriptionText: {
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
creatorContainer: {
|
||||||
|
marginBottom: 2,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
creatorSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
creatorLabel: {
|
||||||
|
color: colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
creatorText: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 14,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MetadataScreen;
|
||||||
472
src/screens/NotificationSettingsScreen.tsx
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
SafeAreaView,
|
||||||
|
StatusBar,
|
||||||
|
} from 'react-native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { colors } from '../styles/colors';
|
||||||
|
import { notificationService, NotificationSettings } from '../services/notificationService';
|
||||||
|
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
|
||||||
|
const NotificationSettingsScreen = () => {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const [settings, setSettings] = useState<NotificationSettings>({
|
||||||
|
enabled: true,
|
||||||
|
newEpisodeNotifications: true,
|
||||||
|
reminderNotifications: true,
|
||||||
|
upcomingShowsNotifications: true,
|
||||||
|
timeBeforeAiring: 24,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [countdown, setCountdown] = useState<number | null>(null);
|
||||||
|
const [testNotificationId, setTestNotificationId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load settings on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
const savedSettings = await notificationService.getSettings();
|
||||||
|
setSettings(savedSettings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading notification settings:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add countdown effect
|
||||||
|
useEffect(() => {
|
||||||
|
let intervalId: NodeJS.Timeout;
|
||||||
|
|
||||||
|
if (countdown !== null && countdown > 0) {
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
setCountdown(prev => prev !== null ? prev - 1 : null);
|
||||||
|
}, 1000);
|
||||||
|
} else if (countdown === 0) {
|
||||||
|
setCountdown(null);
|
||||||
|
setTestNotificationId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
// Update a setting
|
||||||
|
const updateSetting = async (key: keyof NotificationSettings, value: boolean | number) => {
|
||||||
|
try {
|
||||||
|
const updatedSettings = {
|
||||||
|
...settings,
|
||||||
|
[key]: value,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Special case: if enabling notifications, make sure permissions are granted
|
||||||
|
if (key === 'enabled' && value === true) {
|
||||||
|
// Permissions are handled in the service
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update settings in the service
|
||||||
|
await notificationService.updateSettings({ [key]: value });
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setSettings(updatedSettings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating notification settings:', error);
|
||||||
|
Alert.alert('Error', 'Failed to update notification settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set time before airing
|
||||||
|
const setTimeBeforeAiring = (hours: number) => {
|
||||||
|
updateSetting('timeBeforeAiring', hours);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAllNotifications = async () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Reset Notifications',
|
||||||
|
'This will cancel all scheduled notifications. Are you sure?',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Reset',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await notificationService.cancelAllNotifications();
|
||||||
|
Alert.alert('Success', 'All notifications have been reset');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting notifications:', error);
|
||||||
|
Alert.alert('Error', 'Failed to reset notifications');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestNotification = async () => {
|
||||||
|
try {
|
||||||
|
// Cancel previous test notification if exists
|
||||||
|
if (testNotificationId) {
|
||||||
|
await notificationService.cancelNotification(testNotificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const testNotification = {
|
||||||
|
id: 'test-notification-' + Date.now(),
|
||||||
|
seriesId: 'test-series',
|
||||||
|
seriesName: 'Test Show',
|
||||||
|
episodeTitle: 'Test Episode',
|
||||||
|
season: 1,
|
||||||
|
episode: 1,
|
||||||
|
releaseDate: new Date(Date.now() + 60000).toISOString(), // 1 minute from now
|
||||||
|
notified: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const notificationId = await notificationService.scheduleEpisodeNotification(testNotification);
|
||||||
|
if (notificationId) {
|
||||||
|
setTestNotificationId(notificationId);
|
||||||
|
setCountdown(60); // Start 60 second countdown
|
||||||
|
Alert.alert('Success', 'Test notification scheduled for 1 minute from now');
|
||||||
|
} else {
|
||||||
|
Alert.alert('Error', 'Failed to schedule test notification. Make sure notifications are enabled.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scheduling test notification:', error);
|
||||||
|
Alert.alert('Error', 'Failed to schedule test notification');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Notification Settings</Text>
|
||||||
|
<View style={{ width: 40 }} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>Loading settings...</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Notification Settings</Text>
|
||||||
|
<View style={{ width: 40 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.content}>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(300)}
|
||||||
|
exiting={FadeOut.duration(200)}
|
||||||
|
>
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>General</Text>
|
||||||
|
|
||||||
|
<View style={styles.settingItem}>
|
||||||
|
<View style={styles.settingInfo}>
|
||||||
|
<MaterialIcons name="notifications" size={24} color={colors.text} />
|
||||||
|
<Text style={styles.settingText}>Enable Notifications</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.enabled}
|
||||||
|
onValueChange={(value) => updateSetting('enabled', value)}
|
||||||
|
trackColor={{ false: colors.border, true: colors.primary + '80' }}
|
||||||
|
thumbColor={settings.enabled ? colors.primary : colors.lightGray}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{settings.enabled && (
|
||||||
|
<>
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Notification Types</Text>
|
||||||
|
|
||||||
|
<View style={styles.settingItem}>
|
||||||
|
<View style={styles.settingInfo}>
|
||||||
|
<MaterialIcons name="new-releases" size={24} color={colors.text} />
|
||||||
|
<Text style={styles.settingText}>New Episodes</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.newEpisodeNotifications}
|
||||||
|
onValueChange={(value) => updateSetting('newEpisodeNotifications', value)}
|
||||||
|
trackColor={{ false: colors.border, true: colors.primary + '80' }}
|
||||||
|
thumbColor={settings.newEpisodeNotifications ? colors.primary : colors.lightGray}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.settingItem}>
|
||||||
|
<View style={styles.settingInfo}>
|
||||||
|
<MaterialIcons name="event" size={24} color={colors.text} />
|
||||||
|
<Text style={styles.settingText}>Upcoming Shows</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.upcomingShowsNotifications}
|
||||||
|
onValueChange={(value) => updateSetting('upcomingShowsNotifications', value)}
|
||||||
|
trackColor={{ false: colors.border, true: colors.primary + '80' }}
|
||||||
|
thumbColor={settings.upcomingShowsNotifications ? colors.primary : colors.lightGray}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.settingItem}>
|
||||||
|
<View style={styles.settingInfo}>
|
||||||
|
<MaterialIcons name="alarm" size={24} color={colors.text} />
|
||||||
|
<Text style={styles.settingText}>Reminders</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.reminderNotifications}
|
||||||
|
onValueChange={(value) => updateSetting('reminderNotifications', value)}
|
||||||
|
trackColor={{ false: colors.border, true: colors.primary + '80' }}
|
||||||
|
thumbColor={settings.reminderNotifications ? colors.primary : colors.lightGray}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Notification Timing</Text>
|
||||||
|
|
||||||
|
<Text style={styles.settingDescription}>
|
||||||
|
When should you be notified before an episode airs?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.timingOptions}>
|
||||||
|
{[1, 6, 12, 24].map((hours) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={hours}
|
||||||
|
style={[
|
||||||
|
styles.timingOption,
|
||||||
|
settings.timeBeforeAiring === hours && styles.selectedTimingOption
|
||||||
|
]}
|
||||||
|
onPress={() => setTimeBeforeAiring(hours)}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.timingText,
|
||||||
|
settings.timeBeforeAiring === hours && styles.selectedTimingText
|
||||||
|
]}>
|
||||||
|
{hours === 1 ? '1 hour' : `${hours} hours`}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Advanced</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.resetButton}
|
||||||
|
onPress={resetAllNotifications}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="refresh" size={24} color={colors.error} />
|
||||||
|
<Text style={styles.resetButtonText}>Reset All Notifications</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.resetButton,
|
||||||
|
{ marginTop: 12, backgroundColor: colors.primary + '20', borderColor: colors.primary + '50' }
|
||||||
|
]}
|
||||||
|
onPress={handleTestNotification}
|
||||||
|
disabled={countdown !== null}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="bug-report" size={24} color={colors.primary} />
|
||||||
|
<Text style={[styles.resetButtonText, { color: colors.primary }]}>
|
||||||
|
{countdown !== null
|
||||||
|
? `Notification in ${countdown}s...`
|
||||||
|
: 'Test Notification (1min)'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{countdown !== null && (
|
||||||
|
<View style={styles.countdownContainer}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="timer"
|
||||||
|
size={16}
|
||||||
|
color={colors.primary}
|
||||||
|
style={styles.countdownIcon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.countdownText}>
|
||||||
|
Notification will appear in {countdown} seconds
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.resetDescription}>
|
||||||
|
This will cancel all scheduled notifications. You'll need to re-enable them manually.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
padding: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
settingItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border + '50',
|
||||||
|
},
|
||||||
|
settingInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
settingText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
settingDescription: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.lightGray,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
timingOptions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
timingOption: {
|
||||||
|
backgroundColor: colors.elevation1,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
marginBottom: 8,
|
||||||
|
width: '48%',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
selectedTimingOption: {
|
||||||
|
backgroundColor: colors.primary + '30',
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
timingText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
selectedTimingText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
resetButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: colors.error + '20',
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.error + '50',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
resetButtonText: {
|
||||||
|
color: colors.error,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
resetDescription: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
countdownContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 8,
|
||||||
|
backgroundColor: colors.primary + '10',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
countdownIcon: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
countdownText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default NotificationSettingsScreen;
|
||||||
357
src/screens/PlayerScreen.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Text,
|
||||||
|
ActivityIndicator,
|
||||||
|
StatusBar,
|
||||||
|
Dimensions,
|
||||||
|
Platform,
|
||||||
|
SafeAreaView
|
||||||
|
} from 'react-native';
|
||||||
|
import Video from 'react-native-video';
|
||||||
|
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
interface PlayerParams {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title?: string;
|
||||||
|
poster?: string;
|
||||||
|
stream?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerScreen = () => {
|
||||||
|
const route = useRoute<RouteProp<Record<string, PlayerParams>, string>>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { id, type, title, poster, stream } = route.params;
|
||||||
|
|
||||||
|
const [isPlaying, setIsPlaying] = useState(true);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [controlsVisible, setControlsVisible] = useState(true);
|
||||||
|
|
||||||
|
// Use any for now to fix the type error
|
||||||
|
const videoRef = useRef<any>(null);
|
||||||
|
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Auto-hide controls after a delay
|
||||||
|
useEffect(() => {
|
||||||
|
if (controlsVisible) {
|
||||||
|
if (controlsTimeoutRef.current) {
|
||||||
|
clearTimeout(controlsTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
controlsTimeoutRef.current = setTimeout(() => {
|
||||||
|
setControlsVisible(false);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (controlsTimeoutRef.current) {
|
||||||
|
clearTimeout(controlsTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [controlsVisible]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => setIsPlaying(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleControls = () => {
|
||||||
|
setControlsVisible(!controlsVisible);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlayPause = () => {
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
};
|
||||||
|
|
||||||
|
const seekTo = (time: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.seek(time);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
if (h > 0) {
|
||||||
|
result += `${h}:${m < 10 ? '0' : ''}`;
|
||||||
|
}
|
||||||
|
result += `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoad = (data: any) => {
|
||||||
|
setDuration(data.duration);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgress = (data: any) => {
|
||||||
|
setCurrentTime(data.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (err: any) => {
|
||||||
|
setError(err.error?.errorString || 'Failed to load video');
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GoBackButton = () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color="#FFFFFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Controls = () => (
|
||||||
|
<View style={styles.controlsContainer}>
|
||||||
|
<View style={styles.controlsHeader}>
|
||||||
|
<GoBackButton />
|
||||||
|
<Text style={styles.videoTitle}>{title || 'Video Player'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.controlsCenter}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.playPauseButton}
|
||||||
|
onPress={togglePlayPause}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={isPlaying ? 'pause' : 'play-arrow'}
|
||||||
|
size={48}
|
||||||
|
color="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.controlsBottom}>
|
||||||
|
<Text style={styles.timeText}>{formatTime(currentTime)}</Text>
|
||||||
|
|
||||||
|
<View style={styles.progressBarContainer}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressBar,
|
||||||
|
{ width: `${(currentTime / duration) * 100}%` }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressKnob,
|
||||||
|
{ left: `${(currentTime / duration) * 100}%` }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.timeText}>{formatTime(duration)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stream) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" />
|
||||||
|
<GoBackButton />
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<MaterialIcons name="error-outline" size={64} color="#E50914" />
|
||||||
|
<Text style={styles.errorText}>No stream URL provided</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.errorButton}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
>
|
||||||
|
<Text style={styles.errorButtonText}>Go Back</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.videoContainer}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={toggleControls}
|
||||||
|
>
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
source={{ uri: stream }}
|
||||||
|
style={styles.video}
|
||||||
|
resizeMode="contain"
|
||||||
|
poster={poster}
|
||||||
|
paused={!isPlaying}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
onProgress={handleProgress}
|
||||||
|
onEnd={handleEnd}
|
||||||
|
onError={handleError}
|
||||||
|
repeat={false}
|
||||||
|
playInBackground={false}
|
||||||
|
playWhenInactive={false}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
fullscreen={false}
|
||||||
|
progressUpdateInterval={500}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<View style={styles.loaderContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#E50914" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<MaterialIcons name="error-outline" size={64} color="#E50914" />
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.errorButton}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
>
|
||||||
|
<Text style={styles.errorButtonText}>Go Back</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{controlsVisible && !error && <Controls />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
},
|
||||||
|
videoContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
},
|
||||||
|
controlsContainer: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
controlsHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
paddingTop: Platform.OS === 'ios' ? 50 : 40,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
videoTitle: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
controlsCenter: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
playPauseButton: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
controlsBottom: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 40 : 16,
|
||||||
|
},
|
||||||
|
timeText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 14,
|
||||||
|
width: 50,
|
||||||
|
},
|
||||||
|
progressBarContainer: {
|
||||||
|
flex: 1,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
marginHorizontal: 8,
|
||||||
|
borderRadius: 2,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#E50914',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
progressKnob: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: '#E50914',
|
||||||
|
position: 'absolute',
|
||||||
|
top: -4,
|
||||||
|
marginLeft: -6,
|
||||||
|
},
|
||||||
|
loaderContainer: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
errorButton: {
|
||||||
|
backgroundColor: '#E50914',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
errorButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PlayerScreen;
|
||||||
665
src/screens/SearchScreen.tsx
Normal file
|
|
@ -0,0 +1,665 @@
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
useColorScheme,
|
||||||
|
SafeAreaView,
|
||||||
|
StatusBar,
|
||||||
|
Keyboard,
|
||||||
|
Dimensions,
|
||||||
|
SectionList,
|
||||||
|
Animated as RNAnimated,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { colors } from '../styles';
|
||||||
|
import { catalogService, StreamingContent } from '../services/catalogService';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import Animated, { FadeIn, FadeOut, SlideInRight } from 'react-native-reanimated';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const POSTER_WIDTH = 90;
|
||||||
|
const POSTER_HEIGHT = 135;
|
||||||
|
const RECENT_SEARCHES_KEY = 'recent_searches';
|
||||||
|
const MAX_RECENT_SEARCHES = 10;
|
||||||
|
|
||||||
|
const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster';
|
||||||
|
|
||||||
|
const SkeletonLoader = () => {
|
||||||
|
const pulseAnim = React.useRef(new RNAnimated.Value(0)).current;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const pulse = RNAnimated.loop(
|
||||||
|
RNAnimated.sequence([
|
||||||
|
RNAnimated.timing(pulseAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
RNAnimated.timing(pulseAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
pulse.start();
|
||||||
|
return () => pulse.stop();
|
||||||
|
}, [pulseAnim]);
|
||||||
|
|
||||||
|
const opacity = pulseAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0.3, 0.7],
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderSkeletonItem = () => (
|
||||||
|
<View style={styles.resultItem}>
|
||||||
|
<RNAnimated.View style={[styles.skeletonPoster, { opacity }]} />
|
||||||
|
<View style={styles.itemDetails}>
|
||||||
|
<RNAnimated.View style={[styles.skeletonTitle, { opacity }]} />
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<RNAnimated.View style={[styles.skeletonMeta, { opacity }]} />
|
||||||
|
<RNAnimated.View style={[styles.skeletonMeta, { opacity }]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.skeletonContainer}>
|
||||||
|
{[...Array(5)].map((_, index) => (
|
||||||
|
<View key={index}>
|
||||||
|
{index === 0 && (
|
||||||
|
<RNAnimated.View style={[styles.skeletonSectionHeader, { opacity }]} />
|
||||||
|
)}
|
||||||
|
{renderSkeletonItem()}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchFilter = 'all' | 'movie' | 'series';
|
||||||
|
|
||||||
|
const SearchScreen = () => {
|
||||||
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
// Always use dark mode
|
||||||
|
const isDarkMode = true;
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<StreamingContent[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
const [activeFilter, setActiveFilter] = useState<SearchFilter>('all');
|
||||||
|
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||||
|
const [showRecent, setShowRecent] = useState(true);
|
||||||
|
|
||||||
|
// Set navigation options to hide the header
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerShown: false,
|
||||||
|
});
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
// Load recent searches on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadRecentSearches();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadRecentSearches = async () => {
|
||||||
|
try {
|
||||||
|
const savedSearches = await AsyncStorage.getItem(RECENT_SEARCHES_KEY);
|
||||||
|
if (savedSearches) {
|
||||||
|
setRecentSearches(JSON.parse(savedSearches));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load recent searches:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRecentSearch = async (searchQuery: string) => {
|
||||||
|
try {
|
||||||
|
const newRecentSearches = [
|
||||||
|
searchQuery,
|
||||||
|
...recentSearches.filter(s => s !== searchQuery)
|
||||||
|
].slice(0, MAX_RECENT_SEARCHES);
|
||||||
|
|
||||||
|
setRecentSearches(newRecentSearches);
|
||||||
|
await AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save recent search:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fuzzy search implementation
|
||||||
|
const fuzzyMatch = (str: string, pattern: string): boolean => {
|
||||||
|
pattern = pattern.toLowerCase();
|
||||||
|
str = str.toLowerCase();
|
||||||
|
|
||||||
|
let patternIdx = 0;
|
||||||
|
let strIdx = 0;
|
||||||
|
|
||||||
|
while (patternIdx < pattern.length && strIdx < str.length) {
|
||||||
|
if (pattern[patternIdx] === str[strIdx]) {
|
||||||
|
patternIdx++;
|
||||||
|
}
|
||||||
|
strIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return patternIdx === pattern.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced search function with fuzzy search
|
||||||
|
const debouncedSearch = useCallback(
|
||||||
|
debounce(async (searchQuery: string) => {
|
||||||
|
if (searchQuery.trim().length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
setSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchResults = await catalogService.searchContentCinemeta(searchQuery);
|
||||||
|
|
||||||
|
// Apply fuzzy search on the results
|
||||||
|
const fuzzyResults = searchResults.filter(item =>
|
||||||
|
fuzzyMatch(item.name, searchQuery) ||
|
||||||
|
(item.genres && item.genres.some(genre => fuzzyMatch(genre, searchQuery))) ||
|
||||||
|
(item.year && fuzzyMatch(item.year.toString(), searchQuery))
|
||||||
|
);
|
||||||
|
|
||||||
|
setResults(fuzzyResults);
|
||||||
|
await saveRecentSearch(searchQuery);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, 200), // Reduced from 300ms to 200ms for better responsiveness
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.trim().length >= 2) {
|
||||||
|
setSearching(true);
|
||||||
|
setSearched(true);
|
||||||
|
setShowRecent(false);
|
||||||
|
debouncedSearch(query);
|
||||||
|
} else {
|
||||||
|
setResults([]);
|
||||||
|
setSearched(false);
|
||||||
|
setShowRecent(true);
|
||||||
|
}
|
||||||
|
}, [query, debouncedSearch]);
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
setSearched(false);
|
||||||
|
setActiveFilter('all');
|
||||||
|
setShowRecent(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSearchFilters = () => {
|
||||||
|
const filters: { id: SearchFilter; label: string; icon: keyof typeof MaterialIcons.glyphMap }[] = [
|
||||||
|
{ id: 'all', label: 'All', icon: 'apps' },
|
||||||
|
{ id: 'movie', label: 'Movies', icon: 'movie' },
|
||||||
|
{ id: 'series', label: 'TV Shows', icon: 'tv' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.filtersContainer}>
|
||||||
|
{filters.map((filter) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={filter.id}
|
||||||
|
style={[
|
||||||
|
styles.filterButton,
|
||||||
|
activeFilter === filter.id && styles.filterButtonActive,
|
||||||
|
{ borderColor: isDarkMode ? 'rgba(255,255,255,0.1)' : colors.border }
|
||||||
|
]}
|
||||||
|
onPress={() => setActiveFilter(filter.id)}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={filter.icon}
|
||||||
|
size={20}
|
||||||
|
color={activeFilter === filter.id ? colors.primary : (isDarkMode ? colors.lightGray : colors.mediumGray)}
|
||||||
|
style={styles.filterIcon}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.filterText,
|
||||||
|
activeFilter === filter.id && styles.filterTextActive,
|
||||||
|
{ color: isDarkMode ? colors.white : colors.black }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRecentSearches = () => {
|
||||||
|
if (!showRecent || recentSearches.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.recentSearchesContainer}>
|
||||||
|
<Text style={[styles.recentSearchesTitle, { color: isDarkMode ? colors.white : colors.black }]}>
|
||||||
|
Recent Searches
|
||||||
|
</Text>
|
||||||
|
{recentSearches.map((search, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={styles.recentSearchItem}
|
||||||
|
onPress={() => setQuery(search)}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="history"
|
||||||
|
size={20}
|
||||||
|
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
||||||
|
style={styles.recentSearchIcon}
|
||||||
|
/>
|
||||||
|
<Text style={[
|
||||||
|
styles.recentSearchText,
|
||||||
|
{ color: isDarkMode ? colors.white : colors.black }
|
||||||
|
]}>
|
||||||
|
{search}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item, index, section }: { item: StreamingContent; index: number; section: { title: string; data: StreamingContent[] } }) => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.resultItem,
|
||||||
|
{ backgroundColor: isDarkMode ? colors.darkBackground : colors.white }
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
navigation.navigate('Metadata', { id: item.id, type: item.type });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.posterContainer}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.poster || PLACEHOLDER_POSTER }}
|
||||||
|
style={styles.poster}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.itemDetails}>
|
||||||
|
<Text
|
||||||
|
style={[styles.itemTitle, { color: isDarkMode ? colors.white : colors.black }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
{item.year && (
|
||||||
|
<Text style={[styles.yearText, { color: isDarkMode ? colors.lightGray : colors.mediumGray }]}>
|
||||||
|
{item.year}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{item.genres && item.genres.length > 0 && (
|
||||||
|
<Text style={[styles.genreText, { color: isDarkMode ? colors.lightGray : colors.mediumGray }]}>
|
||||||
|
{item.genres[0]}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSectionHeader = ({ section: { title, data } }: { section: { title: string; data: StreamingContent[] } }) => (
|
||||||
|
<View style={[
|
||||||
|
styles.sectionHeader,
|
||||||
|
{ backgroundColor: isDarkMode ? colors.darkBackground : colors.lightBackground }
|
||||||
|
]}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: isDarkMode ? colors.white : colors.black }]}>
|
||||||
|
{title} ({data.length})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Categorize results
|
||||||
|
const categorizedResults = useMemo(() => {
|
||||||
|
if (!results.length) return [];
|
||||||
|
|
||||||
|
const movieResults = results.filter(item => item.type === 'movie');
|
||||||
|
const seriesResults = results.filter(item => item.type === 'series');
|
||||||
|
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
if (activeFilter === 'all' || activeFilter === 'movie') {
|
||||||
|
if (movieResults.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
title: 'Movies',
|
||||||
|
data: movieResults,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeFilter === 'all' || activeFilter === 'series') {
|
||||||
|
if (seriesResults.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
title: 'TV Shows',
|
||||||
|
data: seriesResults,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}, [results, activeFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[
|
||||||
|
styles.container,
|
||||||
|
{ backgroundColor: colors.black }
|
||||||
|
]}>
|
||||||
|
<StatusBar
|
||||||
|
barStyle="light-content"
|
||||||
|
backgroundColor={colors.black}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Search</Text>
|
||||||
|
<View style={[
|
||||||
|
styles.searchBar,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.darkGray,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="search"
|
||||||
|
size={24}
|
||||||
|
color={colors.lightGray}
|
||||||
|
style={styles.searchIcon}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.searchInput,
|
||||||
|
{ color: colors.white }
|
||||||
|
]}
|
||||||
|
placeholder="Search movies, shows..."
|
||||||
|
placeholderTextColor={colors.lightGray}
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
returnKeyType="search"
|
||||||
|
keyboardAppearance="dark"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{query.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleClearSearch}
|
||||||
|
style={styles.clearButton}
|
||||||
|
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="close"
|
||||||
|
size={20}
|
||||||
|
color={colors.lightGray}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{renderSearchFilters()}
|
||||||
|
|
||||||
|
{searching ? (
|
||||||
|
<SkeletonLoader />
|
||||||
|
) : searched && categorizedResults.length === 0 ? (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="search-off"
|
||||||
|
size={64}
|
||||||
|
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
||||||
|
/>
|
||||||
|
<Text style={[
|
||||||
|
styles.emptyText,
|
||||||
|
{ color: isDarkMode ? colors.white : colors.black }
|
||||||
|
]}>
|
||||||
|
No results found
|
||||||
|
</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.emptySubtext,
|
||||||
|
{ color: isDarkMode ? colors.lightGray : colors.mediumGray }
|
||||||
|
]}>
|
||||||
|
Try different keywords or check your spelling
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{renderRecentSearches()}
|
||||||
|
<SectionList
|
||||||
|
sections={categorizedResults}
|
||||||
|
renderItem={renderItem}
|
||||||
|
renderSectionHeader={renderSectionHeader}
|
||||||
|
keyExtractor={item => `${item.type}-${item.id}`}
|
||||||
|
contentContainerStyle={styles.resultsList}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
stickySectionHeadersEnabled={true}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 40,
|
||||||
|
paddingBottom: 12,
|
||||||
|
backgroundColor: colors.black,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: colors.white,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
searchBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
searchIcon: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
filtersContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
filterButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.darkGray,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
filterButtonActive: {
|
||||||
|
backgroundColor: colors.primary + '20',
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
filterIcon: {
|
||||||
|
marginRight: 2,
|
||||||
|
},
|
||||||
|
filterText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
filterTextActive: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
resultsList: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
resultItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
posterContainer: {
|
||||||
|
width: POSTER_WIDTH,
|
||||||
|
height: POSTER_HEIGHT,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
itemDetails: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
itemTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 8,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
metaRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
yearText: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
genreText: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
skeletonContainer: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
skeletonPoster: {
|
||||||
|
width: POSTER_WIDTH,
|
||||||
|
height: POSTER_HEIGHT,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
skeletonTitle: {
|
||||||
|
height: 22,
|
||||||
|
marginBottom: 8,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
skeletonMeta: {
|
||||||
|
height: 14,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
},
|
||||||
|
skeletonSectionHeader: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
recentSearchesContainer: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
recentSearchesTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
recentSearchItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
recentSearchIcon: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
recentSearchText: {
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SearchScreen;
|
||||||
353
src/screens/SettingsScreen.tsx
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Switch,
|
||||||
|
ScrollView,
|
||||||
|
useColorScheme,
|
||||||
|
SafeAreaView,
|
||||||
|
StatusBar,
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
Dimensions,
|
||||||
|
Pressable
|
||||||
|
} from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
import { colors } from '../styles/colors';
|
||||||
|
import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface SettingItemProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
renderControl: () => React.ReactNode;
|
||||||
|
isLast?: boolean;
|
||||||
|
onPress?: () => void;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingItem: React.FC<SettingItemProps> = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
renderControl,
|
||||||
|
isLast = false,
|
||||||
|
onPress,
|
||||||
|
isDarkMode
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.settingItem,
|
||||||
|
!isLast && styles.settingItemBorder,
|
||||||
|
{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.settingTouchable}
|
||||||
|
onPress={onPress}
|
||||||
|
android_ripple={{
|
||||||
|
color: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
|
||||||
|
borderless: true
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={[
|
||||||
|
styles.settingIconContainer,
|
||||||
|
{ backgroundColor: isDarkMode ? colors.elevation2 : 'rgba(147, 51, 234, 0.08)' }
|
||||||
|
]}>
|
||||||
|
<MaterialIcons name={icon} size={24} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.settingContent}>
|
||||||
|
<Text style={[styles.settingTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.settingDescription, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.settingControl}>
|
||||||
|
{renderControl()}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SettingsScreen: React.FC = () => {
|
||||||
|
const { settings, updateSetting } = useSettings();
|
||||||
|
const systemColorScheme = useColorScheme();
|
||||||
|
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
|
||||||
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
|
||||||
|
const handleResetSettings = useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
'Reset Settings',
|
||||||
|
'Are you sure you want to reset all settings to default values?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Reset',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
(Object.keys(DEFAULT_SETTINGS) as Array<keyof typeof DEFAULT_SETTINGS>).forEach(key => {
|
||||||
|
updateSetting(key, DEFAULT_SETTINGS[key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}, [updateSetting]);
|
||||||
|
|
||||||
|
const renderSectionHeader = (title: string) => (
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={[
|
||||||
|
styles.sectionHeaderText,
|
||||||
|
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
|
||||||
|
]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => (
|
||||||
|
<Switch
|
||||||
|
value={value}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
trackColor={{ false: isDarkMode ? colors.elevation2 : colors.surfaceVariant, true: `${colors.primary}80` }}
|
||||||
|
thumbColor={value ? colors.primary : (isDarkMode ? colors.white : colors.white)}
|
||||||
|
ios_backgroundColor={isDarkMode ? colors.elevation2 : colors.surfaceVariant}
|
||||||
|
style={Platform.select({ ios: { transform: [{ scale: 0.8 }] } })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[
|
||||||
|
styles.container,
|
||||||
|
{ backgroundColor: isDarkMode ? colors.darkBackground : colors.lightBackground }
|
||||||
|
]}>
|
||||||
|
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
||||||
|
<View style={[styles.header, {
|
||||||
|
borderBottomColor: isDarkMode ? colors.border : 'rgba(0,0,0,0.08)'
|
||||||
|
}]}>
|
||||||
|
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
|
||||||
|
Settings
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{renderSectionHeader('Playback')}
|
||||||
|
<SettingItem
|
||||||
|
title="External Player"
|
||||||
|
description="Use external video player when available"
|
||||||
|
icon="open-in-new"
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
renderControl={() => (
|
||||||
|
<CustomSwitch
|
||||||
|
value={settings.useExternalPlayer}
|
||||||
|
onValueChange={(value) => updateSetting('useExternalPlayer', value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{renderSectionHeader('Content')}
|
||||||
|
<SettingItem
|
||||||
|
title="Catalog Settings"
|
||||||
|
description="Customize which catalogs appear on your home screen"
|
||||||
|
icon="view-list"
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
renderControl={() => (
|
||||||
|
<View style={[styles.actionButton, { backgroundColor: colors.primary }]}>
|
||||||
|
<Text style={styles.actionButtonText}>Configure</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
onPress={() => navigation.navigate('CatalogSettings')}
|
||||||
|
/>
|
||||||
|
<SettingItem
|
||||||
|
title="Calendar & Upcoming"
|
||||||
|
description="View and manage your upcoming episode schedule"
|
||||||
|
icon="calendar-today"
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
renderControl={() => (
|
||||||
|
<MaterialIcons
|
||||||
|
name="chevron-right"
|
||||||
|
size={24}
|
||||||
|
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
||||||
|
style={styles.chevronIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
onPress={() => navigation.navigate('Calendar')}
|
||||||
|
/>
|
||||||
|
<SettingItem
|
||||||
|
title="Notifications"
|
||||||
|
description="Configure notifications for new episodes"
|
||||||
|
icon="notifications"
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
renderControl={() => (
|
||||||
|
<MaterialIcons
|
||||||
|
name="chevron-right"
|
||||||
|
size={24}
|
||||||
|
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
||||||
|
style={styles.chevronIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
onPress={() => navigation.navigate('NotificationSettings')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{renderSectionHeader('Advanced')}
|
||||||
|
<SettingItem
|
||||||
|
title="Manage Addons"
|
||||||
|
description="Configure and update your addons"
|
||||||
|
icon="extension"
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
renderControl={() => (
|
||||||
|
<MaterialIcons
|
||||||
|
name="chevron-right"
|
||||||
|
size={24}
|
||||||
|
color={isDarkMode ? colors.lightGray : colors.mediumGray}
|
||||||
|
style={styles.chevronIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
onPress={() => navigation.navigate('Addons')}
|
||||||
|
/>
|
||||||
|
<SettingItem
|
||||||
|
title="Reset All Settings"
|
||||||
|
description="Restore default settings"
|
||||||
|
icon="settings-backup-restore"
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
renderControl={() => (
|
||||||
|
<View style={[styles.actionButton, { backgroundColor: colors.warning }]}>
|
||||||
|
<Text style={styles.actionButtonText}>Reset</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
isLast={true}
|
||||||
|
onPress={handleResetSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{renderSectionHeader('About')}
|
||||||
|
<SettingItem
|
||||||
|
title="App Version"
|
||||||
|
description="HuHuMobile v1.0.0"
|
||||||
|
icon="info"
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
renderControl={() => null}
|
||||||
|
isLast={true}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 32,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
sectionHeaderText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
settingItem: {
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginVertical: 4,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: Platform.OS === 'android' ? 'hidden' : 'visible',
|
||||||
|
},
|
||||||
|
settingItemBorder: {
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
settingTouchable: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
settingIconContainer: {
|
||||||
|
marginRight: 16,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
settingContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
settingTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
letterSpacing: 0.15,
|
||||||
|
},
|
||||||
|
settingDescription: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
letterSpacing: 0.25,
|
||||||
|
},
|
||||||
|
settingControl: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minWidth: 50,
|
||||||
|
},
|
||||||
|
selectButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
selectButtonText: {
|
||||||
|
fontWeight: '600',
|
||||||
|
marginRight: 4,
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: 0.25,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
actionButtonText: {
|
||||||
|
color: colors.white,
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
chevronIcon: {
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SettingsScreen;
|
||||||
793
src/screens/ShowRatingsScreen.tsx
Normal file
|
|
@ -0,0 +1,793 @@
|
||||||
|
import React, { useState, useEffect, useRef, useCallback, memo, Suspense } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
ActivityIndicator,
|
||||||
|
SafeAreaView,
|
||||||
|
TouchableOpacity,
|
||||||
|
Platform,
|
||||||
|
StatusBar,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { colors } from '../styles';
|
||||||
|
import { TMDBService, TMDBShow as Show, TMDBSeason, TMDBEpisode } from '../services/tmdbService';
|
||||||
|
import { RouteProp } from '@react-navigation/native';
|
||||||
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
import axios from 'axios';
|
||||||
|
import Animated, { FadeIn, SlideInRight, withTiming, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||||
|
|
||||||
|
type RootStackParamList = {
|
||||||
|
ShowRatings: { showId: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShowRatingsRouteProp = RouteProp<RootStackParamList, 'ShowRatings'>;
|
||||||
|
|
||||||
|
type RatingSource = 'tmdb' | 'imdb' | 'tvmaze';
|
||||||
|
|
||||||
|
interface TVMazeEpisode {
|
||||||
|
id: number;
|
||||||
|
rating: {
|
||||||
|
average: number | null;
|
||||||
|
};
|
||||||
|
season: number;
|
||||||
|
number: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TVMazeShow {
|
||||||
|
id: number;
|
||||||
|
externals: {
|
||||||
|
imdb: string | null;
|
||||||
|
thetvdb: number | null;
|
||||||
|
};
|
||||||
|
_embedded?: {
|
||||||
|
episodes: TVMazeEpisode[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
route: ShowRatingsRouteProp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRatingColor = (rating: number): string => {
|
||||||
|
if (rating >= 9.0) return '#186A3B'; // Awesome
|
||||||
|
if (rating >= 8.5) return '#28B463'; // Great
|
||||||
|
if (rating >= 8.0) return '#28B463'; // Great
|
||||||
|
if (rating >= 7.5) return '#F4D03F'; // Good
|
||||||
|
if (rating >= 7.0) return '#F39C12'; // Regular
|
||||||
|
if (rating >= 6.0) return '#E74C3C'; // Bad
|
||||||
|
return '#633974'; // Garbage
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoized components
|
||||||
|
const RatingCell = memo(({ episode, ratingSource, getTVMazeRating, isCurrentSeason }: {
|
||||||
|
episode: TMDBEpisode;
|
||||||
|
ratingSource: RatingSource;
|
||||||
|
getTVMazeRating: (seasonNumber: number, episodeNumber: number) => number | null;
|
||||||
|
isCurrentSeason: (episode: TMDBEpisode) => boolean;
|
||||||
|
}) => {
|
||||||
|
const getRatingForSource = useCallback((episode: TMDBEpisode): number | null => {
|
||||||
|
switch (ratingSource) {
|
||||||
|
case 'imdb':
|
||||||
|
return episode.imdb_rating || null;
|
||||||
|
case 'tmdb':
|
||||||
|
return episode.vote_average || null;
|
||||||
|
case 'tvmaze':
|
||||||
|
return getTVMazeRating(episode.season_number, episode.episode_number);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [ratingSource, getTVMazeRating]);
|
||||||
|
|
||||||
|
const isRatingPotentiallyInaccurate = useCallback((episode: TMDBEpisode): boolean => {
|
||||||
|
const rating = getRatingForSource(episode);
|
||||||
|
if (!rating) return false;
|
||||||
|
|
||||||
|
if (ratingSource === 'tmdb' && episode.imdb_rating) {
|
||||||
|
const difference = Math.abs(rating - episode.imdb_rating);
|
||||||
|
return difference >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [getRatingForSource, ratingSource]);
|
||||||
|
|
||||||
|
const rating = getRatingForSource(episode);
|
||||||
|
const isInaccurate = isRatingPotentiallyInaccurate(episode);
|
||||||
|
const isCurrent = isCurrentSeason(episode);
|
||||||
|
|
||||||
|
if (!rating) {
|
||||||
|
if (!episode.air_date || new Date(episode.air_date) > new Date()) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.ratingCell, { backgroundColor: colors.darkGray }]}>
|
||||||
|
<MaterialIcons name="schedule" size={16} color={colors.lightGray} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View style={[styles.ratingCell, { backgroundColor: colors.darkGray }]}>
|
||||||
|
<Text style={[styles.ratingText, { color: colors.lightGray }]}>—</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.ratingCellContainer}>
|
||||||
|
<View style={[
|
||||||
|
styles.ratingCell,
|
||||||
|
{
|
||||||
|
backgroundColor: getRatingColor(rating),
|
||||||
|
opacity: isCurrent ? 0.7 : 1
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
<Text style={styles.ratingText}>{rating.toFixed(1)}</Text>
|
||||||
|
</View>
|
||||||
|
{(isInaccurate || isCurrent) && (
|
||||||
|
<MaterialIcons
|
||||||
|
name={isCurrent ? "schedule" : "warning"}
|
||||||
|
size={12}
|
||||||
|
color={isCurrent ? colors.primary : colors.warning}
|
||||||
|
style={styles.warningIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const RatingSourceToggle = memo(({ ratingSource, setRatingSource }: {
|
||||||
|
ratingSource: RatingSource;
|
||||||
|
setRatingSource: (source: RatingSource) => void;
|
||||||
|
}) => (
|
||||||
|
<View style={styles.ratingSourceContainer}>
|
||||||
|
<Text style={styles.ratingSourceTitle}>Rating Source:</Text>
|
||||||
|
<View style={styles.ratingSourceButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.sourceButton,
|
||||||
|
ratingSource === 'imdb' && styles.sourceButtonActive
|
||||||
|
]}
|
||||||
|
onPress={() => setRatingSource('imdb')}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.sourceButtonText,
|
||||||
|
ratingSource === 'imdb' && styles.sourceButtonTextActive
|
||||||
|
]}>IMDb</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.sourceButton,
|
||||||
|
ratingSource === 'tmdb' && styles.sourceButtonActive
|
||||||
|
]}
|
||||||
|
onPress={() => setRatingSource('tmdb')}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.sourceButtonText,
|
||||||
|
ratingSource === 'tmdb' && styles.sourceButtonTextActive
|
||||||
|
]}>TMDB</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.sourceButton,
|
||||||
|
ratingSource === 'tvmaze' && styles.sourceButtonActive
|
||||||
|
]}
|
||||||
|
onPress={() => setRatingSource('tvmaze')}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.sourceButtonText,
|
||||||
|
ratingSource === 'tvmaze' && styles.sourceButtonTextActive
|
||||||
|
]}>TVMaze</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
|
||||||
|
const ShowInfo = memo(({ show }: { show: Show | null }) => (
|
||||||
|
<View style={styles.showInfo}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: `https://image.tmdb.org/t/p/w500${show?.poster_path}` }}
|
||||||
|
style={styles.poster}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
<View style={styles.showDetails}>
|
||||||
|
<Text style={styles.showTitle}>{show?.name}</Text>
|
||||||
|
<Text style={styles.showYear}>
|
||||||
|
{show?.first_air_date ? `${new Date(show.first_air_date).getFullYear()} - ${show.last_air_date ? new Date(show.last_air_date).getFullYear() : 'Present'}` : ''}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.episodeCountContainer}>
|
||||||
|
<MaterialIcons name="tv" size={16} color={colors.primary} />
|
||||||
|
<Text style={styles.episodeCount}>
|
||||||
|
{show?.number_of_seasons} Seasons • {show?.number_of_episodes} Episodes
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
|
||||||
|
const ShowRatingsScreen = ({ route }: Props) => {
|
||||||
|
const { showId } = route.params;
|
||||||
|
const [show, setShow] = useState<Show | null>(null);
|
||||||
|
const [seasons, setSeasons] = useState<TMDBSeason[]>([]);
|
||||||
|
const [tvmazeEpisodes, setTvmazeEpisodes] = useState<TVMazeEpisode[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingSeasons, setLoadingSeasons] = useState(false);
|
||||||
|
const [loadedSeasons, setLoadedSeasons] = useState<number[]>([]);
|
||||||
|
const [ratingSource, setRatingSource] = useState<RatingSource>('tmdb');
|
||||||
|
const [visibleSeasonRange, setVisibleSeasonRange] = useState({ start: 0, end: 8 });
|
||||||
|
const [loadingProgress, setLoadingProgress] = useState(0);
|
||||||
|
const ratingsCache = useRef<{[key: string]: number | null}>({});
|
||||||
|
|
||||||
|
const fetchTVMazeData = async (imdbId: string) => {
|
||||||
|
try {
|
||||||
|
const lookupResponse = await axios.get(`https://api.tvmaze.com/lookup/shows?imdb=${imdbId}`);
|
||||||
|
const tvmazeId = lookupResponse.data?.id;
|
||||||
|
|
||||||
|
if (tvmazeId) {
|
||||||
|
const showResponse = await axios.get(`https://api.tvmaze.com/shows/${tvmazeId}?embed=episodes`);
|
||||||
|
if (showResponse.data?._embedded?.episodes) {
|
||||||
|
setTvmazeEpisodes(showResponse.data._embedded.episodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching TVMaze data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreSeasons = async () => {
|
||||||
|
if (!show || loadingSeasons) return;
|
||||||
|
|
||||||
|
setLoadingSeasons(true);
|
||||||
|
try {
|
||||||
|
const tmdb = TMDBService.getInstance();
|
||||||
|
const seasonsToLoad = show.seasons
|
||||||
|
.filter(season =>
|
||||||
|
season.season_number > 0 &&
|
||||||
|
!loadedSeasons.includes(season.season_number) &&
|
||||||
|
season.season_number > visibleSeasonRange.start &&
|
||||||
|
season.season_number <= visibleSeasonRange.end
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load seasons in parallel in larger batches
|
||||||
|
const batchSize = 4; // Load 4 seasons at a time
|
||||||
|
const batches = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < seasonsToLoad.length; i += batchSize) {
|
||||||
|
const batch = seasonsToLoad.slice(i, i + batchSize);
|
||||||
|
batches.push(batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadedCount = 0;
|
||||||
|
const totalToLoad = seasonsToLoad.length;
|
||||||
|
|
||||||
|
for (const batch of batches) {
|
||||||
|
const batchResults = await Promise.all(
|
||||||
|
batch.map(season =>
|
||||||
|
tmdb.getSeasonDetails(showId, season.season_number, show.name)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const validResults = batchResults.filter((s): s is TMDBSeason => s !== null);
|
||||||
|
setSeasons(prev => [...prev, ...validResults]);
|
||||||
|
setLoadedSeasons(prev => [...prev, ...batch.map(s => s.season_number)]);
|
||||||
|
|
||||||
|
loadedCount += batch.length;
|
||||||
|
setLoadingProgress((loadedCount / totalToLoad) * 100);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading more seasons:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingProgress(0);
|
||||||
|
setLoadingSeasons(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = useCallback((event: any) => {
|
||||||
|
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||||
|
const isCloseToRight = (contentOffset.x + layoutMeasurement.width) >= (contentSize.width * 0.8);
|
||||||
|
|
||||||
|
if (isCloseToRight && show && !loadingSeasons) {
|
||||||
|
const maxSeasons = Math.max(...show.seasons.map(s => s.season_number));
|
||||||
|
if (visibleSeasonRange.end < maxSeasons) {
|
||||||
|
setVisibleSeasonRange(prev => ({
|
||||||
|
start: prev.end,
|
||||||
|
end: Math.min(prev.end + 8, maxSeasons)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [show, loadingSeasons, visibleSeasonRange.end]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchShowData = async () => {
|
||||||
|
try {
|
||||||
|
const tmdb = TMDBService.getInstance();
|
||||||
|
const showData = await tmdb.getTVShowDetails(showId);
|
||||||
|
if (showData) {
|
||||||
|
setShow(showData);
|
||||||
|
|
||||||
|
// Get external IDs to fetch TVMaze data
|
||||||
|
const externalIds = await tmdb.getShowExternalIds(showId);
|
||||||
|
if (externalIds?.imdb_id) {
|
||||||
|
fetchTVMazeData(externalIds.imdb_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial season range
|
||||||
|
const initialEnd = Math.min(8, Math.max(...showData.seasons.map(s => s.season_number)));
|
||||||
|
setVisibleSeasonRange({ start: 0, end: initialEnd });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching show data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchShowData();
|
||||||
|
}, [showId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMoreSeasons();
|
||||||
|
}, [visibleSeasonRange]);
|
||||||
|
|
||||||
|
const getTVMazeRating = useCallback((seasonNumber: number, episodeNumber: number): number | null => {
|
||||||
|
const episode = tvmazeEpisodes.find(
|
||||||
|
ep => ep.season === seasonNumber && ep.number === episodeNumber
|
||||||
|
);
|
||||||
|
return episode?.rating?.average || null;
|
||||||
|
}, [tvmazeEpisodes]);
|
||||||
|
|
||||||
|
const isCurrentSeason = useCallback((episode: TMDBEpisode): boolean => {
|
||||||
|
if (!seasons.length || !episode.air_date) return false;
|
||||||
|
|
||||||
|
const latestSeasonNumber = Math.max(...seasons.map(s => s.season_number));
|
||||||
|
if (episode.season_number !== latestSeasonNumber) return false;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const airDate = new Date(episode.air_date);
|
||||||
|
const monthsDiff = (now.getFullYear() - airDate.getFullYear()) * 12 +
|
||||||
|
(now.getMonth() - airDate.getMonth());
|
||||||
|
|
||||||
|
return monthsDiff <= 6;
|
||||||
|
}, [seasons]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.black }]}>
|
||||||
|
<StatusBar
|
||||||
|
translucent
|
||||||
|
backgroundColor="transparent"
|
||||||
|
barStyle="light-content"
|
||||||
|
/>
|
||||||
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.black }]}>
|
||||||
|
<StatusBar
|
||||||
|
translucent
|
||||||
|
backgroundColor="transparent"
|
||||||
|
barStyle="light-content"
|
||||||
|
/>
|
||||||
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
|
<Suspense fallback={
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
}>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(150)}
|
||||||
|
style={styles.showInfoContainer}
|
||||||
|
>
|
||||||
|
<ShowInfo show={show} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.delay(50).duration(150)}
|
||||||
|
style={styles.ratingSourceContainer}
|
||||||
|
>
|
||||||
|
<RatingSourceToggle ratingSource={ratingSource} setRatingSource={setRatingSource} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.delay(100).duration(150)}
|
||||||
|
style={styles.legend}
|
||||||
|
>
|
||||||
|
{/* Legend */}
|
||||||
|
<View style={styles.legend}>
|
||||||
|
<Text style={styles.legendTitle}>Rating Scale</Text>
|
||||||
|
<View style={styles.legendItems}>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendColor, { backgroundColor: '#186A3B' }]} />
|
||||||
|
<Text style={styles.legendText}>Awesome (9.0+)</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendColor, { backgroundColor: '#28B463' }]} />
|
||||||
|
<Text style={styles.legendText}>Great (8.0-8.9)</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendColor, { backgroundColor: '#F4D03F' }]} />
|
||||||
|
<Text style={styles.legendText}>Good (7.5-7.9)</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendColor, { backgroundColor: '#F39C12' }]} />
|
||||||
|
<Text style={styles.legendText}>Regular (7.0-7.4)</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendColor, { backgroundColor: '#E74C3C' }]} />
|
||||||
|
<Text style={styles.legendText}>Bad (6.0-6.9)</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendColor, { backgroundColor: '#633974' }]} />
|
||||||
|
<Text style={styles.legendText}>Garbage ({'<'}6.0)</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.warningLegends}>
|
||||||
|
<View style={styles.warningLegend}>
|
||||||
|
<MaterialIcons name="warning" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.warningText}>Rating differs significantly from IMDb</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.warningLegend}>
|
||||||
|
<MaterialIcons name="schedule" size={16} color={colors.primary} />
|
||||||
|
<Text style={styles.warningText}>Current season (ratings may change)</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.delay(150).duration(150)}
|
||||||
|
style={styles.ratingsGrid}
|
||||||
|
>
|
||||||
|
{/* Ratings Grid */}
|
||||||
|
<View style={styles.ratingsGrid}>
|
||||||
|
<View style={styles.gridContainer}>
|
||||||
|
{/* Fixed Episode Column */}
|
||||||
|
<View style={styles.fixedColumn}>
|
||||||
|
<View style={styles.episodeColumn}>
|
||||||
|
<Text style={styles.headerText}>Episode</Text>
|
||||||
|
</View>
|
||||||
|
{Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => (
|
||||||
|
<View key={`e${episodeIndex + 1}`} style={styles.episodeCell}>
|
||||||
|
<Text style={styles.episodeText}>E{episodeIndex + 1}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Scrollable Seasons */}
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.seasonsScrollView}
|
||||||
|
onScroll={onScroll}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
{/* Seasons Header */}
|
||||||
|
<View style={styles.gridHeader}>
|
||||||
|
{seasons.map((season) => (
|
||||||
|
<View key={`s${season.season_number}`} style={styles.ratingColumn}>
|
||||||
|
<Text style={styles.headerText}>S{season.season_number}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{loadingSeasons && (
|
||||||
|
<View style={[styles.ratingColumn, styles.loadingColumn]}>
|
||||||
|
<View style={styles.loadingProgressContainer}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
{loadingProgress > 0 && (
|
||||||
|
<Text style={styles.loadingProgressText}>
|
||||||
|
{Math.round(loadingProgress)}%
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Episodes Grid */}
|
||||||
|
{Array.from({ length: Math.max(...seasons.map(s => s.episodes.length)) }).map((_, episodeIndex) => (
|
||||||
|
<View key={`e${episodeIndex + 1}`} style={styles.gridRow}>
|
||||||
|
{seasons.map((season) => (
|
||||||
|
<View key={`s${season.season_number}e${episodeIndex + 1}`} style={styles.ratingColumn}>
|
||||||
|
{season.episodes[episodeIndex] &&
|
||||||
|
<RatingCell
|
||||||
|
episode={season.episodes[episodeIndex]}
|
||||||
|
ratingSource={ratingSource}
|
||||||
|
getTVMazeRating={getTVMazeRating}
|
||||||
|
isCurrentSeason={isCurrentSeason}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{loadingSeasons && <View style={[styles.ratingColumn, styles.loadingColumn]} />}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</Suspense>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.black,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 8,
|
||||||
|
paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 8 : 8,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
showInfoContainer: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
showInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 12,
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
width: 80,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
showDetails: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
showTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: colors.white,
|
||||||
|
marginBottom: 2,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
showYear: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.lightGray,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
episodeCountContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
episodeCount: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.lightGray,
|
||||||
|
},
|
||||||
|
ratingSection: {
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
ratingSourceContainer: {
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
ratingSourceTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.white,
|
||||||
|
marginBottom: 6,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
ratingSourceButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
sourceButton: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.lightGray,
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
sourceButtonActive: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
sourceButtonText: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
sourceButtonTextActive: {
|
||||||
|
color: colors.white,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
tmdbDisclaimer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.black + '40',
|
||||||
|
padding: 6,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginTop: 8,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
tmdbDisclaimerText: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 12,
|
||||||
|
flex: 1,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
legendTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.white,
|
||||||
|
marginBottom: 8,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
legendItems: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
minWidth: '45%',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
legendColor: {
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 3,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
legendText: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
warningLegends: {
|
||||||
|
marginTop: 8,
|
||||||
|
gap: 6,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.black + '40',
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
warningLegend: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
warningText: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 11,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
ratingsGrid: {
|
||||||
|
backgroundColor: colors.darkBackground,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
gridContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
fixedColumn: {
|
||||||
|
width: 40,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: colors.black + '40',
|
||||||
|
},
|
||||||
|
seasonsScrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
gridHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.black + '40',
|
||||||
|
paddingBottom: 6,
|
||||||
|
paddingLeft: 6,
|
||||||
|
},
|
||||||
|
gridRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 6,
|
||||||
|
paddingLeft: 6,
|
||||||
|
},
|
||||||
|
episodeCell: {
|
||||||
|
height: 28,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingRight: 6,
|
||||||
|
},
|
||||||
|
episodeColumn: {
|
||||||
|
height: 28,
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
paddingRight: 6,
|
||||||
|
},
|
||||||
|
ratingColumn: {
|
||||||
|
width: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerText: {
|
||||||
|
color: colors.white,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
episodeText: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
ratingCell: {
|
||||||
|
width: 32,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 3,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
ratingText: {
|
||||||
|
color: colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
ratingCellContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
width: 32,
|
||||||
|
height: 24,
|
||||||
|
},
|
||||||
|
warningIcon: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -4,
|
||||||
|
right: -4,
|
||||||
|
backgroundColor: colors.black,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 1,
|
||||||
|
},
|
||||||
|
loadingColumn: {
|
||||||
|
width: 40,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
loadingProgressContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
loadingProgressText: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default memo(ShowRatingsScreen, (prevProps, nextProps) => {
|
||||||
|
return prevProps.route.params.showId === nextProps.route.params.showId;
|
||||||
|
});
|
||||||
1
src/screens/StreamScreen.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||