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;