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 (
|
||||
<View style={styles.container}>
|
||||
<Text>Open up App.tsx to start working on your app!</Text>
|
||||
<StatusBar style="auto" />
|
||||
</View>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<CatalogProvider>
|
||||
<PaperProvider theme={CustomDarkTheme}>
|
||||
<NavigationContainer theme={CustomNavigationDarkTheme}>
|
||||
<View style={[styles.container, { backgroundColor: '#000000' }]}>
|
||||
<StatusBar
|
||||
style="light"
|
||||
/>
|
||||
<AppNavigator />
|
||||
</View>
|
||||
</NavigationContainer>
|
||||
</PaperProvider>
|
||||
</CatalogProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
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",
|
||||
"slug": "stremio-expo",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
|
|
@ -13,16 +13,31 @@
|
|||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"INTERNET",
|
||||
"WAKE_LOCK"
|
||||
],
|
||||
"package": "com.stremio.expo"
|
||||
},
|
||||
"web": {
|
||||
"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",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"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-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-system-ui": "^4.0.9",
|
||||
"lodash": "^4.17.21",
|
||||
"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": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-native": "^0.72.8",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"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 @@
|
|||
|
||||