diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index 37cdc90..0000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,4 +0,0 @@
-# These are supported funding model platforms
-
-github: [tapframe]
-ko_fi: tapframe
diff --git a/.gitignore b/.gitignore
index 5262efb..e968fcb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,4 +72,3 @@ SDK54_UPGRADE_SUMMARY.md
SDK54_UPGRADE_SUMMARY.md
build-and-publish-app-releases.sh
bottomnav.md
-/TrailerServices
diff --git a/App.tsx b/App.tsx
index 5b956af..6cdab60 100644
--- a/App.tsx
+++ b/App.tsx
@@ -40,7 +40,6 @@ import UpdateService from './src/services/updateService';
import { memoryMonitorService } from './src/services/memoryMonitorService';
import { aiService } from './src/services/aiService';
import { AccountProvider, useAccount } from './src/contexts/AccountContext';
-import { ToastProvider } from './src/contexts/ToastContext';
Sentry.init({
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
@@ -204,9 +203,7 @@ function App(): React.JSX.Element {
-
-
-
+
diff --git a/TrailerService b/TrailerService
new file mode 160000
index 0000000..2cb2c6d
--- /dev/null
+++ b/TrailerService
@@ -0,0 +1 @@
+Subproject commit 2cb2c6d1a3ca60416160bdb28be800fe249207fc
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 335494d..f9ed83b 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -94,8 +94,8 @@ android {
applicationId 'com.nuvio.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 21
- versionName "1.2.6"
+ versionCode 20
+ versionName "1.2.5"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
@@ -117,7 +117,7 @@ android {
def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
applicationVariants.all { variant ->
variant.outputs.each { output ->
- def baseVersionCode = 21 // Current versionCode 21 from defaultConfig
+ def baseVersionCode = 20 // Current versionCode from defaultConfig
def abiName = output.getFilter(com.android.build.OutputFile.ABI)
def versionCode = baseVersionCode * 100 // Base multiplier
diff --git a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png
index 15b512e..29ed05b 100644
Binary files a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png
index 8b78e2f..1e3a378 100644
Binary files a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png
index 528962a..6b7ddf9 100644
Binary files a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png
index 0969e6a..b977b3f 100644
Binary files a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png
index 7de4782..a79fe8b 100644
Binary files a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
index bf53e9c..4f55492 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
index 13e14ea..acff384 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
index 934bf05..68b2b6b 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
index 52cdd30..7ce788c 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
index 83217a9..695874a 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
index 5fbb1f3..de65e82 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
index b1e5362..f48cd8b 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
index 5133d3d..b9693cb 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
index 0488026..d48403b 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
index 04093c9..eebf7ad 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
index 63d0078..5f71916 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
index 1c6776e..4f6de84 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
index e61bef4..bee06ef 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
index 3e9f36e..4208368 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
index d049451..34afa47 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 78602b8..08f0025 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -3,5 +3,5 @@
contain
false
dark
- 1.2.6
+ 1.2.5
\ No newline at end of file
diff --git a/app.json b/app.json
index 8ddc14f..8662c1f 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Nuvio",
"slug": "nuvio",
- "version": "1.2.6",
+ "version": "1.2.5",
"orientation": "default",
"backgroundColor": "#020404",
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
@@ -10,14 +10,14 @@
"scheme": "nuvio",
"newArchEnabled": true,
"splash": {
- "image": "./src/assets/splash-icon-new.png",
+ "image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#020404"
},
"ios": {
"supportsTablet": true,
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
- "buildNumber": "21",
+ "buildNumber": "20",
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
@@ -48,7 +48,7 @@
"WAKE_LOCK"
],
"package": "com.nuvio.app",
- "versionCode": 21,
+ "versionCode": 20,
"architectures": [
"arm64-v8a",
"armeabi-v7a",
@@ -95,6 +95,6 @@
"fallbackToCacheTimeout": 30000,
"url": "https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest"
},
- "runtimeVersion": "1.2.6"
+ "runtimeVersion": "1.2.5"
}
}
diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png
index 302b45f..d5a9df5 100644
Binary files a/assets/adaptive-icon.png and b/assets/adaptive-icon.png differ
diff --git a/assets/android/ic_launcher-web.png b/assets/android/ic_launcher-web.png
index 9c3a1fb..2efccc3 100644
Binary files a/assets/android/ic_launcher-web.png and b/assets/android/ic_launcher-web.png differ
diff --git a/assets/android/mipmap-hdpi/ic_launcher.png b/assets/android/mipmap-hdpi/ic_launcher.png
index 63653c1..af5bd9d 100644
Binary files a/assets/android/mipmap-hdpi/ic_launcher.png and b/assets/android/mipmap-hdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-hdpi/ic_launcher_foreground.png b/assets/android/mipmap-hdpi/ic_launcher_foreground.png
index 46d5c74..4aaa708 100644
Binary files a/assets/android/mipmap-hdpi/ic_launcher_foreground.png and b/assets/android/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-hdpi/ic_launcher_round.png b/assets/android/mipmap-hdpi/ic_launcher_round.png
index a67afe1..ce446f7 100644
Binary files a/assets/android/mipmap-hdpi/ic_launcher_round.png and b/assets/android/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-ldpi/ic_launcher.png b/assets/android/mipmap-ldpi/ic_launcher.png
index 4f72f37..426e2ae 100644
Binary files a/assets/android/mipmap-ldpi/ic_launcher.png and b/assets/android/mipmap-ldpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-mdpi/ic_launcher.png b/assets/android/mipmap-mdpi/ic_launcher.png
index 12d0832..c450f86 100644
Binary files a/assets/android/mipmap-mdpi/ic_launcher.png and b/assets/android/mipmap-mdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-mdpi/ic_launcher_foreground.png b/assets/android/mipmap-mdpi/ic_launcher_foreground.png
index eedf360..7ec84d6 100644
Binary files a/assets/android/mipmap-mdpi/ic_launcher_foreground.png and b/assets/android/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-mdpi/ic_launcher_round.png b/assets/android/mipmap-mdpi/ic_launcher_round.png
index ee64b08..1185a4d 100644
Binary files a/assets/android/mipmap-mdpi/ic_launcher_round.png and b/assets/android/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-xhdpi/ic_launcher.png b/assets/android/mipmap-xhdpi/ic_launcher.png
index c71d35f..81d12cd 100644
Binary files a/assets/android/mipmap-xhdpi/ic_launcher.png and b/assets/android/mipmap-xhdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-xhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xhdpi/ic_launcher_foreground.png
index 9c303ef..dbdd835 100644
Binary files a/assets/android/mipmap-xhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-xhdpi/ic_launcher_round.png b/assets/android/mipmap-xhdpi/ic_launcher_round.png
index 0d9d1de..17ea551 100644
Binary files a/assets/android/mipmap-xhdpi/ic_launcher_round.png and b/assets/android/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-xxhdpi/ic_launcher.png b/assets/android/mipmap-xxhdpi/ic_launcher.png
index 5cda623..dd251f4 100644
Binary files a/assets/android/mipmap-xxhdpi/ic_launcher.png and b/assets/android/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png
index f510bbf..1a315db 100644
Binary files a/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-xxhdpi/ic_launcher_round.png b/assets/android/mipmap-xxhdpi/ic_launcher_round.png
index 9ec97ab..c16b8d9 100644
Binary files a/assets/android/mipmap-xxhdpi/ic_launcher_round.png and b/assets/android/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher.png b/assets/android/mipmap-xxxhdpi/ic_launcher.png
index ad2c371..b017bf5 100644
Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher.png and b/assets/android/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png
index 7cabe37..1c27650 100644
Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher_round.png b/assets/android/mipmap-xxxhdpi/ic_launcher_round.png
index 71e9834..055fe00 100644
Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher_round.png and b/assets/android/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/assets/android/playstore-icon.png b/assets/android/playstore-icon.png
index 4d1e685..8e950cf 100644
Binary files a/assets/android/playstore-icon.png and b/assets/android/playstore-icon.png differ
diff --git a/assets/android/values/ic_launcher_background.xml b/assets/android/values/ic_launcher_background.xml
index 58403da..dcdf032 100644
--- a/assets/android/values/ic_launcher_background.xml
+++ b/assets/android/values/ic_launcher_background.xml
@@ -1,4 +1,4 @@
- #2f2f2f
+ #151515
\ No newline at end of file
diff --git a/assets/bootsplash/android/drawable-hdpi/bootsplash_logo.png b/assets/bootsplash/android/drawable-hdpi/bootsplash_logo.png
index 0369553..bd6895d 100644
Binary files a/assets/bootsplash/android/drawable-hdpi/bootsplash_logo.png and b/assets/bootsplash/android/drawable-hdpi/bootsplash_logo.png differ
diff --git a/assets/bootsplash/android/drawable-mdpi/bootsplash_logo.png b/assets/bootsplash/android/drawable-mdpi/bootsplash_logo.png
index 0369553..b6d09cc 100644
Binary files a/assets/bootsplash/android/drawable-mdpi/bootsplash_logo.png and b/assets/bootsplash/android/drawable-mdpi/bootsplash_logo.png differ
diff --git a/assets/bootsplash/android/drawable-xhdpi/bootsplash_logo.png b/assets/bootsplash/android/drawable-xhdpi/bootsplash_logo.png
index 0369553..45de631 100644
Binary files a/assets/bootsplash/android/drawable-xhdpi/bootsplash_logo.png and b/assets/bootsplash/android/drawable-xhdpi/bootsplash_logo.png differ
diff --git a/assets/bootsplash/android/drawable-xxhdpi/bootsplash_logo.png b/assets/bootsplash/android/drawable-xxhdpi/bootsplash_logo.png
index 0369553..850078a 100644
Binary files a/assets/bootsplash/android/drawable-xxhdpi/bootsplash_logo.png and b/assets/bootsplash/android/drawable-xxhdpi/bootsplash_logo.png differ
diff --git a/assets/bootsplash/android/drawable-xxxhdpi/bootsplash_logo.png b/assets/bootsplash/android/drawable-xxxhdpi/bootsplash_logo.png
index 0369553..5d38cd5 100644
Binary files a/assets/bootsplash/android/drawable-xxxhdpi/bootsplash_logo.png and b/assets/bootsplash/android/drawable-xxxhdpi/bootsplash_logo.png differ
diff --git a/assets/bootsplash/ios/Images.xcassets/BootSplashLogo-7d142f.imageset/logo-7d142f.png b/assets/bootsplash/ios/Images.xcassets/BootSplashLogo-7d142f.imageset/logo-7d142f.png
index 0369553..19e5b3c 100644
Binary files a/assets/bootsplash/ios/Images.xcassets/BootSplashLogo-7d142f.imageset/logo-7d142f.png and b/assets/bootsplash/ios/Images.xcassets/BootSplashLogo-7d142f.imageset/logo-7d142f.png differ
diff --git a/assets/bootsplash/ios/Images.xcassets/BootSplashLogo-7d142f.imageset/logo-7d142f@2x.png b/assets/bootsplash/ios/Images.xcassets/BootSplashLogo-7d142f.imageset/logo-7d142f@2x.png
index 0369553..115594f 100644
Binary files a/assets/bootsplash/ios/Images.xcassets/BootSplashLogo-7d142f.imageset/logo-7d142f@2x.png and b/assets/bootsplash/ios/Images.xcassets/BootSplashLogo-7d142f.imageset/logo-7d142f@2x.png differ
diff --git a/assets/bootsplash/ios/Images.xcassets/BootSplashLogo-7d142f.imageset/logo-7d142f@3x.png b/assets/bootsplash/ios/Images.xcassets/BootSplashLogo-7d142f.imageset/logo-7d142f@3x.png
index 0369553..061180d 100644
Binary files a/assets/bootsplash/ios/Images.xcassets/BootSplashLogo-7d142f.imageset/logo-7d142f@3x.png and b/assets/bootsplash/ios/Images.xcassets/BootSplashLogo-7d142f.imageset/logo-7d142f@3x.png differ
diff --git a/assets/icon.png b/assets/icon.png
index 0369553..d5a9df5 100644
Binary files a/assets/icon.png and b/assets/icon.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png
index 4e725a1..ffc8aaa 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png
index 3445ca7..4a52eaa 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png
index 23336a0..d5eea9b 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png
index 82e12de..379634b 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png
index 2fe5d46..4ff0ef2 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png
index b969aa7..edcf4d5 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png
index 3445ca7..4a52eaa 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png
index dd5ac1a..e4afe63 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png
index 0128c2a..152a8e6 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png
index 0128c2a..152a8e6 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png
index aea993c..bbc2ad2 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png
index 2698ce9..a67bc43 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png
index b59c049..3a87610 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
index a08fb02..0f242d6 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png b/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png
index 38237e1..e2f386e 100644
Binary files a/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png and b/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png differ
diff --git a/assets/ios/iTunesArtwork@1x.png b/assets/ios/iTunesArtwork@1x.png
index 4d1e685..8e950cf 100644
Binary files a/assets/ios/iTunesArtwork@1x.png and b/assets/ios/iTunesArtwork@1x.png differ
diff --git a/assets/ios/iTunesArtwork@2x.png b/assets/ios/iTunesArtwork@2x.png
index 38237e1..e2f386e 100644
Binary files a/assets/ios/iTunesArtwork@2x.png and b/assets/ios/iTunesArtwork@2x.png differ
diff --git a/assets/ios/iTunesArtwork@3x.png b/assets/ios/iTunesArtwork@3x.png
index b367f5a..5c2b758 100644
Binary files a/assets/ios/iTunesArtwork@3x.png and b/assets/ios/iTunesArtwork@3x.png differ
diff --git a/enginefs b/enginefs
new file mode 160000
index 0000000..3a70b36
--- /dev/null
+++ b/enginefs
@@ -0,0 +1 @@
+Subproject commit 3a70b36f873307cd83fb3178bb891f73cf73aa87
diff --git a/ios/KSPlayerManager.m b/ios/KSPlayerManager.m
index d61dc40..f521027 100644
--- a/ios/KSPlayerManager.m
+++ b/ios/KSPlayerManager.m
@@ -16,11 +16,6 @@ RCT_EXPORT_VIEW_PROPERTY(paused, BOOL)
RCT_EXPORT_VIEW_PROPERTY(volume, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(audioTrack, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(textTrack, NSNumber)
-RCT_EXPORT_VIEW_PROPERTY(allowsExternalPlayback, BOOL)
-RCT_EXPORT_VIEW_PROPERTY(usesExternalPlaybackWhileExternalScreenIsActive, BOOL)
-RCT_EXPORT_VIEW_PROPERTY(subtitleBottomOffset, NSNumber)
-RCT_EXPORT_VIEW_PROPERTY(subtitleFontSize, NSNumber)
-RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString)
// Event properties
RCT_EXPORT_VIEW_PROPERTY(onLoad, RCTDirectEventBlock)
@@ -37,17 +32,11 @@ RCT_EXTERN_METHOD(setVolume:(nonnull NSNumber *)node volume:(nonnull NSNumber *)
RCT_EXTERN_METHOD(setAudioTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId)
RCT_EXTERN_METHOD(setTextTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId)
RCT_EXTERN_METHOD(getTracks:(nonnull NSNumber *)node resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
-RCT_EXTERN_METHOD(setAllowsExternalPlayback:(nonnull NSNumber *)node allows:(BOOL)allows)
-RCT_EXTERN_METHOD(setUsesExternalPlaybackWhileExternalScreenIsActive:(nonnull NSNumber *)node uses:(BOOL)uses)
-RCT_EXTERN_METHOD(getAirPlayState:(nonnull NSNumber *)node resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
-RCT_EXTERN_METHOD(showAirPlayPicker:(nonnull NSNumber *)node)
@end
@interface RCT_EXTERN_MODULE(KSPlayerModule, RCTEventEmitter)
RCT_EXTERN_METHOD(getTracks:(nonnull NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
-RCT_EXTERN_METHOD(getAirPlayState:(nonnull NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
-RCT_EXTERN_METHOD(showAirPlayPicker:(nonnull NSNumber *)nodeTag)
@end
diff --git a/ios/KSPlayerModule.swift b/ios/KSPlayerModule.swift
index 58ace7c..ee487c1 100644
--- a/ios/KSPlayerModule.swift
+++ b/ios/KSPlayerModule.swift
@@ -34,26 +34,4 @@ class KSPlayerModule: RCTEventEmitter {
}
}
}
-
- @objc func getAirPlayState(_ nodeTag: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
- DispatchQueue.main.async {
- if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {
- viewManager.getAirPlayState(nodeTag, resolve: resolve, reject: reject)
- } else {
- reject("NO_VIEW_MANAGER", "KSPlayerViewManager not found", nil)
- }
- }
- }
-
- @objc func showAirPlayPicker(_ nodeTag: NSNumber) {
- print("[KSPlayerModule] showAirPlayPicker called for nodeTag: \(nodeTag)")
- DispatchQueue.main.async {
- if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {
- print("[KSPlayerModule] Found KSPlayerViewManager, calling showAirPlayPicker")
- viewManager.showAirPlayPicker(nodeTag)
- } else {
- print("[KSPlayerModule] Could not find KSPlayerViewManager")
- }
- }
- }
}
diff --git a/ios/KSPlayerView.swift b/ios/KSPlayerView.swift
index fd31269..7ef84c2 100644
--- a/ios/KSPlayerView.swift
+++ b/ios/KSPlayerView.swift
@@ -8,7 +8,6 @@
import Foundation
import KSPlayer
import React
-import AVKit
@objc(KSPlayerView)
class KSPlayerView: UIView {
@@ -18,11 +17,6 @@ class KSPlayerView: UIView {
private var currentVolume: Float = 1.0
weak var viewManager: KSPlayerViewManager?
- // Store constraint references for dynamic updates
- private var subtitleBottomConstraint: NSLayoutConstraint?
-
- // AirPlay properties (removed duplicate declarations)
-
// Event blocks for Fabric
@objc var onLoad: RCTDirectEventBlock?
@objc var onProgress: RCTDirectEventBlock?
@@ -63,52 +57,15 @@ class KSPlayerView: UIView {
setTextTrack(textTrack.intValue)
}
}
-
- // AirPlay properties
- @objc var allowsExternalPlayback: Bool = true {
- didSet {
- setAllowsExternalPlayback(allowsExternalPlayback)
- }
- }
-
- @objc var usesExternalPlaybackWhileExternalScreenIsActive: Bool = true {
- didSet {
- setUsesExternalPlaybackWhileExternalScreenIsActive(usesExternalPlaybackWhileExternalScreenIsActive)
- }
- }
-
- @objc var subtitleBottomOffset: NSNumber = 60 {
- didSet {
- print("KSPlayerView: [PROP SETTER] subtitleBottomOffset setter called with value: \(subtitleBottomOffset.floatValue)")
- updateSubtitlePositioning()
- }
- }
-
- @objc var subtitleFontSize: NSNumber = 16 {
- didSet {
- let size = CGFloat(truncating: subtitleFontSize)
- print("KSPlayerView: [PROP SETTER] subtitleFontSize setter called with value: \(size)")
- updateSubtitleFont(size: size)
- }
- }
-
- @objc var resizeMode: NSString = "contain" {
- didSet {
- print("KSPlayerView: [PROP SETTER] resizeMode setter called with value: \(resizeMode)")
- applyVideoGravity()
- }
- }
override init(frame: CGRect) {
super.init(frame: frame)
setupPlayerView()
- setupCustomSubtitlePositioning()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupPlayerView()
- setupCustomSubtitlePositioning()
}
private func setupPlayerView() {
@@ -131,113 +88,9 @@ class KSPlayerView: UIView {
playerView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
- // Ensure subtitle views are visible and on top
- // KSPlayer's subtitleLabel renders internal subtitles
- playerView.subtitleLabel.isHidden = false
- playerView.subtitleBackView.isHidden = false
- // Move subtitle view to main container for independence from video transformations
- playerView.subtitleBackView.removeFromSuperview()
- self.addSubview(playerView.subtitleBackView)
- self.bringSubviewToFront(playerView.subtitleBackView)
- print("KSPlayerView: [SETUP] Subtitle views made visible")
- print("KSPlayerView: [SETUP] subtitleLabel.isHidden: \(playerView.subtitleLabel.isHidden)")
- print("KSPlayerView: [SETUP] subtitleBackView.isHidden: \(playerView.subtitleBackView.isHidden)")
- print("KSPlayerView: [SETUP] subtitleLabel.frame: \(playerView.subtitleLabel.frame)")
- print("KSPlayerView: [SETUP] subtitleBackView.frame: \(playerView.subtitleBackView.frame)")
-
// Set up player delegates and callbacks
setupPlayerCallbacks()
}
-
- private func setupCustomSubtitlePositioning() {
- // Wait for the player view to be fully set up before modifying subtitle positioning
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
- self?.adjustSubtitlePositioning()
- }
- }
-
- private func adjustSubtitlePositioning() {
- // Remove existing constraints for subtitle positioning
- playerView.subtitleBackView.removeFromSuperview()
- // Add subtitle view to main container (self) instead of playerView to make it independent of video transformations
- self.addSubview(playerView.subtitleBackView)
- // Ensure subtitles are always on top of video
- self.bringSubviewToFront(playerView.subtitleBackView)
-
- // Re-add subtitle label to subtitle back view
- playerView.subtitleBackView.addSubview(playerView.subtitleLabel)
-
- // Set up new constraints for better mobile visibility
- playerView.subtitleBackView.translatesAutoresizingMaskIntoConstraints = false
- playerView.subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
-
- // Store the bottom constraint reference for dynamic updates
- // Constrain to main container (self) instead of playerView to make subtitles independent of video transformations
- subtitleBottomConstraint = playerView.subtitleBackView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -CGFloat(subtitleBottomOffset.floatValue))
-
- NSLayoutConstraint.activate([
- // Position subtitles using dynamic offset from React Native
- subtitleBottomConstraint!,
- playerView.subtitleBackView.centerXAnchor.constraint(equalTo: self.centerXAnchor),
- playerView.subtitleBackView.widthAnchor.constraint(lessThanOrEqualTo: self.widthAnchor, constant: -20),
- playerView.subtitleBackView.heightAnchor.constraint(lessThanOrEqualToConstant: 100),
-
- // Subtitle label constraints within the back view
- playerView.subtitleLabel.leadingAnchor.constraint(equalTo: playerView.subtitleBackView.leadingAnchor, constant: 10),
- playerView.subtitleLabel.trailingAnchor.constraint(equalTo: playerView.subtitleBackView.trailingAnchor, constant: -10),
- playerView.subtitleLabel.topAnchor.constraint(equalTo: playerView.subtitleBackView.topAnchor, constant: 5),
- playerView.subtitleLabel.bottomAnchor.constraint(equalTo: playerView.subtitleBackView.bottomAnchor, constant: -5),
- ])
-
- // Ensure subtitle views are initially hidden
- playerView.subtitleBackView.isHidden = true
- playerView.subtitleLabel.isHidden = true
-
- print("KSPlayerView: Custom subtitle positioning applied - positioned \(subtitleBottomOffset.floatValue)pts from bottom for mobile visibility")
- }
-
- private func updateSubtitlePositioning() {
- // Update subtitle positioning when offset changes
- print("KSPlayerView: [OFFSET UPDATE] subtitleBottomOffset changed to: \(subtitleBottomOffset.floatValue)")
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- print("KSPlayerView: [OFFSET UPDATE] Applying new positioning with offset: \(self.subtitleBottomOffset.floatValue)")
-
- // Update the existing constraint instead of recreating everything
- if let bottomConstraint = self.subtitleBottomConstraint {
- bottomConstraint.constant = -CGFloat(self.subtitleBottomOffset.floatValue)
- print("KSPlayerView: [OFFSET UPDATE] Updated constraint constant to: \(bottomConstraint.constant)")
- } else {
- // Fallback: recreate positioning if constraint reference is missing
- print("KSPlayerView: [OFFSET UPDATE] No constraint reference found, recreating positioning")
- self.adjustSubtitlePositioning()
- }
- }
- }
-
- private func applyVideoGravity() {
- print("KSPlayerView: [VIDEO GRAVITY] Applying resizeMode: \(resizeMode)")
-
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
-
- let contentMode: UIViewContentMode
- switch self.resizeMode.lowercased {
- case "cover":
- contentMode = .scaleAspectFill
- case "stretch":
- contentMode = .scaleToFill
- case "contain":
- contentMode = .scaleAspectFit
- default:
- contentMode = .scaleAspectFit
- }
-
- // Set contentMode on the player itself, not the view
- self.playerView.playerLayer?.player.contentMode = contentMode
- print("KSPlayerView: [VIDEO GRAVITY] Set player contentMode to: \(contentMode)")
- }
- }
private func setupPlayerCallbacks() {
// Configure KSOptions (use static defaults where required)
@@ -250,18 +103,6 @@ class KSPlayerView: UIView {
#endif
}
- private func updateSubtitleFont(size: CGFloat) {
- // Update KSPlayer subtitle font size via SubtitleModel
- SubtitleModel.textFontSize = size
- // Also directly apply to current label for immediate effect
- playerView.subtitleLabel.font = SubtitleModel.textFont
- // Re-render current subtitle parts to apply font
- if let currentTime = playerView.playerLayer?.player.currentPlaybackTime {
- _ = playerView.srtControl.subtitle(currentTime: currentTime)
- }
- print("KSPlayerView: [FONT UPDATE] Applied subtitle font size: \(size)")
- }
-
func setSource(_ source: NSDictionary) {
currentSource = source
@@ -310,15 +151,7 @@ class KSPlayerView: UIView {
playerView.set(resource: resource)
// Set up delegate after setting the resource
- if let playerLayer = playerView.playerLayer {
- playerLayer.delegate = self
- print("KSPlayerView: Delegate set successfully on playerLayer")
-
- // Apply video gravity after player is set up
- applyVideoGravity()
- } else {
- print("KSPlayerView: ERROR - playerLayer is nil, cannot set delegate")
- }
+ playerView.playerLayer?.delegate = self
// Apply current state
if isPaused {
@@ -328,12 +161,6 @@ class KSPlayerView: UIView {
}
setVolume(currentVolume)
-
- // Ensure AirPlay is properly configured after setting source
- DispatchQueue.main.async {
- self.setAllowsExternalPlayback(self.allowsExternalPlayback)
- self.setUsesExternalPlaybackWhileExternalScreenIsActive(self.usesExternalPlaybackWhileExternalScreenIsActive)
- }
}
private func createOptions(with headers: [String: String]) -> KSOptions {
@@ -456,7 +283,7 @@ class KSPlayerView: UIView {
print("KSPlayerView: Successfully selected audio track \(trackId)")
// Verify the selection worked
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
let tracksAfter = player.tracks(mediaType: .audio)
for (index, track) in tracksAfter.enumerated() {
print("KSPlayerView: After selection - Track \(index) (ID: \(track.trackID)) isEnabled: \(track.isEnabled)")
@@ -494,110 +321,44 @@ class KSPlayerView: UIView {
}
func setTextTrack(_ trackId: Int) {
- print("KSPlayerView: [SET TEXT TRACK] Starting setTextTrack with trackId: \(trackId)")
-
- // Wait slightly longer than the 1-second delay for subtitle data source connection
- // This ensures srtControl.addSubtitle(dataSouce:) has been called in VideoPlayerView
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
- guard let self = self else {
- print("KSPlayerView: [SET TEXT TRACK] self is nil, aborting")
- return
+ if let player = playerView.playerLayer?.player {
+ let textTracks = player.tracks(mediaType: .subtitle)
+ print("KSPlayerView: Available text tracks count: \(textTracks.count)")
+ print("KSPlayerView: Requested text track ID: \(trackId)")
+
+ // First try to find track by trackID (proper way)
+ var selectedTrack: MediaPlayerTrack? = nil
+ var trackIndex: Int = -1
+
+ // Try to find by exact trackID match
+ if let track = textTracks.first(where: { Int($0.trackID) == trackId }) {
+ selectedTrack = track
+ trackIndex = textTracks.firstIndex(where: { $0.trackID == track.trackID }) ?? -1
+ print("KSPlayerView: Found text track by trackID \(trackId) at index \(trackIndex)")
+ }
+ // Fallback: treat trackId as array index
+ else if trackId >= 0 && trackId < textTracks.count {
+ selectedTrack = textTracks[trackId]
+ trackIndex = trackId
+ print("KSPlayerView: Found text track by array index \(trackId) (fallback)")
}
-
- print("KSPlayerView: [SET TEXT TRACK] Executing delayed track selection")
-
- if let player = self.playerView.playerLayer?.player {
- let textTracks = player.tracks(mediaType: .subtitle)
- print("KSPlayerView: Available text tracks count: \(textTracks.count)")
- print("KSPlayerView: Requested text track ID: \(trackId)")
- // First try to find track by trackID (proper way)
- var selectedTrack: MediaPlayerTrack? = nil
- var trackIndex: Int = -1
+ if let track = selectedTrack {
+ print("KSPlayerView: Selecting text track \(trackId) (index: \(trackIndex)): '\(track.name)' (ID: \(track.trackID))")
- // Try to find by exact trackID match
- if let track = textTracks.first(where: { Int($0.trackID) == trackId }) {
- selectedTrack = track
- trackIndex = textTracks.firstIndex(where: { $0.trackID == track.trackID }) ?? -1
- print("KSPlayerView: Found text track by trackID \(trackId) at index \(trackIndex)")
- }
- // Fallback: treat trackId as array index
- else if trackId >= 0 && trackId < textTracks.count {
- selectedTrack = textTracks[trackId]
- trackIndex = trackId
- print("KSPlayerView: Found text track by array index \(trackId) (fallback)")
- }
+ // Use KSPlayer's select method which properly handles track selection
+ player.select(track: track)
- if let track = selectedTrack {
- print("KSPlayerView: Selecting text track \(trackId) (index: \(trackIndex)): '\(track.name)' (ID: \(track.trackID))")
-
- // First disable all tracks to ensure only one is active
- for t in textTracks {
- t.isEnabled = false
- }
-
- // Use KSPlayer's select method which properly handles track selection
- player.select(track: track)
-
- // Sync srtControl with player track selection
- // Find the corresponding SubtitleInfo in srtControl and select it
- if let matchingSubtitleInfo = self.playerView.srtControl.subtitleInfos.first(where: { subtitleInfo in
- // Try to match by name or track ID
- subtitleInfo.name.lowercased() == track.name.lowercased() ||
- subtitleInfo.subtitleID == String(track.trackID)
- }) {
- print("KSPlayerView: Found matching SubtitleInfo: \(matchingSubtitleInfo.name) (ID: \(matchingSubtitleInfo.subtitleID))")
- self.playerView.srtControl.selectedSubtitleInfo = matchingSubtitleInfo
- print("KSPlayerView: Set srtControl.selectedSubtitleInfo to: \(matchingSubtitleInfo.name)")
- } else {
- print("KSPlayerView: No matching SubtitleInfo found for track '\(track.name)' (ID: \(track.trackID))")
- print("KSPlayerView: Available SubtitleInfos:")
- for (index, info) in self.playerView.srtControl.subtitleInfos.enumerated() {
- print("KSPlayerView: [\(index)] name='\(info.name)', subtitleID='\(info.subtitleID)'")
- }
- }
-
- // Ensure subtitle views are visible after selection
- self.playerView.subtitleLabel.isHidden = false
- self.playerView.subtitleBackView.isHidden = false
-
- // Debug: Check the enabled state of all tracks after selection
- print("KSPlayerView: Track states after selection:")
- for (index, t) in textTracks.enumerated() {
- print("KSPlayerView: Track \(index): ID=\(t.trackID), Name='\(t.name)', Enabled=\(t.isEnabled)")
- }
-
- // Verify the selection worked after a delay
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
- let tracksAfter = player.tracks(mediaType: .subtitle)
- print("KSPlayerView: Verification after subtitle selection:")
- for (index, track) in tracksAfter.enumerated() {
- print("KSPlayerView: Track \(index) (ID: \(track.trackID)) isEnabled: \(track.isEnabled)")
- }
-
- // Also verify srtControl selection
- if let selectedInfo = self.playerView.srtControl.selectedSubtitleInfo {
- print("KSPlayerView: srtControl.selectedSubtitleInfo: \(selectedInfo.name) (ID: \(selectedInfo.subtitleID))")
- } else {
- print("KSPlayerView: srtControl.selectedSubtitleInfo is nil")
- }
- }
-
- print("KSPlayerView: Successfully selected text track \(trackId)")
- } else if trackId == -1 {
- // Disable all subtitles
- for track in textTracks { track.isEnabled = false }
- // Clear srtControl selection and hide subtitle views
- self.playerView.srtControl.selectedSubtitleInfo = nil
- self.playerView.subtitleLabel.isHidden = true
- self.playerView.subtitleBackView.isHidden = true
- print("KSPlayerView: Disabled all text tracks and cleared srtControl selection")
- } else {
- print("KSPlayerView: Text track \(trackId) not found. Available track IDs: \(textTracks.map { Int($0.trackID) }), array indices: 0..\(textTracks.count - 1)")
- }
+ print("KSPlayerView: Successfully selected text track \(trackId)")
+ } else if trackId == -1 {
+ // Disable all subtitles
+ for track in textTracks { track.isEnabled = false }
+ print("KSPlayerView: Disabled all text tracks")
} else {
- print("KSPlayerView: No player available for text track selection")
+ print("KSPlayerView: Text track \(trackId) not found. Available track IDs: \(textTracks.map { Int($0.trackID) }), array indices: 0..\(textTracks.count - 1)")
}
+ } else {
+ print("KSPlayerView: No player available for text track selection")
}
}
@@ -621,27 +382,10 @@ class KSPlayerView: UIView {
}
let textTracks = player.tracks(mediaType: .subtitle).enumerated().map { index, track in
- // Create a better display name for subtitles
- var displayName = track.name
- if displayName.isEmpty || displayName == "Unknown" {
- if let language = track.language, !language.isEmpty && language != "Unknown" {
- displayName = language
- } else if let languageCode = track.languageCode, !languageCode.isEmpty {
- displayName = languageCode.uppercased()
- } else {
- displayName = "Subtitle \(index + 1)"
- }
- }
-
- // Add language info if not already in the name
- if let language = track.language, !language.isEmpty && language != "Unknown" && !displayName.lowercased().contains(language.lowercased()) {
- displayName += " (\(language))"
- }
-
return [
"id": Int(track.trackID), // Use actual track ID, not array index
"index": index, // Keep index for backward compatibility
- "name": displayName,
+ "name": track.name,
"language": track.language ?? "Unknown",
"languageCode": track.languageCode ?? "",
"isEnabled": track.isEnabled,
@@ -655,94 +399,6 @@ class KSPlayerView: UIView {
]
}
- // AirPlay methods
- func setAllowsExternalPlayback(_ allows: Bool) {
- print("[KSPlayerView] Setting allowsExternalPlayback: \(allows)")
- playerView.playerLayer?.player.allowsExternalPlayback = allows
- }
-
- func setUsesExternalPlaybackWhileExternalScreenIsActive(_ uses: Bool) {
- print("[KSPlayerView] Setting usesExternalPlaybackWhileExternalScreenIsActive: \(uses)")
- playerView.playerLayer?.player.usesExternalPlaybackWhileExternalScreenIsActive = uses
- }
-
- func showAirPlayPicker() {
- print("[KSPlayerView] showAirPlayPicker called")
-
- DispatchQueue.main.async {
- // Create a temporary route picker view for triggering AirPlay
- let routePickerView = AVRoutePickerView()
- routePickerView.tintColor = .white
- routePickerView.alpha = 0.01 // Nearly invisible but still interactive
-
- // Find the current view controller
- guard let viewController = self.findHostViewController() else {
- print("[KSPlayerView] Could not find view controller for AirPlay picker")
- return
- }
-
- // Add to the view controller's view temporarily
- viewController.view.addSubview(routePickerView)
-
- // Position it off-screen but still in the view hierarchy
- routePickerView.frame = CGRect(x: -100, y: -100, width: 44, height: 44)
-
- // Force layout
- viewController.view.setNeedsLayout()
- viewController.view.layoutIfNeeded()
-
- // Wait a bit for the view to be ready, then trigger
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
- // Find and trigger the AirPlay button
- self.triggerAirPlayButton(routePickerView)
-
- // Clean up after a delay
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
- routePickerView.removeFromSuperview()
- print("[KSPlayerView] Cleaned up temporary AirPlay picker")
- }
- }
- }
- }
-
- private func triggerAirPlayButton(_ routePickerView: AVRoutePickerView) {
- // Recursively find the button in the route picker view
- func findButton(in view: UIView) -> UIButton? {
- if let button = view as? UIButton {
- return button
- }
- for subview in view.subviews {
- if let button = findButton(in: subview) {
- return button
- }
- }
- return nil
- }
-
- if let button = findButton(in: routePickerView) {
- print("[KSPlayerView] Found AirPlay button, triggering tap")
- button.sendActions(for: .touchUpInside)
- } else {
- print("[KSPlayerView] Could not find AirPlay button in route picker")
- }
- }
-
- func getAirPlayState() -> [String: Any] {
- guard let player = playerView.playerLayer?.player else {
- return [
- "allowsExternalPlayback": false,
- "usesExternalPlaybackWhileExternalScreenIsActive": false,
- "isExternalPlaybackActive": false
- ]
- }
-
- return [
- "allowsExternalPlayback": player.allowsExternalPlayback,
- "usesExternalPlaybackWhileExternalScreenIsActive": player.usesExternalPlaybackWhileExternalScreenIsActive,
- "isExternalPlaybackActive": player.isExternalPlaybackActive
- ]
- }
-
// Get current player state for React Native
func getCurrentState() -> [String: Any] {
guard let player = playerView.playerLayer?.player else {
@@ -763,81 +419,6 @@ extension KSPlayerView: KSPlayerLayerDelegate {
func player(layer: KSPlayerLayer, state: KSPlayerState) {
switch state {
case .readyToPlay:
- // Ensure AirPlay is properly configured when player is ready
- layer.player.allowsExternalPlayback = allowsExternalPlayback
- layer.player.usesExternalPlaybackWhileExternalScreenIsActive = usesExternalPlaybackWhileExternalScreenIsActive
-
- // Debug: Check subtitle data source connection
- let hasSubtitleDataSource = layer.player.subtitleDataSouce != nil
- print("KSPlayerView: [READY TO PLAY] subtitle data source available: \(hasSubtitleDataSource)")
-
- // Ensure subtitle views are visible
- playerView.subtitleLabel.isHidden = false
- playerView.subtitleBackView.isHidden = false
- print("KSPlayerView: [READY TO PLAY] Verified subtitle views are visible")
- print("KSPlayerView: [READY TO PLAY] subtitleLabel.isHidden: \(playerView.subtitleLabel.isHidden)")
- print("KSPlayerView: [READY TO PLAY] subtitleBackView.isHidden: \(playerView.subtitleBackView.isHidden)")
- print("KSPlayerView: [READY TO PLAY] subtitleLabel.frame: \(playerView.subtitleLabel.frame)")
- print("KSPlayerView: [READY TO PLAY] subtitleBackView.frame: \(playerView.subtitleBackView.frame)")
-
- // Manually connect subtitle data source to srtControl (this is the missing piece!)
- if let subtitleDataSouce = layer.player.subtitleDataSouce {
- print("KSPlayerView: [READY TO PLAY] Connecting subtitle data source to srtControl")
- print("KSPlayerView: [READY TO PLAY] subtitleDataSouce type: \(type(of: subtitleDataSouce))")
-
- // Check if subtitle data source has any subtitle infos
- print("KSPlayerView: [READY TO PLAY] subtitleDataSouce has \(subtitleDataSouce.infos.count) subtitle infos")
-
- for (index, info) in subtitleDataSouce.infos.enumerated() {
- print("KSPlayerView: [READY TO PLAY] subtitleDataSouce info[\(index)]: ID=\(info.subtitleID), Name='\(info.name)', Enabled=\(info.isEnabled)")
- }
- // Wait 1 second like the original KSPlayer code does
- DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) { [weak self] in
- guard let self = self else { return }
- print("KSPlayerView: [READY TO PLAY] About to add subtitle data source to srtControl")
- self.playerView.srtControl.addSubtitle(dataSouce: subtitleDataSouce)
- print("KSPlayerView: [READY TO PLAY] Subtitle data source connected to srtControl")
- print("KSPlayerView: [READY TO PLAY] srtControl.subtitleInfos.count: \(self.playerView.srtControl.subtitleInfos.count)")
-
- // Log all subtitle infos
- for (index, info) in self.playerView.srtControl.subtitleInfos.enumerated() {
- print("KSPlayerView: [READY TO PLAY] SubtitleInfo[\(index)]: name=\(info.name), isEnabled=\(info.isEnabled), subtitleID=\(info.subtitleID)")
- }
-
- // Try to manually trigger subtitle parsing for the current time
- let currentTime = self.playerView.playerLayer?.player.currentPlaybackTime ?? 0
- print("KSPlayerView: [READY TO PLAY] Current playback time: \(currentTime)")
-
- // Force subtitle search for current time
- let hasSubtitle = self.playerView.srtControl.subtitle(currentTime: currentTime)
- print("KSPlayerView: [READY TO PLAY] Manual subtitle search result: \(hasSubtitle)")
- print("KSPlayerView: [READY TO PLAY] Parts count after manual search: \(self.playerView.srtControl.parts.count)")
-
- if let firstPart = self.playerView.srtControl.parts.first {
- print("KSPlayerView: [READY TO PLAY] Found subtitle part: start=\(firstPart.start), end=\(firstPart.end), text='\(firstPart.text?.string ?? "nil")'")
- }
-
- // Auto-select first enabled subtitle if none selected
- if self.playerView.srtControl.selectedSubtitleInfo == nil {
- self.playerView.srtControl.selectedSubtitleInfo = self.playerView.srtControl.subtitleInfos.first { $0.isEnabled }
- if let selected = self.playerView.srtControl.selectedSubtitleInfo {
- print("KSPlayerView: [READY TO PLAY] Auto-selected subtitle: \(selected.name)")
- } else {
- print("KSPlayerView: [READY TO PLAY] No enabled subtitle found for auto-selection")
- }
- } else {
- print("KSPlayerView: [READY TO PLAY] Subtitle already selected: \(self.playerView.srtControl.selectedSubtitleInfo?.name ?? "unknown")")
- }
- }
- } else {
- print("KSPlayerView: [READY TO PLAY] ERROR: No subtitle data source available")
- }
-
- // Determine player backend type
- let uriString = currentSource?["uri"] as? String
- let isMKV = uriString?.lowercased().contains(".mkv") ?? false
- let playerBackend = isMKV ? "KSMEPlayer" : "KSAVPlayer"
-
// Send onLoad event to React Native with track information
let p = layer.player
let tracks = getAvailableTracks()
@@ -849,8 +430,7 @@ extension KSPlayerView: KSPlayerLayerDelegate {
"height": p.naturalSize.height
],
"audioTracks": tracks["audioTracks"] ?? [],
- "textTracks": tracks["textTracks"] ?? [],
- "playerBackend": playerBackend
+ "textTracks": tracks["textTracks"] ?? []
])
case .buffering:
sendEvent("onBuffering", ["isBuffering": true])
@@ -867,86 +447,13 @@ extension KSPlayerView: KSPlayerLayerDelegate {
}
func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) {
- // Debug: Confirm delegate method is being called
- if currentTime.truncatingRemainder(dividingBy: 10.0) < 0.1 {
- print("KSPlayerView: [DELEGATE CALLED] time=\(currentTime), total=\(totalTime)")
- }
-
- // Manually implement subtitle rendering logic from VideoPlayerView
- // This is the critical missing piece that was preventing subtitle rendering
-
- // Debug: Check srtControl state
- let subtitleInfoCount = playerView.srtControl.subtitleInfos.count
- let selectedSubtitle = playerView.srtControl.selectedSubtitleInfo
-
- // Always log subtitle state every 10 seconds to see when it gets populated
- if currentTime.truncatingRemainder(dividingBy: 10.0) < 0.1 {
- print("KSPlayerView: [SUBTITLE DEBUG] time=\(currentTime.truncatingRemainder(dividingBy: 10.0)), subtitleInfos=\(subtitleInfoCount), selected=\(selectedSubtitle?.name ?? "none")")
-
- // Also check if player has subtitle data source
- let player = layer.player
- let hasSubtitleDataSource = player.subtitleDataSouce != nil
- print("KSPlayerView: [SUBTITLE DEBUG] player has subtitle data source: \(hasSubtitleDataSource)")
-
- // Log subtitle view states
- print("KSPlayerView: [SUBTITLE DEBUG] subtitleLabel.isHidden: \(playerView.subtitleLabel.isHidden)")
- print("KSPlayerView: [SUBTITLE DEBUG] subtitleBackView.isHidden: \(playerView.subtitleBackView.isHidden)")
- print("KSPlayerView: [SUBTITLE DEBUG] subtitleLabel.text: '\(playerView.subtitleLabel.text ?? "nil")'")
- print("KSPlayerView: [SUBTITLE DEBUG] subtitleLabel.attributedText: \(playerView.subtitleLabel.attributedText != nil ? "exists" : "nil")")
- print("KSPlayerView: [SUBTITLE DEBUG] subtitleBackView.image: \(playerView.subtitleBackView.image != nil ? "exists" : "nil")")
-
- // Log all subtitle infos
- for (index, info) in playerView.srtControl.subtitleInfos.enumerated() {
- print("KSPlayerView: [SUBTITLE DEBUG] SubtitleInfo[\(index)]: name=\(info.name), isEnabled=\(info.isEnabled)")
- }
- }
-
- let hasSubtitleParts = playerView.srtControl.subtitle(currentTime: currentTime)
-
- // Debug: Check subtitle timing every 10 seconds
- if currentTime.truncatingRemainder(dividingBy: 10.0) < 0.1 && subtitleInfoCount > 0 {
- print("KSPlayerView: [SUBTITLE TIMING] time=\(currentTime), hasParts=\(hasSubtitleParts), partsCount=\(playerView.srtControl.parts.count)")
- if let firstPart = playerView.srtControl.parts.first {
- print("KSPlayerView: [SUBTITLE TIMING] firstPart start=\(firstPart.start), end=\(firstPart.end)")
- print("KSPlayerView: [SUBTITLE TIMING] firstPart text='\(firstPart.text?.string ?? "nil")'")
- print("KSPlayerView: [SUBTITLE TIMING] firstPart hasImage=\(firstPart.image != nil)")
- } else {
- print("KSPlayerView: [SUBTITLE TIMING] No parts available")
- }
- }
-
- if hasSubtitleParts {
- if let part = playerView.srtControl.parts.first {
- print("KSPlayerView: [SUBTITLE RENDER] time=\(currentTime), text='\(part.text?.string ?? "nil")', hasImage=\(part.image != nil)")
- playerView.subtitleBackView.image = part.image
- playerView.subtitleLabel.attributedText = part.text
- playerView.subtitleBackView.isHidden = false
- playerView.subtitleLabel.isHidden = false
- print("KSPlayerView: [SUBTITLE RENDER] Set subtitle text and made views visible")
- print("KSPlayerView: [SUBTITLE RENDER] subtitleLabel.isHidden after: \(playerView.subtitleLabel.isHidden)")
- print("KSPlayerView: [SUBTITLE RENDER] subtitleBackView.isHidden after: \(playerView.subtitleBackView.isHidden)")
- } else {
- print("KSPlayerView: [SUBTITLE RENDER] hasParts=true but no parts available - hiding views")
- playerView.subtitleBackView.image = nil
- playerView.subtitleLabel.attributedText = nil
- playerView.subtitleBackView.isHidden = true
- playerView.subtitleLabel.isHidden = true
- }
- } else {
- // Only log this occasionally to avoid spam
- if currentTime.truncatingRemainder(dividingBy: 30.0) < 0.1 {
- print("KSPlayerView: [SUBTITLE RENDER] time=\(currentTime), hasParts=false - no subtitle at this time")
- }
- }
-
let p = layer.player
// Ensure we have valid duration before sending progress updates
if totalTime > 0 {
sendEvent("onProgress", [
"currentTime": currentTime,
"duration": totalTime,
- "bufferTime": p.playableTime,
- "airPlayState": getAirPlayState()
+ "bufferTime": p.playableTime
])
}
}
diff --git a/ios/KSPlayerViewManager.swift b/ios/KSPlayerViewManager.swift
index 733a842..ce9e3f0 100644
--- a/ios/KSPlayerViewManager.swift
+++ b/ios/KSPlayerViewManager.swift
@@ -12,7 +12,7 @@ import React
@objc(KSPlayerViewManager)
class KSPlayerViewManager: RCTViewManager {
- // Not needed for RCTViewManager-based views; events are exported via Objective-C externs in KSPlayerManager.m
+ // Not needed for RCTViewManager-based views; events are exported via RCT_EXPORT_VIEW_PROPERTY
override func view() -> UIView! {
let view = KSPlayerView()
view.viewManager = self
@@ -96,44 +96,4 @@ class KSPlayerViewManager: RCTViewManager {
}
}
}
-
- // AirPlay methods
- @objc func setAllowsExternalPlayback(_ node: NSNumber, allows: Bool) {
- DispatchQueue.main.async {
- if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
- view.setAllowsExternalPlayback(allows)
- }
- }
- }
-
- @objc func setUsesExternalPlaybackWhileExternalScreenIsActive(_ node: NSNumber, uses: Bool) {
- DispatchQueue.main.async {
- if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
- view.setUsesExternalPlaybackWhileExternalScreenIsActive(uses)
- }
- }
- }
-
- @objc func getAirPlayState(_ node: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
- DispatchQueue.main.async {
- if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
- let airPlayState = view.getAirPlayState()
- resolve(airPlayState)
- } else {
- reject("NO_VIEW", "KSPlayerView not found", nil)
- }
- }
- }
-
- @objc func showAirPlayPicker(_ node: NSNumber) {
- print("[KSPlayerViewManager] showAirPlayPicker called for node: \(node)")
- DispatchQueue.main.async {
- if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
- print("[KSPlayerViewManager] Found KSPlayerView, calling showAirPlayPicker")
- view.showAirPlayPicker()
- } else {
- print("[KSPlayerViewManager] Could not find KSPlayerView for node: \(node)")
- }
- }
- }
-}
\ No newline at end of file
+}
diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj
index 4bf304a..1d96622 100644
--- a/ios/Nuvio.xcodeproj/project.pbxproj
+++ b/ios/Nuvio.xcodeproj/project.pbxproj
@@ -460,8 +460,8 @@
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
- PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
- PRODUCT_NAME = "Nuvio";
+ PRODUCT_BUNDLE_IDENTIFIER = com.nuviohub.app;
+ PRODUCT_NAME = Nuvio;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -492,8 +492,8 @@
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
- PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
- PRODUCT_NAME = "Nuvio";
+ PRODUCT_BUNDLE_IDENTIFIER = com.nuviohub.app;
+ PRODUCT_NAME = Nuvio;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
diff --git a/ios/Nuvio.xcodeproj/xcshareddata/xcschemes/Nuvio.xcscheme b/ios/Nuvio.xcodeproj/xcshareddata/xcschemes/Nuvio.xcscheme
index d56adf8..60f9eb0 100644
--- a/ios/Nuvio.xcodeproj/xcshareddata/xcschemes/Nuvio.xcscheme
+++ b/ios/Nuvio.xcodeproj/xcshareddata/xcschemes/Nuvio.xcscheme
@@ -82,7 +82,7 @@
buildConfiguration = "Debug">
diff --git a/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png
index 288b100..e9a189f 100644
Binary files a/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png and b/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ
diff --git a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png
index 8649d70..efcdf22 100644
Binary files a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png and b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png differ
diff --git a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png
index 8649d70..efcdf22 100644
Binary files a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png and b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png differ
diff --git a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png
index 8649d70..efcdf22 100644
Binary files a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png and b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png differ
diff --git a/ios/Nuvio/Info.plist b/ios/Nuvio/Info.plist
index c4ebe4b..4ab9b8d 100644
--- a/ios/Nuvio/Info.plist
+++ b/ios/Nuvio/Info.plist
@@ -1,101 +1,98 @@
-
- CADisableMinimumFrameDurationOnPhone
-
- CFBundleDevelopmentRegion
- $(DEVELOPMENT_LANGUAGE)
- CFBundleDisplayName
- Nuvio
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- $(PRODUCT_NAME)
- CFBundlePackageType
- $(PRODUCT_BUNDLE_PACKAGE_TYPE)
- CFBundleShortVersionString
- 1.2.6
- CFBundleSignature
- ????
- CFBundleURLTypes
-
-
- CFBundleURLSchemes
-
- nuvio
- com.nuvio.app
-
-
-
- CFBundleURLSchemes
-
- exp+nuvio
-
-
-
- CFBundleVersion
- 21
- LSMinimumSystemVersion
- 12.0
- LSRequiresIPhoneOS
-
- LSSupportsOpeningDocumentsInPlace
-
- NSAppTransportSecurity
-
- NSAllowsArbitraryLoads
-
-
- NSBonjourServices
-
- _http._tcp
-
- NSLocalNetworkUsageDescription
- Allow $(PRODUCT_NAME) to access your local network
- NSMicrophoneUsageDescription
- This app does not require microphone access.
- RCTNewArchEnabled
-
- RCTRootViewBackgroundColor
- 4278322180
- UIBackgroundModes
-
- audio
-
- UIFileSharingEnabled
-
- UILaunchStoryboardName
- SplashScreen
- UIRequiredDeviceCapabilities
-
- arm64
-
- UIRequiresFullScreen
-
- UIStatusBarStyle
- UIStatusBarStyleDefault
- UISupportedInterfaceOrientations
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UISupportedInterfaceOrientations~ipad
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UIUserInterfaceStyle
- Dark
- UIViewControllerBasedStatusBarAppearance
-
-
-
\ No newline at end of file
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Nuvio
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ 1.2.5
+ CFBundleSignature
+ ????
+ CFBundleURLTypes
+
+
+ CFBundleURLSchemes
+
+ nuvio
+ com.nuvio.app
+
+
+
+ CFBundleURLSchemes
+
+ exp+nuvio
+
+
+
+ CFBundleVersion
+ 20
+ LSMinimumSystemVersion
+ 12.0
+ LSRequiresIPhoneOS
+
+ LSSupportsOpeningDocumentsInPlace
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+ NSBonjourServices
+
+ _http._tcp
+
+ RCTNewArchEnabled
+
+ RCTRootViewBackgroundColor
+ 4278322180
+ UIBackgroundModes
+
+ audio
+ fetch
+
+ UIFileSharingEnabled
+
+ UILaunchStoryboardName
+ SplashScreen
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UIRequiresFullScreen
+
+ UIStatusBarStyle
+ UIStatusBarStyleDefault
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UIUserInterfaceStyle
+ Dark
+ UIViewControllerBasedStatusBarAppearance
+
+
+
diff --git a/ios/Nuvio/NuvioRelease.entitlements b/ios/Nuvio/NuvioRelease.entitlements
index a0bc443..0c67376 100644
--- a/ios/Nuvio/NuvioRelease.entitlements
+++ b/ios/Nuvio/NuvioRelease.entitlements
@@ -1,10 +1,5 @@
-
- aps-environment
- development
- com.apple.developer.associated-domains
-
-
-
\ No newline at end of file
+
+
diff --git a/ios/Nuvio/Supporting/Expo.plist b/ios/Nuvio/Supporting/Expo.plist
index c48927a..f325c17 100644
--- a/ios/Nuvio/Supporting/Expo.plist
+++ b/ios/Nuvio/Supporting/Expo.plist
@@ -9,7 +9,7 @@
EXUpdatesLaunchWaitMs
30000
EXUpdatesRuntimeVersion
- 1.2.6
+ 1.2.5
EXUpdatesURL
https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest
diff --git a/nuvio-source.json b/nuvio-source.json
index fbb9f8b..6deae32 100644
--- a/nuvio-source.json
+++ b/nuvio-source.json
@@ -30,14 +30,6 @@
"https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true"
],
"versions": [
- {
- "version": "1.2.6",
- "buildVersion": "21",
- "date": "2025-10-19",
- "localizedDescription": "# Version 1.2.6 – Update Notes\n\n### New Features\n- **AirPlay Support (iOS):**\n - Introduced native AirPlay support for compatible playback.\n - Note: **MKV format not supported** due to iOS restrictions.\n- **Last Streamed Link Caching:**\n - The last streamed link is now cached for **1 hour** on the stream screen for faster playback.\n- **KSPlayer Internal Subtitle Support (iOS):**\n - KSPlayer now supports internal subtitles for improved viewing experience.\n\n### PR Merge – Responsive Video Controls by @qarqun\n- **Responsive Sizing:**\n - All controls now scale dynamically based on screen width for better phone and tablet compatibility.\n- **Play/Pause Animation:**\n - Smooth crossfade transitions with scale effects for a polished user experience.\n- **Seek Animations:**\n - Arc sweep animation on seek buttons.\n - Number slide-out showing +10/-10 seconds.\n - Touch feedback with semi-transparent circle flash.\n- **Technical Details:**\n - Button sizes calculated as a percentage of screen width.\n - All animations use `useNativeDriver` for optimal performance.\n - Separate animation references to prevent animation conflicts.\n\n### Fixes\n- Fixed an issue where **Cinemeta Addon** reappeared even after removal from the addon screen.\n\n---\n\n## Changelog & Download\n[View on GitHub](https://github.com/tapframe/NuvioStreaming/releases/tag/1.2.6)\n\nSince the VLClib alone is 45 MB, Android APKs tend to be larger.\n\n🌐 [Official Website](https://tapframe.github.io/NuvioStreaming/)",
- "downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.2.6/Stable_1-2-6.ipa",
- "size": 25700000
- },
{
"version": "1.2.5",
"buildVersion": "20",
diff --git a/package-lock.json b/package-lock.json
index e9619c5..79e673e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,6 @@
"@expo/metro-runtime": "~6.1.2",
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.6",
- "@legendapp/list": "^2.0.13",
"@lottiefiles/dotlottie-react": "^0.6.5",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/blur": "^4.4.1",
@@ -30,6 +29,7 @@
"@react-navigation/stack": "^7.2.10",
"@sentry/react-native": "~7.3.0",
"@shopify/flash-list": "^2.1.0",
+ "@supabase/supabase-js": "^2.54.0",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.12.2",
@@ -67,7 +67,6 @@
"posthog-react-native": "^4.4.0",
"react": "19.1.0",
"react-native": "0.81.4",
- "react-native-boost": "^0.6.2",
"react-native-bottom-tabs": "^0.12.2",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "^1.11.0",
@@ -81,17 +80,17 @@
"react-native-svg": "15.12.1",
"react-native-url-polyfill": "^2.0.0",
"react-native-vector-icons": "^10.3.0",
- "react-native-video": "^6.17.0",
+ "react-native-video": "^6.12.0",
"react-native-web": "^0.21.0",
"react-native-wheel-color-picker": "^1.3.1",
- "react-native-worklets": "^0.6.1"
+ "react-native-worklets": "^0.6.1",
+ "toastify-react-native": "^7.2.3"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/crypto-js": "^4.2.2",
"@types/react": "~18.3.12",
"@types/react-native": "^0.72.8",
- "@types/react-native-vector-icons": "^6.4.18",
"babel-plugin-transform-remove-console": "^6.9.4",
"patch-package": "^8.0.1",
"react-native-svg-transformer": "^1.5.0",
@@ -2348,27 +2347,6 @@
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
"license": "MIT"
},
- "node_modules/@isaacs/balanced-match": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
- "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
- "license": "MIT",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/@isaacs/brace-expansion": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
- "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
- "license": "MIT",
- "dependencies": {
- "@isaacs/balanced-match": "^4.0.1"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -3658,6 +3636,80 @@
"@sinonjs/commons": "^3.0.0"
}
},
+ "node_modules/@supabase/auth-js": {
+ "version": "2.75.0",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.75.0.tgz",
+ "integrity": "sha512-J8TkeqCOMCV4KwGKVoxmEBuDdHRwoInML2vJilthOo7awVCro2SM+tOcpljORwuBQ1vHUtV62Leit+5wlxrNtw==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/node-fetch": "2.6.15"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.75.0",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.75.0.tgz",
+ "integrity": "sha512-18yk07Moj/xtQ28zkqswxDavXC3vbOwt1hDuYM3/7xPnwwpKnsmPyZ7bQ5th4uqiJzQ135t74La9tuaxBR6e7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/node-fetch": "2.6.15"
+ }
+ },
+ "node_modules/@supabase/node-fetch": {
+ "version": "2.6.15",
+ "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
+ "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ }
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.75.0",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.75.0.tgz",
+ "integrity": "sha512-YfBz4W/z7eYCFyuvHhfjOTTzRrQIvsMG2bVwJAKEVVUqGdzqfvyidXssLBG0Fqlql1zJFgtsPpK1n4meHrI7tg==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/node-fetch": "2.6.15"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.75.0",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.75.0.tgz",
+ "integrity": "sha512-B4Xxsf2NHd5cEnM6MGswOSPSsZKljkYXpvzKKmNxoUmNQOfB7D8HOa6NwHcUBSlxcjV+vIrYKcYXtavGJqeGrw==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/node-fetch": "2.6.15",
+ "@types/phoenix": "^1.6.6",
+ "@types/ws": "^8.18.1",
+ "ws": "^8.18.2"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.75.0",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.75.0.tgz",
+ "integrity": "sha512-wpJMYdfFDckDiHQaTpK+Ib14N/O2o0AAWWhguKvmmMurB6Unx17GGmYp5rrrqCTf8S1qq4IfIxTXxS4hzrUySg==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/node-fetch": "2.6.15"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.75.0",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.75.0.tgz",
+ "integrity": "sha512-8UN/vATSgS2JFuJlMVr51L3eUDz+j1m7Ww63wlvHLKULzCDaVWYzvacCjBTLW/lX/vedI2LBI4Vg+01G9ufsJQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.75.0",
+ "@supabase/functions-js": "2.75.0",
+ "@supabase/node-fetch": "2.6.15",
+ "@supabase/postgrest-js": "2.75.0",
+ "@supabase/realtime-js": "2.75.0",
+ "@supabase/storage-js": "2.75.0"
+ }
+ },
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
@@ -4182,6 +4234,12 @@
"undici-types": "~7.14.0"
}
},
+ "node_modules/@types/phoenix": {
+ "version": "1.6.6",
+ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
+ "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
+ "license": "MIT"
+ },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -4209,27 +4267,6 @@
"@types/react": "*"
}
},
- "node_modules/@types/react-native-vector-icons": {
- "version": "6.4.18",
- "resolved": "https://registry.npmjs.org/@types/react-native-vector-icons/-/react-native-vector-icons-6.4.18.tgz",
- "integrity": "sha512-YGlNWb+k5laTBHd7+uZowB9DpIK3SXUneZqAiKQaj1jnJCZM0x71GDim5JCTMi4IFkhc9m8H/Gm28T5BjyivUw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/react": "*",
- "@types/react-native": "^0.70"
- }
- },
- "node_modules/@types/react-native-vector-icons/node_modules/@types/react-native": {
- "version": "0.70.19",
- "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.70.19.tgz",
- "integrity": "sha512-c6WbyCgWTBgKKMESj/8b4w+zWcZSsCforson7UdXtXMecG3MxCinYi6ihhrHVPyUrVzORsvEzK8zg32z4pK6Sg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/react": "*"
- }
- },
"node_modules/@types/react-native-video": {
"version": "5.0.20",
"resolved": "https://registry.npmjs.org/@types/react-native-video/-/react-native-video-5.0.20.tgz",
@@ -4246,6 +4283,15 @@
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"license": "MIT"
},
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/yargs": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@@ -10713,37 +10759,6 @@
}
}
},
- "node_modules/react-native-boost": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/react-native-boost/-/react-native-boost-0.6.2.tgz",
- "integrity": "sha512-6w9PdGvFzyI1dyN516+mLfFF5vETPsjoc26rUFlzWav7PNbC7WV0KyfTBr0q/cDjZkWLMleWQZkGTqSQ1H4PHg==",
- "license": "MIT",
- "dependencies": {
- "@babel/core": "^7.25.0",
- "@babel/helper-module-imports": "^7.25.0",
- "@babel/helper-plugin-utils": "^7.25.0",
- "minimatch": "^10.0.1"
- },
- "peerDependencies": {
- "react": "*",
- "react-native": "*"
- }
- },
- "node_modules/react-native-boost/node_modules/minimatch": {
- "version": "10.0.3",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
- "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
- "license": "ISC",
- "dependencies": {
- "@isaacs/brace-expansion": "^5.0.0"
- },
- "engines": {
- "node": "20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/react-native-bottom-tabs": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/react-native-bottom-tabs/-/react-native-bottom-tabs-0.12.2.tgz",
@@ -12875,6 +12890,19 @@
"node": ">=8.0"
}
},
+ "node_modules/toastify-react-native": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/toastify-react-native/-/toastify-react-native-7.2.3.tgz",
+ "integrity": "sha512-ngmpTKlTo0IRddwSsNWK+YKbB2veqotHy7Zpil4eksoLAlq0RPSgdVOk5QDEDUONJQ4r7ljGYeRW68KBztirsg==",
+ "license": "MIT",
+ "dependencies": {
+ "react-native-vector-icons": "*"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
diff --git a/package.json b/package.json
index 8ce6708..6c96b1e 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"@react-navigation/stack": "^7.2.10",
"@sentry/react-native": "~7.3.0",
"@shopify/flash-list": "^2.1.0",
+ "@supabase/supabase-js": "^2.54.0",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.12.2",
@@ -84,14 +85,14 @@
"react-native-video": "^6.17.0",
"react-native-web": "^0.21.0",
"react-native-wheel-color-picker": "^1.3.1",
- "react-native-worklets": "^0.6.1"
+ "react-native-worklets": "^0.6.1",
+ "toastify-react-native": "^7.2.3"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/crypto-js": "^4.2.2",
"@types/react": "~18.3.12",
"@types/react-native": "^0.72.8",
- "@types/react-native-vector-icons": "^6.4.18",
"babel-plugin-transform-remove-console": "^6.9.4",
"patch-package": "^8.0.1",
"react-native-svg-transformer": "^1.5.0",
diff --git a/src/assets/splash-icon-new.png b/src/assets/splash-icon-new.png
deleted file mode 100644
index 07445d3..0000000
Binary files a/src/assets/splash-icon-new.png and /dev/null differ
diff --git a/src/components/AnimatedImage.tsx b/src/components/AnimatedImage.tsx
deleted file mode 100644
index a0119c9..0000000
--- a/src/components/AnimatedImage.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import React, { memo, useEffect } from 'react';
-import { StyleSheet } from 'react-native';
-import Animated, {
- useSharedValue,
- useAnimatedStyle,
- withTiming
-} from 'react-native-reanimated';
-import FastImage from '@d11/react-native-fast-image';
-
-interface AnimatedImageProps {
- source: { uri: string } | undefined;
- style: any;
- contentFit: any;
- onLoad?: () => void;
-}
-
-const AnimatedImage = memo(({
- source,
- style,
- contentFit,
- onLoad
-}: AnimatedImageProps) => {
- const opacity = useSharedValue(0);
-
- const animatedStyle = useAnimatedStyle(() => ({
- opacity: opacity.value,
- }));
-
- useEffect(() => {
- if (source?.uri) {
- opacity.value = withTiming(1, { duration: 300 });
- } else {
- opacity.value = 0;
- }
- }, [source?.uri]);
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- opacity.value = 0;
- };
- }, []);
-
- return (
-
-
-
- );
-});
-
-export default AnimatedImage;
diff --git a/src/components/AnimatedText.tsx b/src/components/AnimatedText.tsx
deleted file mode 100644
index 89d9279..0000000
--- a/src/components/AnimatedText.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import React, { memo, useEffect } from 'react';
-import Animated, {
- useSharedValue,
- useAnimatedStyle,
- withTiming,
- withDelay
-} from 'react-native-reanimated';
-
-interface AnimatedTextProps {
- children: React.ReactNode;
- style: any;
- delay?: number;
- numberOfLines?: number;
-}
-
-const AnimatedText = memo(({
- children,
- style,
- delay = 0,
- numberOfLines
-}: AnimatedTextProps) => {
- const opacity = useSharedValue(0);
- const translateY = useSharedValue(20);
-
- const animatedStyle = useAnimatedStyle(() => ({
- opacity: opacity.value,
- transform: [{ translateY: translateY.value }],
- }));
-
- useEffect(() => {
- opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
- translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
- }, [delay]);
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- opacity.value = 0;
- translateY.value = 20;
- };
- }, []);
-
- return (
-
- {children}
-
- );
-});
-
-export default AnimatedText;
diff --git a/src/components/AnimatedView.tsx b/src/components/AnimatedView.tsx
deleted file mode 100644
index 52c8f37..0000000
--- a/src/components/AnimatedView.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import React, { memo, useEffect } from 'react';
-import Animated, {
- useSharedValue,
- useAnimatedStyle,
- withTiming,
- withDelay
-} from 'react-native-reanimated';
-
-interface AnimatedViewProps {
- children: React.ReactNode;
- style?: any;
- delay?: number;
-}
-
-const AnimatedView = memo(({
- children,
- style,
- delay = 0
-}: AnimatedViewProps) => {
- const opacity = useSharedValue(0);
- const translateY = useSharedValue(20);
-
- const animatedStyle = useAnimatedStyle(() => ({
- opacity: opacity.value,
- transform: [{ translateY: translateY.value }],
- }));
-
- useEffect(() => {
- opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
- translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
- }, [delay]);
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- opacity.value = 0;
- translateY.value = 20;
- };
- }, []);
-
- return (
-
- {children}
-
- );
-});
-
-export default AnimatedView;
diff --git a/src/components/ProviderFilter.tsx b/src/components/ProviderFilter.tsx
deleted file mode 100644
index 89005d9..0000000
--- a/src/components/ProviderFilter.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import React, { memo, useCallback } from 'react';
-import { View, Text, StyleSheet, TouchableOpacity, FlatList } from 'react-native';
-
-interface ProviderFilterProps {
- selectedProvider: string;
- providers: Array<{ id: string; name: string; }>;
- onSelect: (id: string) => void;
- theme: any;
-}
-
-const ProviderFilter = memo(({
- selectedProvider,
- providers,
- onSelect,
- theme
-}: ProviderFilterProps) => {
- const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
-
- const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
- onSelect(item.id)}
- >
-
- {item.name}
-
-
- ), [selectedProvider, onSelect, styles]);
-
- return (
-
- item.id}
- horizontal
- showsHorizontalScrollIndicator={false}
- style={styles.filterScroll}
- bounces={true}
- overScrollMode="never"
- decelerationRate="fast"
- initialNumToRender={5}
- maxToRenderPerBatch={3}
- windowSize={3}
- removeClippedSubviews={true}
- getItemLayout={(data, index) => ({
- length: 100, // Approximate width of each item
- offset: 100 * index,
- index,
- })}
- />
-
- );
-});
-
-const createStyles = (colors: any) => StyleSheet.create({
- filterScroll: {
- flexGrow: 0,
- },
- filterChip: {
- backgroundColor: colors.elevation2,
- paddingHorizontal: 14,
- paddingVertical: 8,
- borderRadius: 16,
- marginRight: 8,
- borderWidth: 0,
- },
- filterChipSelected: {
- backgroundColor: colors.primary,
- },
- filterChipText: {
- color: colors.highEmphasis,
- fontWeight: '600',
- letterSpacing: 0.1,
- },
- filterChipTextSelected: {
- color: colors.white,
- fontWeight: '700',
- },
-});
-
-export default ProviderFilter;
diff --git a/src/components/PulsingChip.tsx b/src/components/PulsingChip.tsx
deleted file mode 100644
index 732ce1b..0000000
--- a/src/components/PulsingChip.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import React, { memo } from 'react';
-import { View, Text, StyleSheet } from 'react-native';
-import { useTheme } from '../contexts/ThemeContext';
-
-interface PulsingChipProps {
- text: string;
- delay: number;
-}
-
-const PulsingChip = memo(({ text, delay }: PulsingChipProps) => {
- const { currentTheme } = useTheme();
- const styles = React.useMemo(() => createStyles(currentTheme.colors), [currentTheme.colors]);
- // Make chip static to avoid continuous animation load
- return (
-
- {text}
-
- );
-});
-
-const createStyles = (colors: any) => StyleSheet.create({
- activeScraperChip: {
- backgroundColor: colors.elevation2,
- paddingHorizontal: 8,
- paddingVertical: 3,
- borderRadius: 6,
- borderWidth: 0,
- },
- activeScraperText: {
- color: colors.mediumEmphasis,
- fontSize: 11,
- fontWeight: '400',
- },
-});
-
-export default PulsingChip;
diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx
index 6e1950a..e465740 100644
--- a/src/components/SplashScreen.tsx
+++ b/src/components/SplashScreen.tsx
@@ -29,7 +29,7 @@ const SplashScreen = ({ onFinish }: SplashScreenProps) => {
return (
diff --git a/src/components/StreamCard.tsx b/src/components/StreamCard.tsx
deleted file mode 100644
index 0a1f1a8..0000000
--- a/src/components/StreamCard.tsx
+++ /dev/null
@@ -1,373 +0,0 @@
-import React, { memo, useCallback, useMemo } from 'react';
-import {
- View,
- Text,
- StyleSheet,
- TouchableOpacity,
- ActivityIndicator,
- Platform,
- Clipboard,
-} from 'react-native';
-import { MaterialIcons } from '@expo/vector-icons';
-import FastImage from '@d11/react-native-fast-image';
-import { Stream } from '../types/metadata';
-import QualityBadge from './metadata/QualityBadge';
-import { useSettings } from '../hooks/useSettings';
-import { useDownloads } from '../contexts/DownloadsContext';
-import { useToast } from '../contexts/ToastContext';
-
-interface StreamCardProps {
- stream: Stream;
- onPress: () => void;
- index: number;
- isLoading?: boolean;
- statusMessage?: string;
- theme: any;
- showLogos?: boolean;
- scraperLogo?: string | null;
- showAlert: (title: string, message: string) => void;
- parentTitle?: string;
- parentType?: 'movie' | 'series';
- parentSeason?: number;
- parentEpisode?: number;
- parentEpisodeTitle?: string;
- parentPosterUrl?: string | null;
- providerName?: string;
- parentId?: string;
- parentImdbId?: string;
-}
-
-const StreamCard = memo(({
- stream,
- onPress,
- index,
- isLoading,
- statusMessage,
- theme,
- showLogos,
- scraperLogo,
- showAlert,
- parentTitle,
- parentType,
- parentSeason,
- parentEpisode,
- parentEpisodeTitle,
- parentPosterUrl,
- providerName,
- parentId,
- parentImdbId
-}: StreamCardProps) => {
- const { settings } = useSettings();
- const { startDownload } = useDownloads();
- const { showSuccess, showInfo } = useToast();
-
- // Handle long press to copy stream URL to clipboard
- const handleLongPress = useCallback(async () => {
- if (stream.url) {
- try {
- await Clipboard.setString(stream.url);
-
- // Use toast for Android, custom alert for iOS
- if (Platform.OS === 'android') {
- showSuccess('URL Copied', 'Stream URL copied to clipboard!');
- } else {
- // iOS uses custom alert
- showAlert('Copied!', 'Stream URL has been copied to clipboard.');
- }
- } catch (error) {
- // Fallback: show URL in alert if clipboard fails
- if (Platform.OS === 'android') {
- showInfo('Stream URL', `Stream URL: ${stream.url}`);
- } else {
- showAlert('Stream URL', stream.url);
- }
- }
- }
- }, [stream.url, showAlert, showSuccess, showInfo]);
-
- const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
-
- const streamInfo = useMemo(() => {
- const title = stream.title || '';
- const name = stream.name || '';
-
- // Helper function to format size from bytes
- const formatSize = (bytes: number): string => {
- if (bytes === 0) return '0 Bytes';
- const k = 1024;
- const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
- };
-
- // Get size from title (legacy format) or from stream.size field
- let sizeDisplay = title.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1];
- if (!sizeDisplay && stream.size && typeof stream.size === 'number' && stream.size > 0) {
- sizeDisplay = formatSize(stream.size);
- }
-
- // Extract quality for badge display
- const basicQuality = title.match(/(\d+)p/)?.[1] || null;
-
- return {
- quality: basicQuality,
- isHDR: title.toLowerCase().includes('hdr'),
- isDolby: title.toLowerCase().includes('dolby') || title.includes('DV'),
- size: sizeDisplay,
- isDebrid: stream.behaviorHints?.cached,
- displayName: name || 'Unnamed Stream',
- subTitle: title && title !== name ? title : null
- };
- }, [stream.name, stream.title, stream.behaviorHints, stream.size]);
-
- const handleDownload = useCallback(async () => {
- try {
- const url = stream.url;
- if (!url) return;
- // Prevent duplicate downloads for the same exact URL
- try {
- const downloadsModule = require('../contexts/DownloadsContext');
- if (downloadsModule && downloadsModule.isDownloadingUrl && downloadsModule.isDownloadingUrl(url)) {
- showAlert('Already Downloading', 'This download has already started for this exact link.');
- return;
- }
- } catch {}
- // Show immediate feedback on both platforms
- showAlert('Starting Download', 'Download will be started.');
- const parent: any = stream as any;
- const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content';
- const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie');
- const season = typeof parentSeason === 'number' ? parentSeason : (parent.season || parent.season_number);
- const episode = typeof parentEpisode === 'number' ? parentEpisode : (parent.episode || parent.episode_number);
- const episodeTitle = parentEpisodeTitle || parent.episodeTitle || parent.episode_name;
- // Prefer the stream's display name (often includes provider + resolution)
- const provider = (stream.name as any) || (stream.title as any) || providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider';
-
- // Use parentId first (from route params), fallback to stream metadata
- const idForContent = parentId || parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle;
-
- // Extract tmdbId if available (from parentId or parent metadata)
- let tmdbId: number | undefined = undefined;
- if (parentId && parentId.startsWith('tmdb:')) {
- tmdbId = parseInt(parentId.split(':')[1], 10);
- } else if (typeof parent.tmdbId === 'number') {
- tmdbId = parent.tmdbId;
- }
-
- await startDownload({
- id: String(idForContent),
- type: inferredType,
- title: String(inferredTitle),
- providerName: String(provider),
- season: inferredType === 'series' ? (season ? Number(season) : undefined) : undefined,
- episode: inferredType === 'series' ? (episode ? Number(episode) : undefined) : undefined,
- episodeTitle: inferredType === 'series' ? (episodeTitle ? String(episodeTitle) : undefined) : undefined,
- quality: streamInfo.quality || undefined,
- posterUrl: parentPosterUrl || parent.poster || parent.backdrop || null,
- url,
- headers: (stream.headers as any) || undefined,
- // Pass metadata for progress tracking
- imdbId: parentImdbId || parent.imdbId || undefined,
- tmdbId: tmdbId,
- });
- showAlert('Download Started', 'Your download has been added to the queue.');
- } catch {}
- }, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title, parentId, parentImdbId, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName]);
-
- const isDebrid = streamInfo.isDebrid;
- return (
-
- {/* Scraper Logo */}
- {showLogos && scraperLogo && (
-
-
-
- )}
-
-
-
-
-
- {streamInfo.displayName}
-
- {streamInfo.subTitle && (
-
- {streamInfo.subTitle}
-
- )}
-
-
- {/* Show loading indicator if stream is loading */}
- {isLoading && (
-
-
-
- {statusMessage || "Loading..."}
-
-
- )}
-
-
-
- {streamInfo.isDolby && (
-
- )}
-
- {streamInfo.size && (
-
- 💾 {streamInfo.size}
-
- )}
-
- {streamInfo.isDebrid && (
-
- DEBRID
-
- )}
-
-
-
-
- {settings?.enableDownloads !== false && (
-
-
-
- )}
-
- );
-});
-
-const createStyles = (colors: any) => StyleSheet.create({
- streamCard: {
- flexDirection: 'row',
- alignItems: 'flex-start',
- padding: 14,
- borderRadius: 12,
- marginBottom: 10,
- minHeight: 68,
- backgroundColor: colors.card,
- borderWidth: 0,
- width: '100%',
- zIndex: 1,
- shadowColor: '#000',
- shadowOpacity: 0.04,
- shadowRadius: 2,
- shadowOffset: { width: 0, height: 1 },
- elevation: 0,
- },
- scraperLogoContainer: {
- width: 32,
- height: 32,
- marginRight: 12,
- justifyContent: 'center',
- alignItems: 'center',
- backgroundColor: colors.elevation2,
- borderRadius: 6,
- },
- scraperLogo: {
- width: 24,
- height: 24,
- },
- streamCardLoading: {
- opacity: 0.7,
- },
- streamCardHighlighted: {
- backgroundColor: colors.elevation2,
- shadowOpacity: 0.18,
- },
- streamDetails: {
- flex: 1,
- },
- streamNameRow: {
- flexDirection: 'row',
- alignItems: 'flex-start',
- justifyContent: 'space-between',
- width: '100%',
- flexWrap: 'wrap',
- gap: 8
- },
- streamTitleContainer: {
- flex: 1,
- },
- streamName: {
- fontSize: 14,
- fontWeight: '700',
- marginBottom: 2,
- lineHeight: 20,
- color: colors.highEmphasis,
- letterSpacing: 0.1,
- },
- streamAddonName: {
- fontSize: 12,
- lineHeight: 18,
- color: colors.mediumEmphasis,
- marginBottom: 6,
- },
- streamMetaRow: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: 4,
- marginBottom: 6,
- alignItems: 'center',
- },
- chip: {
- paddingHorizontal: 8,
- paddingVertical: 3,
- borderRadius: 12,
- marginRight: 6,
- marginBottom: 6,
- backgroundColor: colors.elevation2,
- },
- chipText: {
- color: colors.highEmphasis,
- fontSize: 11,
- fontWeight: '600',
- letterSpacing: 0.2,
- },
- loadingIndicator: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 8,
- paddingVertical: 2,
- borderRadius: 12,
- marginLeft: 8,
- },
- loadingText: {
- color: colors.primary,
- fontSize: 12,
- marginLeft: 4,
- fontWeight: '500',
- },
- streamAction: {
- width: 30,
- height: 30,
- borderRadius: 15,
- backgroundColor: colors.primary,
- justifyContent: 'center',
- alignItems: 'center',
- },
-});
-
-export default StreamCard;
diff --git a/src/components/TabletStreamsLayout.tsx b/src/components/TabletStreamsLayout.tsx
deleted file mode 100644
index f94d2d1..0000000
--- a/src/components/TabletStreamsLayout.tsx
+++ /dev/null
@@ -1,795 +0,0 @@
-import React, { memo, useEffect, useState } from 'react';
-import {
- View,
- Text,
- StyleSheet,
- ActivityIndicator,
- FlatList,
- Platform,
- ScrollView,
- TouchableOpacity,
-} from 'react-native';
-import { LinearGradient } from 'expo-linear-gradient';
-import FastImage from '@d11/react-native-fast-image';
-import { MaterialIcons } from '@expo/vector-icons';
-import { BlurView as ExpoBlurView } from 'expo-blur';
-import Animated, {
- useSharedValue,
- useAnimatedStyle,
- withTiming,
- withDelay,
- Easing
-} from 'react-native-reanimated';
-
-// Lazy-safe community blur import for Android
-let AndroidBlurView: any = null;
-if (Platform.OS === 'android') {
- try {
- // eslint-disable-next-line @typescript-eslint/no-var-requires
- AndroidBlurView = require('@react-native-community/blur').BlurView;
- } catch (_) {
- AndroidBlurView = null;
- }
-}
-
-import { Stream } from '../types/metadata';
-import { RootStackNavigationProp } from '../navigation/AppNavigator';
-import ProviderFilter from './ProviderFilter';
-import PulsingChip from './PulsingChip';
-import StreamCard from './StreamCard';
-
-interface TabletStreamsLayoutProps {
- // Background and content props
- episodeImage?: string | null;
- bannerImage?: string | null;
- metadata?: any;
- type: string;
- currentEpisode?: any;
-
- // Movie logo props
- movieLogoError: boolean;
- setMovieLogoError: (error: boolean) => void;
-
- // Stream-related props
- streamsEmpty: boolean;
- selectedProvider: string;
- filterItems: Array<{ id: string; name: string; }>;
- handleProviderChange: (provider: string) => void;
- activeFetchingScrapers: string[];
-
- // Loading states
- isAutoplayWaiting: boolean;
- autoplayTriggered: boolean;
- showNoSourcesError: boolean;
- showInitialLoading: boolean;
- showStillFetching: boolean;
-
- // Stream rendering props
- sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null>;
- renderSectionHeader: ({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => React.ReactElement;
- handleStreamPress: (stream: Stream) => void;
- openAlert: (title: string, message: string) => void;
-
- // Settings and theme
- settings: any;
- currentTheme: any;
- colors: any;
-
- // Other props
- navigation: RootStackNavigationProp;
- insets: any;
- streams: any;
- scraperLogos: Record;
- id: string;
- imdbId?: string;
- loadingStreams: boolean;
- loadingEpisodeStreams: boolean;
- hasStremioStreamProviders: boolean;
-}
-
-const TabletStreamsLayout: React.FC = ({
- episodeImage,
- bannerImage,
- metadata,
- type,
- currentEpisode,
- movieLogoError,
- setMovieLogoError,
- streamsEmpty,
- selectedProvider,
- filterItems,
- handleProviderChange,
- activeFetchingScrapers,
- isAutoplayWaiting,
- autoplayTriggered,
- showNoSourcesError,
- showInitialLoading,
- showStillFetching,
- sections,
- renderSectionHeader,
- handleStreamPress,
- openAlert,
- settings,
- currentTheme,
- colors,
- navigation,
- insets,
- streams,
- scraperLogos,
- id,
- imdbId,
- loadingStreams,
- loadingEpisodeStreams,
- hasStremioStreamProviders,
-}) => {
- const styles = React.useMemo(() => createStyles(colors), [colors]);
-
- // Animation values for backdrop entrance
- const backdropOpacity = useSharedValue(0);
- const backdropScale = useSharedValue(1.05);
- const [backdropLoaded, setBackdropLoaded] = useState(false);
- const [backdropError, setBackdropError] = useState(false);
-
- // Animation values for content panels
- const leftPanelOpacity = useSharedValue(0);
- const leftPanelTranslateX = useSharedValue(-30);
- const rightPanelOpacity = useSharedValue(0);
- const rightPanelTranslateX = useSharedValue(30);
-
- // Get the backdrop source - prioritize episode thumbnail, then show backdrop, then poster
- // For episodes without thumbnails, use show's backdrop instead of poster
- const backdropSource = React.useMemo(() => {
- // Debug logging
- if (__DEV__) {
- console.log('[TabletStreamsLayout] Backdrop source selection:', {
- episodeImage,
- bannerImage,
- metadataPoster: metadata?.poster,
- episodeImageIsPoster: episodeImage === metadata?.poster,
- backdropError
- });
- }
-
- // If episodeImage failed to load, skip it and use backdrop
- if (backdropError && episodeImage && episodeImage !== metadata?.poster) {
- if (__DEV__) console.log('[TabletStreamsLayout] Episode thumbnail failed, falling back to backdrop');
- if (bannerImage) {
- if (__DEV__) console.log('[TabletStreamsLayout] Using show backdrop (episode failed):', bannerImage);
- return { uri: bannerImage };
- }
- }
-
- // If episodeImage exists and is not the same as poster, use it (real episode thumbnail)
- if (episodeImage && episodeImage !== metadata?.poster && !backdropError) {
- if (__DEV__) console.log('[TabletStreamsLayout] Using episode thumbnail:', episodeImage);
- return { uri: episodeImage };
- }
-
- // If episodeImage is the same as poster (fallback case), prioritize backdrop
- if (bannerImage) {
- if (__DEV__) console.log('[TabletStreamsLayout] Using show backdrop:', bannerImage);
- return { uri: bannerImage };
- }
-
- // No fallback to poster images
-
- if (__DEV__) console.log('[TabletStreamsLayout] No backdrop source found');
- return undefined;
- }, [episodeImage, bannerImage, metadata?.poster, backdropError]);
-
- // Animate backdrop when it loads, or animate content immediately if no backdrop
- useEffect(() => {
- if (backdropSource?.uri && backdropLoaded) {
- // Animate backdrop first
- backdropOpacity.value = withTiming(1, {
- duration: 800,
- easing: Easing.out(Easing.cubic)
- });
- backdropScale.value = withTiming(1, {
- duration: 1000,
- easing: Easing.out(Easing.cubic)
- });
-
- // Animate content panels with delay after backdrop starts loading
- leftPanelOpacity.value = withDelay(300, withTiming(1, {
- duration: 600,
- easing: Easing.out(Easing.cubic)
- }));
- leftPanelTranslateX.value = withDelay(300, withTiming(0, {
- duration: 600,
- easing: Easing.out(Easing.cubic)
- }));
-
- rightPanelOpacity.value = withDelay(500, withTiming(1, {
- duration: 600,
- easing: Easing.out(Easing.cubic)
- }));
- rightPanelTranslateX.value = withDelay(500, withTiming(0, {
- duration: 600,
- easing: Easing.out(Easing.cubic)
- }));
- } else if (!backdropSource?.uri) {
- // No backdrop available, animate content panels immediately
- leftPanelOpacity.value = withTiming(1, {
- duration: 600,
- easing: Easing.out(Easing.cubic)
- });
- leftPanelTranslateX.value = withTiming(0, {
- duration: 600,
- easing: Easing.out(Easing.cubic)
- });
-
- rightPanelOpacity.value = withDelay(200, withTiming(1, {
- duration: 600,
- easing: Easing.out(Easing.cubic)
- }));
- rightPanelTranslateX.value = withDelay(200, withTiming(0, {
- duration: 600,
- easing: Easing.out(Easing.cubic)
- }));
- }
- }, [backdropSource?.uri, backdropLoaded]);
-
- // Reset animation when episode changes
- useEffect(() => {
- backdropOpacity.value = 0;
- backdropScale.value = 1.05;
- leftPanelOpacity.value = 0;
- leftPanelTranslateX.value = -30;
- rightPanelOpacity.value = 0;
- rightPanelTranslateX.value = 30;
- setBackdropLoaded(false);
- setBackdropError(false);
- }, [episodeImage]);
-
- // Animated styles for backdrop
- const backdropAnimatedStyle = useAnimatedStyle(() => ({
- opacity: backdropOpacity.value,
- transform: [{ scale: backdropScale.value }],
- }));
-
- // Animated styles for content panels
- const leftPanelAnimatedStyle = useAnimatedStyle(() => ({
- opacity: leftPanelOpacity.value,
- transform: [{ translateX: leftPanelTranslateX.value }],
- }));
-
- const rightPanelAnimatedStyle = useAnimatedStyle(() => ({
- opacity: rightPanelOpacity.value,
- transform: [{ translateX: rightPanelTranslateX.value }],
- }));
-
- const handleBackdropLoad = () => {
- setBackdropLoaded(true);
- };
-
- const handleBackdropError = () => {
- if (__DEV__) console.log('[TabletStreamsLayout] Backdrop image failed to load:', backdropSource?.uri);
- setBackdropError(true);
- setBackdropLoaded(false);
- };
-
- const renderStreamContent = () => {
- if (showNoSourcesError) {
- return (
-
-
- No streaming sources available
-
- Please add streaming sources in settings
-
- navigation.navigate('Addons')}
- >
- Add Sources
-
-
- );
- }
-
- if (streamsEmpty) {
- if (showInitialLoading || showStillFetching) {
- return (
-
-
-
- {isAutoplayWaiting ? 'Finding best stream for autoplay...' :
- showStillFetching ? 'Still fetching streams…' :
- 'Finding available streams...'}
-
-
- );
- } else {
- return (
-
-
- No streams available
-
- );
- }
- }
-
- return (
-
- {sections.filter(Boolean).map((section, sectionIndex) => (
-
- {renderSectionHeader({ section: section! })}
-
- {section!.data && section!.data.length > 0 ? (
- {
- if (item && item.url) {
- return `${item.url}-${sectionIndex}-${index}`;
- }
- return `empty-${sectionIndex}-${index}`;
- }}
- renderItem={({ item, index }) => (
-
- handleStreamPress(item)}
- index={index}
- isLoading={false}
- statusMessage={undefined}
- theme={currentTheme}
- showLogos={settings.showScraperLogos}
- scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogos[(item.addonId || (item as any).addon) as string] || null : null}
- showAlert={(t: string, m: string) => openAlert(t, m)}
- parentTitle={metadata?.name}
- parentType={type as 'movie' | 'series'}
- parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined}
- parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined}
- parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined}
- parentPosterUrl={episodeImage || metadata?.poster || undefined}
- providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))}
- parentId={id}
- parentImdbId={imdbId || undefined}
- />
-
- )}
- scrollEnabled={false}
- initialNumToRender={6}
- maxToRenderPerBatch={2}
- windowSize={3}
- removeClippedSubviews={true}
- showsVerticalScrollIndicator={false}
- getItemLayout={(data, index) => ({
- length: 78,
- offset: 78 * index,
- index,
- })}
- />
- ) : null}
-
- ))}
-
- {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && (
-
-
- Loading more sources...
-
- )}
-
- );
- };
-
- return (
-
- {/* Full Screen Background with Entrance Animation */}
- {backdropSource?.uri ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
- {/* Left Panel: Movie Logo/Episode Info */}
-
- {type === 'movie' && metadata && (
-
- {metadata.logo && !movieLogoError ? (
- setMovieLogoError(true)}
- />
- ) : (
- {metadata.name}
- )}
-
- )}
-
- {type === 'series' && currentEpisode && (
-
- {currentEpisode.episodeString}
- {currentEpisode.name}
- {currentEpisode.overview && (
- {currentEpisode.overview}
- )}
-
- )}
-
-
- {/* Right Panel: Streams List */}
-
- {Platform.OS === 'android' && AndroidBlurView ? (
-
-
-
- {/* Always show filter container to prevent layout shift */}
-
- {!streamsEmpty && (
-
- )}
-
-
- {/* Active Scrapers Status */}
- {activeFetchingScrapers.length > 0 && (
-
- Fetching from:
-
- {activeFetchingScrapers.map((scraperName, index) => (
-
- ))}
-
-
- )}
-
- {/* Stream content area - always show ScrollView to prevent flash */}
-
- {/* Show autoplay loading overlay if waiting for autoplay */}
- {isAutoplayWaiting && !autoplayTriggered && (
-
-
-
- Starting best stream...
-
-
- )}
-
- {renderStreamContent()}
-
-
-
-
- ) : (
-
-
- {/* Always show filter container to prevent layout shift */}
-
- {!streamsEmpty && (
-
- )}
-
-
- {/* Active Scrapers Status */}
- {activeFetchingScrapers.length > 0 && (
-
- Fetching from:
-
- {activeFetchingScrapers.map((scraperName, index) => (
-
- ))}
-
-
- )}
-
- {/* Stream content area - always show ScrollView to prevent flash */}
-
- {/* Show autoplay loading overlay if waiting for autoplay */}
- {isAutoplayWaiting && !autoplayTriggered && (
-
-
-
- Starting best stream...
-
-
- )}
-
- {renderStreamContent()}
-
-
-
- )}
-
-
- );
-};
-
-// Create a function to generate styles with the current theme colors
-const createStyles = (colors: any) => StyleSheet.create({
- streamsMainContent: {
- flex: 1,
- backgroundColor: colors.darkBackground,
- paddingTop: 12,
- zIndex: 1,
- // iOS-specific fixes for navigation transition glitches
- ...(Platform.OS === 'ios' && {
- // Ensure proper rendering during transitions
- opacity: 1,
- // Prevent iOS optimization that can cause glitches
- shouldRasterizeIOS: false,
- }),
- },
- streamsMainContentMovie: {
- paddingTop: Platform.OS === 'android' ? 10 : 15,
- },
- filterContainer: {
- paddingHorizontal: 12,
- paddingBottom: 8,
- },
- streamsContent: {
- flex: 1,
- width: '100%',
- zIndex: 2,
- },
- streamsContainer: {
- paddingHorizontal: 12,
- paddingBottom: 20,
- width: '100%',
- },
- streamsHeroEpisodeNumber: {
- color: colors.primary,
- fontSize: 14,
- fontWeight: 'bold',
- marginBottom: 2,
- textShadowColor: 'rgba(0,0,0,0.75)',
- textShadowOffset: { width: 0, height: 1 },
- textShadowRadius: 2,
- },
- streamsHeroTitle: {
- color: colors.highEmphasis,
- fontSize: 24,
- fontWeight: 'bold',
- marginBottom: 4,
- textShadowColor: 'rgba(0,0,0,0.75)',
- textShadowOffset: { width: 0, height: 1 },
- textShadowRadius: 3,
- },
- streamsHeroOverview: {
- color: colors.mediumEmphasis,
- fontSize: 14,
- lineHeight: 20,
- marginBottom: 2,
- textShadowColor: 'rgba(0,0,0,0.75)',
- textShadowOffset: { width: 0, height: 1 },
- textShadowRadius: 2,
- },
- noStreams: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- padding: 32,
- },
- noStreamsText: {
- color: colors.textMuted,
- fontSize: 16,
- marginTop: 16,
- },
- noStreamsSubText: {
- color: colors.mediumEmphasis,
- fontSize: 14,
- marginTop: 8,
- textAlign: 'center',
- },
- addSourcesButton: {
- marginTop: 24,
- paddingHorizontal: 20,
- paddingVertical: 10,
- backgroundColor: colors.primary,
- borderRadius: 8,
- },
- addSourcesButtonText: {
- color: colors.white,
- fontSize: 14,
- fontWeight: '600',
- },
- loadingContainer: {
- alignItems: 'center',
- paddingVertical: 24,
- },
- loadingText: {
- color: colors.primary,
- fontSize: 12,
- marginLeft: 4,
- fontWeight: '500',
- },
- footerLoading: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- padding: 16,
- },
- footerLoadingText: {
- color: colors.primary,
- fontSize: 12,
- marginLeft: 8,
- fontWeight: '500',
- },
- activeScrapersContainer: {
- paddingHorizontal: 16,
- paddingVertical: 8,
- backgroundColor: 'transparent',
- marginHorizontal: 16,
- marginBottom: 4,
- },
- activeScrapersTitle: {
- color: colors.mediumEmphasis,
- fontSize: 12,
- fontWeight: '500',
- marginBottom: 6,
- opacity: 0.8,
- },
- activeScrapersRow: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: 4,
- },
- autoplayOverlay: {
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- backgroundColor: 'rgba(0,0,0,0.8)',
- padding: 16,
- alignItems: 'center',
- zIndex: 10,
- },
- autoplayIndicator: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: colors.elevation2,
- paddingHorizontal: 16,
- paddingVertical: 12,
- borderRadius: 8,
- },
- autoplayText: {
- color: colors.primary,
- fontSize: 14,
- marginLeft: 8,
- fontWeight: '600',
- },
- // Tablet-specific styles
- tabletLayout: {
- flex: 1,
- flexDirection: 'row',
- position: 'relative',
- },
- tabletFullScreenBackground: {
- ...StyleSheet.absoluteFillObject,
- },
- tabletNoBackdropBackground: {
- ...StyleSheet.absoluteFillObject,
- backgroundColor: colors.darkBackground,
- },
- tabletFullScreenGradient: {
- ...StyleSheet.absoluteFillObject,
- },
- tabletLeftPanel: {
- width: '40%',
- justifyContent: 'center',
- alignItems: 'center',
- padding: 24,
- zIndex: 2,
- },
- tabletMovieLogoContainer: {
- width: '80%',
- alignItems: 'center',
- justifyContent: 'center',
- },
- tabletMovieLogo: {
- width: '100%',
- height: 120,
- marginBottom: 16,
- },
- tabletMovieTitle: {
- color: colors.highEmphasis,
- fontSize: 32,
- fontWeight: '900',
- textAlign: 'center',
- letterSpacing: -0.5,
- textShadowColor: 'rgba(0,0,0,0.8)',
- textShadowOffset: { width: 0, height: 2 },
- textShadowRadius: 4,
- },
- tabletEpisodeInfo: {
- width: '80%',
- },
- tabletEpisodeText: {
- textShadowColor: 'rgba(0,0,0,1)',
- textShadowOffset: { width: 0, height: 0 },
- textShadowRadius: 4,
- },
- tabletEpisodeNumber: {
- fontSize: 18,
- fontWeight: 'bold',
- marginBottom: 8,
- },
- tabletEpisodeTitle: {
- fontSize: 28,
- fontWeight: 'bold',
- marginBottom: 12,
- lineHeight: 34,
- },
- tabletEpisodeOverview: {
- fontSize: 16,
- lineHeight: 24,
- opacity: 0.95,
- },
- tabletRightPanel: {
- width: '60%',
- flex: 1,
- paddingTop: Platform.OS === 'android' ? 60 : 20,
- zIndex: 2,
- },
- tabletStreamsContent: {
- backgroundColor: 'rgba(0,0,0,0.2)',
- borderRadius: 24,
- margin: 12,
- overflow: 'hidden', // Ensures content respects rounded corners
- },
- tabletBlurContent: {
- flex: 1,
- padding: 16,
- backgroundColor: 'transparent',
- },
- androidBlurView: {
- flex: 1,
- backgroundColor: 'transparent',
- },
-});
-
-export default memo(TabletStreamsLayout);
diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx
index 1d8bd4c..7639a9f 100644
--- a/src/components/home/CatalogSection.tsx
+++ b/src/components/home/CatalogSection.tsx
@@ -1,13 +1,13 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions } from 'react-native';
-import { FlashList } from '@shopify/flash-list';
+import { LegendList } from '@legendapp/list';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { CatalogContent, StreamingContent } from '../../services/catalogService';
import { useTheme } from '../../contexts/ThemeContext';
import ContentItem from './ContentItem';
-import Animated, { FadeIn, Layout } from 'react-native-reanimated';
+import Animated, { FadeIn } from 'react-native-reanimated';
import { RootStackParamList } from '../../navigation/AppNavigator';
interface CatalogSectionProps {
@@ -16,26 +16,6 @@ interface CatalogSectionProps {
const { width } = Dimensions.get('window');
-// Enhanced responsive breakpoints
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-};
-
-const getDeviceType = (deviceWidth: number) => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
-};
-
-const deviceType = getDeviceType(width);
-const isTablet = deviceType === 'tablet';
-const isLargeTablet = deviceType === 'largeTablet';
-const isTV = deviceType === 'tv';
-
// Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
@@ -90,51 +70,18 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
);
}, [handleContentPress]);
- // Memoize the ItemSeparatorComponent to prevent re-creation (responsive spacing)
- const separatorWidth = isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8;
- const ItemSeparator = useCallback(() => , [separatorWidth]);
+ // Memoize the ItemSeparatorComponent to prevent re-creation
+ const ItemSeparator = useCallback(() => , []);
// Memoize the keyExtractor to prevent re-creation
const keyExtractor = useCallback((item: StreamingContent) => `${item.id}-${item.type}`, []);
- // FlashList v2 optimization: getItemType for better performance
- const getItemType = useCallback((item: StreamingContent) => {
- // Return different types based on content for better recycling
- return item.type === 'movie' ? 'movie' : 'series';
- }, []);
-
return (
-
-
+
+
-
- {catalog.name}
-
-
+ {catalog.name}
+
@@ -144,50 +91,25 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
addonId: catalog.addon
})
}
- style={[
- styles.viewAllButton,
- {
- paddingVertical: isTV ? 10 : isLargeTablet ? 9 : isTablet ? 8 : 8,
- paddingHorizontal: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 10,
- borderRadius: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 20 : 20,
- }
- ]}
+ style={styles.viewAllButton}
>
- View All
-
+ View All
+
- {}}
- // FlashList v2 optimizations
- drawDistance={500}
+ recycleItems={true}
+ maintainVisibleContentPosition
/>
);
@@ -201,6 +123,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
+ paddingHorizontal: 16,
marginBottom: 16,
},
titleContainer: {
@@ -209,7 +132,7 @@ const styles = StyleSheet.create({
marginRight: 16,
},
catalogTitle: {
- fontSize: 24, // will be overridden responsively
+ fontSize: 24,
fontWeight: '800',
letterSpacing: 0.5,
marginBottom: 4,
@@ -218,26 +141,26 @@ const styles = StyleSheet.create({
position: 'absolute',
bottom: -2,
left: 0,
- width: 40, // overridden responsively
- height: 3, // overridden responsively
+ width: 40,
+ height: 3,
borderRadius: 2,
opacity: 0.8,
},
viewAllButton: {
flexDirection: 'row',
alignItems: 'center',
- paddingVertical: 8, // overridden responsively
- paddingHorizontal: 10, // overridden responsively
- borderRadius: 20, // overridden responsively
+ paddingVertical: 8,
+ paddingHorizontal: 10,
+ borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.1)',
},
viewAllText: {
- fontSize: 14, // overridden responsively
+ fontSize: 14,
fontWeight: '600',
- marginRight: 4, // overridden responsively
+ marginRight: 4,
},
catalogList: {
- // padding will be applied responsively in JSX
+ paddingHorizontal: 16,
},
});
diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx
index d8466f6..3a23b17 100644
--- a/src/components/home/ContentItem.tsx
+++ b/src/components/home/ContentItem.tsx
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
-import { useToast } from '../../contexts/ToastContext';
+import { Toast } from 'toastify-react-native';
import { DeviceEventEmitter } from 'react-native';
-import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Share } from 'react-native';
+import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Animated, Share } from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons, Feather } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
@@ -11,8 +11,6 @@ import { DropUpMenu } from './DropUpMenu';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { storageService } from '../../services/storageService';
import { TraktService } from '../../services/traktService';
-import { useTraktContext } from '../../contexts/TraktContext';
-import Animated, { FadeIn } from 'react-native-reanimated';
interface ContentItemProps {
item: StreamingContent;
@@ -23,39 +21,21 @@ interface ContentItemProps {
const { width } = Dimensions.get('window');
-// Enhanced responsive breakpoints
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-};
-
-const getDeviceType = (screenWidth: number) => {
- if (screenWidth >= BREAKPOINTS.tv) return 'tv';
- if (screenWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (screenWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
-};
-
// Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => {
- const deviceType = getDeviceType(screenWidth);
-
- // Responsive sizing based on device type
- const MIN_POSTER_WIDTH = deviceType === 'tv' ? 180 : deviceType === 'largeTablet' ? 160 : deviceType === 'tablet' ? 140 : 100;
- const MAX_POSTER_WIDTH = deviceType === 'tv' ? 220 : deviceType === 'largeTablet' ? 200 : deviceType === 'tablet' ? 180 : 130;
- const LEFT_PADDING = deviceType === 'tv' ? 32 : deviceType === 'largeTablet' ? 28 : deviceType === 'tablet' ? 24 : 16;
- const SPACING = deviceType === 'tv' ? 12 : deviceType === 'largeTablet' ? 10 : deviceType === 'tablet' ? 8 : 8;
+ // Detect if device is a tablet (width >= 768px is common tablet breakpoint)
+ const isTablet = screenWidth >= 768;
+
+ const MIN_POSTER_WIDTH = isTablet ? 140 : 100; // Bigger minimum for tablets
+ const MAX_POSTER_WIDTH = isTablet ? 180 : 130; // Bigger maximum for tablets
+ const LEFT_PADDING = 16; // Left padding
+ const SPACING = 8; // Space between posters
// Calculate available width for posters (reserve space for left padding)
const availableWidth = screenWidth - LEFT_PADDING;
// Try different numbers of full posters to find the best fit
- let bestLayout = {
- numFullPosters: 3,
- posterWidth: deviceType === 'tv' ? 200 : deviceType === 'largeTablet' ? 180 : deviceType === 'tablet' ? 160 : 120
- };
+ let bestLayout = { numFullPosters: 3, posterWidth: isTablet ? 160 : 120 };
for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster
@@ -108,9 +88,6 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
const [isWatched, setIsWatched] = useState(false);
const [imageError, setImageError] = useState(false);
- // Trakt integration
- const { isAuthenticated, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection } = useTraktContext();
-
useEffect(() => {
// Reset image error state when item changes, allowing for retry on re-render
setImageError(false);
@@ -118,24 +95,19 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
const { currentTheme } = useTheme();
const { settings, isLoaded } = useSettings();
- const { showSuccess, showInfo } = useToast();
const posterRadius = typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12;
+ const fadeInOpacity = React.useRef(new Animated.Value(1)).current;
// Memoize poster width calculation to avoid recalculating on every render
const posterWidth = React.useMemo(() => {
- const deviceType = getDeviceType(width);
- const sizeMultiplier = deviceType === 'tv' ? 1.2 : deviceType === 'largeTablet' ? 1.1 : deviceType === 'tablet' ? 1.0 : 0.9;
-
switch (settings.posterSize) {
case 'small':
- return Math.max(90, POSTER_WIDTH - 15) * sizeMultiplier;
- case 'medium':
- return Math.max(110, POSTER_WIDTH + 10) * sizeMultiplier;
+ return Math.max(100, Math.min(POSTER_WIDTH - 10, POSTER_WIDTH));
case 'large':
- return Math.max(130, POSTER_WIDTH + 25) * sizeMultiplier;
+ return Math.min(POSTER_WIDTH + 20, POSTER_WIDTH + 30);
default:
- return POSTER_WIDTH * sizeMultiplier;
+ return POSTER_WIDTH;
}
- }, [settings.posterSize, width]);
+ }, [settings.posterSize]);
// Intersection observer simulation for lazy loading
const itemRef = useRef(null);
@@ -153,10 +125,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
case 'library':
if (inLibrary) {
catalogService.removeFromLibrary(item.type, item.id);
- showInfo('Removed from Library', 'Removed from your local library');
+ Toast.info('Removed from Library');
} else {
catalogService.addToLibrary(item);
- showSuccess('Added to Library', 'Added to your local library');
+ Toast.success('Added to Library');
}
break;
case 'watched': {
@@ -165,7 +137,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
try {
await AsyncStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
} catch {}
- showInfo(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched', targetWatched ? 'Item marked as watched' : 'Item marked as unwatched');
+ Toast.info(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched');
setTimeout(() => {
DeviceEventEmitter.emit('watchedStatusChanged');
}, 100);
@@ -208,30 +180,8 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
Share.share({ message, url, title: item.name });
break;
}
- case 'trakt-watchlist': {
- if (isInWatchlist(item.id, item.type as 'movie' | 'show')) {
- await removeFromWatchlist(item.id, item.type as 'movie' | 'show');
- showInfo('Removed from Watchlist', 'Removed from your Trakt watchlist');
- } else {
- await addToWatchlist(item.id, item.type as 'movie' | 'show');
- showSuccess('Added to Watchlist', 'Added to your Trakt watchlist');
- }
- setMenuVisible(false);
- break;
- }
- case 'trakt-collection': {
- if (isInCollection(item.id, item.type as 'movie' | 'show')) {
- await removeFromCollection(item.id, item.type as 'movie' | 'show');
- showInfo('Removed from Collection', 'Removed from your Trakt collection');
- } else {
- await addToCollection(item.id, item.type as 'movie' | 'show');
- showSuccess('Added to Collection', 'Added to your Trakt collection');
- }
- setMenuVisible(false);
- break;
- }
}
- }, [item, inLibrary, isWatched, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection, showSuccess, showInfo]);
+ }, [item, inLibrary, isWatched]);
const handleMenuClose = useCallback(() => {
setMenuVisible(false);
@@ -282,7 +232,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
return (
<>
-
+
)}
- {isAuthenticated && isInWatchlist(item.id, item.type as 'movie' | 'show') && (
-
-
-
- )}
- {isAuthenticated && isInCollection(item.id, item.type as 'movie' | 'show') && (
-
-
-
- )}
{settings.showPosterTitles && (
-
+
{item.name}
)}
@@ -428,20 +359,8 @@ const styles = StyleSheet.create({
borderRadius: 8,
padding: 4,
},
- traktWatchlistIcon: {
- position: 'absolute',
- top: 8,
- right: 8,
- padding: 2,
- },
- traktCollectionIcon: {
- position: 'absolute',
- top: 8,
- right: 32, // Positioned to the left of watchlist icon
- padding: 2,
- },
title: {
- fontSize: 13, // Will be overridden responsively
+ fontSize: 13,
fontWeight: '500',
marginTop: 4,
textAlign: 'center',
diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx
index 436409c..67fea35 100644
--- a/src/components/home/ContinueWatchingSection.tsx
+++ b/src/components/home/ContinueWatchingSection.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
+import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
View,
Text,
@@ -7,11 +7,10 @@ import {
Dimensions,
AppState,
AppStateStatus,
- ActivityIndicator,
- Platform
+ ActivityIndicator
} from 'react-native';
import { FlashList } from '@shopify/flash-list';
-import Animated, { FadeIn, Layout } from 'react-native-reanimated';
+import Animated, { FadeIn } from 'react-native-reanimated';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
@@ -24,8 +23,6 @@ import { logger } from '../../utils/logger';
import * as Haptics from 'expo-haptics';
import { TraktService } from '../../services/traktService';
import { stremioService } from '../../services/stremioService';
-import { streamCacheService } from '../../services/streamCacheService';
-import { useSettings } from '../../hooks/useSettings';
import CustomAlert from '../../components/CustomAlert';
// Define interface for continue watching items
@@ -42,14 +39,6 @@ interface ContinueWatchingRef {
refresh: () => Promise;
}
-// Enhanced responsive breakpoints for Continue Watching section
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-};
-
// Dynamic poster calculation based on screen width for Continue Watching section
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items
@@ -100,7 +89,6 @@ const isEpisodeReleased = (video: any): boolean => {
const ContinueWatchingSection = React.forwardRef((props, ref) => {
const navigation = useNavigation>();
const { currentTheme } = useTheme();
- const { settings } = useSettings();
const [continueWatchingItems, setContinueWatchingItems] = useState([]);
const [loading, setLoading] = useState(true);
const appState = useRef(AppState.currentState);
@@ -108,88 +96,6 @@ const ContinueWatchingSection = React.forwardRef((props, re
const [deletingItemId, setDeletingItemId] = useState(null);
const longPressTimeoutRef = useRef(null);
- // Enhanced responsive sizing for tablets and TV screens
- const [dimensions, setDimensions] = useState(Dimensions.get('window'));
- const deviceWidth = dimensions.width;
- const deviceHeight = dimensions.height;
-
- // Listen for dimension changes (orientation changes)
- useEffect(() => {
- const subscription = Dimensions.addEventListener('change', ({ window }) => {
- setDimensions(window);
- });
-
- return () => subscription?.remove();
- }, []);
-
- // Determine device type based on width
- const getDeviceType = useCallback(() => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- }, [deviceWidth]);
-
- const deviceType = getDeviceType();
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
- const isLargeScreen = isTablet || isLargeTablet || isTV;
-
- // Enhanced responsive sizing for continue watching items
- const computedItemWidth = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 400; // Larger items for TV
- case 'largeTablet':
- return 350; // Medium-large items for large tablets
- case 'tablet':
- return 320; // Medium items for tablets
- default:
- return 280; // Original phone size
- }
- }, [deviceType]);
-
- const computedItemHeight = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 160; // Taller items for TV
- case 'largeTablet':
- return 140; // Medium-tall items for large tablets
- case 'tablet':
- return 130; // Medium items for tablets
- default:
- return 120; // Original phone height
- }
- }, [deviceType]);
-
- // Enhanced spacing and padding
- const horizontalPadding = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 32;
- case 'largeTablet':
- return 28;
- case 'tablet':
- return 24;
- default:
- return 16; // phone
- }
- }, [deviceType]);
-
- const itemSpacing = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 20;
- case 'largeTablet':
- return 18;
- case 'tablet':
- return 16;
- default:
- return 16; // phone
- }
- }, [deviceType]);
-
// Alert state for CustomAlert
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
@@ -202,10 +108,6 @@ const ContinueWatchingSection = React.forwardRef((props, re
// Track recently removed items to prevent immediate re-addition
const recentlyRemovedRef = useRef>(new Set());
const REMOVAL_IGNORE_DURATION = 10000; // 10 seconds
-
- // Track last Trakt sync to prevent excessive API calls
- const lastTraktSyncRef = useRef(0);
- const TRAKT_SYNC_COOLDOWN = 5 * 60 * 1000; // 5 minutes between Trakt syncs
// Cache for metadata to avoid redundant API calls
const metadataCache = useRef>({});
@@ -466,15 +368,6 @@ const ContinueWatchingSection = React.forwardRef((props, re
const traktService = TraktService.getInstance();
const isAuthed = await traktService.isAuthenticated();
if (!isAuthed) return;
-
- // Check Trakt sync cooldown to prevent excessive API calls
- const now = Date.now();
- if (now - lastTraktSyncRef.current < TRAKT_SYNC_COOLDOWN) {
- logger.log(`[TraktSync] Skipping Trakt sync - cooldown active (${Math.round((TRAKT_SYNC_COOLDOWN - (now - lastTraktSyncRef.current)) / 1000)}s remaining)`);
- return;
- }
-
- lastTraktSyncRef.current = now;
const historyItems = await traktService.getWatchedEpisodesHistory(1, 200);
const latestWatchedByShow: Record = {};
for (const item of historyItems) {
@@ -491,21 +384,18 @@ const ContinueWatchingSection = React.forwardRef((props, re
}
}
- // Collect all valid Trakt items first, then merge as a batch
- const traktBatch: ContinueWatchingItem[] = [];
-
- for (const [showId, info] of Object.entries(latestWatchedByShow)) {
+ const perShowPromises = Object.entries(latestWatchedByShow).map(async ([showId, info]) => {
try {
// Check if this show was recently removed by the user
const showKey = `series:${showId}`;
if (recentlyRemovedRef.current.has(showKey)) {
logger.log(`🚫 [TraktSync] Skipping recently removed show: ${showKey}`);
- continue;
+ return;
}
-
+
const nextEpisode = info.episode + 1;
const cachedData = await getCachedMetadata('series', showId);
- if (!cachedData?.basicContent) continue;
+ if (!cachedData?.basicContent) return;
const { metadata, basicContent } = cachedData;
let nextEpisodeVideo = null;
if (metadata?.videos && Array.isArray(metadata.videos)) {
@@ -515,16 +405,18 @@ const ContinueWatchingSection = React.forwardRef((props, re
}
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
logger.log(`➕ [TraktSync] Adding next episode for ${showId}: S${info.season}E${nextEpisode}`);
- traktBatch.push({
- ...basicContent,
- id: showId,
- type: 'series',
- progress: 0,
- lastUpdated: info.watchedAt,
- season: info.season,
- episode: nextEpisode,
- episodeTitle: `Episode ${nextEpisode}`,
- } as ContinueWatchingItem);
+ await mergeBatchIntoState([
+ {
+ ...basicContent,
+ id: showId,
+ type: 'series',
+ progress: 0,
+ lastUpdated: info.watchedAt,
+ season: info.season,
+ episode: nextEpisode,
+ episodeTitle: `Episode ${nextEpisode}`,
+ } as ContinueWatchingItem,
+ ]);
}
// Persist "watched" progress for the episode that Trakt reported (only if not recently removed)
@@ -553,12 +445,8 @@ const ContinueWatchingSection = React.forwardRef((props, re
} catch (err) {
// Continue with other shows even if one fails
}
- }
-
- // Merge all Trakt items as a single batch to ensure proper sorting
- if (traktBatch.length > 0) {
- await mergeBatchIntoState(traktBatch);
- }
+ });
+ await Promise.allSettled(perShowPromises);
} catch (err) {
// Continue even if Trakt history merge fails
}
@@ -587,8 +475,7 @@ const ContinueWatchingSection = React.forwardRef((props, re
appState.current.match(/inactive|background/) &&
nextAppState === 'active'
) {
- // App has come to the foreground - force Trakt sync by resetting cooldown
- lastTraktSyncRef.current = 0; // Reset cooldown to allow immediate Trakt sync
+ // App has come to the foreground - trigger a background refresh
loadContinueWatching(true);
}
appState.current = nextAppState;
@@ -606,10 +493,9 @@ const ContinueWatchingSection = React.forwardRef((props, re
clearTimeout(refreshTimerRef.current);
}
refreshTimerRef.current = setTimeout(() => {
- // Only trigger background refresh for local progress updates, not Trakt sync
- // This prevents the feedback loop where Trakt sync triggers more progress updates
+ // Trigger a background refresh
loadContinueWatching(true);
- }, 2000); // Increased debounce to reduce frequency
+ }, 800); // Shorter debounce for snappier UI without battery impact
};
// Try to set up a custom event listener or use a timer as fallback
@@ -657,131 +543,15 @@ const ContinueWatchingSection = React.forwardRef((props, re
// Expose the refresh function via the ref
React.useImperativeHandle(ref, () => ({
refresh: async () => {
- // Manual refresh bypasses Trakt cooldown to get fresh data
- lastTraktSyncRef.current = 0; // Reset cooldown for manual refresh
+ // Allow manual refresh to show loading indicator
await loadContinueWatching(false);
return true;
}
}));
- const handleContentPress = useCallback(async (item: ContinueWatchingItem) => {
- try {
- logger.log(`🎬 [ContinueWatching] User clicked on: ${item.name} (${item.type}:${item.id})`);
-
- // Check if cached streams are enabled in settings
- if (!settings.useCachedStreams) {
- logger.log(`📺 [ContinueWatching] Cached streams disabled, navigating to ${settings.openMetadataScreenWhenCacheDisabled ? 'MetadataScreen' : 'StreamsScreen'} for ${item.name}`);
-
- // Navigate based on the second setting
- if (settings.openMetadataScreenWhenCacheDisabled) {
- // Navigate to MetadataScreen
- if (item.type === 'series' && item.season && item.episode) {
- const episodeId = `${item.id}:${item.season}:${item.episode}`;
- navigation.navigate('Metadata', {
- id: item.id,
- type: item.type,
- episodeId: episodeId
- });
- } else {
- navigation.navigate('Metadata', {
- id: item.id,
- type: item.type
- });
- }
- } else {
- // Navigate to StreamsScreen
- if (item.type === 'series' && item.season && item.episode) {
- const episodeId = `${item.id}:${item.season}:${item.episode}`;
- navigation.navigate('Streams', {
- id: item.id,
- type: item.type,
- episodeId: episodeId
- });
- } else {
- navigation.navigate('Streams', {
- id: item.id,
- type: item.type
- });
- }
- }
- return;
- }
-
- // Check if we have a cached stream for this content
- const episodeId = item.type === 'series' && item.season && item.episode
- ? `${item.id}:${item.season}:${item.episode}`
- : undefined;
-
- logger.log(`🔍 [ContinueWatching] Looking for cached stream with episodeId: ${episodeId || 'none'}`);
-
- const cachedStream = await streamCacheService.getCachedStream(item.id, item.type, episodeId);
-
- if (cachedStream) {
- // We have a valid cached stream, navigate directly to player
- logger.log(`🚀 [ContinueWatching] Using cached stream for ${item.name}`);
-
- // Determine the player route based on platform
- const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
-
- // Navigate directly to player with cached stream data
- navigation.navigate(playerRoute as any, {
- uri: cachedStream.stream.url,
- title: cachedStream.metadata?.name || item.name,
- episodeTitle: cachedStream.episodeTitle || (item.type === 'series' ? `Episode ${item.episode}` : undefined),
- season: cachedStream.season || item.season,
- episode: cachedStream.episode || item.episode,
- quality: (cachedStream.stream.title?.match(/(\d+)p/) || [])[1] || undefined,
- year: cachedStream.metadata?.year || item.year,
- streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name,
- streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream',
- headers: cachedStream.stream.headers || undefined,
- forceVlc: false,
- id: item.id,
- type: item.type,
- episodeId: episodeId,
- imdbId: cachedStream.imdbId || cachedStream.metadata?.imdbId || item.imdb_id,
- backdrop: cachedStream.metadata?.backdrop || item.banner,
- videoType: undefined, // Let player auto-detect
- } as any);
-
- return;
- }
-
- // No cached stream or cache failed, navigate to StreamsScreen
- logger.log(`📺 [ContinueWatching] No cached stream, navigating to StreamsScreen for ${item.name}`);
-
- if (item.type === 'series' && item.season && item.episode) {
- // For series, navigate to the specific episode
- navigation.navigate('Streams', {
- id: item.id,
- type: item.type,
- episodeId: episodeId
- });
- } else {
- // For movies or series without specific episode, navigate to main content
- navigation.navigate('Streams', {
- id: item.id,
- type: item.type
- });
- }
- } catch (error) {
- logger.warn('[ContinueWatching] Error handling content press:', error);
- // Fallback to StreamsScreen on any error
- if (item.type === 'series' && item.season && item.episode) {
- const episodeId = `${item.id}:${item.season}:${item.episode}`;
- navigation.navigate('Streams', {
- id: item.id,
- type: item.type,
- episodeId: episodeId
- });
- } else {
- navigation.navigate('Streams', {
- id: item.id,
- type: item.type
- });
- }
- }
- }, [navigation, settings.useCachedStreams, settings.openMetadataScreenWhenCacheDisabled]);
+ const handleContentPress = useCallback((id: string, type: string) => {
+ navigation.navigate('Metadata', { id, type });
+ }, [navigation]);
// Handle long press to delete (moved before renderContinueWatchingItem)
const handleLongPress = useCallback((item: ContinueWatchingItem) => {
@@ -841,28 +611,18 @@ const ContinueWatchingSection = React.forwardRef((props, re
// Memoized render function for continue watching items
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
handleContentPress(item)}
+ onPress={() => handleContentPress(item.id, item.type)}
onLongPress={() => handleLongPress(item)}
delayLongPress={800}
>
{/* Poster Image */}
-
+
((props, re
{/* Content Details */}
-
+
{(() => {
const isUpNext = item.type === 'series' && item.progress === 0;
return (
{item.name}
{isUpNext && (
-
- Up Next
+
+ Up Next
)}
@@ -930,24 +669,12 @@ const ContinueWatchingSection = React.forwardRef((props, re
if (item.type === 'series' && item.season && item.episode) {
return (
-
+
Season {item.season}
{item.episodeTitle && (
{item.episodeTitle}
@@ -957,13 +684,7 @@ const ContinueWatchingSection = React.forwardRef((props, re
);
} else {
return (
-
+
{item.year} • {item.type === 'movie' ? 'Movie' : 'Series'}
);
@@ -973,12 +694,7 @@ const ContinueWatchingSection = React.forwardRef((props, re
{/* Progress Bar */}
{item.progress > 0 && (
-
+
((props, re
]}
/>
-
+
{Math.round(item.progress)}% watched
)}
- ), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet]);
+ ), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId]);
// Memoized key extractor
const keyExtractor = useCallback((item: ContinueWatchingItem) => `continue-${item.id}-${item.type}`, []);
// Memoized item separator
- const ItemSeparator = useCallback(() => , [itemSpacing]);
+ const ItemSeparator = useCallback(() => , []);
// If no continue watching items, don't render anything
if (continueWatchingItems.length === 0) {
@@ -1016,27 +726,11 @@ const ContinueWatchingSection = React.forwardRef((props, re
}
return (
-
-
+
+
- Continue Watching
-
+ Continue Watching
+
@@ -1046,13 +740,7 @@ const ContinueWatchingSection = React.forwardRef((props, re
keyExtractor={keyExtractor}
horizontal
showsHorizontalScrollIndicator={false}
- contentContainerStyle={[
- styles.wideList,
- {
- paddingLeft: horizontalPadding,
- paddingRight: horizontalPadding
- }
- ]}
+ contentContainerStyle={styles.wideList}
ItemSeparatorComponent={ItemSeparator}
onEndReachedThreshold={0.7}
onEndReached={() => {}}
@@ -1080,6 +768,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
+ paddingHorizontal: 16,
marginBottom: 16,
},
titleContainer: {
@@ -1101,6 +790,7 @@ const styles = StyleSheet.create({
opacity: 0.8,
},
wideList: {
+ paddingHorizontal: 16,
paddingBottom: 8,
paddingTop: 4,
},
diff --git a/src/components/home/DropUpMenu.tsx b/src/components/home/DropUpMenu.tsx
index fe70f35..8df89ef 100644
--- a/src/components/home/DropUpMenu.tsx
+++ b/src/components/home/DropUpMenu.tsx
@@ -12,7 +12,6 @@ import {
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image';
-import { useTraktContext } from '../../contexts/TraktContext';
import { colors } from '../../styles/colors';
import Animated, {
useAnimatedStyle,
@@ -44,9 +43,6 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
const isDarkMode = useColorScheme() === 'dark';
const SNAP_THRESHOLD = 100;
- // Trakt integration
- const { isAuthenticated, isInWatchlist, isInCollection } = useTraktContext();
-
useEffect(() => {
if (visible) {
opacity.value = withTiming(1, { duration: 200 });
@@ -96,9 +92,6 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
// Robustly determine if the item is in the library (saved)
const isSaved = typeof isSavedProp === 'boolean' ? isSavedProp : !!item.inLibrary;
const isWatched = !!isWatchedProp;
- const inTraktWatchlist = isAuthenticated && isInWatchlist(item.id, item.type);
- const inTraktCollection = isAuthenticated && isInCollection(item.id, item.type);
-
let menuOptions = [
{
icon: 'bookmark',
@@ -124,22 +117,6 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
}
];
- // Add Trakt options if authenticated
- if (isAuthenticated) {
- menuOptions.push(
- {
- icon: 'playlist-add-check',
- label: inTraktWatchlist ? 'Remove from Trakt Watchlist' : 'Add to Trakt Watchlist',
- action: 'trakt-watchlist'
- },
- {
- icon: 'video-library',
- label: inTraktCollection ? 'Remove from Trakt Collection' : 'Add to Trakt Collection',
- action: 'trakt-collection'
- }
- );
- }
-
// If used in LibraryScreen, only show 'Remove from Library' if item is in library
if (isSavedProp === true) {
menuOptions = menuOptions.filter(opt => opt.action !== 'library' || isSaved);
diff --git a/src/components/home/HeroCarousel.tsx b/src/components/home/HeroCarousel.tsx
index 45aeb01..58b1398 100644
--- a/src/components/home/HeroCarousel.tsx
+++ b/src/components/home/HeroCarousel.tsx
@@ -19,6 +19,7 @@ if (Platform.OS === 'ios') {
liquidGlassAvailable = false;
}
}
+import { MaterialIcons } from '@expo/vector-icons';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
@@ -43,7 +44,7 @@ const HeroCarousel: React.FC = ({ items, loading = false }) =
const insets = useSafeAreaInsets();
const { settings } = useSettings();
- const data = useMemo(() => (items && items.length ? items.slice(0, 5) : []), [items]);
+ const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]);
const [activeIndex, setActiveIndex] = useState(0);
const [failedLogoIds, setFailedLogoIds] = useState>(new Set());
const scrollViewRef = useRef(null);
@@ -101,8 +102,7 @@ const HeroCarousel: React.FC = ({ items, loading = false }) =
},
});
- // Debounced activeIndex update to reduce JS bridge crossings
- const lastIndexUpdateRef = useRef(0);
+ // Derive the index reactively and only set state when it changes
useAnimatedReaction(
() => {
const idx = Math.round(scrollX.value / interval);
@@ -110,12 +110,6 @@ const HeroCarousel: React.FC = ({ items, loading = false }) =
},
(idx, prevIdx) => {
if (idx == null || idx === prevIdx) return;
-
- // Debounce updates to reduce JS bridge crossings
- const now = Date.now();
- if (now - lastIndexUpdateRef.current < 100) return; // 100ms debounce
- lastIndexUpdateRef.current = now;
-
// Clamp to bounds to avoid out-of-range access
const clamped = Math.max(0, Math.min(idx, data.length - 1));
runOnJS(setActiveIndex)(clamped);
@@ -129,20 +123,23 @@ const HeroCarousel: React.FC = ({ items, loading = false }) =
navigation.navigate('Metadata', { id, type });
}, [navigation]);
+ const handleNavigateToStreams = useCallback((id: string, type: any) => {
+ navigation.navigate('Streams', { id, type });
+ }, [navigation]);
+
// Container animation based on scroll - must be before early returns
- // TEMPORARILY DISABLED FOR PERFORMANCE TESTING
- // const containerAnimatedStyle = useAnimatedStyle(() => {
- // const translateX = scrollX.value;
- // const progress = Math.abs(translateX) / (data.length * (CARD_WIDTH + 16));
- //
- // // Very subtle scale animation for the entire container
- // const scale = 1 - progress * 0.01;
- // const clampedScale = Math.max(0.99, Math.min(1, scale));
- //
- // return {
- // transform: [{ scale: clampedScale }],
- // };
- // });
+ const containerAnimatedStyle = useAnimatedStyle(() => {
+ const translateX = scrollX.value;
+ const progress = Math.abs(translateX) / (data.length * (CARD_WIDTH + 16));
+
+ // Very subtle scale animation for the entire container
+ const scale = 1 - progress * 0.01;
+ const clampedScale = Math.max(0.99, Math.min(1, scale));
+
+ return {
+ transform: [{ scale: clampedScale }],
+ };
+ });
if (loading) {
return (
@@ -196,6 +193,18 @@ const HeroCarousel: React.FC = ({ items, loading = false }) =
item: StreamingContent;
insets: any;
}) => {
+ const animatedOpacity = useSharedValue(1);
+
+ useEffect(() => {
+ // Start with opacity 0 and animate to 1, but only if it's a new item
+ animatedOpacity.value = 0;
+ animatedOpacity.value = withTiming(1, { duration: 400 });
+ }, [item.id]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: animatedOpacity.value,
+ }));
+
return (
= ({ items, loading = false }) =
] as StyleProp}
pointerEvents="none"
>
-
{Platform.OS === 'android' ? (
= ({ items, loading = false }) =
locations={[0.4, 1]}
style={styles.backgroundOverlay as ViewStyle}
/>
-
+
);
});
@@ -254,8 +263,33 @@ const HeroCarousel: React.FC = ({ items, loading = false }) =
return (
-
- {/* Removed preload images for performance - let FastImage cache handle it naturally */}
+
+ {settings.enableHomeHeroBackground && data.length > 0 && (
+
+ {data[activeIndex + 1] && (
+
+ )}
+ {activeIndex > 0 && data[activeIndex - 1] && (
+
+ )}
+
+ )}
{settings.enableHomeHeroBackground && data[activeIndex] && (
= ({ items, loading = false }) =
decelerationRate="fast"
contentContainerStyle={contentPadding}
onScroll={scrollHandler}
- scrollEventThrottle={32}
+ scrollEventThrottle={8}
disableIntervalMomentum
pagingEnabled={false}
bounces={false}
@@ -293,6 +327,7 @@ const HeroCarousel: React.FC = ({ items, loading = false }) =
logoFailed={failedLogoIds.has(item.id)}
onLogoError={() => setFailedLogoIds((prev) => new Set(prev).add(item.id))}
onPressInfo={() => handleNavigateToMetadata(item.id, item.type)}
+ onPressPlay={() => handleNavigateToStreams(item.id, item.type)}
scrollX={scrollX}
index={index}
/>
@@ -308,12 +343,13 @@ interface CarouselCardProps {
colors: any;
logoFailed: boolean;
onLogoError: () => void;
+ onPressPlay: () => void;
onPressInfo: () => void;
scrollX: SharedValue;
index: number;
}
-const CarouselCard: React.FC = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index }) => {
+const CarouselCard: React.FC = memo(({ item, colors, logoFailed, onLogoError, onPressPlay, onPressInfo, scrollX, index }) => {
const [bannerLoaded, setBannerLoaded] = useState(false);
const [logoLoaded, setLogoLoaded] = useState(false);
@@ -347,28 +383,31 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail
opacity: logoOpacity.value,
}));
- // ULTRA-OPTIMIZED: Only animate the center card and ±1 neighbors
- // Use a simple distance-based approach instead of reading scrollX.value during render
- const shouldAnimate = useMemo(() => {
- // For now, animate all cards but with early exit in worklets
- // This avoids reading scrollX.value during render
- return true;
- }, [index]);
-
- // Combined animation for genres and actions (same calculation)
- const overlayAnimatedStyle = useAnimatedStyle(() => {
+ const genresAnimatedStyle = useAnimatedStyle(() => {
const translateX = scrollX.value;
const cardOffset = index * (CARD_WIDTH + 16);
const distance = Math.abs(translateX - cardOffset);
+ const maxDistance = (CARD_WIDTH + 16) * 0.5; // Smaller threshold for smoother transition
- // AGGRESSIVE early exit for cards far from center
- if (distance > (CARD_WIDTH + 16) * 1.2) {
- return { opacity: 0 };
- }
-
- const maxDistance = (CARD_WIDTH + 16) * 0.5;
+ // Hide genres when scrolling (not centered)
const progress = Math.min(distance / maxDistance, 1);
- const opacity = 1 - progress;
+ const opacity = 1 - progress; // Linear fade out
+ const clampedOpacity = Math.max(0, Math.min(1, opacity));
+
+ return {
+ opacity: clampedOpacity,
+ };
+ });
+
+ const actionsAnimatedStyle = useAnimatedStyle(() => {
+ const translateX = scrollX.value;
+ const cardOffset = index * (CARD_WIDTH + 16);
+ const distance = Math.abs(translateX - cardOffset);
+ const maxDistance = (CARD_WIDTH + 16) * 0.5; // Smaller threshold for smoother transition
+
+ // Hide actions when scrolling (not centered)
+ const progress = Math.min(distance / maxDistance, 1);
+ const opacity = 1 - progress; // Linear fade out
const clampedOpacity = Math.max(0, Math.min(1, opacity));
return {
@@ -376,20 +415,11 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail
};
});
- // ULTRA-OPTIMIZED: Only animate center card and ±1 neighbors
+ // Scroll-based animations
const cardAnimatedStyle = useAnimatedStyle(() => {
const translateX = scrollX.value;
const cardOffset = index * (CARD_WIDTH + 16);
const distance = Math.abs(translateX - cardOffset);
-
- // AGGRESSIVE early exit for cards far from center
- if (distance > (CARD_WIDTH + 16) * 1.5) {
- return {
- transform: [{ scale: 0.9 }],
- opacity: 0.7
- };
- }
-
const maxDistance = CARD_WIDTH + 16;
// Scale animation based on distance from center
@@ -406,40 +436,38 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail
};
});
- // TEMPORARILY DISABLED FOR PERFORMANCE TESTING
- // const bannerParallaxStyle = useAnimatedStyle(() => {
- // const translateX = scrollX.value;
- // const cardOffset = index * (CARD_WIDTH + 16);
- // const distance = translateX - cardOffset;
- //
- // // Reduced parallax effect to prevent displacement
- // const parallaxOffset = distance * 0.05;
- //
- // return {
- // transform: [{ translateX: parallaxOffset }],
- // };
- // });
+ const bannerParallaxStyle = useAnimatedStyle(() => {
+ const translateX = scrollX.value;
+ const cardOffset = index * (CARD_WIDTH + 16);
+ const distance = translateX - cardOffset;
+
+ // Reduced parallax effect to prevent displacement
+ const parallaxOffset = distance * 0.05;
+
+ return {
+ transform: [{ translateX: parallaxOffset }],
+ };
+ });
- // TEMPORARILY DISABLED FOR PERFORMANCE TESTING
- // const infoParallaxStyle = useAnimatedStyle(() => {
- // const translateX = scrollX.value;
- // const cardOffset = index * (CARD_WIDTH + 16);
- // const distance = Math.abs(translateX - cardOffset);
- // const maxDistance = CARD_WIDTH + 16;
- //
- // // Hide info section when scrolling (not centered)
- // const progress = distance / maxDistance;
- // const opacity = 1 - progress * 2; // Fade out faster when scrolling
- // const clampedOpacity = Math.max(0, Math.min(1, opacity));
- //
- // // Minimal parallax for info section to prevent displacement
- // const parallaxOffset = -(translateX - cardOffset) * 0.02;
- //
- // return {
- // transform: [{ translateY: parallaxOffset }],
- // opacity: clampedOpacity,
- // };
- // });
+ const infoParallaxStyle = useAnimatedStyle(() => {
+ const translateX = scrollX.value;
+ const cardOffset = index * (CARD_WIDTH + 16);
+ const distance = Math.abs(translateX - cardOffset);
+ const maxDistance = CARD_WIDTH + 16;
+
+ // Hide info section when scrolling (not centered)
+ const progress = distance / maxDistance;
+ const opacity = 1 - progress * 2; // Fade out faster when scrolling
+ const clampedOpacity = Math.max(0, Math.min(1, opacity));
+
+ // Minimal parallax for info section to prevent displacement
+ const parallaxOffset = -(translateX - cardOffset) * 0.02;
+
+ return {
+ transform: [{ translateY: parallaxOffset }],
+ opacity: clampedOpacity,
+ };
+ });
useEffect(() => {
if (bannerLoaded) {
@@ -460,8 +488,9 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail
}, [logoLoaded]);
return (
-
= memo(({ item, colors, logoFail
{!bannerLoaded && (
)}
-
+
= memo(({ item, colors, logoFail
{/* Static genres positioned absolutely over the card */}
{item.genres && (
-
-
- {item.genres.slice(0, 3).join(' • ')}
-
-
+
+ {item.genres.slice(0, 3).join(' • ')}
+
)}
+ {/* Static action buttons positioned absolutely over the card */}
+
+
+
+
+ Play
+
+
+
+ Info
+
+
+
{/* Static logo positioned absolutely over the card */}
{item.logo && !logoFailed && (
@@ -542,7 +594,7 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail
) : null}
-
+
);
});
@@ -679,6 +731,31 @@ const styles = StyleSheet.create({
height: 64,
marginBottom: 6,
},
+ playButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ borderRadius: 24,
+ },
+ playText: {
+ fontWeight: '700',
+ marginLeft: 6,
+ fontSize: 14,
+ },
+ secondaryButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 14,
+ paddingVertical: 9,
+ borderRadius: 22,
+ borderWidth: 1,
+ },
+ secondaryText: {
+ fontWeight: '600',
+ marginLeft: 6,
+ fontSize: 14,
+ },
logoOverlay: {
position: 'absolute',
left: 0,
@@ -687,7 +764,7 @@ const styles = StyleSheet.create({
bottom: 0,
alignItems: 'center',
justifyContent: 'flex-end',
- paddingBottom: 40, // Position above genres
+ paddingBottom: 80, // Position above genres and actions
},
titleOverlay: {
position: 'absolute',
@@ -697,9 +774,19 @@ const styles = StyleSheet.create({
bottom: 0,
alignItems: 'center',
justifyContent: 'flex-end',
- paddingBottom: 50, // Position above genres
+ paddingBottom: 90, // Position above genres and actions
},
genresOverlay: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ paddingBottom: 65, // Position above actions
+ },
+ actionsOverlay: {
position: 'absolute',
left: 0,
right: 0,
diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx
index f9a53a6..96f14f4 100644
--- a/src/components/home/ThisWeekSection.tsx
+++ b/src/components/home/ThisWeekSection.tsx
@@ -18,7 +18,7 @@ import { useTraktContext } from '../../contexts/TraktContext';
import { useLibrary } from '../../hooks/useLibrary';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { parseISO, isThisWeek, format, isAfter, isBefore } from 'date-fns';
-import Animated, { FadeIn, Layout } from 'react-native-reanimated';
+import Animated, { FadeIn } from 'react-native-reanimated';
import { useCalendarData } from '../../hooks/useCalendarData';
import { memoryManager } from '../../utils/memoryManager';
import { tmdbService } from '../../services/tmdbService';
@@ -28,14 +28,6 @@ const { width } = Dimensions.get('window');
const ITEM_WIDTH = width * 0.75; // phone default
const ITEM_HEIGHT = 180; // phone default
-// Enhanced responsive breakpoints
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-};
-
interface ThisWeekEpisode {
id: string;
seriesId: string;
@@ -57,77 +49,11 @@ export const ThisWeekSection = React.memo(() => {
const { currentTheme } = useTheme();
const { calendarData, loading } = useCalendarData();
- // Enhanced responsive sizing for tablets and TV screens
+ // Responsive sizing for tablets
const deviceWidth = Dimensions.get('window').width;
- const deviceHeight = Dimensions.get('window').height;
-
- // Determine device type based on width
- const getDeviceType = useCallback(() => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- }, [deviceWidth]);
-
- const deviceType = getDeviceType();
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
- const isLargeScreen = isTablet || isLargeTablet || isTV;
-
- // Enhanced responsive sizing
- const computedItemWidth = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return Math.min(deviceWidth * 0.25, 400); // 4 items per row on TV
- case 'largeTablet':
- return Math.min(deviceWidth * 0.35, 350); // 3 items per row on large tablet
- case 'tablet':
- return Math.min(deviceWidth * 0.46, 300); // 2 items per row on tablet
- default:
- return ITEM_WIDTH; // phone
- }
- }, [deviceType, deviceWidth]);
-
- const computedItemHeight = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 280;
- case 'largeTablet':
- return 250;
- case 'tablet':
- return 220;
- default:
- return ITEM_HEIGHT; // phone
- }
- }, [deviceType]);
-
- // Enhanced spacing and padding
- const horizontalPadding = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 32;
- case 'largeTablet':
- return 28;
- case 'tablet':
- return 24;
- default:
- return 16; // phone
- }
- }, [deviceType]);
-
- const itemSpacing = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 20;
- case 'largeTablet':
- return 18;
- case 'tablet':
- return 16;
- default:
- return 16; // phone
- }
- }, [deviceType]);
+ const isTablet = deviceWidth >= 768;
+ const computedItemWidth = useMemo(() => (isTablet ? Math.min(deviceWidth * 0.46, 560) : ITEM_WIDTH), [isTablet, deviceWidth]);
+ const computedItemHeight = useMemo(() => (isTablet ? 220 : ITEM_HEIGHT), [isTablet]);
// Use the already memory-optimized calendar data instead of fetching separately
const thisWeekEpisodes = useMemo(() => {
@@ -218,70 +144,35 @@ export const ThisWeekSection = React.memo(() => {
'rgba(0,0,0,0.8)',
'rgba(0,0,0,0.95)'
]}
- style={[
- styles.gradient,
- {
- padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
- }
- ]}
+ style={styles.gradient}
locations={[0, 0.4, 0.6, 0.8, 1]}
>
{/* Content area */}
-
+
{item.seriesName}
-
+
{item.title}
{item.overview && (
-
+
{item.overview}
)}
-
+
S{item.season}:E{item.episode} •
-
+
{formattedDate}
@@ -294,47 +185,15 @@ export const ThisWeekSection = React.memo(() => {
};
return (
-
-
+
+
- This Week
-
+ This Week
+
-
- View All
-
+
+ View All
+
@@ -344,26 +203,20 @@ export const ThisWeekSection = React.memo(() => {
renderItem={renderEpisodeItem}
horizontal
showsHorizontalScrollIndicator={false}
- contentContainerStyle={[
- styles.listContent,
- {
- paddingLeft: horizontalPadding,
- paddingRight: horizontalPadding
- }
- ]}
- snapToInterval={computedItemWidth + itemSpacing}
+ contentContainerStyle={[styles.listContent, { paddingLeft: isTablet ? 24 : 16, paddingRight: isTablet ? 24 : 16 }]}
+ snapToInterval={computedItemWidth + 16}
decelerationRate="fast"
snapToAlignment="start"
- initialNumToRender={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3}
- windowSize={isTV ? 4 : isLargeTablet ? 4 : 3}
- maxToRenderPerBatch={isTV ? 4 : isLargeTablet ? 4 : 3}
+ initialNumToRender={isTablet ? 4 : 3}
+ windowSize={3}
+ maxToRenderPerBatch={3}
removeClippedSubviews
getItemLayout={(data, index) => {
- const length = computedItemWidth + itemSpacing;
+ const length = computedItemWidth + 16;
const offset = length * index;
return { length, offset, index };
}}
- ItemSeparatorComponent={() => }
+ ItemSeparatorComponent={() => }
/>
);
@@ -377,6 +230,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
+ paddingHorizontal: 16,
marginBottom: 16,
},
titleContainer: {
@@ -412,6 +266,8 @@ const styles = StyleSheet.create({
marginRight: 4,
},
listContent: {
+ paddingLeft: 16,
+ paddingRight: 16,
paddingBottom: 8,
},
loadingContainer: {
@@ -457,7 +313,7 @@ const styles = StyleSheet.create({
padding: 12,
borderRadius: 16,
},
- contentArea: {
+ contentArea: {
width: '100%',
},
seriesName: {
diff --git a/src/components/metadata/CastSection.tsx b/src/components/metadata/CastSection.tsx
index 3f24812..00f4c19 100644
--- a/src/components/metadata/CastSection.tsx
+++ b/src/components/metadata/CastSection.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo } from 'react';
+import React from 'react';
import {
View,
Text,
@@ -6,7 +6,6 @@ import {
FlatList,
TouchableOpacity,
ActivityIndicator,
- Dimensions,
} from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import Animated, {
@@ -14,14 +13,6 @@ import Animated, {
} from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
-// Enhanced responsive breakpoints for Cast Section
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-};
-
interface CastSectionProps {
cast: any[];
loadingCast: boolean;
@@ -37,78 +28,6 @@ export const CastSection: React.FC = ({
}) => {
const { currentTheme } = useTheme();
- // Enhanced responsive sizing for tablets and TV screens
- const deviceWidth = Dimensions.get('window').width;
- const deviceHeight = Dimensions.get('window').height;
-
- // Determine device type based on width
- const getDeviceType = useCallback(() => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- }, [deviceWidth]);
-
- const deviceType = getDeviceType();
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
- const isLargeScreen = isTablet || isLargeTablet || isTV;
-
- // Enhanced spacing and padding
- const horizontalPadding = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 32;
- case 'largeTablet':
- return 28;
- case 'tablet':
- return 24;
- default:
- return 16; // phone
- }
- }, [deviceType]);
-
- // Enhanced cast card sizing
- const castCardWidth = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 120;
- case 'largeTablet':
- return 110;
- case 'tablet':
- return 100;
- default:
- return 90; // phone
- }
- }, [deviceType]);
-
- const castImageSize = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 100;
- case 'largeTablet':
- return 90;
- case 'tablet':
- return 85;
- default:
- return 80; // phone
- }
- }, [deviceType]);
-
- const castCardSpacing = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 20;
- case 'largeTablet':
- return 18;
- case 'tablet':
- return 16;
- default:
- return 16; // phone
- }
- }, [deviceType]);
-
if (loadingCast) {
return (
@@ -126,52 +45,25 @@ export const CastSection: React.FC = ({
style={styles.castSection}
entering={FadeIn.duration(300).delay(150)}
>
-
- Cast
+
+ Cast
item.id.toString()}
renderItem={({ item, index }) => (
onSelectCastMember(item)}
activeOpacity={0.7}
>
-
+
{item.profile_path ? (
= ({
resizeMode={FastImage.resizeMode.cover}
/>
) : (
-
-
+
+
{item.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)}
)}
- {item.name}
+ {item.name}
{isTmdbEnrichmentEnabled && item.character && (
- {item.character}
+ {item.character}
)}
@@ -242,12 +107,14 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
+ paddingHorizontal: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
},
castList: {
+ paddingHorizontal: 16,
paddingBottom: 4,
},
castCard: {
diff --git a/src/components/metadata/CollectionSection.tsx b/src/components/metadata/CollectionSection.tsx
deleted file mode 100644
index 2b57f58..0000000
--- a/src/components/metadata/CollectionSection.tsx
+++ /dev/null
@@ -1,234 +0,0 @@
-import React from 'react';
-import {
- View,
- Text,
- StyleSheet,
- FlatList,
- TouchableOpacity,
- ActivityIndicator,
- Dimensions,
-} from 'react-native';
-import FastImage from '@d11/react-native-fast-image';
-import { useNavigation, StackActions } from '@react-navigation/native';
-import { NavigationProp } from '@react-navigation/native';
-import { RootStackParamList } from '../../navigation/AppNavigator';
-import { StreamingContent } from '../../services/catalogService';
-import { useTheme } from '../../contexts/ThemeContext';
-import { TMDBService } from '../../services/tmdbService';
-import { catalogService } from '../../services/catalogService';
-import CustomAlert from '../../components/CustomAlert';
-
-const { width } = Dimensions.get('window');
-
-// Breakpoints for responsive sizing
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-} as const;
-
-interface CollectionSectionProps {
- collectionName: string;
- collectionMovies: StreamingContent[];
- loadingCollection: boolean;
-}
-
-export const CollectionSection: React.FC = ({
- collectionName,
- collectionMovies,
- loadingCollection
-}) => {
- const { currentTheme } = useTheme();
- const navigation = useNavigation>();
-
- // Determine device type
- const deviceWidth = Dimensions.get('window').width;
- const getDeviceType = React.useCallback(() => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- }, [deviceWidth]);
- const deviceType = getDeviceType();
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
-
- // Responsive spacing & sizes
- const horizontalPadding = React.useMemo(() => {
- switch (deviceType) {
- case 'tv': return 32;
- case 'largeTablet': return 28;
- case 'tablet': return 24;
- default: return 16;
- }
- }, [deviceType]);
-
- const itemSpacing = React.useMemo(() => {
- switch (deviceType) {
- case 'tv': return 14;
- case 'largeTablet': return 12;
- case 'tablet': return 12;
- default: return 12;
- }
- }, [deviceType]);
-
- const backdropWidth = React.useMemo(() => {
- switch (deviceType) {
- case 'tv': return 240;
- case 'largeTablet': return 220;
- case 'tablet': return 200;
- default: return 180;
- }
- }, [deviceType]);
- const backdropHeight = React.useMemo(() => backdropWidth * (9/16), [backdropWidth]); // 16:9 aspect ratio
-
- const [alertVisible, setAlertVisible] = React.useState(false);
- const [alertTitle, setAlertTitle] = React.useState('');
- const [alertMessage, setAlertMessage] = React.useState('');
- const [alertActions, setAlertActions] = React.useState([]);
-
- const handleItemPress = async (item: StreamingContent) => {
- try {
- // Extract TMDB ID from the tmdb:123456 format
- const tmdbId = item.id.replace('tmdb:', '');
-
- // Get Stremio ID directly using catalogService
- const stremioId = await catalogService.getStremioId(item.type, tmdbId);
-
- if (stremioId) {
- navigation.dispatch(
- StackActions.push('Metadata', {
- id: stremioId,
- type: item.type
- })
- );
- } else {
- throw new Error('Could not find Stremio ID');
- }
- } catch (error) {
- if (__DEV__) console.error('Error navigating to collection item:', error);
- setAlertTitle('Error');
- setAlertMessage('Unable to load this content. Please try again later.');
- setAlertActions([{ label: 'OK', onPress: () => {} }]);
- setAlertVisible(true);
- }
- };
-
- const renderItem = ({ item }: { item: StreamingContent }) => (
- handleItemPress(item)}
- >
-
-
- {item.name}
-
- {item.year && (
-
- {item.year}
-
- )}
-
- );
-
- if (loadingCollection) {
- return (
-
-
-
- );
- }
-
- if (!collectionMovies || collectionMovies.length === 0) {
- return null; // Don't render anything if there are no collection movies
- }
-
- return (
-
-
- {collectionName}
-
- item.id}
- horizontal
- showsHorizontalScrollIndicator={false}
- contentContainerStyle={[styles.listContentContainer, {
- paddingHorizontal: horizontalPadding,
- paddingRight: horizontalPadding + itemSpacing
- }]}
- />
- setAlertVisible(false)}
- />
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- marginTop: 16,
- marginBottom: 16,
- },
- sectionTitle: {
- fontSize: 20,
- fontWeight: '800',
- marginBottom: 12,
- marginTop: 8,
- },
- listContentContainer: {
- paddingRight: 32, // Will be overridden responsively
- },
- itemContainer: {
- marginRight: 12, // will be overridden responsively
- },
- backdrop: {
- borderRadius: 8, // overridden responsively
- marginBottom: 8,
- },
- title: {
- fontSize: 13, // overridden responsively
- fontWeight: '500',
- lineHeight: 18, // overridden responsively
- marginBottom: 2,
- },
- year: {
- fontSize: 11, // overridden responsively
- fontWeight: '400',
- opacity: 0.8,
- },
- loadingContainer: {
- justifyContent: 'center',
- alignItems: 'center',
- paddingVertical: 20,
- },
-});
-
-export default CollectionSection;
diff --git a/src/components/metadata/CommentsSection.tsx b/src/components/metadata/CommentsSection.tsx
index db1cf2a..60b99c6 100644
--- a/src/components/metadata/CommentsSection.tsx
+++ b/src/components/metadata/CommentsSection.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useState, useRef, useMemo } from 'react';
+import React, { useCallback, useState, useRef } from 'react';
import {
View,
Text,
@@ -21,13 +21,7 @@ import { useTraktComments } from '../../hooks/useTraktComments';
import { useSettings } from '../../hooks/useSettings';
import BottomSheet, { BottomSheetView, BottomSheetScrollView } from '@gorhom/bottom-sheet';
-// Enhanced responsive breakpoints for Comments Section
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-};
+const { width } = Dimensions.get('window');
interface CommentsSectionProps {
imdbId: string;
@@ -197,64 +191,6 @@ const CompactCommentCard: React.FC<{
}).start();
}, [fadeInOpacity]);
- // Enhanced responsive sizing for tablets and TV screens
- const deviceWidth = Dimensions.get('window').width;
- const deviceHeight = Dimensions.get('window').height;
-
- // Determine device type based on width
- const getDeviceType = useCallback(() => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- }, [deviceWidth]);
-
- const deviceType = getDeviceType();
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
- const isLargeScreen = isTablet || isLargeTablet || isTV;
-
- // Enhanced comment card sizing
- const commentCardWidth = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 360;
- case 'largeTablet':
- return 320;
- case 'tablet':
- return 300;
- default:
- return 280; // phone
- }
- }, [deviceType]);
-
- const commentCardHeight = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 200;
- case 'largeTablet':
- return 185;
- case 'tablet':
- return 175;
- default:
- return 170; // phone
- }
- }, [deviceType]);
-
- const commentCardSpacing = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 16;
- case 'largeTablet':
- return 14;
- case 'tablet':
- return 12;
- default:
- return 12; // phone
- }
- }, [deviceType]);
-
// Safety check - ensure comment data exists
if (!comment || !comment.comment) {
return null;
@@ -336,11 +272,6 @@ const CompactCommentCard: React.FC<{
borderColor: theme.colors.border,
opacity: fadeInOpacity,
transform: isPressed ? [{ scale: 0.98 }] : [{ scale: 1 }],
- width: commentCardWidth,
- height: commentCardHeight,
- marginRight: commentCardSpacing,
- padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
- borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
},
]}
>
@@ -356,41 +287,18 @@ const CompactCommentCard: React.FC<{
>
{/* Trakt Icon - Top Right Corner */}
-
+
{/* Header Section - Fixed at top */}
-
+
-
+
{username}
{user.vip && (
-
- VIP
+
+ VIP
)}
@@ -398,107 +306,48 @@ const CompactCommentCard: React.FC<{
{/* Rating - Show stars */}
{comment.user_stats?.rating && (
-
+
{renderCompactStars(comment.user_stats.rating)}
-
+
{comment.user_stats.rating}/10
)}
{/* Comment Preview - Flexible area that fills space */}
-
+
{shouldBlurContent ? (
- ⚠️ This comment contains spoilers. Tap to reveal.
+ ⚠️ This comment contains spoilers. Tap to reveal.
) : (
)}
{/* Meta Info - Fixed at bottom */}
-
+
{comment.spoiler && (
- Spoiler
+ Spoiler
)}
-
+
{formatRelativeTime(comment.created_at)}
{comment.likes > 0 && (
-
+
👍 {comment.likes}
)}
{comment.replies > 0 && (
-
+
💬 {comment.replies}
)}
@@ -729,38 +578,6 @@ export const CommentsSection: React.FC = ({
const { settings } = useSettings();
const [hasLoadedOnce, setHasLoadedOnce] = React.useState(false);
- // Enhanced responsive sizing for tablets and TV screens
- const deviceWidth = Dimensions.get('window').width;
- const deviceHeight = Dimensions.get('window').height;
-
- // Determine device type based on width
- const getDeviceType = useCallback(() => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- }, [deviceWidth]);
-
- const deviceType = getDeviceType();
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
- const isLargeScreen = isTablet || isLargeTablet || isTV;
-
- // Enhanced spacing and padding
- const horizontalPadding = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 32;
- case 'largeTablet':
- return 28;
- case 'tablet':
- return 24;
- default:
- return 16; // phone
- }
- }, [deviceType]);
-
const {
comments,
loading,
@@ -837,66 +654,41 @@ export const CommentsSection: React.FC = ({
const renderSkeletons = useCallback(() => {
const placeholders = [0, 1, 2];
- // Responsive skeleton sizes to match CompactCommentCard
- const skWidth = isTV ? 360 : isLargeTablet ? 320 : isTablet ? 300 : 280;
- const skHeight = isTV ? 200 : isLargeTablet ? 185 : isTablet ? 175 : 170;
- const skPad = isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12;
- const gap = isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12;
- const headLineWidth = isTV ? 160 : isLargeTablet ? 140 : isTablet ? 130 : 120;
- const ratingWidth = isTV ? 100 : isLargeTablet ? 90 : isTablet ? 85 : 80;
- const statWidth = isTV ? 44 : isLargeTablet ? 40 : isTablet ? 38 : 36;
- const badgeW = isTV ? 60 : isLargeTablet ? 56 : isTablet ? 52 : 50;
- const badgeH = isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12;
-
return (
-
+
{placeholders.map((i) => (
-
+
-
+
-
-
-
+
+
+
-
-
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
))}
);
- }, [currentTheme, isTV, isLargeTablet, isTablet]);
+ }, [currentTheme]);
// Don't show section if not authenticated, if comments are disabled in settings, or if still checking authentication
// Only show when authentication is definitively true and settings allow it
@@ -913,23 +705,9 @@ export const CommentsSection: React.FC = ({
}
return (
-
-
-
+
+
+
Trakt Comments
@@ -966,14 +744,11 @@ export const CommentsSection: React.FC = ({
renderItem={renderComment}
contentContainerStyle={styles.horizontalList}
removeClippedSubviews={false}
- getItemLayout={(data, index) => {
- const itemWidth = isTV ? 376 : isLargeTablet ? 334 : isTablet ? 312 : 292; // width + marginRight
- return {
- length: itemWidth,
- offset: itemWidth * index,
- index,
- };
- }}
+ getItemLayout={(data, index) => ({
+ length: 292, // width + marginRight
+ offset: 292 * index,
+ index,
+ })}
onEndReached={() => {
if (hasMore && !loading) {
loadMore();
@@ -1216,6 +991,7 @@ export const CommentBottomSheet: React.FC<{
const styles = StyleSheet.create({
container: {
+ padding: 16,
marginBottom: 24,
},
header: {
@@ -1232,7 +1008,11 @@ const styles = StyleSheet.create({
paddingRight: 16,
},
compactCard: {
+ width: 280,
+ height: 170,
+ padding: 12,
paddingBottom: 16,
+ marginRight: 12,
borderRadius: 12,
borderWidth: 1,
shadowColor: '#000',
diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx
index 24ab07c..64fef14 100644
--- a/src/components/metadata/HeroSection.tsx
+++ b/src/components/metadata/HeroSection.tsx
@@ -47,7 +47,6 @@ import Animated, {
SharedValue,
} from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
-import { useToast } from '../../contexts/ToastContext';
import { useTraktContext } from '../../contexts/TraktContext';
import { useSettings } from '../../hooks/useSettings';
import { useTrailer } from '../../contexts/TrailerContext';
@@ -95,12 +94,6 @@ interface HeroSectionProps {
getPlayButtonText: () => string;
setBannerImage: (bannerImage: string | null) => void;
groupedEpisodes?: { [seasonNumber: number]: any[] };
- // Trakt integration props
- isAuthenticated?: boolean;
- isInWatchlist?: boolean;
- isInCollection?: boolean;
- onToggleWatchlist?: () => void;
- onToggleCollection?: () => void;
dynamicBackgroundColor?: string;
handleBack: () => void;
tmdbId?: number | null;
@@ -121,13 +114,7 @@ const ActionButtons = memo(({
groupedEpisodes,
metadata,
aiChatEnabled,
- settings,
- // Trakt integration props
- isAuthenticated,
- isInWatchlist,
- isInCollection,
- onToggleWatchlist,
- onToggleCollection
+ settings
}: {
handleShowStreams: () => void;
toggleLibrary: () => void;
@@ -143,15 +130,8 @@ const ActionButtons = memo(({
metadata: any;
aiChatEnabled?: boolean;
settings: any;
- // Trakt integration props
- isAuthenticated?: boolean;
- isInWatchlist?: boolean;
- isInCollection?: boolean;
- onToggleWatchlist?: () => void;
- onToggleCollection?: () => void;
}) => {
const { currentTheme } = useTheme();
- const { showSaved, showTraktSaved, showRemoved, showTraktRemoved, showSuccess, showInfo } = useToast();
// Performance optimization: Cache theme colors
const themeColors = useMemo(() => ({
@@ -198,51 +178,6 @@ const ActionButtons = memo(({
}
}, [id, navigation, settings.enrichMetadataWithTMDB]);
- // Enhanced save handler that combines local library + Trakt watchlist
- const handleSaveAction = useCallback(async () => {
- const wasInLibrary = inLibrary;
-
- // Always toggle local library first
- toggleLibrary();
-
- // If authenticated, also toggle Trakt watchlist
- if (isAuthenticated && onToggleWatchlist) {
- await onToggleWatchlist();
- }
-
- // Show appropriate toast
- if (isAuthenticated) {
- if (wasInLibrary) {
- showTraktRemoved();
- } else {
- showTraktSaved();
- }
- } else {
- if (wasInLibrary) {
- showRemoved();
- } else {
- showSaved();
- }
- }
- }, [toggleLibrary, isAuthenticated, onToggleWatchlist, inLibrary, showSaved, showTraktSaved, showRemoved, showTraktRemoved]);
-
- // Enhanced collection handler with toast notifications
- const handleCollectionAction = useCallback(async () => {
- const wasInCollection = isInCollection;
-
- // Toggle collection
- if (onToggleCollection) {
- await onToggleCollection();
- }
-
- // Show appropriate toast
- if (wasInCollection) {
- showInfo('Removed from Collection', 'Removed from your Trakt collection');
- } else {
- showSuccess('Added to Collection', 'Added to your Trakt collection');
- }
- }, [onToggleCollection, isInCollection, showSuccess, showInfo]);
-
// Optimized play button style calculation
const playButtonStyle = useMemo(() => {
if (isWatched && type === 'movie') {
@@ -335,332 +270,125 @@ const ActionButtons = memo(({
return isWatched ? 'Play' : playButtonText;
}, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]);
- // Determine if we should show buttons in a single row (Play, Save, and optionally one other button)
- const hasAiChat = aiChatEnabled;
- const hasTraktCollection = isAuthenticated;
- const hasRatings = type === 'series';
-
- // Count additional buttons (excluding Play and Save)
- const additionalButtonCount = (hasAiChat ? 1 : 0) + (hasTraktCollection ? 1 : 0) + (hasRatings ? 1 : 0);
-
- // Show single row when there are 0 additional buttons (2 total: Play + Save) or 1 additional button (3 total)
- const shouldShowSingleRow = additionalButtonCount <= 1;
-
return (
- {shouldShowSingleRow ? (
- /* Single Row Layout - Play, Save, and optionally one other button (2-3 total) */
-
-
- {
- if (isWatched) {
- return type === 'movie' ? 'replay' : 'play-arrow';
- }
- return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow';
- })()}
- size={isTablet ? 28 : 24}
- color={isWatched && type === 'movie' ? "#fff" : "#000"}
+
+ {
+ if (isWatched) {
+ return type === 'movie' ? 'replay' : 'play-arrow';
+ }
+ return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow';
+ })()}
+ size={isTablet ? 28 : 24}
+ color={isWatched && type === 'movie' ? "#fff" : "#000"}
+ />
+ {finalPlayButtonText}
+
+
+
+ {Platform.OS === 'ios' ? (
+ GlassViewComp && liquidGlassAvailable ? (
+
- {finalPlayButtonText}
-
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+ {inLibrary ? 'Saved' : 'Save'}
+
+
-
- {Platform.OS === 'ios' ? (
- GlassViewComp && liquidGlassAvailable ? (
-
- ) : (
-
- )
- ) : (
-
- )}
- {
+ // Extract episode info if it's a series
+ let episodeData = null;
+ if (type === 'series' && watchProgress?.episodeId) {
+ const parts = watchProgress.episodeId.split(':');
+ if (parts.length >= 3) {
+ episodeData = {
+ seasonNumber: parseInt(parts[1], 10),
+ episodeNumber: parseInt(parts[2], 10)
+ };
+ }
+ }
+
+ navigation.navigate('AIChat', {
+ contentId: id,
+ contentType: type,
+ episodeId: episodeData ? watchProgress.episodeId : undefined,
+ seasonNumber: episodeData?.seasonNumber,
+ episodeNumber: episodeData?.episodeNumber,
+ title: metadata?.name || metadata?.title || 'Unknown'
+ });
+ }}
+ activeOpacity={0.85}
+ >
+ {Platform.OS === 'ios' ? (
+ GlassViewComp && liquidGlassAvailable ? (
+
-
- {inLibrary ? 'Saved' : 'Save'}
-
-
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+ )}
- {/* Third Button - AI Chat, Trakt Collection, or Ratings (only if available) */}
- {hasAiChat && additionalButtonCount === 1 && (
- {
- // Extract episode info if it's a series
- let episodeData = null;
- if (type === 'series' && watchProgress?.episodeId) {
- const parts = watchProgress.episodeId.split(':');
- if (parts.length >= 3) {
- episodeData = {
- seasonNumber: parseInt(parts[1], 10),
- episodeNumber: parseInt(parts[2], 10)
- };
- }
- }
-
- navigation.navigate('AIChat', {
- contentId: id,
- contentType: type,
- episodeId: episodeData ? watchProgress.episodeId : undefined,
- seasonNumber: episodeData?.seasonNumber,
- episodeNumber: episodeData?.episodeNumber,
- title: metadata?.name || metadata?.title || 'Unknown'
- });
- }}
- activeOpacity={0.85}
- >
- {Platform.OS === 'ios' ? (
- GlassViewComp && liquidGlassAvailable ? (
-
- ) : (
-
- )
- ) : (
-
- )}
-
-
- )}
-
- {hasTraktCollection && !hasAiChat && additionalButtonCount === 1 && (
-
- {Platform.OS === 'ios' ? (
- GlassViewComp && liquidGlassAvailable ? (
-
- ) : (
-
- )
- ) : (
-
- )}
-
-
- )}
-
- {hasRatings && !hasAiChat && !hasTraktCollection && additionalButtonCount === 1 && (
-
- {Platform.OS === 'ios' ? (
- GlassViewComp && liquidGlassAvailable ? (
-
- ) : (
-
- )
- ) : (
-
- )}
-
-
- )}
-
- ) : (
- <>
- {/* Play Button Row - Only Play button */}
-
-
- {
- if (isWatched) {
- return type === 'movie' ? 'replay' : 'play-arrow';
- }
- return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow';
- })()}
- size={isTablet ? 28 : 24}
- color={isWatched && type === 'movie' ? "#fff" : "#000"}
- />
- {finalPlayButtonText}
-
-
-
- {/* Secondary Action Row - All other buttons */}
-
- {/* Save Button */}
+ {type === 'series' && (
{Platform.OS === 'ios' ? (
GlassViewComp && liquidGlassAvailable ? (
) : (
-
+
)
) : (
-
+
)}
-
-
- {inLibrary ? 'Saved' : 'Save'}
-
-
- {/* AI Chat Button */}
- {aiChatEnabled && (
- {
- // Extract episode info if it's a series
- let episodeData = null;
- if (type === 'series' && watchProgress?.episodeId) {
- const parts = watchProgress.episodeId.split(':');
- if (parts.length >= 3) {
- episodeData = {
- seasonNumber: parseInt(parts[1], 10),
- episodeNumber: parseInt(parts[2], 10)
- };
- }
- }
-
- navigation.navigate('AIChat', {
- contentId: id,
- contentType: type,
- episodeId: episodeData ? watchProgress.episodeId : undefined,
- seasonNumber: episodeData?.seasonNumber,
- episodeNumber: episodeData?.episodeNumber,
- title: metadata?.name || metadata?.title || 'Unknown'
- });
- }}
- activeOpacity={0.85}
- >
- {Platform.OS === 'ios' ? (
- GlassViewComp && liquidGlassAvailable ? (
-
- ) : (
-
- )
- ) : (
-
- )}
-
-
- )}
-
- {/* Trakt Collection Button */}
- {isAuthenticated && (
-
- {Platform.OS === 'ios' ? (
- GlassViewComp && liquidGlassAvailable ? (
-
- ) : (
-
- )
- ) : (
-
- )}
-
-
- )}
-
- {/* Ratings Button (for series) */}
- {type === 'series' && (
-
- {Platform.OS === 'ios' ? (
- GlassViewComp && liquidGlassAvailable ? (
-
- ) : (
-
- )
- ) : (
-
- )}
-
-
- )}
-
- >
)}
);
@@ -1064,12 +792,6 @@ const HeroSection: React.FC = memo(({
dynamicBackgroundColor,
handleBack,
tmdbId,
- // Trakt integration props
- isAuthenticated,
- isInWatchlist,
- isInCollection,
- onToggleWatchlist,
- onToggleCollection
}) => {
const { currentTheme } = useTheme();
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
@@ -1851,7 +1573,7 @@ const HeroSection: React.FC = memo(({
// When unmuting, hide action buttons, genre, title card, and watch progress
actionButtonsOpacity.value = withTiming(0, { duration: 300 });
genreOpacity.value = withTiming(0, { duration: 300 });
- titleCardTranslateY.value = withTiming(100, { duration: 300 }); // Increased from 60 to 120 for further down movement
+ titleCardTranslateY.value = withTiming(60, { duration: 300 });
watchProgressOpacity.value = withTiming(0, { duration: 300 });
} else {
// When muting, show action buttons, genre, title card, and watch progress
@@ -1978,12 +1700,6 @@ const HeroSection: React.FC = memo(({
metadata={metadata}
aiChatEnabled={settings?.aiChatEnabled}
settings={settings}
- // Trakt integration props
- isAuthenticated={isAuthenticated}
- isInWatchlist={isInWatchlist}
- isInCollection={isInCollection}
- onToggleWatchlist={onToggleWatchlist}
- onToggleCollection={onToggleCollection}
/>
@@ -2129,8 +1845,8 @@ const styles = StyleSheet.create({
paddingVertical: 0,
},
actionButtons: {
- flexDirection: 'column',
- gap: 12,
+ flexDirection: 'row',
+ gap: 8,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
@@ -2138,58 +1854,6 @@ const styles = StyleSheet.create({
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
},
- singleRowLayout: {
- flexDirection: 'row',
- gap: 4,
- alignItems: 'center',
- justifyContent: 'center',
- width: '100%',
- maxWidth: isTablet ? 600 : '100%',
- alignSelf: 'center',
- },
- singleRowPlayButton: {
- flex: 2,
- maxWidth: isTablet ? 200 : 150,
- },
- singleRowSaveButton: {
- flex: 2,
- maxWidth: isTablet ? 200 : 150,
- },
- singleRowIconButton: {
- width: isTablet ? 50 : 44,
- height: isTablet ? 50 : 44,
- borderRadius: isTablet ? 25 : 22,
- flex: 0,
- },
- singleRowPlayButtonFullWidth: {
- flex: 1,
- marginHorizontal: 2,
- },
- singleRowSaveButtonFullWidth: {
- flex: 1,
- marginHorizontal: 2,
- },
- primaryActionRow: {
- flexDirection: 'row',
- gap: 12,
- alignItems: 'center',
- justifyContent: 'center',
- width: '100%',
- },
- playButtonRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- width: '100%',
- },
- secondaryActionRow: {
- flexDirection: 'row',
- gap: 12,
- alignItems: 'center',
- justifyContent: 'center',
- width: '100%',
- flexWrap: 'wrap',
- },
actionButton: {
flexDirection: 'row',
alignItems: 'center',
@@ -2222,16 +1886,6 @@ const styles = StyleSheet.create({
justifyContent: 'center',
overflow: 'hidden',
},
- traktButton: {
- width: 50,
- height: 50,
- borderRadius: 25,
- borderWidth: 1.5,
- borderColor: 'rgba(255,255,255,0.7)',
- alignItems: 'center',
- justifyContent: 'center',
- overflow: 'hidden',
- },
playButtonText: {
color: '#000',
fontWeight: '700',
@@ -2520,7 +2174,7 @@ const styles = StyleSheet.create({
// Tablet-specific styles
tabletActionButtons: {
- flexDirection: 'column',
+ flexDirection: 'row',
gap: 16,
alignItems: 'center',
justifyContent: 'center',
@@ -2556,11 +2210,6 @@ const styles = StyleSheet.create({
height: 60,
borderRadius: 30,
},
- tabletTraktButton: {
- width: 60,
- height: 60,
- borderRadius: 30,
- },
tabletHeroTitle: {
fontSize: 36,
fontWeight: '900',
diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx
index 3c7c0fe..14463d7 100644
--- a/src/components/metadata/MetadataDetails.tsx
+++ b/src/components/metadata/MetadataDetails.tsx
@@ -1,11 +1,10 @@
-import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
- Dimensions,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image';
@@ -21,15 +20,6 @@ import Animated, {
import { useTheme } from '../../contexts/ThemeContext';
import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen';
import { getAgeRatingColor } from '../../utils/ageRatingColors';
-
-// Enhanced responsive breakpoints for Metadata Details
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-};
-
// MetadataSourceSelector removed
interface MetadataDetailsProps {
@@ -55,38 +45,6 @@ const MetadataDetails: React.FC = ({
const [isMDBEnabled, setIsMDBEnabled] = useState(false);
const [isTextTruncated, setIsTextTruncated] = useState(false);
- // Enhanced responsive sizing for tablets and TV screens
- const deviceWidth = Dimensions.get('window').width;
- const deviceHeight = Dimensions.get('window').height;
-
- // Determine device type based on width
- const getDeviceType = useCallback(() => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- }, [deviceWidth]);
-
- const deviceType = getDeviceType();
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
- const isLargeScreen = isTablet || isLargeTablet || isTV;
-
- // Enhanced spacing and padding
- const horizontalPadding = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 32;
- case 'largeTablet':
- return 28;
- case 'tablet':
- return 24;
- default:
- return 16; // phone
- }
- }, [deviceType]);
-
// Animation values for smooth height transition
const animatedHeight = useSharedValue(0);
const [measuredHeights, setMeasuredHeights] = useState({ collapsed: 0, expanded: 0 });
@@ -186,31 +144,12 @@ function formatRuntime(runtime: string): string {
)}
{/* Meta Info */}
-
+
{metadata.year && (
- {metadata.year}
+ {metadata.year}
)}
{metadata.runtime && (
-
+
{formatRuntime(metadata.runtime)}
)}
@@ -218,32 +157,17 @@ function formatRuntime(runtime: string): string {
{metadata.certification}
)}
{metadata.imdbRating && !isMDBEnabled && (
- {metadata.imdbRating}
+ {metadata.imdbRating}
)}
@@ -254,62 +178,18 @@ function formatRuntime(runtime: string): string {
{/* Creator/Director Info */}
{metadata.directors && metadata.directors.length > 0 && (
-
- Director{metadata.directors.length > 1 ? 's' : ''}:
- {metadata.directors.join(', ')}
+
+ Director{metadata.directors.length > 1 ? 's' : ''}:
+ {metadata.directors.join(', ')}
)}
{metadata.creators && metadata.creators.length > 0 && (
-
- Creator{metadata.creators.length > 1 ? 's' : ''}:
- {metadata.creators.join(', ')}
+
+ Creator{metadata.creators.length > 1 ? 's' : ''}:
+ {metadata.creators.join(', ')}
)}
@@ -317,41 +197,19 @@ function formatRuntime(runtime: string): string {
{/* Description */}
{metadata.description && (
{/* Hidden text elements to measure heights */}
{metadata.description}
{metadata.description}
@@ -364,14 +222,7 @@ function formatRuntime(runtime: string): string {
>
@@ -379,25 +230,13 @@ function formatRuntime(runtime: string): string {
{(isTextTruncated || isFullDescriptionOpen) && (
-
-
+
+
{isFullDescriptionOpen ? 'Show Less' : 'Show More'}
@@ -428,6 +267,8 @@ const styles = StyleSheet.create({
metaInfo: {
flexDirection: 'row',
alignItems: 'center',
+ gap: 18,
+ paddingHorizontal: 16,
marginBottom: 12,
},
metaText: {
@@ -462,6 +303,7 @@ const styles = StyleSheet.create({
},
creatorContainer: {
marginBottom: 2,
+ paddingHorizontal: 16,
},
creatorSection: {
flexDirection: 'row',
@@ -482,6 +324,7 @@ const styles = StyleSheet.create({
},
descriptionContainer: {
marginBottom: 16,
+ paddingHorizontal: 16,
},
description: {
fontSize: 15,
diff --git a/src/components/metadata/MoreLikeThisSection.tsx b/src/components/metadata/MoreLikeThisSection.tsx
index 13ae71c..64d97b9 100644
--- a/src/components/metadata/MoreLikeThisSection.tsx
+++ b/src/components/metadata/MoreLikeThisSection.tsx
@@ -20,13 +20,32 @@ import CustomAlert from '../../components/CustomAlert';
const { width } = Dimensions.get('window');
-// Breakpoints for responsive sizing
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-} as const;
+// Dynamic poster calculation based on screen width for More Like This section
+const calculatePosterLayout = (screenWidth: number) => {
+ const MIN_POSTER_WIDTH = 100; // Slightly smaller for more items in this section
+ const MAX_POSTER_WIDTH = 130; // Maximum poster width
+ const HORIZONTAL_PADDING = 48; // Total horizontal padding/margins
+
+ // Calculate how many posters can fit (aim for slightly more items than main sections)
+ const availableWidth = screenWidth - HORIZONTAL_PADDING;
+ const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
+
+ // Limit to reasonable number of columns (3-7 for this section)
+ const numColumns = Math.min(Math.max(maxColumns, 3), 7);
+
+ // Calculate actual poster width
+ const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
+
+ return {
+ numColumns,
+ posterWidth,
+ spacing: 12 // Space between posters
+ };
+};
+
+const posterLayout = calculatePosterLayout(width);
+const POSTER_WIDTH = posterLayout.posterWidth;
+const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
interface MoreLikeThisSectionProps {
recommendations: StreamingContent[];
@@ -40,48 +59,6 @@ export const MoreLikeThisSection: React.FC = ({
const { currentTheme } = useTheme();
const navigation = useNavigation>();
- // Determine device type
- const deviceWidth = Dimensions.get('window').width;
- const getDeviceType = React.useCallback(() => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- }, [deviceWidth]);
- const deviceType = getDeviceType();
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
-
- // Responsive spacing & sizes
- const horizontalPadding = React.useMemo(() => {
- switch (deviceType) {
- case 'tv': return 32;
- case 'largeTablet': return 28;
- case 'tablet': return 24;
- default: return 16;
- }
- }, [deviceType]);
-
- const itemSpacing = React.useMemo(() => {
- switch (deviceType) {
- case 'tv': return 14;
- case 'largeTablet': return 12;
- case 'tablet': return 12;
- default: return 12;
- }
- }, [deviceType]);
-
- const posterWidth = React.useMemo(() => {
- switch (deviceType) {
- case 'tv': return 180;
- case 'largeTablet': return 160;
- case 'tablet': return 140;
- default: return 120;
- }
- }, [deviceType]);
- const posterHeight = React.useMemo(() => posterWidth * 1.5, [posterWidth]);
-
const [alertVisible, setAlertVisible] = React.useState(false);
const [alertTitle, setAlertTitle] = React.useState('');
const [alertMessage, setAlertMessage] = React.useState('');
@@ -117,15 +94,15 @@ export const MoreLikeThisSection: React.FC = ({
const renderItem = ({ item }: { item: StreamingContent }) => (
handleItemPress(item)}
>
-
+
{item.name}
@@ -144,15 +121,15 @@ export const MoreLikeThisSection: React.FC = ({
}
return (
-
- More Like This
+
+ More Like This
item.id}
horizontal
showsHorizontalScrollIndicator={false}
- contentContainerStyle={[styles.listContentContainer, { paddingHorizontal: horizontalPadding, paddingRight: horizontalPadding + itemSpacing }]}
+ contentContainerStyle={styles.listContentContainer}
/>
= ({ imdbId, type })
const fadeAnim = useRef(new Animated.Value(0)).current;
const { currentTheme } = useTheme();
- // Responsive device type
- const deviceWidth = Dimensions.get('window').width;
- const getDeviceType = useCallback(() => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- }, [deviceWidth]);
-
- const deviceType = getDeviceType();
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
-
- const horizontalPadding = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 32;
- case 'largeTablet':
- return 28;
- case 'tablet':
- return 24;
- default:
- return 16;
- }
- }, [deviceType]);
-
- const iconSize = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 20;
- case 'largeTablet':
- return 18;
- case 'tablet':
- return 16;
- default:
- return 16;
- }
- }, [deviceType]);
-
- const textSize = useMemo(() => (isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14), [isTV, isLargeTablet, isTablet]);
- const itemSpacing = useMemo(() => (isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12), [isTV, isLargeTablet, isTablet]);
- const iconTextGap = useMemo(() => (isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4), [isTV, isLargeTablet, isTablet]);
-
useEffect(() => {
loadProviderSettings();
checkMDBListEnabled();
@@ -216,7 +164,6 @@ export const RatingsSection: React.FC = ({ imdbId, type })
style={[
styles.container,
{
- paddingHorizontal: horizontalPadding,
opacity: fadeAnim,
transform: [{
translateY: fadeAnim.interpolate({
@@ -233,22 +180,22 @@ export const RatingsSection: React.FC = ({ imdbId, type })
const displayValue = config.transform(parseFloat(value as string));
return (
-
+
{config.isImage ? (
) : (
-
+
{React.createElement(config.icon as any, {
- width: iconSize,
- height: iconSize,
+ width: 16,
+ height: 16,
})}
)}
-
+
{displayValue}
@@ -263,6 +210,7 @@ const styles = StyleSheet.create({
container: {
marginTop: 2,
marginBottom: 8,
+ paddingHorizontal: 16,
},
loadingContainer: {
height: 40,
diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx
index 44e8e9d..9d20443 100644
--- a/src/components/metadata/SeriesContent.tsx
+++ b/src/components/metadata/SeriesContent.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
+import React, { useEffect, useState, useRef } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList } from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
@@ -15,14 +15,6 @@ import { TraktService } from '../../services/traktService';
import { logger } from '../../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage';
-// Enhanced responsive breakpoints for Seasons Section
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-};
-
interface SeriesContentProps {
episodes: Episode[];
selectedSeason: number;
@@ -50,120 +42,8 @@ export const SeriesContent: React.FC = ({
const { currentTheme } = useTheme();
const { settings } = useSettings();
const { width } = useWindowDimensions();
+ const isTablet = width > 768;
const isDarkMode = useColorScheme() === 'dark';
-
- // Enhanced responsive sizing for tablets and TV screens
- const deviceWidth = Dimensions.get('window').width;
- const deviceHeight = Dimensions.get('window').height;
-
- // Determine device type based on width
- const getDeviceType = useCallback(() => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- }, [deviceWidth]);
-
- const deviceType = getDeviceType();
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
- const isLargeScreen = isTablet || isLargeTablet || isTV;
-
- // Enhanced spacing and padding for seasons section
- const horizontalPadding = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 32;
- case 'largeTablet':
- return 28;
- case 'tablet':
- return 24;
- default:
- return 16; // phone
- }
- }, [deviceType]);
-
- // Match ThisWeekSection card sizing for horizontal episode cards
- const horizontalCardWidth = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return Math.min(deviceWidth * 0.25, 400);
- case 'largeTablet':
- return Math.min(deviceWidth * 0.35, 350);
- case 'tablet':
- return Math.min(deviceWidth * 0.46, 300);
- default:
- return width * 0.75;
- }
- }, [deviceType, deviceWidth, width]);
-
- const horizontalCardHeight = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 280;
- case 'largeTablet':
- return 250;
- case 'tablet':
- return 220;
- default:
- return 180;
- }
- }, [deviceType]);
-
- const horizontalItemSpacing = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 20;
- case 'largeTablet':
- return 18;
- case 'tablet':
- return 16;
- default:
- return 16;
- }
- }, [deviceType]);
-
- // Enhanced season poster sizing
- const seasonPosterWidth = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 140;
- case 'largeTablet':
- return 130;
- case 'tablet':
- return 120;
- default:
- return 100; // phone
- }
- }, [deviceType]);
-
- const seasonPosterHeight = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 210;
- case 'largeTablet':
- return 195;
- case 'tablet':
- return 180;
- default:
- return 150; // phone
- }
- }, [deviceType]);
-
- const seasonButtonSpacing = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 20;
- case 'largeTablet':
- return 18;
- case 'tablet':
- return 16;
- default:
- return 16; // phone
- }
- }, [deviceType]);
-
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({});
// Delay item entering animations to avoid FlashList initial layout glitches
const [enableItemAnimations, setEnableItemAnimations] = useState(false);
@@ -462,22 +342,12 @@ export const SeriesContent: React.FC = ({
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
return (
-
-
+
+
Seasons
{/* Dropdown Toggle Button */}
@@ -490,10 +360,7 @@ export const SeriesContent: React.FC = ({
: currentTheme.colors.elevation3,
borderColor: seasonViewMode === 'posters'
? 'rgba(255,255,255,0.2)'
- : 'rgba(255,255,255,0.3)',
- paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
- paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
- borderRadius: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6
+ : 'rgba(255,255,255,0.3)'
}
]}
onPress={() => {
@@ -508,8 +375,7 @@ export const SeriesContent: React.FC = ({
{
color: seasonViewMode === 'posters'
? currentTheme.colors.mediumEmphasis
- : currentTheme.colors.highEmphasis,
- fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12
+ : currentTheme.colors.highEmphasis
}
]}>
{seasonViewMode === 'posters' ? 'Posters' : 'Text'}
@@ -523,12 +389,7 @@ export const SeriesContent: React.FC = ({
horizontal
showsHorizontalScrollIndicator={false}
style={styles.seasonSelectorContainer}
- contentContainerStyle={[
- styles.seasonSelectorContent,
- {
- paddingBottom: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8
- }
- ]}
+ contentContainerStyle={[styles.seasonSelectorContent, isTablet && styles.seasonSelectorContentTablet]}
initialNumToRender={5}
maxToRenderPerBatch={5}
windowSize={3}
@@ -555,13 +416,7 @@ export const SeriesContent: React.FC = ({
onSeasonChange(season)}
@@ -593,23 +448,12 @@ export const SeriesContent: React.FC = ({
onSeasonChange(season)}
>
-
+
= ({
{selectedSeason === season && (
)}
@@ -629,19 +471,18 @@ export const SeriesContent: React.FC = ({
Season {season}
-
+
);
}}
@@ -709,43 +550,22 @@ export const SeriesContent: React.FC = ({
key={episode.id}
style={[
styles.episodeCardVertical,
- {
- backgroundColor: currentTheme.colors.elevation2,
- borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
- marginBottom: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
- height: isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 120
- }
+ { backgroundColor: currentTheme.colors.elevation2 }
]}
onPress={() => onSelectEpisode(episode)}
activeOpacity={0.7}
>
-
- {episodeString}
+
+ {episodeString}
{showProgress && (
@@ -758,112 +578,53 @@ export const SeriesContent: React.FC = ({
)}
{progressPercent >= 85 && (
-
-
+
+
)}
- {(!progress || progressPercent === 0) && (
-
- )}
+ isTablet && styles.episodeTitleTablet,
+ { color: currentTheme.colors.text }
+ ]} numberOfLines={2}>
{episode.name}
{effectiveVote > 0 && (
-
+
{effectiveVote.toFixed(1)}
)}
{effectiveRuntime && (
-
-
+
+
{formatRuntime(effectiveRuntime)}
)}
{episode.air_date && (
-
+
{formatDate(episode.air_date)}
)}
@@ -871,12 +632,9 @@ export const SeriesContent: React.FC = ({
+ isTablet && styles.episodeOverviewTablet,
+ { color: currentTheme.colors.mediumEmphasis }
+ ]} numberOfLines={isTablet ? 3 : 2}>
{episode.overview || 'No description available'}
@@ -926,25 +684,47 @@ export const SeriesContent: React.FC = ({
key={episode.id}
style={[
styles.episodeCardHorizontal,
- {
- borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
- height: horizontalCardHeight,
- elevation: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8,
- shadowOpacity: isTV ? 0.4 : isLargeTablet ? 0.35 : isTablet ? 0.3 : 0.3,
- shadowRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8
- },
+ isTablet && styles.episodeCardHorizontalTablet,
// Gradient border styling
{
borderWidth: 1,
- borderColor: 'rgba(255,255,255,0.12)',
+ borderColor: 'transparent',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
+ elevation: 12,
}
]}
onPress={() => onSelectEpisode(episode)}
activeOpacity={0.85}
>
- {/* Solid outline replaces gradient border */}
+ {/* Gradient Border Container */}
+
+
+
{/* Background Image */}
= ({
style={styles.episodeGradient}
>
{/* Content Container */}
-
+
{/* Episode Number Badge */}
-
- {episodeString}
+
+ {episodeString}
{/* Episode Title */}
-
+
{episode.name}
{/* Episode Description */}
-
+
{episode.overview || 'No description available'}
{/* Metadata Row */}
-
+
{episode.runtime && (
-
+
{formatRuntime(episode.runtime)}
)}
{episode.vote_average > 0 && (
-
-
+
+
{episode.vote_average.toFixed(1)}
@@ -1072,34 +799,12 @@ export const SeriesContent: React.FC = ({
{/* Completed Badge */}
{progressPercent >= 85 && (
-
-
+
+
)}
- {(!progress || progressPercent === 0) && (
-
- )}
@@ -1119,15 +824,7 @@ export const SeriesContent: React.FC = ({
-
+
{currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'}
@@ -1157,10 +854,7 @@ export const SeriesContent: React.FC = ({
entering={enableItemAnimations ? FadeIn.duration(300).delay(100 + index * 30) : undefined as any}
style={[
styles.episodeCardWrapperHorizontal,
- {
- width: horizontalCardWidth,
- marginRight: horizontalItemSpacing
- }
+ isTablet && styles.episodeCardWrapperHorizontalTablet
]}
>
{renderHorizontalEpisodeCard(episode)}
@@ -1169,22 +863,17 @@ export const SeriesContent: React.FC = ({
keyExtractor={episode => episode.id.toString()}
horizontal
showsHorizontalScrollIndicator={false}
- contentContainerStyle={[
- styles.episodeListContentHorizontal,
- {
- paddingLeft: horizontalPadding,
- paddingRight: horizontalPadding
- }
- ]}
+ contentContainerStyle={isTablet ? styles.episodeListContentHorizontalTablet : styles.episodeListContentHorizontal}
removeClippedSubviews
initialNumToRender={3}
maxToRenderPerBatch={5}
windowSize={5}
getItemLayout={(data, index) => {
- const length = horizontalCardWidth + horizontalItemSpacing;
+ const cardWidth = isTablet ? width * 0.4 : width * 0.75;
+ const margin = isTablet ? 20 : 16;
return {
- length,
- offset: length * index,
+ length: cardWidth + margin,
+ offset: (cardWidth + margin) * index,
index,
};
}}
@@ -1203,13 +892,7 @@ export const SeriesContent: React.FC = ({
)}
keyExtractor={episode => episode.id.toString()}
- contentContainerStyle={[
- styles.episodeListContentVertical,
- {
- paddingHorizontal: horizontalPadding,
- paddingBottom: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 8
- }
- ]}
+ contentContainerStyle={isTablet ? styles.episodeListContentVerticalTablet : styles.episodeListContentVertical}
removeClippedSubviews
/>
)
@@ -1254,6 +937,11 @@ const styles = StyleSheet.create({
// Vertical Layout Styles
episodeListContentVertical: {
paddingBottom: 8,
+ paddingHorizontal: 16,
+ },
+ episodeListContentVerticalTablet: {
+ paddingHorizontal: 16,
+ paddingBottom: 8,
},
episodeGridVertical: {
flexDirection: 'row',
@@ -1410,10 +1098,20 @@ const styles = StyleSheet.create({
// Horizontal Layout Styles
episodeListContentHorizontal: {
- // Padding will be added responsively
+ paddingLeft: 16,
+ paddingRight: 16,
+ },
+ episodeListContentHorizontalTablet: {
+ paddingLeft: 24,
+ paddingRight: 24,
},
episodeCardWrapperHorizontal: {
- // Dimensions will be set responsively
+ width: Dimensions.get('window').width * 0.75,
+ marginRight: 16,
+ },
+ episodeCardWrapperHorizontalTablet: {
+ width: Dimensions.get('window').width * 0.4,
+ marginRight: 20,
},
episodeCardHorizontal: {
borderRadius: 16,
@@ -1430,6 +1128,13 @@ const styles = StyleSheet.create({
width: '100%',
backgroundColor: 'transparent',
},
+ episodeCardHorizontalTablet: {
+ height: 260,
+ borderRadius: 20,
+ elevation: 12,
+ shadowOpacity: 0.4,
+ shadowRadius: 16,
+ },
episodeBackgroundImage: {
width: '100%',
height: '100%',
@@ -1568,6 +1273,11 @@ const styles = StyleSheet.create({
// Season Selector Styles
seasonSelectorWrapper: {
marginBottom: 20,
+ paddingHorizontal: 16,
+ },
+ seasonSelectorWrapperTablet: {
+ marginBottom: 24,
+ paddingHorizontal: 24,
},
seasonSelectorHeader: {
flexDirection: 'row',
@@ -1596,14 +1306,32 @@ const styles = StyleSheet.create({
},
seasonButton: {
alignItems: 'center',
+ marginRight: 16,
+ width: 100,
+ },
+ seasonButtonTablet: {
+ alignItems: 'center',
+ marginRight: 20,
+ width: 120,
},
selectedSeasonButton: {
opacity: 1,
},
seasonPosterContainer: {
position: 'relative',
+ width: 100,
+ height: 150,
borderRadius: 8,
overflow: 'hidden',
+ marginBottom: 8,
+ },
+ seasonPosterContainerTablet: {
+ position: 'relative',
+ width: 120,
+ height: 180,
+ borderRadius: 12,
+ overflow: 'hidden',
+ marginBottom: 12,
},
seasonPoster: {
width: '100%',
@@ -1654,7 +1382,22 @@ const styles = StyleSheet.create({
},
seasonTextButton: {
alignItems: 'center',
+ marginRight: 16,
+ width: 110,
justifyContent: 'center',
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderRadius: 12,
+ backgroundColor: 'transparent',
+ },
+ seasonTextButtonTablet: {
+ alignItems: 'center',
+ marginRight: 20,
+ width: 130,
+ justifyContent: 'center',
+ paddingVertical: 14,
+ paddingHorizontal: 18,
+ borderRadius: 14,
backgroundColor: 'transparent',
},
selectedSeasonTextButton: {
diff --git a/src/components/metadata/TrailersSection.tsx b/src/components/metadata/TrailersSection.tsx
index f55359f..c059a59 100644
--- a/src/components/metadata/TrailersSection.tsx
+++ b/src/components/metadata/TrailersSection.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback, memo, useRef, useMemo } from 'react';
+import React, { useState, useEffect, useCallback, memo, useRef } from 'react';
import {
View,
Text,
@@ -21,13 +21,8 @@ import TrailerService from '../../services/trailerService';
import TrailerModal from './TrailerModal';
import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated';
-// Enhanced responsive breakpoints for Trailers Section
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-};
+const { width } = Dimensions.get('window');
+const isTablet = width >= 768;
interface TrailerVideo {
id: string;
@@ -71,65 +66,6 @@ const TrailersSection: React.FC = memo(({
const [dropdownVisible, setDropdownVisible] = useState(false);
const [backendAvailable, setBackendAvailable] = useState(null);
- // Enhanced responsive sizing for tablets and TV screens
- const deviceWidth = Dimensions.get('window').width;
- const deviceHeight = Dimensions.get('window').height;
-
- // Determine device type based on width
- const getDeviceType = useCallback(() => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- }, [deviceWidth]);
-
- const deviceType = getDeviceType();
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
- const isLargeScreen = isTablet || isLargeTablet || isTV;
-
- // Enhanced spacing and padding
- const horizontalPadding = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 32;
- case 'largeTablet':
- return 28;
- case 'tablet':
- return 24;
- default:
- return 16; // phone
- }
- }, [deviceType]);
-
- // Enhanced trailer card sizing
- const trailerCardWidth = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 240;
- case 'largeTablet':
- return 220;
- case 'tablet':
- return 200;
- default:
- return 170; // phone
- }
- }, [deviceType]);
-
- const trailerCardSpacing = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 16;
- case 'largeTablet':
- return 14;
- case 'tablet':
- return 12;
- default:
- return 12; // phone
- }
- }, [deviceType]);
-
// Smooth reveal animation after trailers are fetched
const sectionOpacitySV = useSharedValue(0);
const sectionTranslateYSV = useSharedValue(8);
@@ -155,6 +91,24 @@ const TrailersSection: React.FC = memo(({
sectionTranslateYSV.value = withDelay(500, withTiming(0, { duration: 400 }));
}, [sectionOpacitySV, sectionTranslateYSV]);
+ // Check if trailer service backend is available
+ const checkBackendAvailability = useCallback(async (): Promise => {
+ try {
+ const serverStatus = TrailerService.getServerStatus();
+ const healthUrl = `${serverStatus.localUrl.replace('/trailer', '/health')}`;
+
+ const response = await fetch(healthUrl, {
+ method: 'GET',
+ signal: AbortSignal.timeout(3000), // 3 second timeout
+ });
+ const isAvailable = response.ok;
+ logger.info('TrailersSection', `Backend availability check: ${isAvailable ? 'AVAILABLE' : 'UNAVAILABLE'}`);
+ return isAvailable;
+ } catch (error) {
+ logger.warn('TrailersSection', 'Backend availability check failed:', error);
+ return false;
+ }
+ }, []);
// Fetch trailers from TMDB
useEffect(() => {
@@ -162,7 +116,17 @@ const TrailersSection: React.FC = memo(({
const initializeTrailers = async () => {
resetSectionAnimation();
- setBackendAvailable(true); // Assume available, let TrailerService handle errors
+ // First check if backend is available
+ const available = await checkBackendAvailability();
+ setBackendAvailable(available);
+
+ if (!available) {
+ logger.warn('TrailersSection', 'Trailer service backend is not available - skipping trailer loading');
+ setLoading(false);
+ return;
+ }
+
+ // Backend is available, proceed with fetching trailers
await fetchTrailers();
};
@@ -306,7 +270,7 @@ const TrailersSection: React.FC = memo(({
};
initializeTrailers();
- }, [tmdbId, type]);
+ }, [tmdbId, type, checkBackendAvailability]);
// Categorize trailers by type
const categorizeTrailers = (videos: any[]): CategorizedTrailers => {
@@ -498,48 +462,22 @@ const TrailersSection: React.FC = memo(({
}
return (
-
+
{/* Enhanced Header with Category Selector */}
-
+
Trailers & Videos
{/* Category Selector - Right Aligned */}
{trailerCategories.length > 0 && selectedCategory && (
@@ -547,7 +485,7 @@ const TrailersSection: React.FC = memo(({
@@ -568,58 +506,32 @@ const TrailersSection: React.FC = memo(({
>
{trailerCategories.map(category => (
handleCategorySelect(category)}
activeOpacity={0.7}
>
-
+
{formatTrailerType(category)}
-
+
{trailers[category].length}
@@ -636,25 +548,16 @@ const TrailersSection: React.FC = memo(({
{trailers[selectedCategory].map((trailer, index) => (
handleTrailerPress(trailer)}
activeOpacity={0.9}
>
@@ -662,71 +565,33 @@ const TrailersSection: React.FC = memo(({
{/* Subtle Gradient Overlay */}
-
+
{/* Trailer Info */}
-
+
{trailer.displayName || trailer.name}
-
+
{new Date(trailer.published_at).getFullYear()}
))}
{/* Scroll Indicator - shows when there are more items to scroll */}
- {trailers[selectedCategory].length > (isTV ? 5 : isLargeTablet ? 4 : isTablet ? 4 : 3) && (
-
+ {trailers[selectedCategory].length > (isTablet ? 4 : 3) && (
+
@@ -749,6 +614,7 @@ const TrailersSection: React.FC = memo(({
const styles = StyleSheet.create({
container: {
+ paddingHorizontal: 16,
marginTop: 24,
marginBottom: 16,
},
@@ -883,11 +749,13 @@ const styles = StyleSheet.create({
},
trailersScrollContent: {
paddingHorizontal: 4, // Restore padding for first/last items
+ gap: 12,
paddingRight: 20, // Extra padding at end for scroll indicator
},
// Enhanced Trailer Card Styles
trailerCard: {
+ width: isTablet ? 200 : 170,
backgroundColor: 'rgba(255,255,255,0.03)',
borderRadius: 16,
borderWidth: 1,
diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx
index 6a190a2..2354da8 100644
--- a/src/components/player/AndroidVideoPlayer.tsx
+++ b/src/components/player/AndroidVideoPlayer.tsx
@@ -1187,12 +1187,6 @@ const AndroidVideoPlayer: React.FC = () => {
if (isMounted.current) {
setSeekTime(null);
isSeeking.current = false;
-
- // IMMEDIATE SYNC: Update Trakt progress immediately after seeking
- if (duration > 0 && data?.currentTime !== undefined) {
- traktAutosync.handleProgressUpdate(data.currentTime, duration, true); // force=true for immediate sync
- }
-
// Resume playback on iOS if we paused for seeking
if (Platform.OS === 'ios') {
const shouldResume = wasPlayingBeforeDragRef.current || iosWasPausedDuringSeekRef.current === false || isDragging;
@@ -1588,11 +1582,6 @@ const AndroidVideoPlayer: React.FC = () => {
}
controlsTimeout.current = setTimeout(hideControls, 5000);
-
- // Auto-fetch and load English external subtitles if available
- if (imdbId) {
- fetchAvailableSubtitles(undefined, true);
- }
} catch (error) {
logger.error('[AndroidVideoPlayer] Error in onLoad:', error);
// Set fallback values to prevent crashes
diff --git a/src/components/player/KSPlayerComponent.tsx b/src/components/player/KSPlayerComponent.tsx
index 5bf3710..aa9d8fc 100644
--- a/src/components/player/KSPlayerComponent.tsx
+++ b/src/components/player/KSPlayerComponent.tsx
@@ -12,11 +12,6 @@ interface KSPlayerViewProps {
volume?: number;
audioTrack?: number;
textTrack?: number;
- allowsExternalPlayback?: boolean;
- usesExternalPlaybackWhileExternalScreenIsActive?: boolean;
- subtitleBottomOffset?: number;
- subtitleFontSize?: number;
- resizeMode?: 'contain' | 'cover' | 'stretch';
onLoad?: (data: any) => void;
onProgress?: (data: any) => void;
onBuffering?: (data: any) => void;
@@ -37,10 +32,6 @@ export interface KSPlayerRef {
setAudioTrack: (trackId: number) => void;
setTextTrack: (trackId: number) => void;
getTracks: () => Promise<{ audioTracks: any[]; textTracks: any[] }>;
- setAllowsExternalPlayback: (allows: boolean) => void;
- setUsesExternalPlaybackWhileExternalScreenIsActive: (uses: boolean) => void;
- getAirPlayState: () => Promise<{ allowsExternalPlayback: boolean; usesExternalPlaybackWhileExternalScreenIsActive: boolean; isExternalPlaybackActive: boolean }>;
- showAirPlayPicker: () => void;
}
export interface KSPlayerProps {
@@ -49,11 +40,6 @@ export interface KSPlayerProps {
volume?: number;
audioTrack?: number;
textTrack?: number;
- allowsExternalPlayback?: boolean;
- usesExternalPlaybackWhileExternalScreenIsActive?: boolean;
- subtitleBottomOffset?: number;
- subtitleFontSize?: number;
- resizeMode?: 'contain' | 'cover' | 'stretch';
onLoad?: (data: any) => void;
onProgress?: (data: any) => void;
onBuffering?: (data: any) => void;
@@ -123,38 +109,6 @@ const KSPlayer = forwardRef((props, ref) => {
}
return { audioTracks: [], textTracks: [] };
},
- setAllowsExternalPlayback: (allows: boolean) => {
- if (nativeRef.current) {
- const node = findNodeHandle(nativeRef.current);
- // @ts-ignore legacy UIManager commands path for Paper
- const commandId = UIManager.getViewManagerConfig('KSPlayerView').Commands.setAllowsExternalPlayback;
- UIManager.dispatchViewManagerCommand(node, commandId, [allows]);
- }
- },
- setUsesExternalPlaybackWhileExternalScreenIsActive: (uses: boolean) => {
- if (nativeRef.current) {
- const node = findNodeHandle(nativeRef.current);
- // @ts-ignore legacy UIManager commands path for Paper
- const commandId = UIManager.getViewManagerConfig('KSPlayerView').Commands.setUsesExternalPlaybackWhileExternalScreenIsActive;
- UIManager.dispatchViewManagerCommand(node, commandId, [uses]);
- }
- },
- getAirPlayState: async () => {
- if (nativeRef.current) {
- const node = findNodeHandle(nativeRef.current);
- return await KSPlayerModule.getAirPlayState(node);
- }
- return { allowsExternalPlayback: false, usesExternalPlaybackWhileExternalScreenIsActive: false, isExternalPlaybackActive: false };
- },
- showAirPlayPicker: () => {
- if (nativeRef.current) {
- const node = findNodeHandle(nativeRef.current);
- console.log('[KSPlayerComponent] Calling showAirPlayPicker with node:', node);
- KSPlayerModule.showAirPlayPicker(node);
- } else {
- console.log('[KSPlayerComponent] nativeRef.current is null');
- }
- },
}));
// No need for event listeners - events are handled through props
@@ -175,11 +129,6 @@ const KSPlayer = forwardRef((props, ref) => {
volume={props.volume}
audioTrack={props.audioTrack}
textTrack={props.textTrack}
- allowsExternalPlayback={props.allowsExternalPlayback}
- usesExternalPlaybackWhileExternalScreenIsActive={props.usesExternalPlaybackWhileExternalScreenIsActive}
- subtitleBottomOffset={props.subtitleBottomOffset}
- subtitleFontSize={props.subtitleFontSize}
- resizeMode={props.resizeMode}
onLoad={(e: any) => props.onLoad?.(e?.nativeEvent ?? e)}
onProgress={(e: any) => props.onProgress?.(e?.nativeEvent ?? e)}
onBuffering={(e: any) => props.onBuffering?.(e?.nativeEvent ?? e)}
diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx
index 60cb9e4..688af14 100644
--- a/src/components/player/KSPlayerCore.tsx
+++ b/src/components/player/KSPlayerCore.tsx
@@ -94,20 +94,13 @@ const KSPlayerCore: React.FC = () => {
const screenData = Dimensions.get('screen');
const [screenDimensions, setScreenDimensions] = useState(screenData);
- // iPad/macOS-specific fullscreen handling
+ // iPad-specific fullscreen handling
const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000);
- const isMacOS = Platform.OS === 'ios' && Platform.isPad === true;
- const shouldUseFullscreen = isIPad || isMacOS;
+ const shouldUseFullscreen = isIPad;
// Use window dimensions for iPad instead of screen dimensions
const windowData = Dimensions.get('window');
const effectiveDimensions = shouldUseFullscreen ? windowData : screenData;
-
- // Helper to get appropriate dimensions for gesture areas and overlays
- const getDimensions = () => ({
- width: shouldUseFullscreen ? windowData.width : screenDimensions.width,
- height: shouldUseFullscreen ? windowData.height : screenDimensions.height,
- });
const [paused, setPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
@@ -118,7 +111,6 @@ const KSPlayerCore: React.FC = () => {
const [textTracks, setTextTracks] = useState([]);
const [selectedTextTrack, setSelectedTextTrack] = useState(-1);
const [resizeMode, setResizeMode] = useState('contain');
- const [playerBackend, setPlayerBackend] = useState('');
const [buffered, setBuffered] = useState(0);
const [seekPosition, setSeekPosition] = useState(null);
const ksPlayerRef = useRef(null);
@@ -261,10 +253,6 @@ const KSPlayerCore: React.FC = () => {
const controlsTimeout = useRef(null);
const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false);
- // AirPlay state
- const [isAirPlayActive, setIsAirPlayActive] = useState(false);
- const [allowsAirPlay, setAllowsAirPlay] = useState(true);
-
// Silent startup-timeout retry state
const startupRetryCountRef = useRef(0);
const startupRetryTimerRef = useRef(null);
@@ -878,9 +866,6 @@ const KSPlayerCore: React.FC = () => {
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] KSPlayer seek completed to ${timeInSeconds.toFixed(2)}s`);
}
-
- // IMMEDIATE SYNC: Update Trakt progress immediately after seeking
- traktAutosync.handleProgressUpdate(timeInSeconds, duration, true); // force=true for immediate sync
}
}, 500);
};
@@ -961,18 +946,6 @@ const KSPlayerCore: React.FC = () => {
safeSetState(() => setBuffered(bufferedTime));
}
- // Update AirPlay state if available
- if (event.airPlayState) {
- const wasAirPlayActive = isAirPlayActive;
- setIsAirPlayActive(event.airPlayState.isExternalPlaybackActive);
- setAllowsAirPlay(event.airPlayState.allowsExternalPlayback);
-
- // Log AirPlay state changes for debugging
- if (wasAirPlayActive !== event.airPlayState.isExternalPlaybackActive) {
- if (__DEV__) logger.log(`[VideoPlayer] AirPlay state changed: ${event.airPlayState.isExternalPlaybackActive ? 'ACTIVE' : 'INACTIVE'}`);
- }
- }
-
// Safety: if audio is advancing but onLoad didn't fire, dismiss opening overlay
if (!isOpeningAnimationComplete) {
setIsVideoLoaded(true);
@@ -1056,24 +1029,6 @@ const KSPlayerCore: React.FC = () => {
logger.error('[VideoPlayer] onLoad called with null/undefined data');
return;
}
- // Extract player backend information
- if (data.playerBackend) {
- const newPlayerBackend = data.playerBackend;
- setPlayerBackend(newPlayerBackend);
- if (DEBUG_MODE) {
- logger.log(`[VideoPlayer] Player backend: ${newPlayerBackend}`);
- }
-
- // Reset AirPlay state if switching to KSMEPlayer (which doesn't support AirPlay)
- if (newPlayerBackend === 'KSMEPlayer' && (isAirPlayActive || allowsAirPlay)) {
- setIsAirPlayActive(false);
- setAllowsAirPlay(false);
- if (DEBUG_MODE) {
- logger.log('[VideoPlayer] Reset AirPlay state for KSMEPlayer');
- }
- }
- }
-
// KSPlayer returns duration in seconds directly
const videoDuration = data.duration;
if (DEBUG_MODE) {
@@ -1284,11 +1239,6 @@ const KSPlayerCore: React.FC = () => {
}
controlsTimeout.current = setTimeout(hideControls, 5000);
-
- // Auto-fetch and load English external subtitles if available
- if (imdbId) {
- fetchAvailableSubtitles(undefined, true);
- }
} catch (error) {
logger.error('[VideoPlayer] Error in onLoad:', error);
// Set fallback values to prevent crashes
@@ -1315,12 +1265,6 @@ const KSPlayerCore: React.FC = () => {
};
const cycleAspectRatio = () => {
- // iOS KSPlayer: toggle native resize mode so subtitles remain independent
- if (Platform.OS === 'ios') {
- setResizeMode((prev) => (prev === 'cover' ? 'contain' : 'cover'));
- return;
- }
- // Fallback (non‑iOS paths): keep legacy zoom behavior
const newZoom = zoomScale === 1.1 ? 1 : 1.1;
setZoomScale(newZoom);
setZoomTranslateX(0);
@@ -2257,7 +2201,7 @@ const KSPlayerCore: React.FC = () => {
if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor);
if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth);
if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right');
- if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset);
+ if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset);
if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing);
if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier);
if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec);
@@ -2297,7 +2241,7 @@ const KSPlayerCore: React.FC = () => {
subtitleOutlineColor,
subtitleOutlineWidth,
subtitleAlign,
- subtitleBottomOffset,
+ subtitleBottomOffset,
subtitleLetterSpacing,
subtitleLineHeightMultiplier,
subtitleOffsetSec,
@@ -2379,27 +2323,6 @@ const KSPlayerCore: React.FC = () => {
}
}, [pendingSeek, isPlayerReady, isVideoLoaded, duration]);
- // AirPlay handler
- const handleAirPlayPress = async () => {
- if (!ksPlayerRef.current) return;
-
- try {
- // First ensure AirPlay is enabled
- if (!allowsAirPlay) {
- ksPlayerRef.current.setAllowsExternalPlayback(true);
- setAllowsAirPlay(true);
- logger.log(`[VideoPlayer] AirPlay enabled before showing picker`);
- }
-
- // Show the AirPlay picker
- ksPlayerRef.current.showAirPlayPicker();
-
- logger.log(`[VideoPlayer] AirPlay picker triggered - check console for native logs`);
- } catch (error) {
- logger.error('[VideoPlayer] Error showing AirPlay picker:', error);
- }
- };
-
const handleSelectStream = async (newStream: any) => {
if (newStream.url === currentStreamUrl) {
setShowSourcesModal(false);
@@ -2493,7 +2416,7 @@ const KSPlayerCore: React.FC = () => {
{
{
opacity: backgroundFadeAnim,
zIndex: shouldHideOpeningOverlay ? -1 : 3000,
- width: shouldUseFullscreen ? '100%' : screenDimensions.width,
- height: shouldUseFullscreen ? '100%' : screenDimensions.height,
+ width: screenDimensions.width,
+ height: screenDimensions.height,
}
]}
pointerEvents={shouldHideOpeningOverlay ? 'none' : 'auto'}
@@ -2522,8 +2445,8 @@ const KSPlayerCore: React.FC = () => {
@@ -2588,8 +2511,8 @@ const KSPlayerCore: React.FC = () => {
style={[
styles.sourceChangeOverlay,
{
- width: shouldUseFullscreen ? '100%' : screenDimensions.width,
- height: shouldUseFullscreen ? '100%' : screenDimensions.height,
+ width: screenDimensions.width,
+ height: screenDimensions.height,
opacity: fadeAnim,
}
]}
@@ -2609,8 +2532,8 @@ const KSPlayerCore: React.FC = () => {
{
opacity: DISABLE_OPENING_OVERLAY ? 1 : openingFadeAnim,
transform: DISABLE_OPENING_OVERLAY ? [] : [{ scale: openingScaleAnim }],
- width: shouldUseFullscreen ? '100%' : screenDimensions.width,
- height: shouldUseFullscreen ? '100%' : screenDimensions.height,
+ width: screenDimensions.width,
+ height: screenDimensions.height,
}
]}
>
@@ -2630,10 +2553,10 @@ const KSPlayerCore: React.FC = () => {
>
@@ -2655,10 +2578,10 @@ const KSPlayerCore: React.FC = () => {
>
@@ -2687,18 +2610,18 @@ const KSPlayerCore: React.FC = () => {
>
@@ -2711,8 +2634,8 @@ const KSPlayerCore: React.FC = () => {
position: 'absolute',
top: 0,
left: 0,
- width: getDimensions().width,
- height: getDimensions().height,
+ width: screenDimensions.width,
+ height: screenDimensions.height,
}}>
{
>
0 ? headers : undefined
@@ -2732,11 +2655,6 @@ const KSPlayerCore: React.FC = () => {
volume={volume / 100}
audioTrack={selectedAudioTrack ?? undefined}
textTrack={useCustomSubtitles ? -1 : selectedTextTrack}
- allowsExternalPlayback={allowsAirPlay}
- usesExternalPlaybackWhileExternalScreenIsActive={true}
- subtitleBottomOffset={subtitleBottomOffset}
- subtitleFontSize={subtitleSize}
- resizeMode={resizeMode === 'none' ? 'contain' : resizeMode}
onProgress={handleProgress}
onLoad={onLoad}
onEnd={onEnd}
@@ -2769,7 +2687,6 @@ const KSPlayerCore: React.FC = () => {
skip={skip}
handleClose={handleClose}
cycleAspectRatio={cycleAspectRatio}
- currentResizeMode={resizeMode}
setShowAudioModal={setShowAudioModal}
setShowSubtitleModal={setShowSubtitleModal}
isSubtitleModalOpen={showSubtitleModal}
@@ -2779,12 +2696,8 @@ const KSPlayerCore: React.FC = () => {
onSlidingComplete={handleSlidingComplete}
buffered={buffered}
formatTime={formatTime}
- playerBackend={playerBackend}
cyclePlaybackSpeed={cyclePlaybackSpeed}
currentPlaybackSpeed={playbackSpeed}
- isAirPlayActive={isAirPlayActive}
- allowsAirPlay={allowsAirPlay}
- onAirPlayPress={handleAirPlayPress}
/>
{showPauseOverlay && (
@@ -2811,7 +2724,7 @@ const KSPlayerCore: React.FC = () => {
}}
>
{/* Strong horizontal fade from left side */}
-
+
{
{
string;
playerBackend?: string;
- // AirPlay props
- isAirPlayActive?: boolean;
- allowsAirPlay?: boolean;
- onAirPlayPress?: () => void;
}
export const PlayerControls: React.FC = ({
@@ -85,177 +80,8 @@ export const PlayerControls: React.FC = ({
buffered,
formatTime,
playerBackend,
- isAirPlayActive,
- allowsAirPlay,
- onAirPlayPress,
}) => {
const { currentTheme } = useTheme();
-
-
- /* Responsive Spacing */
- const screenWidth = Dimensions.get('window').width;
- const buttonSpacing = screenWidth * 0.10; // Reduced from 15% to 10%
-
- const playButtonSize = screenWidth * 0.08; // 8% of screen width (reduced from 12%)
- const playIconSizeCalculated = playButtonSize * 0.6; // 60% of button size
- const seekButtonSize = screenWidth * 0.07; // 7% of screen width (reduced from 11%)
- const seekIconSize = seekButtonSize * 0.75; // 75% of button size
- const seekNumberSize = seekButtonSize * 0.25; // 25% of button size
- const arcBorderWidth = seekButtonSize * 0.05; // 5% of button size
-
- /* Animations - State & Refs */
- const [showBackwardSign, setShowBackwardSign] = React.useState(false);
- const [showForwardSign, setShowForwardSign] = React.useState(false);
-
- /* Separate Animations for Each Button */
- const backwardPressAnim = React.useRef(new Animated.Value(0)).current;
- const backwardSlideAnim = React.useRef(new Animated.Value(0)).current;
- const backwardScaleAnim = React.useRef(new Animated.Value(1)).current;
- const backwardArcOpacity = React.useRef(new Animated.Value(0)).current;
- const backwardArcRotation = React.useRef(new Animated.Value(0)).current;
-
- const forwardPressAnim = React.useRef(new Animated.Value(0)).current;
- const forwardSlideAnim = React.useRef(new Animated.Value(0)).current;
- const forwardScaleAnim = React.useRef(new Animated.Value(1)).current;
- const forwardArcOpacity = React.useRef(new Animated.Value(0)).current;
- const forwardArcRotation = React.useRef(new Animated.Value(0)).current;
-
- const playPressAnim = React.useRef(new Animated.Value(0)).current;
- const playIconScale = React.useRef(new Animated.Value(1)).current;
- const playIconOpacity = React.useRef(new Animated.Value(1)).current;
-
- /* Handle Seek with Animation */
- const handleSeekWithAnimation = (seconds: number) => {
- const isForward = seconds > 0;
-
- if (isForward) {
- setShowForwardSign(true);
- } else {
- setShowBackwardSign(true);
- }
-
- const pressAnim = isForward ? forwardPressAnim : backwardPressAnim;
- const slideAnim = isForward ? forwardSlideAnim : backwardSlideAnim;
- const scaleAnim = isForward ? forwardScaleAnim : backwardScaleAnim;
- const arcOpacity = isForward ? forwardArcOpacity : backwardArcOpacity;
- const arcRotation = isForward ? forwardArcRotation : backwardArcRotation;
-
- Animated.parallel([
- // Button press effect (circle flash)
- Animated.sequence([
- Animated.timing(pressAnim, {
- toValue: 1,
- duration: 100,
- useNativeDriver: true,
- }),
- Animated.timing(pressAnim, {
- toValue: 0,
- duration: 200,
- useNativeDriver: true,
- }),
- ]),
- // Number slide out
- Animated.sequence([
- Animated.timing(slideAnim, {
- toValue: isForward ? (seekButtonSize * 0.75) : -(seekButtonSize * 0.75),
- duration: 250,
- useNativeDriver: true,
- }),
- Animated.timing(slideAnim, {
- toValue: 0,
- duration: 120,
- useNativeDriver: true,
- }),
- ]),
- // Button scale pulse
- Animated.sequence([
- Animated.timing(scaleAnim, {
- toValue: 1.15,
- duration: 150,
- useNativeDriver: true,
- }),
- Animated.timing(scaleAnim, {
- toValue: 1,
- duration: 150,
- useNativeDriver: true,
- }),
- ]),
- // Arc sweep animation
- Animated.parallel([
- Animated.timing(arcOpacity, {
- toValue: 1,
- duration: 50,
- useNativeDriver: true,
- }),
- Animated.timing(arcRotation, {
- toValue: 1,
- duration: 200,
- useNativeDriver: true,
- }),
- ]),
- ]).start(() => {
- if (isForward) {
- setShowForwardSign(false);
- } else {
- setShowBackwardSign(false);
- }
- arcOpacity.setValue(0);
- arcRotation.setValue(0);
- });
-
- skip(seconds);
- };
-
- /* Handle Play/Pause with Animation */
- const handlePlayPauseWithAnimation = () => {
- Animated.sequence([
- Animated.timing(playPressAnim, {
- toValue: 1,
- duration: 100,
- useNativeDriver: true,
- }),
- Animated.timing(playPressAnim, {
- toValue: 0,
- duration: 200,
- useNativeDriver: true,
- }),
- ]).start();
-
- Animated.sequence([
- Animated.timing(playIconScale, {
- toValue: 0.85,
- duration: 150,
- useNativeDriver: true,
- }),
- Animated.timing(playIconScale, {
- toValue: 1,
- duration: 150,
- useNativeDriver: true,
- }),
- ]).start();
-
- togglePlayback();
- };
-
-
-
-
- const deviceWidth = Dimensions.get('window').width;
- const BREAKPOINTS = { phone: 0, tablet: 768, largeTablet: 1024, tv: 1440 } as const;
- const getDeviceType = (w: number) => {
- if (w >= BREAKPOINTS.tv) return 'tv';
- if (w >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (w >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- };
- const deviceType = getDeviceType(deviceWidth);
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
-
- const closeIconSize = isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20;
- const skipIconSize = isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20;
- const playIconSize = isTV ? 48 : isLargeTablet ? 40 : isTablet ? 36 : 32;
return (
= ({
)}
-
+
-
- {/* Center Controls - CloudStream Style */}
-
-
- {/* Backward Seek Button (-10s) */}
- handleSeekWithAnimation(-10)}
- activeOpacity={0.7}
- >
-
-
-
-
-
- {showBackwardSign ? '-10' : '10'}
-
-
-
-
-
-
+ {/* Center Controls (Play/Pause, Skip) */}
+
+ {/* Left Skip Button */}
+ skip(-10)} style={styles.skipButton}>
+
+
+
+ 10
{/* Play/Pause Button */}
-
-
-
-
-
-
-
+
+
- {/* Forward Seek Button (+10s) */}
- handleSeekWithAnimation(10)}
- activeOpacity={0.7}
- >
-
-
-
-
-
- {showForwardSign ? '+10' : '10'}
-
-
-
-
-
-
+ {/* Right Skip Button */}
+ skip(10)} style={styles.skipButton}>
+
+
+
+ 10
-
-
-
-
{/* Bottom Gradient */}
= ({
)}
-
- {/* AirPlay Button - iOS only, KSAVPlayer only */}
- {Platform.OS === 'ios' && onAirPlayPress && playerBackend === 'KSAVPlayer' && (
-
-
-
- {allowsAirPlay ? 'AirPlay' : 'AirPlay Off'}
-
-
- )}
diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx
index b6713d4..d3269b8 100644
--- a/src/components/player/modals/SubtitleModals.tsx
+++ b/src/components/player/modals/SubtitleModals.tsx
@@ -306,75 +306,47 @@ export const SubtitleModals: React.FC = ({
Built-in Subtitles
- {/* Built-in subtitles now enabled for KSPlayer */}
- {isKsPlayerActive && (
+ {/* Notice about built-in subtitle limitations - only when KSPlayer active on iOS */}
+ {isIos && isKsPlayerActive && (
-
+
- Built-in subtitles enabled for KSPlayer
+ Built-in subtitles temporarily disabled
- KSPlayer built-in subtitle rendering is now available. You can select from embedded subtitle tracks below.
+ Due to some React Native limitations with KSPlayer, built-in subtitle rendering is temporarily disabled. Please use external subtitles instead for the best experience.
)}
-
- {/* Disable Subtitles Button */}
- {
- selectTextTrack(-1);
- setSelectedOnlineSubtitleId(null);
- }}
- activeOpacity={0.7}
- >
-
-
- Disable All Subtitles
-
- {selectedTextTrack === -1 && (
-
- )}
-
-
-
- {/* Always show built-in subtitles */}
- {ksTextTracks.length > 0 && (
+
+ {(!isIos || (isIos && !isKsPlayerActive)) && (
{ksTextTracks.map((track) => {
const isSelected = selectedTextTrack === track.id && !useCustomSubtitles;
+ // Debug logging for subtitle selection
+ if (__DEV__ && ksTextTracks.length > 0) {
+ console.log('[SubtitleModals] Track:', track.id, track.name, 'Selected:', selectedTextTrack, 'isSelected:', isSelected, 'useCustom:', useCustomSubtitles);
+ }
return (
{
- if (w >= BREAKPOINTS.tv) return 'tv';
- if (w >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (w >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
-};
-const deviceType = getDeviceType(deviceWidth);
-const isTablet = deviceType === 'tablet';
-const isLargeTablet = deviceType === 'largeTablet';
-const isTV = deviceType === 'tv';
-
-// Scales for larger displays
-const padH = isTV ? 28 : isLargeTablet ? 24 : isTablet ? 20 : 20;
-const padV = isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16;
-const titleFont = isTV ? 28 : isLargeTablet ? 24 : isTablet ? 22 : 18;
-const episodeInfoFont = isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14;
-const metadataFont = isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12;
-const qualityPadH = isTV ? 10 : isLargeTablet ? 9 : isTablet ? 8 : 8;
-const qualityPadV = isTV ? 4 : isLargeTablet ? 3 : isTablet ? 3 : 2;
-const qualityRadius = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4;
-const qualityTextFont = isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11;
-const controlsGap = isTV ? 56 : isLargeTablet ? 48 : isTablet ? 44 : 40;
-const controlsTranslateY = isTV ? -48 : isLargeTablet ? -42 : isTablet ? -36 : -30;
-const skipTextFont = isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12;
-const sliderBottom = isTV ? 80 : isLargeTablet ? 70 : isTablet ? 65 : 55;
-const progressTouchHeight = isTV ? 48 : isLargeTablet ? 44 : isTablet ? 40 : 40;
-const progressBarHeight = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 5 : 4;
-const progressThumbSize = isTV ? 24 : isLargeTablet ? 20 : isTablet ? 18 : 16;
-const progressThumbTop = isTV ? -10 : isLargeTablet ? -8 : isTablet ? -7 : -6;
-const durationFont = isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12;
-const bottomButtonTextFont = isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12;
+import { StyleSheet } from 'react-native';
export const styles = StyleSheet.create({
container: {
@@ -71,14 +37,14 @@ export const styles = StyleSheet.create({
padding: 0,
},
topGradient: {
- paddingTop: padV,
- paddingHorizontal: padH,
- paddingBottom: Math.max(10, Math.round(padV * 0.6)),
+ paddingTop: 20,
+ paddingHorizontal: 20,
+ paddingBottom: 10,
},
bottomGradient: {
- paddingBottom: padV,
- paddingHorizontal: padH,
- paddingTop: padV,
+ paddingBottom: 20,
+ paddingHorizontal: 20,
+ paddingTop: 20,
},
header: {
flexDirection: 'row',
@@ -91,12 +57,12 @@ export const styles = StyleSheet.create({
},
title: {
color: 'white',
- fontSize: titleFont,
+ fontSize: 18,
fontWeight: 'bold',
},
episodeInfo: {
color: 'rgba(255, 255, 255, 0.9)',
- fontSize: episodeInfoFont,
+ fontSize: 14,
marginTop: 3,
},
metadataRow: {
@@ -107,20 +73,20 @@ export const styles = StyleSheet.create({
},
metadataText: {
color: 'rgba(255, 255, 255, 0.7)',
- fontSize: metadataFont,
+ fontSize: 12,
marginRight: 8,
},
qualityBadge: {
backgroundColor: 'rgba(229, 9, 20, 0.2)',
- paddingHorizontal: qualityPadH,
- paddingVertical: qualityPadV,
- borderRadius: qualityRadius,
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 4,
marginRight: 8,
marginBottom: 4,
},
qualityText: {
color: '#E50914',
- fontSize: qualityTextFont,
+ fontSize: 11,
fontWeight: 'bold',
},
providerText: {
@@ -131,121 +97,60 @@ export const styles = StyleSheet.create({
closeButton: {
padding: 8,
},
-
-
- /* CloudStream Style - Center Controls */
controls: {
position: 'absolute',
flexDirection: 'row',
- justifyContent: 'center',
+ justifyContent: 'space-between',
alignItems: 'center',
left: 0,
right: 0,
top: '50%',
- transform: [{ translateY: controlsTranslateY }],
- paddingHorizontal: 20,
- gap: controlsGap,
+ transform: [{ translateY: -30 }],
+ paddingHorizontal: 40,
zIndex: 1000,
},
-
- /* CloudStream Style - Seek Buttons */
- seekButtonContainer: {
- alignItems: 'center',
- justifyContent: 'center',
- position: 'relative',
- },
- buttonCircle: {
- position: 'absolute',
- width: 50,
- height: 50,
- borderRadius: 25,
- backgroundColor: 'rgba(255, 255, 255, 0.3)',
- alignItems: 'center',
- justifyContent: 'center',
- },
- seekNumberContainer: {
- position: 'absolute',
- alignItems: 'center',
- justifyContent: 'center',
- width: 50,
- height: 50,
- },
- seekNumber: {
- color: '#FFFFFF',
- fontSize: 24,
- fontWeight: '500',
- opacity: 1,
- textAlign: 'center',
- marginLeft: -7, // Adjusted for better centering with icon
- },
-
- /* CloudStream Style - Play Button */
playButton: {
alignItems: 'center',
justifyContent: 'center',
+ backgroundColor: 'rgba(255, 255, 255, 0.2)',
+ borderRadius: 40,
+ padding: 15,
+ width: 80,
+ height: 80,
},
- playButtonCircle: {
+ skipButton: {
+ flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
- position: 'relative',
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ borderRadius: 8,
+ padding: 12,
+ width: 60,
},
- playIcon: {
- color: '#FFFFFF',
- opacity: 1,
+ skipText: {
+ color: 'white',
+ fontSize: 12,
+ fontWeight: '600',
+ marginTop: 4,
},
-
- /* CloudStream Style - Arc Animations */
- arcContainer: {
- position: 'absolute',
- alignItems: 'center',
- justifyContent: 'center',
- left: 0,
- right: 0,
- top: 0,
- bottom: 0,
- },
- arcLeft: {
- borderWidth: 4,
- borderColor: 'rgba(255, 255, 255, 0.9)',
- borderTopColor: 'transparent',
- borderRightColor: 'transparent',
- borderBottomColor: 'transparent',
- position: 'absolute',
- },
- arcRight: {
- borderWidth: 4,
- borderColor: 'rgba(255, 255, 255, 0.9)',
- borderTopColor: 'transparent',
- borderLeftColor: 'transparent',
- borderBottomColor: 'transparent',
- position: 'absolute',
- },
- playPressCircle: {
- position: 'absolute',
- backgroundColor: 'rgba(255, 255, 255, 0.3)',
- },
-
-
-
-
bottomControls: {
gap: 12,
},
sliderContainer: {
position: 'absolute',
- bottom: sliderBottom,
+ bottom: 55,
left: 0,
right: 0,
- paddingHorizontal: padH,
+ paddingHorizontal: 20,
zIndex: 1000,
},
progressTouchArea: {
- height: progressTouchHeight, // Increased touch area for larger displays
+ height: 40, // Increased from 30 to give more space for the thumb
justifyContent: 'center',
width: '100%',
},
progressBarContainer: {
- height: progressBarHeight,
+ height: 4,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 2,
overflow: 'hidden',
@@ -269,12 +174,12 @@ export const styles = StyleSheet.create({
},
progressThumb: {
position: 'absolute',
- width: progressThumbSize,
- height: progressThumbSize,
- borderRadius: progressThumbSize / 2,
+ width: 16,
+ height: 16,
+ borderRadius: 8,
backgroundColor: '#E50914',
- top: progressThumbTop, // Position to center on the progress bar
- marginLeft: -(progressThumbSize / 2), // Center the thumb horizontally
+ top: -6, // Position to center on the progress bar
+ marginLeft: -8, // Center the thumb horizontally
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
@@ -292,7 +197,7 @@ export const styles = StyleSheet.create({
},
duration: {
color: 'white',
- fontSize: durationFont,
+ fontSize: 12,
fontWeight: '500',
},
bottomButtons: {
@@ -307,7 +212,7 @@ export const styles = StyleSheet.create({
},
bottomButtonText: {
color: 'white',
- fontSize: bottomButtonTextFont,
+ fontSize: 12,
},
modalOverlay: {
flex: 1,
@@ -1103,15 +1008,4 @@ export const styles = StyleSheet.create({
marginTop: 8,
textAlign: 'center',
},
- // Additional missing styles
- skipButton: {
- alignItems: 'center',
- justifyContent: 'center',
- padding: 10,
- },
- skipText: {
- color: 'white',
- fontSize: skipTextFont,
- marginTop: 2,
- },
});
\ No newline at end of file
diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx
deleted file mode 100644
index a831744..0000000
--- a/src/components/ui/Toast.tsx
+++ /dev/null
@@ -1,284 +0,0 @@
-import React, { useEffect, useRef } from 'react';
-import {
- View,
- Text,
- StyleSheet,
- Animated,
- Dimensions,
- TouchableOpacity,
- Platform,
-} from 'react-native';
-import { MaterialIcons } from '@expo/vector-icons';
-import { useTheme } from '../../contexts/ThemeContext';
-
-const { width: screenWidth } = Dimensions.get('window');
-
-export interface ToastConfig {
- id: string;
- type: 'success' | 'error' | 'warning' | 'info';
- title: string;
- message?: string;
- duration?: number;
- position?: 'top' | 'bottom';
- action?: {
- label: string;
- onPress: () => void;
- };
-}
-
-interface ToastProps extends ToastConfig {
- onRemove: (id: string) => void;
-}
-
-const Toast: React.FC = ({
- id,
- type,
- title,
- message,
- duration = 4000,
- position = 'top',
- action,
- onRemove,
-}) => {
- const { currentTheme } = useTheme();
- const translateY = useRef(new Animated.Value(position === 'top' ? -100 : 100)).current;
- const opacity = useRef(new Animated.Value(0)).current;
- const scale = useRef(new Animated.Value(0.8)).current;
-
- useEffect(() => {
- // Animate in
- Animated.parallel([
- Animated.timing(translateY, {
- toValue: 0,
- duration: 300,
- useNativeDriver: true,
- }),
- Animated.timing(opacity, {
- toValue: 1,
- duration: 300,
- useNativeDriver: true,
- }),
- Animated.spring(scale, {
- toValue: 1,
- tension: 100,
- friction: 8,
- useNativeDriver: true,
- }),
- ]).start();
-
- // Auto remove
- const timer = setTimeout(() => {
- removeToast();
- }, duration);
-
- return () => clearTimeout(timer);
- }, []);
-
- const removeToast = () => {
- Animated.parallel([
- Animated.timing(translateY, {
- toValue: position === 'top' ? -100 : 100,
- duration: 250,
- useNativeDriver: true,
- }),
- Animated.timing(opacity, {
- toValue: 0,
- duration: 250,
- useNativeDriver: true,
- }),
- Animated.timing(scale, {
- toValue: 0.8,
- duration: 250,
- useNativeDriver: true,
- }),
- ]).start(() => {
- onRemove(id);
- });
- };
-
- const getToastConfig = () => {
- // Use the app's theme colors directly
- const isDarkTheme = true; // App uses dark theme by default
-
- switch (type) {
- case 'success':
- return {
- icon: 'check-circle' as const,
- color: currentTheme.colors.success,
- backgroundColor: currentTheme.colors.darkBackground,
- borderColor: currentTheme.colors.success,
- textColor: currentTheme.colors.highEmphasis,
- messageColor: currentTheme.colors.mediumEmphasis,
- };
- case 'error':
- return {
- icon: 'error' as const,
- color: currentTheme.colors.error,
- backgroundColor: currentTheme.colors.darkBackground,
- borderColor: currentTheme.colors.error,
- textColor: currentTheme.colors.highEmphasis,
- messageColor: currentTheme.colors.mediumEmphasis,
- };
- case 'warning':
- return {
- icon: 'warning' as const,
- color: currentTheme.colors.warning,
- backgroundColor: currentTheme.colors.darkBackground,
- borderColor: currentTheme.colors.warning,
- textColor: currentTheme.colors.highEmphasis,
- messageColor: currentTheme.colors.mediumEmphasis,
- };
- case 'info':
- return {
- icon: 'info' as const,
- color: currentTheme.colors.info,
- backgroundColor: currentTheme.colors.darkBackground,
- borderColor: currentTheme.colors.info,
- textColor: currentTheme.colors.highEmphasis,
- messageColor: currentTheme.colors.mediumEmphasis,
- };
- default:
- return {
- icon: 'info' as const,
- color: currentTheme.colors.mediumEmphasis,
- backgroundColor: currentTheme.colors.darkBackground,
- borderColor: currentTheme.colors.border,
- textColor: currentTheme.colors.highEmphasis,
- messageColor: currentTheme.colors.mediumEmphasis,
- };
- }
- };
-
- const config = getToastConfig();
-
- return (
-
-
-
-
-
-
-
-
- {title}
-
- {message && (
-
- {message}
-
- )}
-
-
-
-
- {action && (
- {
- action.onPress();
- removeToast();
- }}
- >
- {action.label}
-
- )}
-
-
-
-
-
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- position: 'absolute',
- left: 16,
- right: 16,
- borderRadius: 12,
- borderWidth: 1,
- shadowColor: '#000',
- shadowOffset: {
- width: 0,
- height: 4,
- },
- shadowOpacity: 0.3,
- shadowRadius: 12,
- elevation: 8,
- zIndex: 1000,
- },
- content: {
- flexDirection: 'row',
- alignItems: 'center',
- padding: 16,
- minHeight: 60,
- },
- leftSection: {
- flexDirection: 'row',
- alignItems: 'center',
- flex: 1,
- },
- iconContainer: {
- width: 32,
- height: 32,
- borderRadius: 16,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: 12,
- },
- textContainer: {
- flex: 1,
- },
- title: {
- fontSize: 16,
- fontWeight: '600',
- lineHeight: 20,
- marginBottom: 2,
- },
- message: {
- fontSize: 14,
- lineHeight: 18,
- fontWeight: '400',
- },
- rightSection: {
- flexDirection: 'row',
- alignItems: 'center',
- marginLeft: 12,
- },
- actionButton: {
- paddingHorizontal: 12,
- paddingVertical: 6,
- borderRadius: 6,
- marginRight: 8,
- },
- actionText: {
- color: 'white',
- fontSize: 14,
- fontWeight: '600',
- },
- closeButton: {
- padding: 4,
- },
-});
-
-export default Toast;
diff --git a/src/components/ui/ToastManager.tsx b/src/components/ui/ToastManager.tsx
deleted file mode 100644
index 3089bc9..0000000
--- a/src/components/ui/ToastManager.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import React, { useState, useCallback } from 'react';
-import { View, StyleSheet } from 'react-native';
-import Toast, { ToastConfig } from './Toast';
-
-interface ToastManagerProps {
- toasts: ToastConfig[];
- onRemoveToast: (id: string) => void;
-}
-
-const ToastManager: React.FC = ({ toasts, onRemoveToast }) => {
- return (
-
- {toasts.map((toast) => (
-
- ))}
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- zIndex: 1000,
- },
-});
-
-export default ToastManager;
-
diff --git a/src/contexts/AccountContext.tsx b/src/contexts/AccountContext.tsx
index a2d3699..f82fff4 100644
--- a/src/contexts/AccountContext.tsx
+++ b/src/contexts/AccountContext.tsx
@@ -1,5 +1,8 @@
import React, { createContext, useContext, useEffect, useMemo, useState, useRef } from 'react';
+import { InteractionManager } from 'react-native';
import accountService, { AuthUser } from '../services/AccountService';
+import supabase from '../services/supabaseClient';
+import syncService from '../services/SyncService';
type AccountContextValue = {
user: AuthUser | null;
@@ -19,19 +22,73 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child
const loadingTimeoutRef = useRef(null);
useEffect(() => {
- // Initial user load
- const loadUser = async () => {
- try {
+ // Initial session (load full profile)
+ // Defer heavy work until after initial interactions to reduce launch CPU spike
+ const task = InteractionManager.runAfterInteractions(() => {
+ (async () => {
const u = await accountService.getCurrentUser();
setUser(u);
- } catch (error) {
- console.warn('[AccountContext] Failed to load user:', error);
- } finally {
+ setLoading(false);
+ // Stage sync operations to avoid blocking the JS thread
+ syncService.init();
+ if (u) {
+ try {
+ await syncService.migrateLocalScopeToUser();
+ // Longer yield to event loop to reduce CPU pressure
+ await new Promise(resolve => setTimeout(resolve, 100));
+ await syncService.subscribeRealtime();
+ await new Promise(resolve => setTimeout(resolve, 100));
+ // Pull first to hydrate local state, then push to avoid wiping server with empty local
+ await syncService.fullPull();
+ await new Promise(resolve => setTimeout(resolve, 100));
+ await syncService.fullPush();
+ } catch {}
+ }
+ })();
+ });
+
+ // Auth state listener
+ const { data: subscription } = supabase.auth.onAuthStateChange(async (event, session) => {
+ // Only set loading for actual auth changes, not initial session
+ if (event !== 'INITIAL_SESSION') {
+ setLoading(true);
+ }
+ try {
+ const fullUser = session?.user ? await accountService.getCurrentUser() : null;
+ setUser(fullUser);
+ // Immediately clear loading so UI can transition to MainTabs/Auth
+ setLoading(false);
+ if (fullUser) {
+ // Run sync in background without blocking UI
+ setTimeout(async () => {
+ try {
+ await syncService.migrateLocalScopeToUser();
+ await new Promise(r => setTimeout(r, 0));
+ await syncService.subscribeRealtime();
+ await new Promise(r => setTimeout(r, 0));
+ await syncService.fullPull();
+ await new Promise(r => setTimeout(r, 0));
+ await syncService.fullPush();
+ } catch (error) {
+ console.warn('[AccountContext] Background sync failed:', error);
+ }
+ }, 0);
+ } else {
+ syncService.unsubscribeRealtime();
+ }
+ } catch (e) {
setLoading(false);
}
- };
+ });
- loadUser();
+ return () => {
+ subscription.subscription.unsubscribe();
+ task.cancel();
+ if (loadingTimeoutRef.current) {
+ clearTimeout(loadingTimeoutRef.current);
+ loadingTimeoutRef.current = null;
+ }
+ };
}, []);
const value = useMemo(() => ({
diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx
deleted file mode 100644
index ca6d739..0000000
--- a/src/contexts/ToastContext.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import React, { createContext, useContext, useEffect, useState } from 'react';
-import ToastManager from '../components/ui/ToastManager';
-import { ToastConfig } from '../components/ui/Toast';
-import { toastService } from '../services/toastService';
-
-interface ToastContextType {
- showSuccess: (title: string, message?: string, options?: Partial) => string;
- showError: (title: string, message?: string, options?: Partial) => string;
- showWarning: (title: string, message?: string, options?: Partial) => string;
- showInfo: (title: string, message?: string, options?: Partial) => string;
- showCustom: (config: Omit) => string;
- removeToast: (id: string) => void;
- removeAllToasts: () => void;
- // Convenience methods
- showSaved: () => string;
- showRemoved: () => string;
- showTraktSaved: () => string;
- showTraktRemoved: () => string;
- showNetworkError: () => string;
- showAuthError: () => string;
- showSyncSuccess: (count: number) => string;
- showProgressSaved: () => string;
-}
-
-const ToastContext = createContext(undefined);
-
-export const useToast = (): ToastContextType => {
- const context = useContext(ToastContext);
- if (!context) {
- throw new Error('useToast must be used within a ToastProvider');
- }
- return context;
-};
-
-interface ToastProviderProps {
- children: React.ReactNode;
-}
-
-export const ToastProvider: React.FC = ({ children }) => {
- const [toasts, setToasts] = useState([]);
-
- useEffect(() => {
- const unsubscribe = toastService.subscribe(setToasts);
- return unsubscribe;
- }, []);
-
- const contextValue: ToastContextType = {
- showSuccess: toastService.success.bind(toastService),
- showError: toastService.error.bind(toastService),
- showWarning: toastService.warning.bind(toastService),
- showInfo: toastService.info.bind(toastService),
- showCustom: toastService.custom.bind(toastService),
- removeToast: toastService.remove.bind(toastService),
- removeAllToasts: toastService.removeAll.bind(toastService),
- showSaved: toastService.showSaved.bind(toastService),
- showRemoved: toastService.showRemoved.bind(toastService),
- showTraktSaved: toastService.showTraktSaved.bind(toastService),
- showTraktRemoved: toastService.showTraktRemoved.bind(toastService),
- showNetworkError: toastService.showNetworkError.bind(toastService),
- showAuthError: toastService.showAuthError.bind(toastService),
- showSyncSuccess: toastService.showSyncSuccess.bind(toastService),
- showProgressSaved: toastService.showProgressSaved.bind(toastService),
- };
-
- return (
-
- {children}
-
-
- );
-};
-
diff --git a/src/contexts/TraktContext.tsx b/src/contexts/TraktContext.tsx
index 0f8c181..1cc30fe 100644
--- a/src/contexts/TraktContext.tsx
+++ b/src/contexts/TraktContext.tsx
@@ -30,13 +30,6 @@ interface TraktContextProps {
markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise;
markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise;
forceSyncTraktProgress?: () => Promise;
- // Trakt content management
- addToWatchlist: (imdbId: string, type: 'movie' | 'show') => Promise;
- removeFromWatchlist: (imdbId: string, type: 'movie' | 'show') => Promise;
- addToCollection: (imdbId: string, type: 'movie' | 'show') => Promise;
- removeFromCollection: (imdbId: string, type: 'movie' | 'show') => Promise;
- isInWatchlist: (imdbId: string, type: 'movie' | 'show') => boolean;
- isInCollection: (imdbId: string, type: 'movie' | 'show') => boolean;
}
const TraktContext = createContext(undefined);
diff --git a/src/hooks/useCalendarData.ts b/src/hooks/useCalendarData.ts
index 888761c..a209146 100644
--- a/src/hooks/useCalendarData.ts
+++ b/src/hooks/useCalendarData.ts
@@ -55,7 +55,7 @@ export const useCalendarData = (): UseCalendarDataReturn => {
try {
// Check memory pressure and cleanup if needed
memoryManager.checkMemoryPressure();
-
+
if (!forceRefresh) {
const cachedData = await robustCalendarCache.getCachedCalendarData(
libraryItems,
@@ -65,7 +65,7 @@ export const useCalendarData = (): UseCalendarDataReturn => {
watched: watchedShows,
}
);
-
+
if (cachedData) {
setCalendarData(cachedData);
setLoading(false);
@@ -156,11 +156,11 @@ export const useCalendarData = (): UseCalendarDataReturn => {
allSeries,
async (series: StreamingContent, index: number) => {
try {
- // Use the new memory-efficient method to fetch upcoming and recent episodes
+ // Use the new memory-efficient method to fetch only upcoming episodes
const episodeData = await stremioService.getUpcomingEpisodes(series.type, series.id, {
- daysBack: 90, // 3 months back for recently released episodes
- daysAhead: 60, // 2 months ahead for upcoming episodes
- maxEpisodes: 50, // Increased limit to get more episodes per series
+ daysBack: 14, // 2 weeks back
+ daysAhead: 28, // 4 weeks ahead
+ maxEpisodes: 25, // Limit episodes per series
});
if (episodeData && episodeData.episodes.length > 0) {
@@ -191,7 +191,7 @@ export const useCalendarData = (): UseCalendarDataReturn => {
// Transform episodes with memory-efficient processing
const transformedEpisodes = episodeData.episodes.map(video => {
const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {};
- const episode = {
+ return {
id: video.id,
seriesId: series.id,
title: tmdbEpisode.name || video.title || `Episode ${video.episode}`,
@@ -205,9 +205,6 @@ export const useCalendarData = (): UseCalendarDataReturn => {
still_path: tmdbEpisode.still_path || null,
season_poster_path: tmdbEpisode.season_poster_path || null
};
-
-
- return episode;
});
// Clear references to help garbage collection
@@ -260,17 +257,10 @@ export const useCalendarData = (): UseCalendarDataReturn => {
// Process results and separate episodes from no-episode series
for (const result of processedSeries) {
- if (!result) {
- logger.error(`[CalendarData] Null/undefined result in processedSeries`);
- continue;
- }
-
if (result.type === 'episodes' && Array.isArray(result.data)) {
allEpisodes.push(...result.data);
- } else if (result.type === 'no-episodes' && result.data) {
+ } else if (result.type === 'no-episodes') {
seriesWithoutEpisodes.push(result.data as CalendarEpisode);
- } else {
- logger.warn(`[CalendarData] Unexpected result type or missing data:`, result);
}
}
@@ -281,111 +271,35 @@ export const useCalendarData = (): UseCalendarDataReturn => {
allEpisodes = memoryManager.limitArraySize(allEpisodes, 500);
seriesWithoutEpisodes = memoryManager.limitArraySize(seriesWithoutEpisodes, 100);
- // Sort episodes by release date with error handling
- allEpisodes.sort((a, b) => {
- try {
- const dateA = new Date(a.releaseDate).getTime();
- const dateB = new Date(b.releaseDate).getTime();
- return dateA - dateB;
- } catch (error) {
- logger.warn(`[CalendarData] Error sorting episodes: ${a.releaseDate} vs ${b.releaseDate}`, error);
- return 0; // Keep original order if sorting fails
- }
- });
-
- logger.log(`[CalendarData] Total episodes fetched: ${allEpisodes.length}`);
-
- // Use memory-efficient filtering with error handling
+ // Sort episodes by release date
+ allEpisodes.sort((a, b) => new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime());
+
+ // Use memory-efficient filtering
const thisWeekEpisodes = await memoryManager.filterLargeArray(
- allEpisodes,
- ep => {
- try {
- if (!ep.releaseDate) return false;
- const parsed = parseISO(ep.releaseDate);
- return isThisWeek(parsed) && isAfter(parsed, new Date());
- } catch (error) {
- logger.warn(`[CalendarData] Error parsing date for this week filtering: ${ep.releaseDate}`, error);
- return false;
- }
- }
+ allEpisodes,
+ ep => isThisWeek(parseISO(ep.releaseDate))
);
-
+
const upcomingEpisodes = await memoryManager.filterLargeArray(
- allEpisodes,
- ep => {
- try {
- if (!ep.releaseDate) return false;
- const parsed = parseISO(ep.releaseDate);
- return isAfter(parsed, new Date()) && !isThisWeek(parsed);
- } catch (error) {
- logger.warn(`[CalendarData] Error parsing date for upcoming filtering: ${ep.releaseDate}`, error);
- return false;
- }
- }
+ allEpisodes,
+ ep => isAfter(parseISO(ep.releaseDate), new Date()) && !isThisWeek(parseISO(ep.releaseDate))
);
-
+
const recentEpisodes = await memoryManager.filterLargeArray(
- allEpisodes,
- ep => {
- try {
- if (!ep.releaseDate) return false;
- const parsed = parseISO(ep.releaseDate);
- return isBefore(parsed, new Date());
- } catch (error) {
- logger.warn(`[CalendarData] Error parsing date for recent filtering: ${ep.releaseDate}`, error);
- return false;
- }
- }
+ allEpisodes,
+ ep => isBefore(parseISO(ep.releaseDate), new Date()) && !isThisWeek(parseISO(ep.releaseDate))
);
-
- logger.log(`[CalendarData] Episode categorization: This Week: ${thisWeekEpisodes.length}, Upcoming: ${upcomingEpisodes.length}, Recently Released: ${recentEpisodes.length}, Series without episodes: ${seriesWithoutEpisodes.length}`);
-
- // Debug: Show some example episodes from each category
- if (thisWeekEpisodes && thisWeekEpisodes.length > 0) {
- logger.log(`[CalendarData] This Week examples:`, thisWeekEpisodes.slice(0, 3).map(ep => ({
- title: ep.title,
- date: ep.releaseDate,
- series: ep.seriesName
- })));
- }
- if (recentEpisodes && recentEpisodes.length > 0) {
- logger.log(`[CalendarData] Recently Released examples:`, recentEpisodes.slice(0, 3).map(ep => ({
- title: ep.title,
- date: ep.releaseDate,
- series: ep.seriesName
- })));
- }
-
+
const sections: CalendarSection[] = [];
- if (thisWeekEpisodes.length > 0) {
- sections.push({ title: 'This Week', data: thisWeekEpisodes });
- logger.log(`[CalendarData] Added 'This Week' section with ${thisWeekEpisodes.length} episodes`);
- }
- if (upcomingEpisodes.length > 0) {
- sections.push({ title: 'Upcoming', data: upcomingEpisodes });
- logger.log(`[CalendarData] Added 'Upcoming' section with ${upcomingEpisodes.length} episodes`);
- }
- if (recentEpisodes.length > 0) {
- sections.push({ title: 'Recently Released', data: recentEpisodes });
- logger.log(`[CalendarData] Added 'Recently Released' section with ${recentEpisodes.length} episodes`);
- }
- if (seriesWithoutEpisodes.length > 0) {
- sections.push({ title: 'Series with No Scheduled Episodes', data: seriesWithoutEpisodes });
- logger.log(`[CalendarData] Added 'Series with No Scheduled Episodes' section with ${seriesWithoutEpisodes.length} episodes`);
- }
-
- // Log section details before setting
- logger.log(`[CalendarData] About to set calendarData with ${sections.length} sections:`);
- sections.forEach((section, index) => {
- logger.log(` Section ${index}: "${section.title}" with ${section.data?.length || 0} episodes`);
- });
-
+ 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);
-
+
// Clear large arrays to help garbage collection
- // Note: Don't clear the arrays that are referenced in sections (thisWeekEpisodes, upcomingEpisodes, recentEpisodes, seriesWithoutEpisodes)
- // as they would empty the section data
- memoryManager.clearObjects(allEpisodes);
+ memoryManager.clearObjects(allEpisodes, thisWeekEpisodes, upcomingEpisodes, recentEpisodes);
await robustCalendarCache.setCachedCalendarData(
sections,
diff --git a/src/hooks/useFeaturedContent.ts b/src/hooks/useFeaturedContent.ts
index 0f04e60..ca2ecdf 100644
--- a/src/hooks/useFeaturedContent.ts
+++ b/src/hooks/useFeaturedContent.ts
@@ -59,16 +59,25 @@ export function useFeaturedContent() {
const loadFeaturedContent = useCallback(async (forceRefresh = false) => {
const t0 = Date.now();
+ logger.info('[useFeaturedContent] load:start', { forceRefresh, contentSource: 'catalogs', selectedCatalogsCount: (selectedCatalogs || []).length });
// Check if we should use cached data (disabled if DISABLE_CACHE)
const now = Date.now();
const cacheAge = now - persistentStore.lastFetchTime;
+ logger.debug('[useFeaturedContent] cache:status', {
+ disabled: DISABLE_CACHE,
+ hasFeatured: Boolean(persistentStore.featuredContent),
+ allCount: persistentStore.allFeaturedContent?.length || 0,
+ cacheAgeMs: cacheAge,
+ timeoutMs: CACHE_TIMEOUT,
+ });
if (!DISABLE_CACHE) {
if (!forceRefresh &&
persistentStore.featuredContent &&
persistentStore.allFeaturedContent.length > 0 &&
cacheAge < CACHE_TIMEOUT) {
// Use cached data
+ logger.info('[useFeaturedContent] cache:use', { duration: `${Date.now() - t0}ms` });
setFeaturedContent(persistentStore.featuredContent);
setAllFeaturedContent(persistentStore.allFeaturedContent);
setLoading(false);
@@ -77,6 +86,7 @@ export function useFeaturedContent() {
}
}
+ logger.info('[useFeaturedContent] fetch:start', { source: 'catalogs' });
setLoading(true);
cleanup();
abortControllerRef.current = new AbortController();
@@ -89,6 +99,7 @@ export function useFeaturedContent() {
// Load from installed catalogs
const tCats = Date.now();
const catalogs = await catalogService.getHomeCatalogs();
+ logger.info('[useFeaturedContent] catalogs:list', { count: catalogs?.length || 0, duration: `${Date.now() - tCats}ms` });
if (signal.aborted) return;
@@ -103,6 +114,7 @@ export function useFeaturedContent() {
return selectedCatalogs.includes(catalogId);
})
: catalogs; // Use all catalogs if none specifically selected
+ logger.debug('[useFeaturedContent] catalogs:filtered', { filteredCount: filteredCatalogs.length, selectedCount: selectedCatalogs?.length || 0 });
// Flatten all catalog items into a single array, filter out items without posters
const tFlat = Date.now();
@@ -112,6 +124,7 @@ export function useFeaturedContent() {
// Remove duplicates based on ID
index === self.findIndex(t => t.id === item.id)
);
+ logger.info('[useFeaturedContent] catalogs:items', { total: allItems.length, duration: `${Date.now() - tFlat}ms` });
// Sort by popular, newest, etc. (possibly enhanced later) and take first 10
const topItems = allItems.sort(() => Math.random() - 0.5).slice(0, 10);
@@ -136,8 +149,10 @@ export function useFeaturedContent() {
// If enrichment is disabled, use addon logo if available
if (!settings.enrichMetadataWithTMDB) {
if (base.logo && !isTmdbUrl(base.logo)) {
+ logger.debug('[useFeaturedContent] enrichment disabled, using addon logo', { name: item.name, logo: base.logo });
return base;
}
+ logger.debug('[useFeaturedContent] enrichment disabled, no addon logo available', { name: item.name });
return { ...base, logo: undefined };
}
@@ -157,13 +172,16 @@ export function useFeaturedContent() {
if (!tmdbId && !imdbId) return base;
// Try TMDB if we have a TMDB id
if (tmdbId) {
+ logger.debug('[useFeaturedContent] logo:try:tmdb', { name: item.name, id: item.id, tmdbId, lang: preferredLanguage });
const logoUrl = await tmdbService.getContentLogo(item.type === 'series' ? 'tv' : 'movie', tmdbId as string, preferredLanguage);
if (logoUrl) {
+ logger.debug('[useFeaturedContent] logo:tmdb:ok', { name: item.name, id: item.id, url: logoUrl, lang: preferredLanguage });
return { ...base, logo: logoUrl };
}
}
return base;
} catch (error) {
+ logger.error('[useFeaturedContent] logo:error', { name: item.name, id: item.id, error: String(error) });
return base;
}
};
@@ -179,6 +197,7 @@ export function useFeaturedContent() {
logoSource: c.logo ? (isTmdbUrl(String(c.logo)) ? 'tmdb' : 'addon') : 'none',
logo: c.logo || undefined,
}));
+ logger.info('[useFeaturedContent] catalogs:logos:details (enrich=true)', { items: details });
} catch {}
} else {
// When enrichment is disabled, prefer addon-provided logos; if missing, fetch basic meta to pull logo (like HeroSection)
@@ -200,15 +219,18 @@ export function useFeaturedContent() {
// Attempt to fill missing logos from addon meta details for a limited subset
const candidates = baseItems.filter(i => !i.logo).slice(0, 10);
+ logger.debug('[useFeaturedContent] catalogs:no-enrich:missing-logos', { count: candidates.length });
try {
const filled = await Promise.allSettled(candidates.map(async (item) => {
try {
const meta = await catalogService.getBasicContentDetails(item.type, item.id);
if (meta?.logo) {
+ logger.debug('[useFeaturedContent] catalogs:no-enrich:filled-logo', { id: item.id, name: item.name, logo: meta.logo });
return { id: item.id, logo: meta.logo } as { id: string; logo: string };
}
} catch (e) {
+ logger.warn('[useFeaturedContent] catalogs:no-enrich:fill-failed', { id: item.id, error: String(e) });
}
return { id: item.id, logo: undefined as any };
}));
@@ -235,6 +257,7 @@ export function useFeaturedContent() {
logoSource: c.logo ? (isTmdbUrl(String(c.logo)) ? 'tmdb' : 'addon') : 'none',
logo: c.logo || undefined,
}));
+ logger.info('[useFeaturedContent] catalogs:logos:details (no-enrich)', { items: details });
} catch {}
}
}
@@ -244,6 +267,7 @@ export function useFeaturedContent() {
// Safety guard: if nothing came back within a reasonable time, stop loading
if (!formattedContent || formattedContent.length === 0) {
+ logger.warn('[useFeaturedContent] results:empty');
// Fall back to any cached featured item so UI can render something
const cachedJson = await AsyncStorage.getItem(STORAGE_KEY).catch(() => null);
if (cachedJson) {
@@ -253,6 +277,7 @@ export function useFeaturedContent() {
formattedContent = Array.isArray(parsed.allFeaturedContent) && parsed.allFeaturedContent.length > 0
? parsed.allFeaturedContent
: [parsed.featuredContent];
+ logger.info('[useFeaturedContent] fallback:storage', { count: formattedContent.length });
}
} catch {}
}
@@ -270,6 +295,12 @@ export function useFeaturedContent() {
if (formattedContent.length > 0) {
persistentStore.featuredContent = formattedContent[0];
setFeaturedContent(formattedContent[0]);
+ logger.info('[useFeaturedContent] setting featuredContent', {
+ id: formattedContent[0].id,
+ name: formattedContent[0].name,
+ hasLogo: Boolean(formattedContent[0].logo),
+ logo: formattedContent[0].logo
+ });
currentIndexRef.current = 0;
// Persist cache for fast startup (skipped when cache disabled)
if (!DISABLE_CACHE) {
@@ -282,6 +313,7 @@ export function useFeaturedContent() {
allFeaturedContent: formattedContent,
})
);
+ logger.debug('[useFeaturedContent] cache:written', { firstId: formattedContent[0]?.id });
} catch {}
}
} else {
@@ -294,13 +326,16 @@ export function useFeaturedContent() {
}
} catch (error) {
if (signal.aborted) {
+ logger.info('[useFeaturedContent] fetch:aborted');
} else {
+ logger.error('[useFeaturedContent] fetch:error', { error: String(error) });
}
setFeaturedContent(null);
setAllFeaturedContent([]);
} finally {
if (!signal.aborted) {
setLoading(false);
+ logger.info('[useFeaturedContent] load:done', { duration: `${Date.now() - t0}ms` });
}
}
}, [cleanup, genreMap, loadingGenres, selectedCatalogs]);
@@ -309,6 +344,7 @@ export function useFeaturedContent() {
useEffect(() => {
if (DISABLE_CACHE) {
// Skip hydration entirely
+ logger.debug('[useFeaturedContent] hydrate:skipped');
return;
}
let cancelled = false;
@@ -328,6 +364,7 @@ export function useFeaturedContent() {
setFeaturedContent(parsed.featuredContent);
setAllFeaturedContent(persistentStore.allFeaturedContent);
setLoading(false);
+ logger.info('[useFeaturedContent] hydrate:storage', { allCount: persistentStore.allFeaturedContent.length });
}
}
} catch {}
@@ -355,6 +392,7 @@ export function useFeaturedContent() {
// Force refresh if settings changed during app restart, but only if we have content
if (settingsChanged && persistentStore.featuredContent) {
+ logger.info('[useFeaturedContent] settings:changed', { selectedCount: settings.selectedHeroCatalogs?.length || 0 });
loadFeaturedContent(true);
}
}, [settings, loadFeaturedContent]);
@@ -372,6 +410,11 @@ export function useFeaturedContent() {
const tmdbLangChanged = persistentStore.lastSettings.tmdbLanguagePreference !== nextTmdbLang;
if (catalogsChanged || logoPrefChanged || tmdbLangChanged) {
+ logger.info('[useFeaturedContent] event:settings-changed:immediate-refresh', {
+ catalogsChanged,
+ logoPrefChanged,
+ tmdbLangChanged
+ });
// Update internal state immediately so dependent effects are in sync
setSelectedCatalogs(nextSelected);
diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts
index d7369da..3c9ad63 100644
--- a/src/hooks/useMetadata.ts
+++ b/src/hooks/useMetadata.ts
@@ -107,8 +107,6 @@ interface UseMetadataReturn {
imdbId: string | null;
scraperStatuses: ScraperStatus[];
activeFetchingScrapers: string[];
- collectionMovies: StreamingContent[];
- loadingCollection: boolean;
}
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
@@ -134,8 +132,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const [loadAttempts, setLoadAttempts] = useState(0);
const [recommendations, setRecommendations] = useState([]);
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
- const [collectionMovies, setCollectionMovies] = useState([]);
- const [loadingCollection, setLoadingCollection] = useState(false);
const [imdbId, setImdbId] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({});
@@ -879,13 +875,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
// Commit final metadata once and cache it
- // Clear banner field if TMDB enrichment is enabled to prevent flash
- if (settings.enrichMetadataWithTMDB) {
- finalMetadata = {
- ...finalMetadata,
- banner: undefined, // Let useMetadataAssets handle banner via TMDB
- };
- }
setMetadata(finalMetadata);
cacheService.setMetadata(id, type, finalMetadata);
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
@@ -1943,94 +1932,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
tmdbId,
movieDetails: movieDetailsObj
}));
-
- // Fetch collection data if movie belongs to a collection
- if (movieDetails.belongs_to_collection) {
- setLoadingCollection(true);
- try {
- const collectionDetails = await tmdbService.getCollectionDetails(
- movieDetails.belongs_to_collection.id,
- lang
- );
-
- if (collectionDetails && collectionDetails.parts) {
- // Fetch individual movie images to get backdrops with embedded titles/logos
- const collectionMoviesData = await Promise.all(
- collectionDetails.parts.map(async (part: any, index: number) => {
- let movieBackdropUrl = undefined;
-
- // Try to fetch movie images with language parameter
- try {
- const movieImages = await tmdbService.getMovieImagesFull(part.id);
- if (movieImages && movieImages.backdrops && movieImages.backdrops.length > 0) {
- // Filter and sort backdrops by language and quality
- const languageBackdrops = movieImages.backdrops
- .filter((backdrop: any) => backdrop.aspect_ratio > 1.0) // Landscape orientation
- .sort((a: any, b: any) => {
- // Prioritize backdrops with the requested language
- const aHasLang = a.iso_639_1 === lang;
- const bHasLang = b.iso_639_1 === lang;
- if (aHasLang && !bHasLang) return -1;
- if (!aHasLang && bHasLang) return 1;
-
- // Then prioritize English if requested language not available
- const aIsEn = a.iso_639_1 === 'en';
- const bIsEn = b.iso_639_1 === 'en';
- if (aIsEn && !bIsEn) return -1;
- if (!aIsEn && bIsEn) return 1;
-
- // Then sort by vote average (quality), then by resolution
- if (a.vote_average !== b.vote_average) {
- return b.vote_average - a.vote_average;
- }
- return (b.width * b.height) - (a.width * a.height);
- });
-
- if (languageBackdrops.length > 0) {
- movieBackdropUrl = tmdbService.getImageUrl(languageBackdrops[0].file_path, 'original');
- }
- }
- } catch (error) {
- if (__DEV__) console.warn('[useMetadata] Failed to fetch movie images for:', part.id, error);
- }
-
- return {
- id: `tmdb:${part.id}`,
- type: 'movie',
- name: part.title,
- poster: part.poster_path ? tmdbService.getImageUrl(part.poster_path, 'w500') : 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
- banner: movieBackdropUrl || (part.backdrop_path ? tmdbService.getImageUrl(part.backdrop_path, 'original') : undefined),
- year: part.release_date ? new Date(part.release_date).getFullYear() : undefined,
- description: part.overview,
- collection: {
- id: collectionDetails.id,
- name: collectionDetails.name,
- poster_path: collectionDetails.poster_path,
- backdrop_path: collectionDetails.backdrop_path
- }
- };
- })
- ) as StreamingContent[];
-
- setCollectionMovies(collectionMoviesData);
-
- // Update metadata with collection info
- setMetadata((prev: any) => ({
- ...prev,
- collection: {
- id: collectionDetails.id,
- name: collectionDetails.name,
- poster_path: collectionDetails.poster_path,
- backdrop_path: collectionDetails.backdrop_path
- }
- }));
- }
- } catch (error) {
- if (__DEV__) console.error('[useMetadata] Error fetching collection:', error);
- } finally {
- setLoadingCollection(false);
- }
- }
}
}
@@ -2116,7 +2017,5 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
imdbId,
scraperStatuses,
activeFetchingScrapers,
- collectionMovies,
- loadingCollection,
};
};
\ No newline at end of file
diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts
index bb88eb3..0aa6e9e 100644
--- a/src/hooks/useMetadataAnimations.ts
+++ b/src/hooks/useMetadataAnimations.ts
@@ -40,7 +40,7 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) =
// Combined hero animations
const heroOpacity = useSharedValue(1);
const heroScale = useSharedValue(1); // Start at 1 for Android compatibility
- const heroHeightValue = useSharedValue(height * 0.55);
+ const heroHeightValue = useSharedValue(height * 0.5);
// Combined UI element animations
const uiElementsOpacity = useSharedValue(1);
diff --git a/src/hooks/useMetadataAssets.ts b/src/hooks/useMetadataAssets.ts
index e9411a4..db2d826 100644
--- a/src/hooks/useMetadataAssets.ts
+++ b/src/hooks/useMetadataAssets.ts
@@ -251,10 +251,16 @@ export const useMetadataAssets = (
setLoadingBanner(true);
+ // Show fallback banner immediately to prevent blank state
+ const fallbackBanner = metadata?.banner || metadata?.poster || null;
+ if (fallbackBanner && !bannerImage) {
+ setBannerImage(fallbackBanner);
+ setBannerSource('default');
+ }
// If enrichment is disabled, use addon banner and don't fetch from external sources
if (!settings.enrichMetadataWithTMDB) {
- const addonBanner = metadata?.banner || null;
+ const addonBanner = metadata?.banner || metadata?.poster || null;
if (addonBanner && addonBanner !== bannerImage) {
setBannerImage(addonBanner);
setBannerSource('default');
@@ -306,6 +312,15 @@ export const useMetadataAssets = (
finalBanner = tmdbService.getImageUrl(details.backdrop_path);
bannerSourceType = 'tmdb';
+ // Preload the image
+ if (finalBanner) {
+ FastImage.preload([{ uri: finalBanner }]);
+ }
+ }
+ else if (details?.poster_path) {
+ finalBanner = tmdbService.getImageUrl(details.poster_path);
+ bannerSourceType = 'tmdb';
+
// Preload the image
if (finalBanner) {
FastImage.preload([{ uri: finalBanner }]);
@@ -317,9 +332,9 @@ export const useMetadataAssets = (
}
}
- // Final fallback to metadata banner only
+ // Final fallback to metadata
if (!finalBanner) {
- finalBanner = metadata?.banner || null;
+ finalBanner = metadata?.banner || metadata?.poster || null;
bannerSourceType = 'default';
}
@@ -331,8 +346,8 @@ export const useMetadataAssets = (
forcedBannerRefreshDone.current = true;
} catch (error) {
- // Use default banner on error (only addon banner)
- const defaultBanner = metadata?.banner || null;
+ // Use default banner on error
+ const defaultBanner = metadata?.banner || metadata?.poster || null;
if (defaultBanner !== bannerImage) {
setBannerImage(defaultBanner);
setBannerSource('default');
diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts
index fd36dcd..7dc62db 100644
--- a/src/hooks/useSettings.ts
+++ b/src/hooks/useSettings.ts
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
+import { syncService } from '../services/SyncService';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Simple event emitter for settings changes
@@ -35,7 +36,7 @@ export interface AppSettings {
enableBackgroundPlayback: boolean;
cacheLimit: number;
useExternalPlayer: boolean;
- preferredPlayer: 'internal' | 'vlc' | 'infuse' | 'outplayer' | 'vidhub' | 'infuse_livecontainer' | 'external';
+ preferredPlayer: 'internal' | 'vlc' | 'infuse' | 'outplayer' | 'vidhub' | 'external';
showHeroSection: boolean;
featuredContentSource: 'tmdb' | 'catalogs';
heroStyle: 'legacy' | 'carousel';
@@ -82,11 +83,6 @@ export interface AppSettings {
useTmdbLocalizedMetadata: boolean; // Use TMDB localized metadata (titles, overviews) per tmdbLanguagePreference
// Trakt integration
showTraktComments: boolean; // Show Trakt comments in metadata screens
- // Continue Watching behavior
- useCachedStreams: boolean; // Enable/disable direct player navigation from Continue Watching cache
- openMetadataScreenWhenCacheDisabled: boolean; // When cache disabled, open MetadataScreen instead of StreamsScreen
- streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour)
- enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile
}
export const DEFAULT_SETTINGS: AppSettings = {
@@ -142,11 +138,6 @@ export const DEFAULT_SETTINGS: AppSettings = {
useTmdbLocalizedMetadata: false,
// Trakt integration
showTraktComments: true, // Show Trakt comments by default when authenticated
- // Continue Watching behavior
- useCachedStreams: false, // Enable by default
- openMetadataScreenWhenCacheDisabled: true, // Default to StreamsScreen when cache disabled
- streamCacheTTL: 60 * 60 * 1000, // Default: 1 hour in milliseconds
- enableStreamsBackdrop: true, // Enable by default (new behavior)
};
const SETTINGS_STORAGE_KEY = 'app_settings';
@@ -239,6 +230,8 @@ export const useSettings = () => {
settingsEmitter.emit();
}
+ // If authenticated, push settings to server to prevent overwrite on next pull
+ try { syncService.pushSettings(); } catch {}
} catch (error) {
if (__DEV__) console.error('Failed to save settings:', error);
}
@@ -251,4 +244,4 @@ export const useSettings = () => {
};
};
-export default useSettings;
+export default useSettings;
\ No newline at end of file
diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts
index fa181e1..5a6edea 100644
--- a/src/hooks/useTraktAutosync.ts
+++ b/src/hooks/useTraktAutosync.ts
@@ -35,7 +35,6 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
const hasStartedWatching = useRef(false);
const hasStopped = useRef(false); // New: Track if we've already stopped for this session
const isSessionComplete = useRef(false); // New: Track if session is completely finished (scrobbled)
- const isUnmounted = useRef(false); // New: Track if component has unmounted
const lastSyncTime = useRef(0);
const lastSyncProgress = useRef(0);
const sessionKey = useRef(null);
@@ -44,23 +43,21 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
// Generate a unique session key for this content instance
useEffect(() => {
- const contentKey = options.type === 'movie'
+ const contentKey = options.type === 'movie'
? `movie:${options.imdbId}`
- : `episode:${options.showImdbId || options.imdbId}:${options.season}:${options.episode}`;
+ : `episode:${options.imdbId}:${options.season}:${options.episode}`;
sessionKey.current = `${contentKey}:${Date.now()}`;
// Reset all session state for new content
hasStartedWatching.current = false;
hasStopped.current = false;
isSessionComplete.current = false;
- isUnmounted.current = false; // Reset unmount flag for new mount
lastStopCall.current = 0;
logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`);
return () => {
unmountCount.current++;
- isUnmounted.current = true; // Mark as unmounted to prevent post-unmount operations
logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`);
};
}, [options.imdbId, options.season, options.episode, options.type]);
@@ -107,10 +104,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
// Start watching (scrobble start)
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
- if (isUnmounted.current) return; // Prevent execution after component unmount
-
logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, alreadyStopped=${hasStopped.current}, sessionComplete=${isSessionComplete.current}, session=${sessionKey.current}`);
-
+
if (!isAuthenticated || !autosyncSettings.enabled) {
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
return;
@@ -161,8 +156,6 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
duration: number,
force: boolean = false
) => {
- if (isUnmounted.current) return; // Prevent execution after component unmount
-
if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) {
return;
}
@@ -238,8 +231,6 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
// Handle playback end/pause
const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' | 'user_close' = 'ended') => {
- if (isUnmounted.current) return; // Prevent execution after component unmount
-
const now = Date.now();
// Removed excessive logging for handlePlaybackEnd calls
@@ -348,7 +339,12 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
return;
}
- // Note: No longer boosting progress since Trakt API handles 80% threshold correctly
+ // For natural end events, ensure we cross Trakt's 80% scrobble threshold reliably.
+ // If close to the end, boost to 95% to avoid rounding issues.
+ if (reason === 'ended' && progressPercent < 95) {
+ logger.log(`[TraktAutosync] Natural end detected at ${progressPercent.toFixed(1)}%, boosting to 95% for scrobble`);
+ progressPercent = 95;
+ }
// Mark stop attempt and update timestamp
lastStopCall.current = now;
@@ -372,8 +368,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
currentTime
);
- // Mark session as complete if >= user completion threshold
- if (progressPercent >= autosyncSettings.completionThreshold) {
+ // Mark session as complete if high progress (scrobbled)
+ if (progressPercent >= 80) {
isSessionComplete.current = true;
logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`);
@@ -424,7 +420,6 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
hasStartedWatching.current = false;
hasStopped.current = false;
isSessionComplete.current = false;
- isUnmounted.current = false;
lastSyncTime.current = 0;
lastSyncProgress.current = 0;
unmountCount.current = 0;
diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts
index 0a585f5..c06f177 100644
--- a/src/hooks/useTraktIntegration.ts
+++ b/src/hooks/useTraktIntegration.ts
@@ -26,10 +26,6 @@ export function useTraktIntegration() {
const [continueWatching, setContinueWatching] = useState([]);
const [ratedContent, setRatedContent] = useState([]);
const [lastAuthCheck, setLastAuthCheck] = useState(Date.now());
-
- // State for real-time status tracking
- const [watchlistItems, setWatchlistItems] = useState>(new Set());
- const [collectionItems, setCollectionItems] = useState>(new Set());
// Check authentication status
const checkAuthStatus = useCallback(async () => {
@@ -112,39 +108,6 @@ export function useTraktIntegration() {
setCollectionShows(collectionShows);
setContinueWatching(continueWatching);
setRatedContent(ratings);
-
- // Populate watchlist and collection sets for quick lookups
- const newWatchlistItems = new Set();
- const newCollectionItems = new Set();
-
- // Add movies to sets
- watchlistMovies.forEach(item => {
- if (item.movie?.ids?.imdb) {
- newWatchlistItems.add(`movie:${item.movie.ids.imdb}`);
- }
- });
-
- collectionMovies.forEach(item => {
- if (item.movie?.ids?.imdb) {
- newCollectionItems.add(`movie:${item.movie.ids.imdb}`);
- }
- });
-
- // Add shows to sets
- watchlistShows.forEach(item => {
- if (item.show?.ids?.imdb) {
- newWatchlistItems.add(`show:${item.show.ids.imdb}`);
- }
- });
-
- collectionShows.forEach(item => {
- if (item.show?.ids?.imdb) {
- newCollectionItems.add(`show:${item.show.ids.imdb}`);
- }
- });
-
- setWatchlistItems(newWatchlistItems);
- setCollectionItems(newCollectionItems);
} catch (error) {
logger.error('[useTraktIntegration] Error loading all collections:', error);
} finally {
@@ -200,105 +163,6 @@ export function useTraktIntegration() {
}
}, [isAuthenticated, loadWatchedItems]);
- // Add content to Trakt watchlist
- const addToWatchlist = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise => {
- if (!isAuthenticated) return false;
-
- try {
- const success = await traktService.addToWatchlist(imdbId, type);
- if (success) {
- // Ensure consistent IMDb ID format (with 'tt' prefix)
- const normalizedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
- setWatchlistItems(prev => new Set(prev).add(`${type}:${normalizedImdbId}`));
- // Don't refresh immediately - let the local state handle the UI update
- // The data will be refreshed on next app focus or manual refresh
- }
- return success;
- } catch (error) {
- logger.error('[useTraktIntegration] Error adding to watchlist:', error);
- return false;
- }
- }, [isAuthenticated]);
-
- // Remove content from Trakt watchlist
- const removeFromWatchlist = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise => {
- if (!isAuthenticated) return false;
-
- try {
- const success = await traktService.removeFromWatchlist(imdbId, type);
- if (success) {
- // Ensure consistent IMDb ID format (with 'tt' prefix)
- const normalizedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
- setWatchlistItems(prev => {
- const newSet = new Set(prev);
- newSet.delete(`${type}:${normalizedImdbId}`);
- return newSet;
- });
- // Don't refresh immediately - let the local state handle the UI update
- }
- return success;
- } catch (error) {
- logger.error('[useTraktIntegration] Error removing from watchlist:', error);
- return false;
- }
- }, [isAuthenticated]);
-
- // Add content to Trakt collection
- const addToCollection = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise => {
- if (!isAuthenticated) return false;
-
- try {
- const success = await traktService.addToCollection(imdbId, type);
- if (success) {
- // Ensure consistent IMDb ID format (with 'tt' prefix)
- const normalizedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
- setCollectionItems(prev => new Set(prev).add(`${type}:${normalizedImdbId}`));
- // Don't refresh immediately - let the local state handle the UI update
- }
- return success;
- } catch (error) {
- logger.error('[useTraktIntegration] Error adding to collection:', error);
- return false;
- }
- }, [isAuthenticated]);
-
- // Remove content from Trakt collection
- const removeFromCollection = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise => {
- if (!isAuthenticated) return false;
-
- try {
- const success = await traktService.removeFromCollection(imdbId, type);
- if (success) {
- // Ensure consistent IMDb ID format (with 'tt' prefix)
- const normalizedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
- setCollectionItems(prev => {
- const newSet = new Set(prev);
- newSet.delete(`${type}:${normalizedImdbId}`);
- return newSet;
- });
- // Don't refresh immediately - let the local state handle the UI update
- }
- return success;
- } catch (error) {
- logger.error('[useTraktIntegration] Error removing from collection:', error);
- return false;
- }
- }, [isAuthenticated]);
-
- // Check if content is in Trakt watchlist
- const isInWatchlist = useCallback((imdbId: string, type: 'movie' | 'show'): boolean => {
- // Ensure consistent IMDb ID format (with 'tt' prefix)
- const normalizedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
- return watchlistItems.has(`${type}:${normalizedImdbId}`);
- }, [watchlistItems]);
-
- // Check if content is in Trakt collection
- const isInCollection = useCallback((imdbId: string, type: 'movie' | 'show'): boolean => {
- // Ensure consistent IMDb ID format (with 'tt' prefix)
- const normalizedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
- return collectionItems.has(`${type}:${normalizedImdbId}`);
- }, [collectionItems]);
-
// Mark an episode as watched
const markEpisodeAsWatched = useCallback(async (
imdbId: string,
@@ -666,13 +530,6 @@ export function useTraktIntegration() {
getTraktPlaybackProgress,
syncAllProgress,
fetchAndMergeTraktProgress,
- forceSyncTraktProgress, // For manual testing
- // Trakt content management
- addToWatchlist,
- removeFromWatchlist,
- addToCollection,
- removeFromCollection,
- isInWatchlist,
- isInCollection
+ forceSyncTraktProgress // For manual testing
};
}
\ No newline at end of file
diff --git a/src/hooks/useUpdatePopup.ts b/src/hooks/useUpdatePopup.ts
index ed3cc89..87ba6c7 100644
--- a/src/hooks/useUpdatePopup.ts
+++ b/src/hooks/useUpdatePopup.ts
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Platform } from 'react-native';
-import { toastService } from '../services/toastService';
+import { Toast } from 'toastify-react-native';
import UpdateService, { UpdateInfo } from '../services/updateService';
import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -78,13 +78,13 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
// The app will automatically reload with the new version
console.log('Update installed successfully');
} else {
- toastService.showError('Installation Failed', 'Unable to install the update. Please try again later or check your internet connection.');
+ Toast.error('Unable to install the update. Please try again later or check your internet connection.');
// Show popup again after failed installation
setShowUpdatePopup(true);
}
} catch (error) {
if (__DEV__) console.error('Error installing update:', error);
- toastService.showError('Installation Error', 'An error occurred while installing the update. Please try again later.');
+ Toast.error('An error occurred while installing the update. Please try again later.');
// Show popup again after error
setShowUpdatePopup(true);
} finally {
@@ -135,7 +135,7 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
(async () => {
try { await AsyncStorage.setItem(UPDATE_BADGE_KEY, 'true'); } catch {}
})();
- toastService.showInfo('Update Available', 'Update available — go to Settings → App Updates');
+ try { Toast.info('Update available — go to Settings → App Updates'); } catch {}
setShowUpdatePopup(false);
} else {
setShowUpdatePopup(true);
diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
index 58794fa..c805799 100644
--- a/src/navigation/AppNavigator.tsx
+++ b/src/navigation/AppNavigator.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef, useMemo, useState } from 'react';
+import React, { useEffect, useRef, useMemo } 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';
@@ -15,6 +15,7 @@ import { HeaderVisibility } from '../contexts/HeaderVisibility';
import { Stream } from '../types/streams';
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../contexts/ThemeContext';
+import ToastManager from 'toastify-react-native';
import { PostHogProvider } from 'posthog-react-native';
// Optional iOS Glass effect (expo-glass-effect) with safe fallback
@@ -67,8 +68,6 @@ import AISettingsScreen from '../screens/AISettingsScreen';
import AIChatScreen from '../screens/AIChatScreen';
import BackdropGalleryScreen from '../screens/BackdropGalleryScreen';
import BackupScreen from '../screens/BackupScreen';
-import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen';
-import ContributorsScreen from '../screens/ContributorsScreen';
// Stack navigator types
export type RootStackParamList = {
@@ -175,8 +174,6 @@ export type RootStackParamList = {
type: 'movie' | 'tv';
title: string;
};
- ContinueWatchingSettings: undefined;
- Contributors: undefined;
};
export type RootStackNavigationProp = NativeStackNavigationProp;
@@ -430,21 +427,11 @@ const TabIcon = React.memo(({ focused, color, iconName, iconLibrary = 'material'
// Update the TabScreenWrapper component with fixed layout dimensions
const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) => {
- const [dimensions, setDimensions] = useState(Dimensions.get('window'));
-
- useEffect(() => {
- const subscription = Dimensions.addEventListener('change', ({ window }) => {
- setDimensions(window);
- });
-
- return () => subscription?.remove();
- }, []);
-
const isTablet = useMemo(() => {
- const { width, height } = dimensions;
+ const { width, height } = Dimensions.get('window');
const smallestDimension = Math.min(width, height);
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
- }, [dimensions]);
+ }, []);
const insets = useSafeAreaInsets();
// Force consistent status bar settings
useEffect(() => {
@@ -510,15 +497,6 @@ const MainTabs = () => {
const { useSettings: useSettingsHook } = require('../hooks/useSettings');
const { settings: appSettings } = useSettingsHook();
const [hasUpdateBadge, setHasUpdateBadge] = React.useState(false);
- const [dimensions, setDimensions] = useState(Dimensions.get('window'));
-
- useEffect(() => {
- const subscription = Dimensions.addEventListener('change', ({ window }) => {
- setDimensions(window);
- });
-
- return () => subscription?.remove();
- }, []);
React.useEffect(() => {
if (Platform.OS !== 'android') return;
let mounted = true;
@@ -552,10 +530,10 @@ const MainTabs = () => {
}, []);
const { isHomeLoading } = useLoading();
const isTablet = useMemo(() => {
- const { width, height } = dimensions;
+ const { width, height } = Dimensions.get('window');
const smallestDimension = Math.min(width, height);
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
- }, [dimensions]);
+ }, []);
const insets = useSafeAreaInsets();
const isIosTablet = Platform.OS === 'ios' && isTablet;
const [hidden, setHidden] = React.useState(HeaderVisibility.isHidden());
@@ -694,7 +672,7 @@ const MainTabs = () => {
bottom: 0,
left: 0,
right: 0,
- height: Platform.OS === 'android' ? 70 + insets.bottom : 85 + insets.bottom,
+ height: 85 + insets.bottom,
backgroundColor: 'transparent',
overflow: 'hidden',
}}>
@@ -744,8 +722,8 @@ const MainTabs = () => {
@@ -1289,36 +1267,6 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
},
}}
/>
-
-
+ {/* Global toast customization using ThemeContext */}
+ (
+
+ {props.text1}
+ {props.text2 ? (
+ {props.text2}
+ ) : null}
+
+ ),
+ success: (props: any) => (
+
+ {props.text1}
+ {props.text2 ? (
+ {props.text2}
+ ) : null}
+
+ ),
+ error: (props: any) => (
+
+ {props.text1}
+ {props.text2 ? (
+ {props.text2}
+ ) : null}
+
+ ),
+ }}
+ />
);
};
diff --git a/src/screens/AuthScreen.tsx b/src/screens/AuthScreen.tsx
index fa9eda6..edb36e5 100644
--- a/src/screens/AuthScreen.tsx
+++ b/src/screens/AuthScreen.tsx
@@ -7,7 +7,7 @@ import { useTheme } from '../contexts/ThemeContext';
import { useAccount } from '../contexts/AccountContext';
import { useNavigation, useRoute } from '@react-navigation/native';
import * as Haptics from 'expo-haptics';
-import { useToast } from '../contexts/ToastContext';
+import ToastManager, { Toast } from 'toastify-react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { width, height } = Dimensions.get('window');
@@ -19,7 +19,6 @@ const AuthScreen: React.FC = () => {
const route = useRoute();
const fromOnboarding = !!route?.params?.fromOnboarding;
const insets = useSafeAreaInsets();
- const { showError, showSuccess } = useToast();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -150,7 +149,7 @@ const AuthScreen: React.FC = () => {
if (mode === 'signup' && signupDisabled) {
const msg = 'Sign up is currently disabled due to upcoming system changes';
setError(msg);
- showError('Sign Up Disabled', 'Sign up is currently disabled due to upcoming system changes');
+ Toast.error(msg);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
return;
}
@@ -158,21 +157,21 @@ const AuthScreen: React.FC = () => {
if (!isEmailValid) {
const msg = 'Enter a valid email address';
setError(msg);
- showError('Invalid Email', 'Enter a valid email address');
+ Toast.error(msg);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
return;
}
if (!isPasswordValid) {
const msg = 'Password must be at least 6 characters';
setError(msg);
- showError('Password Too Short', 'Password must be at least 6 characters');
+ Toast.error(msg);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
return;
}
if (mode === 'signup' && !passwordsMatch) {
const msg = 'Passwords do not match';
setError(msg);
- showError('Passwords Don\'t Match', 'Passwords do not match');
+ Toast.error(msg);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
return;
}
@@ -181,11 +180,11 @@ const AuthScreen: React.FC = () => {
const err = mode === 'signin' ? await signIn(email.trim(), password) : await signUp(email.trim(), password);
if (err) {
setError(err);
- showError('Authentication Failed', err);
+ Toast.error(err);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
} else {
const msg = mode === 'signin' ? 'Logged in successfully' : 'Sign up successful';
- showSuccess('Success', msg);
+ Toast.success(msg);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
// Navigate to main tabs after successful authentication
diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx
index a3318d3..e2eb31a 100644
--- a/src/screens/CalendarScreen.tsx
+++ b/src/screens/CalendarScreen.tsx
@@ -232,20 +232,6 @@ const CalendarScreen = () => {
// Log when rendering with relevant state info
logger.log(`[Calendar] Rendering: loading=${loading}, calendarData sections=${calendarData.length}, allEpisodes=${allEpisodes.length}`);
-
- // Log section details
- if (calendarData.length > 0) {
- calendarData.forEach((section, index) => {
- logger.log(`[Calendar] Section ${index}: "${section.title}" with ${section.data.length} episodes`);
- if (section.data && section.data.length > 0) {
- logger.log(`[Calendar] First episode in "${section.title}": ${section.data[0].seriesName} - ${section.data[0].title} (${section.data[0].releaseDate})`);
- } else {
- logger.log(`[Calendar] Section "${section.title}" has empty or undefined data array`);
- }
- });
- } else {
- logger.log(`[Calendar] No calendarData sections available`);
- }
// Handle date selection from calendar
const handleDateSelect = useCallback((date: Date) => {
diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx
index 2bdec21..ce72e9c 100644
--- a/src/screens/CatalogScreen.tsx
+++ b/src/screens/CatalogScreen.tsx
@@ -58,13 +58,12 @@ const SPACING = {
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
-// Dynamic column and spacing calculation based on screen width
+// Dynamic column calculation based on screen width
const calculateCatalogLayout = (screenWidth: number) => {
const MIN_ITEM_WIDTH = 120;
const MAX_ITEM_WIDTH = 180; // Increased for tablets
- // Increase padding and spacing on larger screens for proper breathing room
- const HORIZONTAL_PADDING = screenWidth >= 1600 ? SPACING.xl * 4 : screenWidth >= 1200 ? SPACING.xl * 3 : screenWidth >= 1000 ? SPACING.xl * 2 : SPACING.lg * 2;
- const ITEM_SPACING = screenWidth >= 1600 ? SPACING.xl : screenWidth >= 1200 ? SPACING.lg : screenWidth >= 1000 ? SPACING.md : SPACING.sm;
+ const HORIZONTAL_PADDING = SPACING.lg * 2;
+ const ITEM_SPACING = SPACING.sm;
// Calculate how many columns can fit
const availableWidth = screenWidth - HORIZONTAL_PADDING;
@@ -81,12 +80,9 @@ const calculateCatalogLayout = (screenWidth: number) => {
} else if (screenWidth < 1200) {
// Large tablet: 4-6 columns
numColumns = Math.min(Math.max(maxColumns, 4), 6);
- } else if (screenWidth < 1600) {
- // Desktop-ish: 5-8 columns
- numColumns = Math.min(Math.max(maxColumns, 5), 8);
} else {
- // Ultra-wide: 6-10 columns
- numColumns = Math.min(Math.max(maxColumns, 6), 10);
+ // Very large screens: 5-8 columns
+ numColumns = Math.min(Math.max(maxColumns, 5), 8);
}
// Calculate actual item width with proper spacing
@@ -94,13 +90,11 @@ const calculateCatalogLayout = (screenWidth: number) => {
const itemWidth = (availableWidth - totalSpacing) / numColumns;
// Ensure item width doesn't exceed maximum
- const finalItemWidth = Math.floor(Math.min(itemWidth, MAX_ITEM_WIDTH));
+ const finalItemWidth = Math.min(itemWidth, MAX_ITEM_WIDTH);
return {
numColumns,
- itemWidth: finalItemWidth,
- itemSpacing: ITEM_SPACING,
- containerPadding: HORIZONTAL_PADDING / 2, // use half per side for contentContainerStyle padding
+ itemWidth: finalItemWidth
};
};
@@ -115,6 +109,9 @@ const createStyles = (colors: any) => StyleSheet.create({
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
+ // Center header on very wide screens
+ alignSelf: 'center',
+ maxWidth: 1400,
width: '100%',
},
backButton: {
@@ -134,11 +131,17 @@ const createStyles = (colors: any) => StyleSheet.create({
paddingHorizontal: 16,
paddingBottom: 16,
paddingTop: 8,
+ // Center title on very wide screens
+ alignSelf: 'center',
+ maxWidth: 1400,
width: '100%',
},
list: {
padding: SPACING.lg,
paddingTop: SPACING.sm,
+ // Center content on very wide screens
+ alignSelf: 'center',
+ maxWidth: 1400, // Prevent content from being too wide on large screens
width: '100%',
},
item: {
@@ -650,12 +653,11 @@ const CatalogScreen: React.FC = ({ route, navigation }) => {
const effectiveItemWidth = React.useMemo(() => {
if (effectiveNumColumns === screenData.numColumns) return screenData.itemWidth;
// recompute width for custom columns on mobile to maintain spacing roughly similar
- const HORIZONTAL_PADDING = (screenData as any).containerPadding ? (screenData as any).containerPadding * 2 : 16 * 2;
- const ITEM_SPACING = (screenData as any).itemSpacing ?? 8;
+ const HORIZONTAL_PADDING = 16 * 2; // SPACING.lg * 2
+ const ITEM_SPACING = 8; // SPACING.sm
const availableWidth = screenData.width - HORIZONTAL_PADDING;
const totalSpacing = ITEM_SPACING * (effectiveNumColumns - 1);
- const width = (availableWidth - totalSpacing) / effectiveNumColumns;
- return Math.floor(width);
+ return (availableWidth - totalSpacing) / effectiveNumColumns;
}, [effectiveNumColumns, screenData.width, screenData.itemWidth]);
// Helper function to optimize poster URLs
@@ -676,7 +678,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => {
// Calculate if this is the last item in a row
const isLastInRow = (index + 1) % effectiveNumColumns === 0;
// For proper spacing
- const rightMargin = isLastInRow ? 0 : ((screenData as any).itemSpacing ?? SPACING.sm);
+ const rightMargin = isLastInRow ? 0 : SPACING.sm;
return (
= ({ route, navigation }) => {
keyExtractor={(item) => `${item.id}-${item.type}`}
numColumns={effectiveNumColumns}
key={effectiveNumColumns}
- ItemSeparatorComponent={() => }
refreshControl={
= ({ route, navigation }) => {
tintColor={colors.primary}
/>
}
- contentContainerStyle={[styles.list, { paddingHorizontal: (screenData as any).containerPadding ?? SPACING.lg, paddingTop: SPACING.sm, paddingBottom: SPACING.lg }]}
+ contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
getItemType={() => 'item'}
diff --git a/src/screens/ContinueWatchingSettingsScreen.tsx b/src/screens/ContinueWatchingSettingsScreen.tsx
deleted file mode 100644
index 72bfdca..0000000
--- a/src/screens/ContinueWatchingSettingsScreen.tsx
+++ /dev/null
@@ -1,471 +0,0 @@
-import React, { useState, useCallback, useEffect } from 'react';
-import {
- View,
- Text,
- StyleSheet,
- TouchableOpacity,
- ScrollView,
- StatusBar,
- Platform,
- Switch,
- Animated,
-} from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
-import { useNavigation } from '@react-navigation/native';
-import { NavigationProp } from '@react-navigation/native';
-import { MaterialIcons } from '@expo/vector-icons';
-import { useTheme } from '../contexts/ThemeContext';
-import { useSettings } from '../hooks/useSettings';
-import { RootStackParamList } from '../navigation/AppNavigator';
-
-// TTL options in milliseconds - organized in rows
-const TTL_OPTIONS = [
- [
- { label: '15 min', value: 15 * 60 * 1000 },
- { label: '30 min', value: 30 * 60 * 1000 },
- { label: '1 hour', value: 60 * 60 * 1000 },
- ],
- [
- { label: '2 hours', value: 2 * 60 * 60 * 1000 },
- { label: '6 hours', value: 6 * 60 * 60 * 1000 },
- { label: '12 hours', value: 12 * 60 * 60 * 1000 },
- ],
- [
- { label: '24 hours', value: 24 * 60 * 60 * 1000 },
- ],
-];
-
-const ContinueWatchingSettingsScreen: React.FC = () => {
- const navigation = useNavigation>();
- const { settings, updateSetting } = useSettings();
- const { currentTheme } = useTheme();
- const colors = currentTheme.colors;
- const styles = createStyles(colors);
- const [showSavedIndicator, setShowSavedIndicator] = useState(false);
- const fadeAnim = React.useRef(new Animated.Value(0)).current;
-
- // Prevent iOS entrance flicker by restoring a non-translucent StatusBar
- useEffect(() => {
- try {
- StatusBar.setTranslucent(false);
- StatusBar.setBackgroundColor(colors.darkBackground);
- StatusBar.setBarStyle('light-content');
- if (Platform.OS === 'ios') {
- StatusBar.setHidden(false);
- }
- } catch {}
- }, [colors.darkBackground]);
-
- const handleBack = useCallback(() => {
- navigation.goBack();
- }, [navigation]);
-
- // Fade in/out animation for the "Changes saved" indicator
- useEffect(() => {
- if (showSavedIndicator) {
- Animated.sequence([
- Animated.timing(fadeAnim, {
- toValue: 1,
- duration: 300,
- useNativeDriver: true
- }),
- Animated.delay(1000),
- Animated.timing(fadeAnim, {
- toValue: 0,
- duration: 300,
- useNativeDriver: true
- })
- ]).start(() => setShowSavedIndicator(false));
- }
- }, [showSavedIndicator, fadeAnim]);
-
- const handleUpdateSetting = useCallback((
- key: K,
- value: typeof settings[K]
- ) => {
- updateSetting(key, value);
- setShowSavedIndicator(true);
- }, [updateSetting]);
-
- const CustomSwitch = ({ value, onValueChange }: { value: boolean; onValueChange: (value: boolean) => void }) => (
-
- );
-
- const SettingItem = ({
- title,
- description,
- value,
- onValueChange,
- isLast = false
- }: {
- title: string;
- description: string;
- value: boolean;
- onValueChange: (value: boolean) => void;
- isLast?: boolean;
- }) => (
-
-
-
- {title}
-
-
- {description}
-
-
-
-
- );
-
-
- const TTLPickerItem = ({ option }: { option: { label: string; value: number } }) => {
- const isSelected = settings.streamCacheTTL === option.value;
- return (
- handleUpdateSetting('streamCacheTTL', option.value)}
- activeOpacity={0.7}
- >
-
- {option.label}
-
- {isSelected && (
-
- )}
-
- );
- };
-
- return (
-
-
-
- {/* Header */}
-
-
-
- Settings
-
-
-
-
- Continue Watching
-
-
- {/* Content */}
-
-
- PLAYBACK BEHAVIOR
-
- handleUpdateSetting('useCachedStreams', value)}
- isLast={!settings.useCachedStreams}
- />
- {!settings.useCachedStreams && (
- handleUpdateSetting('openMetadataScreenWhenCacheDisabled', value)}
- isLast={true}
- />
- )}
-
-
-
- {settings.useCachedStreams && (
-
- CACHE SETTINGS
-
-
-
- Stream Cache Duration
-
-
- How long to keep cached stream links before they expire
-
-
- {TTL_OPTIONS.map((row, rowIndex) => (
-
- {row.map((option) => (
-
- ))}
-
- ))}
-
-
-
-
- )}
-
- {settings.useCachedStreams && (
-
-
-
-
-
- Important Note
-
-
-
- Not all stream links may remain active for the full cache duration. Longer cache times may result in expired links. If a cached link fails, the app will fall back to fetching fresh streams.
-
-
-
- )}
-
-
-
-
-
-
- How it works
-
-
-
- {settings.useCachedStreams ? (
- <>
- • Streams are cached for your selected duration after playing{'\n'}
- • Cached streams are validated before use{'\n'}
- • If cache is invalid or expired, falls back to content screen{'\n'}
- • "Use Cached Streams" controls direct player vs screen navigation{'\n'}
- • "Open Metadata Screen" appears only when cached streams are disabled
- >
- ) : (
- <>
- • When cached streams are disabled, clicking Continue Watching items opens content screens{'\n'}
- • "Open Metadata Screen" option controls which screen to open{'\n'}
- • Metadata screen shows content details and allows manual stream selection{'\n'}
- • Streams screen shows available streams for immediate playback
- >
- )}
-
-
-
-
-
- {/* Saved indicator */}
-
-
- Changes saved
-
-
- );
-};
-
-// Create a styles creator function that accepts the theme colors
-const createStyles = (colors: any) => StyleSheet.create({
- container: {
- flex: 1,
- },
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: 16,
- paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 8 : 8,
- },
- backButton: {
- flexDirection: 'row',
- alignItems: 'center',
- padding: 8,
- },
- backText: {
- fontSize: 17,
- fontWeight: '400',
- color: colors.primary,
- },
- headerTitle: {
- fontSize: 34,
- fontWeight: '700',
- color: colors.white,
- paddingHorizontal: 16,
- paddingBottom: 16,
- paddingTop: 8,
- },
- content: {
- flex: 1,
- },
- contentContainer: {
- paddingBottom: 100,
- },
- section: {
- marginBottom: 24,
- },
- sectionTitle: {
- fontSize: 13,
- fontWeight: '600',
- color: colors.mediumGray,
- marginHorizontal: 16,
- marginBottom: 8,
- letterSpacing: 0.5,
- textTransform: 'uppercase',
- },
- settingsCard: {
- marginHorizontal: 16,
- backgroundColor: colors.elevation2,
- borderRadius: 12,
- padding: 16,
- shadowColor: '#000',
- shadowOffset: { width: 0, height: 2 },
- shadowOpacity: 0.1,
- shadowRadius: 4,
- elevation: 2,
- overflow: 'hidden',
- },
- settingItem: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: 16,
- borderBottomWidth: 1,
- },
- settingContent: {
- flex: 1,
- marginRight: 16,
- },
- settingTitle: {
- fontSize: 16,
- fontWeight: '600',
- marginBottom: 4,
- },
- settingDescription: {
- fontSize: 14,
- lineHeight: 20,
- },
- infoCard: {
- marginHorizontal: 16,
- marginTop: 16,
- backgroundColor: colors.elevation2,
- borderRadius: 12,
- padding: 16,
- shadowColor: '#000',
- shadowOffset: { width: 0, height: 2 },
- shadowOpacity: 0.1,
- shadowRadius: 4,
- elevation: 2,
- },
- infoHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- marginBottom: 12,
- },
- infoTitle: {
- fontSize: 16,
- fontWeight: '600',
- marginLeft: 8,
- },
- infoText: {
- fontSize: 14,
- lineHeight: 20,
- },
- savedIndicator: {
- position: 'absolute',
- bottom: 32,
- left: 16,
- right: 16,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: 12,
- paddingHorizontal: 20,
- borderRadius: 8,
- },
- savedText: {
- color: '#FFFFFF',
- fontSize: 14,
- fontWeight: '600',
- marginLeft: 8,
- },
- ttlOptionsContainer: {
- width: '100%',
- gap: 8,
- },
- ttlRow: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- width: '100%',
- gap: 8,
- },
- ttlOption: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 12,
- paddingVertical: 10,
- borderRadius: 8,
- borderWidth: 1,
- flex: 1,
- justifyContent: 'center',
- gap: 6,
- },
- ttlOptionText: {
- fontSize: 14,
- fontWeight: '600',
- },
- warningCard: {
- marginHorizontal: 16,
- marginTop: 16,
- backgroundColor: colors.elevation2,
- borderRadius: 12,
- padding: 16,
- borderWidth: 1,
- shadowColor: '#000',
- shadowOffset: { width: 0, height: 2 },
- shadowOpacity: 0.1,
- shadowRadius: 4,
- elevation: 2,
- },
- warningHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- marginBottom: 12,
- },
- warningTitle: {
- fontSize: 16,
- fontWeight: '600',
- marginLeft: 8,
- },
- warningText: {
- fontSize: 14,
- lineHeight: 20,
- },
-});
-
-export default ContinueWatchingSettingsScreen;
diff --git a/src/screens/ContributorsScreen.tsx b/src/screens/ContributorsScreen.tsx
deleted file mode 100644
index ae270b5..0000000
--- a/src/screens/ContributorsScreen.tsx
+++ /dev/null
@@ -1,568 +0,0 @@
-import React, { useState, useEffect, useCallback } from 'react';
-import {
- View,
- Text,
- StyleSheet,
- TouchableOpacity,
- ScrollView,
- SafeAreaView,
- StatusBar,
- Platform,
- Dimensions,
- Linking,
- RefreshControl,
- FlatList,
- ActivityIndicator
-} from 'react-native';
-import AsyncStorage from '@react-native-async-storage/async-storage';
-import { useNavigation } from '@react-navigation/native';
-import { NavigationProp } from '@react-navigation/native';
-import FastImage from '@d11/react-native-fast-image';
-import { Feather } from '@expo/vector-icons';
-import { useTheme } from '../contexts/ThemeContext';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { fetchContributors, GitHubContributor } from '../services/githubReleaseService';
-import { RootStackParamList } from '../navigation/AppNavigator';
-
-const { width, height } = Dimensions.get('window');
-const isTablet = width >= 768;
-const isLargeTablet = width >= 1024;
-
-const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
-
-interface ContributorCardProps {
- contributor: GitHubContributor;
- currentTheme: any;
- isTablet: boolean;
- isLargeTablet: boolean;
-}
-
-const ContributorCard: React.FC = ({ contributor, currentTheme, isTablet, isLargeTablet }) => {
- const handlePress = useCallback(() => {
- Linking.openURL(contributor.html_url);
- }, [contributor.html_url]);
-
- return (
-
-
-
-
- {contributor.login}
-
-
- {contributor.contributions} contributions
-
-
-
-
- );
-};
-
-const ContributorsScreen: React.FC = () => {
- const navigation = useNavigation>();
- const { currentTheme } = useTheme();
- const insets = useSafeAreaInsets();
-
- const [contributors, setContributors] = useState([]);
- const [loading, setLoading] = useState(true);
- const [refreshing, setRefreshing] = useState(false);
- const [error, setError] = useState(null);
-
- const loadContributors = useCallback(async (isRefresh = false) => {
- try {
- if (isRefresh) {
- setRefreshing(true);
- } else {
- setLoading(true);
- }
- setError(null);
-
- // Check cache first (unless refreshing)
- if (!isRefresh) {
- try {
- const cachedData = await AsyncStorage.getItem('github_contributors');
- const cacheTimestamp = await AsyncStorage.getItem('github_contributors_timestamp');
- const now = Date.now();
- const ONE_HOUR = 60 * 60 * 1000; // 1 hour cache
-
- if (cachedData && cacheTimestamp) {
- const timestamp = parseInt(cacheTimestamp, 10);
- if (now - timestamp < ONE_HOUR) {
- const parsedData = JSON.parse(cachedData);
- // Only use cache if it has actual contributors data
- if (parsedData && Array.isArray(parsedData) && parsedData.length > 0) {
- setContributors(parsedData);
- setLoading(false);
- return;
- } else {
- // Remove invalid cache
- await AsyncStorage.removeItem('github_contributors');
- await AsyncStorage.removeItem('github_contributors_timestamp');
- if (__DEV__) console.log('Removed invalid contributors cache');
- }
- }
- }
- } catch (cacheError) {
- if (__DEV__) console.error('Cache read error:', cacheError);
- // Remove corrupted cache
- try {
- await AsyncStorage.removeItem('github_contributors');
- await AsyncStorage.removeItem('github_contributors_timestamp');
- } catch {}
- }
- }
-
- const data = await fetchContributors();
- if (data && Array.isArray(data) && data.length > 0) {
- setContributors(data);
- // Only cache valid data
- try {
- await AsyncStorage.setItem('github_contributors', JSON.stringify(data));
- await AsyncStorage.setItem('github_contributors_timestamp', Date.now().toString());
- } catch (cacheError) {
- if (__DEV__) console.error('Cache write error:', cacheError);
- }
- } else {
- // Clear any existing cache if we get invalid data
- try {
- await AsyncStorage.removeItem('github_contributors');
- await AsyncStorage.removeItem('github_contributors_timestamp');
- } catch {}
- setError('Unable to load contributors. This might be due to GitHub API rate limits.');
- }
- } catch (err) {
- setError('Failed to load contributors. Please check your internet connection.');
- if (__DEV__) console.error('Error loading contributors:', err);
- } finally {
- setLoading(false);
- setRefreshing(false);
- }
- }, []);
-
- useEffect(() => {
- // Clear any invalid cache on mount
- const clearInvalidCache = async () => {
- try {
- const cachedData = await AsyncStorage.getItem('github_contributors');
- if (cachedData) {
- const parsedData = JSON.parse(cachedData);
- if (!parsedData || !Array.isArray(parsedData) || parsedData.length === 0) {
- await AsyncStorage.removeItem('github_contributors');
- await AsyncStorage.removeItem('github_contributors_timestamp');
- if (__DEV__) console.log('Cleared invalid cache on mount');
- }
- }
- } catch (error) {
- if (__DEV__) console.error('Error checking cache on mount:', error);
- }
- };
-
- clearInvalidCache();
- loadContributors();
- }, [loadContributors]);
-
- const handleRefresh = useCallback(() => {
- loadContributors(true);
- }, [loadContributors]);
-
- const renderContributor = useCallback(({ item }: { item: GitHubContributor }) => (
-
- ), [currentTheme]);
-
- const keyExtractor = useCallback((item: GitHubContributor) => item.id.toString(), []);
-
- const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top);
-
- if (loading && !refreshing) {
- return (
-
-
-
-
- navigation.goBack()}
- >
-
- Settings
-
-
-
- Contributors
-
-
-
-
-
- Loading contributors...
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- navigation.goBack()}
- >
-
- Settings
-
-
-
- Contributors
-
-
-
-
-
- {error ? (
-
-
-
- {error}
-
-
- GitHub API rate limit exceeded. Please try again later or pull to refresh.
-
- loadContributors()}
- >
-
- Try Again
-
-
-
- ) : contributors.length === 0 ? (
-
-
-
- No contributors found
-
-
- ) : (
-
- }
- showsVerticalScrollIndicator={false}
- >
-
-
-
-
- We're grateful for every contribution
-
-
- Each line of code, bug report, and suggestion helps make Nuvio better for everyone
-
-
-
-
-
-
- )}
-
-
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- headerContainer: {
- paddingHorizontal: 20,
- paddingBottom: 8,
- backgroundColor: 'transparent',
- zIndex: 2,
- },
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- marginBottom: 12,
- },
- backButton: {
- flexDirection: 'row',
- alignItems: 'center',
- },
- backText: {
- fontSize: 16,
- fontWeight: '500',
- marginLeft: 4,
- },
- headerTitle: {
- fontSize: 32,
- fontWeight: '800',
- letterSpacing: 0.3,
- paddingLeft: 4,
- },
- tabletHeaderTitle: {
- fontSize: 40,
- letterSpacing: 0.5,
- },
- content: {
- flex: 1,
- zIndex: 1,
- alignItems: 'center',
- },
- contentContainer: {
- flex: 1,
- width: '100%',
- },
- tabletContentContainer: {
- maxWidth: 1000,
- width: '100%',
- },
- scrollView: {
- flex: 1,
- },
- gratitudeCard: {
- padding: 20,
- marginBottom: 20,
- borderRadius: 16,
- shadowColor: '#000',
- shadowOffset: { width: 0, height: 2 },
- shadowOpacity: 0.1,
- shadowRadius: 4,
- elevation: 3,
- },
- tabletGratitudeCard: {
- padding: 32,
- marginBottom: 32,
- borderRadius: 24,
- shadowOpacity: 0.15,
- shadowRadius: 8,
- elevation: 5,
- },
- gratitudeContent: {
- alignItems: 'center',
- },
- gratitudeText: {
- fontSize: 18,
- fontWeight: '600',
- marginTop: 12,
- marginBottom: 8,
- textAlign: 'center',
- },
- tabletGratitudeText: {
- fontSize: 24,
- fontWeight: '700',
- marginTop: 16,
- marginBottom: 12,
- },
- gratitudeSubtext: {
- fontSize: 14,
- lineHeight: 20,
- opacity: 0.8,
- textAlign: 'center',
- },
- tabletGratitudeSubtext: {
- fontSize: 17,
- lineHeight: 26,
- maxWidth: 600,
- },
- loadingContainer: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- },
- loadingText: {
- marginTop: 12,
- fontSize: 16,
- },
- errorContainer: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- paddingHorizontal: 40,
- },
- errorText: {
- fontSize: 16,
- textAlign: 'center',
- marginTop: 16,
- marginBottom: 8,
- },
- errorSubtext: {
- fontSize: 14,
- textAlign: 'center',
- opacity: 0.7,
- marginBottom: 24,
- },
- retryButton: {
- paddingHorizontal: 24,
- paddingVertical: 12,
- borderRadius: 8,
- },
- retryText: {
- fontSize: 16,
- fontWeight: '600',
- },
- emptyContainer: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- paddingHorizontal: 40,
- },
- emptyText: {
- fontSize: 16,
- textAlign: 'center',
- marginTop: 16,
- },
- listContent: {
- paddingHorizontal: 16,
- paddingBottom: 20,
- },
- tabletListContent: {
- paddingHorizontal: 32,
- paddingBottom: 40,
- },
- tabletRow: {
- justifyContent: 'space-between',
- },
- contributorCard: {
- flexDirection: 'row',
- alignItems: 'center',
- padding: 16,
- marginBottom: 12,
- borderRadius: 16,
- shadowColor: '#000',
- shadowOffset: { width: 0, height: 2 },
- shadowOpacity: 0.1,
- shadowRadius: 4,
- elevation: 3,
- },
- tabletContributorCard: {
- padding: 20,
- marginBottom: 16,
- marginHorizontal: 6,
- borderRadius: 20,
- shadowOpacity: 0.15,
- shadowRadius: 8,
- elevation: 5,
- width: '48%',
- },
- avatar: {
- width: 60,
- height: 60,
- borderRadius: 30,
- marginRight: 16,
- },
- tabletAvatar: {
- width: 80,
- height: 80,
- borderRadius: 40,
- marginRight: 20,
- },
- contributorInfo: {
- flex: 1,
- },
- username: {
- fontSize: 16,
- fontWeight: '600',
- marginBottom: 4,
- },
- tabletUsername: {
- fontSize: 18,
- fontWeight: '700',
- },
- contributions: {
- fontSize: 14,
- opacity: 0.8,
- },
- tabletContributions: {
- fontSize: 16,
- },
- externalIcon: {
- marginLeft: 8,
- },
-});
-
-export default ContributorsScreen;
diff --git a/src/screens/DownloadsScreen.tsx b/src/screens/DownloadsScreen.tsx
index 9873673..a5f3591 100644
--- a/src/screens/DownloadsScreen.tsx
+++ b/src/screens/DownloadsScreen.tsx
@@ -29,7 +29,7 @@ import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { useDownloads } from '../contexts/DownloadsContext';
import type { DownloadItem } from '../contexts/DownloadsContext';
-import { useToast } from '../contexts/ToastContext';
+import { Toast } from 'toastify-react-native';
import CustomAlert from '../components/CustomAlert';
const { height, width } = Dimensions.get('window');
@@ -98,7 +98,6 @@ const DownloadItemComponent: React.FC<{
onRequestRemove: (item: DownloadItem) => void;
}> = React.memo(({ item, onPress, onAction, onRequestRemove }) => {
const { currentTheme } = useTheme();
- const { showSuccess, showInfo } = useToast();
const [posterUrl, setPosterUrl] = useState(item.posterUrl || null);
// Try to fetch poster if not available
@@ -114,18 +113,18 @@ const DownloadItemComponent: React.FC<{
if (item.status === 'completed' && item.fileUri) {
Clipboard.setString(item.fileUri);
if (Platform.OS === 'android') {
- showSuccess('Path Copied', 'Local file path copied to clipboard');
+ Toast.success('Local file path copied to clipboard');
} else {
Alert.alert('Copied', 'Local file path copied to clipboard');
}
} else if (item.status !== 'completed') {
if (Platform.OS === 'android') {
- showInfo('Download Incomplete', 'Download is not complete yet');
+ Toast.info('Download is not complete yet');
} else {
Alert.alert('Not Available', 'The local file path is available only after the download is complete.');
}
}
- }, [item.status, item.fileUri, showSuccess, showInfo]);
+ }, [item.status, item.fileUri]);
const formatBytes = (bytes?: number) => {
if (!bytes || bytes <= 0) return '0 B';
@@ -344,7 +343,6 @@ const DownloadsScreen: React.FC = () => {
const { currentTheme } = useTheme();
const { top: safeAreaTop } = useSafeAreaInsets();
const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads();
- const { showSuccess, showInfo } = useToast();
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all');
diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx
index 2363171..ca1c596 100644
--- a/src/screens/HomeScreen.tsx
+++ b/src/screens/HomeScreen.tsx
@@ -9,7 +9,6 @@ import {
StatusBar,
useColorScheme,
Dimensions,
- useWindowDimensions,
ImageBackground,
ScrollView,
Platform,
@@ -30,7 +29,7 @@ import { Stream } from '../types/metadata';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
-import Animated, { FadeIn, Layout } from 'react-native-reanimated';
+import Animated, { FadeIn } from 'react-native-reanimated';
import { PanGestureHandler } from 'react-native-gesture-handler';
import {
Gesture,
@@ -60,7 +59,7 @@ import { useLoading } from '../contexts/LoadingContext';
import * as ScreenOrientation from 'expo-screen-orientation';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { useToast } from '../contexts/ToastContext';
+import { Toast } from 'toastify-react-native';
import FirstTimeWelcome from '../components/FirstTimeWelcome';
import { HeaderVisibility } from '../contexts/HeaderVisibility';
@@ -112,7 +111,6 @@ const HomeScreen = () => {
const continueWatchingRef = useRef(null);
const { settings } = useSettings();
const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
- const { showInfo } = useToast();
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
const refreshTimeoutRef = useRef(null);
@@ -126,16 +124,6 @@ const HomeScreen = () => {
const totalCatalogsRef = useRef(0);
const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory
const insets = useSafeAreaInsets();
-
- // Stabilize insets to prevent iOS layout shifts
- const [stableInsetsTop, setStableInsetsTop] = useState(insets.top);
- useEffect(() => {
- // Only update insets after initial mount to prevent shifting
- const timer = setTimeout(() => {
- setStableInsetsTop(insets.top);
- }, 100);
- return () => clearTimeout(timer);
- }, [insets.top]);
const {
featuredContent,
@@ -353,7 +341,7 @@ const HomeScreen = () => {
await AsyncStorage.removeItem('showLoginHintToastOnce');
hideTimer = setTimeout(() => setHintVisible(false), 2000);
// Also show a global toast for consistency across screens
- showInfo('Sign In Available', 'You can sign in anytime from Settings → Account');
+ try { Toast.info('You can sign in anytime from Settings → Account', 'bottom'); } catch {}
}
} catch {}
})();
@@ -390,10 +378,7 @@ const HomeScreen = () => {
// Allow free rotation on tablets; lock portrait on phones
try {
- // Use device physical characteristics, not current orientation
- const isTabletDevice = Platform.OS === 'ios'
- ? (Platform as any).isPad === true
- : Math.min(windowWidth, Dimensions.get('screen').height) >= 768;
+ const isTabletDevice = Platform.OS === 'ios' ? (Platform as any).isPad === true : isTablet;
if (isTabletDevice) {
ScreenOrientation.unlockAsync();
} else {
@@ -620,11 +605,11 @@ const HomeScreen = () => {
// Stable keyExtractor for FlashList
const keyExtractor = useCallback((item: HomeScreenListItem) => item.key, []);
- // Use reactive window dimensions that update on orientation changes
- const { width: windowWidth } = useWindowDimensions();
+ // Memoize device check to avoid repeated Dimensions.get calls
const isTablet = useMemo(() => {
- return windowWidth >= 768;
- }, [windowWidth]);
+ const deviceWidth = Dimensions.get('window').width;
+ return deviceWidth >= 768;
+ }, []);
// Memoize individual section components to prevent re-renders
const memoizedFeaturedContent = useMemo(() => {
@@ -644,7 +629,7 @@ const HomeScreen = () => {
loading={featuredLoading}
/>
);
- }, [isTablet, settings.heroStyle, showHeroSection, featuredContentSource, featuredLoading]);
+ }, [isTablet, settings.heroStyle, showHeroSection, featuredContentSource, featuredContent, allFeaturedContent, isSaved, handleSaveToLibrary, featuredLoading]);
const memoizedThisWeekSection = useMemo(() => , []);
const memoizedContinueWatchingSection = useMemo(() => , []);
@@ -668,11 +653,15 @@ const HomeScreen = () => {
const renderListItem = useCallback(({ item }: { item: HomeScreenListItem; index: number }) => {
switch (item.type) {
case 'thisWeek':
- return memoizedThisWeekSection;
+ return {memoizedThisWeekSection};
case 'continueWatching':
return null; // Moved to ListHeaderComponent to avoid remounts on scroll
case 'catalog':
- return ;
+ return (
+
+
+
+ );
case 'placeholder':
return (
@@ -712,7 +701,7 @@ const HomeScreen = () => {
);
case 'welcome':
- return ;
+ return ;
default:
return null;
}
@@ -758,10 +747,10 @@ const HomeScreen = () => {
}
}, [toggleHeader]);
- // Memoize content container style - use stable insets to prevent iOS shifting
- const contentContainerStyle = useMemo(() =>
- StyleSheet.flatten([styles.scrollContent, { paddingTop: stableInsetsTop }]),
- [stableInsetsTop]
+ // Memoize content container style
+ const contentContainerStyle = useMemo(() =>
+ StyleSheet.flatten([styles.scrollContent, { paddingTop: insets.top }]),
+ [insets.top]
);
// Memoize the main content section
@@ -786,7 +775,7 @@ const HomeScreen = () => {
onEndReached={handleLoadMoreCatalogs}
onEndReachedThreshold={0.6}
recycleItems={true}
- maintainVisibleContentPosition={Platform.OS !== 'ios'} // Disable on iOS to prevent shifting
+ maintainVisibleContentPosition
onScroll={handleScroll}
/>
{/* Toasts are rendered globally at root */}
@@ -1352,5 +1341,4 @@ const HomeScreenWithFocusSync = (props: any) => {
return ;
};
-export default React.memo(HomeScreenWithFocusSync);
-
+export default React.memo(HomeScreenWithFocusSync);
\ No newline at end of file
diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx
index 0239c2d..26ffb0a 100644
--- a/src/screens/LibraryScreen.tsx
+++ b/src/screens/LibraryScreen.tsx
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { DeviceEventEmitter } from 'react-native';
import { Share } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
-import { useToast } from '../contexts/ToastContext';
+import { Toast } from 'toastify-react-native';
import DropUpMenu from '../components/home/DropUpMenu';
import {
View,
@@ -208,7 +208,6 @@ const LibraryScreen = () => {
const [filter, setFilter] = useState<'trakt' | 'movies' | 'series'>('movies');
const [showTraktContent, setShowTraktContent] = useState(false);
const [selectedTraktFolder, setSelectedTraktFolder] = useState(null);
- const { showInfo, showError } = useToast();
// DropUpMenu state
const [menuVisible, setMenuVisible] = useState(false);
const [selectedItem, setSelectedItem] = useState(null);
@@ -1006,11 +1005,11 @@ const LibraryScreen = () => {
case 'library': {
try {
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
- showInfo('Removed from Library', 'Item removed from your library');
+ Toast.info('Removed from Library');
setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type)));
setMenuVisible(false);
} catch (error) {
- showError('Failed to update Library', 'Unable to remove item from library');
+ Toast.error('Failed to update Library');
}
break;
}
@@ -1020,7 +1019,7 @@ const LibraryScreen = () => {
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
const newWatched = !selectedItem.watched;
await AsyncStorage.setItem(key, newWatched ? 'true' : 'false');
- showInfo(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', newWatched ? 'Item marked as watched' : 'Item marked as unwatched');
+ Toast.info(newWatched ? 'Marked as Watched' : 'Marked as Unwatched');
// Instantly update local state
setLibraryItems(prev => prev.map(item =>
item.id === selectedItem.id && item.type === selectedItem.type
@@ -1028,7 +1027,7 @@ const LibraryScreen = () => {
: item
));
} catch (error) {
- showError('Failed to update watched status', 'Unable to update watched status');
+ Toast.error('Failed to update watched status');
}
break;
}
diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx
index 675a5f3..40043de 100644
--- a/src/screens/MetadataScreen.tsx
+++ b/src/screens/MetadataScreen.tsx
@@ -17,7 +17,6 @@ import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/nativ
import { MaterialIcons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import { useTheme } from '../contexts/ThemeContext';
-import { useTraktContext } from '../contexts/TraktContext';
import { useMetadata } from '../hooks/useMetadata';
import { useDominantColor, preloadDominantColor } from '../hooks/useDominantColor';
import { CastSection } from '../components/metadata/CastSection';
@@ -28,7 +27,6 @@ import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'
import { RatingsSection } from '../components/metadata/RatingsSection';
import { CommentsSection, CommentBottomSheet } from '../components/metadata/CommentsSection';
import TrailersSection from '../components/metadata/TrailersSection';
-import CollectionSection from '../components/metadata/CollectionSection';
import { RouteParams, Episode } from '../types/metadata';
import Animated, {
useAnimatedStyle,
@@ -68,14 +66,6 @@ const MemoizedCastSection = memo(CastSection);
const MemoizedSeriesContent = memo(SeriesContent);
const MemoizedMovieContent = memo(MovieContent);
const MemoizedMoreLikeThisSection = memo(MoreLikeThisSection);
-// Enhanced responsive breakpoints for Metadata Screen
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-};
-
const MemoizedRatingsSection = memo(RatingsSection);
const MemoizedCommentsSection = memo(CommentsSection);
const MemoizedCastDetailsModal = memo(CastDetailsModal);
@@ -96,41 +86,6 @@ const MetadataScreen: React.FC = () => {
const { top: safeAreaTop } = useSafeAreaInsets();
const { pauseTrailer } = useTrailer();
- // Trakt integration
- const { isAuthenticated, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection } = useTraktContext();
-
- // Enhanced responsive sizing for tablets and TV screens
- const deviceWidth = Dimensions.get('window').width;
- const deviceHeight = Dimensions.get('window').height;
-
- // Determine device type based on width
- const getDeviceType = useCallback(() => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
- }, [deviceWidth]);
-
- const deviceType = getDeviceType();
- const isTablet = deviceType === 'tablet';
- const isLargeTablet = deviceType === 'largeTablet';
- const isTV = deviceType === 'tv';
- const isLargeScreen = isTablet || isLargeTablet || isTV;
-
- // Enhanced spacing and padding for production sections
- const horizontalPadding = useMemo(() => {
- switch (deviceType) {
- case 'tv':
- return 32;
- case 'largeTablet':
- return 28;
- case 'tablet':
- return 24;
- default:
- return 16; // phone
- }
- }, [deviceType]);
-
// Optimized state management - reduced state variables
const [isContentReady, setIsContentReady] = useState(false);
const [showCastModal, setShowCastModal] = useState(false);
@@ -183,8 +138,6 @@ const MetadataScreen: React.FC = () => {
setMetadata,
imdbId,
tmdbId,
- collectionMovies,
- loadingCollection,
} = useMetadata({ id, type, addonId });
@@ -970,24 +923,6 @@ const MetadataScreen: React.FC = () => {
getPlayButtonText={watchProgressData.getPlayButtonText}
setBannerImage={assetData.setBannerImage}
groupedEpisodes={groupedEpisodes}
- // Trakt integration props
- isAuthenticated={isAuthenticated}
- isInWatchlist={isInWatchlist(id, type as 'movie' | 'show')}
- isInCollection={isInCollection(id, type as 'movie' | 'show')}
- onToggleWatchlist={async () => {
- if (isInWatchlist(id, type as 'movie' | 'show')) {
- await removeFromWatchlist(id, type as 'movie' | 'show');
- } else {
- await addToWatchlist(id, type as 'movie' | 'show');
- }
- }}
- onToggleCollection={async () => {
- if (isInCollection(id, type as 'movie' | 'show')) {
- await removeFromCollection(id, type as 'movie' | 'show');
- } else {
- await addToCollection(id, type as 'movie' | 'show');
- }
- }}
dynamicBackgroundColor={dynamicBackgroundColor}
handleBack={handleBack}
tmdbId={tmdbId}
@@ -1008,53 +943,19 @@ const MetadataScreen: React.FC = () => {
{/* Production info row — shown below description and above cast for series */}
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length > 0 && metadata?.networks && metadata.networks.length > 0 && metadata?.description && (
-
- Network
-
+
+ Network
+
{metadata.networks.slice(0, 6).map((net) => (
-
+
{net.logo ? (
) : (
- {net.name}
+ {net.name}
)}
))}
@@ -1078,46 +979,17 @@ const MetadataScreen: React.FC = () => {
metadata?.networks && Array.isArray(metadata.networks) &&
metadata.networks.some((n: any) => !!n?.logo) &&
metadata?.description && (
-
- Production
-
+
+ Production
+
{metadata.networks
.filter((net: any) => !!net?.logo)
.slice(0, 6)
.map((net: any) => (
-
+
@@ -1147,38 +1019,29 @@ const MetadataScreen: React.FC = () => {
{/* Movie Details section - shown above recommendations for movies when TMDB enrichment is ON */}
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length === 0 && metadata?.movieDetails && (
-
- Movie Details
+
+ Movie Details
{metadata.movieDetails.tagline && (
-
- Tagline
-
+
+ Tagline
+
"{metadata.movieDetails.tagline}"
)}
{metadata.movieDetails.status && (
-
- Status
- {metadata.movieDetails.status}
+
+ Status
+ {metadata.movieDetails.status}
)}
{metadata.movieDetails.releaseDate && (
-
- Release Date
-
+
+ Release Date
+
{new Date(metadata.movieDetails.releaseDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
@@ -1189,43 +1052,43 @@ const MetadataScreen: React.FC = () => {
)}
{metadata.movieDetails.runtime && (
-
- Runtime
-
+
+ Runtime
+
{Math.floor(metadata.movieDetails.runtime / 60)}h {metadata.movieDetails.runtime % 60}m
)}
{metadata.movieDetails.budget && metadata.movieDetails.budget > 0 && (
-
- Budget
-
+
+ Budget
+
${metadata.movieDetails.budget.toLocaleString()}
)}
{metadata.movieDetails.revenue && metadata.movieDetails.revenue > 0 && (
-
- Revenue
-
+
+ Revenue
+
${metadata.movieDetails.revenue.toLocaleString()}
)}
{metadata.movieDetails.originCountry && metadata.movieDetails.originCountry.length > 0 && (
-
- Origin Country
- {metadata.movieDetails.originCountry.join(', ')}
+
+ Origin Country
+ {metadata.movieDetails.originCountry.join(', ')}
)}
{metadata.movieDetails.originalLanguage && (
-
- Original Language
- {metadata.movieDetails.originalLanguage.toUpperCase()}
+
+ Original Language
+ {metadata.movieDetails.originalLanguage.toUpperCase()}
)}
@@ -1248,18 +1111,6 @@ const MetadataScreen: React.FC = () => {
)}
- {/* Collection Section - Lazy loaded */}
- {shouldLoadSecondaryData &&
- Object.keys(groupedEpisodes).length === 0 &&
- metadata?.collection &&
- settings.enrichMetadataWithTMDB && (
-
- )}
-
{/* Recommendations Section with skeleton when loading - Lazy loaded */}
{type === 'movie' && shouldLoadSecondaryData && (
{
{/* TV Details section - shown after episodes for series when TMDB enrichment is ON */}
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length > 0 && metadata?.tvDetails && (
-
- Show Details
+
+ Show Details
{metadata.tvDetails.status && (
-
- Status
- {metadata.tvDetails.status}
+
+ Status
+ {metadata.tvDetails.status}
)}
{metadata.tvDetails.firstAirDate && (
-
- First Air Date
-
+
+ First Air Date
+
{new Date(metadata.tvDetails.firstAirDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
@@ -1318,9 +1160,9 @@ const MetadataScreen: React.FC = () => {
)}
{metadata.tvDetails.lastAirDate && (
-
- Last Air Date
-
+
+ Last Air Date
+
{new Date(metadata.tvDetails.lastAirDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
@@ -1331,46 +1173,46 @@ const MetadataScreen: React.FC = () => {
)}
{metadata.tvDetails.numberOfSeasons && (
-
- Seasons
- {metadata.tvDetails.numberOfSeasons}
+
+ Seasons
+ {metadata.tvDetails.numberOfSeasons}
)}
{metadata.tvDetails.numberOfEpisodes && (
-
- Total Episodes
- {metadata.tvDetails.numberOfEpisodes}
+
+ Total Episodes
+ {metadata.tvDetails.numberOfEpisodes}
)}
{metadata.tvDetails.episodeRunTime && metadata.tvDetails.episodeRunTime.length > 0 && (
-
- Episode Runtime
-
+
+ Episode Runtime
+
{metadata.tvDetails.episodeRunTime.join(' - ')} min
)}
{metadata.tvDetails.originCountry && metadata.tvDetails.originCountry.length > 0 && (
-
- Origin Country
- {metadata.tvDetails.originCountry.join(', ')}
+
+ Origin Country
+ {metadata.tvDetails.originCountry.join(', ')}
)}
{metadata.tvDetails.originalLanguage && (
-
- Original Language
- {metadata.tvDetails.originalLanguage.toUpperCase()}
+
+ Original Language
+ {metadata.tvDetails.originalLanguage.toUpperCase()}
)}
{metadata.tvDetails.createdBy && metadata.tvDetails.createdBy.length > 0 && (
-
- Created By
-
+
+ Created By
+
{metadata.tvDetails.createdBy.map(creator => creator.name).join(', ')}
@@ -1536,6 +1378,7 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
productionContainer: {
+ paddingHorizontal: 16,
marginTop: 0,
marginBottom: 20,
},
diff --git a/src/screens/PlayerSettingsScreen.tsx b/src/screens/PlayerSettingsScreen.tsx
index 821791f..275b6c8 100644
--- a/src/screens/PlayerSettingsScreen.tsx
+++ b/src/screens/PlayerSettingsScreen.tsx
@@ -127,12 +127,6 @@ const PlayerSettingsScreen: React.FC = () => {
description: 'Open streams in VidHub player',
icon: 'ondemand-video',
},
- {
- id: 'infuse_livecontainer',
- title: 'Infuse Livecontainer',
- description: 'Open streams in Infuse player LiveContainer',
- icon: 'smart-display',
- },
] : [
{
id: 'external',
@@ -426,4 +420,4 @@ const styles = StyleSheet.create({
},
});
-export default PlayerSettingsScreen;
+export default PlayerSettingsScreen;
\ No newline at end of file
diff --git a/src/screens/PluginsScreen.tsx b/src/screens/PluginsScreen.tsx
index cafd48a..0fc774a 100644
--- a/src/screens/PluginsScreen.tsx
+++ b/src/screens/PluginsScreen.tsx
@@ -1273,27 +1273,6 @@ const PluginsScreen: React.FC = () => {
const handleToggleLocalScrapers = async (enabled: boolean) => {
await updateSetting('enableLocalScrapers', enabled);
-
- // If enabling local scrapers, refresh repository and reload scrapers
- if (enabled) {
- try {
- setIsRefreshing(true);
- logger.log('[PluginsScreen] Enabling local scrapers - refreshing repository...');
-
- // Refresh repository to ensure scrapers are available
- await localScraperService.refreshRepository();
-
- // Reload scrapers to get the latest state
- await loadScrapers();
-
- logger.log('[PluginsScreen] Local scrapers enabled and repository refreshed');
- } catch (error) {
- logger.error('[PluginsScreen] Failed to refresh repository when enabling local scrapers:', error);
- // Don't show error to user as the toggle still succeeded
- } finally {
- setIsRefreshing(false);
- }
- }
};
const handleToggleUrlValidation = async (enabled: boolean) => {
diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx
index c680fe9..22f1555 100644
--- a/src/screens/SearchScreen.tsx
+++ b/src/screens/SearchScreen.tsx
@@ -45,33 +45,14 @@ import { useTheme } from '../contexts/ThemeContext';
import LoadingSpinner from '../components/common/LoadingSpinner';
const { width, height } = Dimensions.get('window');
-
-// Enhanced responsive breakpoints
-const BREAKPOINTS = {
- phone: 0,
- tablet: 768,
- largeTablet: 1024,
- tv: 1440,
-};
-
-const getDeviceType = (deviceWidth: number) => {
- if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
- if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
- if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
- return 'phone';
-};
-
-const deviceType = getDeviceType(width);
-const isTablet = deviceType === 'tablet';
-const isLargeTablet = deviceType === 'largeTablet';
-const isTV = deviceType === 'tv';
+const isTablet = width >= 768;
const TAB_BAR_HEIGHT = 85;
-// Responsive poster sizes
-const HORIZONTAL_ITEM_WIDTH = isTV ? width * 0.14 : isLargeTablet ? width * 0.16 : isTablet ? width * 0.18 : width * 0.3;
+// Tablet-optimized poster sizes
+const HORIZONTAL_ITEM_WIDTH = isTablet ? width * 0.18 : width * 0.3;
const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5;
-const POSTER_WIDTH = isTV ? 90 : isLargeTablet ? 80 : isTablet ? 70 : 90;
-const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
+const POSTER_WIDTH = isTablet ? 70 : 90;
+const POSTER_HEIGHT = isTablet ? 105 : 135;
const RECENT_SEARCHES_KEY = 'recent_searches';
const MAX_RECENT_SEARCHES = 10;
@@ -616,20 +597,13 @@ const SearchScreen = () => {
)}
{item.name}
{item.year && (
-
+
{item.year}
)}
@@ -678,16 +652,8 @@ const SearchScreen = () => {
{/* Movies */}
{movieResults.length > 0 && (
-
-
+
+
Movies ({movieResults.length})
{
{/* TV Shows */}
{seriesResults.length > 0 && (
-
-
+
+
TV Shows ({seriesResults.length})
{
{/* Other types */}
{otherResults.length > 0 && (
-
-
+
+
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
{
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
// Keep header below floating top navigator on tablets by adding extra offset
- const tabletNavOffset = (isTV || isLargeTablet || isTablet) ? 64 : 0;
+ const tabletNavOffset = isTablet ? 64 : 0;
const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset;
const headerHeight = headerBaseHeight + topSpacing + 60;
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
index f0771d6..20b3e9d 100644
--- a/src/screens/SettingsScreen.tsx
+++ b/src/screens/SettingsScreen.tsx
@@ -27,7 +27,6 @@ import { useCatalogContext } from '../contexts/CatalogContext';
import { useTraktContext } from '../contexts/TraktContext';
import { useTheme } from '../contexts/ThemeContext';
import { catalogService } from '../services/catalogService';
-import { fetchTotalDownloads } from '../services/githubReleaseService';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Sentry from '@sentry/react-native';
import { getDisplayedAppVersion } from '../utils/version';
@@ -292,9 +291,6 @@ const SettingsScreen: React.FC = () => {
const [mdblistKeySet, setMdblistKeySet] = useState(false);
const [openRouterKeySet, setOpenRouterKeySet] = useState(false);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
- const [totalDownloads, setTotalDownloads] = useState(null);
- const [displayDownloads, setDisplayDownloads] = useState(null);
- const [isCountingUp, setIsCountingUp] = useState(false);
// Add a useEffect to check Trakt authentication status on focus
useEffect(() => {
@@ -350,13 +346,6 @@ const SettingsScreen: React.FC = () => {
// Check OpenRouter API key status
const openRouterKey = await AsyncStorage.getItem('openrouter_api_key');
setOpenRouterKeySet(!!openRouterKey);
-
- // Load GitHub total downloads (initial load only, polling happens in useEffect)
- const downloads = await fetchTotalDownloads();
- if (downloads !== null) {
- setTotalDownloads(downloads);
- setDisplayDownloads(downloads);
- }
} catch (error) {
if (__DEV__) console.error('Error loading settings data:', error);
@@ -377,60 +366,6 @@ const SettingsScreen: React.FC = () => {
return unsubscribe;
}, [navigation, loadData]);
- // Poll GitHub downloads every 10 seconds when on the About section
- useEffect(() => {
- // Only poll when viewing the About section (where downloads counter is shown)
- const shouldPoll = isTablet ? selectedCategory === 'about' : true;
-
- if (!shouldPoll) return;
-
- const pollInterval = setInterval(async () => {
- try {
- const downloads = await fetchTotalDownloads();
- if (downloads !== null && downloads !== totalDownloads) {
- setTotalDownloads(downloads);
- }
- } catch (error) {
- if (__DEV__) console.error('Error polling downloads:', error);
- }
- }, 3600000); // 3600000 milliseconds (1 hour)
-
- return () => clearInterval(pollInterval);
- }, [selectedCategory, isTablet, totalDownloads]);
-
- // Animate counting up when totalDownloads changes
- useEffect(() => {
- if (totalDownloads === null || displayDownloads === null) return;
- if (totalDownloads === displayDownloads) return;
-
- setIsCountingUp(true);
- const start = displayDownloads;
- const end = totalDownloads;
- const duration = 2000; // 2 seconds animation
- const startTime = Date.now();
-
- const animate = () => {
- const now = Date.now();
- const elapsed = now - startTime;
- const progress = Math.min(elapsed / duration, 1);
-
- // Ease out quad for smooth deceleration
- const easeProgress = 1 - Math.pow(1 - progress, 2);
- const current = Math.floor(start + (end - start) * easeProgress);
-
- setDisplayDownloads(current);
-
- if (progress < 1) {
- requestAnimationFrame(animate);
- } else {
- setDisplayDownloads(end);
- setIsCountingUp(false);
- }
- };
-
- requestAnimationFrame(animate);
- }, [totalDownloads]);
-
const handleResetSettings = useCallback(() => {
openAlert(
'Reset Settings',
@@ -548,14 +483,6 @@ const SettingsScreen: React.FC = () => {
icon="home"
renderControl={ChevronRight}
onPress={() => navigation.navigate('HomeScreenSettings')}
- isTablet={isTablet}
- />
- navigation.navigate('ContinueWatchingSettings')}
isLast={true}
isTablet={isTablet}
/>
@@ -583,24 +510,9 @@ const SettingsScreen: React.FC = () => {
onValueChange={(value) => updateSetting('episodeLayoutStyle', value ? 'horizontal' : 'vertical')}
/>
)}
- isLast={isTablet}
+ isLast={true}
isTablet={isTablet}
/>
- {!isTablet && (
- (
- updateSetting('enableStreamsBackdrop', value)}
- />
- )}
- isLast={true}
- isTablet={isTablet}
- />
- )}
);
@@ -717,14 +629,6 @@ const SettingsScreen: React.FC = () => {
title="Version"
description={getDisplayedAppVersion()}
icon="info"
- isTablet={isTablet}
- />
- navigation.navigate('Contributors')}
isLast={true}
isTablet={isTablet}
/>
@@ -876,17 +780,6 @@ const SettingsScreen: React.FC = () => {
{selectedCategory === 'about' && (
<>
- {displayDownloads !== null && (
-
-
- {displayDownloads.toLocaleString()}
-
-
- downloads and counting
-
-
- )}
-
Made with ❤️ by Tapframe and Friends
@@ -972,17 +865,6 @@ const SettingsScreen: React.FC = () => {
{renderCategoryContent('developer')}
{renderCategoryContent('cache')}
- {displayDownloads !== null && (
-
-
- {displayDownloads.toLocaleString()}
-
-
- downloads and counting
-
-
- )}
-
Made with ❤️ by Tapframe and friends
@@ -1304,24 +1186,6 @@ const styles = StyleSheet.create({
height: 32,
width: 150,
},
- downloadsContainer: {
- marginTop: 20,
- marginBottom: 12,
- alignItems: 'center',
- },
- downloadsNumber: {
- fontSize: 32,
- fontWeight: '800',
- letterSpacing: 1,
- marginBottom: 4,
- },
- downloadsLabel: {
- fontSize: 11,
- fontWeight: '600',
- opacity: 0.6,
- letterSpacing: 1.2,
- textTransform: 'uppercase',
- },
loadingSpinner: {
width: 16,
height: 16,
diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx
index 83e92bc..a863c56 100644
--- a/src/screens/StreamsScreen.tsx
+++ b/src/screens/StreamsScreen.tsx
@@ -47,30 +47,9 @@ import QualityBadge from '../components/metadata/QualityBadge';
import { logger } from '../utils/logger';
import { isMkvStream } from '../utils/mkvDetection';
import CustomAlert from '../components/CustomAlert';
-import { useToast } from '../contexts/ToastContext';
+import { Toast } from 'toastify-react-native';
import { useDownloads } from '../contexts/DownloadsContext';
-import { streamCacheService } from '../services/streamCacheService';
-import { useDominantColor } from '../hooks/useDominantColor';
import { PaperProvider } from 'react-native-paper';
-import { BlurView as ExpoBlurView } from 'expo-blur';
-import TabletStreamsLayout from '../components/TabletStreamsLayout';
-import ProviderFilter from '../components/ProviderFilter';
-import PulsingChip from '../components/PulsingChip';
-import StreamCard from '../components/StreamCard';
-import AnimatedImage from '../components/AnimatedImage';
-import AnimatedText from '../components/AnimatedText';
-import AnimatedView from '../components/AnimatedView';
-
-// Lazy-safe community blur import for Android
-let AndroidBlurView: any = null;
-if (Platform.OS === 'android') {
- try {
- // eslint-disable-next-line @typescript-eslint/no-var-requires
- AndroidBlurView = require('@react-native-community/blur').BlurView;
- } catch (_) {
- AndroidBlurView = null;
- }
-}
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png';
@@ -104,8 +83,349 @@ const detectMkvViaHead = async (url: string, headers?: Record) =
};
// Animated Components
+const AnimatedImage = memo(({
+ source,
+ style,
+ contentFit,
+ onLoad
+}: {
+ source: { uri: string } | undefined;
+ style: any;
+ contentFit: any;
+ onLoad?: () => void;
+}) => {
+ const opacity = useSharedValue(0);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ }));
+
+ useEffect(() => {
+ if (source?.uri) {
+ opacity.value = withTiming(1, { duration: 300 });
+ } else {
+ opacity.value = 0;
+ }
+ }, [source?.uri]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ opacity.value = 0;
+ };
+ }, []);
+
+ return (
+
+
+
+ );
+});
+
+const AnimatedText = memo(({
+ children,
+ style,
+ delay = 0,
+ numberOfLines
+}: {
+ children: React.ReactNode;
+ style: any;
+ delay?: number;
+ numberOfLines?: number;
+}) => {
+ const opacity = useSharedValue(0);
+ const translateY = useSharedValue(20);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ transform: [{ translateY: translateY.value }],
+ }));
+
+ useEffect(() => {
+ opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
+ translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
+ }, [delay]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ opacity.value = 0;
+ translateY.value = 20;
+ };
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+});
+
+const AnimatedView = memo(({
+ children,
+ style,
+ delay = 0
+}: {
+ children: React.ReactNode;
+ style?: any;
+ delay?: number;
+}) => {
+ const opacity = useSharedValue(0);
+ const translateY = useSharedValue(20);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ transform: [{ translateY: translateY.value }],
+ }));
+
+ useEffect(() => {
+ opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
+ translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
+ }, [delay]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ opacity.value = 0;
+ translateY.value = 20;
+ };
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+});
// Extracted Components
+const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, theme, showLogos, scraperLogo, showAlert, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName, parentId, parentImdbId }: {
+ stream: Stream;
+ onPress: () => void;
+ index: number;
+ isLoading?: boolean;
+ statusMessage?: string;
+ theme: any;
+ showLogos?: boolean;
+ scraperLogo?: string | null;
+ showAlert: (title: string, message: string) => void;
+ parentTitle?: string;
+ parentType?: 'movie' | 'series';
+ parentSeason?: number;
+ parentEpisode?: number;
+ parentEpisodeTitle?: string;
+ parentPosterUrl?: string | null;
+ providerName?: string;
+ parentId?: string; // Content ID (e.g., tt0903747 or tmdb:1396)
+ parentImdbId?: string; // IMDb ID if available
+}) => {
+ const { useSettings } = require('../hooks/useSettings');
+ const { settings } = useSettings();
+ const { startDownload } = useDownloads();
+
+ // Handle long press to copy stream URL to clipboard
+ const handleLongPress = useCallback(async () => {
+ if (stream.url) {
+ try {
+ await Clipboard.setString(stream.url);
+
+ // Use toast for Android, custom alert for iOS
+ if (Platform.OS === 'android') {
+ Toast.success('Stream URL copied to clipboard!', 'bottom');
+ } else {
+ // iOS uses custom alert
+ showAlert('Copied!', 'Stream URL has been copied to clipboard.');
+ }
+ } catch (error) {
+ // Fallback: show URL in alert if clipboard fails
+ if (Platform.OS === 'android') {
+ Toast.info(`Stream URL: ${stream.url}`, 'bottom');
+ } else {
+ showAlert('Stream URL', stream.url);
+ }
+ }
+ }
+ }, [stream.url, showAlert]);
+ const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
+
+ const streamInfo = useMemo(() => {
+ const title = stream.title || '';
+ const name = stream.name || '';
+
+ // Helper function to format size from bytes
+ const formatSize = (bytes: number): string => {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ };
+
+ // Get size from title (legacy format) or from stream.size field
+ let sizeDisplay = title.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1];
+ if (!sizeDisplay && stream.size && typeof stream.size === 'number' && stream.size > 0) {
+ sizeDisplay = formatSize(stream.size);
+ }
+
+ // Extract quality for badge display
+ const basicQuality = title.match(/(\d+)p/)?.[1] || null;
+
+ return {
+ quality: basicQuality,
+ isHDR: title.toLowerCase().includes('hdr'),
+ isDolby: title.toLowerCase().includes('dolby') || title.includes('DV'),
+ size: sizeDisplay,
+ isDebrid: stream.behaviorHints?.cached,
+ displayName: name || 'Unnamed Stream',
+ subTitle: title && title !== name ? title : null
+ };
+ }, [stream.name, stream.title, stream.behaviorHints, stream.size]);
+
+ // Logo is provided by parent to avoid per-card async work
+
+ const handleDownload = useCallback(async () => {
+ try {
+ const url = stream.url;
+ if (!url) return;
+ // Prevent duplicate downloads for the same exact URL
+ try {
+ const downloadsModule = require('../contexts/DownloadsContext');
+ if (downloadsModule && downloadsModule.isDownloadingUrl && downloadsModule.isDownloadingUrl(url)) {
+ showAlert('Already Downloading', 'This download has already started for this exact link.');
+ return;
+ }
+ } catch {}
+ // Show immediate feedback on both platforms
+ showAlert('Starting Download', 'Download will be started.');
+ const parent: any = stream as any;
+ const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content';
+ const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie');
+ const season = typeof parentSeason === 'number' ? parentSeason : (parent.season || parent.season_number);
+ const episode = typeof parentEpisode === 'number' ? parentEpisode : (parent.episode || parent.episode_number);
+ const episodeTitle = parentEpisodeTitle || parent.episodeTitle || parent.episode_name;
+ // Prefer the stream's display name (often includes provider + resolution)
+ const provider = (stream.name as any) || (stream.title as any) || providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider';
+
+ // Use parentId first (from route params), fallback to stream metadata
+ const idForContent = parentId || parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle;
+
+ // Extract tmdbId if available (from parentId or parent metadata)
+ let tmdbId: number | undefined = undefined;
+ if (parentId && parentId.startsWith('tmdb:')) {
+ tmdbId = parseInt(parentId.split(':')[1], 10);
+ } else if (typeof parent.tmdbId === 'number') {
+ tmdbId = parent.tmdbId;
+ }
+
+ await startDownload({
+ id: String(idForContent),
+ type: inferredType,
+ title: String(inferredTitle),
+ providerName: String(provider),
+ season: inferredType === 'series' ? (season ? Number(season) : undefined) : undefined,
+ episode: inferredType === 'series' ? (episode ? Number(episode) : undefined) : undefined,
+ episodeTitle: inferredType === 'series' ? (episodeTitle ? String(episodeTitle) : undefined) : undefined,
+ quality: streamInfo.quality || undefined,
+ posterUrl: parentPosterUrl || parent.poster || parent.backdrop || null,
+ url,
+ headers: (stream.headers as any) || undefined,
+ // Pass metadata for progress tracking
+ imdbId: parentImdbId || parent.imdbId || undefined,
+ tmdbId: tmdbId,
+ });
+ showAlert('Download Started', 'Your download has been added to the queue.');
+ } catch {}
+ }, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title, parentId, parentImdbId, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName]);
+
+ const isDebrid = streamInfo.isDebrid;
+ return (
+
+ {/* Scraper Logo */}
+ {showLogos && scraperLogo && (
+
+
+
+ )}
+
+
+
+
+
+ {streamInfo.displayName}
+
+ {streamInfo.subTitle && (
+
+ {streamInfo.subTitle}
+
+ )}
+
+
+ {/* Show loading indicator if stream is loading */}
+ {isLoading && (
+
+
+
+ {statusMessage || "Loading..."}
+
+
+ )}
+
+
+
+ {streamInfo.isDolby && (
+
+ )}
+
+ {streamInfo.size && (
+
+ 💾 {streamInfo.size}
+
+ )}
+
+ {streamInfo.isDebrid && (
+
+ DEBRID
+
+ )}
+
+
+
+
+ {settings?.enableDownloads !== false && (
+
+
+
+ )}
+
+ );
+});
const QualityTag = React.memo(({ text, color, theme }: { text: string; color: string; theme: any }) => {
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
@@ -117,7 +437,72 @@ const QualityTag = React.memo(({ text, color, theme }: { text: string; color: st
);
});
+const PulsingChip = memo(({ text, delay }: { text: string; delay: number }) => {
+ const { currentTheme } = useTheme();
+ const styles = React.useMemo(() => createStyles(currentTheme.colors), [currentTheme.colors]);
+ // Make chip static to avoid continuous animation load
+ return (
+
+ {text}
+
+ );
+});
+const ProviderFilter = memo(({
+ selectedProvider,
+ providers,
+ onSelect,
+ theme
+}: {
+ selectedProvider: string;
+ providers: Array<{ id: string; name: string; }>;
+ onSelect: (id: string) => void;
+ theme: any;
+}) => {
+ const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
+
+ const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
+ onSelect(item.id)}
+ >
+
+ {item.name}
+
+
+ ), [selectedProvider, onSelect, styles]);
+
+ return (
+
+ item.id}
+ horizontal
+ showsHorizontalScrollIndicator={false}
+ style={styles.filterScroll}
+ bounces={true}
+ overScrollMode="never"
+ decelerationRate="fast"
+ initialNumToRender={5}
+ maxToRenderPerBatch={3}
+ windowSize={3}
+ removeClippedSubviews={true}
+ getItemLayout={(data, index) => ({
+ length: 100, // Approximate width of each item
+ offset: 100 * index,
+ index,
+ })}
+ />
+
+ );
+});
export const StreamsScreen = () => {
const insets = useSafeAreaInsets();
@@ -128,20 +513,6 @@ export const StreamsScreen = () => {
const { currentTheme } = useTheme();
const { colors } = currentTheme;
const { pauseTrailer, resumeTrailer } = useTrailer();
- const { showSuccess, showInfo } = useToast();
-
- // Add dimension listener and tablet detection
- const [dimensions, setDimensions] = useState(Dimensions.get('window'));
-
- useEffect(() => {
- const subscription = Dimensions.addEventListener('change', ({ window }) => {
- setDimensions(window);
- });
- return () => subscription?.remove();
- }, []);
-
- const deviceWidth = dimensions.width;
- const isTablet = deviceWidth >= 768;
// Add refs to prevent excessive updates and duplicate loads
const isMounted = useRef(true);
@@ -228,7 +599,7 @@ export const StreamsScreen = () => {
// Get backdrop from metadata assets
const setMetadataStub = useCallback(() => {}, []);
- const memoizedSettings = useMemo(() => settings, [settings.logoSourcePreference, settings.tmdbLanguagePreference, settings.enrichMetadataWithTMDB]);
+ const memoizedSettings = useMemo(() => settings, [settings.logoSourcePreference, settings.tmdbLanguagePreference]);
const { bannerImage } = useMetadataAssets(metadata, id, type, imdbId, memoizedSettings, setMetadataStub);
// Create styles using current theme colors
@@ -334,18 +705,10 @@ export const StreamsScreen = () => {
const nextLoading = { ...prevLoading };
let changed = false;
expectedProviders.forEach(providerId => {
- const providerExists = currentStreamsData[providerId];
- const hasStreams = providerExists &&
+ const hasStreams = currentStreamsData[providerId] &&
currentStreamsData[providerId].streams &&
currentStreamsData[providerId].streams.length > 0;
-
- // Stop loading if:
- // 1. Provider exists (completed) and has streams, OR
- // 2. Provider exists (completed) but has 0 streams, OR
- // 3. Overall loading is false
- const shouldStopLoading = providerExists || !(loadingStreams || loadingEpisodeStreams);
- const value = !shouldStopLoading;
-
+ const value = (loadingStreams || loadingEpisodeStreams) && !hasStreams;
if (nextLoading[providerId] !== value) {
nextLoading[providerId] = value;
changed = true;
@@ -769,28 +1132,6 @@ export const StreamsScreen = () => {
// Do NOT pre-force VLC. Let ExoPlayer try first; fallback occurs on decoder error in the player.
let forceVlc = !!options?.forceVlc;
- // Save stream to cache for future use
- try {
- const episodeId = (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined;
- const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined;
- const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined;
- const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined;
-
- await streamCacheService.saveStreamToCache(
- id,
- type,
- stream,
- metadata,
- episodeId,
- season,
- episode,
- episodeTitle,
- imdbId || undefined,
- settings.streamCacheTTL
- );
- } catch (error) {
- logger.warn('[StreamsScreen] Failed to save stream to cache:', error);
- }
// Show a quick full-screen black overlay to mask rotation flicker
// by setting a transient state that renders a covering View (implementation already supported by dark backgrounds)
@@ -948,18 +1289,6 @@ export const StreamsScreen = () => {
];
break;
- case 'infuse_livecontainer':
- const infuseUrls = [
- `infuse://x-callback-url/play?url=${streamUrl}`,
- `infuse://play?url=${streamUrl}`,
- `infuse://${streamUrl}`
- ];
- externalPlayerUrls = infuseUrls.map(infuseUrl => {
- const encoded = Buffer.from(infuseUrl).toString('base64');
- return `livecontainer://open-url?url=${encoded}`;
- });
- break;
-
default:
// If no matching player or the setting is somehow invalid, use internal player
navigateToPlayer(stream);
@@ -1117,22 +1446,14 @@ export const StreamsScreen = () => {
const installedAddons = stremioService.getInstalledAddons();
const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams;
- // Only include providers that actually have streams
- const providersWithStreams = Object.keys(streams).filter(key => {
- const providerData = streams[key];
- if (!providerData || !providerData.streams) {
- return false;
- }
-
- // Only show providers (addons or plugins) if they have actual streams
- return providerData.streams.length > 0;
- });
-
+ // Make sure we include all providers with streams, not just those in availableProviders
const allProviders = new Set([
- ...Array.from(availableProviders).filter((provider: string) =>
- streams[provider] && streams[provider].streams && streams[provider].streams.length > 0
- ),
- ...providersWithStreams
+ ...availableProviders,
+ ...Object.keys(streams).filter(key =>
+ streams[key] &&
+ streams[key].streams &&
+ streams[key].streams.length > 0
+ )
]);
// In grouped mode, separate addons and plugins
@@ -1163,7 +1484,7 @@ export const StreamsScreen = () => {
filterChips.push({ id: provider, name: installedAddon?.name || provider });
});
- // Add single grouped plugins chip if there are any plugins with streams
+ // Add single grouped plugins chip if there are any plugins
if (pluginProviders.length > 0) {
filterChips.push({ id: 'grouped-plugins', name: localScraperService.getRepositoryName() });
}
@@ -1200,7 +1521,7 @@ export const StreamsScreen = () => {
];
}, [availableProviders, type, episodeStreams, groupedStreams, settings.streamDisplayMode]);
- const sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null> = useMemo(() => {
+ const sections = useMemo(() => {
const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams;
const installedAddons = stremioService.getInstalledAddons();
@@ -1292,7 +1613,12 @@ export const StreamsScreen = () => {
const isEmptyDueToQualityFilter = totalOriginalCount > 0 && totalStreamsCount === 0;
if (isEmptyDueToQualityFilter) {
- return []; // Return empty array instead of showing placeholder
+ return [{
+ title: 'Available Streams',
+ addonId: 'grouped-all',
+ data: [{ isEmptyPlaceholder: true } as any],
+ isEmptyDueToQualityFilter: true
+ }];
}
// Combine streams: Addons first (unsorted), then sorted plugins
@@ -1418,13 +1744,13 @@ export const StreamsScreen = () => {
});
}
- // Exclude providers with no streams at all
- if (filteredStreams.length === 0) {
- return null; // Return null to exclude this section completely
- }
-
if (isEmptyDueToQualityFilter) {
- return null; // Return null to exclude this section completely
+ return {
+ title: addonName,
+ addonId,
+ data: [{ isEmptyPlaceholder: true } as any],
+ isEmptyDueToQualityFilter
+ };
}
let processedStreams = filteredStreams;
@@ -1500,7 +1826,7 @@ export const StreamsScreen = () => {
});
return result;
- }).filter(Boolean); // Filter out null values
+ });
}
}, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, addonResponseOrder, settings.streamSortMode, selectedEpisode, metadata]);
@@ -1508,11 +1834,11 @@ export const StreamsScreen = () => {
React.useEffect(() => {
console.log('🔍 [StreamsScreen] Final sections:', {
sectionsCount: sections.length,
- sections: sections.filter(Boolean).map(s => ({
- title: s!.title,
- addonId: s!.addonId,
- dataCount: s!.data?.length || 0,
- isEmptyDueToQualityFilter: s!.isEmptyDueToQualityFilter
+ sections: sections.map(s => ({
+ title: s.title,
+ addonId: s.addonId,
+ dataCount: s.data?.length || 0,
+ isEmptyDueToQualityFilter: s.isEmptyDueToQualityFilter
}))
});
}, [sections]);
@@ -1533,9 +1859,8 @@ export const StreamsScreen = () => {
const path = currentEpisode.still_path || hydratedStill || '';
return tmdbService.getImageUrl(path, 'original');
}
- // No poster fallback
- return null;
- }, [currentEpisode, metadata, episodeThumbnail, tmdbEpisodeOverride?.still_path, settings.enrichMetadataWithTMDB]);
+ return metadata?.poster || null;
+ }, [currentEpisode, metadata, episodeThumbnail, tmdbEpisodeOverride?.still_path]);
// Effective TMDB fields for hero (series)
const effectiveEpisodeVote = useMemo(() => {
@@ -1550,48 +1875,6 @@ export const StreamsScreen = () => {
return r;
}, [currentEpisode, tmdbEpisodeOverride?.runtime]);
- // Mobile backdrop source selection logic
- const mobileBackdropSource = useMemo(() => {
- // For series episodes: prioritize episodeImage, fallback to bannerImage
- if (type === 'series' || (type === 'other' && selectedEpisode)) {
- if (episodeImage) {
- return episodeImage;
- }
- if (bannerImage) {
- return bannerImage;
- }
- }
-
- // For movies: prioritize bannerImage
- if (type === 'movie') {
- if (bannerImage) {
- return bannerImage;
- }
- }
-
- // For other types or when no specific image available
- return bannerImage || episodeImage;
- }, [type, selectedEpisode, episodeImage, bannerImage]);
-
- // Backdrop source for color extraction - only episodes, not movies
- const colorExtractionSource = useMemo(() => {
- // Only extract colors if backdrop is enabled
- if (!settings.enableStreamsBackdrop) {
- return null;
- }
-
- if (type === 'series' || (type === 'other' && selectedEpisode)) {
- // Only use episodeImage - don't fallback to bannerImage
- // This ensures we get episode-specific colors, not show-wide colors
- return episodeImage || null;
- }
- // Return null for movies - no color extraction
- return null;
- }, [type, selectedEpisode, episodeImage, settings.enableStreamsBackdrop]);
-
- // Extract dominant color from backdrop for gradient
- const { dominantColor } = useDominantColor(colorExtractionSource);
-
// Prefetch hero/backdrop and title logo when StreamsScreen opens
useEffect(() => {
const urls: string[] = [];
@@ -1606,39 +1889,11 @@ export const StreamsScreen = () => {
});
}, [episodeImage, bannerImage, metadata]);
- // Helper to create gradient colors from dominant color
- const createGradientColors = useCallback((baseColor: string | null): [string, string, string, string, string] => {
- if (!baseColor || baseColor === '#1a1a1a') {
- // Fallback to black gradient with stronger bottom edge
- return ['rgba(0,0,0,0)', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.6)', 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.95)'];
- }
-
- // Convert hex to RGB
- const r = parseInt(baseColor.substr(1, 2), 16);
- const g = parseInt(baseColor.substr(3, 2), 16);
- const b = parseInt(baseColor.substr(5, 2), 16);
-
- // Create gradient stops with much stronger opacity at bottom
- return [
- `rgba(${r},${g},${b},0)`,
- `rgba(${r},${g},${b},0.3)`,
- `rgba(${r},${g},${b},0.6)`,
- `rgba(${r},${g},${b},0.85)`,
- `rgba(${r},${g},${b},0.95)`,
- ];
- }, []);
-
- const gradientColors = useMemo(() =>
- createGradientColors(dominantColor),
- [dominantColor, createGradientColors]
- );
-
const isLoading = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? loadingEpisodeStreams : loadingStreams;
const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams;
// Determine extended loading phases
- const streamsEmpty = Object.keys(streams).length === 0 ||
- Object.values(streams).every(provider => !provider.streams || provider.streams.length === 0);
+ const streamsEmpty = Object.keys(streams).length === 0;
const loadElapsed = streamsLoadStart ? Date.now() - streamsLoadStart : 0;
const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000);
const showStillFetching = streamsEmpty && loadElapsed >= 10000;
@@ -1708,7 +1963,7 @@ export const StreamsScreen = () => {
{Platform.OS !== 'ios' && (
{
)}
- {isTablet ? (
-
- ) : (
- // PHONE LAYOUT (existing structure)
- <>
- {/* Full Screen Background for Mobile */}
- {settings.enableStreamsBackdrop ? (
-
- {mobileBackdropSource ? (
-
- ) : (
-
- )}
- {Platform.OS === 'android' && AndroidBlurView ? (
-
- ) : (
-
- )}
- {/* Dark overlay to reduce brightness */}
- {Platform.OS === 'ios' && (
-
- )}
-
- ) : (
-
- )}
-
- {type === 'movie' && metadata && (
-
-
- {metadata.logo && !movieLogoError ? (
- setMovieLogoError(true)}
- />
- ) : (
-
- {metadata.name}
-
- )}
-
-
- )}
-
- {metadata?.videos && metadata.videos.length > 1 && selectedEpisode && (
-
-
-
-
-
-
- {currentEpisode ? (
-
-
- {currentEpisode.episodeString}
-
-
- {currentEpisode.name}
-
- {!!currentEpisode.overview && (
-
-
- {currentEpisode.overview}
-
-
- )}
-
-
- {tmdbService.formatAirDate(currentEpisode.air_date)}
-
- {effectiveEpisodeVote > 0 && (
-
-
-
- {effectiveEpisodeVote.toFixed(1)}
-
-
- )}
- {!!effectiveEpisodeRuntime && (
-
-
-
- {effectiveEpisodeRuntime >= 60
- ? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m`
- : `${effectiveEpisodeRuntime}m`}
-
-
- )}
-
-
- ) : (
- // Placeholder to reserve space and avoid layout shift while loading
-
- )}
-
-
-
-
-
- )}
-
- {/* Gradient overlay to blend hero section with streams container */}
- {metadata?.videos && metadata.videos.length > 1 && selectedEpisode && settings.enableStreamsBackdrop && (
-
-
+
+ {metadata.logo && !movieLogoError ? (
+ setMovieLogoError(true)}
/>
-
- )}
-
-
-
- {!streamsEmpty && (
-
- )}
-
-
- {/* Active Scrapers Status */}
- {activeFetchingScrapers.length > 0 && (
-
- Fetching from:
-
- {activeFetchingScrapers.map((scraperName, index) => (
-
- ))}
-
-
- )}
-
- {/* Update the streams/loading state display logic */}
- { showNoSourcesError ? (
-
-
- No streaming sources available
-
- Please add streaming sources in settings
-
- navigation.navigate('Addons')}
- >
- Add Sources
-
-
- ) : streamsEmpty ? (
- showInitialLoading ? (
-
-
-
- {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'}
-
-
- ) : showStillFetching ? (
-
-
- Still fetching streams…
-
- ) : (
- // No streams and not loading = no streams available
-
-
- No streams available
-
- )
) : (
- // Show streams immediately when available, even if still loading others
-
- {/* Show autoplay loading overlay if waiting for autoplay */}
- {isAutoplayWaiting && !autoplayTriggered && (
-
-
-
- Starting best stream...
-
-
- )}
-
-
- {sections.filter(Boolean).map((section, sectionIndex) => (
-
- {/* Section Header */}
- {renderSectionHeader({ section: section! })}
-
- {/* Stream Cards using FlatList */}
- {section!.data && section!.data.length > 0 ? (
- {
- if (item && item.url) {
- return `${item.url}-${sectionIndex}-${index}`;
- }
- return `empty-${sectionIndex}-${index}`;
- }}
- renderItem={({ item, index }) => (
-
- handleStreamPress(item)}
- index={index}
- isLoading={false}
- statusMessage={undefined}
- theme={currentTheme}
- showLogos={settings.showScraperLogos}
- scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogoCache.get((item.addonId || (item as any).addon) as string) || null : null}
- showAlert={(t, m) => openAlert(t, m)}
- parentTitle={metadata?.name}
- parentType={type as 'movie' | 'series'}
- parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined}
- parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined}
- parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined}
- parentPosterUrl={episodeImage || metadata?.poster || undefined}
- providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))}
- parentId={id}
- parentImdbId={imdbId || undefined}
- />
-
- )}
- scrollEnabled={false}
- initialNumToRender={6}
- maxToRenderPerBatch={2}
- windowSize={3}
- removeClippedSubviews={true}
- showsVerticalScrollIndicator={false}
- getItemLayout={(data, index) => ({
- length: 78, // Approximate height of StreamCard (68 minHeight + 10 marginBottom)
- offset: 78 * index,
- index,
- })}
- />
- ) : null}
-
- ))}
-
- {/* Footer Loading */}
- {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && (
-
-
- Loading more sources...
-
- )}
-
-
+
+ {metadata.name}
+
)}
- >
+
)}
+
+ {metadata?.videos && metadata.videos.length > 1 && selectedEpisode && (
+
+
+
+
+
+
+ {currentEpisode ? (
+
+
+ {currentEpisode.episodeString}
+
+
+ {currentEpisode.name}
+
+ {!!currentEpisode.overview && (
+
+
+ {currentEpisode.overview}
+
+
+ )}
+
+
+ {tmdbService.formatAirDate(currentEpisode.air_date)}
+
+ {effectiveEpisodeVote > 0 && (
+
+
+
+ {effectiveEpisodeVote.toFixed(1)}
+
+
+ )}
+ {!!effectiveEpisodeRuntime && (
+
+
+
+ {effectiveEpisodeRuntime >= 60
+ ? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m`
+ : `${effectiveEpisodeRuntime}m`}
+
+
+ )}
+
+
+ ) : (
+ // Placeholder to reserve space and avoid layout shift while loading
+
+ )}
+
+
+
+
+
+ )}
+
+
+
+ {Object.keys(streams).length > 0 && (
+
+ )}
+
+
+ {/* Active Scrapers Status */}
+ {activeFetchingScrapers.length > 0 && (
+
+ Fetching from:
+
+ {activeFetchingScrapers.map((scraperName, index) => (
+
+ ))}
+
+
+ )}
+
+ {/* Update the streams/loading state display logic */}
+ { showNoSourcesError ? (
+
+
+ No streaming sources available
+
+ Please add streaming sources in settings
+
+ navigation.navigate('Addons')}
+ >
+ Add Sources
+
+
+ ) : streamsEmpty ? (
+ showInitialLoading ? (
+
+
+
+ {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'}
+
+
+ ) : showStillFetching ? (
+
+
+ Still fetching streams…
+
+ ) : (
+ // No streams and not loading = no streams available
+
+
+ No streams available
+
+ )
+ ) : (
+ // Show streams immediately when available, even if still loading others
+
+ {/* Show autoplay loading overlay if waiting for autoplay */}
+ {isAutoplayWaiting && !autoplayTriggered && (
+
+
+
+ Starting best stream...
+
+
+ )}
+
+
+ {sections.map((section, sectionIndex) => (
+
+ {/* Section Header */}
+ {renderSectionHeader({ section })}
+
+ {/* Stream Cards using FlatList */}
+ {section.data && section.data.length > 0 ? (
+ {
+ if (item && item.url) {
+ return `${item.url}-${sectionIndex}-${index}`;
+ }
+ return `empty-${sectionIndex}-${index}`;
+ }}
+ renderItem={({ item, index }) => (
+
+ handleStreamPress(item)}
+ index={index}
+ isLoading={false}
+ statusMessage={undefined}
+ theme={currentTheme}
+ showLogos={settings.showScraperLogos}
+ scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogoCache.get((item.addonId || (item as any).addon) as string) || null : null}
+ showAlert={(t, m) => openAlert(t, m)}
+ parentTitle={metadata?.name}
+ parentType={type as 'movie' | 'series'}
+ parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined}
+ parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined}
+ parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined}
+ parentPosterUrl={episodeImage || metadata?.poster || undefined}
+ providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))}
+ parentId={id}
+ parentImdbId={imdbId || undefined}
+ />
+
+ )}
+ scrollEnabled={false}
+ initialNumToRender={6}
+ maxToRenderPerBatch={2}
+ windowSize={3}
+ removeClippedSubviews={true}
+ showsVerticalScrollIndicator={false}
+ getItemLayout={(data, index) => ({
+ length: 78, // Approximate height of StreamCard (68 minHeight + 10 marginBottom)
+ offset: 78 * index,
+ index,
+ })}
+ />
+ ) : (
+ // Empty section placeholder
+
+
+
+
+ No streams available
+
+
+ All streams were filtered by your quality settings
+
+
+
+ )}
+
+ ))}
+
+ {/* Footer Loading */}
+ {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && (
+
+
+ Loading more sources...
+
+ )}
+
+
+ )}
+
{
const createStyles = (colors: any) => StyleSheet.create({
container: {
flex: 1,
- backgroundColor: 'transparent',
+ backgroundColor: colors.darkBackground,
// iOS-specific fixes for navigation transition glitches
...(Platform.OS === 'ios' && {
// Ensure the background is properly rendered during transitions
@@ -2141,7 +2301,7 @@ const createStyles = (colors: any) => StyleSheet.create({
},
streamsMainContent: {
flex: 1,
- backgroundColor: 'transparent',
+ backgroundColor: colors.darkBackground,
paddingTop: 12,
zIndex: 1,
// iOS-specific fixes for navigation transition glitches
@@ -2362,13 +2522,13 @@ const createStyles = (colors: any) => StyleSheet.create({
height: 220, // Fixed height to prevent layout shift
marginBottom: 0,
position: 'relative',
- backgroundColor: 'transparent',
+ backgroundColor: colors.black,
pointerEvents: 'box-none',
},
streamsHeroBackground: {
width: '100%',
height: '100%',
- backgroundColor: 'transparent',
+ backgroundColor: colors.black,
},
streamsHeroGradient: {
...StyleSheet.absoluteFillObject,
@@ -2486,7 +2646,7 @@ const createStyles = (colors: any) => StyleSheet.create({
movieTitleContainer: {
width: '100%',
height: 140,
- backgroundColor: 'transparent',
+ backgroundColor: colors.darkBackground,
pointerEvents: 'box-none',
justifyContent: 'center',
paddingTop: Platform.OS === 'android' ? 65 : 35,
@@ -2610,108 +2770,28 @@ const createStyles = (colors: any) => StyleSheet.create({
fontSize: 11,
fontWeight: '400',
},
- // Tablet-specific styles
- tabletLayout: {
- flex: 1,
- flexDirection: 'row',
- position: 'relative',
- },
- tabletFullScreenBackground: {
- ...StyleSheet.absoluteFillObject,
- },
- tabletFullScreenGradient: {
- ...StyleSheet.absoluteFillObject,
- },
- tabletLeftPanel: {
- width: '40%',
- justifyContent: 'center',
- alignItems: 'center',
- padding: 24,
- zIndex: 2,
- },
- tabletMovieLogoContainer: {
- width: '80%',
- alignItems: 'center',
- justifyContent: 'center',
- },
- tabletMovieLogo: {
- width: '100%',
- height: 120,
- marginBottom: 16,
- },
- tabletMovieTitle: {
- color: colors.highEmphasis,
- fontSize: 32,
- fontWeight: '900',
- textAlign: 'center',
- letterSpacing: -0.5,
- textShadowColor: 'rgba(0,0,0,0.8)',
- textShadowOffset: { width: 0, height: 2 },
- textShadowRadius: 4,
- },
- tabletEpisodeInfo: {
- width: '80%',
- },
- tabletEpisodeText: {
- textShadowColor: 'rgba(0,0,0,1)',
- textShadowOffset: { width: 0, height: 0 },
- textShadowRadius: 4,
- },
- tabletEpisodeNumber: {
- fontSize: 18,
- fontWeight: 'bold',
- marginBottom: 8,
- },
- tabletEpisodeTitle: {
- fontSize: 28,
- fontWeight: 'bold',
- marginBottom: 12,
- lineHeight: 34,
- },
- tabletEpisodeOverview: {
- fontSize: 16,
- lineHeight: 24,
- opacity: 0.95,
- },
- tabletRightPanel: {
- width: '60%',
- flex: 1,
- paddingTop: Platform.OS === 'android' ? 60 : 20,
- zIndex: 2,
- },
- tabletStreamsContent: {
- backgroundColor: 'rgba(0,0,0,0.2)',
- borderRadius: 24,
- margin: 12,
- overflow: 'hidden', // Ensures content respects rounded corners
- },
- tabletBlurContent: {
- flex: 1,
+ emptySectionContainer: {
padding: 16,
- backgroundColor: 'transparent',
+ alignItems: 'center',
+ justifyContent: 'center',
+ minHeight: 80,
},
- backButtonContainerTablet: {
- zIndex: 3,
+ emptySectionContent: {
+ alignItems: 'center',
+ justifyContent: 'center',
},
- mobileFullScreenBackground: {
- ...StyleSheet.absoluteFillObject,
- width: '100%',
- height: '100%',
+ emptySectionTitle: {
+ fontSize: 14,
+ fontWeight: '600',
+ marginTop: 8,
+ textAlign: 'center',
},
- mobileNoBackdropBackground: {
- ...StyleSheet.absoluteFillObject,
- backgroundColor: colors.darkBackground,
- },
- heroBlendOverlay: {
- position: 'absolute',
- top: 220, // Height of hero container
- left: 0,
- right: 0,
- height: 60, // Extend gradient 60px into streams area
- zIndex: 0,
- pointerEvents: 'none',
+ emptySectionSubtitle: {
+ fontSize: 12,
+ marginTop: 4,
+ textAlign: 'center',
+ lineHeight: 16,
},
});
export default memo(StreamsScreen);
-
diff --git a/src/screens/UpdateScreen.tsx b/src/screens/UpdateScreen.tsx
index e795e03..9d9ea48 100644
--- a/src/screens/UpdateScreen.tsx
+++ b/src/screens/UpdateScreen.tsx
@@ -11,7 +11,7 @@ import {
Dimensions,
Linking
} from 'react-native';
-import { useToast } from '../contexts/ToastContext';
+import { Toast } from 'toastify-react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
@@ -70,7 +70,6 @@ const UpdateScreen: React.FC = () => {
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const github = useGithubMajorUpdate();
- const { showInfo } = useToast();
// CustomAlert state
const [alertVisible, setAlertVisible] = useState(false);
@@ -153,7 +152,7 @@ const UpdateScreen: React.FC = () => {
// Also refresh GitHub section on mount (works in dev and prod)
try { github.refresh(); } catch {}
if (Platform.OS === 'android') {
- showInfo('Checking for Updates', 'Checking for updates…');
+ try { Toast.info('Checking for updates…'); } catch {}
}
}, []);
diff --git a/src/services/AccountService.ts b/src/services/AccountService.ts
index 084af94..029cea1 100644
--- a/src/services/AccountService.ts
+++ b/src/services/AccountService.ts
@@ -1,4 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
+import supabase from './supabaseClient';
export type AuthUser = {
id: string;
@@ -7,7 +8,6 @@ export type AuthUser = {
displayName?: string;
};
-const USER_DATA_KEY = '@user:data';
const USER_SCOPE_KEY = '@user:current';
class AccountService {
@@ -20,41 +20,53 @@ class AccountService {
}
async signUpWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> {
- // Since signup is disabled, always return error
- return { error: 'Sign up is currently disabled due to upcoming system changes' };
+ const { data, error } = await supabase.auth.signUp({ email, password });
+ if (error) return { error: error.message };
+ const user = data.user ? { id: data.user.id, email: data.user.email ?? undefined } : undefined;
+ if (user) await AsyncStorage.setItem(USER_SCOPE_KEY, user.id);
+ // Initialize profile row
+ if (user) {
+ await supabase.from('user_profiles').upsert({ user_id: user.id }, { onConflict: 'user_id' });
+ }
+ return { user };
}
async signInWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> {
- // Since signin is disabled, always return error
- return { error: 'Authentication is currently disabled' };
+ const { data, error } = await supabase.auth.signInWithPassword({ email, password });
+ if (error) return { error: error.message };
+ const user = data.user ? { id: data.user.id, email: data.user.email ?? undefined } : undefined;
+ if (user) await AsyncStorage.setItem(USER_SCOPE_KEY, user.id);
+ return { user };
}
async signOut(): Promise {
- await AsyncStorage.removeItem(USER_DATA_KEY);
+ await supabase.auth.signOut();
await AsyncStorage.setItem(USER_SCOPE_KEY, 'local');
}
async getCurrentUser(): Promise {
- try {
- const userData = await AsyncStorage.getItem(USER_DATA_KEY);
- if (!userData) return null;
- return JSON.parse(userData);
- } catch {
- return null;
- }
+ const { data } = await supabase.auth.getUser();
+ const u = data.user;
+ if (!u) return null;
+ // Fetch profile for avatar and display name
+ const { data: profile } = await supabase
+ .from('user_profiles')
+ .select('avatar_url, display_name')
+ .eq('user_id', u.id)
+ .maybeSingle();
+ return { id: u.id, email: u.email ?? undefined, avatarUrl: profile?.avatar_url ?? undefined, displayName: profile?.display_name ?? undefined };
}
async updateProfile(partial: { avatarUrl?: string; displayName?: string }): Promise {
- try {
- const currentUser = await this.getCurrentUser();
- if (!currentUser) return 'Not authenticated';
-
- const updatedUser = { ...currentUser, ...partial };
- await AsyncStorage.setItem(USER_DATA_KEY, JSON.stringify(updatedUser));
- return null;
- } catch {
- return 'Failed to update profile';
- }
+ const { data } = await supabase.auth.getUser();
+ const userId = data.user?.id;
+ if (!userId) return 'Not authenticated';
+ const { error } = await supabase.from('user_profiles').upsert({
+ user_id: userId,
+ avatar_url: partial.avatarUrl,
+ display_name: partial.displayName,
+ }, { onConflict: 'user_id' });
+ return error?.message ?? null;
}
async getCurrentUserIdScoped(): Promise {
diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts
new file mode 100644
index 0000000..3c4783a
--- /dev/null
+++ b/src/services/SyncService.ts
@@ -0,0 +1,1146 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import supabase from './supabaseClient';
+import accountService from './AccountService';
+import { storageService } from './storageService';
+import { addonEmitter, ADDON_EVENTS, stremioService } from './stremioService';
+import { catalogService, StreamingContent } from './catalogService';
+// import localScraperService from './localScraperService';
+import { settingsEmitter } from '../hooks/useSettings';
+import { logger } from '../utils/logger';
+import { traktService } from './traktService';
+
+type WatchProgressRow = {
+ user_id: string;
+ media_type: string;
+ media_id: string;
+ episode_id: string;
+ current_time_seconds: number;
+ duration_seconds: number;
+ last_updated_ms: number;
+ trakt_synced?: boolean;
+ trakt_last_synced_ms?: number | null;
+ trakt_progress_percent?: number | null;
+};
+
+const SYNC_QUEUE_KEY = '@sync_queue';
+
+class SyncService {
+ private static instance: SyncService;
+ private syncing = false;
+ private suppressPush = false;
+ private realtimeChannels: any[] = [];
+ private pullDebounceTimer: NodeJS.Timeout | null = null;
+ private addonsPollInterval: NodeJS.Timeout | null = null;
+ private suppressLibraryPush: boolean = false;
+ private libraryUnsubscribe: (() => void) | null = null;
+
+ static getInstance(): SyncService {
+ if (!SyncService.instance) SyncService.instance = new SyncService();
+ return SyncService.instance;
+ }
+
+ init(): void {
+ // Watch progress updates
+ storageService.subscribeToWatchProgressUpdates(() => {
+ if (this.suppressPush) return;
+ logger.log('[Sync] watch_progress local change → push');
+ this.pushWatchProgress().catch(() => undefined);
+ });
+ storageService.onWatchProgressRemoved((id, type, episodeId) => {
+ if (this.suppressPush) return;
+ logger.log(`[Sync] watch_progress removed → soft delete ${type}:${id}:${episodeId || ''}`);
+ this.softDeleteWatchProgress(type, id, episodeId).catch(() => undefined);
+ });
+
+ // Addon order and changes
+ addonEmitter.on(ADDON_EVENTS.ORDER_CHANGED, () => { logger.log('[Sync] addon order changed → push'); this.pushAddons(); });
+ addonEmitter.on(ADDON_EVENTS.ADDON_ADDED, () => { logger.log('[Sync] addon added → push'); this.pushAddons(); });
+ addonEmitter.on(ADDON_EVENTS.ADDON_REMOVED, () => { logger.log('[Sync] addon removed → push'); this.pushAddons(); });
+
+ // Settings updates: no realtime push; sync only on app restart
+ logger.log('[Sync] init completed (listeners wired; settings push disabled)');
+
+ // Library local change → push
+ if (this.libraryUnsubscribe) {
+ try { this.libraryUnsubscribe(); } catch {}
+ this.libraryUnsubscribe = null;
+ }
+ const unsubAdd = catalogService.onLibraryAdd((item) => {
+ if (this.suppressLibraryPush) return;
+ logger.log(`[Sync] library add → push ${item.type}:${item.id}`);
+ this.pushLibraryAdd(item).catch(() => undefined);
+ });
+ const unsubRem = catalogService.onLibraryRemove((type, id) => {
+ if (this.suppressLibraryPush) return;
+ logger.log(`[Sync] library remove → push ${type}:${id}`);
+ this.pushLibraryRemove(type, id).catch(() => undefined);
+ });
+ this.libraryUnsubscribe = () => { try { unsubAdd(); unsubRem(); } catch {} };
+ }
+
+ subscribeRealtime = async (): Promise => {
+ const user = await accountService.getCurrentUser();
+ if (!user) return;
+ const userId = user.id;
+ const traktActive = await traktService.isAuthenticated();
+
+ const addChannel = (table: string, handler: (payload: any) => void) => {
+ const channel = supabase
+ .channel(`rt-${table}`)
+ .on('postgres_changes', { event: '*', schema: 'public', table, filter: `user_id=eq.${userId}` }, handler)
+ .subscribe();
+ this.realtimeChannels.push(channel);
+ logger.log(`[Sync] Realtime subscribed: ${table}`);
+ };
+
+ // Watch progress realtime is disabled when Trakt is active
+ if (!traktActive) {
+ // Watch progress: apply granular updates (ignore self-caused pushes via suppressPush)
+ addChannel('watch_progress', async (payload) => {
+ try {
+ const row = (payload.new || payload.old);
+ if (!row) return;
+ const type = row.media_type as string;
+ const id = row.media_id as string;
+ const episodeId = (payload.eventType === 'DELETE') ? (row.episode_id || '') : (row.episode_id || '');
+ this.suppressPush = true;
+ const deletedAt = (row as any).deleted_at;
+ if (payload.eventType === 'DELETE' || deletedAt) {
+ await storageService.removeWatchProgress(id, type, episodeId || undefined);
+ // Record tombstone with remote timestamp if available
+ try {
+ const remoteUpdated = (row as any).updated_at ? new Date((row as any).updated_at).getTime() : Date.now();
+ await storageService.addWatchProgressTombstone(id, type, episodeId || undefined, remoteUpdated);
+ } catch {}
+ } else {
+ // Preserve the most recent timestamp between local and remote to maintain proper continue watching order
+ const remoteTimestamp = row.last_updated_ms || Date.now();
+ const existingProgress = await storageService.getWatchProgress(id, type, (row.episode_id && row.episode_id.length > 0) ? row.episode_id : undefined);
+ const localTimestamp = existingProgress?.lastUpdated || 0;
+
+ // Use the newer timestamp to maintain proper continue watching order across devices
+ const finalTimestamp = Math.max(remoteTimestamp, localTimestamp);
+
+ await storageService.setWatchProgress(
+ id,
+ type,
+ {
+ currentTime: row.current_time_seconds || 0,
+ duration: row.duration_seconds || 0,
+ lastUpdated: finalTimestamp,
+ traktSynced: row.trakt_synced ?? undefined,
+ traktLastSynced: row.trakt_last_synced_ms ?? undefined,
+ traktProgress: row.trakt_progress_percent ?? undefined,
+ },
+ // Ensure we pass through the full remote episode_id as-is; empty string becomes undefined
+ (row.episode_id && row.episode_id.length > 0) ? row.episode_id : undefined,
+ { preserveTimestamp: true, forceNotify: true, forceWrite: true }
+ );
+ }
+ } catch {}
+ finally {
+ this.suppressPush = false;
+ }
+ });
+ } else {
+ logger.log('[Sync] Trakt active → skipping watch_progress realtime subscription');
+ }
+
+ const debouncedPull = (payload?: any) => {
+ if (payload?.table) logger.log(`[Sync][rt] change on ${payload.table} → debounced fullPull`);
+ if (this.pullDebounceTimer) clearTimeout(this.pullDebounceTimer);
+ this.pullDebounceTimer = setTimeout(() => {
+ logger.log('[Sync] fullPull (debounced) start');
+ this.fullPull()
+ .then(() => logger.log('[Sync] fullPull (debounced) done'))
+ .catch((e) => { if (__DEV__) console.warn('[Sync] fullPull (debounced) error', e); });
+ }, 300);
+ };
+
+ // Addons: just re-pull snapshot quickly
+ addChannel('installed_addons', () => debouncedPull({ table: 'installed_addons' }));
+ // Library realtime: apply row-level changes
+ addChannel('user_library', async (payload) => {
+ try {
+ const row = (payload.new || payload.old);
+ if (!row) return;
+ const mediaType = (row.media_type as string) === 'movie' ? 'movie' : 'series';
+ const mediaId = row.media_id as string;
+ this.suppressLibraryPush = true;
+ const deletedAt = (row as any).deleted_at;
+ if (payload.eventType === 'DELETE' || deletedAt) {
+ await catalogService.removeFromLibrary(mediaType, mediaId);
+ logger.log(`[Sync][rt] user_library DELETE ${mediaType}:${mediaId}`);
+ } else {
+ const content: StreamingContent = {
+ id: mediaId,
+ type: mediaType,
+ name: (row.title as string) || mediaId,
+ poster: (row.poster_url as string) || '',
+ inLibrary: true,
+ year: row.year ?? undefined,
+ } as any;
+ await catalogService.addToLibrary(content);
+ logger.log(`[Sync][rt] user_library ${payload.eventType} ${mediaType}:${mediaId}`);
+ }
+ } catch (e) {
+ if (__DEV__) console.warn('[Sync][rt] user_library handler error', e);
+ } finally {
+ this.suppressLibraryPush = false;
+ }
+ });
+ // Excluded: local_scrapers, scraper_repository from realtime sync
+ logger.log('[Sync] Realtime subscriptions active');
+
+ // Fallback polling for addons (in case realtime isn't enabled)
+ if (this.addonsPollInterval) clearInterval(this.addonsPollInterval);
+ this.addonsPollInterval = setInterval(async () => {
+ try {
+ const u = await accountService.getCurrentUser();
+ if (!u) return;
+ // Compare excluding preinstalled addons
+ const exclude = new Set(['com.linvo.cinemeta', 'org.stremio.opensubtitlesv3']);
+ const localIds = new Set(
+ (await stremioService.getInstalledAddonsAsync())
+ .map((a: any) => a.id)
+ .filter((id: string) => !exclude.has(id))
+ );
+ const { data: remote } = await supabase
+ .from('installed_addons')
+ .select('addon_id')
+ .eq('user_id', u.id);
+ const remoteIds = new Set(
+ ((remote || []) as any[])
+ .map(r => r.addon_id as string)
+ .filter((id: string) => !exclude.has(id))
+ );
+ if (localIds.size !== remoteIds.size) {
+ logger.log('[Sync][poll] addons mismatch by count → pull snapshot');
+ await this.pullAddonsSnapshot(u.id);
+ return;
+ }
+ for (const id of remoteIds) {
+ if (!localIds.has(id)) {
+ logger.log('[Sync][poll] addons mismatch by set → pull snapshot');
+ await this.pullAddonsSnapshot(u.id);
+ break;
+ }
+ }
+ } catch (e) {
+ // silent
+ }
+ }, 21600000); // Increased from 4 hours to 6 hours to reduce background CPU
+ };
+
+ unsubscribeRealtime = (): void => {
+ try {
+ logger.log(`[Sync] Realtime unsubscribe (${this.realtimeChannels.length})`);
+ for (const ch of this.realtimeChannels) {
+ try { ch.unsubscribe?.(); } catch {}
+ }
+ } finally {
+ this.realtimeChannels = [];
+ if (this.addonsPollInterval) {
+ clearInterval(this.addonsPollInterval);
+ this.addonsPollInterval = null;
+ }
+ if (this.libraryUnsubscribe) {
+ try { this.libraryUnsubscribe(); } catch {}
+ this.libraryUnsubscribe = null;
+ }
+ }
+ };
+
+ async migrateLocalScopeToUser(): Promise {
+ const user = await accountService.getCurrentUser();
+ if (!user) return;
+ const userId = user.id;
+ const keys = await AsyncStorage.getAllKeys();
+ const migrations: Array> = [];
+ const moveKey = async (from: string, to: string) => {
+ const val = await AsyncStorage.getItem(from);
+ if (val == null) return;
+ const exists = await AsyncStorage.getItem(to);
+ if (!exists) {
+ await AsyncStorage.setItem(to, val);
+ } else {
+ // Prefer the one with newer lastUpdated if JSON
+ try {
+ const a = JSON.parse(val);
+ const b = JSON.parse(exists);
+ const aLU = a?.lastUpdated ?? 0;
+ const bLU = b?.lastUpdated ?? 0;
+ if (aLU > bLU) await AsyncStorage.setItem(to, val);
+ } catch {
+ // Keep existing if equal
+ }
+ }
+ await AsyncStorage.removeItem(from);
+ };
+
+ // Watch progress/content durations/subtitles/app settings
+ for (const k of keys) {
+ if (k.startsWith('@user:local:@watch_progress:')) {
+ const suffix = k.replace('@user:local:@watch_progress:', '');
+ migrations.push(moveKey(k, `@user:${userId}:@watch_progress:${suffix}`));
+ } else if (k.startsWith('@user:local:@content_duration:')) {
+ const suffix = k.replace('@user:local:@content_duration:', '');
+ migrations.push(moveKey(k, `@user:${userId}:@content_duration:${suffix}`));
+ } else if (k === '@user:local:@subtitle_settings') {
+ migrations.push(moveKey(k, `@user:${userId}:@subtitle_settings`));
+ } else if (k === 'app_settings') {
+ migrations.push(moveKey('app_settings', `@user:${userId}:app_settings`));
+ } else if (k === '@user:local:app_settings') {
+ migrations.push(moveKey(k, `@user:${userId}:app_settings`));
+ } else if (k === '@user:local:stremio-addons' || k === 'stremio-addons') {
+ migrations.push(moveKey(k, `@user:${userId}:stremio-addons`));
+ } else if (k === '@user:local:stremio-addon-order') {
+ migrations.push(moveKey(k, `@user:${userId}:stremio-addon-order`));
+ // Do NOT migrate local scraper keys; they are device-local and unscoped
+ } else if (k === '@user:local:local-scrapers') {
+ // intentionally skip
+ } else if (k === '@user:local:scraper-repository-url') {
+ // intentionally skip
+ } else if (k === '@user:local:stremio-library') {
+ migrations.push((async () => {
+ const val = (await AsyncStorage.getItem(k)) || '{}';
+ await moveKey(k, `@user:${userId}:stremio-library`);
+ try {
+ const parsed = JSON.parse(val) as Record;
+ const count = Array.isArray(parsed) ? parsed.length : Object.keys(parsed || {}).length;
+ if (count > 0) await AsyncStorage.setItem(`@user:${userId}:library_initialized`, 'true');
+ } catch {}
+ })());
+ } else if (k === 'stremio-library') {
+ migrations.push((async () => {
+ const val = (await AsyncStorage.getItem(k)) || '{}';
+ await moveKey(k, `@user:${userId}:stremio-library`);
+ try {
+ const parsed = JSON.parse(val) as Record;
+ const count = Array.isArray(parsed) ? parsed.length : Object.keys(parsed || {}).length;
+ if (count > 0) await AsyncStorage.setItem(`@user:${userId}:library_initialized`, 'true');
+ } catch {}
+ })());
+ }
+ }
+ // Migrate legacy theme keys into scoped app_settings
+ try {
+ const legacyThemeId = await AsyncStorage.getItem('current_theme');
+ const legacyCustomThemesJson = await AsyncStorage.getItem('custom_themes');
+ const scopedSettingsKey = `@user:${userId}:app_settings`;
+ let scopedSettings: any = {};
+ try { scopedSettings = JSON.parse((await AsyncStorage.getItem(scopedSettingsKey)) || '{}'); } catch {}
+ let changed = false;
+ if (legacyThemeId && scopedSettings.themeId !== legacyThemeId) {
+ scopedSettings.themeId = legacyThemeId;
+ changed = true;
+ }
+ if (legacyCustomThemesJson) {
+ const legacyCustomThemes = JSON.parse(legacyCustomThemesJson);
+ if (Array.isArray(legacyCustomThemes)) {
+ scopedSettings.customThemes = legacyCustomThemes;
+ changed = true;
+ }
+ }
+ if (changed) {
+ await AsyncStorage.setItem(scopedSettingsKey, JSON.stringify(scopedSettings));
+ }
+ } catch {}
+ await Promise.all(migrations);
+ logger.log(`[Sync] migrateLocalScopeToUser done (moved ~${migrations.length} keys)`);
+ }
+
+ async fullPush(): Promise {
+ logger.log('[Sync] fullPush start');
+ await Promise.allSettled([
+ this.pushWatchProgress(),
+ // Settings push only at app start/sign-in handled by fullPush itself; keep here OK
+ this.pushSettings(),
+ this.pushAddons(),
+ // Excluded: this.pushLocalScrapers(),
+ this.pushLibrary(),
+ ]);
+ logger.log('[Sync] fullPush done');
+ }
+
+ async fullPull(): Promise {
+ logger.log('[Sync] fullPull start');
+ const user = await accountService.getCurrentUser();
+ if (!user) return;
+ const userId = user.id;
+ const traktActive = await traktService.isAuthenticated();
+
+ await Promise.allSettled([
+ (!traktActive ? (async () => {
+ logger.log('[Sync] pull watch_progress');
+ const { data: wp } = await supabase
+ .from('watch_progress')
+ .select('*')
+ .eq('user_id', userId)
+ .is('deleted_at', null);
+ if (wp && Array.isArray(wp)) {
+ const remoteActiveKeys = new Set();
+ for (const row of wp as any[]) {
+ // Preserve the most recent timestamp between local and remote to maintain proper continue watching order
+ const remoteTimestamp = row.last_updated_ms || Date.now();
+ const existingProgress = await storageService.getWatchProgress(
+ row.media_id,
+ row.media_type,
+ (row.episode_id && row.episode_id.length > 0) ? row.episode_id : undefined
+ );
+ const localTimestamp = existingProgress?.lastUpdated || 0;
+
+ // Use the newer timestamp to maintain proper continue watching order across devices
+ const finalTimestamp = Math.max(remoteTimestamp, localTimestamp);
+
+ await storageService.setWatchProgress(
+ row.media_id,
+ row.media_type,
+ {
+ currentTime: row.current_time_seconds,
+ duration: row.duration_seconds,
+ lastUpdated: finalTimestamp,
+ traktSynced: row.trakt_synced ?? undefined,
+ traktLastSynced: row.trakt_last_synced_ms ?? undefined,
+ traktProgress: row.trakt_progress_percent ?? undefined,
+ },
+ // Ensure full episode_id is preserved; treat empty as undefined
+ (row.episode_id && row.episode_id.length > 0) ? row.episode_id : undefined,
+ { preserveTimestamp: true, forceNotify: true, forceWrite: true }
+ );
+ remoteActiveKeys.add(`${row.media_type}|${row.media_id}|${row.episode_id || ''}`);
+ }
+ // Remove any local progress not present on server (server is source of truth)
+ try {
+ const allLocal = await storageService.getAllWatchProgress();
+ for (const [key] of Object.entries(allLocal)) {
+ const parts = key.split(':');
+ const type = parts[0];
+ const id = parts[1];
+ const ep = parts[2] || '';
+ const k = `${type}|${id}|${ep}`;
+ if (!remoteActiveKeys.has(k)) {
+ this.suppressPush = true;
+ await storageService.removeWatchProgress(id, type, ep || undefined);
+ this.suppressPush = false;
+ }
+ }
+ } catch {}
+ }
+ })() : Promise.resolve()),
+ (async () => {
+ logger.log('[Sync] pull user_settings');
+ const { data: us } = await supabase
+ .from('user_settings')
+ .select('*')
+ .eq('user_id', userId)
+ .single();
+ if (us) {
+ // Merge remote settings with existing local settings, preferring remote values
+ // but preserving any local-only keys (e.g., newly added client-side settings
+ // not yet present on the server). This avoids losing local preferences on restart.
+ try {
+ const localScopedJson = (await AsyncStorage.getItem(`@user:${userId}:app_settings`)) || '{}';
+ const localLegacyJson = (await AsyncStorage.getItem('app_settings')) || '{}';
+ // Prefer scoped local if available; fall back to legacy
+ let localSettings: Record = {};
+ try { localSettings = JSON.parse(localScopedJson); } catch {}
+ if (!localSettings || Object.keys(localSettings).length === 0) {
+ try { localSettings = JSON.parse(localLegacyJson); } catch { localSettings = {}; }
+ }
+
+ const remoteRaw: Record = (us.app_settings || {}) as Record;
+ // Exclude episodeLayoutStyle from remote to keep it local-only
+ const { episodeLayoutStyle: _remoteEpisodeLayoutStyle, ...remoteSettingsSansLocalOnly } = remoteRaw || {};
+ // Merge: start from local, override with remote (sans excluded keys)
+ const mergedSettings = { ...(localSettings || {}), ...(remoteSettingsSansLocalOnly || {}) };
+
+ await AsyncStorage.setItem(`@user:${userId}:app_settings`, JSON.stringify(mergedSettings));
+ await AsyncStorage.setItem('app_settings', JSON.stringify(mergedSettings));
+
+ // Sync continue watching removed items (stored in app_settings)
+ if (remoteSettingsSansLocalOnly?.continue_watching_removed) {
+ await AsyncStorage.setItem(`@user:${userId}:@continue_watching_removed`, JSON.stringify(remoteSettingsSansLocalOnly.continue_watching_removed));
+ }
+
+ await storageService.saveSubtitleSettings(us.subtitle_settings || {});
+ // Notify listeners that settings changed due to sync
+ try { settingsEmitter.emit(); } catch {}
+ } catch (e) {
+ // Fallback to writing remote settings as-is if merge fails
+ const remoteRaw: Record = (us.app_settings || {}) as Record;
+ const { episodeLayoutStyle: _remoteEpisodeLayoutStyle, ...remoteSettingsSansLocalOnly } = remoteRaw || {};
+ await AsyncStorage.setItem(`@user:${userId}:app_settings`, JSON.stringify(remoteSettingsSansLocalOnly));
+ await AsyncStorage.setItem('app_settings', JSON.stringify(remoteSettingsSansLocalOnly));
+
+ // Sync continue watching removed items in fallback (stored in app_settings)
+ if (remoteSettingsSansLocalOnly?.continue_watching_removed) {
+ await AsyncStorage.setItem(`@user:${userId}:@continue_watching_removed`, JSON.stringify(remoteSettingsSansLocalOnly.continue_watching_removed));
+ }
+
+ await storageService.saveSubtitleSettings(us.subtitle_settings || {});
+ try { settingsEmitter.emit(); } catch {}
+ }
+ }
+ })(),
+ this.smartPullAddons(userId), // Use smart pull instead of destructive pull
+ this.pullLibrary(userId),
+ ]);
+ logger.log('[Sync] fullPull done');
+ }
+
+ private async pullLibrary(userId: string): Promise {
+ try {
+ logger.log('[Sync] pull user_library');
+ const { data, error } = await supabase
+ .from('user_library')
+ .select('media_type, media_id, title, poster_url, year, deleted_at, updated_at')
+ .eq('user_id', userId);
+ if (error) {
+ if (__DEV__) console.warn('[SyncService] pull library error', error);
+ return;
+ }
+ const obj: Record = {};
+ for (const row of (data || []) as any[]) {
+ if (row.deleted_at) continue;
+ const key = `${row.media_type}:${row.media_id}`;
+ obj[key] = {
+ id: row.media_id,
+ type: row.media_type,
+ name: row.title || row.media_id,
+ poster: row.poster_url || '',
+ year: row.year || undefined,
+ inLibrary: true,
+ };
+ }
+ await AsyncStorage.setItem(`@user:${userId}:stremio-library`, JSON.stringify(obj));
+ await AsyncStorage.setItem('stremio-library', JSON.stringify(obj));
+ logger.log(`[Sync] pull user_library wrote items=${Object.keys(obj).length}`);
+ } catch (e) {
+ if (__DEV__) console.warn('[SyncService] pullLibrary exception', e);
+ }
+ }
+
+ private async pushLibrary(): Promise {
+ const user = await accountService.getCurrentUser();
+ if (!user) return;
+ try {
+ const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
+ const json =
+ (await AsyncStorage.getItem(`@user:${scope}:stremio-library`)) ||
+ (await AsyncStorage.getItem('stremio-library')) || '{}';
+ const itemsObj = JSON.parse(json) as Record;
+ const entries = Object.values(itemsObj) as any[];
+ logger.log(`[Sync] push user_library entries=${entries.length}`);
+ const initialized = (await AsyncStorage.getItem(`@user:${user.id}:library_initialized`)) === 'true';
+ // If not initialized and local entries are 0, attempt to import from server first
+ if (!initialized && entries.length === 0) {
+ logger.log('[Sync] user_library not initialized and local empty → pulling before deletions');
+ await this.pullLibrary(user.id);
+ const post = (await AsyncStorage.getItem(`@user:${user.id}:stremio-library`)) || '{}';
+ const postObj = JSON.parse(post) as Record;
+ const postEntries = Object.values(postObj) as any[];
+ if (postEntries.length > 0) {
+ await AsyncStorage.setItem(`@user:${user.id}:library_initialized`, 'true');
+ }
+ }
+ // Upsert rows
+ if (entries.length > 0) {
+ const rows = entries.map((it) => ({
+ user_id: user.id,
+ media_type: it.type === 'movie' ? 'movie' : 'series',
+ media_id: it.id,
+ title: it.name || it.title || it.id,
+ poster_url: it.poster || it.poster_url || null,
+ year: normalizeYear(it.year),
+ updated_at: new Date().toISOString(),
+ }));
+ const { error: upErr } = await supabase
+ .from('user_library')
+ .upsert(rows, { onConflict: 'user_id,media_type,media_id' });
+ if (upErr && __DEV__) console.warn('[SyncService] push library upsert error', upErr);
+ else await AsyncStorage.setItem(`@user:${user.id}:library_initialized`, 'true');
+ }
+ // No computed deletions; removals happen only via explicit user action (soft delete)
+ } catch (e) {
+ if (__DEV__) console.warn('[SyncService] pushLibrary exception', e);
+ }
+ }
+
+ private async pushLibraryAdd(item: StreamingContent): Promise {
+ const user = await accountService.getCurrentUser();
+ if (!user) return;
+ try {
+ const row = {
+ user_id: user.id,
+ media_type: item.type === 'movie' ? 'movie' : 'series',
+ media_id: item.id,
+ title: (item as any).name || (item as any).title || item.id,
+ poster_url: (item as any).poster || null,
+ year: normalizeYear((item as any).year),
+ deleted_at: null as any,
+ updated_at: new Date().toISOString(),
+ };
+ const { error } = await supabase.from('user_library').upsert(row, { onConflict: 'user_id,media_type,media_id' });
+ if (error && __DEV__) console.warn('[SyncService] pushLibraryAdd error', error);
+ } catch (e) {
+ if (__DEV__) console.warn('[SyncService] pushLibraryAdd exception', e);
+ }
+ }
+
+ private async pushLibraryRemove(type: string, id: string): Promise {
+ const user = await accountService.getCurrentUser();
+ if (!user) return;
+ try {
+ const { error } = await supabase
+ .from('user_library')
+ .update({ deleted_at: new Date().toISOString(), updated_at: new Date().toISOString() })
+ .eq('user_id', user.id)
+ .eq('media_type', type === 'movie' ? 'movie' : 'series')
+ .eq('media_id', id);
+ if (error && __DEV__) console.warn('[SyncService] pushLibraryRemove error', error);
+ } catch (e) {
+ if (__DEV__) console.warn('[SyncService] pushLibraryRemove exception', e);
+ }
+ }
+
+ private async pullAddonsSnapshot(userId: string): Promise {
+ logger.log('[Sync] pull installed_addons');
+ const { data: addons, error: addonsErr } = await supabase
+ .from('installed_addons')
+ .select('*')
+ .eq('user_id', userId)
+ .order('position', { ascending: true });
+ if (addonsErr) {
+ if (__DEV__) console.warn('[SyncService] pull addons error', addonsErr);
+ return;
+ }
+ if (!(addons && Array.isArray(addons))) return;
+
+ // Start from currently installed (to preserve pre-installed like Cinemeta/OpenSubtitles)
+ const map = new Map();
+
+ for (const a of addons as any[]) {
+ try {
+ // Skip server addon if user explicitly removed it locally (tombstone)
+ try {
+ const removed = await stremioService.hasUserRemovedAddon(a.addon_id);
+ if (removed && a.addon_id !== 'com.linvo.cinemeta') {
+ continue;
+ }
+ } catch {}
+ let manifest = a.manifest_data;
+ if (!manifest) {
+ const urlToUse = a.original_url || a.url;
+ if (urlToUse) {
+ manifest = await stremioService.getManifest(urlToUse);
+ }
+ }
+ if (!manifest) {
+ manifest = {
+ id: a.addon_id,
+ name: a.name || a.addon_id,
+ version: a.version || '1.0.0',
+ description: a.description || '',
+ url: a.url || a.original_url || '',
+ originalUrl: a.original_url || a.url || '',
+ catalogs: [],
+ resources: [],
+ types: [],
+ };
+ }
+ manifest.id = a.addon_id;
+ map.set(a.addon_id, manifest);
+ } catch (e) {
+ if (__DEV__) console.warn('[SyncService] failed to fetch manifest for', a.addon_id, e);
+ }
+ }
+
+ // Always include preinstalled regardless of server
+ try { map.set('com.linvo.cinemeta', await stremioService.getManifest('https://v3-cinemeta.strem.io/manifest.json')); } catch {}
+
+ // Only include OpenSubtitles if user hasn't explicitly removed it
+ const hasUserRemovedOpenSubtitles = await stremioService.hasUserRemovedAddon('org.stremio.opensubtitlesv3');
+ if (!hasUserRemovedOpenSubtitles) {
+ try { map.set('org.stremio.opensubtitlesv3', await stremioService.getManifest('https://opensubtitles-v3.strem.io/manifest.json')); } catch {}
+ }
+
+ (stremioService as any).installedAddons = map;
+ let order = (addons as any[]).map(a => a.addon_id);
+ const ensureFront = (arr: string[], id: string) => {
+ const idx = arr.indexOf(id);
+ if (idx === -1) arr.unshift(id);
+ else if (idx > 0) { arr.splice(idx, 1); arr.unshift(id); }
+ };
+ ensureFront(order, 'com.linvo.cinemeta');
+
+ // Only ensure OpenSubtitles is in order if user hasn't removed it
+ if (!hasUserRemovedOpenSubtitles) {
+ ensureFront(order, 'org.stremio.opensubtitlesv3');
+ }
+ // Prefer local order if it exists; otherwise use remote
+ try {
+ const userScope = `@user:${userId}:stremio-addon-order`;
+ const [localScopedOrder, localLegacyOrder, localGuestOrder] = await Promise.all([
+ AsyncStorage.getItem(userScope),
+ AsyncStorage.getItem('stremio-addon-order'),
+ AsyncStorage.getItem('@user:local:stremio-addon-order'),
+ ]);
+ const localOrderRaw = localScopedOrder || localLegacyOrder || localGuestOrder;
+ if (localOrderRaw) {
+ const localOrder = JSON.parse(localOrderRaw) as string[];
+ // Filter to only installed ids
+ const localFiltered = localOrder.filter(id => map.has(id));
+ if (localFiltered.length > 0) {
+ order = localFiltered;
+ }
+ }
+ } catch {}
+
+ (stremioService as any).addonOrder = order;
+ await (stremioService as any).saveInstalledAddons();
+ await (stremioService as any).saveAddonOrder();
+ // Mark addons initialized for this user to prevent destructive merges on first push
+ try { await AsyncStorage.setItem(`@user:${userId}:addons_initialized`, 'true'); } catch {}
+ // Push merged order to server to preserve across devices
+ try {
+ const rows = order.map((addonId: string, idx: number) => ({
+ user_id: userId,
+ addon_id: addonId,
+ position: idx,
+ }));
+ const { error } = await supabase
+ .from('installed_addons')
+ .upsert(rows, { onConflict: 'user_id,addon_id' });
+ if (error) logger.warn('[SyncService] push merged addon order error', error);
+ } catch (e) {
+ logger.warn('[SyncService] push merged addon order exception', e);
+ }
+ }
+
+ async pushWatchProgress(): Promise {
+ const user = await accountService.getCurrentUser();
+ if (!user) return;
+ // When Trakt is authenticated, disable account push for continue watching
+ try {
+ if (await traktService.isAuthenticated()) {
+ logger.log('[Sync] Trakt active → skipping push watch_progress');
+ return;
+ }
+ } catch {}
+ const userId = user.id;
+ const unsynced = await storageService.getUnsyncedProgress();
+ logger.log(`[Sync] push watch_progress rows=${unsynced.length}`);
+ const rows: any[] = unsynced.map(({ id, type, episodeId, progress }) => ({
+ user_id: userId,
+ media_type: type,
+ media_id: id,
+ episode_id: episodeId || '',
+ current_time_seconds: Math.floor(progress.currentTime || 0),
+ duration_seconds: Math.floor(progress.duration || 0),
+ last_updated_ms: progress.lastUpdated || Date.now(),
+ trakt_synced: progress.traktSynced ?? undefined,
+ trakt_last_synced_ms: progress.traktLastSynced ?? undefined,
+ trakt_progress_percent: progress.traktProgress ?? undefined,
+ deleted_at: null,
+ updated_at: new Date().toISOString(),
+ }));
+ if (rows.length > 0) {
+ // Prevent resurrecting remotely-deleted rows when server has newer update
+ try {
+ const keys = rows.map(r => ({ media_type: r.media_type, media_id: r.media_id, episode_id: r.episode_id }));
+ const { data: remote } = await supabase
+ .from('watch_progress')
+ .select('media_type,media_id,episode_id,deleted_at,updated_at')
+ .eq('user_id', userId)
+ .in('media_type', keys.map(k => k.media_type))
+ .in('media_id', keys.map(k => k.media_id))
+ .in('episode_id', keys.map(k => k.episode_id));
+ const shouldSkip = new Set();
+ if (remote) {
+ for (const r of remote as any[]) {
+ const key = `${r.media_type}|${r.media_id}|${r.episode_id || ''}`;
+ if (r.deleted_at && r.updated_at) {
+ const remoteUpdatedMs = new Date(r.updated_at as string).getTime();
+ // Find matching local row
+ const local = rows.find(x => x.media_type === r.media_type && x.media_id === r.media_id && x.episode_id === (r.episode_id || ''));
+ const localUpdatedMs = local?.last_updated_ms ?? 0;
+ if (remoteUpdatedMs >= localUpdatedMs) {
+ shouldSkip.add(key);
+ // also write a tombstone locally
+ try { await storageService.addWatchProgressTombstone(r.media_id, r.media_type, r.episode_id || undefined, remoteUpdatedMs); } catch {}
+ }
+ }
+ }
+ }
+ if (shouldSkip.size > 0) {
+ logger.log(`[Sync] push watch_progress skipping resurrect count=${shouldSkip.size}`);
+ }
+ // Filter rows to upsert
+ const filteredRows = rows.filter(r => !shouldSkip.has(`${r.media_type}|${r.media_id}|${r.episode_id}`));
+ if (filteredRows.length > 0) {
+ const { error } = await supabase
+ .from('watch_progress')
+ .upsert(filteredRows, { onConflict: 'user_id,media_type,media_id,episode_id' });
+ if (error && __DEV__) console.warn('[SyncService] push watch_progress error', error);
+ else logger.log('[Sync] push watch_progress upsert ok');
+ }
+ } catch (e) {
+ // Fallback to normal upsert if pre-check fails
+ const { error } = await supabase
+ .from('watch_progress')
+ .upsert(rows, { onConflict: 'user_id,media_type,media_id,episode_id' });
+ if (error && __DEV__) console.warn('[SyncService] push watch_progress error', error);
+ else logger.log('[Sync] push watch_progress upsert ok');
+ }
+ }
+
+ // Deletions occur only on explicit remove; no bulk deletions here
+ }
+
+ private async softDeleteWatchProgress(type: string, id: string, episodeId?: string): Promise {
+ const user = await accountService.getCurrentUser();
+ if (!user) return;
+ // When Trakt is authenticated, do not propagate deletes to account server for watch progress
+ try {
+ if (await traktService.isAuthenticated()) {
+ logger.log('[Sync] Trakt active → skipping softDelete watch_progress');
+ return;
+ }
+ } catch {}
+ try {
+ const { error } = await supabase
+ .from('watch_progress')
+ .update({ deleted_at: new Date().toISOString(), updated_at: new Date().toISOString() })
+ .eq('user_id', user.id)
+ .eq('media_type', type)
+ .eq('media_id', id)
+ .eq('episode_id', episodeId || '');
+ if (error && __DEV__) console.warn('[SyncService] softDeleteWatchProgress error', error);
+ } catch (e) {
+ if (__DEV__) console.warn('[SyncService] softDeleteWatchProgress exception', e);
+ }
+ }
+
+ async pushSettings(): Promise {
+ const user = await accountService.getCurrentUser();
+ if (!user) return;
+ const userId = user.id;
+ logger.log('[Sync] push user_settings start');
+ const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
+ const appSettingsJson =
+ (await AsyncStorage.getItem(`@user:${scope}:app_settings`)) ||
+ (await AsyncStorage.getItem('app_settings')) ||
+ '{}';
+ const parsed = JSON.parse(appSettingsJson) as Record;
+ // Exclude local-only settings from push
+ const { episodeLayoutStyle: _localEpisodeLayoutStyle, ...appSettings } = parsed || {};
+ const subtitleSettings = (await storageService.getSubtitleSettings()) || {};
+ const continueWatchingRemoved = await storageService.getContinueWatchingRemoved();
+
+ // Include continue watching removed items in app_settings
+ const appSettingsWithRemoved = {
+ ...appSettings,
+ continue_watching_removed: continueWatchingRemoved
+ };
+
+ const { error } = await supabase.from('user_settings').upsert({
+ user_id: userId,
+ app_settings: appSettingsWithRemoved,
+ subtitle_settings: subtitleSettings,
+ });
+ if (error && __DEV__) console.warn('[SyncService] push settings error', error);
+ else logger.log('[Sync] push user_settings ok');
+ }
+
+ async pushAddons(): Promise {
+ const user = await accountService.getCurrentUser();
+ if (!user) return;
+ const userId = user.id;
+ let addons = await stremioService.getInstalledAddonsAsync();
+ logger.log(`[Sync] push installed_addons count=${addons.length}`);
+ let order = (stremioService as any).addonOrder as string[];
+
+ // Safety: Check if this device has ever synced addons for this user
+ // Only pull if this is truly a first-time sync AND remote has significantly more addons
+ try {
+ const deviceInitialized = (await AsyncStorage.getItem(`@user:${userId}:addons_initialized`)) === 'true';
+ const { data: remoteBefore } = await supabase
+ .from('installed_addons')
+ .select('addon_id')
+ .eq('user_id', userId);
+ const remoteCount = (remoteBefore || []).length;
+
+ // Only pull if:
+ // 1. This device hasn't initialized addons for this user
+ // 2. Remote has significantly more addons (not just pre-installed ones)
+ // 3. Local has only pre-installed addons (2 or fewer)
+ const hasOnlyPreInstalled = addons.length <= 2 &&
+ addons.every(a => ['com.linvo.cinemeta', 'org.stremio.opensubtitlesv3'].includes(a.id));
+
+ if (!deviceInitialized && remoteCount > 2 && hasOnlyPreInstalled) {
+ logger.log('[Sync] Device first-time sync with only pre-installed addons → pulling before push');
+ await this.pullAddonsSnapshot(userId);
+ // refresh local state after pull
+ addons = await stremioService.getInstalledAddonsAsync();
+ order = (stremioService as any).addonOrder as string[];
+ } else if (!deviceInitialized && remoteCount > addons.length) {
+ logger.log('[Sync] Device first-time sync but local has custom addons → merging instead of pulling');
+ // Don't pull - merge the addons instead
+ await this.mergeAddonsFromServer(userId);
+ addons = await stremioService.getInstalledAddonsAsync();
+ order = (stremioService as any).addonOrder as string[];
+ }
+ } catch {}
+
+ const removedListJson = (await AsyncStorage.getItem('user_removed_addons')) || '[]';
+ let removedList: string[] = [];
+ try { removedList = JSON.parse(removedListJson); } catch { removedList = []; }
+
+ const rows = addons.map((a: any) => ({
+ user_id: userId,
+ addon_id: a.id,
+ name: a.name,
+ url: a.url,
+ original_url: a.originalUrl,
+ version: a.version,
+ description: a.description,
+ position: Math.max(0, order.indexOf(a.id)),
+ manifest_data: a,
+ }));
+ // Delete remote addons that no longer exist locally (excluding pre-installed to be safe)
+ // Enhanced safety: only delete if device has been initialized and user explicitly removed addons
+ try {
+ const { data: remote, error: rErr } = await supabase
+ .from('installed_addons')
+ .select('addon_id')
+ .eq('user_id', userId);
+ if (!rErr && remote) {
+ const deviceInitialized = (await AsyncStorage.getItem(`@user:${userId}:addons_initialized`)) === 'true';
+
+ // Only perform deletions if:
+ // 1. Device has been initialized (not first-time sync)
+ // 2. Local addons are not just pre-installed ones
+ const hasOnlyPreInstalled = addons.length <= 2 &&
+ addons.every(a => ['com.linvo.cinemeta', 'org.stremio.opensubtitlesv3'].includes(a.id));
+
+ if (!deviceInitialized || hasOnlyPreInstalled) {
+ logger.log('[Sync] skipping deletions during first-time sync or when only pre-installed addons present');
+ } else {
+ const localIds = new Set(addons.map((a: any) => a.id));
+ const toDeletePromises = (remote as any[])
+ .map(r => r.addon_id as string)
+ .map(async id => {
+ if (localIds.has(id)) return null; // Don't delete if still installed locally
+ // If user removed Cinemeta locally, allow server deletion as well
+ // If user explicitly removed this addon locally, prefer local removal and delete remotely
+ if (removedList.includes(id)) return id;
+ return id; // Delete other addons that are no longer installed locally
+ });
+
+ const toDeleteResults = await Promise.all(toDeletePromises);
+ const toDelete = toDeleteResults.filter(id => id !== null);
+ logger.log(`[Sync] push installed_addons deletions=${toDelete.length}`);
+ if (toDelete.length > 0) {
+ const del = await supabase
+ .from('installed_addons')
+ .delete()
+ .eq('user_id', userId)
+ .in('addon_id', toDelete);
+ if (del.error && __DEV__) console.warn('[SyncService] delete addons error', del.error);
+ }
+ }
+ }
+ } catch (e) {
+ if (__DEV__) console.warn('[SyncService] deletion sync for addons failed', e);
+ }
+ const { error } = await supabase.from('installed_addons').upsert(rows, { onConflict: 'user_id,addon_id' });
+ if (error && __DEV__) console.warn('[SyncService] push addons error', error);
+ }
+
+ // Excluded: pushLocalScrapers (local scrapers are device-local only)
+
+ private async smartPullAddons(userId: string): Promise {
+ logger.log('[Sync] smartPullAddons: intelligent addon synchronization');
+ try {
+ // Check if this device has been initialized for this user
+ const deviceInitialized = (await AsyncStorage.getItem(`@user:${userId}:addons_initialized`)) === 'true';
+
+ // Get current local addons
+ const localAddons = await stremioService.getInstalledAddonsAsync();
+ const localAddonIds = new Set(localAddons.map(a => a.id));
+
+ // Get remote addons
+ const { data: remoteAddons } = await supabase
+ .from('installed_addons')
+ .select('*')
+ .eq('user_id', userId)
+ .order('position', { ascending: true });
+
+ if (!remoteAddons || remoteAddons.length === 0) {
+ logger.log('[Sync] smartPullAddons: no remote addons found');
+ return;
+ }
+
+ const remoteAddonIds = new Set(remoteAddons.map(a => a.addon_id));
+
+ // Determine sync strategy based on context
+ const hasOnlyPreInstalled = localAddons.length <= 2 &&
+ localAddons.every(a => ['com.linvo.cinemeta', 'org.stremio.opensubtitlesv3'].includes(a.id));
+
+ if (!deviceInitialized && hasOnlyPreInstalled && remoteAddons.length > 2) {
+ // First-time sync with only pre-installed addons - safe to pull
+ logger.log('[Sync] smartPullAddons: first-time sync with only pre-installed → pulling');
+ await this.pullAddonsSnapshot(userId);
+ } else if (!deviceInitialized && localAddons.length > 2) {
+ // First-time sync but user has custom addons - merge instead
+ logger.log('[Sync] smartPullAddons: first-time sync with custom addons → merging');
+ await this.mergeAddonsFromServer(userId);
+ } else if (deviceInitialized) {
+ // Device already initialized - only merge missing addons
+ logger.log('[Sync] smartPullAddons: device initialized → merging missing addons only');
+ await this.mergeMissingAddonsOnly(userId, localAddonIds, remoteAddons);
+ } else {
+ // Default case - merge
+ logger.log('[Sync] smartPullAddons: default case → merging');
+ await this.mergeAddonsFromServer(userId);
+ }
+
+ // Mark device as initialized after successful sync
+ if (!deviceInitialized) {
+ await AsyncStorage.setItem(`@user:${userId}:addons_initialized`, 'true');
+ logger.log('[Sync] smartPullAddons: marked device as initialized');
+ }
+
+ } catch (e) {
+ logger.error('[Sync] smartPullAddons failed:', e);
+ }
+ }
+
+ private async mergeMissingAddonsOnly(userId: string, localAddonIds: Set, remoteAddons: any[]): Promise {
+ logger.log('[Sync] mergeMissingAddonsOnly: adding only missing addons');
+ try {
+ const addonsToInstall: any[] = [];
+
+ for (const remoteAddon of remoteAddons) {
+ if (!localAddonIds.has(remoteAddon.addon_id)) {
+ // Honor local tombstone: skip addons user explicitly removed
+ try {
+ const removed = await stremioService.hasUserRemovedAddon(remoteAddon.addon_id);
+ if (removed && remoteAddon.addon_id !== 'com.linvo.cinemeta') {
+ continue;
+ }
+ } catch {}
+ try {
+ let manifest = remoteAddon.manifest_data;
+ if (!manifest && remoteAddon.original_url) {
+ manifest = await stremioService.getManifest(remoteAddon.original_url);
+ }
+ if (manifest) {
+ addonsToInstall.push(manifest);
+ }
+ } catch (e) {
+ logger.warn('[Sync] Failed to fetch manifest for missing addon:', remoteAddon.addon_id);
+ }
+ }
+ }
+
+ // Install missing addons locally
+ for (const manifest of addonsToInstall) {
+ try {
+ await stremioService.installAddon(manifest.originalUrl || manifest.url);
+ logger.log('[Sync] Installed missing addon:', manifest.id);
+ } catch (e) {
+ logger.warn('[Sync] Failed to install missing addon:', manifest.id);
+ }
+ }
+
+ logger.log(`[Sync] mergeMissingAddonsOnly completed: ${addonsToInstall.length} addons installed`);
+ } catch (e) {
+ logger.error('[Sync] mergeMissingAddonsOnly failed:', e);
+ }
+ }
+
+ private async mergeAddonsFromServer(userId: string): Promise {
+ logger.log('[Sync] mergeAddonsFromServer: merging server addons with local addons');
+ try {
+ const { data: remoteAddons } = await supabase
+ .from('installed_addons')
+ .select('*')
+ .eq('user_id', userId)
+ .order('position', { ascending: true });
+
+ if (!remoteAddons || remoteAddons.length === 0) return;
+
+ // Get current local addons
+ const localAddons = await stremioService.getInstalledAddonsAsync();
+ const localAddonIds = new Set(localAddons.map(a => a.id));
+
+ // Merge remote addons that aren't already local
+ const addonsToInstall: any[] = [];
+ for (const remoteAddon of remoteAddons as any[]) {
+ if (!localAddonIds.has(remoteAddon.addon_id)) {
+ // Honor local tombstone: skip addons user explicitly removed
+ try {
+ const removed = await stremioService.hasUserRemovedAddon(remoteAddon.addon_id);
+ if (removed && remoteAddon.addon_id !== 'com.linvo.cinemeta') {
+ continue;
+ }
+ } catch {}
+ try {
+ let manifest = remoteAddon.manifest_data;
+ if (!manifest && remoteAddon.original_url) {
+ manifest = await stremioService.getManifest(remoteAddon.original_url);
+ }
+ if (manifest) {
+ addonsToInstall.push(manifest);
+ }
+ } catch (e) {
+ logger.warn('[Sync] Failed to fetch manifest for remote addon:', remoteAddon.addon_id);
+ }
+ }
+ }
+
+ // Install missing addons locally
+ for (const manifest of addonsToInstall) {
+ try {
+ await stremioService.installAddon(manifest.originalUrl || manifest.url);
+ logger.log('[Sync] Merged addon from server:', manifest.id);
+ } catch (e) {
+ logger.warn('[Sync] Failed to install merged addon:', manifest.id);
+ }
+ }
+
+ logger.log(`[Sync] mergeAddonsFromServer completed: ${addonsToInstall.length} addons merged`);
+ } catch (e) {
+ logger.error('[Sync] mergeAddonsFromServer failed:', e);
+ }
+ }
+}
+
+export const syncService = SyncService.getInstance();
+export default syncService;
+
+// Small helper to batch delete operations
+function chunkArray(arr: T[], size: number): T[][] {
+ const res: T[][] = [];
+ for (let i = 0; i < arr.length; i += size) res.push(arr.slice(i, i + size));
+ return res;
+}
+
+// Normalize year values to integer or null
+function normalizeYear(value: any): number | null {
+ if (value == null) return null;
+ if (typeof value === 'number' && Number.isInteger(value)) return value;
+ if (typeof value === 'string') {
+ // Extract first 4 consecutive digits
+ const m = value.match(/\d{4}/);
+ if (m) {
+ const y = parseInt(m[0], 10);
+ if (y >= 1900 && y <= 2100) return y;
+ return y;
+ }
+ }
+ return null;
+}
+
diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts
index a86bb8e..d2381e5 100644
--- a/src/services/catalogService.ts
+++ b/src/services/catalogService.ts
@@ -125,12 +125,6 @@ export interface StreamingContent {
originCountry?: string[];
tagline?: string;
};
- collection?: {
- id: number;
- name: string;
- poster_path?: string;
- backdrop_path?: string;
- };
}
export interface CatalogContent {
@@ -720,6 +714,14 @@ class CatalogService {
if (!logoUrl || logoUrl.trim() === '' || logoUrl === 'null' || logoUrl === 'undefined') {
logoUrl = undefined;
}
+ try {
+ logger.debug('[CatalogService] convertMetaToStreamingContent:logo', {
+ id: meta.id,
+ name: meta.name,
+ hasLogo: Boolean(logoUrl),
+ logo: logoUrl || undefined,
+ });
+ } catch {}
return {
id: meta.id,
diff --git a/src/services/githubReleaseService.ts b/src/services/githubReleaseService.ts
index b2696b7..4847189 100644
--- a/src/services/githubReleaseService.ts
+++ b/src/services/githubReleaseService.ts
@@ -59,61 +59,4 @@ export function isAnyUpgrade(current: string, latest: string): boolean {
return b[2] > a[2];
}
-export async function fetchTotalDownloads(): Promise {
- try {
- const res = await fetch('https://api.github.com/repos/tapframe/NuvioStreaming/releases', {
- headers: {
- 'Accept': 'application/vnd.github+json',
- 'User-Agent': `Nuvio/${Platform.OS}`,
- },
- });
- if (!res.ok) return null;
- const releases = await res.json();
-
- let total = 0;
- releases.forEach((release: any) => {
- if (release.assets && Array.isArray(release.assets)) {
- release.assets.forEach((asset: any) => {
- total += asset.download_count || 0;
- });
- }
- });
-
- return total;
- } catch {
- return null;
- }
-}
-
-export interface GitHubContributor {
- login: string;
- id: number;
- avatar_url: string;
- html_url: string;
- contributions: number;
- type: string;
-}
-
-export async function fetchContributors(): Promise {
- try {
- const res = await fetch('https://api.github.com/repos/tapframe/NuvioStreaming/contributors', {
- headers: {
- 'Accept': 'application/vnd.github+json',
- 'User-Agent': `Nuvio/${Platform.OS}`,
- },
- });
-
- if (!res.ok) {
- if (__DEV__) console.error('GitHub API error:', res.status, res.statusText);
- return null;
- }
-
- const contributors = await res.json();
- return contributors;
- } catch (error) {
- if (__DEV__) console.error('Error fetching contributors:', error);
- return null;
- }
-}
-
diff --git a/src/services/localScraperService.ts b/src/services/localScraperService.ts
index bbd37f9..ff7e801 100644
--- a/src/services/localScraperService.ts
+++ b/src/services/localScraperService.ts
@@ -864,30 +864,6 @@ class LocalScraperService {
async getStreams(type: string, tmdbId: string, season?: number, episode?: number, callback?: ScraperCallback): Promise {
await this.ensureInitialized();
- // Check if local scrapers are enabled
- const userSettings = await this.getUserScraperSettings();
- if (!userSettings.enableLocalScrapers) {
- logger.log('[LocalScraperService] Local scrapers are disabled');
- return;
- }
-
- // If no repository is configured, return early
- if (!this.repositoryUrl) {
- logger.log('[LocalScraperService] No repository URL configured');
- return;
- }
-
- // If no scrapers are installed, try to refresh repository
- if (this.installedScrapers.size === 0) {
- logger.log('[LocalScraperService] No scrapers installed, attempting to refresh repository');
- try {
- await this.performRepositoryRefresh();
- } catch (error) {
- logger.error('[LocalScraperService] Failed to refresh repository for getStreams:', error);
- return;
- }
- }
-
// Get available scrapers from manifest (respects manifestEnabled)
const availableScrapers = await this.getAvailableScrapers();
const enabledScrapers = availableScrapers
@@ -1323,23 +1299,6 @@ class LocalScraperService {
return false;
}
- // If no repository is configured, return false
- if (!this.repositoryUrl) {
- logger.log('[LocalScraperService] No repository URL configured');
- return false;
- }
-
- // If no scrapers are installed, try to refresh repository
- if (this.installedScrapers.size === 0) {
- logger.log('[LocalScraperService] No scrapers installed, attempting to refresh repository');
- try {
- await this.performRepositoryRefresh();
- } catch (error) {
- logger.error('[LocalScraperService] Failed to refresh repository for hasScrapers check:', error);
- return false;
- }
- }
-
// Check if there are any enabled scrapers based on user settings
if (userSettings.enabledScrapers && userSettings.enabledScrapers.size > 0) {
return true;
diff --git a/src/services/robustCalendarCache.ts b/src/services/robustCalendarCache.ts
index 7e9a4de..9ca3296 100644
--- a/src/services/robustCalendarCache.ts
+++ b/src/services/robustCalendarCache.ts
@@ -16,7 +16,7 @@ interface TraktCollections {
}
const THIS_WEEK_CACHE_KEY = 'this_week_episodes_cache';
-const CALENDAR_CACHE_KEY = 'calendar_data_cache_v2';
+const CALENDAR_CACHE_KEY = 'calendar_data_cache';
const CACHE_DURATION_MS = 30 * 60 * 1000; // 30 minutes (increased to reduce API calls)
const ERROR_CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes for error recovery
diff --git a/src/services/streamCacheService.ts b/src/services/streamCacheService.ts
deleted file mode 100644
index 2b55fce..0000000
--- a/src/services/streamCacheService.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import AsyncStorage from '@react-native-async-storage/async-storage';
-import { logger } from '../utils/logger';
-
-export interface CachedStream {
- stream: any; // Stream object
- metadata: any; // Metadata object
- episodeId?: string; // For series episodes
- season?: number;
- episode?: number;
- episodeTitle?: string;
- imdbId?: string; // IMDb ID for subtitle fetching
- timestamp: number; // When it was cached
- url: string; // Stream URL for quick validation
-}
-
-export interface StreamCacheEntry {
- cachedStream: CachedStream;
- expiresAt: number; // Timestamp when cache expires
-}
-
-const DEFAULT_CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds (fallback)
-const CACHE_KEY_PREFIX = 'stream_cache_';
-
-class StreamCacheService {
- /**
- * Save a stream to cache
- */
- async saveStreamToCache(
- id: string,
- type: string,
- stream: any,
- metadata: any,
- episodeId?: string,
- season?: number,
- episode?: number,
- episodeTitle?: string,
- imdbId?: string,
- cacheDuration?: number
- ): Promise {
- try {
- const cacheKey = this.getCacheKey(id, type, episodeId);
- const now = Date.now();
-
- const cachedStream: CachedStream = {
- stream,
- metadata,
- episodeId,
- season,
- episode,
- episodeTitle,
- imdbId,
- timestamp: now,
- url: stream.url
- };
-
- const ttl = cacheDuration || DEFAULT_CACHE_DURATION;
- const cacheEntry: StreamCacheEntry = {
- cachedStream,
- expiresAt: now + ttl
- };
-
- await AsyncStorage.setItem(cacheKey, JSON.stringify(cacheEntry));
- logger.log(`💾 [StreamCache] Saved stream cache for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`);
- logger.log(`💾 [StreamCache] Cache key: ${cacheKey}`);
- logger.log(`💾 [StreamCache] Stream URL: ${stream.url}`);
- logger.log(`💾 [StreamCache] TTL: ${ttl / 1000 / 60} minutes`);
- logger.log(`💾 [StreamCache] Expires at: ${new Date(now + ttl).toISOString()}`);
- } catch (error) {
- logger.warn('[StreamCache] Failed to save stream to cache:', error);
- }
- }
-
- /**
- * Get cached stream if it exists and is still valid
- */
- async getCachedStream(id: string, type: string, episodeId?: string): Promise {
- try {
- const cacheKey = this.getCacheKey(id, type, episodeId);
- logger.log(`🔍 [StreamCache] Looking for cached stream with key: ${cacheKey}`);
-
- const cachedData = await AsyncStorage.getItem(cacheKey);
-
- if (!cachedData) {
- logger.log(`❌ [StreamCache] No cached data found for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`);
- return null;
- }
-
- const cacheEntry: StreamCacheEntry = JSON.parse(cachedData);
- const now = Date.now();
-
- logger.log(`🔍 [StreamCache] Found cached data, expires at: ${new Date(cacheEntry.expiresAt).toISOString()}`);
- logger.log(`🔍 [StreamCache] Current time: ${new Date(now).toISOString()}`);
-
- // Check if cache has expired
- if (now > cacheEntry.expiresAt) {
- logger.log(`⏰ [StreamCache] Cache expired for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`);
- await this.removeCachedStream(id, type, episodeId);
- return null;
- }
-
- // Skip URL validation for now - many CDNs block HEAD requests
- // This was causing valid streams to be rejected
- logger.log(`🔍 [StreamCache] Skipping URL validation (CDN compatibility)`);
-
- logger.log(`✅ [StreamCache] Using cached stream for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`);
- return cacheEntry.cachedStream;
- } catch (error) {
- logger.warn('[StreamCache] Failed to get cached stream:', error);
- return null;
- }
- }
-
- /**
- * Remove cached stream
- */
- async removeCachedStream(id: string, type: string, episodeId?: string): Promise {
- try {
- const cacheKey = this.getCacheKey(id, type, episodeId);
- await AsyncStorage.removeItem(cacheKey);
- logger.log(`🗑️ [StreamCache] Removed cached stream for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`);
- } catch (error) {
- logger.warn('[StreamCache] Failed to remove cached stream:', error);
- }
- }
-
- /**
- * Clear all cached streams
- */
- async clearAllCachedStreams(): Promise {
- try {
- const allKeys = await AsyncStorage.getAllKeys();
- const cacheKeys = allKeys.filter(key => key.startsWith(CACHE_KEY_PREFIX));
-
- for (const key of cacheKeys) {
- await AsyncStorage.removeItem(key);
- }
-
- logger.log(`🧹 [StreamCache] Cleared ${cacheKeys.length} cached streams`);
- } catch (error) {
- logger.warn('[StreamCache] Failed to clear all cached streams:', error);
- }
- }
-
- /**
- * Get cache key for a specific content item
- */
- private getCacheKey(id: string, type: string, episodeId?: string): string {
- const baseKey = `${CACHE_KEY_PREFIX}${type}:${id}`;
- return episodeId ? `${baseKey}:${episodeId}` : baseKey;
- }
-
- /**
- * Validate if a stream URL is still accessible
- */
- private async validateStreamUrl(url: string): Promise {
- try {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 3000); // 3 second timeout
-
- const response = await fetch(url, {
- method: 'HEAD',
- signal: controller.signal as any,
- } as any);
-
- clearTimeout(timeout);
- return response.ok;
- } catch (error) {
- return false;
- }
- }
-
- /**
- * Get cache info for debugging
- */
- async getCacheInfo(): Promise<{ totalCached: number; expiredCount: number; validCount: number }> {
- try {
- const allKeys = await AsyncStorage.getAllKeys();
- const cacheKeys = allKeys.filter((key: string) => key.startsWith(CACHE_KEY_PREFIX));
-
- let expiredCount = 0;
- let validCount = 0;
- const now = Date.now();
-
- for (const key of cacheKeys) {
- try {
- const cachedData = await AsyncStorage.getItem(key);
- if (cachedData) {
- const cacheEntry: StreamCacheEntry = JSON.parse(cachedData);
- if (now > cacheEntry.expiresAt) {
- expiredCount++;
- } else {
- validCount++;
- }
- }
- } catch (error) {
- // Skip invalid entries
- }
- }
-
- return {
- totalCached: cacheKeys.length,
- expiredCount,
- validCount
- };
- } catch (error) {
- return { totalCached: 0, expiredCount: 0, validCount: 0 };
- }
- }
-}
-
-export const streamCacheService = new StreamCacheService();
diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts
index bf3cf27..c1a3947 100644
--- a/src/services/stremioService.ts
+++ b/src/services/stremioService.ts
@@ -339,11 +339,9 @@ class StremioService {
}
}
- // Install Cinemeta for new users, but allow existing users to uninstall it
+ // Ensure Cinemeta is always installed as a pre-installed addon
const cinemetaId = 'com.linvo.cinemeta';
- const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId);
-
- if (!this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemeta) {
+ if (!this.installedAddons.has(cinemetaId)) {
try {
const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json');
this.installedAddons.set(cinemetaId, cinemetaManifest);
@@ -434,9 +432,8 @@ class StremioService {
this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id));
}
- // Add Cinemeta to order only if user hasn't removed it
- const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId);
- if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemetaOrder) {
+ // Ensure required pre-installed addons are present without forcing their position
+ if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId)) {
this.addonOrder.push(cinemetaId);
}
@@ -576,6 +573,7 @@ class StremioService {
await this.saveInstalledAddons();
await this.saveAddonOrder();
+ try { (require('./SyncService').syncService as any).pushAddons?.(); } catch {}
// Emit an event that an addon was added
addonEmitter.emit(ADDON_EVENTS.ADDON_ADDED, manifest.id);
} else {
@@ -598,6 +596,7 @@ class StremioService {
// Persist removals before app possibly exits
await this.saveInstalledAddons();
await this.saveAddonOrder();
+ try { (require('./SyncService').syncService as any).pushAddons?.(); } catch {}
// Emit an event that an addon was removed
addonEmitter.emit(ADDON_EVENTS.ADDON_REMOVED, id);
}
@@ -755,11 +754,13 @@ class StremioService {
const urlPathStyle = `${baseUrl}/catalog/${type}/${encodedId}/skip=${pageSkip}.json${queryParams ? `?${queryParams}` : ''}`;
// Add filters to path style (append with & or ? based on presence of queryParams)
const urlPathWithFilters = urlPathStyle + (urlPathStyle.includes('?') ? filterQuery : (filterQuery ? `?${filterQuery.slice(1)}` : ''));
+ try { logger.log('[StremioService] getCatalog URL (path-style)', { url: urlPathWithFilters, page, pageSize: this.DEFAULT_PAGE_SIZE, type, id, filters, manifest: manifest.id }); } catch {}
// Candidate 2: Query-style skip URL: /catalog/{type}/{id}.json?skip={N}&limit={PAGE_SIZE}
let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${this.DEFAULT_PAGE_SIZE}`;
if (queryParams) urlQueryStyle += `&${queryParams}`;
urlQueryStyle += filterQuery;
+ try { logger.log('[StremioService] getCatalog URL (query-style)', { url: urlQueryStyle, page, pageSize: this.DEFAULT_PAGE_SIZE, type, id, filters, manifest: manifest.id }); } catch {}
// Try path-style first, then fallback to query-style
let response;
@@ -778,6 +779,7 @@ class StremioService {
try {
const key = `${manifest.id}|${type}|${id}`;
if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore);
+ logger.log('[StremioService] getCatalog response meta', { hasMore, count: Array.isArray(response.data.metas) ? response.data.metas.length : 0 });
} catch {}
if (response.data.metas && Array.isArray(response.data.metas)) {
return response.data.metas;
@@ -796,18 +798,23 @@ class StremioService {
}
async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise {
+ console.log(`🔍 [StremioService] getMetaDetails called:`, { type, id, preferredAddonId });
try {
// Validate content ID first
const isValidId = await this.isValidContentId(type, id);
+ console.log(`🔍 [StremioService] Content ID validation:`, { type, id, isValidId });
if (!isValidId) {
+ console.log(`🔍 [StremioService] Invalid content ID, returning null`);
return null;
}
const addons = this.getInstalledAddons();
+ console.log(`🔍 [StremioService] Found ${addons.length} installed addons`);
// If a preferred addon is specified, try it first
if (preferredAddonId) {
+ console.log(`🔍 [StremioService] Preferred addon specified:`, { preferredAddonId });
const preferredAddon = addons.find(addon => addon.id === preferredAddonId);
if (preferredAddon && preferredAddon.resources) {
@@ -852,26 +859,49 @@ class StremioService {
}
}
+ console.log(`🔍 [StremioService] Preferred addon support check:`, {
+ hasMetaSupport,
+ supportsIdPrefix,
+ addonId: preferredAddon.id,
+ addonName: preferredAddon.name,
+ hasDeclaredPrefixes: preferredAddon.idPrefixes && preferredAddon.idPrefixes.length > 0
+ });
// Only require ID prefix compatibility if the addon has declared specific prefixes
const requiresIdPrefix = preferredAddon.idPrefixes && preferredAddon.idPrefixes.length > 0;
const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
if (isSupported) {
+ console.log(`🔍 [StremioService] Requesting metadata from preferred addon:`, { url });
try {
const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 });
});
+ console.log(`🔍 [StremioService] Preferred addon response:`, {
+ hasData: !!response.data,
+ hasMeta: !!response.data?.meta,
+ metaId: response.data?.meta?.id,
+ metaName: response.data?.meta?.name
+ });
if (response.data && response.data.meta) {
+ console.log(`🔍 [StremioService] Successfully got metadata from preferred addon`);
return response.data.meta;
} else {
+ console.log(`🔍 [StremioService] Preferred addon returned no metadata`);
}
} catch (error: any) {
+ console.log(`🔍 [StremioService] Preferred addon request failed:`, {
+ errorMessage: error.message,
+ isAxiosError: error.isAxiosError,
+ responseStatus: error.response?.status,
+ responseData: error.response?.data
+ });
// Continue trying other addons
}
} else {
+ console.log(`🔍 [StremioService] Preferred addon doesn't support this content type${requiresIdPrefix ? ' or ID prefix' : ''}`);
}
}
}
@@ -882,23 +912,40 @@ class StremioService {
'http://v3-cinemeta.strem.io'
];
+ console.log(`🔍 [StremioService] Trying Cinemeta URLs:`, { cinemetaUrls });
for (const baseUrl of cinemetaUrls) {
try {
const encodedId = encodeURIComponent(id);
const url = `${baseUrl}/meta/${type}/${encodedId}.json`;
+ console.log(`🔍 [StremioService] Requesting from Cinemeta:`, { url });
const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 });
});
+ console.log(`🔍 [StremioService] Cinemeta response:`, {
+ hasData: !!response.data,
+ hasMeta: !!response.data?.meta,
+ metaId: response.data?.meta?.id,
+ metaName: response.data?.meta?.name
+ });
if (response.data && response.data.meta) {
+ console.log(`🔍 [StremioService] Successfully got metadata from Cinemeta`);
return response.data.meta;
} else {
+ console.log(`🔍 [StremioService] Cinemeta returned no metadata`);
}
} catch (error: any) {
+ console.log(`🔍 [StremioService] Cinemeta request failed:`, {
+ baseUrl,
+ errorMessage: error.message,
+ isAxiosError: error.isAxiosError,
+ responseStatus: error.response?.status,
+ responseData: error.response?.data
+ });
continue; // Try next URL
}
}
@@ -944,12 +991,20 @@ class StremioService {
}
// Require meta support, but allow any ID if addon doesn't declare specific prefixes
+ console.log(`🔍 [StremioService] Addon support check:`, {
+ addonId: addon.id,
+ addonName: addon.name,
+ hasMetaSupport,
+ supportsIdPrefix,
+ hasDeclaredPrefixes: addon.idPrefixes && addon.idPrefixes.length > 0
+ });
// Only require ID prefix compatibility if the addon has declared specific prefixes
const requiresIdPrefix = addon.idPrefixes && addon.idPrefixes.length > 0;
const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
if (!isSupported) {
+ console.log(`🔍 [StremioService] Addon doesn't support this content type${requiresIdPrefix ? ' or ID prefix' : ''}, skipping`);
continue;
}
@@ -958,23 +1013,52 @@ class StremioService {
const encodedId = encodeURIComponent(id);
const url = queryParams ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/meta/${type}/${encodedId}.json`;
+ console.log(`🔍 [StremioService] Requesting from addon:`, {
+ addonId: addon.id,
+ addonName: addon.name,
+ url
+ });
const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 });
});
+ console.log(`🔍 [StremioService] Addon response:`, {
+ addonId: addon.id,
+ hasData: !!response.data,
+ hasMeta: !!response.data?.meta,
+ metaId: response.data?.meta?.id,
+ metaName: response.data?.meta?.name
+ });
if (response.data && response.data.meta) {
+ console.log(`🔍 [StremioService] Successfully got metadata from addon:`, { addonId: addon.id });
return response.data.meta;
} else {
+ console.log(`🔍 [StremioService] Addon returned no metadata:`, { addonId: addon.id });
}
} catch (error: any) {
+ console.log(`🔍 [StremioService] Addon request failed:`, {
+ addonId: addon.id,
+ addonName: addon.name,
+ errorMessage: error.message,
+ isAxiosError: error.isAxiosError,
+ responseStatus: error.response?.status,
+ responseData: error.response?.data
+ });
continue; // Try next addon
}
}
+ console.log(`🔍 [StremioService] No metadata found from any addon`);
return null;
} catch (error) {
+ console.log(`🔍 [StremioService] getMetaDetails caught error:`, {
+ errorMessage: error instanceof Error ? error.message : String(error),
+ isAxiosError: (error as any)?.isAxiosError,
+ responseStatus: (error as any)?.response?.status,
+ responseData: (error as any)?.response?.data
+ });
logger.error('Error in getMetaDetails:', error);
return null;
}
@@ -1018,21 +1102,15 @@ class StremioService {
// Filter episodes to only include those within our date range
// This is done immediately after fetching to reduce memory footprint
-
const filteredEpisodes = metadata.videos
.filter(video => {
- if (!video.released) {
- logger.log(`[StremioService] Episode ${video.id} has no release date`);
- return false;
- }
+ if (!video.released) return false;
const releaseDate = new Date(video.released);
- const inRange = releaseDate >= startDate && releaseDate <= endDate;
- return inRange;
+ return releaseDate >= startDate && releaseDate <= endDate;
})
.sort((a, b) => new Date(a.released).getTime() - new Date(b.released).getTime())
.slice(0, maxEpisodes); // Limit number of episodes to prevent memory overflow
-
return {
seriesName: metadata.name,
poster: metadata.poster || '',
@@ -1556,9 +1634,11 @@ class StremioService {
const index = this.addonOrder.indexOf(id);
if (index > 0) {
// Swap with the previous item
- [this.addonOrder[index - 1], this.addonOrder[index]] =
+ [this.addonOrder[index - 1], this.addonOrder[index]] =
[this.addonOrder[index], this.addonOrder[index - 1]];
this.saveAddonOrder();
+ // Immediately push to server to avoid resets on restart
+ try { (require('./SyncService').syncService as any).pushAddons?.(); } catch {}
// Emit an event that the order has changed
addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED);
return true;
@@ -1570,9 +1650,11 @@ class StremioService {
const index = this.addonOrder.indexOf(id);
if (index >= 0 && index < this.addonOrder.length - 1) {
// Swap with the next item
- [this.addonOrder[index], this.addonOrder[index + 1]] =
+ [this.addonOrder[index], this.addonOrder[index + 1]] =
[this.addonOrder[index + 1], this.addonOrder[index]];
this.saveAddonOrder();
+ // Immediately push to server to avoid resets on restart
+ try { (require('./SyncService').syncService as any).pushAddons?.(); } catch {}
// Emit an event that the order has changed
addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED);
return true;
diff --git a/src/services/supabaseClient.ts b/src/services/supabaseClient.ts
new file mode 100644
index 0000000..bf3949b
--- /dev/null
+++ b/src/services/supabaseClient.ts
@@ -0,0 +1,24 @@
+import 'react-native-url-polyfill/auto';
+import 'react-native-get-random-values';
+import { createClient } from '@supabase/supabase-js';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+
+const SUPABASE_URL = process.env.EXPO_PUBLIC_SUPABASE_URL;
+const SUPABASE_ANON_KEY = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
+
+if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
+ throw new Error('Missing Supabase environment variables. Please check your .env file.');
+}
+
+export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
+ auth: {
+ persistSession: true,
+ storage: AsyncStorage as unknown as Storage,
+ autoRefreshToken: true,
+ detectSessionInUrl: false,
+ },
+});
+
+export default supabase;
+
diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts
index 89c3a65..a7ecc9a 100644
--- a/src/services/tmdbService.ts
+++ b/src/services/tmdbService.ts
@@ -77,32 +77,6 @@ export interface TMDBTrendingResult {
};
}
-export interface TMDBCollection {
- id: number;
- name: string;
- overview: string;
- poster_path: string | null;
- backdrop_path: string | null;
- parts: TMDBCollectionPart[];
-}
-
-export interface TMDBCollectionPart {
- id: number;
- title: string;
- overview: string;
- poster_path: string | null;
- backdrop_path: string | null;
- release_date: string;
- adult: boolean;
- video: boolean;
- vote_average: number;
- vote_count: number;
- genre_ids: number[];
- original_language: string;
- original_title: string;
- popularity: number;
-}
-
export class TMDBService {
private static instance: TMDBService;
private static ratingCache: Map = new Map();
@@ -630,41 +604,6 @@ export class TMDBService {
}
}
- /**
- * Get collection details by collection ID
- */
- async getCollectionDetails(collectionId: number, language: string = 'en'): Promise {
- try {
- const response = await axios.get(`${BASE_URL}/collection/${collectionId}`, {
- headers: await this.getHeaders(),
- params: await this.getParams({
- language,
- }),
- });
- return response.data;
- } catch (error) {
- return null;
- }
- }
-
- /**
- * Get collection images by collection ID
- */
- async getCollectionImages(collectionId: number, language: string = 'en'): Promise {
- try {
- const response = await axios.get(`${BASE_URL}/collection/${collectionId}/images`, {
- headers: await this.getHeaders(),
- params: await this.getParams({
- language,
- include_image_language: `${language},en,null`
- }),
- });
- return response.data;
- } catch (error) {
- return null;
- }
- }
-
/**
* Get movie images (logos, posters, backdrops) by TMDB ID - returns full images object
*/
diff --git a/src/services/toastService.ts b/src/services/toastService.ts
deleted file mode 100644
index dc81093..0000000
--- a/src/services/toastService.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-import { ToastConfig } from '../components/ui/Toast';
-
-class ToastService {
- private static instance: ToastService;
- private toasts: ToastConfig[] = [];
- private listeners: Array<(toasts: ToastConfig[]) => void> = [];
- private idCounter = 0;
-
- private constructor() {}
-
- static getInstance(): ToastService {
- if (!ToastService.instance) {
- ToastService.instance = new ToastService();
- }
- return ToastService.instance;
- }
-
- private generateId(): string {
- return `toast_${++this.idCounter}_${Date.now()}`;
- }
-
- private notifyListeners(): void {
- this.listeners.forEach(listener => listener([...this.toasts]));
- }
-
- subscribe(listener: (toasts: ToastConfig[]) => void): () => void {
- this.listeners.push(listener);
- // Immediately call with current toasts
- listener([...this.toasts]);
-
- // Return unsubscribe function
- return () => {
- const index = this.listeners.indexOf(listener);
- if (index > -1) {
- this.listeners.splice(index, 1);
- }
- };
- }
-
- private addToast(config: Omit): string {
- const id = this.generateId();
- const toast: ToastConfig = {
- id,
- duration: 4000,
- position: 'top',
- ...config,
- };
-
- this.toasts.push(toast);
- this.notifyListeners();
- return id;
- }
-
- success(title: string, message?: string, options?: Partial): string {
- return this.addToast({
- type: 'success',
- title,
- message,
- ...options,
- });
- }
-
- error(title: string, message?: string, options?: Partial): string {
- return this.addToast({
- type: 'error',
- title,
- message,
- duration: 6000, // Longer duration for errors
- ...options,
- });
- }
-
- warning(title: string, message?: string, options?: Partial): string {
- return this.addToast({
- type: 'warning',
- title,
- message,
- ...options,
- });
- }
-
- info(title: string, message?: string, options?: Partial): string {
- return this.addToast({
- type: 'info',
- title,
- message,
- ...options,
- });
- }
-
- custom(config: Omit): string {
- return this.addToast(config);
- }
-
- remove(id: string): void {
- this.toasts = this.toasts.filter(toast => toast.id !== id);
- this.notifyListeners();
- }
-
- removeAll(): void {
- this.toasts = [];
- this.notifyListeners();
- }
-
- // Convenience methods for common use cases
- showSaved(): string {
- return this.success('Saved', 'Added to your library');
- }
-
- showRemoved(): string {
- return this.info('Removed', 'Removed from your library');
- }
-
- showTraktSaved(): string {
- return this.success('Saved to Trakt', 'Added to watchlist and library');
- }
-
- showTraktRemoved(): string {
- return this.info('Removed from Trakt', 'Removed from watchlist');
- }
-
- showNetworkError(): string {
- return this.error(
- 'Network Error',
- 'Please check your internet connection',
- { duration: 8000 }
- );
- }
-
- showAuthError(): string {
- return this.error(
- 'Authentication Error',
- 'Please log in to Trakt again',
- { duration: 8000 }
- );
- }
-
- showSyncSuccess(count: number): string {
- return this.success(
- 'Sync Complete',
- `Synced ${count} items to Trakt`,
- { duration: 3000 }
- );
- }
-
- showProgressSaved(): string {
- return this.success('Progress Saved', 'Your watch progress has been synced');
- }
-}
-
-export const toastService = ToastService.getInstance();
-export default toastService;
-
diff --git a/src/services/trailerService.ts b/src/services/trailerService.ts
index ad6b405..fa4c736 100644
--- a/src/services/trailerService.ts
+++ b/src/services/trailerService.ts
@@ -53,31 +53,29 @@ export class TrailerService {
* @returns Promise - The trailer URL or null if not found
*/
private static async getTrailerFromLocalServer(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise {
- const startTime = Date.now();
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
-
- // Build URL with parameters
- const params = new URLSearchParams();
-
- // Always send title and year for logging and fallback
- params.append('title', title);
- params.append('year', year.toString());
-
- if (tmdbId) {
- params.append('tmdbId', tmdbId);
- params.append('type', type || 'movie');
- logger.info('TrailerService', `Using TMDB API for: ${title} (TMDB ID: ${tmdbId})`);
- } else {
- logger.info('TrailerService', `Auto-searching trailer for: ${title} (${year})`);
- }
-
- const url = `${this.AUTO_SEARCH_URL}?${params.toString()}`;
- logger.info('TrailerService', `Local server request URL: ${url}`);
- logger.info('TrailerService', `Local server timeout set to ${this.TIMEOUT}ms`);
- logger.info('TrailerService', `Making fetch request to: ${url}`);
-
try {
+ const startTime = Date.now();
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
+
+ // Build URL with parameters
+ const params = new URLSearchParams();
+
+ // Always send title and year for logging and fallback
+ params.append('title', title);
+ params.append('year', year.toString());
+
+ if (tmdbId) {
+ params.append('tmdbId', tmdbId);
+ params.append('type', type || 'movie');
+ logger.info('TrailerService', `Using TMDB API for: ${title} (TMDB ID: ${tmdbId})`);
+ } else {
+ logger.info('TrailerService', `Auto-searching trailer for: ${title} (${year})`);
+ }
+
+ const url = `${this.AUTO_SEARCH_URL}?${params.toString()}`;
+ logger.info('TrailerService', `Local server request URL: ${url}`);
+ logger.info('TrailerService', `Local server timeout set to ${this.TIMEOUT}ms`);
const response = await fetch(url, {
method: 'GET',
@@ -87,8 +85,6 @@ export class TrailerService {
},
signal: controller.signal,
});
-
- logger.info('TrailerService', `Fetch request completed. Response status: ${response.status}`);
clearTimeout(timeoutId);
@@ -141,12 +137,6 @@ export class TrailerService {
} else {
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
logger.error('TrailerService', `Error in auto-search: ${msg}`);
- logger.error('TrailerService', `Error details:`, {
- name: (error as any)?.name,
- message: (error as any)?.message,
- stack: (error as any)?.stack,
- url: url
- });
}
return null; // Return null to trigger XPrime fallback
}
diff --git a/src/services/traktService.ts b/src/services/traktService.ts
index b356147..3fdb1b3 100644
--- a/src/services/traktService.ts
+++ b/src/services/traktService.ts
@@ -562,7 +562,7 @@ export class TraktService {
// Rate limiting - Optimized for real-time scrobbling
private lastApiCall: number = 0;
- private readonly MIN_API_INTERVAL = 500; // Reduced to 500ms for faster updates
+ private readonly MIN_API_INTERVAL = 1000; // Reduced from 3000ms to 1000ms for real-time updates
private requestQueue: Array<() => Promise> = [];
private isProcessingQueue: boolean = false;
@@ -1212,10 +1212,10 @@ export class TraktService {
// Try multiple search approaches
const searchUrls = [
- `${TRAKT_API_URL}/search/${type === 'show' ? 'show' : type}?id_type=imdb&id=${cleanImdbId}`,
- `${TRAKT_API_URL}/search/${type === 'show' ? 'show' : type}?query=${encodeURIComponent(cleanImdbId)}&id_type=imdb`,
+ `${TRAKT_API_URL}/search/${type}?id_type=imdb&id=${cleanImdbId}`,
+ `${TRAKT_API_URL}/search/${type}?query=${encodeURIComponent(cleanImdbId)}&id_type=imdb`,
// Also try with the full tt-prefixed ID in case the API accepts it
- `${TRAKT_API_URL}/search/${type === 'show' ? 'show' : type}?id_type=imdb&id=tt${cleanImdbId}`
+ `${TRAKT_API_URL}/search/${type}?id_type=imdb&id=tt${cleanImdbId}`
];
for (const searchUrl of searchUrls) {
@@ -1240,7 +1240,7 @@ export class TraktService {
logger.log(`[TraktService] Search response data:`, data);
if (data && data.length > 0) {
- const traktId = data[0][type === 'show' ? 'show' : type]?.ids?.trakt;
+ const traktId = data[0][type]?.ids?.trakt;
if (traktId) {
logger.log(`[TraktService] Found Trakt ID: ${traktId} for IMDb ID: ${cleanImdbId}`);
return traktId;
@@ -1740,8 +1740,8 @@ export class TraktService {
const watchingKey = this.getWatchingKey(contentData);
const lastSync = this.lastSyncTimes.get(watchingKey) || 0;
- // IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 100ms)
- if (!force && (now - lastSync) < 100) {
+ // IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 300ms)
+ if (!force && (now - lastSync) < 300) {
return true; // Skip this sync, but return success
}
@@ -1791,12 +1791,13 @@ export class TraktService {
// Record this stop attempt
this.lastStopCalls.set(watchingKey, now);
- // Use pause if below user threshold, stop only when ready to scrobble
- const useStop = progress >= this.completionThreshold;
+ // Respect higher user threshold by pausing below effective threshold
+ const effectiveThreshold = Math.max(80, this.completionThreshold);
const result = await this.queueRequest(async () => {
- return useStop
- ? await this.stopWatching(contentData, progress)
- : await this.pauseWatching(contentData, progress);
+ if (progress < effectiveThreshold) {
+ return await this.pauseWatching(contentData, progress);
+ }
+ return await this.stopWatching(contentData, progress);
});
if (result) {
@@ -1809,8 +1810,7 @@ export class TraktService {
logger.log(`[TraktService] Marked as scrobbled to prevent restarts: ${watchingKey}`);
}
- // Action reflects actual endpoint used based on user threshold
- const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused';
+ const action = progress >= effectiveThreshold ? 'scrobbled' : 'paused';
logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
return true;
@@ -1889,11 +1889,11 @@ export class TraktService {
this.lastStopCalls.set(watchingKey, Date.now());
- // BYPASS QUEUE: Use pause if below user threshold, stop only when ready to scrobble
- const useStop = progress >= this.completionThreshold;
- const result = useStop
- ? await this.stopWatching(contentData, progress)
- : await this.pauseWatching(contentData, progress);
+ // BYPASS QUEUE: Respect higher user threshold by pausing below effective threshold
+ const effectiveThreshold = Math.max(80, this.completionThreshold);
+ const result = progress < effectiveThreshold
+ ? await this.pauseWatching(contentData, progress)
+ : await this.stopWatching(contentData, progress);
if (result) {
this.currentlyWatching.delete(watchingKey);
@@ -1904,8 +1904,7 @@ export class TraktService {
this.scrobbledTimestamps.set(watchingKey, Date.now());
}
- // Action reflects actual endpoint used based on user threshold
- const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused';
+ const action = progress >= effectiveThreshold ? 'scrobbled' : 'paused';
logger.log(`[TraktService] IMMEDIATE: Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
return true;
@@ -2339,7 +2338,7 @@ export class TraktService {
try {
logger.log(`[TraktService] Searching Trakt for ${type} with TMDB ID: ${tmdbId}`);
- const response = await fetch(`${TRAKT_API_URL}/search/${type === 'show' ? 'show' : type}?id_type=tmdb&id=${tmdbId}`, {
+ const response = await fetch(`${TRAKT_API_URL}/search/${type}?id_type=tmdb&id=${tmdbId}`, {
headers: {
'Content-Type': 'application/json',
'trakt-api-version': '2',
@@ -2356,7 +2355,7 @@ export class TraktService {
const data = await response.json();
logger.log(`[TraktService] TMDB search response:`, data);
if (data && data.length > 0) {
- const traktId = data[0][type === 'show' ? 'show' : type]?.ids?.trakt;
+ const traktId = data[0][type]?.ids?.trakt;
if (traktId) {
logger.log(`[TraktService] Found Trakt ID via TMDB: ${traktId} for TMDB ID: ${tmdbId}`);
return traktId;
@@ -2463,162 +2462,6 @@ export class TraktService {
}
}
- /**
- * Add content to Trakt watchlist
- */
- public async addToWatchlist(imdbId: string, type: 'movie' | 'show'): Promise {
- try {
- if (!await this.isAuthenticated()) {
- return false;
- }
-
- // Ensure IMDb ID includes the 'tt' prefix
- const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
-
- const payload = type === 'movie'
- ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] }
- : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] };
-
- await this.apiRequest('/sync/watchlist', 'POST', payload);
- logger.log(`[TraktService] Added ${type} to watchlist: ${imdbId}`);
- return true;
- } catch (error) {
- logger.error(`[TraktService] Failed to add ${type} to watchlist:`, error);
- return false;
- }
- }
-
- /**
- * Remove content from Trakt watchlist
- */
- public async removeFromWatchlist(imdbId: string, type: 'movie' | 'show'): Promise {
- try {
- if (!await this.isAuthenticated()) {
- return false;
- }
-
- // Ensure IMDb ID includes the 'tt' prefix
- const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
-
- const payload = type === 'movie'
- ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] }
- : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] };
-
- await this.apiRequest('/sync/watchlist/remove', 'POST', payload);
- logger.log(`[TraktService] Removed ${type} from watchlist: ${imdbId}`);
- return true;
- } catch (error) {
- logger.error(`[TraktService] Failed to remove ${type} from watchlist:`, error);
- return false;
- }
- }
-
- /**
- * Add content to Trakt collection
- */
- public async addToCollection(imdbId: string, type: 'movie' | 'show'): Promise {
- try {
- if (!await this.isAuthenticated()) {
- return false;
- }
-
- // Ensure IMDb ID includes the 'tt' prefix
- const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
-
- const payload = type === 'movie'
- ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] }
- : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] };
-
- await this.apiRequest('/sync/collection', 'POST', payload);
- logger.log(`[TraktService] Added ${type} to collection: ${imdbId}`);
- return true;
- } catch (error) {
- logger.error(`[TraktService] Failed to add ${type} to collection:`, error);
- return false;
- }
- }
-
- /**
- * Remove content from Trakt collection
- */
- public async removeFromCollection(imdbId: string, type: 'movie' | 'show'): Promise {
- try {
- if (!await this.isAuthenticated()) {
- return false;
- }
-
- // Ensure IMDb ID includes the 'tt' prefix
- const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
-
- const payload = type === 'movie'
- ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] }
- : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] };
-
- await this.apiRequest('/sync/collection/remove', 'POST', payload);
- logger.log(`[TraktService] Removed ${type} from collection: ${imdbId}`);
- return true;
- } catch (error) {
- logger.error(`[TraktService] Failed to remove ${type} from collection:`, error);
- return false;
- }
- }
-
- /**
- * Check if content is in Trakt watchlist
- */
- public async isInWatchlist(imdbId: string, type: 'movie' | 'show'): Promise {
- try {
- if (!await this.isAuthenticated()) {
- return false;
- }
-
- // Ensure IMDb ID includes the 'tt' prefix
- const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
-
- const watchlistItems = type === 'movie'
- ? await this.getWatchlistMovies()
- : await this.getWatchlistShows();
-
- return watchlistItems.some(item => {
- const itemImdbId = type === 'movie'
- ? item.movie?.ids?.imdb
- : item.show?.ids?.imdb;
- return itemImdbId === imdbIdWithPrefix;
- });
- } catch (error) {
- logger.error(`[TraktService] Failed to check if ${type} is in watchlist:`, error);
- return false;
- }
- }
-
- /**
- * Check if content is in Trakt collection
- */
- public async isInCollection(imdbId: string, type: 'movie' | 'show'): Promise {
- try {
- if (!await this.isAuthenticated()) {
- return false;
- }
-
- // Ensure IMDb ID includes the 'tt' prefix
- const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
-
- const collectionItems = type === 'movie'
- ? await this.getCollectionMovies()
- : await this.getCollectionShows();
-
- return collectionItems.some(item => {
- const itemImdbId = type === 'movie'
- ? item.movie?.ids?.imdb
- : item.show?.ids?.imdb;
- return itemImdbId === imdbIdWithPrefix;
- });
- } catch (error) {
- logger.error(`[TraktService] Failed to check if ${type} is in collection:`, error);
- return false;
- }
- }
-
/**
* Handle app state changes to reduce memory pressure
*/
diff --git a/src/utils/version.ts b/src/utils/version.ts
index df5a2ca..f29182b 100644
--- a/src/utils/version.ts
+++ b/src/utils/version.ts
@@ -1,7 +1,7 @@
// Single source of truth for the app version displayed in Settings
// Update this when bumping app version
-export const APP_VERSION = '1.2.6';
+export const APP_VERSION = '1.2.5';
export function getDisplayedAppVersion(): string {
return APP_VERSION;