Merge branch 'main' into patch-5

This commit is contained in:
AdityasahuX07 2025-12-19 23:39:40 +05:30 committed by GitHub
commit 104d0f4516
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 6910 additions and 4270 deletions

2
.gitignore vendored
View file

@ -85,3 +85,5 @@ node_modules
expofs.md
ios/sentry.properties
android/sentry.properties
Stremio addons refer
trakt-docs

View file

@ -95,8 +95,8 @@ android {
applicationId 'com.nuvio.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 25
versionName "1.2.10"
versionCode 26
versionName "1.2.11"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
@ -118,7 +118,7 @@ android {
def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
applicationVariants.all { variant ->
variant.outputs.each { output ->
def baseVersionCode = 25 // Current versionCode 25 from defaultConfig
def baseVersionCode = 26 // Current versionCode 26 from defaultConfig
def abiName = output.getFilter(com.android.build.OutputFile.ABI)
def versionCode = baseVersionCode * 100 // Base multiplier

View file

@ -3,5 +3,5 @@
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
<string name="expo_runtime_version">1.2.10</string>
<string name="expo_runtime_version">1.2.11</string>
</resources>

View file

@ -1,4 +1,4 @@
defaults.url=https://sentry.io/
defaults.org=tapframe
defaults.project=react-native
auth.token=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
auth.token=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

View file

@ -2,7 +2,7 @@
"expo": {
"name": "Nuvio",
"slug": "nuvio",
"version": "1.2.10",
"version": "1.2.11",
"orientation": "default",
"backgroundColor": "#020404",
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
@ -18,7 +18,7 @@
"supportsTablet": true,
"requireFullScreen": true,
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
"buildNumber": "25",
"buildNumber": "26",
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
@ -52,7 +52,7 @@
"android.permission.WRITE_SETTINGS"
],
"package": "com.nuvio.app",
"versionCode": 25,
"versionCode": 26,
"architectures": [
"arm64-v8a",
"armeabi-v7a",
@ -105,6 +105,6 @@
"fallbackToCacheTimeout": 30000,
"url": "https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest"
},
"runtimeVersion": "1.2.10"
"runtimeVersion": "1.2.11"
}
}

View file

@ -48,8 +48,8 @@ RCT_EXTERN_METHOD(showAirPlayPicker:(nonnull NSNumber *)node)
@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)
RCT_EXTERN_METHOD(getTracks:(NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getAirPlayState:(NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(showAirPlayPicker:(NSNumber *)nodeTag)
@end

View file

@ -25,7 +25,11 @@ class KSPlayerModule: RCTEventEmitter {
]
}
@objc func getTracks(_ nodeTag: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
@objc func getTracks(_ nodeTag: NSNumber?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
guard let nodeTag = nodeTag else {
reject("INVALID_ARGUMENT", "nodeTag must not be nil", nil)
return
}
DispatchQueue.main.async {
if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {
viewManager.getTracks(nodeTag, resolve: resolve, reject: reject)
@ -35,7 +39,11 @@ class KSPlayerModule: RCTEventEmitter {
}
}
@objc func getAirPlayState(_ nodeTag: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
@objc func getAirPlayState(_ nodeTag: NSNumber?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
guard let nodeTag = nodeTag else {
reject("INVALID_ARGUMENT", "nodeTag must not be nil", nil)
return
}
DispatchQueue.main.async {
if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {
viewManager.getAirPlayState(nodeTag, resolve: resolve, reject: reject)
@ -45,7 +53,11 @@ class KSPlayerModule: RCTEventEmitter {
}
}
@objc func showAirPlayPicker(_ nodeTag: NSNumber) {
@objc func showAirPlayPicker(_ nodeTag: NSNumber?) {
guard let nodeTag = nodeTag else {
print("[KSPlayerModule] showAirPlayPicker called with nil nodeTag")
return
}
print("[KSPlayerModule] showAirPlayPicker called for nodeTag: \(nodeTag)")
DispatchQueue.main.async {
if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {

View file

@ -413,14 +413,12 @@
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Nuvio/Pods-Nuvio-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/MobileVLCKit/MobileVLCKit.framework/MobileVLCKit",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MobileVLCKit.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
@ -477,7 +475,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
PRODUCT_NAME = Nuvio;
PRODUCT_NAME = "Nuvio";
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@ -508,8 +506,8 @@
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
PRODUCT_NAME = Nuvio;
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
PRODUCT_NAME = "Nuvio";
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";

View file

@ -1,99 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nuvio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.10</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>nuvio</string>
<string>com.nuvio.app</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+nuvio</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>25</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>RCTNewArchEnabled</key>
<true/>
<key>RCTRootViewBackgroundColor</key>
<integer>4278322180</integer>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nuvio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.11</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>nuvio</string>
<string>com.nuvio.app</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+nuvio</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>26</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your local network</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require microphone access.</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>RCTRootViewBackgroundColor</key>
<integer>4278322180</integer>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View file

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

View file

@ -9,7 +9,7 @@
<key>EXUpdatesLaunchWaitMs</key>
<integer>30000</integer>
<key>EXUpdatesRuntimeVersion</key>
<string>1.2.10</string>
<string>1.2.11</string>
<key>EXUpdatesURL</key>
<string>https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest</string>
</dict>

View file

@ -21,7 +21,7 @@ platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
prepare_react_native_project!
target 'Nuvio' do
use_expo_modules!
use_expo_modules!(exclude: ['expo-libvlc-player'])
if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1'
config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"];

View file

@ -1,17 +1,17 @@
PODS:
- DisplayCriteria (1.1.0)
- EASClient (1.0.7):
- EASClient (1.0.8):
- ExpoModulesCore
- EXApplication (7.0.7):
- EXApplication (7.0.8):
- ExpoModulesCore
- EXConstants (18.0.10):
- EXConstants (18.0.12):
- ExpoModulesCore
- EXJSONUtils (0.15.0)
- EXManifests (1.0.8):
- EXManifests (1.0.10):
- ExpoModulesCore
- EXNotifications (0.32.12):
- EXNotifications (0.32.15):
- ExpoModulesCore
- Expo (54.0.23):
- Expo (54.0.29):
- ExpoModulesCore
- hermes-engine
- RCTRequired
@ -36,15 +36,15 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-client (6.0.17):
- expo-dev-client (6.0.20):
- EXManifests
- expo-dev-launcher
- expo-dev-menu
- expo-dev-menu-interface
- EXUpdatesInterface
- expo-dev-launcher (6.0.17):
- expo-dev-launcher (6.0.20):
- EXManifests
- expo-dev-launcher/Main (= 6.0.17)
- expo-dev-launcher/Main (= 6.0.20)
- expo-dev-menu
- expo-dev-menu-interface
- ExpoModulesCore
@ -73,7 +73,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-launcher/Main (6.0.17):
- expo-dev-launcher/Main (6.0.20):
- EXManifests
- expo-dev-launcher/Unsafe
- expo-dev-menu
@ -104,7 +104,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-launcher/Unsafe (6.0.17):
- expo-dev-launcher/Unsafe (6.0.20):
- EXManifests
- expo-dev-menu
- expo-dev-menu-interface
@ -134,9 +134,9 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-menu (7.0.16):
- expo-dev-menu/Main (= 7.0.16)
- expo-dev-menu/ReactNativeCompatibles (= 7.0.16)
- expo-dev-menu (7.0.18):
- expo-dev-menu/Main (= 7.0.18)
- expo-dev-menu/ReactNativeCompatibles (= 7.0.18)
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -159,7 +159,7 @@ PODS:
- ReactNativeDependencies
- Yoga
- expo-dev-menu-interface (2.0.0)
- expo-dev-menu/Main (7.0.16):
- expo-dev-menu/Main (7.0.18):
- EXManifests
- expo-dev-menu-interface
- ExpoModulesCore
@ -185,7 +185,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-menu/ReactNativeCompatibles (7.0.16):
- expo-dev-menu/ReactNativeCompatibles (7.0.18):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -207,38 +207,35 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- ExpoAsset (12.0.9):
- ExpoAsset (12.0.11):
- ExpoModulesCore
- ExpoBlur (15.0.7):
- ExpoBlur (15.0.8):
- ExpoModulesCore
- ExpoBrightness (14.0.7):
- ExpoBrightness (14.0.8):
- ExpoModulesCore
- ExpoCrypto (15.0.7):
- ExpoCrypto (15.0.8):
- ExpoModulesCore
- ExpoDevice (8.0.9):
- ExpoDevice (8.0.10):
- ExpoModulesCore
- ExpoDocumentPicker (14.0.7):
- ExpoDocumentPicker (14.0.8):
- ExpoModulesCore
- ExpoFileSystem (19.0.17):
- ExpoFileSystem (19.0.21):
- ExpoModulesCore
- ExpoFont (14.0.9):
- ExpoFont (14.0.10):
- ExpoModulesCore
- ExpoGlassEffect (0.1.7):
- ExpoGlassEffect (0.1.8):
- ExpoModulesCore
- ExpoHaptics (15.0.7):
- ExpoHaptics (15.0.8):
- ExpoModulesCore
- ExpoKeepAwake (15.0.7):
- ExpoKeepAwake (15.0.8):
- ExpoModulesCore
- ExpoLibVlcPlayer (2.2.3):
- ExpoLinearGradient (15.0.8):
- ExpoModulesCore
- MobileVLCKit (= 3.6.1b1)
- ExpoLinearGradient (15.0.7):
- ExpoLinking (8.0.10):
- ExpoModulesCore
- ExpoLinking (8.0.8):
- ExpoLocalization (17.0.8):
- ExpoModulesCore
- ExpoLocalization (17.0.7):
- ExpoModulesCore
- ExpoModulesCore (3.0.25):
- ExpoModulesCore (3.0.29):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -263,7 +260,7 @@ PODS:
- Yoga
- ExpoRandom (14.0.1):
- ExpoModulesCore
- ExpoScreenOrientation (9.0.7):
- ExpoScreenOrientation (9.0.8):
- ExpoModulesCore
- hermes-engine
- RCTRequired
@ -286,14 +283,14 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- ExpoSharing (14.0.7):
- ExpoSharing (14.0.8):
- ExpoModulesCore
- ExpoSystemUI (6.0.8):
- ExpoSystemUI (6.0.9):
- ExpoModulesCore
- ExpoWebBrowser (15.0.9):
- ExpoWebBrowser (15.0.10):
- ExpoModulesCore
- EXStructuredHeaders (5.0.0)
- EXUpdates (29.0.12):
- EXUpdates (29.0.15):
- EASClient
- EXManifests
- ExpoModulesCore
@ -332,7 +329,7 @@ PODS:
- hermes-engine (0.81.4):
- hermes-engine/Pre-built (= 0.81.4)
- hermes-engine/Pre-built (0.81.4)
- ImageColors (2.5.0):
- ImageColors (2.5.1):
- ExpoModulesCore
- KSPlayer (1.1.0):
- KSPlayer/Audio (= 1.1.0)
@ -406,8 +403,7 @@ PODS:
- ReactNativeDependencies
- Yoga
- MMKVCore (2.2.4)
- MobileVLCKit (3.6.1b1)
- NitroMmkv (4.0.0):
- NitroMmkv (4.1.0):
- hermes-engine
- MMKVCore (= 2.2.4)
- NitroModules
@ -432,7 +428,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- NitroModules (0.31.6):
- NitroModules (0.31.10):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -1758,7 +1754,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-bottom-tabs (1.0.2):
- react-native-bottom-tabs (1.1.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -1770,7 +1766,7 @@ PODS:
- React-graphics
- React-ImageManager
- React-jsi
- react-native-bottom-tabs/common (= 1.0.2)
- react-native-bottom-tabs/common (= 1.1.0)
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
@ -1782,7 +1778,7 @@ PODS:
- ReactNativeDependencies
- SwiftUIIntrospect (~> 1.0)
- Yoga
- react-native-bottom-tabs/common (1.0.2):
- react-native-bottom-tabs/common (1.1.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -1904,30 +1900,6 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-skia (2.3.13):
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React
- React-callinvoker
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-slider (5.1.1):
- hermes-engine
- RCTRequired
@ -1973,7 +1945,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-video (6.17.0):
- react-native-video (6.18.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -1985,7 +1957,7 @@ PODS:
- React-graphics
- React-ImageManager
- React-jsi
- react-native-video/Video (= 6.17.0)
- react-native-video/Video (= 6.18.0)
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
@ -1996,7 +1968,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-video/Fabric (6.17.0):
- react-native-video/Fabric (6.18.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2018,7 +1990,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-video/Video (6.17.0):
- react-native-video/Video (6.18.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2463,7 +2435,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNReanimated (4.1.5):
- RNReanimated (4.2.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2485,10 +2457,10 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNReanimated/reanimated (= 4.1.5)
- RNReanimated/reanimated (= 4.2.0)
- RNWorklets
- Yoga
- RNReanimated/reanimated (4.1.5):
- RNReanimated/reanimated (4.2.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2510,10 +2482,10 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNReanimated/reanimated/apple (= 4.1.5)
- RNReanimated/reanimated/apple (= 4.2.0)
- RNWorklets
- Yoga
- RNReanimated/reanimated/apple (4.1.5):
- RNReanimated/reanimated/apple (4.2.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2584,7 +2556,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNSentry (7.6.0):
- RNSentry (7.7.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2606,9 +2578,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Sentry/HybridSDK (= 8.57.2)
- Sentry/HybridSDK (= 8.57.3)
- Yoga
- RNSVG (15.15.0):
- RNSVG (15.15.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2629,9 +2601,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNSVG/common (= 15.15.0)
- RNSVG/common (= 15.15.1)
- Yoga
- RNSVG/common (15.15.0):
- RNSVG/common (15.15.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2675,7 +2647,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNWorklets (0.6.1):
- RNWorklets (0.7.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2697,9 +2669,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNWorklets/worklets (= 0.6.1)
- RNWorklets/worklets (= 0.7.1)
- Yoga
- RNWorklets/worklets (0.6.1):
- RNWorklets/worklets (0.7.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2721,9 +2693,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNWorklets/worklets/apple (= 0.6.1)
- RNWorklets/worklets/apple (= 0.7.1)
- Yoga
- RNWorklets/worklets/apple (0.6.1):
- RNWorklets/worklets/apple (0.7.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@ -2746,9 +2718,9 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- SDWebImage (5.21.3):
- SDWebImage/Core (= 5.21.3)
- SDWebImage/Core (5.21.3)
- SDWebImage (5.21.5):
- SDWebImage/Core (= 5.21.5)
- SDWebImage/Core (5.21.5)
- SDWebImageAVIFCoder (0.11.1):
- libavif/core (>= 0.11.0)
- SDWebImage (~> 5.10)
@ -2757,7 +2729,7 @@ PODS:
- SDWebImageWebPCoder (0.15.0):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
- Sentry/HybridSDK (8.57.2)
- Sentry/HybridSDK (8.57.3)
- SwiftUIIntrospect (1.3.0)
- Yoga (0.0.0)
@ -2785,7 +2757,6 @@ DEPENDENCIES:
- ExpoGlassEffect (from `../node_modules/expo-glass-effect/ios`)
- ExpoHaptics (from `../node_modules/expo-haptics/ios`)
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
- ExpoLibVlcPlayer (from `../node_modules/expo-libvlc-player/ios`)
- ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`)
- ExpoLinking (from `../node_modules/expo-linking/ios`)
- ExpoLocalization (from `../node_modules/expo-localization/ios`)
@ -2848,7 +2819,6 @@ DEPENDENCIES:
- react-native-google-cast (from `../node_modules/react-native-google-cast`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- "react-native-skia (from `../node_modules/@shopify/react-native-skia`)"
- "react-native-slider (from `../node_modules/@react-native-community/slider`)"
- react-native-video (from `../node_modules/react-native-video`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
@ -2901,7 +2871,6 @@ SPEC REPOS:
- libwebp
- lottie-ios
- MMKVCore
- MobileVLCKit
- PromisesObjC
- ReachabilitySwift
- SDWebImage
@ -2959,8 +2928,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-haptics/ios"
ExpoKeepAwake:
:path: "../node_modules/expo-keep-awake/ios"
ExpoLibVlcPlayer:
:path: "../node_modules/expo-libvlc-player/ios"
ExpoLinearGradient:
:path: "../node_modules/expo-linear-gradient/ios"
ExpoLinking:
@ -3087,8 +3054,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-community/netinfo"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-skia:
:path: "../node_modules/@shopify/react-native-skia"
react-native-slider:
:path: "../node_modules/@react-native-community/slider"
react-native-video:
@ -3178,13 +3143,13 @@ EXTERNAL SOURCES:
CHECKOUT OPTIONS:
DisplayCriteria:
:commit: cbc74996afb55e096bf1ff240f07d1d206ac86df
:commit: 101cceed0f2d9b6833ee69cf29b65a042de720a3
:git: https://github.com/kingslay/KSPlayer.git
FFmpegKit:
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
:git: https://github.com/kingslay/FFmpegKit.git
KSPlayer:
:commit: cbc74996afb55e096bf1ff240f07d1d206ac86df
:commit: 101cceed0f2d9b6833ee69cf29b65a042de720a3
:git: https://github.com/kingslay/KSPlayer.git
Libass:
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
@ -3192,46 +3157,45 @@ CHECKOUT OPTIONS:
SPEC CHECKSUMS:
DisplayCriteria: bb0a90faf14b30848bc50ac0516340ce50164187
EASClient: 68127f1248d2b25fdc82dbbfb17be95d1c4700be
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
EASClient: 40dd9e740684782610c49becab2643782ea1a20c
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
EXConstants: 805f35b1b295c542ca6acce836f21a1f9ee104d5
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
EXManifests: 224345a575fca389073c416297b6348163f28d1a
EXNotifications: 7cff475adb5d7a255a9ea46bbd2589cb3b454506
Expo: fb09185d798c2876a4c5ca89a5c6b8b72b6dbecf
expo-dev-client: b6e7b4f4063ae44b5e68cc6a8bcc0c79c3037c1a
expo-dev-launcher: c8813e0064e8768d676ee490c0f7ef1784d70b98
expo-dev-menu: 0a1194185c9eec1da0e507b734180775363be442
EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b
EXNotifications: 983f04ad4ad879b181179e326bf220541e478386
Expo: 8fa2204bf8483fe546b4ec87c90d3ca189afc8db
expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d
expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3
expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
ExpoAsset: 9ba6fbd677fb8e241a3899ac00fa735bc911eadf
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
ExpoBrightness: 32672952bf8b152d0cceaf8ec9f1def3a9a5e0d9
ExpoCrypto: c1fbce112d1b6b79652bbe380b4fd4cc91676595
ExpoDevice: 148accb4071873d19fba80a2506c58ffa433d620
ExpoDocumentPicker: 2200eefc2817f19315fa18f0147e0b80ece86926
ExpoFileSystem: b79eadbda7b7f285f378f95f959cc9313a1c9c61
ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
ExpoLibVlcPlayer: 6b4a27f54f5300550227cffcf25cc88ab4f6c7c9
ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27
ExpoLinking: f051f28e50ea9269ff539317c166adec81d9342d
ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
ExpoModulesCore: aa1a8e103d41de84baa5d7c6b98314e2230f1eef
ExpoAsset: 23a958e97d3d340919fe6774db35d563241e6c03
ExpoBlur: b90747a3f22a8b6ceffd9cb0dc41a4184efdc656
ExpoBrightness: 46c980463e8a54b9ce77f923c4bff0bb0c9526e0
ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322
ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a
ExpoDocumentPicker: 7cd9e71a0f66fb19eb0a586d6f26eee1284692e0
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
ExpoFont: 35ac6191ed86bbf56b3ebd2d9154eda9fad5b509
ExpoGlassEffect: 8ce45eca31f12e949e23a4ee13e2bfb59e9b0785
ExpoHaptics: d3a6375d8dcc3a1083d003bc2298ff654fafb536
ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296
ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58
ExpoLinking: f4c4a351523da72a6bfa7e1f4ca92aee1043a3ca
ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
ExpoRandom: d1444df65007bdd4070009efd5dab18e20bf0f00
ExpoScreenOrientation: ef9ab3fb85c8a8ff57d52aa169b750aca03f0f4c
ExpoSharing: 032c01bb034319e2374badf082ae935be866d2e9
ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
ExpoWebBrowser: b973e1351fdcf5fec0c400997b1851f5a8219ec3
ExpoScreenOrientation: c68bd20f210d0616960638c787889e07787e5adb
ExpoSharing: 0d983394ed4a80334bab5a0d5384f75710feb7e8
ExpoSystemUI: 2ad325f361a2fcd96a464e8574e19935c461c9cc
ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52
EXStructuredHeaders: c951e77f2d936f88637421e9588c976da5827368
EXUpdates: ef83273afc231a627b170358c90689ac30a4429d
EXUpdates: f20abbc8a9f4e150656fe88126d52f52d4e7793f
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
FBLazyVector: 9e0cd874afd81d9a4d36679daca991b58b260d42
FFmpegKit: 3885085fbbc320745838ee4c8a1f9c5e5953dab2
google-cast-sdk: 32f65af50d164e3c475e79ad123db3cc26fbcd37
hermes-engine: 35c763d57c9832d0eef764316ca1c4d043581394
ImageColors: 51cd79f7a9d2524b7a681c660b0a50574085563b
ImageColors: e12eb73e29bc1feaa3c228db8c174a1b25acb59d
KSPlayer: f163ac6195f240b6fa5b8225aeb39ec811a70c62
Libass: e88af2324e1217e3a4c8bdc675f6f23a9dfc7677
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
@ -3240,9 +3204,8 @@ SPEC CHECKSUMS:
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df
MobileVLCKit: 2d9c7c373393ae43086aeeff890bf0b1afc15c5c
NitroMmkv: 7fe66a61d5acab6516098a64f42af575595e7566
NitroModules: a672a4b7470810b8dae8fc2ff91eabaa2e1eff7d
NitroMmkv: 4af10c70043b4c3cded3f16547627c7d9d8e3b8b
NitroModules: a71a5ab2911caf79e45170e6e12475b5260a12d0
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
RCTDeprecation: 7487d6dda857ccd4cb3dd6ecfccdc3170e85dcbc
RCTRequired: 54128b7df8be566881d48c7234724a78cb9b6157
@ -3279,15 +3242,14 @@ SPEC CHECKSUMS:
React-Mapbuffer: fbe1da882a187e5898bdf125e1cc6e603d27ecae
React-microtasksnativemodule: 76905804171d8ccbe69329fc84c57eb7934add7f
react-native-blur: 1b00ef07fe0efdc0c40b37139a5268ccad73c72d
react-native-bottom-tabs: b6459855502662d724d84b7edc937ea2b5a988ff
react-native-bottom-tabs: bcb70e4fae95fc9da0da875f7414acda26dfc551
react-native-device-brightness: 1a997350d060c3df9f303b1df84a4f7c5cbeb924
react-native-get-random-values: a603782b2b222a34533c66371614790282dba3f1
react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
react-native-skia: e386a7d05f10c87d2b0f9bf0165a6b59bc0c7410
react-native-slider: f954578344106f0a732a4358ce3a3e11015eb6e1
react-native-video: 5d9635903e562e0c5eb47c5fa401f1c807d6e068
react-native-video: f5982e21efab0dc356d92541a8a9e19581307f58
React-NativeModulesApple: a9464983ccc0f66f45e93558671f60fc7536e438
React-oscompat: 73db7dbc80edef36a9d6ed3c6c4e1724ead4236d
React-perflogger: 123272debf907cc423962adafcf4513320e43757
@ -3322,20 +3284,20 @@ SPEC CHECKSUMS:
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
RNFastImage: 2d36f4cfed9b2342f94f8591c8be69dd047ac67c
RNGestureHandler: 723f29dac55e25f109d263ed65cecc4b9c4bd46a
RNReanimated: 1442a577e066e662f0ce1cd1864a65c8e547aee0
RNReanimated: e1c71e6e693a66b203ae98773347b625d3cc85ee
RNScreens: 61c18865ab074f4d995ac8d7cf5060522a649d05
RNSentry: be6d501966b60b30547abe59ea86626d80ad2680
RNSVG: 99ab6158011aece12019b236f168faa7a1e41af6
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522
RNVectorIcons: 4351544f100d4f12cac156a7c13399e60bab3e26
RNWorklets: 54d8dffb7f645873a58484658ddfd4bd1a9a0bc1
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
RNWorklets: 9eb6d567fa43984e96b6924a6df504b8a15980cd
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
Sentry: 83a3814c3ca042874b39c5c5bdffb6570d4d760e
Sentry: c643eb180df401dd8c734c5036ddd9dd9218daa6
SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d
Yoga: 051f086b5ccf465ff2ed38a2cf5a558ae01aaaa1
PODFILE CHECKSUM: 1db7b3713ca6ad8568e4bdf6b72b92b72ee8199d
PODFILE CHECKSUM: 7c74c9cd2c7f3df7ab68b4284d9f324282e54542
COCOAPODS: 1.16.2

View file

@ -1,4 +1,4 @@
defaults.url=https://sentry.io/
defaults.org=tapframe
defaults.project=react-native
auth.token=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
auth.token=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

View file

@ -30,6 +30,14 @@
"https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true"
],
"versions": [
{
"version": "1.2.11",
"buildVersion": "27",
"date": "2025-12-15",
"localizedDescription": "# Nuvio Media Hub v1.2.11 \n\n## Update Notes\n- **Dependency updates** for improved stability \n- **Android animation improvements** for smoother UI interactions \n- Multiple **backend bug fixes** \n\n## Note for iOS Users\n- iOS users can continue using the **TestFlight build** \n- It may show version **1.2.10 (27)**, but the **build is the same as 1.2.11** \n- This is intentional, as bumping the version would require a manual TestFlight review",
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.2.11/Stable_1-2-11.ipa",
"size": 25700000
},
{
"version": "1.2.10",
"buildVersion": "25",
@ -208,4 +216,4 @@
}
],
"news": []
}
}

1623
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,6 @@
"@react-navigation/stack": "^7.2.10",
"@sentry/react-native": "^7.6.0",
"@shopify/flash-list": "^2.2.0",
"@shopify/react-native-skia": "^2.3.13",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.12.2",
@ -78,7 +77,7 @@
"react-native-mmkv": "^4.0.0",
"react-native-nitro-modules": "^0.31.2",
"react-native-paper": "^5.14.5",
"react-native-reanimated": "^4.1.1",
"react-native-reanimated": "^4.2.0",
"react-native-reanimated-carousel": "^4.0.3",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "^4.18.0",
@ -88,7 +87,7 @@
"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.7.1"
},
"devDependencies": {
"@babel/core": "^7.25.2",

View file

@ -15,7 +15,7 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
import { useTheme } from '../contexts/ThemeContext';
import { Portal, Dialog, Button } from 'react-native-paper';
import { Portal } from 'react-native-paper';
interface CustomAlertProps {
visible: boolean;
@ -40,8 +40,8 @@ export const CustomAlert = ({
}: CustomAlertProps) => {
const opacity = useSharedValue(0);
const scale = useSharedValue(0.95);
const isDarkMode = useColorScheme() === 'dark';
const { currentTheme } = useTheme();
// Using hardcoded dark theme values to match SeriesContent modal
const themeColors = currentTheme.colors;
useEffect(() => {
@ -68,10 +68,11 @@ export const CustomAlert = ({
const handleActionPress = useCallback((action: { label: string; onPress: () => void; style?: object }) => {
try {
action.onPress();
// Don't auto-close here if the action handles it, or check if we should
// Standard behavior is to close
onClose();
} catch (error) {
console.warn('[CustomAlert] Error in action handler:', error);
// Still close the alert even if action fails
onClose();
}
}, [onClose]);
@ -91,7 +92,7 @@ export const CustomAlert = ({
<Animated.View
style={[
styles.overlay,
{ backgroundColor: 'rgba(0,0,0,0.6)' },
{ backgroundColor: 'rgba(0, 0, 0, 0.85)' },
overlayStyle
]}
>
@ -100,23 +101,22 @@ export const CustomAlert = ({
<Animated.View style={[
styles.alertContainer,
alertStyle,
{
backgroundColor: themeColors.darkBackground,
borderColor: themeColors.primary,
}
]}>
{/* Title */}
<Text style={[styles.title, { color: themeColors.highEmphasis }]}>
<Text style={styles.title}>
{title}
</Text>
{/* Message */}
<Text style={[styles.message, { color: themeColors.mediumEmphasis }]}>
<Text style={styles.message}>
{message}
</Text>
{/* Actions */}
<View style={styles.actionsRow}>
<View style={[
styles.actionsRow,
actions.length === 1 && { justifyContent: 'center' }
]}>
{actions.map((action, idx) => {
const isPrimary = idx === actions.length - 1;
return (
@ -125,9 +125,10 @@ export const CustomAlert = ({
style={[
styles.actionButton,
isPrimary
? { ...styles.primaryButton, backgroundColor: themeColors.primary }
? { backgroundColor: themeColors.primary }
: styles.secondaryButton,
action.style
action.style,
actions.length === 1 && { minWidth: 120, maxWidth: '100%' }
]}
onPress={() => handleActionPress(action)}
activeOpacity={0.7}
@ -135,8 +136,8 @@ export const CustomAlert = ({
<Text style={[
styles.actionText,
isPrimary
? { color: themeColors.white }
: { color: themeColors.primary }
? { color: '#FFFFFF' }
: { color: '#FFFFFF' }
]}>
{action.label}
</Text>
@ -157,6 +158,7 @@ const styles = StyleSheet.create({
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
},
overlayPressable: {
...StyleSheet.absoluteFillObject,
@ -165,29 +167,32 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 24,
paddingHorizontal: 20,
width: '100%',
},
alertContainer: {
width: '100%',
maxWidth: 340,
borderRadius: 24,
padding: 28,
maxWidth: 400,
backgroundColor: '#1E1E1E', // Solid opaque dark background
borderRadius: 16,
padding: 24,
borderWidth: 1,
borderColor: '#007AFF', // iOS blue - will be overridden by theme
overflow: 'hidden', // Ensure background fills entire card
borderColor: 'rgba(255, 255, 255, 0.1)',
overflow: 'hidden',
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 24,
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.51,
shadowRadius: 13.16,
},
android: {
elevation: 12,
elevation: 20,
},
}),
},
title: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '700',
marginBottom: 8,
@ -195,6 +200,7 @@ const styles = StyleSheet.create({
letterSpacing: 0.2,
},
message: {
color: '#AAAAAA',
fontSize: 15,
marginBottom: 24,
textAlign: 'center',
@ -209,17 +215,16 @@ const styles = StyleSheet.create({
},
actionButton: {
paddingHorizontal: 20,
paddingVertical: 11,
paddingVertical: 12,
borderRadius: 12,
minWidth: 80,
alignItems: 'center',
justifyContent: 'center',
},
primaryButton: {
// Background color set dynamically via theme
flex: 1, // Distribute space
maxWidth: 200, // But limit width
},
secondaryButton: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
actionText: {
fontSize: 16,

View file

@ -611,7 +611,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
// Stop any playing trailer
try {
setTrailerPlaying(false);
} catch {}
} catch { }
// Check if we should resume based on watch progress
const shouldResume = watchProgress &&
@ -744,13 +744,32 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
const timeSinceInteraction = Date.now() - lastInteractionRef.current;
// Only auto-advance if user hasn't interacted recently (5 seconds) and no trailer playing
if (timeSinceInteraction >= 5000 && (!globalTrailerPlaying || !trailerReady)) {
setCurrentIndex((prev) => (prev + 1) % items.length);
// Set next index preview for crossfade
const nextIdx = (currentIndex + 1) % items.length;
setNextIndex(nextIdx);
// Set drag direction for slide animation (left/next)
dragDirection.value = -1;
// Animate crossfade before changing index
dragProgress.value = withTiming(
1,
{
duration: 500,
easing: Easing.out(Easing.cubic),
},
(finished) => {
if (finished) {
runOnJS(setCurrentIndex)(nextIdx);
}
}
);
} else {
// Retry after remaining time
startAutoPlay();
}
}, 25000); // Auto-advance every 25 seconds
}, [items.length, globalTrailerPlaying, trailerReady]);
}, [items.length, globalTrailerPlaying, trailerReady, currentIndex, dragDirection, dragProgress]);
useEffect(() => {
startAutoPlay();
@ -852,7 +871,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
.onEnd((event) => {
const velocity = event.velocityX;
const translationX = event.translationX;
const swipeThreshold = width * 0.05; // Very small threshold - minimal swipe needed
const swipeThreshold = width * 0.16; // 16% threshold for swipe detection
if (Math.abs(translationX) > swipeThreshold || Math.abs(velocity) > 300) {
// Complete the swipe - animate to full opacity before navigation
@ -1159,61 +1178,61 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
style={logoAnimatedStyle}
>
{currentItem.logo && !logoError[currentIndex] ? (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => {
if (currentItem) {
navigation.navigate('Metadata', {
id: currentItem.id,
type: currentItem.type,
});
}
<TouchableOpacity
activeOpacity={0.7}
onPress={() => {
if (currentItem) {
navigation.navigate('Metadata', {
id: currentItem.id,
type: currentItem.type,
});
}
}}
>
<View
style={[
styles.logoContainer,
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
? { marginBottom: 4 } // Minimal spacing for small logos
: { marginBottom: 8 } // Small spacing for normal logos
]}
onLayout={(event) => {
const { height } = event.nativeEvent.layout;
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
}}
>
<View
style={[
styles.logoContainer,
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
? { marginBottom: 4 } // Minimal spacing for small logos
: { marginBottom: 8 } // Small spacing for normal logos
]}
onLayout={(event) => {
const { height } = event.nativeEvent.layout;
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
<Image
source={{ uri: currentItem.logo }}
style={styles.logo}
resizeMode="contain"
onLoad={() => setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
onError={() => {
setLogoError((prev) => ({ ...prev, [currentIndex]: true }));
logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo);
}}
>
<Image
source={{ uri: currentItem.logo }}
style={styles.logo}
resizeMode="contain"
onLoad={() => setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
onError={() => {
setLogoError((prev) => ({ ...prev, [currentIndex]: true }));
logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo);
}}
/>
</View>
</TouchableOpacity>
) : (
<TouchableOpacity
activeOpacity={0.8}
onPress={() => {
if (currentItem) {
navigation.navigate('Metadata', {
id: currentItem.id,
type: currentItem.type,
});
}
}}
>
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={2}>
{currentItem.name}
</Text>
</View>
</TouchableOpacity>
)}
</Animated.View>
/>
</View>
</TouchableOpacity>
) : (
<TouchableOpacity
activeOpacity={0.8}
onPress={() => {
if (currentItem) {
navigation.navigate('Metadata', {
id: currentItem.id,
type: currentItem.type,
});
}
}}
>
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={2}>
{currentItem.name}
</Text>
</View>
</TouchableOpacity>
)}
</Animated.View>
{/* Metadata Badge - Always Visible */}
<View style={styles.metadataContainer}>
@ -1231,7 +1250,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
</View>
</View>
{/* Action Buttons - Play and Save buttons */}
{/* Action Buttons - Play and Save buttons */}
<View style={styles.buttonsContainer}>
{/* Play Button */}
<TouchableOpacity

View file

@ -41,13 +41,13 @@ const calculatePosterLayout = (screenWidth: number) => {
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
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: 120 };
for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
@ -55,12 +55,12 @@ const calculatePosterLayout = (screenWidth: number) => {
// We'll use minimal right padding (8px) to maximize space
const usableWidth = availableWidth - 8;
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
bestLayout = { numFullPosters: n, posterWidth };
}
}
return {
numFullPosters: bestLayout.numFullPosters,
posterWidth: bestLayout.posterWidth,
@ -82,8 +82,8 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
const renderContentItem = useCallback(({ item }: { item: StreamingContent, index: number }) => {
return (
<ContentItem
item={item}
<ContentItem
item={item}
onPress={handleContentPress}
/>
);
@ -96,25 +96,11 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
// Memoize the keyExtractor to prevent re-creation
const keyExtractor = useCallback((item: StreamingContent) => `${item.id}-${item.type}`, []);
// Calculate item width for getItemLayout - use base POSTER_WIDTH for consistent spacing
// Note: ContentItem may apply size multipliers based on settings, but base width ensures consistent layout
const itemWidth = useMemo(() => POSTER_WIDTH, []);
// getItemLayout for consistent spacing and better performance
const getItemLayout = useCallback((data: any, index: number) => {
const length = itemWidth + separatorWidth;
const paddingHorizontal = isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 16;
return {
length,
offset: paddingHorizontal + (length * index),
index,
};
}, [itemWidth, separatorWidth, isTV, isLargeTablet, isTablet]);
return (
<Animated.View
<View
style={styles.catalogContainer}
entering={FadeIn.duration(400)}
>
<View style={[
styles.catalogHeader,
@ -145,7 +131,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
/>
</View>
<TouchableOpacity
onPress={() =>
onPress={() =>
navigation.navigate('Catalog', {
id: catalog.id,
type: catalog.type,
@ -176,7 +162,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
/>
</TouchableOpacity>
</View>
<FlatList
data={catalog.items}
renderItem={renderContentItem}
@ -195,14 +181,13 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
}
])}
ItemSeparatorComponent={ItemSeparator}
getItemLayout={getItemLayout}
removeClippedSubviews={true}
initialNumToRender={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3}
maxToRenderPerBatch={isTV ? 4 : isLargeTablet ? 4 : 3}
windowSize={isTV ? 4 : isLargeTablet ? 4 : 3}
updateCellsBatchingPeriod={50}
/>
</Animated.View>
</View>
);
};
@ -262,8 +247,8 @@ export default React.memo(CatalogSection, (prevProps, nextProps) => {
prevProps.catalog.name === nextProps.catalog.name &&
prevProps.catalog.items.length === nextProps.catalog.items.length &&
// Deep compare the first few items to detect changes
prevProps.catalog.items.slice(0, 3).every((item, index) =>
nextProps.catalog.items[index] &&
prevProps.catalog.items.slice(0, 3).every((item, index) =>
nextProps.catalog.items[index] &&
item.id === nextProps.catalog.items[index].id &&
item.poster === nextProps.catalog.items[index].poster
)

View file

@ -41,7 +41,7 @@ const getDeviceType = (screenWidth: number) => {
// 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;
@ -52,9 +52,9 @@ const calculatePosterLayout = (screenWidth: number) => {
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: deviceType === 'tv' ? 200 : deviceType === 'largeTablet' ? 180 : deviceType === 'tablet' ? 160 : 120
};
for (let n = 3; n <= 6; n++) {
@ -96,7 +96,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
return () => unsubscribe();
}, [item.id, item.type]);
// Load watched state from AsyncStorage when item changes
// Load watched state from AsyncStorage when item changes
useEffect(() => {
const updateWatched = () => {
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then((val: string | null) => setIsWatched(val === 'true'));
@ -126,7 +126,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
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;
@ -139,6 +139,30 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
}
}, [settings.posterSize, width]);
// Determine dimensions based on poster shape
const { finalWidth, finalAspectRatio, borderRadius } = React.useMemo(() => {
const shape = item.posterShape || 'poster';
const baseHeight = posterWidth / (2 / 3); // Standard height derived from portrait width
let w = posterWidth;
let ratio = 2 / 3;
if (shape === 'landscape') {
ratio = 16 / 9;
// Maintain same height as portrait posters
w = baseHeight * ratio;
} else if (shape === 'square') {
ratio = 1;
w = baseHeight;
}
return {
finalWidth: w,
finalAspectRatio: ratio,
borderRadius: typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12
};
}, [posterWidth, item.posterShape, settings.posterBorderRadius]);
// Intersection observer simulation for lazy loading
const itemRef = useRef<View>(null);
@ -169,7 +193,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
setIsWatched(targetWatched);
try {
await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
} catch {}
} catch { }
showInfo(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched', targetWatched ? 'Item marked as watched' : 'Item marked as unwatched');
setTimeout(() => {
DeviceEventEmitter.emit('watchedStatusChanged');
@ -185,7 +209,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
undefined,
{ forceNotify: true, forceWrite: true }
);
} catch {}
} catch { }
if (item.type === 'movie') {
try {
@ -194,9 +218,9 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
await trakt.addToWatchedMovies(item.id);
try {
await storageService.updateTraktSyncStatus(item.id, item.type, true, 100);
} catch {}
} catch { }
}
} catch {}
} catch { }
}
}
setMenuVisible(false);
@ -242,44 +266,34 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
setMenuVisible(false);
}, []);
// Memoize optimized poster URL to prevent recalculating
const optimizedPosterUrl = React.useMemo(() => {
if (!item.poster || item.poster.includes('placeholder')) {
return 'https://via.placeholder.com/154x231/333/666?text=No+Image';
}
// For TMDB images, use smaller sizes
if (item.poster.includes('image.tmdb.org')) {
// Replace any size with w154 (fits 100-130px tiles perfectly)
return item.poster.replace(/\/w\d+\//, '/w154/');
}
// For metahub images, use smaller sizes
if (item.poster.includes('placeholder')) {
return item.poster.replace('/medium/', '/small/');
}
// Return original URL for other sources to avoid breaking them
return item.poster;
}, [item.poster, item.id]);
// While settings load, render a placeholder with reserved space (poster aspect + title)
if (!isLoaded) {
const placeholderRadius = 12;
return (
<View style={[styles.itemContainer, { width: posterWidth }]}>
<View style={[styles.itemContainer, { width: finalWidth }]}>
<View
style={[
styles.contentItem,
{
width: posterWidth,
borderRadius: placeholderRadius,
width: finalWidth,
aspectRatio: finalAspectRatio,
borderRadius,
backgroundColor: currentTheme.colors.elevation1,
},
]}
/>
{/* Reserve space for title to keep section spacing stable */}
<View style={{ height: 18, marginTop: 4 }} />
</View>
);
@ -287,24 +301,24 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
return (
<>
<Animated.View style={[styles.itemContainer, { width: posterWidth }]} entering={FadeIn.duration(300)}>
<Animated.View style={[styles.itemContainer, { width: finalWidth }]} entering={FadeIn.duration(300)}>
<TouchableOpacity
style={[styles.contentItem, { width: posterWidth, borderRadius: posterRadius }]}
style={[styles.contentItem, { width: finalWidth, aspectRatio: finalAspectRatio, borderRadius }]}
activeOpacity={0.7}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
>
<View ref={itemRef} style={[styles.contentItemContainer, { borderRadius: posterRadius }] }>
<View ref={itemRef} style={[styles.contentItemContainer, { borderRadius }]}>
{/* Image with FastImage for aggressive caching */}
{item.poster ? (
<FastImage
source={{
source={{
uri: optimizedPosterUrl,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable
}}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius: posterRadius }]}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius }]}
resizeMode={FastImage.resizeMode.cover}
onLoad={() => {
setImageError(false);
@ -316,14 +330,14 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
/>
) : (
// Show placeholder for items without posters
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center', borderRadius: posterRadius }] }>
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center', borderRadius: posterRadius }]}>
<Text style={{ color: currentTheme.colors.textMuted, fontSize: 10, textAlign: 'center' }}>
{item.name.substring(0, 20)}...
</Text>
</View>
)}
{imageError && (
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="broken-image" size={24} color={currentTheme.colors.textMuted} />
</View>
)}
@ -350,14 +364,14 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
</View>
</TouchableOpacity>
{settings.showPosterTitles && (
<Text
<Text
style={[
styles.title,
{
styles.title,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: getDeviceType(width) === 'tv' ? 16 : getDeviceType(width) === 'largeTablet' ? 15 : getDeviceType(width) === 'tablet' ? 14 : 13
}
]}
]}
numberOfLines={2}
>
{item.name}

View file

@ -293,51 +293,50 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const mergeBatchIntoState = async (batch: ContinueWatchingItem[]) => {
if (!batch || batch.length === 0) return;
// 1. Filter items first (async checks) - do this BEFORE any state updates
const validItems: ContinueWatchingItem[] = [];
for (const it of batch) {
const key = `${it.type}:${it.id}`;
// Skip recently removed items
if (recentlyRemovedRef.current.has(key)) {
continue;
}
// Skip persistently removed items
const isRemoved = await storageService.isContinueWatchingRemoved(it.id, it.type);
if (isRemoved) {
continue;
}
validItems.push(it);
}
if (validItems.length === 0) return;
// 2. Single state update for the entire batch
setContinueWatchingItems((prev) => {
const map = new Map<string, ContinueWatchingItem>();
// Add existing items
for (const it of prev) {
map.set(`${it.type}:${it.id}`, it);
}
// Merge new valid items
for (const it of validItems) {
const key = `${it.type}:${it.id}`;
const existing = map.get(key);
// Only update if newer or doesn't exist
if (!existing || (it.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
map.set(key, it);
}
}
const merged = Array.from(map.values());
merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
return merged;
});
// Process batch items asynchronously to check removal status
for (const it of batch) {
const key = `${it.type}:${it.id}`;
// Skip recently removed items to prevent immediate re-addition
if (recentlyRemovedRef.current.has(key)) {
continue;
}
// Skip items that have been persistently marked as removed
const isRemoved = await storageService.isContinueWatchingRemoved(it.id, it.type);
if (isRemoved) {
continue;
}
// Add the item to state
setContinueWatchingItems((prev) => {
const map = new Map<string, ContinueWatchingItem>();
for (const existing of prev) {
map.set(`${existing.type}:${existing.id}`, existing);
}
const existing = map.get(key);
if (!existing || (it.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
map.set(key, it);
const merged = Array.from(map.values());
merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
return merged;
}
return prev;
});
}
};
try {
@ -604,7 +603,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
});
// TRÅKT: fetch history and merge incrementally as well
// TRAKT: fetch playback progress (in-progress items) and history, merge incrementally
const traktMergePromise = (async () => {
try {
const traktService = TraktService.getInstance();
@ -619,28 +618,132 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
lastTraktSyncRef.current = now;
const historyItems = await traktService.getWatchedEpisodesHistory(1, 200);
// Fetch both playback progress (paused items) and watch history in parallel
const [playbackItems, historyItems, watchedShows] = await Promise.all([
traktService.getPlaybackProgress(), // Items with actual progress %
traktService.getWatchedEpisodesHistory(1, 200), // Completed episodes
traktService.getWatchedShows(), // For reset_at handling
]);
// Build a map of shows with reset_at for re-watching support
const showResetMap: Record<string, number> = {};
for (const show of watchedShows) {
if (show.show?.ids?.imdb && show.reset_at) {
const imdbId = show.show.ids.imdb.startsWith('tt')
? show.show.ids.imdb
: `tt${show.show.ids.imdb}`;
showResetMap[imdbId] = new Date(show.reset_at).getTime();
}
}
const traktBatch: ContinueWatchingItem[] = [];
const processedShows = new Set<string>(); // Track which shows we've added
// STEP 1: Process playback progress items (in-progress, paused)
// These have actual progress percentage from Trakt
for (const item of playbackItems) {
try {
// Skip items with very low or very high progress
if (item.progress <= 0 || item.progress >= 85) continue;
if (item.type === 'movie' && item.movie?.ids?.imdb) {
const imdbId = item.movie.ids.imdb.startsWith('tt')
? item.movie.ids.imdb
: `tt${item.movie.ids.imdb}`;
// Check if recently removed
const movieKey = `movie:${imdbId}`;
if (recentlyRemovedRef.current.has(movieKey)) continue;
const cachedData = await getCachedMetadata('movie', imdbId);
if (!cachedData?.basicContent) continue;
const pausedAt = new Date(item.paused_at).getTime();
traktBatch.push({
...cachedData.basicContent,
id: imdbId,
type: 'movie',
progress: item.progress,
lastUpdated: pausedAt,
} as ContinueWatchingItem);
logger.log(`📺 [TraktPlayback] Adding movie ${item.movie.title} with ${item.progress.toFixed(1)}% progress`);
} else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) {
const showImdb = item.show.ids.imdb.startsWith('tt')
? item.show.ids.imdb
: `tt${item.show.ids.imdb}`;
// Check if recently removed
const showKey = `series:${showImdb}`;
if (recentlyRemovedRef.current.has(showKey)) continue;
// Check reset_at - skip if this was paused before re-watch started
const resetTime = showResetMap[showImdb];
const pausedAt = new Date(item.paused_at).getTime();
if (resetTime && pausedAt < resetTime) {
logger.log(`🔄 [TraktPlayback] Skipping ${showImdb} S${item.episode.season}E${item.episode.number} - paused before reset_at`);
continue;
}
const cachedData = await getCachedMetadata('series', showImdb);
if (!cachedData?.basicContent) continue;
traktBatch.push({
...cachedData.basicContent,
id: showImdb,
type: 'series',
progress: item.progress,
lastUpdated: pausedAt,
season: item.episode.season,
episode: item.episode.number,
episodeTitle: item.episode.title || `Episode ${item.episode.number}`,
} as ContinueWatchingItem);
processedShows.add(showImdb);
logger.log(`📺 [TraktPlayback] Adding ${item.show.title} S${item.episode.season}E${item.episode.number} with ${item.progress.toFixed(1)}% progress`);
}
} catch (err) {
// Continue with other items
}
}
// STEP 2: Process watch history for shows NOT in playback progress
// Find the next episode for completed shows
const latestWatchedByShow: Record<string, { season: number; episode: number; watchedAt: number }> = {};
for (const item of historyItems) {
if (item.type !== 'episode') continue;
const showImdb = item.show?.ids?.imdb ? `tt${item.show.ids.imdb.replace(/^tt/, '')}` : null;
const showImdb = item.show?.ids?.imdb
? (item.show.ids.imdb.startsWith('tt') ? item.show.ids.imdb : `tt${item.show.ids.imdb}`)
: null;
if (!showImdb) continue;
// Skip if we already have an in-progress episode for this show
if (processedShows.has(showImdb)) continue;
const season = item.episode?.season;
const epNum = item.episode?.number;
if (season === undefined || epNum === undefined) continue;
const watchedAt = new Date(item.watched_at).getTime();
// Check reset_at - skip episodes watched before re-watch started
const resetTime = showResetMap[showImdb];
if (resetTime && watchedAt < resetTime) {
continue; // This was watched in a previous viewing
}
const existing = latestWatchedByShow[showImdb];
if (!existing || existing.watchedAt < watchedAt) {
latestWatchedByShow[showImdb] = { season, episode: epNum, watchedAt };
}
}
// Collect all valid Trakt items first, then merge as a batch
const traktBatch: ContinueWatchingItem[] = [];
// Add next episodes for completed shows
for (const [showId, info] of Object.entries(latestWatchedByShow)) {
try {
// Check if this show was recently removed by the user
// Check if this show was recently removed
const showKey = `series:${showId}`;
if (recentlyRemovedRef.current.has(showKey)) {
logger.log(`🚫 [TraktSync] Skipping recently removed show: ${showKey}`);
@ -659,7 +762,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
...basicContent,
id: showId,
type: 'series',
progress: 0,
progress: 0, // Next episode, not started
lastUpdated: info.watchedAt,
season: nextEpisodeVideo.season,
episode: nextEpisodeVideo.episode,
@ -668,13 +771,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
}
// Persist "watched" progress for the episode that Trakt reported (only if not recently removed)
// Persist "watched" progress for the episode that Trakt reported
if (!recentlyRemovedRef.current.has(showKey)) {
const watchedEpisodeId = `${showId}:${info.season}:${info.episode}`;
const existingProgress = allProgress[`series:${showId}:${watchedEpisodeId}`];
const existingPercent = existingProgress ? (existingProgress.currentTime / existingProgress.duration) * 100 : 0;
if (!existingProgress || existingPercent < 85) {
logger.log(`💾 [TraktSync] Adding local progress for ${showId}: S${info.season}E${info.episode}`);
await storageService.setWatchProgress(
showId,
'series',
@ -688,20 +790,19 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
`${info.season}:${info.episode}`
);
}
} else {
logger.log(`🚫 [TraktSync] Skipping local progress for recently removed show: ${showKey}`);
}
} catch (err) {
// Continue with other shows even if one fails
// Continue with other shows
}
}
// Merge all Trakt items as a single batch to ensure proper sorting
if (traktBatch.length > 0) {
logger.log(`📋 [TraktSync] Merging ${traktBatch.length} items from Trakt (playback + history)`);
await mergeBatchIntoState(traktBatch);
}
} catch (err) {
// Continue even if Trakt history merge fails
logger.error('[TraktSync] Error in Trakt merge:', err);
}
})();
@ -1157,9 +1258,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
return (
<Animated.View
<View
style={styles.container}
entering={FadeIn.duration(350)}
>
<View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
<View style={styles.titleContainer}>
@ -1207,7 +1307,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
</Animated.View>
</View>
);
});

View file

@ -83,6 +83,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
const [activeIndex, setActiveIndex] = useState(0);
const [failedLogoIds, setFailedLogoIds] = useState<Set<string>>(new Set());
const scrollViewRef = useRef<any>(null);
const [isScrollReady, setIsScrollReady] = useState(false);
const [flippedMap, setFlippedMap] = useState<Record<string, boolean>>({});
const toggleFlipById = useCallback((id: string) => {
setFlippedMap((prev) => ({ ...prev, [id]: !prev[id] }));
@ -95,9 +96,9 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
// Optimized: update background as soon as scroll starts, without waiting for momentum end
const scrollX = useSharedValue(0);
const paginationProgress = useSharedValue(0);
// Parallel image prefetch: start fetching banners and logos as soon as data arrives
const itemsToPreload = useMemo(() => data.slice(0, 12), [data]);
const itemsToPreload = useMemo(() => data.slice(0, 3), [data]);
useEffect(() => {
if (!itemsToPreload.length) return;
try {
@ -121,17 +122,19 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
// no-op: prefetch is best-effort
}
}, [itemsToPreload]);
// Comprehensive reset when component mounts/remounts to prevent glitching
useEffect(() => {
// Start at the first real item for looping
scrollX.value = loopingEnabled ? interval : 0;
setActiveIndex(0);
setIsScrollReady(false);
// Scroll to position 0 after a brief delay to ensure ScrollView is ready
// Scroll to position and mark ready after layout
const timer = setTimeout(() => {
scrollViewRef.current?.scrollTo({ x: loopingEnabled ? interval : 0, y: 0, animated: false });
}, 50);
setIsScrollReady(true);
}, 100);
return () => clearTimeout(timer);
}, []);
@ -141,10 +144,12 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
if (data.length > 0) {
scrollX.value = loopingEnabled ? interval : 0;
setActiveIndex(0);
setIsScrollReady(false);
const timer = setTimeout(() => {
scrollViewRef.current?.scrollTo({ x: loopingEnabled ? interval : 0, y: 0, animated: false });
}, 100);
setIsScrollReady(true);
}, 150);
return () => clearTimeout(timer);
}
@ -158,7 +163,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
}, 50);
return () => clearTimeout(timer);
}, [windowWidth, windowHeight, interval, loopingEnabled]);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollX.value = event.contentOffset.x;
@ -192,12 +197,12 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ 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);
@ -263,22 +268,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
height: cardHeight,
}
] as StyleProp<ViewStyle>}>
<View style={styles.bannerContainer as ViewStyle}>
<View style={styles.skeletonBannerFull as ViewStyle} />
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.25)"]}
locations={[0.6, 1]}
style={styles.bannerOverlay as ViewStyle}
/>
</View>
<View style={styles.info as ViewStyle}>
<View style={[styles.skeletonLine, { width: '62%' }] as StyleProp<ViewStyle>} />
<View style={[styles.skeletonLine, { width: '44%', marginTop: 6 }] as StyleProp<ViewStyle>} />
<View style={styles.skeletonActions as ViewStyle}>
<View style={[styles.skeletonPill, { width: 96 }] as StyleProp<ViewStyle>} />
<View style={[styles.skeletonPill, { width: 80 }] as StyleProp<ViewStyle>} />
</View>
</View>
<View style={styles.skeletonBannerFull as ViewStyle} />
</View>
</View>
))}
@ -289,11 +279,11 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
}
// Memoized background component with improved timing
const BackgroundImage = React.memo(({
item,
const BackgroundImage = React.memo(({
item,
insets
}: {
item: StreamingContent;
}: {
item: StreamingContent;
insets: any;
}) => {
return (
@ -317,7 +307,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
) : (
<>
<FastImage
source={{
source={{
uri: item.banner || item.poster,
priority: FastImage.priority.low,
cache: FastImage.cacheControl.immutable
@ -352,15 +342,15 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
if (!hasData) return null;
return (
<Animated.View entering={FadeIn.duration(150).easing(Easing.out(Easing.cubic))}>
<View>
<Animated.View style={[styles.container as ViewStyle, { paddingTop: 12 + effectiveTopOffset }]}>
{/* Removed preload images for performance - let FastImage cache handle it naturally */}
{settings.enableHomeHeroBackground && data[activeIndex] && (
<BackgroundImage
item={data[activeIndex]}
insets={insets}
/>
)}
{settings.enableHomeHeroBackground && data[activeIndex] && (
<BackgroundImage
item={data[activeIndex]}
insets={insets}
/>
)}
{/* Bottom blend to HomeScreen background (not the card) */}
{settings.enableHomeHeroBackground && (
<LinearGradient
@ -383,6 +373,8 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
pagingEnabled={false}
bounces={false}
overScrollMode="never"
style={{ opacity: isScrollReady ? 1 : 0 }}
contentOffset={{ x: loopingEnabled ? interval : 0, y: 0 }}
onMomentumScrollEnd={(e) => {
if (!loopingEnabled) return;
// Determine current page index in cloned space
@ -400,6 +392,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
}}
>
{(loopingEnabled ? loopData : data).map((item, index) => (
/* TEST 5: ORIGINAL CARD WITHOUT LINEAR GRADIENT */
<CarouselCard
key={`${item.id}-${index}-${loopingEnabled ? 'loop' : 'base'}`}
item={item}
@ -444,10 +437,162 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
}}
/>
</View>
</Animated.View>
</View>
);
};
// MINIMAL ANIMATED CARD FOR PERFORMANCE TESTING
interface AnimatedCardWrapperProps {
item: StreamingContent;
index: number;
scrollX: SharedValue<number>;
interval: number;
cardWidth: number;
cardHeight: number;
colors: any;
isTablet: boolean;
}
const AnimatedCardWrapper: React.FC<AnimatedCardWrapperProps> = memo(({
item, index, scrollX, interval, cardWidth, cardHeight, colors, isTablet
}) => {
const cardAnimatedStyle = useAnimatedStyle(() => {
const translateX = scrollX.value;
const cardOffset = index * interval;
const distance = Math.abs(translateX - cardOffset);
if (distance > interval * 1.5) {
return {
transform: [{ scale: isTablet ? 0.95 : 0.9 }],
opacity: isTablet ? 0.85 : 0.7
};
}
const maxDistance = interval;
const scale = 1 - (distance / maxDistance) * 0.1;
const clampedScale = Math.max(isTablet ? 0.95 : 0.9, Math.min(1, scale));
const opacity = 1 - (distance / maxDistance) * 0.3;
const clampedOpacity = Math.max(isTablet ? 0.85 : 0.7, Math.min(1, opacity));
return {
transform: [{ scale: clampedScale }],
opacity: clampedOpacity,
};
});
const logoOpacity = useSharedValue(0);
const [logoLoaded, setLogoLoaded] = useState(false);
const isFlipped = useSharedValue(0);
useEffect(() => {
if (logoLoaded) {
logoOpacity.value = withTiming(1, { duration: 300, easing: Easing.out(Easing.ease) });
}
}, [logoLoaded]);
const logoAnimatedStyle = useAnimatedStyle(() => ({
opacity: logoOpacity.value,
}));
// TEST 4: FLIP STYLES
const frontFlipStyle = useAnimatedStyle(() => {
const rotate = interpolate(isFlipped.value, [0, 1], [0, 180]);
return {
transform: [
{ perspective: 1000 },
{ rotateY: `${rotate}deg` },
],
} as any;
});
const backFlipStyle = useAnimatedStyle(() => {
const rotate = interpolate(isFlipped.value, [0, 1], [-180, 0]);
return {
transform: [
{ perspective: 1000 },
{ rotateY: `${rotate}deg` },
],
} as any;
});
// TEST 4: OVERLAY ANIMATED STYLE (genres opacity on scroll)
const overlayAnimatedStyle = useAnimatedStyle(() => {
const translateX = scrollX.value;
const cardOffset = index * interval;
const distance = Math.abs(translateX - cardOffset);
if (distance > interval * 1.2) {
return { opacity: 0 };
}
const maxDistance = interval * 0.5;
const progress = Math.min(distance / maxDistance, 1);
const opacity = 1 - progress;
const clampedOpacity = Math.max(0, Math.min(1, opacity));
return {
opacity: clampedOpacity,
};
});
return (
<View style={{ width: cardWidth + 16 }}>
<Animated.View style={[
{
width: cardWidth,
height: cardHeight,
backgroundColor: colors.elevation1,
borderRadius: 16,
overflow: 'hidden',
},
cardAnimatedStyle
]}>
<FastImage
source={{
uri: item.banner || item.poster,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable
}}
style={{ width: '100%', height: '100%', position: 'absolute' }}
resizeMode={FastImage.resizeMode.cover}
/>
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.6)"]}
locations={[0.4, 0.7, 1]}
style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }}
/>
{item.logo && (
<View style={{ position: 'absolute', left: 0, right: 0, bottom: 40, alignItems: 'center' }}>
<Animated.View style={logoAnimatedStyle}>
<FastImage
source={{
uri: item.logo,
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable
}}
style={{ width: Math.round(cardWidth * 0.72), height: 64 }}
resizeMode={FastImage.resizeMode.contain}
onLoad={() => setLogoLoaded(true)}
/>
</Animated.View>
</View>
)}
{/* TEST 4: GENRES with overlayAnimatedStyle */}
{item.genres && (
<View style={{ position: 'absolute', left: 0, right: 0, bottom: 12, alignItems: 'center' }}>
<Animated.Text
style={[{ color: 'rgba(255,255,255,0.7)', fontSize: 13, textAlign: 'center' }, overlayAnimatedStyle]}
numberOfLines={1}
>
{item.genres.slice(0, 3).join(' • ')}
</Animated.Text>
</View>
)}
</Animated.View>
</View>
);
});
interface CarouselCardProps {
item: StreamingContent;
colors: any;
@ -467,13 +612,13 @@ interface CarouselCardProps {
const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index, flipped, onToggleFlip, interval, cardWidth, cardHeight, isTablet }) => {
const [bannerLoaded, setBannerLoaded] = useState(false);
const [logoLoaded, setLogoLoaded] = useState(false);
const bannerOpacity = useSharedValue(0);
const logoOpacity = useSharedValue(0);
const genresOpacity = useSharedValue(0);
const actionsOpacity = useSharedValue(0);
const isFlipped = useSharedValue(flipped ? 1 : 0);
// Reset animations when component mounts/remounts to prevent glitching
useEffect(() => {
bannerOpacity.value = 0;
@ -484,17 +629,17 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
setBannerLoaded(false);
setLogoLoaded(false);
}, [item.id]);
const inputRange = [
(index - 1) * interval,
index * interval,
(index + 1) * interval,
];
const bannerAnimatedStyle = useAnimatedStyle(() => ({
opacity: bannerOpacity.value,
}));
const logoAnimatedStyle = useAnimatedStyle(() => ({
opacity: logoOpacity.value,
}));
@ -538,52 +683,52 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
const translateX = scrollX.value;
const cardOffset = index * interval;
const distance = Math.abs(translateX - cardOffset);
// AGGRESSIVE early exit for cards far from center
if (distance > interval * 1.2) {
return { opacity: 0 };
}
const maxDistance = interval * 0.5;
const progress = Math.min(distance / maxDistance, 1);
const opacity = 1 - progress;
const clampedOpacity = Math.max(0, Math.min(1, opacity));
return {
opacity: clampedOpacity,
};
});
// ULTRA-OPTIMIZED: Only animate center card and ±1 neighbors
const cardAnimatedStyle = useAnimatedStyle(() => {
const translateX = scrollX.value;
const cardOffset = index * interval;
const distance = Math.abs(translateX - cardOffset);
// AGGRESSIVE early exit for cards far from center
if (distance > interval * 1.5) {
return {
transform: [{ scale: isTablet ? 0.95 : 0.9 }],
opacity: isTablet ? 0.85 : 0.7
return {
transform: [{ scale: isTablet ? 0.95 : 0.9 }],
opacity: isTablet ? 0.85 : 0.7
};
}
const maxDistance = interval;
// Scale animation based on distance from center
const scale = 1 - (distance / maxDistance) * 0.1;
const clampedScale = Math.max(isTablet ? 0.95 : 0.9, Math.min(1, scale));
// Opacity animation for cards that are far from center
const opacity = 1 - (distance / maxDistance) * 0.3;
const clampedOpacity = Math.max(isTablet ? 0.85 : 0.7, Math.min(1, opacity));
return {
transform: [{ scale: clampedScale }],
opacity: clampedOpacity,
};
});
// TEMPORARILY DISABLED FOR PERFORMANCE TESTING
// const bannerParallaxStyle = useAnimatedStyle(() => {
// const translateX = scrollX.value;
@ -597,7 +742,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
// transform: [{ translateX: parallaxOffset }],
// };
// });
// TEMPORARILY DISABLED FOR PERFORMANCE TESTING
// const infoParallaxStyle = useAnimatedStyle(() => {
// const translateX = scrollX.value;
@ -618,21 +763,21 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
// opacity: clampedOpacity,
// };
// });
useEffect(() => {
if (bannerLoaded) {
bannerOpacity.value = withTiming(1, {
duration: 250,
easing: Easing.out(Easing.ease)
bannerOpacity.value = withTiming(1, {
duration: 250,
easing: Easing.out(Easing.ease)
});
}
}, [bannerLoaded]);
useEffect(() => {
if (logoLoaded) {
logoOpacity.value = withTiming(1, {
duration: 300,
easing: Easing.out(Easing.ease)
logoOpacity.value = withTiming(1, {
duration: 300,
easing: Easing.out(Easing.ease)
});
}
}, [logoLoaded]);
@ -669,11 +814,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
onLoad={() => setBannerLoaded(true)}
/>
</Animated.View>
<LinearGradient
colors={["rgba(0,0,0,0.18)", "rgba(0,0,0,0.72)"]}
locations={[0.3, 1]}
style={styles.bannerGradient as ViewStyle}
/>
{/* Overlay removed for performance - readability via text shadows */}
</View>
<View style={styles.backContent as ViewStyle}>
{item.logo && !logoFailed ? (
@ -733,11 +874,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
onLoad={() => setBannerLoaded(true)}
/>
</Animated.View>
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.6)"]}
locations={[0.4, 0.7, 1]}
style={styles.bannerGradient as ViewStyle}
/>
{/* Overlay removed for performance - readability via text shadows */}
</View>
{item.logo && !logoFailed ? (
<View style={styles.logoOverlay as ViewStyle} pointerEvents="none">
@ -757,23 +894,23 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
</View>
) : (
<View style={styles.titleOverlay as ViewStyle} pointerEvents="none">
<Animated.View entering={FadeIn.duration(300)}>
<View>
<Text style={[styles.title as TextStyle, { color: colors.highEmphasis, textAlign: 'center' }]} numberOfLines={1}>
{item.name}
</Text>
</Animated.View>
</View>
</View>
)}
{item.genres && (
<View style={styles.genresOverlay as ViewStyle} pointerEvents="none">
<Animated.View entering={FadeIn.duration(400).delay(100)}>
<View>
<Animated.Text
style={[styles.genres as TextStyle, { color: colors.mediumEmphasis, textAlign: 'center' }, overlayAnimatedStyle]}
numberOfLines={1}
>
{item.genres.slice(0, 3).join(' • ')}
</Animated.Text>
</Animated.View>
</View>
</View>
)}
</TouchableOpacity>
@ -787,11 +924,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
style={styles.banner as any}
resizeMode={FastImage.resizeMode.cover}
/>
<LinearGradient
colors={["rgba(0,0,0,0.25)", "rgba(0,0,0,0.85)"]}
locations={[0.3, 1]}
style={styles.bannerGradient as ViewStyle}
/>
{/* Overlay removed for performance - readability via text shadows */}
</View>
<View style={styles.backContent as ViewStyle}>
{item.logo && !logoFailed ? (
@ -980,7 +1113,7 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.18)'
},
info: {
position: 'absolute',
left: 0,

View file

@ -1,19 +1,38 @@
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import React, { useEffect, useRef, forwardRef, useImperativeHandle, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
Dimensions,
Animated,
StatusBar,
Easing,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
withSequence,
withDelay,
Easing,
interpolate,
cancelAnimation,
runOnJS,
SharedValue,
} from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
const { width, height } = Dimensions.get('window');
// Responsive breakpoints
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
interface MetadataLoadingScreenProps {
type?: 'movie' | 'series';
onExitComplete?: () => void;
@ -23,44 +42,120 @@ export interface MetadataLoadingScreenRef {
exit: () => void;
}
// Animated shimmer skeleton component
const ShimmerSkeleton = ({
width: elementWidth,
height: elementHeight,
borderRadius = 8,
marginBottom = 8,
style = {},
delay = 0,
shimmerProgress,
baseColor,
highlightColor,
}: {
width: number | string;
height: number;
borderRadius?: number;
marginBottom?: number;
style?: any;
delay?: number;
shimmerProgress: SharedValue<number>;
baseColor: string;
highlightColor: string;
}) => {
const animatedStyle = useAnimatedStyle(() => {
const translateX = interpolate(
shimmerProgress.value,
[0, 1],
[-width, width]
);
return {
transform: [{ translateX }],
};
});
return (
<View style={[
{
width: elementWidth,
height: elementHeight,
borderRadius,
marginBottom,
backgroundColor: baseColor,
overflow: 'hidden',
},
style
]}>
<Animated.View
style={[
StyleSheet.absoluteFill,
animatedStyle,
]}
>
<LinearGradient
colors={[
'transparent',
highlightColor,
highlightColor,
'transparent',
]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[StyleSheet.absoluteFill, { width: width * 2 }]}
/>
</Animated.View>
</View>
);
};
export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, MetadataLoadingScreenProps>(({
type = 'movie',
onExitComplete
}, ref) => {
const { currentTheme } = useTheme();
// Animation values - shimmer removed
// Scene transition animation values (matching tab navigator)
const sceneOpacity = useRef(new Animated.Value(0)).current;
const sceneScale = useRef(new Animated.Value(0.95)).current;
const sceneTranslateY = useRef(new Animated.Value(8)).current;
// Responsive sizing
const deviceWidth = Dimensions.get('window').width;
const deviceType = useMemo(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const isTV = deviceType === 'tv';
const isLargeTablet = deviceType === 'largeTablet';
const isTablet = deviceType === 'tablet';
const horizontalPadding = isTV ? 48 : isLargeTablet ? 32 : isTablet ? 24 : 16;
// Shimmer animation
const shimmerProgress = useSharedValue(0);
// Staggered fade-in for sections
const heroOpacity = useSharedValue(0);
const contentOpacity = useSharedValue(0);
const castOpacity = useSharedValue(0);
// Exit animation value
const exitProgress = useSharedValue(0);
// Colors for skeleton
const baseColor = currentTheme.colors.elevation1 || 'rgba(255,255,255,0.08)';
const highlightColor = 'rgba(255,255,255,0.12)';
// Exit animation function
const exit = () => {
const exitAnimation = Animated.parallel([
Animated.timing(sceneOpacity, {
toValue: 0,
duration: 100,
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
useNativeDriver: true,
}),
Animated.timing(sceneScale, {
toValue: 0.95,
duration: 100,
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
useNativeDriver: true,
}),
Animated.timing(sceneTranslateY, {
toValue: 8,
duration: 100,
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
useNativeDriver: true,
}),
]);
exitAnimation.start(() => {
onExitComplete?.();
exitProgress.value = withTiming(1, {
duration: 200,
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
}, (finished) => {
'worklet';
if (finished && onExitComplete) {
runOnJS(onExitComplete)();
}
});
};
@ -70,70 +165,57 @@ export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, Metada
}));
useEffect(() => {
// Scene entrance animation (matching tab navigator)
const sceneAnimation = Animated.parallel([
Animated.timing(sceneOpacity, {
toValue: 1,
duration: 100,
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
useNativeDriver: true,
// Start shimmer animation
shimmerProgress.value = withRepeat(
withTiming(1, {
duration: 1500,
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0)
}),
Animated.timing(sceneScale, {
toValue: 1,
duration: 100,
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
useNativeDriver: true,
}),
Animated.timing(sceneTranslateY, {
toValue: 0,
duration: 100,
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
useNativeDriver: true,
}),
]);
-1, // infinite
false
);
sceneAnimation.start();
// Shimmer effect removed
// Staggered entrance animations
heroOpacity.value = withTiming(1, { duration: 300 });
contentOpacity.value = withDelay(100, withTiming(1, { duration: 300 }));
castOpacity.value = withDelay(200, withTiming(1, { duration: 300 }));
return () => {
sceneAnimation.stop();
cancelAnimation(shimmerProgress);
cancelAnimation(heroOpacity);
cancelAnimation(contentOpacity);
cancelAnimation(castOpacity);
};
}, []);
// Shimmer translate removed
// Animated styles
const containerStyle = useAnimatedStyle(() => ({
opacity: interpolate(exitProgress.value, [0, 1], [1, 0]),
transform: [
{ scale: interpolate(exitProgress.value, [0, 1], [1, 0.98]) },
],
}));
const SkeletonElement = ({
width: elementWidth,
height: elementHeight,
borderRadius = 8,
marginBottom = 8,
style = {},
}: {
width: number | string;
height: number;
borderRadius?: number;
marginBottom?: number;
style?: any;
}) => (
<View style={[
{
width: elementWidth,
height: elementHeight,
borderRadius,
marginBottom,
backgroundColor: currentTheme.colors.card,
overflow: 'hidden',
},
style
]}>
{/* Pulsating overlay removed */}
{/* Shimmer overlay removed */}
</View>
);
const heroStyle = useAnimatedStyle(() => ({
opacity: heroOpacity.value,
}));
const contentStyle = useAnimatedStyle(() => ({
opacity: contentOpacity.value,
transform: [
{ translateY: interpolate(contentOpacity.value, [0, 1], [10, 0]) },
],
}));
const castStyle = useAnimatedStyle(() => ({
opacity: castOpacity.value,
transform: [
{ translateY: interpolate(castOpacity.value, [0, 1], [10, 0]) },
],
}));
return (
<SafeAreaView
<SafeAreaView
style={[styles.container, {
backgroundColor: currentTheme.colors.darkBackground,
}]}
@ -144,107 +226,325 @@ export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, Metada
backgroundColor="transparent"
barStyle="light-content"
/>
<Animated.View
style={[
styles.content,
{
opacity: sceneOpacity,
transform: [
{ scale: sceneScale },
{ translateY: sceneTranslateY }
],
}
]}
>
{/* Hero Skeleton */}
<View style={styles.heroSection}>
<SkeletonElement
width="100%"
height={height * 0.6}
<Animated.View style={[styles.content, containerStyle]}>
{/* Hero Section Skeleton */}
<Animated.View style={[styles.heroSection, { height: height * 0.65 }, heroStyle]}>
<ShimmerSkeleton
width="100%"
height={height * 0.65}
borderRadius={0}
marginBottom={0}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
{/* Overlay content on hero */}
{/* Back Button Skeleton */}
<View style={{
position: 'absolute',
top: Platform.OS === 'android' ? 40 : 50,
left: isTablet ? 32 : 16,
zIndex: 10
}}>
<ShimmerSkeleton
width={40}
height={40}
borderRadius={20}
marginBottom={0}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
</View>
{/* Gradient overlay */}
<View style={styles.heroOverlay}>
<LinearGradient
colors={[
'transparent',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.8)',
'rgba(0,0,0,0.05)',
'rgba(0,0,0,0.15)',
'rgba(0,0,0,0.35)',
'rgba(0,0,0,0.65)',
currentTheme.colors.darkBackground,
]}
locations={[0, 0.3, 0.55, 0.75, 0.9, 1]}
style={StyleSheet.absoluteFill}
/>
{/* Bottom hero content skeleton */}
<View style={styles.heroBottomContent}>
<SkeletonElement width="60%" height={32} borderRadius={16} />
<SkeletonElement width="40%" height={20} borderRadius={10} />
<View style={styles.genresRow}>
<SkeletonElement width={80} height={24} borderRadius={12} marginBottom={0} style={{ marginRight: 8 }} />
<SkeletonElement width={90} height={24} borderRadius={12} marginBottom={0} style={{ marginRight: 8 }} />
<SkeletonElement width={70} height={24} borderRadius={12} marginBottom={0} />
{/* Hero bottom content - Matches HeroSection.tsx structure */}
<View style={[styles.heroBottomContent, { paddingHorizontal: horizontalPadding }]}>
{/* Logo placeholder - Centered and larger */}
<View style={{ alignItems: 'center', width: '100%', marginBottom: 16 }}>
<ShimmerSkeleton
width={isTV ? 400 : isLargeTablet ? 300 : width * 0.65}
height={isTV ? 120 : isLargeTablet ? 100 : 90}
borderRadius={12}
marginBottom={0}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
</View>
<View style={styles.buttonsRow}>
<SkeletonElement width={120} height={44} borderRadius={22} marginBottom={0} style={{ marginRight: 12 }} />
<SkeletonElement width={100} height={44} borderRadius={22} marginBottom={0} />
{/* Watch Progress Placeholder - Centered Glass Bar */}
<View style={{ alignItems: 'center', width: '100%', marginBottom: 16 }}>
<ShimmerSkeleton
width="75%"
height={45} // Matches glass background height + padding
borderRadius={12}
marginBottom={0}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
style={{ opacity: 0.5 }} // Slight transparency for glass effect
/>
</View>
{/* Genre Info Row - Centered */}
<View style={[styles.metaRow, { justifyContent: 'center', marginBottom: 20 }]}>
<ShimmerSkeleton
width={isTV ? 60 : 50}
height={12}
borderRadius={6}
marginBottom={0}
style={{ marginRight: 8 }}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
<View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)', marginRight: 8 }} />
<ShimmerSkeleton
width={isTV ? 80 : 70}
height={12}
borderRadius={6}
marginBottom={0}
style={{ marginRight: 8 }}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
<View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)', marginRight: 8 }} />
<ShimmerSkeleton
width={isTV ? 50 : 40}
height={12}
borderRadius={6}
marginBottom={0}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
</View>
{/* Action buttons row - Play, Save, Collection, Rates */}
<View style={[styles.buttonsRow, { justifyContent: 'center', gap: 6 }]}>
{/* Play Button */}
<ShimmerSkeleton
width={isTV ? 180 : isLargeTablet ? 160 : isTablet ? 150 : (width - 32 - 100 - 24) / 2} // Calc based on screen width
height={isTV ? 52 : isLargeTablet ? 48 : 46}
borderRadius={isTV ? 26 : 23}
marginBottom={0}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
{/* Save Button */}
<ShimmerSkeleton
width={isTV ? 180 : isLargeTablet ? 160 : isTablet ? 150 : (width - 32 - 100 - 24) / 2}
height={isTV ? 52 : isLargeTablet ? 48 : 46}
borderRadius={isTV ? 26 : 23}
marginBottom={0}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
{/* Collection Icon */}
<ShimmerSkeleton
width={isTV ? 52 : isLargeTablet ? 48 : 46}
height={isTV ? 52 : isLargeTablet ? 48 : 46}
borderRadius={isTV ? 26 : 23}
marginBottom={0}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
{/* Ratings Icon (if series) - Always show for skeleton consistency */}
<ShimmerSkeleton
width={isTV ? 52 : isLargeTablet ? 48 : 46}
height={isTV ? 52 : isLargeTablet ? 48 : 46}
borderRadius={isTV ? 26 : 23}
marginBottom={0}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
</View>
</View>
</View>
</View>
</Animated.View>
{/* Content Section Skeletons */}
<View style={styles.contentSection}>
{/* Synopsis skeleton */}
<View style={styles.synopsisSection}>
<SkeletonElement width="30%" height={24} borderRadius={12} />
<SkeletonElement width="100%" height={16} borderRadius={8} />
<SkeletonElement width="95%" height={16} borderRadius={8} />
<SkeletonElement width="80%" height={16} borderRadius={8} />
{/* Content Section */}
<Animated.View style={[styles.contentSection, { paddingHorizontal: horizontalPadding }, contentStyle]}>
{/* Description skeleton */}
<View style={styles.descriptionSection}>
<ShimmerSkeleton
width="100%"
height={isTV ? 18 : 15}
borderRadius={4}
marginBottom={10}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
<ShimmerSkeleton
width="95%"
height={isTV ? 18 : 15}
borderRadius={4}
marginBottom={10}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
<ShimmerSkeleton
width="75%"
height={isTV ? 18 : 15}
borderRadius={4}
marginBottom={0}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
</View>
</Animated.View>
{/* Cast section skeleton */}
<View style={styles.castSection}>
<SkeletonElement width="20%" height={24} borderRadius={12} />
<View style={styles.castRow}>
{[1, 2, 3, 4].map((item) => (
<View key={item} style={styles.castItem}>
<SkeletonElement width={80} height={80} borderRadius={40} marginBottom={8} />
<SkeletonElement width={60} height={12} borderRadius={6} marginBottom={4} />
<SkeletonElement width={70} height={10} borderRadius={5} marginBottom={0} />
</View>
))}
</View>
{/* Cast Section */}
<Animated.View style={[styles.castSection, { paddingHorizontal: horizontalPadding }, castStyle]}>
<ShimmerSkeleton
width={isTV ? 80 : 60}
height={isTV ? 24 : 20}
borderRadius={4}
marginBottom={16}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
<View style={styles.castRow}>
{[1, 2, 3, 4, 5].map((item) => (
<View key={item} style={styles.castItem}>
<ShimmerSkeleton
width={isTV ? 100 : isLargeTablet ? 90 : isTablet ? 85 : 80}
height={isTV ? 100 : isLargeTablet ? 90 : isTablet ? 85 : 80}
borderRadius={isTV ? 50 : isLargeTablet ? 45 : isTablet ? 42 : 40}
marginBottom={8}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
<ShimmerSkeleton
width={isTV ? 70 : 60}
height={isTV ? 14 : 12}
borderRadius={4}
marginBottom={4}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
</View>
))}
</View>
</Animated.View>
{/* Episodes/Details skeleton based on type */}
{type === 'series' ? (
<View style={styles.episodesSection}>
<SkeletonElement width="25%" height={24} borderRadius={12} />
<SkeletonElement width={150} height={36} borderRadius={18} />
{/* Episodes/Recommendations Section */}
{type === 'series' ? (
<Animated.View style={[styles.episodesSection, { paddingHorizontal: horizontalPadding }, castStyle]}>
<ShimmerSkeleton
width={isTV ? 120 : 100}
height={isTV ? 24 : 20}
borderRadius={4}
marginBottom={16}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
{/* Season selector */}
<ShimmerSkeleton
width={isTV ? 180 : 140}
height={isTV ? 40 : 36}
borderRadius={20}
marginBottom={20}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
{/* Episode cards */}
<View style={styles.episodeList}>
{[1, 2, 3].map((item) => (
<View key={item} style={styles.episodeItem}>
<SkeletonElement width={120} height={68} borderRadius={8} marginBottom={0} style={{ marginRight: 12 }} />
<View key={item} style={styles.episodeCard}>
<ShimmerSkeleton
width={isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 140}
height={isTV ? 112 : isLargeTablet ? 100 : isTablet ? 90 : 80}
borderRadius={8}
marginBottom={0}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
<View style={styles.episodeInfo}>
<SkeletonElement width="80%" height={16} borderRadius={8} />
<SkeletonElement width="60%" height={14} borderRadius={7} />
<SkeletonElement width="90%" height={12} borderRadius={6} />
<ShimmerSkeleton
width="80%"
height={isTV ? 16 : 14}
borderRadius={4}
marginBottom={6}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
<ShimmerSkeleton
width="60%"
height={isTV ? 14 : 12}
borderRadius={4}
marginBottom={0}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
</View>
</View>
))}
</View>
) : (
<View style={styles.detailsSection}>
<SkeletonElement width="25%" height={24} borderRadius={12} />
<View style={styles.detailsGrid}>
<SkeletonElement width="48%" height={60} borderRadius={8} />
<SkeletonElement width="48%" height={60} borderRadius={8} />
</View>
</Animated.View>
) : (
<Animated.View style={[styles.recommendationsSection, { paddingHorizontal: horizontalPadding }, castStyle]}>
<ShimmerSkeleton
width={isTV ? 140 : 110}
height={isTV ? 24 : 20}
borderRadius={4}
marginBottom={16}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
<View style={styles.posterRow}>
{[1, 2, 3, 4].map((item) => (
<ShimmerSkeleton
key={item}
width={isTV ? 140 : isLargeTablet ? 120 : isTablet ? 110 : 100}
height={isTV ? 210 : isLargeTablet ? 180 : isTablet ? 165 : 150}
borderRadius={8}
marginBottom={0}
style={{ marginRight: 12 }}
shimmerProgress={shimmerProgress}
baseColor={baseColor}
highlightColor={highlightColor}
/>
))}
</View>
)}
</View>
</Animated.View>
)}
</Animated.View>
</SafeAreaView>
);
@ -258,7 +558,6 @@ const styles = StyleSheet.create({
flex: 1,
},
heroSection: {
height: height * 0.6,
position: 'relative',
},
heroOverlay: {
@ -266,54 +565,52 @@ const styles = StyleSheet.create({
justifyContent: 'flex-end',
},
heroBottomContent: {
position: 'absolute',
bottom: 20,
left: 20,
right: 20,
paddingBottom: 20,
},
genresRow: {
metaRow: {
flexDirection: 'row',
marginBottom: 16,
alignItems: 'center',
marginBottom: 8,
},
buttonsRow: {
flexDirection: 'row',
marginBottom: 8,
alignItems: 'center',
},
contentSection: {
padding: 20,
paddingTop: 16,
},
synopsisSection: {
marginBottom: 32,
descriptionSection: {
marginBottom: 24,
},
castSection: {
marginBottom: 32,
marginBottom: 24,
},
castRow: {
flexDirection: 'row',
marginTop: 16,
},
castItem: {
alignItems: 'center',
marginRight: 16,
},
episodesSection: {
marginBottom: 32,
marginBottom: 24,
},
episodeItem: {
episodeList: {
gap: 16,
},
episodeCard: {
flexDirection: 'row',
marginBottom: 16,
alignItems: 'center',
gap: 12,
},
episodeInfo: {
flex: 1,
justifyContent: 'center',
},
detailsSection: {
marginBottom: 32,
recommendationsSection: {
marginBottom: 24,
},
detailsGrid: {
posterRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 16,
},
});

View file

@ -200,7 +200,7 @@ const CompactCommentCard: React.FC<{
// 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';
@ -208,13 +208,13 @@ const CompactCommentCard: React.FC<{
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) {
@ -228,7 +228,7 @@ const CompactCommentCard: React.FC<{
return 280; // phone
}
}, [deviceType]);
const commentCardHeight = useMemo(() => {
switch (deviceType) {
case 'tv':
@ -241,7 +241,7 @@ const CompactCommentCard: React.FC<{
return 170; // phone
}
}, [deviceType]);
const commentCardSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
@ -354,156 +354,156 @@ const CompactCommentCard: React.FC<{
}}
activeOpacity={1}
>
{/* Trakt Icon - Top Right Corner */}
<View style={styles.traktIconContainer}>
<TraktIcon width={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} height={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} />
</View>
{/* Header Section - Fixed at top */}
<View style={[
styles.compactHeader,
{
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
}
]}>
<View style={styles.usernameContainer}>
<Text style={[
styles.compactUsername,
{
color: theme.colors.highEmphasis,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
}
]}>
{username}
</Text>
{user.vip && (
<View style={[
styles.miniVipBadge,
{
paddingHorizontal: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
paddingVertical: isTV ? 2 : isLargeTablet ? 2 : isTablet ? 1 : 1,
borderRadius: isTV ? 8 : isLargeTablet ? 7 : isTablet ? 6 : 6
}
]}>
<Text style={[
styles.miniVipText,
{
fontSize: isTV ? 11 : isLargeTablet ? 10 : isTablet ? 9 : 9
}
]}>VIP</Text>
</View>
)}
{/* Trakt Icon - Top Right Corner */}
<View style={styles.traktIconContainer}>
<TraktIcon width={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} height={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} />
</View>
</View>
{/* Rating - Show stars */}
{comment.user_stats?.rating && (
{/* Header Section - Fixed at top */}
<View style={[
styles.compactRating,
styles.compactHeader,
{
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
}
]}>
{renderCompactStars(comment.user_stats.rating)}
<Text style={[
styles.compactRatingText,
{
color: theme.colors.mediumEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
<View style={styles.usernameContainer}>
<Text style={[
styles.compactUsername,
{
color: theme.colors.highEmphasis,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
}
]}>
{username}
</Text>
{user.vip && (
<View style={[
styles.miniVipBadge,
{
paddingHorizontal: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
paddingVertical: isTV ? 2 : isLargeTablet ? 2 : isTablet ? 1 : 1,
borderRadius: isTV ? 8 : isLargeTablet ? 7 : isTablet ? 6 : 6
}
]}>
<Text style={[
styles.miniVipText,
{
fontSize: isTV ? 11 : isLargeTablet ? 10 : isTablet ? 9 : 9
}
]}>VIP</Text>
</View>
)}
</View>
</View>
{/* Rating - Show stars */}
{comment.user_stats?.rating && (
<View style={[
styles.compactRating,
{
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
}
]}>
{comment.user_stats.rating}/10
</Text>
</View>
)}
{renderCompactStars(comment.user_stats.rating)}
<Text style={[
styles.compactRatingText,
{
color: theme.colors.mediumEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
}
]}>
{comment.user_stats.rating}/10
</Text>
</View>
)}
{/* Comment Preview - Flexible area that fills space */}
<View style={[
styles.commentContainer,
shouldBlurContent ? styles.blurredContent : undefined,
{
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
}
]}>
{shouldBlurContent ? (
<Text style={[
styles.compactComment,
{
color: theme.colors.highEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
}
]}> This comment contains spoilers. Tap to reveal.</Text>
) : (
<MarkdownText
text={comment.comment}
theme={theme}
numberOfLines={isLargeScreen ? 4 : 3}
revealedInlineSpoilers={isSpoilerRevealed}
onSpoilerPress={onSpoilerPress}
textStyle={[
{/* Comment Preview - Flexible area that fills space */}
<View style={[
styles.commentContainer,
shouldBlurContent ? styles.blurredContent : undefined,
{
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
}
]}>
{shouldBlurContent ? (
<Text style={[
styles.compactComment,
{
color: theme.colors.highEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
}
]}
/>
)}
</View>
]}> This comment contains spoilers. Tap to reveal.</Text>
) : (
<MarkdownText
text={comment.comment}
theme={theme}
numberOfLines={isLargeScreen ? 4 : 3}
revealedInlineSpoilers={isSpoilerRevealed}
onSpoilerPress={onSpoilerPress}
textStyle={[
styles.compactComment,
{
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
}
]}
/>
)}
</View>
{/* Meta Info - Fixed at bottom */}
<View style={[
styles.compactMeta,
{
paddingTop: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 6 : 6
}
]}>
<View style={styles.compactBadges}>
{comment.spoiler && (
{/* Meta Info - Fixed at bottom */}
<View style={[
styles.compactMeta,
{
paddingTop: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 6 : 6
}
]}>
<View style={styles.compactBadges}>
{comment.spoiler && (
<Text style={[
styles.spoilerMiniText,
{
color: theme.colors.error,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
}
]}>Spoiler</Text>
)}
</View>
<View style={styles.compactStats}>
<Text style={[
styles.spoilerMiniText,
{
color: theme.colors.error,
styles.compactTime,
{
color: theme.colors.mediumEmphasis,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
}
]}>Spoiler</Text>
)}
</View>
<View style={styles.compactStats}>
<Text style={[
styles.compactTime,
{
color: theme.colors.mediumEmphasis,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
}
]}>
{formatRelativeTime(comment.created_at)}
</Text>
{comment.likes > 0 && (
<Text style={[
styles.compactStat,
{
color: theme.colors.mediumEmphasis,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
}
]}>
👍 {comment.likes}
{formatRelativeTime(comment.created_at)}
</Text>
)}
{comment.replies > 0 && (
<Text style={[
styles.compactStat,
{
color: theme.colors.mediumEmphasis,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
}
]}>
💬 {comment.replies}
</Text>
)}
{comment.likes > 0 && (
<Text style={[
styles.compactStat,
{
color: theme.colors.mediumEmphasis,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
}
]}>
👍 {comment.likes}
</Text>
)}
{comment.replies > 0 && (
<Text style={[
styles.compactStat,
{
color: theme.colors.mediumEmphasis,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
}
]}>
💬 {comment.replies}
</Text>
)}
</View>
</View>
</View>
</TouchableOpacity>
</Animated.View>
);
@ -614,105 +614,105 @@ const ExpandedCommentBottomSheet: React.FC<{
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
{/* Close Button */}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<MaterialIcons name="close" size={24} color={theme.colors.highEmphasis} />
</TouchableOpacity>
{/* Close Button */}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<MaterialIcons name="close" size={24} color={theme.colors.highEmphasis} />
</TouchableOpacity>
{/* User Info */}
<View style={styles.modalHeader}>
<View style={styles.userInfo}>
<Text
style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}
numberOfLines={1}
ellipsizeMode="tail"
>
{username}
</Text>
{user.vip && (
<View style={styles.vipBadge}>
<Text style={styles.vipText}>VIP</Text>
</View>
)}
</View>
{(() => {
const { datePart, timePart } = formatDateParts(comment.created_at);
return (
<View style={styles.dateTimeContainer}>
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
{datePart}
</Text>
{!!timePart && (
<Text style={[styles.modalTime, { color: theme.colors.mediumEmphasis }]}>
{timePart}
</Text>
)}
</View>
);
})()}
</View>
{/* Rating */}
{comment.user_stats?.rating && (
<View style={styles.modalRating}>
{renderStars(comment.user_stats.rating)}
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
{comment.user_stats.rating}/10
</Text>
</View>
)}
{/* Full Comment (Markdown with inline spoilers) */}
{shouldBlurModalContent ? (
<View style={styles.spoilerContainer}>
<View style={[styles.spoilerIcon, { backgroundColor: theme.colors.card }]}>
<MaterialIcons name="visibility-off" size={20} color={theme.colors.mediumEmphasis} />
{/* User Info */}
<View style={styles.modalHeader}>
<View style={styles.userInfo}>
<Text
style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}
numberOfLines={1}
ellipsizeMode="tail"
>
{username}
</Text>
{user.vip && (
<View style={styles.vipBadge}>
<Text style={styles.vipText}>VIP</Text>
</View>
<Text style={[styles.spoilerTitle, { color: theme.colors.highEmphasis }]}>Contains spoilers</Text>
<TouchableOpacity
style={[styles.revealButton, { borderColor: theme.colors.primary }]}
onPress={onSpoilerPress}
activeOpacity={0.9}
>
<MaterialIcons name="visibility" size={18} color={theme.colors.primary} />
<Text style={[styles.revealButtonText, { color: theme.colors.primary }]}>Reveal</Text>
</TouchableOpacity>
</View>
) : (
<View style={{ marginBottom: 16 }}>
<MarkdownText
text={comment.comment}
theme={theme}
revealedInlineSpoilers={true}
textStyle={styles.modalComment}
/>
</View>
)}
{/* Comment Meta */}
<View style={styles.modalMeta}>
{comment.spoiler && (
<Text style={[styles.spoilerText, { color: theme.colors.error }]}>Spoiler</Text>
)}
<View style={styles.modalStats}>
{comment.likes > 0 && (
<View style={styles.likesContainer}>
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
{comment.likes}
</Text>
</View>
)}
{comment.replies > 0 && (
<View style={styles.repliesContainer}>
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
{comment.replies}
</Text>
</View>
)}
</View>
</View>
{(() => {
const { datePart, timePart } = formatDateParts(comment.created_at);
return (
<View style={styles.dateTimeContainer}>
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
{datePart}
</Text>
{!!timePart && (
<Text style={[styles.modalTime, { color: theme.colors.mediumEmphasis }]}>
{timePart}
</Text>
)}
</View>
);
})()}
</View>
{/* Rating */}
{comment.user_stats?.rating && (
<View style={styles.modalRating}>
{renderStars(comment.user_stats.rating)}
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
{comment.user_stats.rating}/10
</Text>
</View>
)}
{/* Full Comment (Markdown with inline spoilers) */}
{shouldBlurModalContent ? (
<View style={styles.spoilerContainer}>
<View style={[styles.spoilerIcon, { backgroundColor: theme.colors.card }]}>
<MaterialIcons name="visibility-off" size={20} color={theme.colors.mediumEmphasis} />
</View>
<Text style={[styles.spoilerTitle, { color: theme.colors.highEmphasis }]}>Contains spoilers</Text>
<TouchableOpacity
style={[styles.revealButton, { borderColor: theme.colors.primary }]}
onPress={onSpoilerPress}
activeOpacity={0.9}
>
<MaterialIcons name="visibility" size={18} color={theme.colors.primary} />
<Text style={[styles.revealButtonText, { color: theme.colors.primary }]}>Reveal</Text>
</TouchableOpacity>
</View>
) : (
<View style={{ marginBottom: 16 }}>
<MarkdownText
text={comment.comment}
theme={theme}
revealedInlineSpoilers={true}
textStyle={styles.modalComment}
/>
</View>
)}
{/* Comment Meta */}
<View style={styles.modalMeta}>
{comment.spoiler && (
<Text style={[styles.spoilerText, { color: theme.colors.error }]}>Spoiler</Text>
)}
<View style={styles.modalStats}>
{comment.likes > 0 && (
<View style={styles.likesContainer}>
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
{comment.likes}
</Text>
</View>
)}
{comment.replies > 0 && (
<View style={styles.repliesContainer}>
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
{comment.replies}
</Text>
</View>
)}
</View>
</View>
</BottomSheetScrollView>
</BottomSheet>
);
@ -732,7 +732,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
// 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';
@ -740,13 +740,13 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
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) {
@ -772,7 +772,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
} = useTraktComments({
imdbId,
type: type === 'show' ? (season !== undefined && episode !== undefined ? 'episode' :
season !== undefined ? 'season' : 'show') : 'movie',
season !== undefined ? 'season' : 'show') : 'movie',
season,
episode,
enabled: true,
@ -924,8 +924,8 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
}
]}>
<Text style={[
styles.title,
{
styles.title,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
}
@ -992,7 +992,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
) : (
<>
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
Load More
</Text>
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.primary} />
@ -1022,15 +1022,19 @@ export const CommentBottomSheet: React.FC<{
}> = ({ comment, visible, onClose, theme, isSpoilerRevealed, onSpoilerPress }) => {
const bottomSheetRef = useRef<BottomSheet>(null);
// Early return before any Reanimated components are rendered
// This prevents the BottomSheet from initializing when not needed
if (!visible || !comment) {
return null;
}
console.log('CommentBottomSheet: Rendered with visible:', visible, 'comment:', comment?.id);
// Calculate the index based on visibility - start at medium height (50%)
const sheetIndex = visible && comment ? 1 : -1;
const sheetIndex = 1; // Always 1 when visible and comment are truthy
console.log('CommentBottomSheet: Calculated sheetIndex:', sheetIndex);
if (!comment) return null;
const user = comment.user || {};
const username = user.name || user.username || 'Anonymous User';
const hasSpoiler = comment.spoiler;
@ -1115,100 +1119,100 @@ export const CommentBottomSheet: React.FC<{
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
{/* User Info */}
<View style={styles.modalHeader}>
<View style={styles.userInfo}>
<Text
style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}
numberOfLines={1}
ellipsizeMode="tail"
>
{username}
</Text>
{user.vip && (
<View style={styles.vipBadge}>
<Text style={styles.vipText}>VIP</Text>
</View>
)}
</View>
{(() => {
const { datePart, timePart } = formatDateParts(comment.created_at);
return (
<View style={styles.dateTimeContainer}>
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
{datePart}
</Text>
{!!timePart && (
<Text style={[styles.modalTime, { color: theme.colors.mediumEmphasis }]}>
{timePart}
</Text>
)}
</View>
);
})()}
</View>
{/* Rating */}
{comment.user_stats?.rating && (
<View style={styles.modalRating}>
{renderStars(comment.user_stats.rating)}
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
{comment.user_stats.rating}/10
</Text>
</View>
)}
{/* Full Comment (Markdown with inline spoilers) */}
{shouldBlurModalContent ? (
<View style={styles.spoilerContainer}>
<View style={[styles.spoilerIcon, { backgroundColor: theme.colors.card }]}>
<MaterialIcons name="visibility-off" size={20} color={theme.colors.mediumEmphasis} />
{/* User Info */}
<View style={styles.modalHeader}>
<View style={styles.userInfo}>
<Text
style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}
numberOfLines={1}
ellipsizeMode="tail"
>
{username}
</Text>
{user.vip && (
<View style={styles.vipBadge}>
<Text style={styles.vipText}>VIP</Text>
</View>
<Text style={[styles.spoilerTitle, { color: theme.colors.highEmphasis }]}>Contains spoilers</Text>
<TouchableOpacity
style={[styles.revealButton, { borderColor: theme.colors.primary }]}
onPress={onSpoilerPress}
activeOpacity={0.9}
>
<MaterialIcons name="visibility" size={18} color={theme.colors.primary} />
<Text style={[styles.revealButtonText, { color: theme.colors.primary }]}>Reveal</Text>
</TouchableOpacity>
</View>
) : (
<View style={{ marginBottom: 16 }}>
<MarkdownText
text={comment.comment}
theme={theme}
revealedInlineSpoilers={true}
textStyle={styles.modalComment}
/>
</View>
)}
{/* Comment Meta */}
<View style={styles.modalMeta}>
{comment.spoiler && (
<Text style={[styles.spoilerText, { color: theme.colors.error }]}>Spoiler</Text>
)}
<View style={styles.modalStats}>
{comment.likes > 0 && (
<View style={styles.likesContainer}>
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
{comment.likes}
</Text>
</View>
)}
{comment.replies > 0 && (
<View style={styles.repliesContainer}>
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
{comment.replies}
</Text>
</View>
)}
</View>
</View>
{(() => {
const { datePart, timePart } = formatDateParts(comment.created_at);
return (
<View style={styles.dateTimeContainer}>
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
{datePart}
</Text>
{!!timePart && (
<Text style={[styles.modalTime, { color: theme.colors.mediumEmphasis }]}>
{timePart}
</Text>
)}
</View>
);
})()}
</View>
{/* Rating */}
{comment.user_stats?.rating && (
<View style={styles.modalRating}>
{renderStars(comment.user_stats.rating)}
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
{comment.user_stats.rating}/10
</Text>
</View>
)}
{/* Full Comment (Markdown with inline spoilers) */}
{shouldBlurModalContent ? (
<View style={styles.spoilerContainer}>
<View style={[styles.spoilerIcon, { backgroundColor: theme.colors.card }]}>
<MaterialIcons name="visibility-off" size={20} color={theme.colors.mediumEmphasis} />
</View>
<Text style={[styles.spoilerTitle, { color: theme.colors.highEmphasis }]}>Contains spoilers</Text>
<TouchableOpacity
style={[styles.revealButton, { borderColor: theme.colors.primary }]}
onPress={onSpoilerPress}
activeOpacity={0.9}
>
<MaterialIcons name="visibility" size={18} color={theme.colors.primary} />
<Text style={[styles.revealButtonText, { color: theme.colors.primary }]}>Reveal</Text>
</TouchableOpacity>
</View>
) : (
<View style={{ marginBottom: 16 }}>
<MarkdownText
text={comment.comment}
theme={theme}
revealedInlineSpoilers={true}
textStyle={styles.modalComment}
/>
</View>
)}
{/* Comment Meta */}
<View style={styles.modalMeta}>
{comment.spoiler && (
<Text style={[styles.spoilerText, { color: theme.colors.error }]}>Spoiler</Text>
)}
<View style={styles.modalStats}>
{comment.likes > 0 && (
<View style={styles.likesContainer}>
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
{comment.likes}
</Text>
</View>
)}
{comment.replies > 0 && (
<View style={styles.repliesContainer}>
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
{comment.replies}
</Text>
</View>
)}
</View>
</View>
</BottomSheetScrollView>
</BottomSheet>
);

View file

@ -59,7 +59,7 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
// 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';
@ -67,13 +67,13 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
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) {
@ -89,8 +89,11 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
}, [deviceType]);
// Animation values for smooth height transition
const animatedHeight = useSharedValue(0);
// Start with a reasonable default height (3 lines * 24px line height = 72px) to prevent layout shift
const defaultCollapsedHeight = isTV ? 84 : isLargeTablet ? 78 : isTablet ? 72 : 72;
const animatedHeight = useSharedValue(defaultCollapsedHeight);
const [measuredHeights, setMeasuredHeights] = useState({ collapsed: 0, expanded: 0 });
const [hasInitialMeasurement, setHasInitialMeasurement] = useState(false);
useEffect(() => {
const checkMDBListEnabled = async () => {
@ -101,7 +104,7 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
setIsMDBEnabled(false); // Default to disabled if there's an error
}
};
checkMDBListEnabled();
}, []);
@ -114,6 +117,12 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
const handleCollapsedTextLayout = (event: any) => {
const { height } = event.nativeEvent.layout;
setMeasuredHeights(prev => ({ ...prev, collapsed: height }));
// Only set initial measurement flag once we have a valid height
if (height > 0 && !hasInitialMeasurement) {
setHasInitialMeasurement(true);
// Update animated height immediately without animation for first measurement
animatedHeight.value = height;
}
};
const handleExpandedTextLayout = (event: any) => {
@ -128,49 +137,53 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
setIsFullDescriptionOpen(!isFullDescriptionOpen);
};
// Initialize height when component mounts or text changes
// Update height when measurements change (only after initial measurement)
useEffect(() => {
if (measuredHeights.collapsed > 0) {
animatedHeight.value = measuredHeights.collapsed;
if (measuredHeights.collapsed > 0 && hasInitialMeasurement && !isFullDescriptionOpen) {
// Only animate if the height actually changed significantly
const currentHeight = animatedHeight.value;
if (Math.abs(currentHeight - measuredHeights.collapsed) > 5) {
animatedHeight.value = measuredHeights.collapsed;
}
}
}, [measuredHeights.collapsed]);
}, [measuredHeights.collapsed, hasInitialMeasurement, isFullDescriptionOpen]);
// Animated style for smooth height transition
// Animated style for smooth height transition - use minHeight to prevent collapse to 0
const animatedDescriptionStyle = useAnimatedStyle(() => ({
height: animatedHeight.value,
height: animatedHeight.value > 0 ? animatedHeight.value : defaultCollapsedHeight,
overflow: 'hidden',
}));
function formatRuntime(runtime: string): string {
// Try to match formats like "1h55min", "2h 7min", "125 min", etc.
const match = runtime.match(/(?:(\d+)\s*h\s*)?(\d+)\s*min/i);
if (match) {
const h = match[1] ? parseInt(match[1], 10) : 0;
const m = match[2] ? parseInt(match[2], 10) : 0;
if (h > 0) {
return `${h}H ${m}M`;
function formatRuntime(runtime: string): string {
// Try to match formats like "1h55min", "2h 7min", "125 min", etc.
const match = runtime.match(/(?:(\d+)\s*h\s*)?(\d+)\s*min/i);
if (match) {
const h = match[1] ? parseInt(match[1], 10) : 0;
const m = match[2] ? parseInt(match[2], 10) : 0;
if (h > 0) {
return `${h}H ${m}M`;
}
if (m < 60) {
return `${m} MIN`;
}
const hours = Math.floor(m / 60);
const mins = m % 60;
return hours > 0 ? `${hours}H ${mins}M` : `${mins} MIN`;
}
if (m < 60) {
return `${m} MIN`;
// Fallback: treat as minutes if it's a number
const r = parseInt(runtime, 10);
if (!isNaN(r)) {
if (r < 60) return `${r} MIN`;
const h = Math.floor(r / 60);
const m = r % 60;
return h > 0 ? `${h}H ${m}M` : `${m} MIN`;
}
const hours = Math.floor(m / 60);
const mins = m % 60;
return hours > 0 ? `${hours}H ${mins}M` : `${mins} MIN`;
}
// Fallback: treat as minutes if it's a number
const r = parseInt(runtime, 10);
if (!isNaN(r)) {
if (r < 60) return `${r} MIN`;
const h = Math.floor(r / 60);
const m = r % 60;
return h > 0 ? `${h}H ${m}M` : `${m} MIN`;
}
// If not matched, return as is
return runtime;
// If not matched, return as is
return runtime;
}
}
return (
<>
@ -188,17 +201,17 @@ function formatRuntime(runtime: string): string {
{/* Meta Info */}
<View style={[
styles.metaInfo,
styles.metaInfo,
loadingMetadata && styles.dimmed,
{
{
paddingHorizontal: horizontalPadding,
gap: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18
}
]}>
{metadata.year && (
<Text style={[
styles.metaText,
{
styles.metaText,
{
color: currentTheme.colors.text,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
}
@ -206,8 +219,8 @@ function formatRuntime(runtime: string): string {
)}
{metadata.runtime && (
<Text style={[
styles.metaText,
{
styles.metaText,
{
color: currentTheme.colors.text,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
}
@ -232,8 +245,8 @@ function formatRuntime(runtime: string): string {
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[
styles.ratingText,
{
styles.ratingText,
{
color: currentTheme.colors.text,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
}
@ -249,7 +262,7 @@ function formatRuntime(runtime: string): string {
<Animated.View
entering={FadeIn.duration(300).delay(100)}
style={[
styles.creatorContainer,
styles.creatorContainer,
loadingMetadata && styles.dimmed,
{ paddingHorizontal: horizontalPadding }
]}
@ -263,16 +276,16 @@ function formatRuntime(runtime: string): string {
}
]}>
<Text style={[
styles.creatorLabel,
{
styles.creatorLabel,
{
color: currentTheme.colors.white,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
}
]}>Director{metadata.directors.length > 1 ? 's' : ''}:</Text>
<Text style={[
styles.creatorText,
{
styles.creatorText,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
@ -289,16 +302,16 @@ function formatRuntime(runtime: string): string {
}
]}>
<Text style={[
styles.creatorLabel,
{
styles.creatorLabel,
{
color: currentTheme.colors.white,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
}
]}>Creator{metadata.creators.length > 1 ? 's' : ''}:</Text>
<Text style={[
styles.creatorText,
{
styles.creatorText,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
@ -308,11 +321,11 @@ function formatRuntime(runtime: string): string {
)}
</Animated.View>
{/* Description */}
{metadata.description && (
{/* Description - Show skeleton if no description yet to prevent layout shift */}
{metadata.description ? (
<Animated.View
style={[
styles.descriptionContainer,
styles.descriptionContainer,
loadingMetadata && styles.dimmed,
{ paddingHorizontal: horizontalPadding }
]}
@ -321,10 +334,10 @@ function formatRuntime(runtime: string): string {
{/* Hidden text elements to measure heights */}
<Text
style={[
styles.description,
{
color: currentTheme.colors.mediumEmphasis,
position: 'absolute',
styles.description,
{
color: currentTheme.colors.mediumEmphasis,
position: 'absolute',
opacity: 0,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
lineHeight: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24
@ -337,10 +350,10 @@ function formatRuntime(runtime: string): string {
</Text>
<Text
style={[
styles.description,
{
color: currentTheme.colors.mediumEmphasis,
position: 'absolute',
styles.description,
{
color: currentTheme.colors.mediumEmphasis,
position: 'absolute',
opacity: 0,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
lineHeight: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24
@ -359,8 +372,8 @@ function formatRuntime(runtime: string): string {
<Animated.View style={animatedDescriptionStyle}>
<Text
style={[
styles.description,
{
styles.description,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
lineHeight: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24
@ -381,8 +394,8 @@ function formatRuntime(runtime: string): string {
}
]}>
<Text style={[
styles.showMoreText,
{
styles.showMoreText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
}
@ -398,6 +411,20 @@ function formatRuntime(runtime: string): string {
)}
</TouchableOpacity>
</Animated.View>
) : (
/* Skeleton placeholder for description to prevent layout shift */
<View
style={[
styles.descriptionContainer,
{ paddingHorizontal: horizontalPadding, minHeight: defaultCollapsedHeight }
]}
>
<View style={[styles.descriptionSkeleton, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={[styles.skeletonLine, { width: '100%', height: isTV ? 18 : 15, backgroundColor: currentTheme.colors.elevation1, marginBottom: 8 }]} />
<View style={[styles.skeletonLine, { width: '95%', height: isTV ? 18 : 15, backgroundColor: currentTheme.colors.elevation1, marginBottom: 8 }]} />
<View style={[styles.skeletonLine, { width: '80%', height: isTV ? 18 : 15, backgroundColor: currentTheme.colors.elevation1 }]} />
</View>
</View>
)}
</>
);
@ -491,6 +518,12 @@ const styles = StyleSheet.create({
fontSize: 14,
marginRight: 4,
},
descriptionSkeleton: {
borderRadius: 4,
},
skeletonLine: {
borderRadius: 4,
},
});
export default React.memo(MetadataDetails);

View file

@ -8,31 +8,7 @@ interface MovieContentProps {
}
export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => {
const { currentTheme } = useTheme();
const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0;
const castDisplay = hasCast ? metadata.cast!.slice(0, 5).join(', ') : '';
return (
<View style={styles.container}>
{/* Additional metadata */}
<View style={styles.additionalInfo}>
{metadata.director && (
<View style={styles.metadataRow}>
<Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Director:</Text>
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{metadata.director}</Text>
</View>
)}
{hasCast && (
<View style={styles.metadataRow}>
<Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Cast:</Text>
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{castDisplay}</Text>
</View>
)}
</View>
</View>
);
return null;
};
const styles = StyleSheet.create({

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState, useRef, useCallback, useMemo, memo } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList } from 'react-native';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList, Modal, Pressable } from 'react-native';
import * as Haptics from 'expo-haptics';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
@ -12,6 +13,7 @@ import { storageService } from '../../services/storageService';
import { useFocusEffect } from '@react-navigation/native';
import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated';
import { TraktService } from '../../services/traktService';
import { watchedService } from '../../services/watchedService';
import { logger } from '../../utils/logger';
import { mmkvStorage } from '../../services/mmkvStorage';
@ -31,6 +33,7 @@ interface SeriesContentProps {
onSelectEpisode: (episode: Episode) => void;
groupedEpisodes?: { [seasonNumber: number]: Episode[] };
metadata?: { poster?: string; id?: string };
imdbId?: string; // IMDb ID for Trakt sync
}
// Add placeholder constant at the top
@ -46,7 +49,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
onSeasonChange,
onSelectEpisode,
groupedEpisodes = {},
metadata
metadata,
imdbId
}) => {
const { currentTheme } = useTheme();
const { settings } = useSettings();
@ -180,6 +184,11 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
const [posterViewVisible, setPosterViewVisible] = useState(true);
const [textViewVisible, setTextViewVisible] = useState(false);
// Episode action menu state
const [episodeActionMenuVisible, setEpisodeActionMenuVisible] = useState(false);
const [selectedEpisodeForAction, setSelectedEpisodeForAction] = useState<Episode | null>(null);
const [markingAsWatched, setMarkingAsWatched] = useState(false);
// Add refs for the scroll views
const seasonScrollViewRef = useRef<ScrollView | null>(null);
const episodeScrollViewRef = useRef<FlashListRef<Episode>>(null);
@ -517,6 +526,207 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
return rating ?? null;
}, [imdbRatingsMap]);
// Handle long press on episode to show action menu
const handleEpisodeLongPress = useCallback((episode: Episode) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setSelectedEpisodeForAction(episode);
setEpisodeActionMenuVisible(true);
}, []);
// Check if an episode is watched (>= 85% progress)
const isEpisodeWatched = useCallback((episode: Episode): boolean => {
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
const progress = episodeProgress[episodeId];
if (!progress) return false;
const progressPercent = (progress.currentTime / progress.duration) * 100;
return progressPercent >= 85;
}, [episodeProgress, metadata?.id]);
// Mark episode as watched
const handleMarkAsWatched = useCallback(async () => {
if (!selectedEpisodeForAction || !metadata?.id) return;
const episode = selectedEpisodeForAction; // Capture for closure
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
// 1. Optimistic UI Update
setEpisodeProgress(prev => ({
...prev,
[episodeId]: { currentTime: 1, duration: 1, lastUpdated: Date.now() } // 100% progress
}));
// 2. Instant Feedback
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setEpisodeActionMenuVisible(false);
setSelectedEpisodeForAction(null);
// 3. Background Async Operation
const showImdbId = imdbId || metadata.id;
try {
const result = await watchedService.markEpisodeAsWatched(
showImdbId,
metadata.id,
episode.season_number,
episode.episode_number
);
// Reload to ensure consistency (e.g. if optimistic update was slightly off or for other effects)
// But we don't strictly *need* to wait for this to update UI
loadEpisodesProgress();
logger.log(`[SeriesContent] Mark as watched result:`, result);
} catch (error) {
logger.error('[SeriesContent] Error marking episode as watched:', error);
// Ideally revert state here, but simple error logging is often enough for non-critical non-transactional actions
loadEpisodesProgress(); // Reload to revert to source of truth
}
}, [selectedEpisodeForAction, metadata?.id, imdbId]);
// Mark episode as unwatched
const handleMarkAsUnwatched = useCallback(async () => {
if (!selectedEpisodeForAction || !metadata?.id) return;
const episode = selectedEpisodeForAction;
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
// 1. Optimistic UI Update - Remove from progress map
setEpisodeProgress(prev => {
const newState = { ...prev };
delete newState[episodeId];
return newState;
});
// 2. Instant Feedback
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setEpisodeActionMenuVisible(false);
setSelectedEpisodeForAction(null);
// 3. Background Async Operation
const showImdbId = imdbId || metadata.id;
try {
const result = await watchedService.unmarkEpisodeAsWatched(
showImdbId,
metadata.id,
episode.season_number,
episode.episode_number
);
loadEpisodesProgress(); // Sync with source of truth
logger.log(`[SeriesContent] Unmark watched result:`, result);
} catch (error) {
logger.error('[SeriesContent] Error unmarking episode as watched:', error);
loadEpisodesProgress(); // Revert
}
}, [selectedEpisodeForAction, metadata?.id, imdbId]);
// Mark entire season as watched
const handleMarkSeasonAsWatched = useCallback(async () => {
if (!metadata?.id) return;
// Capture values
const currentSeason = selectedSeason;
const seasonEpisodes = groupedEpisodes[currentSeason] || [];
const episodeNumbers = seasonEpisodes.map(ep => ep.episode_number);
// 1. Optimistic UI Update
setEpisodeProgress(prev => {
const next = { ...prev };
seasonEpisodes.forEach(ep => {
const id = ep.stremioId || `${metadata.id}:${ep.season_number}:${ep.episode_number}`;
next[id] = { currentTime: 1, duration: 1, lastUpdated: Date.now() };
});
return next;
});
// 2. Instant Feedback
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setEpisodeActionMenuVisible(false);
setSelectedEpisodeForAction(null);
// 3. Background Async Operation
const showImdbId = imdbId || metadata.id;
try {
const result = await watchedService.markSeasonAsWatched(
showImdbId,
metadata.id,
currentSeason,
episodeNumbers
);
// Re-sync with source of truth
loadEpisodesProgress();
logger.log(`[SeriesContent] Mark season as watched result:`, result);
} catch (error) {
logger.error('[SeriesContent] Error marking season as watched:', error);
loadEpisodesProgress(); // Revert
}
}, [metadata?.id, imdbId, selectedSeason, groupedEpisodes]);
// Check if entire season is watched
const isSeasonWatched = useCallback((): boolean => {
const seasonEpisodes = groupedEpisodes[selectedSeason] || [];
if (seasonEpisodes.length === 0) return false;
return seasonEpisodes.every(ep => {
const episodeId = ep.stremioId || `${metadata?.id}:${ep.season_number}:${ep.episode_number}`;
const progress = episodeProgress[episodeId];
if (!progress) return false;
const progressPercent = (progress.currentTime / progress.duration) * 100;
return progressPercent >= 85;
});
}, [groupedEpisodes, selectedSeason, episodeProgress, metadata?.id]);
// Unmark entire season as watched
const handleMarkSeasonAsUnwatched = useCallback(async () => {
if (!metadata?.id) return;
// Capture values
const currentSeason = selectedSeason;
const seasonEpisodes = groupedEpisodes[currentSeason] || [];
const episodeNumbers = seasonEpisodes.map(ep => ep.episode_number);
// 1. Optimistic UI Update - Remove all episodes of season from progress
setEpisodeProgress(prev => {
const next = { ...prev };
seasonEpisodes.forEach(ep => {
const id = ep.stremioId || `${metadata.id}:${ep.season_number}:${ep.episode_number}`;
delete next[id];
});
return next;
});
// 2. Instant Feedback
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setEpisodeActionMenuVisible(false);
setSelectedEpisodeForAction(null);
// 3. Background Async Operation
const showImdbId = imdbId || metadata.id;
try {
const result = await watchedService.unmarkSeasonAsWatched(
showImdbId,
metadata.id,
currentSeason,
episodeNumbers
);
// Re-sync
loadEpisodesProgress();
logger.log(`[SeriesContent] Unmark season as watched result:`, result);
} catch (error) {
logger.error('[SeriesContent] Error unmarking season as watched:', error);
loadEpisodesProgress(); // Revert
}
}, [metadata?.id, imdbId, selectedSeason, groupedEpisodes]);
// Close action menu
const closeEpisodeActionMenu = useCallback(() => {
setEpisodeActionMenuVisible(false);
setSelectedEpisodeForAction(null);
}, []);
if (loadingSeasons) {
return (
<View style={styles.centeredContainer}>
@ -543,7 +753,11 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => {
if (a === 0) return 1;
if (b === 0) return -1;
return a - b;
});
return (
<View style={[
@ -660,7 +874,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
{ color: currentTheme.colors.highEmphasis }
]
]} numberOfLines={1}>
Season {season}
{season === 0 ? 'Specials' : `Season ${season}`}
</Text>
</TouchableOpacity>
</View>
@ -723,7 +937,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
]
]}
>
Season {season}
{season === 0 ? 'Specials' : `Season ${season}`}
</Text>
</TouchableOpacity>
</View>
@ -822,6 +1036,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
}
]}
onPress={() => onSelectEpisode(episode)}
onLongPress={() => handleEpisodeLongPress(episode)}
delayLongPress={400}
activeOpacity={0.7}
>
<View style={[
@ -1103,6 +1319,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
}
]}
onPress={() => onSelectEpisode(episode)}
onLongPress={() => handleEpisodeLongPress(episode)}
delayLongPress={400}
activeOpacity={0.85}
>
{/* Solid outline replaces gradient border */}
@ -1434,6 +1652,205 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
)
)}
</Animated.View>
{/* Episode Action Menu Modal */}
<Modal
visible={episodeActionMenuVisible}
transparent
animationType="fade"
onRequestClose={closeEpisodeActionMenu}
statusBarTranslucent
>
<Pressable
style={{
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.85)', // Darker overlay
justifyContent: 'center',
alignItems: 'center',
padding: 20,
}}
onPress={closeEpisodeActionMenu}
>
<Pressable
style={{
backgroundColor: '#1E1E1E', // Solid opaque dark background
borderRadius: isTV ? 20 : 16,
padding: isTV ? 24 : 20,
width: isTV ? 400 : isLargeTablet ? 360 : isTablet ? 320 : '100%',
maxWidth: 400,
alignSelf: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)', // Subtle border
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 10,
},
shadowOpacity: 0.51,
shadowRadius: 13.16,
elevation: 20,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View style={{ marginBottom: isTV ? 20 : 16 }}>
<Text style={{
color: '#FFFFFF', // High contrast text
fontSize: isTV ? 20 : 18,
fontWeight: '700',
marginBottom: 4,
}}>
{selectedEpisodeForAction ? `S${selectedEpisodeForAction.season_number}E${selectedEpisodeForAction.episode_number}` : ''}
</Text>
<Text style={{
color: '#AAAAAA', // Medium emphasis text
fontSize: isTV ? 16 : 14,
}} numberOfLines={1} ellipsizeMode="tail">
{selectedEpisodeForAction?.name || ''}
</Text>
</View>
{/* Action buttons */}
<View style={{ gap: isTV ? 12 : 10 }}>
{/* Mark as Watched / Unwatched */}
{selectedEpisodeForAction && (
isEpisodeWatched(selectedEpisodeForAction) ? (
<TouchableOpacity
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)', // Defined background
padding: isTV ? 16 : 14,
borderRadius: isTV ? 12 : 10,
opacity: markingAsWatched ? 0.5 : 1,
}}
onPress={handleMarkAsUnwatched}
disabled={markingAsWatched}
>
<MaterialIcons
name="visibility-off"
size={isTV ? 24 : 22}
color="#FFFFFF"
style={{ marginRight: 12 }}
/>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 16 : 15,
fontWeight: '500',
}}>
{markingAsWatched ? 'Removing...' : 'Mark as Unwatched'}
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: currentTheme.colors.primary,
padding: isTV ? 16 : 14,
borderRadius: isTV ? 12 : 10,
opacity: markingAsWatched ? 0.5 : 1,
}}
onPress={handleMarkAsWatched}
disabled={markingAsWatched}
>
<MaterialIcons
name="check-circle"
size={isTV ? 24 : 22}
color="#FFFFFF"
style={{ marginRight: 12 }}
/>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 16 : 15,
fontWeight: '600',
}}>
{markingAsWatched ? 'Marking...' : 'Mark as Watched'}
</Text>
</TouchableOpacity>
)
)}
{/* Mark Season as Watched / Unwatched */}
{isSeasonWatched() ? (
<TouchableOpacity
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
padding: isTV ? 16 : 14,
borderRadius: isTV ? 12 : 10,
opacity: markingAsWatched ? 0.5 : 1,
}}
onPress={handleMarkSeasonAsUnwatched}
disabled={markingAsWatched}
>
<MaterialIcons
name="playlist-remove"
size={isTV ? 24 : 22}
color="#FFFFFF"
style={{ marginRight: 12 }}
/>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 16 : 15,
fontWeight: '500',
flex: 1, // Allow text to take up space
}} numberOfLines={1}>
{markingAsWatched ? 'Removing...' : `Unmark Season ${selectedSeason}`}
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
padding: isTV ? 16 : 14,
borderRadius: isTV ? 12 : 10,
opacity: markingAsWatched ? 0.5 : 1,
}}
onPress={handleMarkSeasonAsWatched}
disabled={markingAsWatched}
>
<MaterialIcons
name="playlist-add-check"
size={isTV ? 24 : 22}
color="#FFFFFF"
style={{ marginRight: 12 }}
/>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 16 : 15,
fontWeight: '500',
flex: 1,
}} numberOfLines={1}>
{markingAsWatched ? 'Marking...' : `Mark Season ${selectedSeason}`}
</Text>
</TouchableOpacity>
)}
{/* Cancel */}
<TouchableOpacity
style={{
alignItems: 'center',
padding: isTV ? 14 : 12,
marginTop: isTV ? 8 : 4,
}}
onPress={closeEpisodeActionMenu}
>
<Text style={{
color: '#999999',
fontSize: isTV ? 15 : 14,
fontWeight: '500',
}}>
Cancel
</Text>
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
</Modal>
</View>
);
};

View file

@ -133,6 +133,20 @@ const AndroidVideoPlayer: React.FC = () => {
} as any;
};
// Helper to get dynamic volume icon
const getVolumeIcon = (value: number) => {
if (value === 0) return 'volume-off';
if (value < 0.3) return 'volume-mute';
if (value < 0.6) return 'volume-down';
return 'volume-up';
};
// Helper to get dynamic brightness icon
const getBrightnessIcon = (value: number) => {
if (value < 0.3) return 'brightness-low';
if (value < 0.7) return 'brightness-medium';
return 'brightness-high';
};
// Get appropriate headers based on stream type
const getStreamHeaders = () => {
@ -861,18 +875,21 @@ const AndroidVideoPlayer: React.FC = () => {
// Re-apply immersive mode on layout changes to keep system bars hidden
enableImmersiveMode();
});
const initializePlayer = async () => {
StatusBar.setHidden(true, 'none');
enableImmersiveMode();
startOpeningAnimation();
// Initialize current volume and brightness levels
// Volume starts at 1.0 (full volume) - React Native Video handles this natively
setVolume(1.0);
if (DEBUG_MODE) {
logger.log(`[AndroidVideoPlayer] Initial volume: 1.0 (native)`);
}
// Immediate player setup - UI critical
StatusBar.setHidden(true, 'none');
enableImmersiveMode();
startOpeningAnimation();
// Initialize volume immediately (no async)
setVolume(1.0);
if (DEBUG_MODE) {
logger.log(`[AndroidVideoPlayer] Initial volume: 1.0 (native)`);
}
// Defer brightness initialization until after navigation animation completes
// This prevents sluggish player entry
const brightnessTask = InteractionManager.runAfterInteractions(async () => {
try {
// Capture Android system brightness and mode to restore later
if (Platform.OS === 'android') {
@ -900,10 +917,11 @@ const AndroidVideoPlayer: React.FC = () => {
// Fallback to 1.0 if brightness API fails
setBrightness(1.0);
}
};
initializePlayer();
});
return () => {
subscription?.remove();
brightnessTask.cancel();
disableImmersiveMode();
};
}, []);
@ -1758,11 +1776,20 @@ const AndroidVideoPlayer: React.FC = () => {
if (Platform.OS !== 'android') return;
try {
// Restore mode first (if available), then brightness value
if (originalSystemBrightnessModeRef.current !== null && typeof (Brightness as any).setSystemBrightnessModeAsync === 'function') {
await (Brightness as any).setSystemBrightnessModeAsync(originalSystemBrightnessModeRef.current);
}
if (originalSystemBrightnessRef.current !== null && typeof (Brightness as any).setSystemBrightnessAsync === 'function') {
await (Brightness as any).setSystemBrightnessAsync(originalSystemBrightnessRef.current);
// Restore mode first (if available), then brightness value
if (typeof (Brightness as any).restoreSystemBrightnessAsync === 'function') {
await (Brightness as any).restoreSystemBrightnessAsync();
} else {
// Fallback: verify we have permission before attempting to write to system settings
const { status } = await (Brightness as any).getPermissionsAsync();
if (status === 'granted') {
if (originalSystemBrightnessModeRef.current !== null && typeof (Brightness as any).setSystemBrightnessModeAsync === 'function') {
await (Brightness as any).setSystemBrightnessModeAsync(originalSystemBrightnessModeRef.current);
}
if (originalSystemBrightnessRef.current !== null && typeof (Brightness as any).setSystemBrightnessAsync === 'function') {
await (Brightness as any).setSystemBrightnessAsync(originalSystemBrightnessRef.current);
}
}
}
if (DEBUG_MODE) {
logger.log('[AndroidVideoPlayer] Restored Android system brightness and mode');
@ -1772,55 +1799,52 @@ const AndroidVideoPlayer: React.FC = () => {
}
};
await restoreSystemBrightness();
// Don't await brightness restoration - do it in background
restoreSystemBrightness();
// Navigate immediately without delay
ScreenOrientation.unlockAsync().then(() => {
// On tablets keep rotation unlocked; on phones, return to portrait
const { width: dw, height: dh } = Dimensions.get('window');
const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true);
if (!isTablet) {
setTimeout(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
}, 50);
} else {
ScreenOrientation.unlockAsync().catch(() => { });
}
disableImmersiveMode();
// Disable immersive mode immediately (synchronous)
disableImmersiveMode();
// Simple back navigation (StreamsScreen should be below Player)
if ((navigation as any).canGoBack && (navigation as any).canGoBack()) {
(navigation as any).goBack();
} else {
// Fallback to Streams if stack isn't present
(navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true });
}
}).catch(() => {
// Fallback: still try to restore portrait on phones then navigate
const { width: dw, height: dh } = Dimensions.get('window');
const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true);
if (!isTablet) {
setTimeout(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
}, 50);
} else {
ScreenOrientation.unlockAsync().catch(() => { });
}
disableImmersiveMode();
// Navigate IMMEDIATELY - don't wait for orientation changes
if ((navigation as any).canGoBack && (navigation as any).canGoBack()) {
(navigation as any).goBack();
} else {
// Fallback to Streams if stack isn't present
(navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true });
}
// Simple back navigation fallback path
if ((navigation as any).canGoBack && (navigation as any).canGoBack()) {
(navigation as any).goBack();
} else {
(navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true });
}
});
// Fire orientation changes in background - don't await
ScreenOrientation.unlockAsync()
.then(() => {
// On tablets keep rotation unlocked; on phones, return to portrait
const { width: dw, height: dh } = Dimensions.get('window');
const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true);
if (!isTablet) {
setTimeout(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
}, 50);
} else {
ScreenOrientation.unlockAsync().catch(() => { });
}
})
.catch(() => {
// Fallback: still try to restore portrait on phones
const { width: dw, height: dh } = Dimensions.get('window');
const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true);
if (!isTablet) {
setTimeout(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
}, 50);
} else {
ScreenOrientation.unlockAsync().catch(() => { });
}
});
// Send Trakt sync in background (don't await)
const backgroundSync = async () => {
try {
logger.log('[AndroidVideoPlayer] Starting background Trakt sync');
// IMMEDIATE: Force immediate progress update (scrobble/pause) with the exact time
// IMMEDIATE: Force immediate progress update (uses scrobble/stop which handles pause/scrobble)
await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true);
// IMMEDIATE: Use user_close reason to trigger immediate scrobble stop
@ -2558,7 +2582,7 @@ const AndroidVideoPlayer: React.FC = () => {
logger.log('[AndroidVideoPlayer] Fetching streams for next episode:', nextEpisodeId);
// Import stremio service
// Import stremio service
const stremioService = require('../../services/stremioService').default;
let bestStream: any = null;
@ -2803,11 +2827,13 @@ const AndroidVideoPlayer: React.FC = () => {
// Best-effort restore of Android system brightness state on unmount
if (Platform.OS === 'android') {
try {
if (originalSystemBrightnessModeRef.current !== null && typeof (Brightness as any).setSystemBrightnessModeAsync === 'function') {
(Brightness as any).setSystemBrightnessModeAsync(originalSystemBrightnessModeRef.current);
}
if (originalSystemBrightnessRef.current !== null && typeof (Brightness as any).setSystemBrightnessAsync === 'function') {
(Brightness as any).setSystemBrightnessAsync(originalSystemBrightnessRef.current);
// Use restoreSystemBrightnessAsync if available to reset window override
if (typeof (Brightness as any).restoreSystemBrightnessAsync === 'function') {
(Brightness as any).restoreSystemBrightnessAsync();
} else {
// Fallback for older versions or if restore is not available
// Only attempt to write system settings if strictly necessary and likely to succeed
// We skip the permission check here for sync cleanup, but catch the error if it fails
}
} catch (e) {
logger.warn('[AndroidVideoPlayer] Failed to restore system brightness on unmount:', e);
@ -3124,7 +3150,7 @@ const AndroidVideoPlayer: React.FC = () => {
}
]}
>
{/* Combined gesture handler for left side - brightness + tap + long press */}
{/* Left side gesture handler - brightness + tap + long press (Android and iOS) */}
<LongPressGestureHandler
onActivated={onLongPressActivated}
onEnded={onLongPressEnd}
@ -3148,11 +3174,11 @@ const AndroidVideoPlayer: React.FC = () => {
>
<View style={{
position: 'absolute',
top: screenDimensions.height * 0.15, // Back to original margin
top: screenDimensions.height * 0.15,
left: 0,
width: screenDimensions.width * 0.4, // Back to larger area (40% of screen)
height: screenDimensions.height * 0.7, // Back to larger middle portion (70% of screen)
zIndex: 10, // Higher z-index to capture gestures
width: screenDimensions.width * 0.4,
height: screenDimensions.height * 0.7,
zIndex: 10,
}} />
</TapGestureHandler>
</PanGestureHandler>
@ -3406,8 +3432,57 @@ const AndroidVideoPlayer: React.FC = () => {
buffered={buffered}
formatTime={formatTime}
playerBackend={useVLC ? 'VLC' : 'ExoPlayer'}
nextLoadingTitle={nextLoadingTitle}
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 120 : 100}
/>
{/* Combined Volume & Brightness Gesture Indicator - NEW PILL STYLE (No Bar) */}
{(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && (
<View style={localStyles.gestureIndicatorContainer}>
{/* Dynamic Icon */}
<View
style={[
localStyles.iconWrapper,
{
// Conditional Background Color Logic
backgroundColor: gestureControls.showVolumeOverlay && volume === 0
? 'rgba(242, 184, 181)'
: 'rgba(59, 59, 59)'
}
]}
>
<MaterialIcons
name={
gestureControls.showVolumeOverlay
? getVolumeIcon(volume)
: getBrightnessIcon(brightness)
}
size={24} // Reduced size to fit inside a 32-40px circle better
color={
gestureControls.showVolumeOverlay && volume === 0
? 'rgba(96, 20, 16)' // Bright RED for MUTE icon itself
: 'rgba(255, 255, 255)' // White for all other states
}
/>
</View>
{/* Text Label: Shows "Muted" or percentage */}
<Text
style={[
localStyles.gestureText,
// Conditional Text Color Logic
gestureControls.showVolumeOverlay && volume === 0 && { color: 'rgba(242, 184, 181)' } // Light RED for "Muted"
]}
>
{/* Conditional Text Content Logic */}
{gestureControls.showVolumeOverlay && volume === 0
? "Muted" // Display "Muted" when volume is 0
: `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%` // Display percentage otherwise
}
</Text>
</View>
)}
{showPauseOverlay && (
<TouchableOpacity
activeOpacity={1}
@ -3750,214 +3825,20 @@ const AndroidVideoPlayer: React.FC = () => {
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 120 : 100}
/>
{/* Volume Overlay */}
{gestureControls.showVolumeOverlay && (
<Animated.View
style={{
position: 'absolute',
left: screenDimensions.width / 2 - 60,
top: screenDimensions.height / 2 - 60,
opacity: gestureControls.volumeOverlayOpacity,
zIndex: 1000,
}}
>
<View style={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
borderRadius: 12,
padding: 16,
alignItems: 'center',
width: 120,
height: 120,
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 8,
elevation: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
}}>
<MaterialIcons
name={volume === 0 ? "volume-off" : volume < 0.3 ? "volume-mute" : volume < 0.7 ? "volume-down" : "volume-up"}
size={24}
color={volume === 0 ? "#FF6B6B" : "#FFFFFF"}
style={{ marginBottom: 8 }}
/>
{/* Horizontal Dotted Progress Bar */}
<View style={{
width: 80,
height: 6,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 3,
position: 'relative',
overflow: 'hidden',
marginBottom: 8,
}}>
{/* Dotted background */}
<View style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 1,
}}>
{Array.from({ length: 16 }, (_, i) => (
<View
key={i}
style={{
width: 1.5,
height: 1.5,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
borderRadius: 0.75,
}}
/>
))}
</View>
{/* Progress fill */}
<View style={{
position: 'absolute',
top: 0,
left: 0,
width: `${volume * 100}%`,
height: 6,
backgroundColor: volume === 0 ? '#FF6B6B' : '#E50914',
borderRadius: 3,
shadowColor: volume === 0 ? '#FF6B6B' : '#E50914',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.6,
shadowRadius: 2,
}} />
</View>
<Text style={{
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.5,
}}>
{Math.round(volume * 100)}%
</Text>
</View>
</Animated.View>
)}
{/* Brightness Overlay */}
{gestureControls.showBrightnessOverlay && (
<Animated.View
style={{
position: 'absolute',
left: screenDimensions.width / 2 - 60,
top: screenDimensions.height / 2 - 60,
opacity: gestureControls.brightnessOverlayOpacity,
zIndex: 1000,
}}
>
<View style={{
backgroundColor: 'rgba(0, 0, 0, 0.9)',
borderRadius: 12,
padding: 16,
alignItems: 'center',
width: 120,
height: 120,
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 8,
elevation: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
}}>
<MaterialIcons
name={brightness < 0.2 ? "brightness-low" : brightness < 0.5 ? "brightness-medium" : brightness < 0.8 ? "brightness-high" : "brightness-auto"}
size={24}
color={brightness < 0.2 ? "#FFD700" : "#FFFFFF"}
style={{ marginBottom: 8 }}
/>
{/* Horizontal Dotted Progress Bar */}
<View style={{
width: 80,
height: 6,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 3,
position: 'relative',
overflow: 'hidden',
marginBottom: 8,
}}>
{/* Dotted background */}
<View style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 1,
}}>
{Array.from({ length: 16 }, (_, i) => (
<View
key={i}
style={{
width: 1.5,
height: 1.5,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
borderRadius: 0.75,
}}
/>
))}
</View>
{/* Progress fill */}
<View style={{
position: 'absolute',
top: 0,
left: 0,
width: `${brightness * 100}%`,
height: 6,
backgroundColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
borderRadius: 3,
shadowColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.6,
shadowRadius: 2,
}} />
</View>
<Text style={{
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.5,
}}>
{Math.round(brightness * 100)}%
</Text>
</View>
</Animated.View>
)}
{/* Speed Activated Overlay */}
{showSpeedActivatedOverlay && (
<Animated.View
style={{
position: 'absolute',
top: screenDimensions.height * 0.1,
top: screenDimensions.height * 0.06,
left: screenDimensions.width / 2 - 40,
opacity: speedActivatedOverlayOpacity,
zIndex: 1000,
}}
>
<View style={{
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderRadius: 8,
backgroundColor: 'rgba(25, 25, 25, 0.6)',
borderRadius: 35,
paddingHorizontal: 12,
paddingVertical: 6,
alignItems: 'center',
@ -3974,7 +3855,7 @@ const AndroidVideoPlayer: React.FC = () => {
fontWeight: '600',
letterSpacing: 0.5,
}}>
{holdToSpeedValue}x Speed Activated
{holdToSpeedValue}x Speed
</Text>
</View>
</Animated.View>
@ -4183,4 +4064,36 @@ const AndroidVideoPlayer: React.FC = () => {
);
};
export default AndroidVideoPlayer;
// New styles for the gesture indicator
const localStyles = StyleSheet.create({
gestureIndicatorContainer: {
position: 'absolute',
top: '4%', // Adjust this for vertical position
alignSelf: 'center', // Adjust this for horizontal position
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(25, 25, 25)', // Dark pill background
borderRadius: 70,
paddingHorizontal: 15,
paddingVertical: 15,
zIndex: 2000, // Very high z-index to ensure visibility
minWidth: 120, // Adjusted min width since bar is removed
},
iconWrapper: {
borderRadius: 50, // Makes it a perfect circle (set to a high number)
width: 40, // Define the diameter of the circle
height: 40, // Define the diameter of the circle
justifyContent: 'center',
alignItems: 'center',
marginRight: 12, // Margin to separate icon circle from percentage text
},
gestureText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: 'normal',
minWidth: 35,
textAlign: 'right',
},
});
export default AndroidVideoPlayer;

View file

@ -130,7 +130,9 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
getTracks: async () => {
if (nativeRef.current) {
const node = findNodeHandle(nativeRef.current);
return await KSPlayerModule.getTracks(node);
if (node) {
return await KSPlayerModule.getTracks(node);
}
}
return { audioTracks: [], textTracks: [] };
},
@ -153,15 +155,21 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
getAirPlayState: async () => {
if (nativeRef.current) {
const node = findNodeHandle(nativeRef.current);
return await KSPlayerModule.getAirPlayState(node);
if (node) {
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);
if (node) {
console.log('[KSPlayerComponent] Calling showAirPlayPicker with node:', node);
KSPlayerModule.showAirPlayPicker(node);
} else {
console.warn('[KSPlayerComponent] Cannot call showAirPlayPicker: node is null');
}
} else {
console.log('[KSPlayerComponent] nativeRef.current is null');
}

View file

@ -589,20 +589,19 @@ const KSPlayerCore: React.FC = () => {
// Force landscape orientation after opening animation completes
useEffect(() => {
const lockOrientation = async () => {
try {
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation');
} catch (error) {
logger.warn('[VideoPlayer] Failed to lock orientation:', error);
}
};
// Lock orientation after opening animation completes to prevent glitches
// Defer orientation lock until after navigation animation to prevent sluggishness
if (isOpeningAnimationComplete) {
lockOrientation();
const task = InteractionManager.runAfterInteractions(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE)
.then(() => {
if (__DEV__) logger.log('[VideoPlayer] Locked to landscape orientation');
})
.catch((error) => {
logger.warn('[VideoPlayer] Failed to lock orientation:', error);
});
});
return () => task.cancel();
}
return () => {
// Do not unlock orientation here; we unlock explicitly on close to avoid mid-transition flips
};
@ -616,21 +615,24 @@ const KSPlayerCore: React.FC = () => {
enableImmersiveMode();
}
});
const initializePlayer = async () => {
StatusBar.setHidden(true, 'none');
// Enable immersive mode after opening animation to prevent glitches
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
startOpeningAnimation();
// Initialize current volume and brightness levels
// Volume starts at 100 (full volume) for KSPlayer
setVolume(100);
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] Initial volume: 100 (KSPlayer native)`);
}
// Immediate player setup - UI critical
StatusBar.setHidden(true, 'none');
// Enable immersive mode after opening animation to prevent glitches
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
startOpeningAnimation();
// Initialize volume immediately (no async)
setVolume(100);
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] Initial volume: 100 (KSPlayer native)`);
}
// Defer brightness initialization until after navigation animation completes
// This prevents sluggish player entry
const brightnessTask = InteractionManager.runAfterInteractions(async () => {
try {
const currentBrightness = await Brightness.getBrightnessAsync();
setBrightness(currentBrightness);
@ -642,10 +644,11 @@ const KSPlayerCore: React.FC = () => {
// Fallback to 1.0 if brightness API fails
setBrightness(1.0);
}
};
initializePlayer();
});
return () => {
subscription?.remove();
brightnessTask.cancel();
disableImmersiveMode();
};
}, [isOpeningAnimationComplete]);
@ -1381,32 +1384,32 @@ const KSPlayerCore: React.FC = () => {
logger.log(`[VideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`);
// Cleanup and navigate back immediately without delay
const cleanup = async () => {
try {
// Unlock orientation first
await ScreenOrientation.unlockAsync();
logger.log('[VideoPlayer] Orientation unlocked');
} catch (orientationError) {
logger.warn('[VideoPlayer] Failed to unlock orientation:', orientationError);
}
// On iOS tablets, keep rotation unlocked; on phones, return to portrait
if (Platform.OS === 'ios') {
const { width: dw, height: dh } = Dimensions.get('window');
const isTablet = (Platform as any).isPad === true || Math.min(dw, dh) >= 768;
setTimeout(() => {
if (isTablet) {
ScreenOrientation.unlockAsync().catch(() => { });
} else {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
const cleanup = () => {
// Fire orientation changes in background - don't await them
ScreenOrientation.unlockAsync()
.then(() => {
logger.log('[VideoPlayer] Orientation unlocked');
// On iOS tablets, keep rotation unlocked; on phones, return to portrait
if (Platform.OS === 'ios') {
const { width: dw, height: dh } = Dimensions.get('window');
const isTablet = (Platform as any).isPad === true || Math.min(dw, dh) >= 768;
setTimeout(() => {
if (isTablet) {
ScreenOrientation.unlockAsync().catch(() => { });
} else {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
}
}, 50);
}
}, 50);
}
})
.catch((orientationError: any) => {
logger.warn('[VideoPlayer] Failed to unlock orientation:', orientationError);
});
// Disable immersive mode
// Disable immersive mode (synchronous)
disableImmersiveMode();
// Navigate back to previous screen (StreamsScreen expected to be below Player)
// Navigate back IMMEDIATELY - don't wait for orientation
try {
if (navigation.canGoBack()) {
navigation.goBack();
@ -1429,7 +1432,7 @@ const KSPlayerCore: React.FC = () => {
const backgroundSync = async () => {
try {
logger.log('[VideoPlayer] Starting background Trakt sync');
// IMMEDIATE: Force immediate progress update (scrobble/pause) with the exact time
// IMMEDIATE: Force immediate progress update (uses scrobble/stop which handles pause/scrobble)
await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true);
// IMMEDIATE: Use user_close reason to trigger immediate scrobble stop

View file

@ -83,215 +83,225 @@ export function useFeaturedContent() {
const signal = abortControllerRef.current.signal;
try {
let formattedContent: StreamingContent[] = [];
// Load list of catalogs to fetch
const configs = await catalogService.resolveHomeCatalogsToFetch(selectedCatalogs);
{
// Load from installed catalogs with optimization
const tCats = Date.now();
// Pass selected catalogs to service for optimized fetching
const catalogs = await catalogService.getHomeCatalogs(selectedCatalogs);
if (signal.aborted) return;
if (signal.aborted) return;
// Prepare for incremental loading
const seenIds = new Set<string>();
let accumulatedContent: StreamingContent[] = [];
const TARGET_COUNT = 10;
let hasSetInitialContent = false;
// If no catalogs are installed, stop loading and return.
if (catalogs.length === 0) {
formattedContent = [];
} else {
// Use catalogs directly (filtering is now done in service)
const filteredCatalogs = catalogs;
// Helper function to enrich items
const enrichItems = async (items: any[]): Promise<StreamingContent[]> => {
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
// Flatten all catalog items into a single array, filter out items without posters
const tFlat = Date.now();
const allItems = filteredCatalogs.flatMap(catalog => catalog.items)
.filter(item => item.poster)
.filter((item, index, self) =>
// Remove duplicates based on ID
index === self.findIndex(t => t.id === item.id)
);
// Sort by popular, newest, etc. (possibly enhanced later) and take first 10
const topItems = allItems.sort(() => Math.random() - 0.5).slice(0, 10);
// Optionally enrich with logos (TMDB only) for tmdb/imdb sourced IDs
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
const enrichLogo = async (item: any): Promise<StreamingContent> => {
const base: StreamingContent = {
id: item.id,
type: item.type,
name: item.name,
poster: item.poster,
banner: (item as any).banner,
logo: (item as any).logo,
description: (item as any).description,
year: (item as any).year,
genres: (item as any).genres,
inLibrary: Boolean((item as any).inLibrary),
};
try {
if (!settings.enrichMetadataWithTMDB) {
// When enrichment is OFF, keep addon logo or undefined
return { ...base, logo: base.logo || undefined };
}
// When enrichment is ON, fetch from TMDB with language preference
const rawId = String(item.id);
const isTmdb = rawId.startsWith('tmdb:');
const isImdb = rawId.startsWith('tt');
let tmdbId: string | null = null;
let imdbId: string | null = null;
if (isTmdb) tmdbId = rawId.split(':')[1];
if (isImdb) imdbId = rawId.split(':')[0];
if (!tmdbId && imdbId) {
const found = await tmdbService.findTMDBIdByIMDB(imdbId);
tmdbId = found ? String(found) : null;
}
if (tmdbId) {
const logoUrl = await tmdbService.getContentLogo(item.type === 'series' ? 'tv' : 'movie', tmdbId as string, preferredLanguage);
return { ...base, logo: logoUrl || undefined }; // TMDB logo or undefined (no addon fallback)
}
return { ...base, logo: undefined }; // No TMDB ID means no logo
} catch (error) {
return { ...base, logo: undefined }; // Error means no logo
}
const enrichLogo = async (item: any): Promise<StreamingContent> => {
const base: StreamingContent = {
id: item.id,
type: item.type,
name: item.name,
poster: item.poster,
banner: (item as any).banner,
logo: (item as any).logo,
description: (item as any).description,
year: (item as any).year,
genres: (item as any).genres,
inLibrary: Boolean((item as any).inLibrary),
};
// Only enrich with logos if enrichment is enabled
if (settings.enrichMetadataWithTMDB) {
formattedContent = await Promise.all(topItems.map(enrichLogo));
try {
const details = formattedContent.slice(0, 20).map((c) => ({
id: c.id,
name: c.name,
hasLogo: Boolean(c.logo),
logoSource: c.logo ? (isTmdbUrl(String(c.logo)) ? 'tmdb' : 'addon') : 'none',
logo: c.logo || undefined,
}));
} catch { }
} else {
// When enrichment is disabled, prefer addon-provided logos; if missing, fetch basic meta to pull logo (like HeroSection)
const baseItems = topItems.map((item: any) => {
const base: StreamingContent = {
id: item.id,
type: item.type,
name: item.name,
poster: item.poster,
banner: (item as any).banner,
logo: (item as any).logo || undefined,
description: (item as any).description,
year: (item as any).year,
genres: (item as any).genres,
inLibrary: Boolean((item as any).inLibrary),
};
return base;
});
try {
if (!settings.enrichMetadataWithTMDB) {
return { ...base, logo: base.logo || undefined };
}
// Attempt to fill missing logos from addon meta details for a limited subset
const candidates = baseItems.filter(i => !i.logo).slice(0, 10);
const rawId = String(item.id);
const isTmdb = rawId.startsWith('tmdb:');
const isImdb = rawId.startsWith('tt');
let tmdbId: string | null = null;
let imdbId: string | null = null;
if (isTmdb) tmdbId = rawId.split(':')[1];
if (isImdb) imdbId = rawId.split(':')[0];
if (!tmdbId && imdbId) {
const found = await tmdbService.findTMDBIdByIMDB(imdbId);
tmdbId = found ? String(found) : null;
}
if (tmdbId) {
const logoUrl = await tmdbService.getContentLogo(item.type === 'series' ? 'tv' : 'movie', tmdbId as string, preferredLanguage);
return { ...base, logo: logoUrl || undefined };
}
return { ...base, logo: undefined };
} catch (error) {
return { ...base, logo: undefined };
}
};
if (settings.enrichMetadataWithTMDB) {
return Promise.all(items.map(enrichLogo));
} else {
// Fallback logic for when enrichment is disabled
const baseItems = items.map((item: any) => ({
id: item.id,
type: item.type,
name: item.name,
poster: item.poster,
banner: (item as any).banner,
logo: (item as any).logo || undefined,
description: (item as any).description,
year: (item as any).year,
genres: (item as any).genres,
inLibrary: Boolean((item as any).inLibrary),
}));
// Try to get logos for items missing them
const missingLogoCandidates = baseItems.filter((i: any) => !i.logo);
if (missingLogoCandidates.length > 0) {
try {
const filled = await Promise.allSettled(candidates.map(async (item) => {
const filled = await Promise.allSettled(missingLogoCandidates.map(async (item: any) => {
try {
const meta = await catalogService.getBasicContentDetails(item.type, item.id);
if (meta?.logo) {
return { id: item.id, logo: meta.logo } as { id: string; logo: string };
}
} catch (e) {
}
return { id: item.id, logo: undefined as any };
if (meta?.logo) return { id: item.id, logo: meta.logo };
} catch { }
return { id: item.id, logo: undefined };
}));
const idToLogo = new Map<string, string>();
filled.forEach(res => {
if (res.status === 'fulfilled' && res.value && res.value.logo) {
const idToLogo = new Map();
filled.forEach((res: any) => {
if (res.status === 'fulfilled' && res.value?.logo) {
idToLogo.set(res.value.id, res.value.logo);
}
});
formattedContent = baseItems.map(i => (
idToLogo.has(i.id) ? { ...i, logo: idToLogo.get(i.id)! } : i
));
return baseItems.map((i: any) => idToLogo.has(i.id) ? { ...i, logo: idToLogo.get(i.id) } : i);
} catch {
formattedContent = baseItems;
return baseItems;
}
try {
const details = formattedContent.slice(0, 20).map((c) => ({
id: c.id,
name: c.name,
hasLogo: Boolean(c.logo),
logoSource: c.logo ? (isTmdbUrl(String(c.logo)) ? 'tmdb' : 'addon') : 'none',
logo: c.logo || undefined,
}));
} catch { }
}
return baseItems;
}
};
// Process each catalog independently
const processCatalog = async (config: { addon: any, catalog: any }) => {
if (signal.aborted) return;
// Optimization: Stop fetching if we have enough items
// Note: We check length here but parallel requests might race. This is acceptable.
if (accumulatedContent.length >= TARGET_COUNT) return;
try {
const cat = await catalogService.fetchHomeCatalog(config.addon, config.catalog);
if (signal.aborted) return;
if (!cat || !cat.items || cat.items.length === 0) return;
// Deduplicate
const newItems = cat.items.filter(item => {
if (!item.poster) return false;
if (seenIds.has(item.id)) return false;
return true;
});
if (newItems.length === 0) return;
// Take only what we need (or a small batch)
const needed = TARGET_COUNT - accumulatedContent.length;
// Shuffle this batch locally just to mix it up a bit if the catalog returns strict order
const shuffledBatch = newItems.sort(() => Math.random() - 0.5).slice(0, needed);
if (shuffledBatch.length === 0) return;
shuffledBatch.forEach(item => seenIds.add(item.id));
// Enrich this batch
const enrichedBatch = await enrichItems(shuffledBatch);
if (signal.aborted) return;
// Update accumulated content
accumulatedContent = [...accumulatedContent, ...enrichedBatch];
// Update State
// Always update allFeaturedContent to show progress
setAllFeaturedContent([...accumulatedContent]);
// If this is the first batch, set initial state and UNBLOCK LOADING
if (!hasSetInitialContent && accumulatedContent.length > 0) {
hasSetInitialContent = true;
setFeaturedContent(accumulatedContent[0]);
persistentStore.featuredContent = accumulatedContent[0];
persistentStore.allFeaturedContent = accumulatedContent;
currentIndexRef.current = 0;
setLoading(false); // <--- Key improvement: Display content immediately
} else {
// Just update store for subsequent batches
persistentStore.allFeaturedContent = accumulatedContent;
}
} catch (e) {
logger.error('Error processing catalog in parallel', e);
}
};
// If no catalogs to fetch, fallback immediately
if (configs.length === 0) {
// Fallback logic
} else {
// Run fetches in parallel
await Promise.all(configs.map(processCatalog));
}
if (signal.aborted) return;
// Safety guard: if nothing came back within a reasonable time, stop loading
if (!formattedContent || formattedContent.length === 0) {
// Handle case where we finished all fetches but found NOTHING
if (accumulatedContent.length === 0) {
// Fall back to any cached featured item so UI can render something
const cachedJson = await mmkvStorage.getItem(STORAGE_KEY).catch(() => null);
if (cachedJson) {
try {
const parsed = JSON.parse(cachedJson);
if (parsed?.featuredContent) {
formattedContent = Array.isArray(parsed.allFeaturedContent) && parsed.allFeaturedContent.length > 0
const fallback = Array.isArray(parsed.allFeaturedContent) && parsed.allFeaturedContent.length > 0
? parsed.allFeaturedContent
: [parsed.featuredContent];
setAllFeaturedContent(fallback);
setFeaturedContent(fallback[0]);
setLoading(false);
return; // Done
}
} catch { }
}
}
// Update persistent store with the new data (no lastFetchTime when cache disabled)
persistentStore.allFeaturedContent = formattedContent;
if (!DISABLE_CACHE) {
persistentStore.lastFetchTime = now;
}
persistentStore.isFirstLoad = false;
setAllFeaturedContent(formattedContent);
if (formattedContent.length > 0) {
persistentStore.featuredContent = formattedContent[0];
setFeaturedContent(formattedContent[0]);
currentIndexRef.current = 0;
// Persist cache for fast startup (skipped when cache disabled)
if (!DISABLE_CACHE) {
try {
await mmkvStorage.setItem(
STORAGE_KEY,
JSON.stringify({
ts: now,
featuredContent: formattedContent[0],
allFeaturedContent: formattedContent,
})
);
} catch { }
}
} else {
persistentStore.featuredContent = null;
// If still nothing
setFeaturedContent(null);
// Clear persisted cache on empty (skipped when cache disabled)
if (!DISABLE_CACHE) {
try { await mmkvStorage.removeItem(STORAGE_KEY); } catch { }
}
setAllFeaturedContent([]);
setLoading(false); // Ensure we don't hang in loading state
}
// Final persistence
persistentStore.allFeaturedContent = accumulatedContent;
if (!DISABLE_CACHE && accumulatedContent.length > 0) {
persistentStore.lastFetchTime = now;
try {
await mmkvStorage.setItem(
STORAGE_KEY,
JSON.stringify({
ts: now,
featuredContent: accumulatedContent[0],
allFeaturedContent: accumulatedContent,
})
);
} catch { }
}
} catch (error) {
if (signal.aborted) {
} else {
}
setFeaturedContent(null);
setAllFeaturedContent([]);
} finally {
if (!signal.aborted) {
// Even on error, ensure we stop loading
setFeaturedContent(null);
setAllFeaturedContent([]);
setLoading(false);
}
}

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { Platform } from 'react-native';
import { mmkvStorage } from '../services/mmkvStorage';
import * as Updates from 'expo-updates';
import { getDisplayedAppVersion } from '../utils/version';
@ -23,7 +24,14 @@ export function useGithubMajorUpdate(): MajorUpdateData {
const [releaseUrl, setReleaseUrl] = useState<string | undefined>();
const check = useCallback(async () => {
if (Platform.OS === 'ios') return;
try {
// Check if major update alerts are disabled
const majorAlertsEnabled = await mmkvStorage.getItem('@major_updates_alerts_enabled');
if (majorAlertsEnabled === 'false') {
return; // Major update alerts are disabled by user
}
// Always compare with Settings screen version
const current = getDisplayedAppVersion() || Updates.runtimeVersion || '0.0.0';
const info = await fetchLatestGithubRelease();

View file

@ -1064,10 +1064,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const groupedAddonEpisodes: GroupedEpisodes = {};
addonVideos.forEach((video: any) => {
const seasonNumber = video.season;
if (!seasonNumber || seasonNumber < 1) {
return; // Skip season 0, which often contains extras
}
// Use season 0 for videos without season numbers (PPV-style content, specials, etc.)
const seasonNumber = video.season || 0;
const episodeNumber = video.episode || video.number || 1;
if (!groupedAddonEpisodes[seasonNumber]) {
@ -1183,14 +1181,59 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Determine initial season only once per series
const seasons = Object.keys(groupedAddonEpisodes).map(Number);
const firstSeason = Math.min(...seasons);
const nonZeroSeasons = seasons.filter(s => s !== 0);
const firstSeason = nonZeroSeasons.length > 0 ? Math.min(...nonZeroSeasons) : Math.min(...seasons);
if (!initializedSeasonRef.current) {
const nextSeason = firstSeason;
if (selectedSeason !== nextSeason) {
logger.log(`📺 Setting season ${nextSeason} as selected (${groupedAddonEpisodes[nextSeason]?.length || 0} episodes)`);
setSelectedSeason(nextSeason);
// Check for watch progress to auto-select season
let selectedSeasonNumber = firstSeason;
try {
const allProgress = await storageService.getAllWatchProgress();
let mostRecentEpisodeId = '';
let mostRecentTimestamp = 0;
Object.entries(allProgress).forEach(([key, progress]) => {
if (key.includes(`series:${id}:`)) {
const episodeId = key.split(`series:${id}:`)[1];
if (progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) {
mostRecentTimestamp = progress.lastUpdated;
mostRecentEpisodeId = episodeId;
}
}
});
if (mostRecentEpisodeId) {
// Try to parse season from ID or find matching episode
const parts = mostRecentEpisodeId.split(':');
if (parts.length === 3) {
// Format: showId:season:episode
const watchProgressSeason = parseInt(parts[1], 10);
if (groupedAddonEpisodes[watchProgressSeason]) {
selectedSeasonNumber = watchProgressSeason;
logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for ${mostRecentEpisodeId}`);
}
} else {
// Try to find by stremioId
const allEpisodesList = Object.values(groupedAddonEpisodes).flat();
const episode = allEpisodesList.find(ep => ep.stremioId === mostRecentEpisodeId);
if (episode) {
selectedSeasonNumber = episode.season_number;
logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for episode with stremioId ${mostRecentEpisodeId}`);
}
}
} else {
// No watch progress, try persistent storage
selectedSeasonNumber = getSeason(id, firstSeason);
logger.log(`[useMetadata] No watch progress found, using persistent season ${selectedSeasonNumber}`);
}
} catch (error) {
logger.error('[useMetadata] Error checking watch progress for season selection:', error);
selectedSeasonNumber = getSeason(id, firstSeason);
}
setEpisodes(groupedAddonEpisodes[nextSeason] || []);
if (selectedSeason !== selectedSeasonNumber) {
logger.log(`📺 Setting season ${selectedSeasonNumber} as selected`);
setSelectedSeason(selectedSeasonNumber);
}
setEpisodes(groupedAddonEpisodes[selectedSeasonNumber] || []);
initializedSeasonRef.current = true;
} else {
// Keep current selection; refresh episode list for selected season
@ -1240,8 +1283,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setGroupedEpisodes(transformedEpisodes);
// Get the first available season as fallback
const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number));
// Get the first available season as fallback (preferring non-zero seasons)
const availableSeasons = Object.keys(allEpisodes).map(Number);
const nonZeroSeasons = availableSeasons.filter(s => s !== 0);
const firstSeason = nonZeroSeasons.length > 0 ? Math.min(...nonZeroSeasons) : Math.min(...availableSeasons);
if (!initializedSeasonRef.current) {
// Check for watch progress to auto-select season
@ -1318,6 +1363,60 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setError(null);
};
// Extract embedded streams from metadata videos (used by PPV-style addons)
const extractEmbeddedStreams = useCallback(() => {
if (!metadata?.videos) return;
// Check if any video has embedded streams
const videosWithStreams = (metadata.videos as any[]).filter(
(video: any) => video.streams && Array.isArray(video.streams) && video.streams.length > 0
);
if (videosWithStreams.length === 0) return;
// Get the addon info from metadata if available
const addonId = (metadata as any).addonId || 'embedded';
const addonName = (metadata as any).addonName || metadata.name || 'Embedded Streams';
// Extract all streams from videos
const embeddedStreams: Stream[] = [];
for (const video of videosWithStreams) {
for (const stream of video.streams) {
embeddedStreams.push({
...stream,
name: stream.name || stream.title || video.title,
title: stream.title || video.title,
addonId,
addonName,
});
}
}
if (embeddedStreams.length > 0) {
if (__DEV__) console.log(`✅ [extractEmbeddedStreams] Found ${embeddedStreams.length} embedded streams from ${addonName}`);
// Add to grouped streams
setGroupedStreams(prevStreams => ({
...prevStreams,
[addonId]: {
addonName,
streams: embeddedStreams,
},
}));
// Track addon response order
setAddonResponseOrder(prevOrder => {
if (!prevOrder.includes(addonId)) {
return [...prevOrder, addonId];
}
return prevOrder;
});
// Mark loading as complete since we have streams
setLoadingStreams(false);
}
}, [metadata]);
const loadStreams = async () => {
const startTime = Date.now();
try {
@ -1478,6 +1577,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (__DEV__) console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId);
processStremioSource(type, stremioId, false);
// Also extract any embedded streams from metadata (PPV-style addons)
extractEmbeddedStreams();
// Monitor scraper completion status instead of using fixed timeout
const checkScrapersCompletion = () => {
setScraperStatuses(currentStatuses => {
@ -1814,8 +1916,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (metadata && metadata.videos && metadata.videos.length > 0) {
logger.log(`🎬 Metadata updated with ${metadata.videos.length} episodes, reloading series data`);
loadSeriesData().catch((error) => { if (__DEV__) console.error(error); });
// Also extract embedded streams from metadata videos (PPV-style addons)
extractEmbeddedStreams();
}
}, [metadata?.videos, type]);
}, [metadata?.videos, type, extractEmbeddedStreams]);
const loadRecommendations = useCallback(async () => {
if (!settings.enrichMetadataWithTMDB) {

View file

@ -135,7 +135,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
showPosterTitles: true,
enableHomeHeroBackground: true,
// Trailer settings
showTrailers: true, // Enable trailers by default
showTrailers: false, // Trailers disabled by default
trailerMuted: true, // Default to muted for better user experience
// AI
aiChatEnabled: false,

View file

@ -29,9 +29,9 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
stopWatching,
stopWatchingImmediate
} = useTraktIntegration();
const { settings: autosyncSettings } = useTraktAutosyncSettings();
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)
@ -41,66 +41,106 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
const sessionKey = useRef<string | null>(null);
const unmountCount = useRef(0);
const lastStopCall = useRef(0); // New: Track last stop call timestamp
// Generate a unique session key for this content instance
useEffect(() => {
const contentKey = options.type === 'movie'
? `movie:${options.imdbId}`
: `episode:${options.showImdbId || 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]);
// Build Trakt content data from options
const buildContentData = useCallback((): TraktContentData => {
// Ensure year is a number and valid
const parseYear = (year: number | string | undefined): number => {
if (!year) return 0;
if (typeof year === 'number') return year;
// Returns null if required fields are missing or invalid
const buildContentData = useCallback((): TraktContentData | null => {
// Parse and validate year - returns undefined for invalid/missing years
const parseYear = (year: number | string | undefined): number | undefined => {
if (year === undefined || year === null || year === '') return undefined;
if (typeof year === 'number') {
// Year must be a reasonable value (between 1800 and current year + 10)
const currentYear = new Date().getFullYear();
if (year <= 0 || year < 1800 || year > currentYear + 10) {
logger.warn(`[TraktAutosync] Invalid year value: ${year}`);
return undefined;
}
return year;
}
const parsed = parseInt(year.toString(), 10);
return isNaN(parsed) ? 0 : parsed;
if (isNaN(parsed) || parsed <= 0) {
logger.warn(`[TraktAutosync] Failed to parse year: ${year}`);
return undefined;
}
// Validate parsed year range
const currentYear = new Date().getFullYear();
if (parsed < 1800 || parsed > currentYear + 10) {
logger.warn(`[TraktAutosync] Year out of valid range: ${parsed}`);
return undefined;
}
return parsed;
};
// Validate required fields early
if (!options.title || options.title.trim() === '') {
logger.error('[TraktAutosync] Cannot build content data: missing or empty title');
return null;
}
if (!options.imdbId || options.imdbId.trim() === '') {
logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId');
return null;
}
const numericYear = parseYear(options.year);
const numericShowYear = parseYear(options.showYear);
// Validate required fields
if (!options.title || !options.imdbId) {
logger.warn('[TraktAutosync] Missing required fields:', { title: options.title, imdbId: options.imdbId });
// Log warning if year is missing (but don't fail - Trakt can sometimes work with IMDb ID alone)
if (numericYear === undefined) {
logger.warn('[TraktAutosync] Year is missing or invalid, proceeding without year');
}
if (options.type === 'movie') {
return {
type: 'movie',
imdbId: options.imdbId,
title: options.title,
year: numericYear
imdbId: options.imdbId.trim(),
title: options.title.trim(),
year: numericYear // Can be undefined now
};
} else {
// For episodes, also validate season and episode numbers
if (options.season === undefined || options.season === null || options.season < 0) {
logger.error('[TraktAutosync] Cannot build episode content data: invalid season');
return null;
}
if (options.episode === undefined || options.episode === null || options.episode < 0) {
logger.error('[TraktAutosync] Cannot build episode content data: invalid episode');
return null;
}
return {
type: 'episode',
imdbId: options.imdbId,
title: options.title,
imdbId: options.imdbId.trim(),
title: options.title.trim(),
year: numericYear,
season: options.season,
episode: options.episode,
showTitle: options.showTitle || options.title,
showTitle: (options.showTitle || options.title).trim(),
showYear: numericShowYear || numericYear,
showImdbId: options.showImdbId || options.imdbId
showImdbId: (options.showImdbId || options.imdbId).trim()
};
}
}, [options]);
@ -143,7 +183,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
const rawProgress = (currentTime / duration) * 100;
const progressPercent = Math.min(100, Math.max(0, rawProgress));
const contentData = buildContentData();
// Skip if content data is invalid
if (!contentData) {
logger.warn('[TraktAutosync] Skipping start: invalid content data');
return;
}
const success = await startWatching(contentData, progressPercent);
if (success) {
hasStartedWatching.current = true;
@ -184,6 +230,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
if (force) {
// IMMEDIATE: User action (pause/unpause) - bypass queue
const contentData = buildContentData();
if (!contentData) {
logger.warn('[TraktAutosync] Skipping progress update: invalid content data');
return;
}
success = await updateProgressImmediate(contentData, progressPercent);
if (success) {
@ -212,6 +262,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
}
const contentData = buildContentData();
if (!contentData) {
logger.warn('[TraktAutosync] Skipping progress update: invalid content data');
return;
}
success = await updateProgress(contentData, progressPercent, force);
if (success) {
@ -335,9 +389,11 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
// If we have valid progress but no started session, force start one first
if (!hasStartedWatching.current && progressPercent > 1) {
const contentData = buildContentData();
const success = await startWatching(contentData, progressPercent);
if (success) {
hasStartedWatching.current = true;
if (contentData) {
const success = await startWatching(contentData, progressPercent);
if (success) {
hasStartedWatching.current = true;
}
}
}
@ -356,6 +412,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
const contentData = buildContentData();
// Skip if content data is invalid
if (!contentData) {
logger.warn('[TraktAutosync] Skipping stop: invalid content data');
hasStopped.current = false; // Allow retry with valid data
return;
}
// IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends
const success = useImmediate
? await stopWatchingImmediate(contentData, progressPercent)
@ -394,7 +457,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
{ forceNotify: true }
);
}
} catch {}
} catch { }
}
logger.log(`[TraktAutosync] ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);

View file

@ -1,14 +1,14 @@
import { useState, useEffect, useCallback } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import {
traktService,
TraktUser,
TraktWatchedItem,
import {
traktService,
TraktUser,
TraktWatchedItem,
TraktWatchlistItem,
TraktCollectionItem,
TraktRatingItem,
TraktContentData,
TraktPlaybackItem
TraktContentData,
TraktPlaybackItem
} from '../services/traktService';
import { storageService } from '../services/storageService';
import { logger } from '../utils/logger';
@ -25,8 +25,7 @@ export function useTraktIntegration() {
const [collectionShows, setCollectionShows] = useState<TraktCollectionItem[]>([]);
const [continueWatching, setContinueWatching] = useState<TraktPlaybackItem[]>([]);
const [ratedContent, setRatedContent] = useState<TraktRatingItem[]>([]);
const [lastAuthCheck, setLastAuthCheck] = useState<number>(Date.now());
// State for real-time status tracking
const [watchlistItems, setWatchlistItems] = useState<Set<string>>(new Set());
const [collectionItems, setCollectionItems] = useState<Set<string>>(new Set());
@ -39,7 +38,7 @@ export function useTraktIntegration() {
const authenticated = await traktService.isAuthenticated();
logger.log(`[useTraktIntegration] Authentication check result: ${authenticated}`);
setIsAuthenticated(authenticated);
if (authenticated) {
logger.log('[useTraktIntegration] User is authenticated, fetching profile...');
const profile = await traktService.getUserProfile();
@ -49,9 +48,8 @@ export function useTraktIntegration() {
logger.log('[useTraktIntegration] User is not authenticated');
setUserProfile(null);
}
// Update the last auth check timestamp to trigger dependent components to update
setLastAuthCheck(Date.now());
} catch (error) {
logger.error('[useTraktIntegration] Error checking auth status:', error);
} finally {
@ -68,7 +66,7 @@ export function useTraktIntegration() {
// Load watched items
const loadWatchedItems = useCallback(async () => {
if (!isAuthenticated) return;
setIsLoading(true);
try {
const [movies, shows] = await Promise.all([
@ -87,7 +85,7 @@ export function useTraktIntegration() {
// Load all collections (watchlist, collection, continue watching, ratings)
const loadAllCollections = useCallback(async () => {
if (!isAuthenticated) return;
setIsLoading(true);
try {
const [
@ -105,44 +103,44 @@ export function useTraktIntegration() {
traktService.getPlaybackProgressWithImages(),
traktService.getRatingsWithImages()
]);
setWatchlistMovies(watchlistMovies);
setWatchlistShows(watchlistShows);
setCollectionMovies(collectionMovies);
setCollectionShows(collectionShows);
setContinueWatching(continueWatching);
setRatedContent(ratings);
// Populate watchlist and collection sets for quick lookups
const newWatchlistItems = new Set<string>();
const newCollectionItems = new Set<string>();
// 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) {
@ -155,7 +153,7 @@ export function useTraktIntegration() {
// Check if a movie is watched
const isMovieWatched = useCallback(async (imdbId: string): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
return await traktService.isMovieWatched(imdbId);
} catch (error) {
@ -166,12 +164,12 @@ export function useTraktIntegration() {
// Check if an episode is watched
const isEpisodeWatched = useCallback(async (
imdbId: string,
season: number,
imdbId: string,
season: number,
episode: number
): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
return await traktService.isEpisodeWatched(imdbId, season, episode);
} catch (error) {
@ -182,11 +180,11 @@ export function useTraktIntegration() {
// Mark a movie as watched
const markMovieAsWatched = useCallback(async (
imdbId: string,
imdbId: string,
watchedAt: Date = new Date()
): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const result = await traktService.addToWatchedMovies(imdbId, watchedAt);
if (result) {
@ -203,7 +201,7 @@ export function useTraktIntegration() {
// Add content to Trakt watchlist
const addToWatchlist = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const success = await traktService.addToWatchlist(imdbId, type);
if (success) {
@ -223,7 +221,7 @@ export function useTraktIntegration() {
// Remove content from Trakt watchlist
const removeFromWatchlist = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const success = await traktService.removeFromWatchlist(imdbId, type);
if (success) {
@ -246,7 +244,7 @@ export function useTraktIntegration() {
// Add content to Trakt collection
const addToCollection = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const success = await traktService.addToCollection(imdbId, type);
if (success) {
@ -265,7 +263,7 @@ export function useTraktIntegration() {
// Remove content from Trakt collection
const removeFromCollection = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const success = await traktService.removeFromCollection(imdbId, type);
if (success) {
@ -301,13 +299,13 @@ export function useTraktIntegration() {
// Mark an episode as watched
const markEpisodeAsWatched = useCallback(async (
imdbId: string,
season: number,
episode: number,
imdbId: string,
season: number,
episode: number,
watchedAt: Date = new Date()
): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const result = await traktService.addToWatchedEpisodes(imdbId, season, episode, watchedAt);
if (result) {
@ -324,7 +322,7 @@ export function useTraktIntegration() {
// Start watching content (scrobble start)
const startWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
return await traktService.scrobbleStart(contentData, progress);
} catch (error) {
@ -392,12 +390,12 @@ export function useTraktIntegration() {
// Sync progress to Trakt (legacy method)
const syncProgress = useCallback(async (
contentData: TraktContentData,
progress: number,
contentData: TraktContentData,
progress: number,
force: boolean = false
): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
return await traktService.syncProgressToTrakt(contentData, progress, force);
} catch (error) {
@ -409,12 +407,12 @@ export function useTraktIntegration() {
// Get playback progress from Trakt
const getTraktPlaybackProgress = useCallback(async (type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> => {
// getTraktPlaybackProgress call logging removed
if (!isAuthenticated) {
logger.log('[useTraktIntegration] getTraktPlaybackProgress: Not authenticated');
return [];
}
try {
// traktService.getPlaybackProgress call logging removed
const result = await traktService.getPlaybackProgress(type);
@ -429,37 +427,65 @@ export function useTraktIntegration() {
// Sync all local progress to Trakt
const syncAllProgress = useCallback(async (): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const unsyncedProgress = await storageService.getUnsyncedProgress();
logger.log(`[useTraktIntegration] Found ${unsyncedProgress.length} unsynced progress entries`);
let syncedCount = 0;
const batchSize = 5; // Process in smaller batches
const delayBetweenBatches = 2000; // 2 seconds between batches
// Process items in batches to avoid overwhelming the API
for (let i = 0; i < unsyncedProgress.length; i += batchSize) {
const batch = unsyncedProgress.slice(i, i + batchSize);
// Process batch items with individual error handling
const batchPromises = batch.map(async (item) => {
try {
const season = item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined;
const episode = item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined;
// Build content data from stored progress
const contentData: TraktContentData = {
type: item.type as 'movie' | 'episode',
imdbId: item.id,
title: 'Unknown', // We don't store title in progress, this would need metadata lookup
year: 0,
season: item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined,
episode: item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined
season: season,
episode: episode
};
const progressPercent = (item.progress.currentTime / item.progress.duration) * 100;
const success = await traktService.syncProgressToTrakt(contentData, progressPercent, true);
const isCompleted = progressPercent >= traktService.completionThreshold;
let success = false;
if (isCompleted) {
// Item is completed - add to history with original watched date
const watchedAt = new Date(item.progress.lastUpdated);
logger.log(`[useTraktIntegration] Syncing completed item to history with date ${watchedAt.toISOString()}: ${item.type}:${item.id}`);
if (item.type === 'movie') {
success = await traktService.addToWatchedMovies(item.id, watchedAt);
} else if (item.type === 'series' || item.type === 'episode') { // Handle both type strings for safety
if (season !== undefined && episode !== undefined) {
success = await traktService.addToWatchedEpisodes(item.id, season, episode, watchedAt);
}
}
} else {
// Item is in progress - sync as paused (scrobble)
success = await traktService.syncProgressToTrakt(contentData, progressPercent, true);
}
if (success) {
await storageService.updateTraktSyncStatus(item.id, item.type, true, progressPercent, item.episodeId);
await storageService.updateTraktSyncStatus(
item.id,
item.type,
true,
isCompleted ? 100 : progressPercent,
item.episodeId
);
return true;
}
return false;
@ -468,17 +494,17 @@ export function useTraktIntegration() {
return false;
}
});
// Wait for batch to complete
const batchResults = await Promise.all(batchPromises);
syncedCount += batchResults.filter(result => result).length;
// Delay between batches to avoid rate limiting
if (i + batchSize < unsyncedProgress.length) {
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
}
}
logger.log(`[useTraktIntegration] Synced ${syncedCount}/${unsyncedProgress.length} progress entries`);
return syncedCount > 0;
} catch (error) {
@ -492,26 +518,26 @@ export function useTraktIntegration() {
if (!isAuthenticated) {
return false;
}
try {
// Fetch both playback progress and recently watched movies
const [traktProgress, watchedMovies] = await Promise.all([
getTraktPlaybackProgress(),
traktService.getWatchedMovies()
]);
// Progress retrieval logging removed
// Batch process all updates to reduce storage notifications
const updatePromises: Promise<void>[] = [];
// Process playback progress (in-progress items)
for (const item of traktProgress) {
try {
let id: string;
let type: string;
let episodeId: string | undefined;
if (item.type === 'movie' && item.movie) {
id = item.movie.ids.imdb;
type = 'movie';
@ -522,7 +548,7 @@ export function useTraktIntegration() {
} else {
continue;
}
// Try to calculate exact time if we have stored duration
const exactTime = await (async () => {
const storedDuration = await storageService.getContentDuration(id, type, episodeId);
@ -531,13 +557,13 @@ export function useTraktIntegration() {
}
return undefined;
})();
updatePromises.push(
storageService.mergeWithTraktProgress(
id,
type,
item.progress,
item.paused_at,
id,
type,
item.progress,
item.paused_at,
episodeId,
exactTime
)
@ -546,20 +572,20 @@ export function useTraktIntegration() {
logger.error('[useTraktIntegration] Error preparing Trakt progress update:', error);
}
}
// Process watched movies (100% completed)
for (const movie of watchedMovies) {
try {
if (movie.movie?.ids?.imdb) {
const id = movie.movie.ids.imdb;
const watchedAt = movie.last_watched_at;
updatePromises.push(
storageService.mergeWithTraktProgress(
id,
'movie',
100, // 100% progress for watched items
watchedAt
id,
'movie',
100, // 100% progress for watched items
watchedAt
)
);
}
@ -567,10 +593,10 @@ export function useTraktIntegration() {
logger.error('[useTraktIntegration] Error preparing watched movie update:', error);
}
}
// Execute all updates in parallel
await Promise.all(updatePromises);
// Trakt merge logging removed
return true;
} catch (error) {
@ -614,18 +640,15 @@ export function useTraktIntegration() {
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => {
subscription?.remove();
};
}, [isAuthenticated, fetchAndMergeTraktProgress]);
// Trigger sync when auth status is manually refreshed (for login scenarios)
useEffect(() => {
if (isAuthenticated) {
fetchAndMergeTraktProgress();
}
}, [lastAuthCheck, isAuthenticated, fetchAndMergeTraktProgress]);
// Note: Auth check sync removed - fetchAndMergeTraktProgress is already called
// by the isAuthenticated useEffect (lines 595-602) and app focus sync (lines 605-621)
// Having another useEffect on lastAuthCheck caused infinite update depth errors
// Manual force sync function for testing/troubleshooting
const forceSyncTraktProgress = useCallback(async (): Promise<boolean> => {

View file

@ -28,6 +28,11 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
const checkForUpdates = useCallback(async (forceCheck = false) => {
try {
// Check if OTA update alerts are disabled
const otaAlertsEnabled = await mmkvStorage.getItem('@ota_updates_alerts_enabled');
if (otaAlertsEnabled === 'false' && !forceCheck) {
return; // OTA alerts are disabled by user
}
// Check if user has dismissed the popup for this version
const dismissedVersion = await mmkvStorage.getItem(UPDATE_POPUP_STORAGE_KEY);
@ -66,9 +71,9 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
try {
setIsInstalling(true);
setShowUpdatePopup(false);
const success = await UpdateService.downloadAndInstallUpdate();
if (success) {
// Update installed successfully - no restart alert needed
// The app will automatically reload with the new version
@ -116,13 +121,23 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
// Handle startup update check results
useEffect(() => {
const handleStartupUpdateCheck = (updateInfo: UpdateInfo) => {
const handleStartupUpdateCheck = async (updateInfo: UpdateInfo) => {
console.log('UpdatePopup: Received startup update check result', updateInfo);
setUpdateInfo(updateInfo);
setHasCheckedOnStartup(true);
if (updateInfo.isAvailable) {
setShowUpdatePopup(true);
// Check setting before showing
try {
const otaAlertsEnabled = await mmkvStorage.getItem('@ota_updates_alerts_enabled');
if (otaAlertsEnabled === 'false') {
console.log('OTA alerts disabled, suppressing popup');
return;
}
setShowUpdatePopup(true);
} catch {
setShowUpdatePopup(true);
}
}
};
@ -150,9 +165,12 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
// Check if user hasn't dismissed this version
(async () => {
try {
const otaAlertsEnabled = await mmkvStorage.getItem('@ota_updates_alerts_enabled');
if (otaAlertsEnabled === 'false') return;
const dismissedVersion = await mmkvStorage.getItem(UPDATE_POPUP_STORAGE_KEY);
const currentVersion = updateInfo.manifest?.id;
if (dismissedVersion !== currentVersion) {
setShowUpdatePopup(true);
}

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useFocusEffect } from '@react-navigation/native';
import { useTraktContext } from '../contexts/TraktContext';
import { logger } from '../utils/logger';
@ -14,26 +14,31 @@ interface WatchProgressData {
}
export const useWatchProgress = (
id: string,
type: 'movie' | 'series',
id: string,
type: 'movie' | 'series',
episodeId?: string,
episodes: any[] = []
) => {
const [watchProgress, setWatchProgress] = useState<WatchProgressData | null>(null);
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
// Use ref for episodes to avoid infinite loops - episodes array changes on every render
const episodesRef = useRef(episodes);
episodesRef.current = episodes;
// Function to get episode details from episodeId
const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => {
const currentEpisodes = episodesRef.current;
// Try to parse from format "seriesId:season:episode"
const parts = episodeId.split(':');
if (parts.length === 3) {
const [, seasonNum, episodeNum] = parts;
// Find episode in our local episodes array
const episode = episodes.find(
ep => ep.season_number === parseInt(seasonNum) &&
ep.episode_number === parseInt(episodeNum)
const episode = currentEpisodes.find(
ep => ep.season_number === parseInt(seasonNum) &&
ep.episode_number === parseInt(episodeNum)
);
if (episode) {
return {
seasonNumber: seasonNum,
@ -44,7 +49,7 @@ export const useWatchProgress = (
}
// If not found by season/episode, try stremioId
const episodeByStremioId = episodes.find(ep => ep.stremioId === episodeId);
const episodeByStremioId = currentEpisodes.find(ep => ep.stremioId === episodeId);
if (episodeByStremioId) {
return {
seasonNumber: episodeByStremioId.season_number.toString(),
@ -54,15 +59,15 @@ export const useWatchProgress = (
}
return null;
}, [episodes]);
}, []); // Removed episodes dependency - using ref instead
// Enhanced load watch progress with Trakt integration
const loadWatchProgress = useCallback(async () => {
try {
if (id && type) {
if (type === 'series') {
const allProgress = await storageService.getAllWatchProgress();
// Function to get episode number from episodeId
const getEpisodeNumber = (epId: string) => {
const parts = epId.split(':');
@ -93,8 +98,8 @@ export const useWatchProgress = (
if (progress) {
// Always show the current episode progress when viewing it specifically
// This allows HeroSection to properly display watched state
setWatchProgress({
...progress,
setWatchProgress({
...progress,
episodeId,
traktSynced: progress.traktSynced,
traktProgress: progress.traktProgress
@ -105,17 +110,17 @@ export const useWatchProgress = (
} else {
// FIXED: Find the most recently watched episode instead of first unfinished
// Sort by lastUpdated timestamp (most recent first)
const sortedProgresses = seriesProgresses.sort((a, b) =>
const sortedProgresses = seriesProgresses.sort((a, b) =>
b.progress.lastUpdated - a.progress.lastUpdated
);
if (sortedProgresses.length > 0) {
// Use the most recently watched episode
const mostRecentProgress = sortedProgresses[0];
const progress = mostRecentProgress.progress;
// Removed excessive logging for most recent progress
setWatchProgress({
...progress,
episodeId: mostRecentProgress.episodeId,
@ -133,8 +138,8 @@ export const useWatchProgress = (
if (progress && progress.currentTime > 0) {
// Always show progress data, even if watched (≥95%)
// The HeroSection will handle the "watched" state display
setWatchProgress({
...progress,
setWatchProgress({
...progress,
episodeId,
traktSynced: progress.traktSynced,
traktProgress: progress.traktProgress
@ -148,7 +153,7 @@ export const useWatchProgress = (
logger.error('[useWatchProgress] Error loading watch progress:', error);
setWatchProgress(null);
}
}, [id, type, episodeId, episodes]);
}, [id, type, episodeId]); // Removed episodes dependency - using ref instead
// Enhanced function to get play button text with Trakt awareness
const getPlayButtonText = useCallback(() => {
@ -167,19 +172,33 @@ export const useWatchProgress = (
return 'Resume';
}, [watchProgress]);
// Subscribe to storage changes for real-time updates
// Subscribe to storage changes for real-time updates (with debounce to prevent loops)
useEffect(() => {
let debounceTimeout: NodeJS.Timeout | null = null;
const unsubscribe = storageService.subscribeToWatchProgressUpdates(() => {
loadWatchProgress();
// Debounce rapid updates to prevent infinite loops
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
loadWatchProgress();
}, 100);
});
return unsubscribe;
return () => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
unsubscribe();
};
}, [loadWatchProgress]);
// Initial load
// Initial load - only once on mount
useEffect(() => {
loadWatchProgress();
}, [loadWatchProgress]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, type, episodeId]); // Only re-run when core IDs change, not when loadWatchProgress ref changes
// Refresh when screen comes into focus
useFocusEffect(
@ -188,15 +207,16 @@ export const useWatchProgress = (
}, [loadWatchProgress])
);
// Re-load when Trakt authentication status changes
// Re-load when Trakt authentication status changes (with guard)
useEffect(() => {
if (isTraktAuthenticated !== undefined) {
// Small delay to ensure Trakt context is fully initialized
setTimeout(() => {
loadWatchProgress();
}, 100);
}
}, [isTraktAuthenticated, loadWatchProgress]);
// Skip on initial mount, only run when isTraktAuthenticated actually changes
const timeoutId = setTimeout(() => {
loadWatchProgress();
}, 200); // Slightly longer delay to avoid race conditions
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTraktAuthenticated]); // Intentionally exclude loadWatchProgress to prevent loops
return {
watchProgress,

View file

@ -71,6 +71,16 @@ import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsS
import ContributorsScreen from '../screens/ContributorsScreen';
import DebridIntegrationScreen from '../screens/DebridIntegrationScreen';
// Optional Android immersive mode module
let RNImmersiveMode: any = null;
if (Platform.OS === 'android') {
try {
RNImmersiveMode = require('react-native-immersive-mode').default;
} catch {
RNImmersiveMode = null;
}
}
// Stack navigator types
export type RootStackParamList = {
Onboarding: undefined;
@ -91,9 +101,18 @@ export type RootStackParamList = {
Streams: {
id: string;
type: string;
title?: string;
episodeId?: string;
episodeThumbnail?: string;
fromPlayer?: boolean;
metadata?: {
poster?: string;
banner?: string;
releaseInfo?: string;
genres?: string[];
};
resumeTime?: number;
duration?: number;
};
PlayerIOS: {
uri: string;
@ -1066,8 +1085,10 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
if (Platform.OS === 'android') {
// Ensure system navigation bar is shown by default
try {
RNImmersiveMode.setBarMode('Normal');
RNImmersiveMode.fullLayout(false);
if (RNImmersiveMode) {
RNImmersiveMode.setBarMode('Normal');
RNImmersiveMode.fullLayout(false);
}
} catch (error) {
console.log('Immersive mode error:', error);
}
@ -1174,8 +1195,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
component={MetadataScreen}
options={{
headerShown: false,
animation: Platform.OS === 'android' ? 'none' : 'fade',
animationDuration: Platform.OS === 'android' ? 0 : 300,
animation: Platform.OS === 'android' ? 'fade' : 'fade',
animationDuration: Platform.OS === 'android' ? 200 : 300,
...(Platform.OS === 'ios' && {
cardStyleInterpolator: customFadeInterpolator,
animationTypeForReplace: 'push',
@ -1192,8 +1213,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
component={StreamsScreen as any}
options={{
headerShown: false,
animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'none',
animationDuration: Platform.OS === 'android' ? 0 : 300,
animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'fade',
animationDuration: Platform.OS === 'android' ? 200 : 300,
gestureEnabled: true,
gestureDirection: Platform.OS === 'ios' ? 'vertical' : 'horizontal',
...(Platform.OS === 'ios' && { presentation: 'modal' }),
@ -1542,8 +1563,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
name="AIChat"
component={AIChatScreen}
options={{
animation: Platform.OS === 'android' ? 'none' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 220 : 300,
animation: Platform.OS === 'android' ? 'fade' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 200 : 300,
presentation: Platform.OS === 'ios' ? 'fullScreenModal' : 'modal',
gestureEnabled: true,
gestureDirection: Platform.OS === 'ios' ? 'horizontal' : 'vertical',

View file

@ -662,12 +662,16 @@ const AddonsScreen = () => {
const installedAddons = await stremioService.getInstalledAddonsAsync();
// Filter out Torbox addons (managed via DebridIntegrationScreen)
// Filter out only the official Torbox integration addon (managed via DebridIntegrationScreen)
// but allow other addons (like Torrentio, MediaFusion) that may be configured with Torbox
const filteredAddons = installedAddons.filter(addon => {
const isTorboxAddon =
addon.id?.includes('torbox') ||
addon.url?.includes('torbox') ||
(addon as any).transport?.includes('torbox');
return !isTorboxAddon;
const isOfficialTorboxAddon =
addon.url?.includes('stremio.torbox.app') ||
(addon as any).transport?.includes('stremio.torbox.app') ||
// Check for ID but be careful not to catch others if possible, though ID usually comes from URL in stremioService
(addon.id?.includes('stremio.torbox.app'));
return !isOfficialTorboxAddon;
});
setAddons(filteredAddons as ExtendedManifest[]);

View file

@ -10,13 +10,14 @@ import {
RefreshControl,
Dimensions,
Platform,
InteractionManager
InteractionManager,
ScrollView
} from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { RouteProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { RootStackParamList } from '../navigation/AppNavigator';
import { Meta, stremioService } from '../services/stremioService';
import { Meta, stremioService, CatalogExtra } from '../services/stremioService';
import { useTheme } from '../contexts/ThemeContext';
import FastImage from '@d11/react-native-fast-image';
import { BlurView } from 'expo-blur';
@ -65,11 +66,11 @@ const calculateCatalogLayout = (screenWidth: number) => {
// 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;
// Calculate how many columns can fit
const availableWidth = screenWidth - HORIZONTAL_PADDING;
const maxColumns = Math.floor(availableWidth / (MIN_ITEM_WIDTH + ITEM_SPACING));
// More flexible column limits for different screen sizes
let numColumns;
if (screenWidth < 600) {
@ -88,14 +89,14 @@ const calculateCatalogLayout = (screenWidth: number) => {
// Ultra-wide: 6-10 columns
numColumns = Math.min(Math.max(maxColumns, 6), 10);
}
// Calculate actual item width with proper spacing
const totalSpacing = ITEM_SPACING * (numColumns - 1);
const itemWidth = (availableWidth - totalSpacing) / numColumns;
// Ensure item width doesn't exceed maximum
const finalItemWidth = Math.floor(Math.min(itemWidth, MAX_ITEM_WIDTH));
return {
numColumns,
itemWidth: finalItemWidth,
@ -154,7 +155,7 @@ const createStyles = (colors: any) => StyleSheet.create({
},
poster: {
width: '100%',
aspectRatio: 2/3,
aspectRatio: 2 / 3,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
backgroundColor: colors.elevation3,
@ -230,7 +231,38 @@ const createStyles = (colors: any) => StyleSheet.create({
fontSize: 11,
fontWeight: '600',
color: colors.white,
}
},
// Filter chip bar styles
filterContainer: {
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 12,
},
filterScrollContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
filterChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: colors.elevation3,
borderWidth: 1,
borderColor: colors.elevation3,
},
filterChipActive: {
backgroundColor: colors.primary + '30',
borderColor: colors.primary,
},
filterChipText: {
fontSize: 13,
fontWeight: '500',
color: colors.mediumGray,
},
filterChipTextActive: {
color: colors.primary,
},
});
const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
@ -253,6 +285,11 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
});
const [mobileColumnsPref, setMobileColumnsPref] = useState<'auto' | 2 | 3>('auto');
const [nowPlayingMovies, setNowPlayingMovies] = useState<Set<string>>(new Set());
// Filter state for catalog extra properties per protocol
const [catalogExtras, setCatalogExtras] = useState<CatalogExtra[]>([]);
const [selectedFilters, setSelectedFilters] = useState<Record<string, string>>({});
const [activeGenreFilter, setActiveGenreFilter] = useState<string | undefined>(genreFilter);
const [showTitles, setShowTitles] = useState(true); // Default to showing titles
const { currentTheme } = useTheme();
const colors = currentTheme.colors;
const styles = createStyles(colors);
@ -266,7 +303,11 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
if (pref === '2') setMobileColumnsPref(2);
else if (pref === '3') setMobileColumnsPref(3);
else setMobileColumnsPref('auto');
} catch {}
// Load show titles preference (default: true)
const titlesPref = await mmkvStorage.getItem('catalog_show_titles');
setShowTitles(titlesPref !== 'false'); // Default to true if not set
} catch { }
})();
}, []);
@ -284,52 +325,63 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
}, []);
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
// Create display name with proper type suffix
const createDisplayName = (catalogName: string) => {
if (!catalogName) return '';
// Check if the name already includes content type indicators
const lowerName = catalogName.toLowerCase();
const contentType = type === 'movie' ? 'Movies' : type === 'series' ? 'TV Shows' : `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
// If the name already contains type information, return as is
if (lowerName.includes('movie') || lowerName.includes('tv') || lowerName.includes('show') || lowerName.includes('series')) {
return catalogName;
}
// Otherwise append the content type
return `${catalogName} ${contentType}`;
};
// Use actual catalog name if available, otherwise fallback to custom name or original name
const displayName = actualCatalogName
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
(genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` :
`${type.charAt(0).toUpperCase() + type.slice(1)}s`);
// Add effect to get the actual catalog name from addon manifest
// Use actual catalog name if available, otherwise fallback to custom name or original name
const displayName = actualCatalogName
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
(genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` :
`${type.charAt(0).toUpperCase() + type.slice(1)}s`);
// Add effect to get the actual catalog name and filter extras from addon manifest
useEffect(() => {
const getActualCatalogName = async () => {
const getCatalogDetails = async () => {
if (addonId && type && id) {
try {
const manifests = await stremioService.getInstalledAddonsAsync();
const addon = manifests.find(a => a.id === addonId);
if (addon && addon.catalogs) {
const catalog = addon.catalogs.find(c => c.type === type && c.id === id);
if (catalog && catalog.name) {
setActualCatalogName(catalog.name);
if (catalog) {
if (catalog.name) {
setActualCatalogName(catalog.name);
}
// Extract filter extras per protocol (genre, etc.)
if (catalog.extra && Array.isArray(catalog.extra)) {
// Only show filterable extras with options (not search/skip)
const filterableExtras = catalog.extra.filter(
extra => extra.options && extra.options.length > 0 && extra.name !== 'skip'
);
setCatalogExtras(filterableExtras);
logger.log('[CatalogScreen] Loaded catalog extras:', filterableExtras.map(e => e.name));
}
}
}
} catch (error) {
logger.error('Failed to get actual catalog name:', error);
logger.error('Failed to get catalog details:', error);
}
}
};
getActualCatalogName();
getCatalogDetails();
}, [addonId, type, id]);
// Add effect to get data source preference when component mounts
@ -372,7 +424,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
type,
id,
dataSource,
genreFilter
activeGenreFilter
});
try {
if (shouldRefresh) {
@ -383,9 +435,9 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
}
setError(null);
// Process the genre filter - ignore "All" and clean up the value
let effectiveGenreFilter = genreFilter;
let effectiveGenreFilter = activeGenreFilter;
if (effectiveGenreFilter === 'All') {
effectiveGenreFilter = undefined;
logger.log('Genre "All" detected, removing genre filter');
@ -394,7 +446,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
effectiveGenreFilter = effectiveGenreFilter.trim();
logger.log(`Using cleaned genre filter: "${effectiveGenreFilter}"`);
}
// Check if using TMDB as data source and not requesting a specific addon
if (dataSource === DataSource.TMDB && !addonId) {
logger.log('Using TMDB data source for CatalogScreen');
@ -406,7 +458,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
catalogs.forEach(catalog => {
allItems.push(...catalog.items);
});
// Convert StreamingContent to Meta format
const metaItems: Meta[] = allItems.map(item => ({
id: item.id,
@ -423,12 +475,12 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
runtime: item.runtime,
certification: item.certification,
}));
// Remove duplicates
const uniqueItems = metaItems.filter((item, index, self) =>
index === self.findIndex((t) => t.id === item.id)
);
InteractionManager.runAfterInteractions(() => {
setItems(uniqueItems);
setHasMore(false); // TMDB already returns a full set
@ -465,22 +517,22 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
return;
}
}
// Use this flag to track if we found and processed any items
let foundItems = false;
let allItems: Meta[] = [];
// Get all installed addon manifests directly
const manifests = await stremioService.getInstalledAddonsAsync();
if (addonId) {
// If addon ID is provided, find the specific addon
const addon = manifests.find(a => a.id === addonId);
if (!addon) {
throw new Error(`Addon ${addonId} not found`);
}
// Create filters array for genre filtering if provided
const filters = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : [];
@ -509,11 +561,14 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
let nextHasMore = false;
try {
const svcHasMore = addonId ? stremioService.getCatalogHasMore(addonId, type, id) : undefined;
// If service explicitly provides hasMore, use it; otherwise assume there's more if we got any items
// This handles addons with different page sizes (not just 50 items per page)
nextHasMore = typeof svcHasMore === 'boolean' ? svcHasMore : (catalogItems.length > 0);
// If service explicitly provides hasMore, use it
// Otherwise, only assume there's more if we got a reasonable number of items (>= 5)
// This prevents infinite loops when addons return just 1-2 items per page
const MIN_ITEMS_FOR_MORE = 5;
nextHasMore = typeof svcHasMore === 'boolean' ? svcHasMore : (catalogItems.length >= MIN_ITEMS_FOR_MORE);
} catch {
nextHasMore = catalogItems.length > 0;
// Fallback: only assume more if we got at least 5 items
nextHasMore = catalogItems.length >= 5;
}
setHasMore(nextHasMore);
logger.log('[CatalogScreen] Updated items and hasMore', {
@ -525,60 +580,60 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
}
} else if (effectiveGenreFilter) {
// Get all addons that have catalogs of the specified type
const typeManifests = manifests.filter(manifest =>
const typeManifests = manifests.filter(manifest =>
manifest.catalogs && manifest.catalogs.some(catalog => catalog.type === type)
);
// Add debug logging for genre filter
logger.log(`Using genre filter: "${effectiveGenreFilter}" for type: ${type}`);
// For each addon, try to get content with the genre filter
for (const manifest of typeManifests) {
try {
// Find catalogs of this type
const typeCatalogs = manifest.catalogs?.filter(catalog => catalog.type === type) || [];
// For each catalog, try to get content
for (const catalog of typeCatalogs) {
try {
const filters = [{ title: 'genre', value: effectiveGenreFilter }];
// Debug logging for each catalog request
logger.log(`Requesting from ${manifest.name}, catalog ${catalog.id} with genre "${effectiveGenreFilter}"`);
const catalogItems = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (catalogItems && catalogItems.length > 0) {
// Log first few items' genres to debug
const sampleItems = catalogItems.slice(0, 3);
sampleItems.forEach(item => {
logger.log(`Item "${item.name}" has genres: ${JSON.stringify(item.genres)}`);
});
// Filter items client-side to ensure they contain the requested genre
// Some addons might not properly filter by genre on the server
let filteredItems = catalogItems;
if (effectiveGenreFilter) {
const normalizedGenreFilter = effectiveGenreFilter.toLowerCase().trim();
filteredItems = catalogItems.filter(item => {
// Skip items without genres
if (!item.genres || !Array.isArray(item.genres)) {
return false;
}
// Check for genre match (exact or substring)
return item.genres.some(genre => {
const normalizedGenre = genre.toLowerCase().trim();
return normalizedGenre === normalizedGenreFilter ||
normalizedGenre.includes(normalizedGenreFilter) ||
normalizedGenreFilter.includes(normalizedGenre);
return normalizedGenre === normalizedGenreFilter ||
normalizedGenre.includes(normalizedGenreFilter) ||
normalizedGenreFilter.includes(normalizedGenre);
});
});
logger.log(`Filtered ${catalogItems.length} items to ${filteredItems.length} matching genre "${effectiveGenreFilter}"`);
}
allItems = [...allItems, ...filteredItems];
foundItems = filteredItems.length > 0;
}
@ -592,7 +647,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
// Continue with other addons
}
}
// Remove duplicates by ID
const uniqueItems = allItems.filter((item, index, self) =>
index === self.findIndex((t) => t.id === item.id)
@ -607,7 +662,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
});
}
}
if (!foundItems) {
InteractionManager.runAfterInteractions(() => {
setError("No content found for the selected filters");
@ -630,7 +685,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
});
});
}
}, [addonId, type, id, genreFilter, dataSource]);
}, [addonId, type, id, activeGenreFilter, dataSource]);
useEffect(() => {
loadItems(true, 1);
@ -641,6 +696,28 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
loadItems(true);
}, [loadItems]);
// Handle filter chip selection
const handleFilterChange = useCallback((filterName: string, value: string | undefined) => {
logger.log('[CatalogScreen] Filter changed:', filterName, value);
if (filterName === 'genre') {
setActiveGenreFilter(value);
} else {
setSelectedFilters(prev => {
if (value === undefined) {
const { [filterName]: _, ...rest } = prev;
return rest;
}
return { ...prev, [filterName]: value };
});
}
// Reset pagination - don't clear items to avoid flash of empty state
// loadItems will replace items when new data arrives
setPage(1);
setLoading(true);
}, []);
const effectiveNumColumns = React.useMemo(() => {
const isPhone = screenData.width < 600; // basic breakpoint; tablets generally above this
@ -665,12 +742,12 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
if (!poster || poster.includes('placeholder')) {
return 'https://via.placeholder.com/300x450/333333/666666?text=No+Image';
}
// For TMDB images, use smaller sizes for better performance
if (poster.includes('image.tmdb.org')) {
return poster.replace(/\/w\d+\//, '/w300/');
}
return poster;
}, []);
@ -679,12 +756,16 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const isLastInRow = (index + 1) % effectiveNumColumns === 0;
// For proper spacing
const rightMargin = isLastInRow ? 0 : ((screenData as any).itemSpacing ?? SPACING.sm);
// Calculate aspect ratio based on posterShape
const shape = item.posterShape || 'poster';
const aspectRatio = shape === 'landscape' ? 16 / 9 : (shape === 'square' ? 1 : 2 / 3);
return (
<TouchableOpacity
style={[
styles.item,
{
{
marginRight: rightMargin,
width: effectiveItemWidth
}
@ -694,7 +775,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
>
<FastImage
source={{ uri: optimizePosterUrl(item.poster) }}
style={styles.poster}
style={[styles.poster, { aspectRatio }]}
resizeMode={FastImage.resizeMode.cover}
/>
@ -739,9 +820,26 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
</View>
)
)}
{/* Poster Title */}
{showTitles && (
<Text
style={{
fontSize: 12,
fontWeight: '500',
color: colors.mediumGray,
marginTop: 6,
textAlign: 'center',
paddingHorizontal: 4,
}}
numberOfLines={2}
>
{item.name}
</Text>
)}
</TouchableOpacity>
);
}, [navigation, styles, effectiveNumColumns, effectiveItemWidth, type, nowPlayingMovies, colors.white, optimizePosterUrl]);
}, [navigation, styles, effectiveNumColumns, effectiveItemWidth, screenData, type, nowPlayingMovies, colors.white, colors.mediumGray, optimizePosterUrl, addonId, isDarkMode, showTitles]);
const renderEmptyState = () => (
<View style={styles.centered}>
@ -787,7 +885,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
@ -806,7 +904,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
@ -824,7 +922,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
@ -833,7 +931,54 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{/* Filter chip bar - shows when catalog has filterable extras */}
{catalogExtras.length > 0 && (
<View style={styles.filterContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterScrollContent}
>
{catalogExtras.map(extra => (
<React.Fragment key={extra.name}>
{/* All option - clears filter */}
<TouchableOpacity
style={[
styles.filterChip,
(extra.name === 'genre' ? !activeGenreFilter : !selectedFilters[extra.name]) && styles.filterChipActive
]}
onPress={() => handleFilterChange(extra.name, undefined)}
>
<Text style={[
styles.filterChipText,
(extra.name === 'genre' ? !activeGenreFilter : !selectedFilters[extra.name]) && styles.filterChipTextActive
]}>All</Text>
</TouchableOpacity>
{/* Filter options from catalog extra */}
{extra.options?.map(option => {
const isActive = extra.name === 'genre'
? activeGenreFilter === option
: selectedFilters[extra.name] === option;
return (
<TouchableOpacity
key={option}
style={[styles.filterChip, isActive && styles.filterChipActive]}
onPress={() => handleFilterChange(extra.name, option)}
>
<Text style={[styles.filterChipText, isActive && styles.filterChipTextActive]}>
{option}
</Text>
</TouchableOpacity>
);
})}
</React.Fragment>
))}
</ScrollView>
</View>
)}
{items.length > 0 ? (
<FlashList
data={items}

View file

@ -177,17 +177,17 @@ const createStyles = (colors: any) => StyleSheet.create({
optionChipTextSelected: {
color: colors.white,
},
hintRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 16,
paddingVertical: 8,
},
hintText: {
fontSize: 12,
color: colors.mediumGray,
},
hintRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 16,
paddingVertical: 8,
},
hintText: {
fontSize: 12,
color: colors.mediumGray,
},
enabledCount: {
fontSize: 15,
color: colors.mediumGray,
@ -268,6 +268,7 @@ const CatalogSettingsScreen = () => {
const [settings, setSettings] = useState<CatalogSetting[]>([]);
const [groupedSettings, setGroupedSettings] = useState<GroupedCatalogs>({});
const [mobileColumns, setMobileColumns] = useState<'auto' | 2 | 3>('auto');
const [showTitles, setShowTitles] = useState(true); // Default to showing titles
const navigation = useNavigation();
const { refreshCatalogs } = useCatalogContext();
const { currentTheme } = useTheme();
@ -288,11 +289,11 @@ const CatalogSettingsScreen = () => {
const loadSettings = useCallback(async () => {
try {
setLoading(true);
// Get installed addons and their catalogs
const addons = await stremioService.getInstalledAddonsAsync();
const availableCatalogs: CatalogSetting[] = [];
// Get saved enable/disable settings
const savedSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
const savedEnabledSettings: { [key: string]: boolean } = savedSettingsJson ? JSON.parse(savedSettingsJson) : {};
@ -300,12 +301,12 @@ const CatalogSettingsScreen = () => {
// Get saved custom names
const savedCustomNamesJson = await mmkvStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
const savedCustomNames: { [key: string]: string } = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
// Process each addon's catalogs
addons.forEach(addon => {
if (addon.catalogs && addon.catalogs.length > 0) {
const uniqueCatalogs = new Map<string, CatalogSetting>();
addon.catalogs.forEach(catalog => {
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
let displayName = catalog.name || catalog.id;
@ -330,7 +331,7 @@ const CatalogSettingsScreen = () => {
if (!displayName.toLowerCase().includes(catalogType.toLowerCase())) {
displayName = `${displayName} ${catalogType}`.trim();
}
uniqueCatalogs.set(settingKey, {
addonId: addon.id,
catalogId: catalog.id,
@ -340,32 +341,32 @@ const CatalogSettingsScreen = () => {
customName: savedCustomNames[settingKey]
});
});
availableCatalogs.push(...uniqueCatalogs.values());
}
});
// Group settings by addon name
const grouped: GroupedCatalogs = {};
availableCatalogs.forEach(setting => {
const addon = addons.find(a => a.id === setting.addonId);
if (!addon) return;
if (!grouped[setting.addonId]) {
grouped[setting.addonId] = {
name: addon.name,
catalogs: [],
expanded: true,
expanded: true,
enabledCount: 0
};
}
grouped[setting.addonId].catalogs.push(setting);
if (setting.enabled) {
grouped[setting.addonId].enabledCount++;
}
});
setSettings(availableCatalogs);
setGroupedSettings(grouped);
@ -375,6 +376,10 @@ const CatalogSettingsScreen = () => {
if (pref === '2') setMobileColumns(2);
else if (pref === '3') setMobileColumns(3);
else setMobileColumns('auto');
// Load show titles preference (default: true)
const titlesPref = await mmkvStorage.getItem('catalog_show_titles');
setShowTitles(titlesPref !== 'false'); // Default to true if not set
} catch (e) {
// ignore
}
@ -396,7 +401,7 @@ const CatalogSettingsScreen = () => {
settingsObj[key] = setting.enabled;
});
await mmkvStorage.setItem(CATALOG_SETTINGS_KEY, JSON.stringify(settingsObj));
// Small delay to ensure AsyncStorage has fully persisted before triggering refresh
setTimeout(() => {
refreshCatalogs(); // Trigger catalog refresh after saving settings
@ -411,26 +416,26 @@ const CatalogSettingsScreen = () => {
const newSettings = [...settings];
const catalogsForAddon = groupedSettings[addonId].catalogs;
const setting = catalogsForAddon[index];
const updatedSetting = {
...setting,
enabled: !setting.enabled
};
const flatIndex = newSettings.findIndex(s =>
s.addonId === setting.addonId &&
s.type === setting.type &&
const flatIndex = newSettings.findIndex(s =>
s.addonId === setting.addonId &&
s.type === setting.type &&
s.catalogId === setting.catalogId
);
if (flatIndex !== -1) {
newSettings[flatIndex] = updatedSetting;
}
const newGroupedSettings = { ...groupedSettings };
newGroupedSettings[addonId].catalogs[index] = updatedSetting;
newGroupedSettings[addonId].enabledCount += updatedSetting.enabled ? 1 : -1;
setSettings(newSettings);
setGroupedSettings(newGroupedSettings);
saveEnabledSettings(newSettings); // Use specific save function
@ -459,11 +464,11 @@ const CatalogSettingsScreen = () => {
if (!catalogToRename || !currentRenameValue) return;
const settingKey = `${catalogToRename.addonId}:${catalogToRename.type}:${catalogToRename.catalogId}`;
try {
const savedCustomNamesJson = await mmkvStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
const customNames: { [key: string]: string } = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
const trimmedNewName = currentRenameValue.trim();
if (trimmedNewName === catalogToRename.name || trimmedNewName === '') {
@ -471,22 +476,22 @@ const CatalogSettingsScreen = () => {
} else {
customNames[settingKey] = trimmedNewName;
}
await mmkvStorage.setItem(CATALOG_CUSTOM_NAMES_KEY, JSON.stringify(customNames));
// Clear in-memory cache so new name is used immediately
try { clearCustomNameCache(); } catch {}
try { clearCustomNameCache(); } catch { }
// --- Reload settings to reflect the change ---
await loadSettings();
await loadSettings();
// Also trigger home/catalog consumers to refresh
try { refreshCatalogs(); } catch {}
try { refreshCatalogs(); } catch { }
// --- No need to manually update local state anymore ---
} catch (error) {
logger.error('Failed to save custom catalog name:', error);
setAlertTitle('Error');
setAlertMessage('Could not save the custom name.');
setAlertActions([{ label: 'OK', onPress: () => {} }]);
setAlertActions([{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true);
} finally {
setIsRenameModalVisible(false);
@ -533,7 +538,7 @@ const CatalogSettingsScreen = () => {
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>Catalogs</Text>
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
{/* Layout (Mobile only) */}
{Platform.OS && (
@ -552,7 +557,7 @@ const CatalogSettingsScreen = () => {
try {
await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, 'auto');
setMobileColumns('auto');
} catch {}
} catch { }
}}
activeOpacity={0.7}
>
@ -564,7 +569,7 @@ const CatalogSettingsScreen = () => {
try {
await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, '2');
setMobileColumns(2);
} catch {}
} catch { }
}}
activeOpacity={0.7}
>
@ -576,7 +581,7 @@ const CatalogSettingsScreen = () => {
try {
await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, '3');
setMobileColumns(3);
} catch {}
} catch { }
}}
activeOpacity={0.7}
>
@ -587,6 +592,26 @@ const CatalogSettingsScreen = () => {
<MaterialIcons name="info-outline" size={14} color={colors.mediumGray} />
<Text style={styles.hintText}>Applies to phones only. Tablets keep adaptive layout.</Text>
</View>
{/* Show Titles Toggle */}
<View style={[styles.catalogItem, { borderBottomWidth: 0 }]}>
<View style={styles.catalogInfo}>
<Text style={styles.catalogName}>Show Poster Titles</Text>
<Text style={styles.catalogType}>Display title text below each poster</Text>
</View>
<Switch
value={showTitles}
onValueChange={async (value) => {
try {
await mmkvStorage.setItem('catalog_show_titles', value ? 'true' : 'false');
setShowTitles(value);
} catch { }
}}
trackColor={{ false: '#505050', true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.white : undefined}
ios_backgroundColor="#505050"
/>
</View>
</View>
</View>
)}
@ -596,9 +621,9 @@ const CatalogSettingsScreen = () => {
<Text style={styles.addonTitle}>
{group.name.toUpperCase()}
</Text>
<View style={styles.card}>
<TouchableOpacity
<TouchableOpacity
style={styles.groupHeader}
onPress={() => toggleExpansion(addonId)}
activeOpacity={0.7}
@ -608,14 +633,14 @@ const CatalogSettingsScreen = () => {
<Text style={styles.enabledCount}>
{group.enabledCount} of {group.catalogs.length} enabled
</Text>
<MaterialIcons
name={group.expanded ? "keyboard-arrow-down" : "keyboard-arrow-right"}
size={24}
color={colors.mediumGray}
<MaterialIcons
name={group.expanded ? "keyboard-arrow-down" : "keyboard-arrow-right"}
size={24}
color={colors.mediumGray}
/>
</View>
</TouchableOpacity>
{group.expanded && (
<>
<View style={styles.hintRow}>
@ -623,30 +648,30 @@ const CatalogSettingsScreen = () => {
<Text style={styles.hintText}>Long-press a catalog to rename</Text>
</View>
{group.catalogs.map((setting, index) => (
<Pressable
key={`${setting.addonId}:${setting.type}:${setting.catalogId}`}
onLongPress={() => handleLongPress(setting)} // Added long press handler
style={({ pressed }) => [
styles.catalogItem,
pressed && styles.catalogItemPressed, // Optional pressed style
]}
>
<View style={styles.catalogInfo}>
<Text style={styles.catalogName}>
{setting.customName || setting.name} {/* Display custom or default name */}
</Text>
<Text style={styles.catalogType}>
{setting.type.charAt(0).toUpperCase() + setting.type.slice(1)}
</Text>
</View>
<Switch
value={setting.enabled}
onValueChange={() => toggleCatalog(addonId, index)}
trackColor={{ false: '#505050', true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.white : undefined}
ios_backgroundColor="#505050"
/>
</Pressable>
<Pressable
key={`${setting.addonId}:${setting.type}:${setting.catalogId}`}
onLongPress={() => handleLongPress(setting)} // Added long press handler
style={({ pressed }) => [
styles.catalogItem,
pressed && styles.catalogItemPressed, // Optional pressed style
]}
>
<View style={styles.catalogInfo}>
<Text style={styles.catalogName}>
{setting.customName || setting.name} {/* Display custom or default name */}
</Text>
<Text style={styles.catalogType}>
{setting.type.charAt(0).toUpperCase() + setting.type.slice(1)}
</Text>
</View>
<Switch
value={setting.enabled}
onValueChange={() => toggleCatalog(addonId, index)}
trackColor={{ false: '#505050', true: colors.primary }}
thumbColor={Platform.OS === 'android' ? colors.white : undefined}
ios_backgroundColor="#505050"
/>
</Pressable>
))}
</>
)}
@ -706,8 +731,8 @@ const CatalogSettingsScreen = () => {
)}
</Pressable>
) : (
<Pressable style={styles.modalOverlay} onPress={() => setIsRenameModalVisible(false)}>
<Pressable style={styles.modalContent} onPress={(e) => e.stopPropagation()}>
<Pressable style={styles.modalOverlay} onPress={() => setIsRenameModalVisible(false)}>
<Pressable style={styles.modalContent} onPress={(e) => e.stopPropagation()}>
<Text style={styles.modalTitle}>Rename Catalog</Text>
<TextInput
style={styles.modalInput}

View file

@ -772,7 +772,7 @@ const DebridIntegrationScreen = () => {
<Text style={styles.poweredBy}>Powered by</Text>
<View style={styles.logoRow}>
<Image
source={{ uri: 'https://torbox.app/assets/logo-57adbf99.svg' }}
source={{ uri: 'https://torbox.app/assets/logo-bb7a9579.svg' }}
style={styles.logo}
resizeMode="contain"
/>

View file

@ -135,7 +135,7 @@ const HomeScreen = () => {
const [hasAddons, setHasAddons] = useState<boolean | null>(null);
const [hintVisible, setHintVisible] = useState(false);
const totalCatalogsRef = useRef(0);
const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory
const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory
const insets = useSafeAreaInsets();
// Stabilize insets to prevent iOS layout shifts
@ -147,7 +147,7 @@ const HomeScreen = () => {
}, 100);
return () => clearTimeout(timer);
}, [insets.top]);
const {
featuredContent,
allFeaturedContent,
@ -158,43 +158,49 @@ const HomeScreen = () => {
refreshFeatured
} = useFeaturedContent();
// Guard to prevent overlapping fetch calls
const isFetchingRef = useRef(false);
// Progressive catalog loading function with performance optimizations
const loadCatalogsProgressively = useCallback(async () => {
if (isFetchingRef.current) return;
isFetchingRef.current = true;
setCatalogsLoading(true);
setCatalogs([]);
setLoadedCatalogCount(0);
try {
// Check cache first
let catalogSettings: Record<string, boolean> = {};
const now = Date.now();
if (cachedCatalogSettings && (now - catalogSettingsCacheTimestamp) < CATALOG_SETTINGS_CACHE_TTL) {
catalogSettings = cachedCatalogSettings;
} else {
// Load from storage
const catalogSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
// Update cache
cachedCatalogSettings = catalogSettings;
catalogSettingsCacheTimestamp = now;
}
const [addons, addonManifests] = await Promise.all([
catalogService.getAllAddons(),
stremioService.getInstalledAddonsAsync()
]);
// Set hasAddons state based on whether we have any addons - ensure on main thread
InteractionManager.runAfterInteractions(() => {
setHasAddons(addons.length > 0);
});
// Create placeholder array with proper order and track indices
let catalogIndex = 0;
const catalogQueue: (() => Promise<void>)[] = [];
// Launch all catalog loaders in parallel
const launchAllCatalogs = () => {
while (catalogQueue.length > 0) {
@ -204,18 +210,18 @@ const HomeScreen = () => {
}
}
};
for (const addon of addons) {
if (addon.catalogs) {
for (const catalog of addon.catalogs) {
// Check if this catalog is enabled (default to true if no setting exists)
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
const isEnabled = catalogSettings[settingKey] ?? true;
// Only load enabled catalogs
if (isEnabled) {
const currentIndex = catalogIndex;
const catalogLoader = async () => {
try {
const manifest = addonManifests.find((a: any) => a.id === addon.id);
@ -226,7 +232,7 @@ const HomeScreen = () => {
// Aggressively limit items per catalog on Android to reduce memory usage
const limit = Platform.OS === 'android' ? 18 : 30;
const limitedMetas = metas.slice(0, limit);
const items = limitedMetas.map((meta: any) => ({
id: meta.id,
type: meta.type,
@ -267,7 +273,7 @@ const HomeScreen = () => {
displayName = `${displayName} ${contentType}`;
}
}
const catalogContent = {
addon: addon.id,
type: catalog.type,
@ -275,7 +281,7 @@ const HomeScreen = () => {
name: displayName,
items
};
// Update the catalog at its specific position - ensure on main thread
InteractionManager.runAfterInteractions(() => {
setCatalogs(prevCatalogs => {
@ -296,26 +302,37 @@ const HomeScreen = () => {
if (prev === 0) {
setCatalogsLoading(false);
}
// ** Crucial: If all catalogs processed, release the fetch guard **
if (next >= totalCatalogsRef.current) {
isFetchingRef.current = false;
}
return next;
});
});
}
};
catalogQueue.push(catalogLoader);
catalogIndex++;
}
}
}
}
totalCatalogsRef.current = catalogIndex;
// If no catalogs to load, release locks immediately
if (catalogIndex === 0) {
setCatalogsLoading(false);
isFetchingRef.current = false;
return;
}
// Initialize catalogs array with proper length - ensure on main thread
InteractionManager.runAfterInteractions(() => {
setCatalogs(new Array(catalogIndex).fill(null));
});
// Start all catalog requests in parallel
launchAllCatalogs();
} catch (error) {
@ -323,6 +340,7 @@ const HomeScreen = () => {
InteractionManager.runAfterInteractions(() => {
setCatalogsLoading(false);
});
isFetchingRef.current = false;
}
}, []);
@ -355,7 +373,22 @@ const HomeScreen = () => {
// Listen for catalog changes (addon additions/removals) and reload catalogs
useEffect(() => {
loadCatalogsProgressively();
// Skip initial mount (handled by the loadCatalogsProgressively effect)
if (lastUpdate === 0) return;
// Force reset the fetch guard to ensure refresh happens
isFetchingRef.current = false;
// Invalidate catalog settings cache so fresh settings are loaded
cachedCatalogSettings = null;
catalogSettingsCacheTimestamp = 0;
// Small delay to ensure previous fetch is fully stopped
const timer = setTimeout(() => {
loadCatalogsProgressively();
}, 100);
return () => clearTimeout(timer);
}, [lastUpdate, loadCatalogsProgressively]);
// One-time hint after skipping login in onboarding
@ -371,7 +404,7 @@ const HomeScreen = () => {
// Also show a global toast for consistency across screens
// showInfo('Sign In Available', 'You can sign in anytime from Settings → Account');
}
} catch {}
} catch { }
})();
return () => {
if (hideTimer) clearTimeout(hideTimer);
@ -389,10 +422,10 @@ const HomeScreen = () => {
setShowHeroSection(settings.showHeroSection);
setFeaturedContentSource(settings.featuredContentSource);
};
// Subscribe to settings changes
const unsubscribe = settingsEmitter.addListener(handleSettingsChange);
return unsubscribe;
}, [settings.showHeroSection, settings.featuredContentSource]);
@ -409,12 +442,12 @@ const HomeScreen = () => {
StatusBar.setHidden(false);
}
};
statusBarConfig();
// Unlock orientation to allow free rotation
ScreenOrientation.unlockAsync().catch(() => {});
ScreenOrientation.unlockAsync().catch(() => { });
return () => {
// Stop trailer when screen loses focus (navigating to other screens)
setTrailerPlaying(false);
@ -450,12 +483,12 @@ const HomeScreen = () => {
StatusBar.setTranslucent(false);
StatusBar.setBackgroundColor(currentTheme.colors.darkBackground);
}
// Clean up any lingering timeouts
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
// Don't clear FastImage cache on unmount - it causes broken images on remount
// FastImage's native libraries (SDWebImage/Glide) handle memory automatically
// Cache clearing only happens on app background (see AppState handler above)
@ -468,11 +501,11 @@ const HomeScreen = () => {
// Balanced preload images function using FastImage
const preloadImages = useCallback(async (content: StreamingContent[]) => {
if (!content.length) return;
try {
// Moderate prefetching for better performance balance
const MAX_IMAGES = 10; // Preload 10 most important images
// Only preload poster images (skip banner and logo entirely)
const posterImages = content.slice(0, MAX_IMAGES)
.map(item => item.poster)
@ -499,26 +532,27 @@ const HomeScreen = () => {
const handlePlayStream = useCallback(async (stream: Stream) => {
if (!featuredContent) return;
try {
// Don't clear cache before player - causes broken images on return
// FastImage's native libraries handle memory efficiently
// Lock orientation to landscape before navigation to prevent glitches
try {
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
// Longer delay to ensure orientation is fully set before navigation
await new Promise(resolve => setTimeout(resolve, 200));
} catch (orientationError) {
// If orientation lock fails, continue anyway but log it
logger.warn('[HomeScreen] Orientation lock failed:', orientationError);
// Still add a small delay
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise(resolve => setTimeout(resolve, 100));
}
// @ts-ignore
navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', {
uri: stream.url,
uri: stream.url as any,
title: featuredContent.name,
year: featuredContent.year,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
@ -528,10 +562,12 @@ const HomeScreen = () => {
});
} catch (error) {
logger.error('[HomeScreen] Error in handlePlayStream:', error);
// Fallback: navigate anyway
// Fallback: navigate anyway
// @ts-ignore
navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', {
uri: stream.url,
uri: stream.url as any,
title: featuredContent.name,
year: featuredContent.year,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
@ -545,9 +581,9 @@ const HomeScreen = () => {
const refreshContinueWatching = useCallback(async () => {
if (continueWatchingRef.current) {
try {
const hasContent = await continueWatchingRef.current.refresh();
setHasContinueWatching(hasContent);
const hasContent = await continueWatchingRef.current.refresh();
setHasContinueWatching(hasContent);
} catch (error) {
if (__DEV__) console.error('[HomeScreen] Error refreshing continue watching:', error);
setHasContinueWatching(false);
@ -555,19 +591,31 @@ const HomeScreen = () => {
}
}, []);
// Use refs to track state for event listeners without triggering re-effects
const catalogsLengthRef = useRef(catalogs.length);
const catalogsLoadingRef = useRef(catalogsLoading);
useEffect(() => {
catalogsLengthRef.current = catalogs.length;
}, [catalogs.length]);
useEffect(() => {
catalogsLoadingRef.current = catalogsLoading;
}, [catalogsLoading]);
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
// Only refresh continue watching section on focus
refreshContinueWatching();
// Don't reload catalogs unless they haven't been loaded yet
// Catalogs will be refreshed through context updates when addons change
if (catalogs.length === 0 && !catalogsLoading) {
// Uses refs to avoid re-binding the listener on every state change
if (catalogsLengthRef.current === 0 && !catalogsLoadingRef.current) {
loadCatalogsProgressively();
}
});
return unsubscribe;
}, [navigation, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]);
}, [navigation, refreshContinueWatching, loadCatalogsProgressively]);
// Memoize the loading screen to prevent unnecessary re-renders
const renderLoadingScreen = useMemo(() => {
@ -603,7 +651,7 @@ const HomeScreen = () => {
// Only show a limited number of catalogs initially for performance
const catalogsToShow = catalogs.slice(0, visibleCatalogCount);
catalogsToShow.forEach((catalog, index) => {
if (catalog) {
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
@ -637,7 +685,7 @@ const HomeScreen = () => {
// Memoize individual section components to prevent re-renders
const memoizedFeaturedContent = useMemo(() => {
const heroStyleToUse = settings.heroStyle;
// AppleTVHero is only available on mobile devices (not tablets)
if (heroStyleToUse === 'appletv' && !isTablet) {
return (
@ -694,7 +742,7 @@ const HomeScreen = () => {
const lastToggleRef = useRef(0);
const scrollAnimationFrameRef = useRef<number | null>(null);
const isScrollingRef = useRef(false);
const toggleHeader = useCallback((hide: boolean) => {
const now = Date.now();
if (now - lastToggleRef.current < 120) return; // debounce
@ -735,7 +783,7 @@ const HomeScreen = () => {
);
case 'loadMore':
return (
<Animated.View entering={FadeIn.duration(300)}>
<View>
<View style={styles.loadMoreContainer}>
<TouchableOpacity
style={[styles.loadMoreButton, { backgroundColor: currentTheme.colors.primary }]}
@ -747,7 +795,7 @@ const HomeScreen = () => {
</Text>
</TouchableOpacity>
</View>
</Animated.View>
</View>
);
case 'welcome':
return <FirstTimeWelcome />;
@ -783,26 +831,26 @@ const HomeScreen = () => {
const handleScroll = useCallback((event: any) => {
// Persist the event before using requestAnimationFrame to prevent event pooling issues
event.persist();
// Cancel any pending animation frame
if (scrollAnimationFrameRef.current !== null) {
cancelAnimationFrame(scrollAnimationFrameRef.current);
}
// Capture scroll values immediately before async operation
const scrollYValue = event.nativeEvent.contentOffset.y;
// Update shared value for parallax (on UI thread)
scrollY.value = scrollYValue;
// Use requestAnimationFrame to throttle scroll handling
scrollAnimationFrameRef.current = requestAnimationFrame(() => {
const y = scrollYValue;
const dy = y - lastScrollYRef.current;
lastScrollYRef.current = y;
isScrollingRef.current = Math.abs(dy) > 0;
if (y <= 10) {
toggleHeader(false);
return;
@ -813,7 +861,7 @@ const HomeScreen = () => {
} else if (dy < -6) {
toggleHeader(false); // scrolling up
}
scrollAnimationFrameRef.current = null;
});
}, [toggleHeader]);
@ -823,9 +871,9 @@ const HomeScreen = () => {
const contentContainerStyle = useMemo(() => {
const heroStyleToUse = settings.heroStyle;
const isUsingAppleTVHero = heroStyleToUse === 'appletv' && !isTablet && showHeroSection;
return StyleSheet.flatten([
styles.scrollContent,
styles.scrollContent,
{ paddingTop: isUsingAppleTVHero ? 0 : stableInsetsTop }
]);
}, [stableInsetsTop, settings.heroStyle, isTablet, showHeroSection]);
@ -833,9 +881,9 @@ const HomeScreen = () => {
// Memoize the main content section
const renderMainContent = useMemo(() => {
if (isLoading) return null;
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
@ -882,13 +930,13 @@ const calculatePosterLayout = (screenWidth: number) => {
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
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: 120 };
for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
@ -896,12 +944,12 @@ const calculatePosterLayout = (screenWidth: number) => {
// We'll use minimal right padding (8px) to maximize space
const usableWidth = availableWidth - 8;
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
bestLayout = { numFullPosters: n, posterWidth };
}
}
return {
numFullPosters: bestLayout.numFullPosters,
posterWidth: bestLayout.posterWidth,
@ -966,7 +1014,7 @@ const styles = StyleSheet.create<any>({
},
placeholderPoster: {
width: POSTER_WIDTH,
aspectRatio: 2/3,
aspectRatio: 2 / 3,
borderRadius: 12,
marginRight: 2,
},
@ -1203,7 +1251,7 @@ const styles = StyleSheet.create<any>({
},
contentItem: {
width: POSTER_WIDTH,
aspectRatio: 2/3,
aspectRatio: 2 / 3,
margin: 0,
borderRadius: 4,
overflow: 'hidden',

View file

@ -385,47 +385,49 @@ const LibraryScreen = () => {
return folders.filter(folder => folder.itemCount > 0);
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
const renderItem = ({ item }: { item: LibraryItem }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
onLongPress={() => {
setSelectedItem(item);
setMenuVisible(true);
}}
activeOpacity={0.7}
>
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<FastImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
/>
{item.watched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success || '#4CAF50'} />
</View>
)}
{item.progress !== undefined && item.progress < 1 && (
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBar,
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
]}
/>
</View>
)}
</View>
{settings.showPosterTitles && (
const renderItem = ({ item }: { item: LibraryItem }) => {
const aspectRatio = item.posterShape === 'landscape' ? 16 / 9 : (item.posterShape === 'square' ? 1 : 2 / 3);
return (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
onLongPress={() => {
setSelectedItem(item);
setMenuVisible(true);
}}
activeOpacity={0.7}
>
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black, aspectRatio }]}>
<FastImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
/>
{item.watched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success || '#4CAF50'} />
</View>
)}
{item.progress !== undefined && item.progress < 1 && (
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBar,
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
]}
/>
</View>
)}
</View>
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
{item.name}
</Text>
)}
</View>
</TouchableOpacity>
);
</View>
</TouchableOpacity>
);
};
const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => (
<TouchableOpacity

View file

@ -41,7 +41,11 @@ import Animated, {
Easing,
interpolateColor,
withSpring,
createAnimatedComponent,
} from 'react-native-reanimated';
// Create animated version of SafeAreaView for use with Reanimated styles
const AnimatedSafeAreaView = createAnimatedComponent(SafeAreaView);
import { RouteProp } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
@ -911,7 +915,7 @@ const MetadataScreen: React.FC = () => {
return (
<Animated.View style={[animatedBackgroundStyle, { flex: 1 }]}>
<SafeAreaView
<AnimatedSafeAreaView
style={[containerStyle, styles.container]}
edges={[]}
>
@ -1270,6 +1274,7 @@ const MetadataScreen: React.FC = () => {
onSelectEpisode={handleEpisodeSelect}
groupedEpisodes={groupedEpisodes}
metadata={metadata || undefined}
imdbId={imdbId || undefined}
/>
) : (
metadata && <MemoizedMovieContent metadata={metadata} />
@ -1417,7 +1422,7 @@ const MetadataScreen: React.FC = () => {
isSpoilerRevealed={selectedComment ? revealedSpoilers.has(selectedComment.id.toString()) : false}
onSpoilerPress={() => selectedComment && handleSpoilerPress(selectedComment)}
/>
</SafeAreaView>
</AnimatedSafeAreaView>
</Animated.View>
);
};

View file

@ -78,17 +78,17 @@ const createStyles = (colors: any) => StyleSheet.create({
padding: 16,
},
sectionTitle: {
fontSize: 20,
fontWeight: '600',
color: colors.white,
marginBottom: 8,
},
fontSize: 20,
fontWeight: '600',
color: colors.white,
marginBottom: 8,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
sectionDescription: {
fontSize: 14,
color: colors.mediumGray,
@ -283,59 +283,59 @@ const createStyles = (colors: any) => StyleSheet.create({
marginTop: 8,
},
infoText: {
fontSize: 14,
color: colors.mediumEmphasis,
lineHeight: 20,
},
content: {
flex: 1,
},
emptyState: {
alignItems: 'center',
paddingVertical: 32,
},
emptyStateTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.white,
marginTop: 16,
marginBottom: 8,
},
emptyStateDescription: {
fontSize: 14,
color: colors.mediumGray,
textAlign: 'center',
lineHeight: 20,
},
scrapersList: {
gap: 12,
},
scrapersContainer: {
marginBottom: 24,
},
inputContainer: {
marginBottom: 16,
},
lastSection: {
borderBottomWidth: 0,
},
disabledSection: {
opacity: 0.5,
},
disabledText: {
color: colors.elevation3,
},
disabledContainer: {
opacity: 0.5,
},
disabledInput: {
backgroundColor: colors.elevation1,
opacity: 0.5,
},
disabledButton: {
opacity: 0.5,
},
disabledImage: {
fontSize: 14,
color: colors.mediumEmphasis,
lineHeight: 20,
},
content: {
flex: 1,
},
emptyState: {
alignItems: 'center',
paddingVertical: 32,
},
emptyStateTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.white,
marginTop: 16,
marginBottom: 8,
},
emptyStateDescription: {
fontSize: 14,
color: colors.mediumGray,
textAlign: 'center',
lineHeight: 20,
},
scrapersList: {
gap: 12,
},
scrapersContainer: {
marginBottom: 24,
},
inputContainer: {
marginBottom: 16,
},
lastSection: {
borderBottomWidth: 0,
},
disabledSection: {
opacity: 0.5,
},
disabledText: {
color: colors.elevation3,
},
disabledContainer: {
opacity: 0.5,
},
disabledInput: {
backgroundColor: colors.elevation1,
opacity: 0.5,
},
disabledButton: {
opacity: 0.5,
},
disabledImage: {
opacity: 0.3,
},
availableIndicator: {
@ -484,46 +484,60 @@ const createStyles = (colors: any) => StyleSheet.create({
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backgroundColor: 'rgba(0, 0, 0, 0.85)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContent: {
backgroundColor: colors.darkBackground,
backgroundColor: '#1E1E1E', // Match CustomAlert
borderRadius: 16,
padding: 20,
margin: 20,
maxHeight: '70%',
width: screenWidth - 40,
padding: 24,
width: '100%',
maxWidth: 400,
borderWidth: 1,
borderColor: colors.elevation3,
borderColor: 'rgba(255, 255, 255, 0.1)',
alignSelf: 'center',
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.51,
shadowRadius: 13.16,
},
android: {
elevation: 20,
},
}),
},
modalTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.white,
fontSize: 20,
fontWeight: '700',
color: '#FFFFFF',
marginBottom: 8,
textAlign: 'center',
},
modalText: {
fontSize: 16,
color: colors.mediumGray,
lineHeight: 24,
fontSize: 15,
color: '#AAAAAA',
lineHeight: 22,
marginBottom: 16,
textAlign: 'center',
},
modalButton: {
backgroundColor: colors.primary,
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginTop: 16,
minHeight: 48,
},
modalButtonText: {
color: colors.white,
color: '#FFFFFF',
fontSize: 16,
fontWeight: '500',
fontWeight: '600',
},
// Compact modal styles
modalHeader: {
@ -761,10 +775,10 @@ const CollapsibleSection: React.FC<{
<View style={styles.collapsibleSection}>
<TouchableOpacity style={styles.collapsibleHeader} onPress={onToggle}>
<Text style={styles.collapsibleTitle}>{title}</Text>
<Ionicons
name={isExpanded ? "chevron-up" : "chevron-down"}
size={20}
color={colors.mediumGray}
<Ionicons
name={isExpanded ? "chevron-up" : "chevron-down"}
size={20}
color={colors.mediumGray}
/>
</TouchableOpacity>
{isExpanded && <View style={styles.collapsibleContent}>{children}</View>}
@ -803,7 +817,7 @@ const StatusBadge: React.FC<{
};
const config = getStatusConfig();
return (
<View style={{
flexDirection: 'row',
@ -828,7 +842,7 @@ const PluginsScreen: React.FC = () => {
const { currentTheme } = useTheme();
const colors = currentTheme.colors;
const styles = createStyles(colors);
// CustomAlert state
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
@ -842,7 +856,7 @@ const PluginsScreen: React.FC = () => {
) => {
setAlertTitle(title);
setAlertMessage(message);
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]);
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true);
};
@ -856,14 +870,14 @@ const PluginsScreen: React.FC = () => {
const [showboxSavedToken, setShowboxSavedToken] = useState<string>('');
const [showboxScraperId, setShowboxScraperId] = useState<string | null>(null);
const [showboxTokenVisible, setShowboxTokenVisible] = useState<boolean>(false);
// Multiple repositories state
const [repositories, setRepositories] = useState<RepositoryInfo[]>([]);
const [currentRepositoryId, setCurrentRepositoryId] = useState<string>('');
const [showAddRepositoryModal, setShowAddRepositoryModal] = useState(false);
const [newRepositoryUrl, setNewRepositoryUrl] = useState('');
const [switchingRepository, setSwitchingRepository] = useState<string | null>(null);
// New UX state
const [searchQuery, setSearchQuery] = useState('');
const [selectedFilter, setSelectedFilter] = useState<'all' | 'movie' | 'tv'>('all');
@ -897,7 +911,7 @@ const PluginsScreen: React.FC = () => {
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(scraper =>
filtered = filtered.filter(scraper =>
scraper.name.toLowerCase().includes(query) ||
scraper.description.toLowerCase().includes(query) ||
scraper.id.toLowerCase().includes(query)
@ -906,7 +920,7 @@ const PluginsScreen: React.FC = () => {
// Filter by type
if (selectedFilter !== 'all') {
filtered = filtered.filter(scraper =>
filtered = filtered.filter(scraper =>
scraper.supportedTypes?.includes(selectedFilter as 'movie' | 'tv')
);
}
@ -933,7 +947,7 @@ const PluginsScreen: React.FC = () => {
const handleBulkToggle = async (enabled: boolean) => {
try {
setIsRefreshing(true);
const promises = filteredScrapers.map(scraper =>
const promises = filteredScrapers.map(scraper =>
pluginService.setScraperEnabled(scraper.id, enabled)
);
await Promise.all(promises);
@ -994,14 +1008,14 @@ const PluginsScreen: React.FC = () => {
description: '',
enabled: true
});
await loadRepositories();
// Switch to the new repository and refresh it
await pluginService.setCurrentRepository(repoId);
await loadRepositories();
await loadScrapers();
setNewRepositoryUrl('');
setShowAddRepositoryModal(false);
openAlert('Success', 'Repository added and refreshed successfully');
@ -1034,9 +1048,9 @@ const PluginsScreen: React.FC = () => {
// Special handling for the last repository
const isLastRepository = repositories.length === 1;
const alertTitle = isLastRepository ? 'Remove Last Repository' : 'Remove Repository';
const alertMessage = isLastRepository
const alertMessage = isLastRepository
? `Are you sure you want to remove "${repo.name}"? This is your only repository, so you'll have no scrapers available until you add a new repository.`
: `Are you sure you want to remove "${repo.name}"? This will also remove all scrapers from this repository.`;
@ -1044,7 +1058,7 @@ const PluginsScreen: React.FC = () => {
alertTitle,
alertMessage,
[
{ label: 'Cancel', onPress: () => {} },
{ label: 'Cancel', onPress: () => { } },
{
label: 'Remove',
onPress: async () => {
@ -1052,7 +1066,7 @@ const PluginsScreen: React.FC = () => {
await pluginService.removeRepository(repoId);
await loadRepositories();
await loadScrapers();
const successMessage = isLastRepository
const successMessage = isLastRepository
? 'Repository removed successfully. You can add a new repository using the "Add Repository" button.'
: 'Repository removed successfully';
openAlert('Success', successMessage);
@ -1105,14 +1119,14 @@ const PluginsScreen: React.FC = () => {
try {
// First refresh repository names from manifests for existing repositories
await pluginService.refreshRepositoryNamesFromManifests();
const repos = await pluginService.getRepositories();
setRepositories(repos);
setHasRepository(repos.length > 0);
const currentRepoId = pluginService.getCurrentRepositoryId();
setCurrentRepositoryId(currentRepoId);
const currentRepo = repos.find(r => r.id === currentRepoId);
if (currentRepo) {
setRepositoryUrl(currentRepo.url);
@ -1144,7 +1158,7 @@ const PluginsScreen: React.FC = () => {
const url = repositoryUrl.trim();
if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) {
openAlert(
'Invalid URL Format',
'Invalid URL Format',
'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/master'
);
return;
@ -1199,7 +1213,7 @@ const PluginsScreen: React.FC = () => {
// If enabling a scraper, ensure it's installed first
const installedScrapers = await pluginService.getInstalledScrapers();
const isInstalled = installedScrapers.some(scraper => scraper.id === scraperId);
if (!isInstalled) {
// Need to install the scraper first
setIsRefreshing(true);
@ -1207,7 +1221,7 @@ const PluginsScreen: React.FC = () => {
setIsRefreshing(false);
}
}
await pluginService.setScraperEnabled(scraperId, enabled);
await loadScrapers();
} catch (error) {
@ -1222,7 +1236,7 @@ const PluginsScreen: React.FC = () => {
'Clear All Scrapers',
'Are you sure you want to remove all installed scrapers? This action cannot be undone.',
[
{ label: 'Cancel', onPress: () => {} },
{ label: 'Cancel', onPress: () => { } },
{
label: 'Clear',
onPress: async () => {
@ -1245,7 +1259,7 @@ const PluginsScreen: React.FC = () => {
'Clear Repository Cache',
'This will remove the saved repository URL and clear all cached scraper data. You will need to re-enter your repository URL.',
[
{ label: 'Cancel', onPress: () => {} },
{ label: 'Cancel', onPress: () => { } },
{
label: 'Clear Cache',
onPress: async () => {
@ -1274,19 +1288,19 @@ const PluginsScreen: React.FC = () => {
const handleToggleLocalScrapers = async (enabled: boolean) => {
await updateSetting('enableLocalScrapers', enabled);
// If enabling plugins, refresh repository and reload plugins
if (enabled) {
try {
setIsRefreshing(true);
logger.log('[PluginsScreen] Enabling plugins - refreshing repository...');
// Refresh repository to ensure plugins are available
await pluginService.refreshRepository();
// Reload plugins to get the latest state
await loadScrapers();
logger.log('[PluginsScreen] Plugins enabled and repository refreshed');
} catch (error) {
logger.error('[PluginsScreen] Failed to refresh repository when enabling plugins:', error);
@ -1304,7 +1318,7 @@ const PluginsScreen: React.FC = () => {
const handleToggleQualityExclusion = async (quality: string) => {
const currentExcluded = settings.excludedQualities || [];
const isExcluded = currentExcluded.includes(quality);
let newExcluded: string[];
if (isExcluded) {
// Remove from excluded list
@ -1313,14 +1327,14 @@ const PluginsScreen: React.FC = () => {
// Add to excluded list
newExcluded = [...currentExcluded, quality];
}
await updateSetting('excludedQualities', newExcluded);
};
const handleToggleLanguageExclusion = async (language: string) => {
const currentExcluded = settings.excludedLanguages || [];
const isExcluded = currentExcluded.includes(language);
let newExcluded: string[];
if (isExcluded) {
// Remove from excluded list
@ -1329,13 +1343,13 @@ const PluginsScreen: React.FC = () => {
// Add to excluded list
newExcluded = [...currentExcluded, language];
}
await updateSetting('excludedLanguages', newExcluded);
};
// Define available quality options
const qualityOptions = ['Auto', 'Adaptive', '2160p', '4K', '1080p', '720p', '360p', 'DV', 'HDR', 'REMUX', '480p', 'CAM', 'TS'];
// Define available language options
const languageOptions = ['Original', 'English', 'Spanish', 'Latin', 'French', 'German', 'Italian', 'Portuguese', 'Russian', 'Japanese', 'Korean', 'Chinese', 'Arabic', 'Hindi', 'Turkish', 'Dutch', 'Polish'];
@ -1344,7 +1358,7 @@ const PluginsScreen: React.FC = () => {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
@ -1354,7 +1368,7 @@ const PluginsScreen: React.FC = () => {
<Ionicons name="arrow-back" size={24} color={colors.primary} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
<View style={styles.headerActions}>
{/* Help Button */}
<TouchableOpacity
@ -1365,7 +1379,7 @@ const PluginsScreen: React.FC = () => {
</TouchableOpacity>
</View>
</View>
<Text style={styles.headerTitle}>Plugins</Text>
<ScrollView
@ -1429,7 +1443,7 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.sectionDescription}>
Manage multiple scraper repositories. Switch between repositories to access different sets of scrapers.
</Text>
{/* Current Repository */}
{currentRepositoryId && (
<View style={styles.currentRepoContainer}>
@ -1438,7 +1452,7 @@ const PluginsScreen: React.FC = () => {
<Text style={[styles.currentRepoUrl, { fontSize: 12, opacity: 0.7, marginTop: 4 }]}>{repositoryUrl}</Text>
</View>
)}
{/* Repository List */}
{repositories.length > 0 && (
<View style={styles.repositoriesList}>
@ -1467,8 +1481,8 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.repositoryUrl}>{repo.url}</Text>
<Text style={styles.repositoryMeta}>
{repo.scraperCount || 0} scrapers Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'}
</Text>
</View>
</Text>
</View>
<View style={styles.repositoryActions}>
{repo.id !== currentRepositoryId && (
<TouchableOpacity
@ -1502,7 +1516,7 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.repositoryActionButtonText}>Remove</Text>
</TouchableOpacity>
</View>
</View>
</View>
))}
</View>
)}
@ -1541,9 +1555,9 @@ const PluginsScreen: React.FC = () => {
{searchQuery.length > 0 && (
<TouchableOpacity onPress={() => setSearchQuery('')}>
<Ionicons name="close-circle" size={20} color={colors.mediumGray} />
</TouchableOpacity>
)}
</View>
</TouchableOpacity>
)}
</View>
{/* Filter Chips */}
<View style={styles.filterContainer}>
@ -1561,7 +1575,7 @@ const PluginsScreen: React.FC = () => {
selectedFilter === filter && styles.filterChipTextSelected
]}>
{filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'}
</Text>
</Text>
</TouchableOpacity>
))}
</View>
@ -1590,17 +1604,17 @@ const PluginsScreen: React.FC = () => {
{filteredScrapers.length === 0 ? (
<View style={styles.emptyStateContainer}>
<Ionicons
name={searchQuery ? "search" : "download-outline"}
size={48}
<Ionicons
name={searchQuery ? "search" : "download-outline"}
size={48}
color={colors.mediumGray}
style={styles.emptyStateIcon}
/>
<Text style={styles.emptyStateTitle}>
{searchQuery ? 'No Scrapers Found' : 'No Scrapers Available'}
</Text>
</Text>
<Text style={styles.emptyStateDescription}>
{searchQuery
{searchQuery
? `No scrapers match "${searchQuery}". Try a different search term.`
: 'Configure a repository above to view available scrapers.'
}
@ -1613,45 +1627,45 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.secondaryButtonText}>Clear Search</Text>
</TouchableOpacity>
)}
</View>
) : (
<View style={styles.scrapersContainer}>
</View>
) : (
<View style={styles.scrapersContainer}>
{filteredScrapers.map((scraper) => (
<View key={scraper.id} style={styles.scraperCard}>
<View style={styles.scraperCardHeader}>
{scraper.logo ? (
(scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? (
<Image
source={{ uri: scraper.logo }}
style={styles.scraperLogo}
resizeMode="contain"
/>
) : (
<FastImage
source={{ uri: scraper.logo }}
style={styles.scraperLogo}
resizeMode={FastImage.resizeMode.contain}
/>
)
) : (
{scraper.logo ? (
(scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? (
<Image
source={{ uri: scraper.logo }}
style={styles.scraperLogo}
resizeMode="contain"
/>
) : (
<FastImage
source={{ uri: scraper.logo }}
style={styles.scraperLogo}
resizeMode={FastImage.resizeMode.contain}
/>
)
) : (
<View style={styles.scraperLogo} />
)}
<View style={styles.scraperCardInfo}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
<Text style={styles.scraperName}>{scraper.name}</Text>
<StatusBadge status={getScraperStatus(scraper)} colors={colors} />
</View>
<Text style={styles.scraperDescription}>{scraper.description}</Text>
</View>
<Switch
value={scraper.enabled && settings.enableLocalScrapers}
onValueChange={(enabled) => handleToggleScraper(scraper.id, enabled)}
trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))}
/>
</View>
<Text style={styles.scraperDescription}>{scraper.description}</Text>
</View>
<Switch
value={scraper.enabled && settings.enableLocalScrapers}
onValueChange={(enabled) => handleToggleScraper(scraper.id, enabled)}
trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))}
/>
</View>
<View style={styles.scraperCardMeta}>
<View style={styles.scraperCardMetaItem}>
<Ionicons name="information-circle" size={12} color={colors.mediumGray} />
@ -1682,62 +1696,62 @@ const PluginsScreen: React.FC = () => {
</View>
{/* ShowBox Settings - only visible when ShowBox scraper is available */}
{showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && (
{showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && (
<View style={{ marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: colors.elevation3 }}>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox UI Token</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}>
<TextInput
style={[styles.textInput, { flex: 1, marginBottom: 0 }]}
value={showboxUiToken}
onChangeText={setShowboxUiToken}
placeholder="Paste your ShowBox UI token"
placeholderTextColor={colors.mediumGray}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={showboxSavedToken.length > 0 && !showboxTokenVisible}
multiline={false}
numberOfLines={1}
/>
{showboxSavedToken.length > 0 && (
<TouchableOpacity onPress={() => setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}>
<Ionicons name={showboxTokenVisible ? 'eye-off' : 'eye'} size={18} color={colors.primary} />
</TouchableOpacity>
)}
</View>
<View style={styles.buttonRow}>
{showboxUiToken !== showboxSavedToken && (
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={async () => {
if (showboxScraperId) {
await pluginService.setScraperSettings(showboxScraperId, { uiToken: showboxUiToken });
}
setShowboxSavedToken(showboxUiToken);
openAlert('Saved', 'ShowBox settings updated');
}}
>
<Text style={styles.buttonText}>Save</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={async () => {
setShowboxUiToken('');
setShowboxSavedToken('');
if (showboxScraperId) {
await pluginService.setScraperSettings(showboxScraperId, {});
}
}}
>
<Text style={styles.secondaryButtonText}>Clear</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox UI Token</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}>
<TextInput
style={[styles.textInput, { flex: 1, marginBottom: 0 }]}
value={showboxUiToken}
onChangeText={setShowboxUiToken}
placeholder="Paste your ShowBox UI token"
placeholderTextColor={colors.mediumGray}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={showboxSavedToken.length > 0 && !showboxTokenVisible}
multiline={false}
numberOfLines={1}
/>
{showboxSavedToken.length > 0 && (
<TouchableOpacity onPress={() => setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}>
<Ionicons name={showboxTokenVisible ? 'eye-off' : 'eye'} size={18} color={colors.primary} />
</TouchableOpacity>
)}
</View>
<View style={styles.buttonRow}>
{showboxUiToken !== showboxSavedToken && (
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={async () => {
if (showboxScraperId) {
await pluginService.setScraperSettings(showboxScraperId, { uiToken: showboxUiToken });
}
setShowboxSavedToken(showboxUiToken);
openAlert('Saved', 'ShowBox settings updated');
}}
>
<Text style={styles.buttonText}>Save</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={async () => {
setShowboxUiToken('');
setShowboxSavedToken('');
if (showboxScraperId) {
await pluginService.setScraperSettings(showboxScraperId, {});
}
}}
>
<Text style={styles.secondaryButtonText}>Clear</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
))}
</View>
)}
</View>
)}
</CollapsibleSection>
{/* Additional Settings */}
@ -1763,7 +1777,7 @@ const PluginsScreen: React.FC = () => {
disabled={!settings.enableLocalScrapers}
/>
</View>
<View style={styles.settingRow}>
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Group Plugin Streams</Text>
@ -1772,20 +1786,20 @@ const PluginsScreen: React.FC = () => {
</Text>
</View>
<Switch
value={settings.streamDisplayMode === 'grouped'}
onValueChange={(value) => {
updateSetting('streamDisplayMode', value ? 'grouped' : 'separate');
// Auto-disable quality sorting when grouping is disabled
if (!value && settings.streamSortMode === 'quality-then-scraper') {
updateSetting('streamSortMode', 'scraper-then-quality');
}
}}
trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={settings.streamDisplayMode === 'grouped' ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers}
/>
value={settings.streamDisplayMode === 'grouped'}
onValueChange={(value) => {
updateSetting('streamDisplayMode', value ? 'grouped' : 'separate');
// Auto-disable quality sorting when grouping is disabled
if (!value && settings.streamSortMode === 'quality-then-scraper') {
updateSetting('streamSortMode', 'scraper-then-quality');
}
}}
trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={settings.streamDisplayMode === 'grouped' ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers}
/>
</View>
<View style={styles.settingRow}>
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Sort by Quality First</Text>
@ -1794,14 +1808,14 @@ const PluginsScreen: React.FC = () => {
</Text>
</View>
<Switch
value={settings.streamSortMode === 'quality-then-scraper'}
onValueChange={(value) => updateSetting('streamSortMode', value ? 'quality-then-scraper' : 'scraper-then-quality')}
trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={settings.streamSortMode === 'quality-then-scraper' ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers || settings.streamDisplayMode !== 'grouped'}
/>
value={settings.streamSortMode === 'quality-then-scraper'}
onValueChange={(value) => updateSetting('streamSortMode', value ? 'quality-then-scraper' : 'scraper-then-quality')}
trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={settings.streamSortMode === 'quality-then-scraper' ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers || settings.streamDisplayMode !== 'grouped'}
/>
</View>
<View style={styles.settingRow}>
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Show Scraper Logos</Text>
@ -1810,12 +1824,12 @@ const PluginsScreen: React.FC = () => {
</Text>
</View>
<Switch
value={settings.showScraperLogos && settings.enableLocalScrapers}
onValueChange={(value) => updateSetting('showScraperLogos', value)}
trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={settings.showScraperLogos && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers}
/>
value={settings.showScraperLogos && settings.enableLocalScrapers}
onValueChange={(value) => updateSetting('showScraperLogos', value)}
trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={settings.showScraperLogos && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers}
/>
</View>
</CollapsibleSection>
@ -1830,7 +1844,7 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.sectionDescription}>
Exclude specific video qualities from search results. Tap on a quality to exclude it from plugin results.
</Text>
<View style={styles.qualityChipsContainer}>
{qualityOptions.map((quality) => {
const isExcluded = (settings.excludedQualities || []).includes(quality);
@ -1856,7 +1870,7 @@ const PluginsScreen: React.FC = () => {
);
})}
</View>
{(settings.excludedQualities || []).length > 0 && (
<Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}>
Excluded qualities: {(settings.excludedQualities || []).join(', ')}
@ -1875,11 +1889,11 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.sectionDescription}>
Exclude specific languages from search results. Tap on a language to exclude it from plugin results.
</Text>
<Text style={[styles.infoText, { marginTop: 8, fontSize: 13, color: colors.mediumEmphasis }]}>
<Text style={{ fontWeight: '600' }}>Note:</Text> This filter only applies to providers that include language information in their stream names. It does not affect other providers.
</Text>
<View style={styles.qualityChipsContainer}>
{languageOptions.map((language) => {
const isExcluded = (settings.excludedLanguages || []).includes(language);
@ -1905,7 +1919,7 @@ const PluginsScreen: React.FC = () => {
);
})}
</View>
{(settings.excludedLanguages || []).length > 0 && (
<Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}>
Excluded languages: {(settings.excludedLanguages || []).join(', ')}
@ -1988,36 +2002,36 @@ const PluginsScreen: React.FC = () => {
/>
{/* Format Hint */}
<Text style={styles.formatHint}>
Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch
</Text>
{/* Format Hint */}
<Text style={styles.formatHint}>
Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch
</Text>
{/* Action Buttons */}
<View style={styles.compactActions}>
<TouchableOpacity
style={[styles.compactButton, styles.cancelButton]}
onPress={() => {
setShowAddRepositoryModal(false);
setNewRepositoryUrl('');
}}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
{/* Action Buttons */}
<View style={styles.compactActions}>
<TouchableOpacity
style={[styles.compactButton, styles.cancelButton]}
onPress={() => {
setShowAddRepositoryModal(false);
setNewRepositoryUrl('');
}}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.compactButton, styles.addButton, (!newRepositoryUrl.trim() || isLoading) && styles.disabledButton]}
onPress={handleAddRepository}
disabled={!newRepositoryUrl.trim() || isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<Text style={styles.addButtonText}>Add</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
<TouchableOpacity
style={[styles.compactButton, styles.addButton, (!newRepositoryUrl.trim() || isLoading) && styles.disabledButton]}
onPress={handleAddRepository}
disabled={!newRepositoryUrl.trim() || isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<Text style={styles.addButtonText}>Add</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
</View>
</View>
</Modal>

View file

@ -18,7 +18,7 @@ import {
Platform,
Easing,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useNavigation, useRoute, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons, Feather } from '@expo/vector-icons';
import { catalogService, StreamingContent, GroupedSearchResults, AddonSearchResults } from '../services/catalogService';
@ -235,6 +235,15 @@ const SearchScreen = () => {
const addonOrderRankRef = useRef<Record<string, number>>({});
// Track if this is the initial mount to prevent unnecessary operations
const isInitialMount = useRef(true);
// Track mount status for async operations
const isMounted = useRef(true);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
// DropUpMenu state
const [menuVisible, setMenuVisible] = useState(false);
const [selectedItem, setSelectedItem] = useState<StreamingContent | null>(null);
@ -380,45 +389,87 @@ const SearchScreen = () => {
// Create a stable debounced search function using useMemo
const debouncedSearch = useMemo(() => {
return debounce(async (searchQuery: string) => {
if (!searchQuery.trim()) {
// Cancel any in-flight live search
liveSearchHandle.current?.cancel();
liveSearchHandle.current = null;
setResults({ byAddon: [], allResults: [] });
setSearching(false);
return;
// Cancel any in-flight live search
liveSearchHandle.current?.cancel();
liveSearchHandle.current = null;
performLiveSearch(searchQuery);
}, 800);
}, []); // Empty dependency array - create once and never recreate
// Track focus state to strictly prevent updates when blurred (fixes Telemetry crash)
useFocusEffect(
useCallback(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
// Cancel any active searches immediately on blur
if (liveSearchHandle.current) {
liveSearchHandle.current.cancel();
liveSearchHandle.current = null;
}
debouncedSearch.cancel();
};
}, [debouncedSearch])
);
// Live search implementation
const performLiveSearch = async (searchQuery: string) => {
// strict guard: don't search if unmounted or blurred
if (!isMounted.current) return;
if (!searchQuery || searchQuery.trim().length === 0) {
setResults({ byAddon: [], allResults: [] });
setSearching(false);
return;
}
setSearching(true);
setResults({ byAddon: [], allResults: [] });
// Reset order rank for new search
addonOrderRankRef.current = {};
try {
if (liveSearchHandle.current) {
liveSearchHandle.current.cancel();
}
// Cancel prior live search
liveSearchHandle.current?.cancel();
setResults({ byAddon: [], allResults: [] });
setSearching(true);
// Pre-fetch addon list to establish a stable order rank
const addons = await catalogService.getAllAddons();
// ... (rank logic) ...
const rank: Record<string, number> = {};
let rankCounter = 0;
logger.info('Starting live search for:', searchQuery);
// Preload addon order to keep sections sorted by installation order
try {
const addons = await catalogService.getAllAddons();
const rank: Record<string, number> = {};
addons.forEach((a, idx) => { rank[a.id] = idx; });
addonOrderRankRef.current = rank;
} catch { }
// Cinemeta first
rank['com.linvo.cinemeta'] = rankCounter++;
// Then others
addons.forEach(addon => {
if (addon.id !== 'com.linvo.cinemeta') {
rank[addon.id] = rankCounter++;
}
});
addonOrderRankRef.current = rank;
const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => {
// Append/update this addon section immediately with minimal changes
setResults(prev => {
const rank = addonOrderRankRef.current;
const getRank = (id: string) => rank[id] ?? Number.MAX_SAFE_INTEGER;
// Prevent updates if component is unmounted or blurred
if (!isMounted.current) return;
// Append/update this addon section...
setResults(prev => {
// ... (existing update logic) ...
if (!isMounted.current) return prev; // Extra guard inside setter
const getRank = (id: string) => addonOrderRankRef.current[id] ?? Number.MAX_SAFE_INTEGER;
// ... (same logic as before) ...
const existingIndex = prev.byAddon.findIndex(s => s.addonId === section.addonId);
if (existingIndex >= 0) {
// Update existing section in-place (preserve order and other sections)
const copy = prev.byAddon.slice();
copy[existingIndex] = section;
return { byAddon: copy, allResults: prev.allResults };
}
// Insert new section at correct position based on rank
// Insert new section
const insertRank = getRank(section.addonId);
let insertAt = prev.byAddon.length;
for (let i = 0; i < prev.byAddon.length; i++) {
@ -442,15 +493,24 @@ const SearchScreen = () => {
return { byAddon: nextByAddon, allResults: prev.allResults };
});
// Save to recents after first result batch
try {
await saveRecentSearch(searchQuery);
} catch { }
});
liveSearchHandle.current = handle;
}, 800);
}, []); // Empty dependency array - create once and never recreate
liveSearchHandle.current = handle;
await handle.done;
if (isMounted.current) {
setSearching(false);
}
} catch (error) {
if (isMounted.current) {
console.error('Live search error:', error);
setSearching(false);
}
}
};
useEffect(() => {
// Skip initial mount to prevent unnecessary operations
if (isInitialMount.current) {
@ -503,22 +563,20 @@ const SearchScreen = () => {
if (!showRecent || recentSearches.length === 0) return null;
return (
<Animated.View
<View
style={styles.recentSearchesContainer}
entering={FadeIn.duration(300)}
>
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
Recent Searches
</Text>
{recentSearches.map((search, index) => (
<AnimatedTouchable
<TouchableOpacity
key={index}
style={styles.recentSearchItem}
onPress={() => {
setQuery(search);
Keyboard.dismiss();
}}
entering={FadeIn.duration(300).delay(index * 50)}
>
<MaterialIcons
name="history"
@ -541,9 +599,9 @@ const SearchScreen = () => {
>
<MaterialIcons name="close" size={16} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</AnimatedTouchable>
</TouchableOpacity>
))}
</Animated.View>
</View>
);
};
@ -557,6 +615,25 @@ const SearchScreen = () => {
}) => {
const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary);
const [watched, setWatched] = React.useState(false);
// Calculate dimensions based on poster shape
const { itemWidth, aspectRatio } = useMemo(() => {
const shape = item.posterShape || 'poster';
const baseHeight = HORIZONTAL_POSTER_HEIGHT;
let w = HORIZONTAL_ITEM_WIDTH;
let r = 2 / 3;
if (shape === 'landscape') {
r = 16 / 9;
w = baseHeight * r;
} else if (shape === 'square') {
r = 1;
w = baseHeight;
}
return { itemWidth: w, aspectRatio: r };
}, [item.posterShape]);
React.useEffect(() => {
const updateWatched = () => {
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
@ -572,9 +649,10 @@ const SearchScreen = () => {
});
return () => unsubscribe();
}, [item.id, item.type]);
return (
<AnimatedTouchable
style={styles.horizontalItem}
<TouchableOpacity
style={[styles.horizontalItem, { width: itemWidth }]}
onPress={() => {
navigation.navigate('Metadata', { id: item.id, type: item.type });
}}
@ -584,10 +662,14 @@ const SearchScreen = () => {
// Do NOT toggle refreshFlag here
}}
delayLongPress={300}
entering={FadeIn.duration(300).delay(index * 50)}
activeOpacity={0.7}
>
<View style={[styles.horizontalItemPosterContainer, {
width: itemWidth,
height: undefined, // Let aspect ratio control height or keep fixed height with width?
// Actually, since we derived width from fixed height, we can keep height fixed or use aspect.
// Using aspect ratio is safer if baseHeight changes.
aspectRatio: aspectRatio,
backgroundColor: currentTheme.colors.darkBackground,
borderColor: 'rgba(255,255,255,0.05)'
}]}>
@ -634,7 +716,7 @@ const SearchScreen = () => {
{item.year}
</Text>
)}
</AnimatedTouchable>
</TouchableOpacity>
);
};
@ -664,7 +746,7 @@ const SearchScreen = () => {
);
return (
<Animated.View entering={FadeIn.duration(300).delay(addonIndex * 50)}>
<View>
{/* Addon Header */}
<View style={styles.addonHeaderContainer}>
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
@ -679,7 +761,7 @@ const SearchScreen = () => {
{/* Movies */}
{movieResults.length > 0 && (
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
@ -708,12 +790,12 @@ const SearchScreen = () => {
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</Animated.View>
</View>
)}
{/* TV Shows */}
{seriesResults.length > 0 && (
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
@ -742,12 +824,12 @@ const SearchScreen = () => {
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</Animated.View>
</View>
)}
{/* Other types */}
{otherResults.length > 0 && (
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
@ -776,9 +858,9 @@ const SearchScreen = () => {
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</Animated.View>
</View>
)}
</Animated.View>
</View>
);
}, (prev, next) => {
// Only re-render if this section's reference changed
@ -804,13 +886,8 @@ const SearchScreen = () => {
}, []);
return (
<Animated.View
<View
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
entering={Platform.OS === 'android' ? undefined : FadeIn.duration(350)}
exiting={Platform.OS === 'android' ?
FadeOut.duration(200).withInitialValues({ opacity: 1 }) :
FadeOut.duration(250)
}
>
<StatusBar
barStyle="light-content"
@ -884,9 +961,8 @@ const SearchScreen = () => {
/>
</View>
) : query.trim().length === 1 ? (
<Animated.View
<View
style={styles.emptyContainer}
entering={FadeIn.duration(300)}
>
<MaterialIcons
name="search"
@ -899,11 +975,10 @@ const SearchScreen = () => {
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
Type at least 2 characters to search
</Text>
</Animated.View>
</View>
) : searched && !hasResultsToShow ? (
<Animated.View
<View
style={styles.emptyContainer}
entering={FadeIn.duration(300)}
>
<MaterialIcons
name="search-off"
@ -916,14 +991,13 @@ const SearchScreen = () => {
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
Try different keywords or check your spelling
</Text>
</Animated.View>
</View>
) : (
<Animated.ScrollView
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollViewContent}
keyboardShouldPersistTaps="handled"
onScrollBeginDrag={Keyboard.dismiss}
entering={FadeIn.duration(300)}
showsVerticalScrollIndicator={false}
>
{!query.trim() && renderRecentSearches()}
@ -935,7 +1009,7 @@ const SearchScreen = () => {
addonIndex={addonIndex}
/>
))}
</Animated.ScrollView>
</ScrollView>
)}
</View>
{/* DropUpMenu integration for search results */}
@ -981,7 +1055,7 @@ const SearchScreen = () => {
}}
/>
)}
</Animated.View>
</View>
);
};

View file

@ -410,9 +410,9 @@ export const StreamsScreen = () => {
isLoadingStreamsRef.current = true;
try {
// Check for Stremio addons
const hasStremioProviders = await stremioService.hasStreamProviders();
if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders);
// Check for Stremio addons that support this content type (including embedded streams)
const hasStremioProviders = await stremioService.hasStreamProviders(type);
if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders, 'for type:', type);
// Check for local scrapers (only if enabled in settings)
const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers();
@ -802,19 +802,25 @@ export const StreamsScreen = () => {
}, [type, id, currentEpisode?.season_number, currentEpisode?.episode_number]);
const navigateToPlayer = useCallback(async (stream: Stream, options?: { forceVlc?: boolean; headers?: Record<string, string> }) => {
// Filter headers for Vidrock - only send essential headers
// Filter headers for Vidrock - only send essential headers
// Filter headers for Vidrock - only send essential headers
const filterHeadersForVidrock = (headers: Record<string, string> | undefined): Record<string, string> | undefined => {
if (!headers) return undefined;
// Only keep essential headers for Vidrock
const essentialHeaders: Record<string, string> = {};
// @ts-ignore
if (headers['User-Agent']) essentialHeaders['User-Agent'] = headers['User-Agent'];
// @ts-ignore
if (headers['Referer']) essentialHeaders['Referer'] = headers['Referer'];
// @ts-ignore
if (headers['Origin']) essentialHeaders['Origin'] = headers['Origin'];
return Object.keys(essentialHeaders).length > 0 ? essentialHeaders : undefined;
};
// @ts-ignore
const finalHeaders = filterHeadersForVidrock(options?.headers || stream.headers);
// Add logging here
@ -883,8 +889,9 @@ export const StreamsScreen = () => {
// Simple platform check - iOS uses KSPlayerCore, Android uses AndroidVideoPlayer
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
// @ts-ignore
navigation.navigate(playerRoute as any, {
uri: stream.url,
uri: stream.url as any,
title: metadata?.name || '',
episodeTitle: (type === 'series' || type === 'other') ? currentEpisode?.name : undefined,
season: (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined,
@ -1040,6 +1047,9 @@ export const StreamsScreen = () => {
if (index >= externalPlayerUrls.length) {
if (__DEV__) console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`);
// Try direct URL as last resort
if (__DEV__) console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`);
// Try direct URL as last resort
// @ts-ignore
Linking.openURL(stream.url)
.then(() => { if (__DEV__) console.log('Opened with direct URL'); })
.catch(() => {

View file

@ -9,7 +9,8 @@ import {
StatusBar,
Platform,
Dimensions,
Linking
Linking,
Switch
} from 'react-native';
import { useToast } from '../contexts/ToastContext';
import { useNavigation } from '@react-navigation/native';
@ -37,9 +38,9 @@ interface SettingsCardProps {
const SettingsCard: React.FC<SettingsCardProps> = ({ children, title, isTablet = false }) => {
const { currentTheme } = useTheme();
return (
<View
<View
style={[
styles.cardContainer,
isTablet && styles.tabletCardContainer
@ -111,19 +112,84 @@ const UpdateScreen: React.FC = () => {
const [updateProgress, setUpdateProgress] = useState<number>(0);
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'downloading' | 'installing' | 'success' | 'error'>('idle');
// Update notification settings
const [otaAlertsEnabled, setOtaAlertsEnabled] = useState(true);
const [majorAlertsEnabled, setMajorAlertsEnabled] = useState(true);
// Load notification settings on mount
useEffect(() => {
(async () => {
try {
const otaSetting = await mmkvStorage.getItem('@ota_updates_alerts_enabled');
const majorSetting = await mmkvStorage.getItem('@major_updates_alerts_enabled');
// Default to true if not set
setOtaAlertsEnabled(otaSetting !== 'false');
setMajorAlertsEnabled(majorSetting !== 'false');
} catch { }
})();
}, []);
// Handle toggling OTA alerts with warning
const handleOtaAlertsToggle = async (value: boolean) => {
if (!value) {
openAlert(
'Disable OTA Update Alerts?',
'You will no longer receive automatic notifications for OTA updates.\n\n⚠ Warning: Staying on the latest version is important for:\n• Bug fixes and stability improvements\n• New features and enhancements\n• Providing accurate feedback and crash reports\n\nYou can still manually check for updates in this screen.',
[
{ label: 'Cancel', onPress: () => setAlertVisible(false) },
{
label: 'Disable',
onPress: async () => {
await mmkvStorage.setItem('@ota_updates_alerts_enabled', 'false');
setOtaAlertsEnabled(false);
setAlertVisible(false);
}
}
]
);
} else {
await mmkvStorage.setItem('@ota_updates_alerts_enabled', 'true');
setOtaAlertsEnabled(true);
}
};
// Handle toggling Major update alerts with warning
const handleMajorAlertsToggle = async (value: boolean) => {
if (!value) {
openAlert(
'Disable Major Update Alerts?',
'You will no longer receive notifications for major app updates that require reinstallation.\n\n⚠ Warning: Major updates often include:\n• Critical security patches\n• Breaking changes that require app reinstall\n• Important compatibility fixes\n\nYou can still check for updates manually.',
[
{ label: 'Cancel', onPress: () => setAlertVisible(false) },
{
label: 'Disable',
onPress: async () => {
await mmkvStorage.setItem('@major_updates_alerts_enabled', 'false');
setMajorAlertsEnabled(false);
setAlertVisible(false);
}
}
]
);
} else {
await mmkvStorage.setItem('@major_updates_alerts_enabled', 'true');
setMajorAlertsEnabled(true);
}
};
const checkForUpdates = async () => {
try {
setIsChecking(true);
setUpdateStatus('checking');
setUpdateProgress(0);
setLastOperation('Checking for updates...');
const info = await UpdateService.checkForUpdates();
setUpdateInfo(info);
setLastChecked(new Date());
// Logs disabled
if (info.isAvailable) {
setUpdateStatus('available');
setLastOperation(`Update available: ${info.manifest?.id || 'unknown'}`);
@ -135,7 +201,7 @@ const UpdateScreen: React.FC = () => {
if (__DEV__) console.error('Error checking for updates:', error);
setUpdateStatus('error');
setLastOperation(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
openAlert('Error', 'Failed to check for updates');
openAlert('Error', 'Failed to check for updates');
} finally {
setIsChecking(false);
}
@ -146,12 +212,12 @@ const UpdateScreen: React.FC = () => {
if (Platform.OS === 'android') {
// ensure badge clears when entering this screen
(async () => {
try { await mmkvStorage.removeItem('@update_badge_pending'); } catch {}
try { await mmkvStorage.removeItem('@update_badge_pending'); } catch { }
})();
}
checkForUpdates();
// Also refresh GitHub section on mount (works in dev and prod)
try { github.refresh(); } catch {}
try { github.refresh(); } catch { }
if (Platform.OS === 'android') {
showInfo('Checking for Updates', 'Checking for updates…');
}
@ -163,7 +229,7 @@ const UpdateScreen: React.FC = () => {
setUpdateStatus('downloading');
setUpdateProgress(0);
setLastOperation('Downloading update...');
// Simulate progress updates
const progressInterval = setInterval(() => {
setUpdateProgress(prev => {
@ -171,30 +237,30 @@ const UpdateScreen: React.FC = () => {
return prev + Math.random() * 10;
});
}, 500);
const success = await UpdateService.downloadAndInstallUpdate();
clearInterval(progressInterval);
setUpdateProgress(100);
setUpdateStatus('installing');
setLastOperation('Installing update...');
// Logs disabled
if (success) {
setUpdateStatus('success');
setLastOperation('Update installed successfully');
openAlert('Success', 'Update will be applied on next app restart');
openAlert('Success', 'Update will be applied on next app restart');
} else {
setUpdateStatus('error');
setLastOperation('No update available to install');
openAlert('No Update', 'No update available to install');
openAlert('No Update', 'No update available to install');
}
} catch (error) {
if (__DEV__) console.error('Error installing update:', error);
setUpdateStatus('error');
setLastOperation(`Installation error: ${error instanceof Error ? error.message : 'Unknown error'}`);
openAlert('Error', 'Failed to install update');
openAlert('Error', 'Failed to install update');
} finally {
setIsInstalling(false);
}
@ -236,7 +302,7 @@ const UpdateScreen: React.FC = () => {
try {
setLastOperation('Testing connectivity...');
const isReachable = await UpdateService.testUpdateConnectivity();
if (isReachable) {
setLastOperation('Update server is reachable');
} else {
@ -334,7 +400,7 @@ const UpdateScreen: React.FC = () => {
{ backgroundColor: currentTheme.colors.darkBackground }
]}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
@ -346,316 +412,367 @@ const UpdateScreen: React.FC = () => {
Settings
</Text>
</TouchableOpacity>
<View style={styles.headerActions}>
{/* Empty for now, but ready for future actions */}
</View>
</View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
App Updates
</Text>
<View style={styles.contentContainer}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<SettingsCard title="APP UPDATES" isTablet={isTablet}>
{/* Main Update Card */}
<View style={styles.updateMainCard}>
{/* Status Section */}
<View style={styles.updateStatusSection}>
<View style={[styles.statusIndicator, { backgroundColor: `${getStatusColor()}20` }]}>
{getStatusIcon()}
</View>
<View style={styles.statusContent}>
<Text style={[styles.statusMainText, { color: currentTheme.colors.highEmphasis }]}>
{getStatusText()}
</Text>
<Text style={[styles.statusDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
{lastOperation || 'Ready to check for updates'}
</Text>
</View>
<View style={styles.contentContainer}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<SettingsCard title="APP UPDATES" isTablet={isTablet}>
{/* Main Update Card */}
<View style={styles.updateMainCard}>
{/* Status Section */}
<View style={styles.updateStatusSection}>
<View style={[styles.statusIndicator, { backgroundColor: `${getStatusColor()}20` }]}>
{getStatusIcon()}
</View>
{/* Progress Section */}
{(updateStatus === 'downloading' || updateStatus === 'installing') && (
<View style={styles.progressSection}>
<View style={styles.progressHeader}>
<Text style={[styles.progressLabel, { color: currentTheme.colors.mediumEmphasis }]}>
{updateStatus === 'downloading' ? 'Downloading' : 'Installing'}
</Text>
<Text style={[styles.progressPercentage, { color: currentTheme.colors.primary }]}>
{Math.round(updateProgress)}%
</Text>
</View>
<View style={[styles.modernProgressBar, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<View
style={[
styles.modernProgressFill,
{
backgroundColor: currentTheme.colors.primary,
width: `${updateProgress}%`
}
]}
/>
</View>
</View>
)}
{/* Action Section */}
<View style={styles.actionSection}>
<TouchableOpacity
style={[
styles.modernButton,
styles.primaryAction,
{ backgroundColor: currentTheme.colors.primary },
(isChecking || isInstalling) && styles.disabledAction
]}
onPress={checkForUpdates}
disabled={isChecking || isInstalling}
activeOpacity={0.8}
>
{isChecking ? (
<MaterialIcons name="refresh" size={18} color="white" />
) : (
<MaterialIcons name="system-update" size={18} color="white" />
)}
<Text style={styles.modernButtonText}>
{isChecking ? 'Checking...' : 'Check for Updates'}
</Text>
</TouchableOpacity>
{updateInfo?.isAvailable && updateStatus !== 'success' && (
<TouchableOpacity
style={[
styles.modernButton,
styles.installAction,
{ backgroundColor: currentTheme.colors.success || '#34C759' },
(isInstalling) && styles.disabledAction
]}
onPress={installUpdate}
disabled={isInstalling}
activeOpacity={0.8}
>
{isInstalling ? (
<MaterialIcons name="install-mobile" size={18} color="white" />
) : (
<MaterialIcons name="download" size={18} color="white" />
)}
<Text style={styles.modernButtonText}>
{isInstalling ? 'Installing...' : 'Install Update'}
</Text>
</TouchableOpacity>
)}
<View style={styles.statusContent}>
<Text style={[styles.statusMainText, { color: currentTheme.colors.highEmphasis }]}>
{getStatusText()}
</Text>
<Text style={[styles.statusDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
{lastOperation || 'Ready to check for updates'}
</Text>
</View>
</View>
{/* Release Notes */}
{updateInfo?.isAvailable && !!getReleaseNotes() && (
<View style={styles.infoSection}>
<View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Release notes:</Text>
{/* Progress Section */}
{(updateStatus === 'downloading' || updateStatus === 'installing') && (
<View style={styles.progressSection}>
<View style={styles.progressHeader}>
<Text style={[styles.progressLabel, { color: currentTheme.colors.mediumEmphasis }]}>
{updateStatus === 'downloading' ? 'Downloading' : 'Installing'}
</Text>
<Text style={[styles.progressPercentage, { color: currentTheme.colors.primary }]}>
{Math.round(updateProgress)}%
</Text>
</View>
<View style={[styles.modernProgressBar, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<View
style={[
styles.modernProgressFill,
{
backgroundColor: currentTheme.colors.primary,
width: `${updateProgress}%`
}
]}
/>
</View>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>{getReleaseNotes()}</Text>
</View>
)}
{/* Info Section */}
<View style={styles.infoSection}>
<View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="info-outline" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Version:</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{updateInfo?.manifest?.id ? `${updateInfo.manifest.id.substring(0, 8)}...` : 'Unknown'}
{/* Action Section */}
<View style={styles.actionSection}>
<TouchableOpacity
style={[
styles.modernButton,
styles.primaryAction,
{ backgroundColor: currentTheme.colors.primary },
(isChecking || isInstalling) && styles.disabledAction
]}
onPress={checkForUpdates}
disabled={isChecking || isInstalling}
activeOpacity={0.8}
>
{isChecking ? (
<MaterialIcons name="refresh" size={18} color="white" />
) : (
<MaterialIcons name="system-update" size={18} color="white" />
)}
<Text style={styles.modernButtonText}>
{isChecking ? 'Checking...' : 'Check for Updates'}
</Text>
</View>
{lastChecked && (
<View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="schedule" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Last checked:</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{formatDate(lastChecked)}
</Text>
</View>
)}
</View>
</TouchableOpacity>
{/* Current Version Section */}
<View style={styles.infoSection}>
<View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="verified" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current version:</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}
selectable>
{currentInfo?.manifest?.id || (currentInfo?.isEmbeddedLaunch === false ? 'Unknown' : 'Embedded')}
</Text>
</View>
{!!getCurrentReleaseNotes() && (
<View style={{ marginTop: 8 }}>
<View style={[styles.infoItem, { alignItems: 'flex-start' }]}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current release notes:</Text>
</View>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{getCurrentReleaseNotes()}
</Text>
</View>
)}
</View>
{/* Developer Logs removed */}
</SettingsCard>
{/* GitHub Release (compact) only show when update is available */}
{github.latestTag && isAnyUpgrade(getDisplayedAppVersion(), github.latestTag) ? (
<SettingsCard title="GITHUB RELEASE" isTablet={isTablet}>
<View style={styles.infoSection}>
<View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="new-releases" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current:</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{getDisplayedAppVersion()}
</Text>
</View>
<View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="tag" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Latest:</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{github.latestTag}
</Text>
</View>
{github.releaseNotes ? (
<View style={{ marginTop: 4 }}>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Notes:</Text>
<Text
numberOfLines={3}
style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}
>
{github.releaseNotes}
</Text>
</View>
) : null}
<View style={[styles.actionSection, { marginTop: 8 }]}>
<View style={{ flexDirection: 'row', gap: 10 }}>
<TouchableOpacity
style={[styles.modernButton, { backgroundColor: currentTheme.colors.primary, flex: 1 }]}
onPress={() => github.releaseUrl ? Linking.openURL(github.releaseUrl as string) : null}
activeOpacity={0.8}
>
<MaterialIcons name="open-in-new" size={18} color="white" />
<Text style={styles.modernButtonText}>View Release</Text>
</TouchableOpacity>
</View>
</View>
</View>
</SettingsCard>
) : null}
{false && (
<SettingsCard title="UPDATE LOGS" isTablet={isTablet}>
<View style={styles.logsContainer}>
<View style={styles.logsHeader}>
<Text style={[styles.logsHeaderText, { color: currentTheme.colors.highEmphasis }]}>
Update Service Logs
</Text>
<View style={styles.logsActions}>
<TouchableOpacity
style={[styles.logActionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={testConnectivity}
activeOpacity={0.7}
>
<MaterialIcons name="wifi" size={16} color={currentTheme.colors.primary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.logActionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={testAssetUrls}
activeOpacity={0.7}
>
<MaterialIcons name="link" size={16} color={currentTheme.colors.primary} />
</TouchableOpacity>
{/* Test log removed */}
{/* Copy all logs removed */}
{/* Refresh logs removed */}
{/* Clear logs removed */}
</View>
</View>
<ScrollView
style={[styles.logsScrollView, { backgroundColor: currentTheme.colors.elevation2 }]}
showsVerticalScrollIndicator={true}
nestedScrollEnabled={true}
{updateInfo?.isAvailable && updateStatus !== 'success' && (
<TouchableOpacity
style={[
styles.modernButton,
styles.installAction,
{ backgroundColor: currentTheme.colors.success || '#34C759' },
(isInstalling) && styles.disabledAction
]}
onPress={installUpdate}
disabled={isInstalling}
activeOpacity={0.8}
>
{false ? (
<Text style={[styles.noLogsText, { color: currentTheme.colors.mediumEmphasis }]}>No logs available</Text>
{isInstalling ? (
<MaterialIcons name="install-mobile" size={18} color="white" />
) : (
([] as string[]).map((log, index) => {
const isError = log.indexOf('[ERROR]') !== -1;
const isWarning = log.indexOf('[WARN]') !== -1;
return (
<TouchableOpacity
key={index}
style={[
styles.logEntry,
{ backgroundColor: 'rgba(255,255,255,0.05)' }
]}
onPress={() => {}}
activeOpacity={0.7}
>
<View style={styles.logEntryContent}>
<Text style={[
styles.logText,
{
color: isError
? (currentTheme.colors.error || '#ff4444')
: isWarning
? (currentTheme.colors.warning || '#ffaa00')
: currentTheme.colors.mediumEmphasis
}
]}>
{log}
</Text>
<MaterialIcons
name="content-copy"
size={14}
color={currentTheme.colors.mediumEmphasis}
style={styles.logCopyIcon}
/>
</View>
</TouchableOpacity>
);
})
<MaterialIcons name="download" size={18} color="white" />
)}
</ScrollView>
<Text style={styles.modernButtonText}>
{isInstalling ? 'Installing...' : 'Install Update'}
</Text>
</TouchableOpacity>
)}
</View>
</View>
{/* Release Notes */}
{updateInfo?.isAvailable && !!getReleaseNotes() && (
<View style={styles.infoSection}>
<View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Release notes:</Text>
</View>
</SettingsCard>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>{getReleaseNotes()}</Text>
</View>
)}
</ScrollView>
</View>
{/* Info Section */}
<View style={styles.infoSection}>
<View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="info-outline" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Version:</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{updateInfo?.manifest?.id ? `${updateInfo.manifest.id.substring(0, 8)}...` : 'Unknown'}
</Text>
</View>
{lastChecked && (
<View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="schedule" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Last checked:</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{formatDate(lastChecked)}
</Text>
</View>
)}
</View>
{/* Current Version Section */}
<View style={styles.infoSection}>
<View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="verified" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current version:</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}
selectable>
{currentInfo?.manifest?.id || (currentInfo?.isEmbeddedLaunch === false ? 'Unknown' : 'Embedded')}
</Text>
</View>
{!!getCurrentReleaseNotes() && (
<View style={{ marginTop: 8 }}>
<View style={[styles.infoItem, { alignItems: 'flex-start' }]}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current release notes:</Text>
</View>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{getCurrentReleaseNotes()}
</Text>
</View>
)}
</View>
{/* Developer Logs removed */}
</SettingsCard>
{/* GitHub Release (compact) only show when update is available */}
{github.latestTag && isAnyUpgrade(getDisplayedAppVersion(), github.latestTag) ? (
<SettingsCard title="GITHUB RELEASE" isTablet={isTablet}>
<View style={styles.infoSection}>
<View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="new-releases" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current:</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{getDisplayedAppVersion()}
</Text>
</View>
<View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="tag" size={14} color={currentTheme.colors.primary} />
</View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Latest:</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{github.latestTag}
</Text>
</View>
{github.releaseNotes ? (
<View style={{ marginTop: 4 }}>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Notes:</Text>
<Text
numberOfLines={3}
style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}
>
{github.releaseNotes}
</Text>
</View>
) : null}
<View style={[styles.actionSection, { marginTop: 8 }]}>
<View style={{ flexDirection: 'row', gap: 10 }}>
<TouchableOpacity
style={[styles.modernButton, { backgroundColor: currentTheme.colors.primary, flex: 1 }]}
onPress={() => github.releaseUrl ? Linking.openURL(github.releaseUrl as string) : null}
activeOpacity={0.8}
>
<MaterialIcons name="open-in-new" size={18} color="white" />
<Text style={styles.modernButtonText}>View Release</Text>
</TouchableOpacity>
</View>
</View>
</View>
</SettingsCard>
) : null}
{/* Update Notification Settings */}
<SettingsCard title="NOTIFICATION SETTINGS" isTablet={isTablet}>
{/* OTA Updates Toggle */}
<View style={styles.settingRow}>
<View style={styles.settingInfo}>
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
OTA Update Alerts
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Show notifications for over-the-air updates
</Text>
</View>
<Switch
value={otaAlertsEnabled}
onValueChange={handleOtaAlertsToggle}
trackColor={{ false: '#505050', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? '#fff' : undefined}
ios_backgroundColor="#505050"
/>
</View>
{/* Major Updates Toggle */}
<View style={[styles.settingRow, { borderBottomWidth: 0 }]}>
<View style={styles.settingInfo}>
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
Major Update Alerts
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Show notifications for new app versions on GitHub
</Text>
</View>
<Switch
value={majorAlertsEnabled}
onValueChange={handleMajorAlertsToggle}
trackColor={{ false: '#505050', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? '#fff' : undefined}
ios_backgroundColor="#505050"
/>
</View>
{/* Warning note */}
<View style={[styles.infoItem, { paddingHorizontal: 16, paddingBottom: 12 }]}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.warning || '#FFA500'}20` }]}>
<MaterialIcons name="info-outline" size={14} color={currentTheme.colors.warning || '#FFA500'} />
</View>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis, flex: 1 }]}>
Keeping alerts enabled ensures you receive bug fixes and can provide accurate crash reports.
</Text>
</View>
</SettingsCard>
{false && (
<SettingsCard title="UPDATE LOGS" isTablet={isTablet}>
<View style={styles.logsContainer}>
<View style={styles.logsHeader}>
<Text style={[styles.logsHeaderText, { color: currentTheme.colors.highEmphasis }]}>
Update Service Logs
</Text>
<View style={styles.logsActions}>
<TouchableOpacity
style={[styles.logActionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={testConnectivity}
activeOpacity={0.7}
>
<MaterialIcons name="wifi" size={16} color={currentTheme.colors.primary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.logActionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={testAssetUrls}
activeOpacity={0.7}
>
<MaterialIcons name="link" size={16} color={currentTheme.colors.primary} />
</TouchableOpacity>
{/* Test log removed */}
{/* Copy all logs removed */}
{/* Refresh logs removed */}
{/* Clear logs removed */}
</View>
</View>
<ScrollView
style={[styles.logsScrollView, { backgroundColor: currentTheme.colors.elevation2 }]}
showsVerticalScrollIndicator={true}
nestedScrollEnabled={true}
>
{false ? (
<Text style={[styles.noLogsText, { color: currentTheme.colors.mediumEmphasis }]}>No logs available</Text>
) : (
([] as string[]).map((log, index) => {
const isError = log.indexOf('[ERROR]') !== -1;
const isWarning = log.indexOf('[WARN]') !== -1;
return (
<TouchableOpacity
key={index}
style={[
styles.logEntry,
{ backgroundColor: 'rgba(255,255,255,0.05)' }
]}
onPress={() => { }}
activeOpacity={0.7}
>
<View style={styles.logEntryContent}>
<Text style={[
styles.logText,
{
color: isError
? (currentTheme.colors.error || '#ff4444')
: isWarning
? (currentTheme.colors.warning || '#ffaa00')
: currentTheme.colors.mediumEmphasis
}
]}>
{log}
</Text>
<MaterialIcons
name="content-copy"
size={14}
color={currentTheme.colors.mediumEmphasis}
style={styles.logCopyIcon}
/>
</View>
</TouchableOpacity>
);
})
)}
</ScrollView>
</View>
</SettingsCard>
)}
</ScrollView>
</View>
<CustomAlert
visible={alertVisible}
title={alertTitle}
@ -715,7 +832,7 @@ const styles = StyleSheet.create({
width: '100%',
paddingBottom: 90,
},
// Common card styles
cardContainer: {
width: '100%',
@ -754,7 +871,7 @@ const styles = StyleSheet.create({
shadowRadius: 8,
elevation: 5,
},
// Update UI Styles
updateMainCard: {
padding: 20,
@ -810,9 +927,9 @@ const styles = StyleSheet.create({
overflow: 'hidden',
},
modernProgressFill: {
height: '100%',
borderRadius: 4,
},
height: '100%',
borderRadius: 4,
},
actionSection: {
gap: 12,
},
@ -905,7 +1022,7 @@ const styles = StyleSheet.create({
fontSize: 11,
fontWeight: '600',
},
// Logs styles
logsContainer: {
padding: 20,
@ -962,6 +1079,30 @@ const styles = StyleSheet.create({
textAlign: 'center',
paddingVertical: 20,
},
// Settings toggle styles
settingRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: 0.5,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
},
settingInfo: {
flex: 1,
marginRight: 12,
},
settingLabel: {
fontSize: 16,
fontWeight: '500',
marginBottom: 4,
},
settingDescription: {
fontSize: 13,
lineHeight: 18,
},
});
export default UpdateScreen;

View file

@ -56,7 +56,7 @@ export interface StreamingContent {
name: string;
tmdbId?: number;
poster: string;
posterShape?: string;
posterShape?: 'poster' | 'square' | 'landscape';
banner?: string;
logo?: string;
imdbRating?: string;
@ -323,7 +323,7 @@ class CatalogService {
};
}
async getHomeCatalogs(limitIds?: string[]): Promise<CatalogContent[]> {
async resolveHomeCatalogsToFetch(limitIds?: string[]): Promise<{ addon: StreamingAddon; catalog: any }[]> {
const addons = await this.getAllAddons();
// Load enabled/disabled settings
@ -360,59 +360,70 @@ class CatalogService {
catalogsToFetch = potentialCatalogs.sort(() => 0.5 - Math.random()).slice(0, 5);
}
// Create promises for the selected catalogs
const catalogPromises = catalogsToFetch.map(async ({ addon, catalog }) => {
try {
// Hoist manifest list retrieval and find once
const addonManifests = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifests.find(a => a.id === addon.id);
if (!manifest) return null;
return catalogsToFetch;
}
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
if (metas && metas.length > 0) {
// Cap items per catalog to reduce memory and rendering load
const limited = metas.slice(0, 12);
const items = limited.map(meta => this.convertMetaToStreamingContent(meta));
async fetchHomeCatalog(addon: StreamingAddon, catalog: any): Promise<CatalogContent | null> {
try {
// Hoist manifest list retrieval and find once
const addonManifests = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifests.find(a => a.id === addon.id);
if (!manifest) return null;
// Get potentially custom display name; if customized, respect it as-is
const originalName = catalog.name || catalog.id;
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName);
const isCustom = displayName !== originalName;
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
if (metas && metas.length > 0) {
// Cap items per catalog to reduce memory and rendering load
const limited = metas.slice(0, 12);
const items = limited.map(meta => this.convertMetaToStreamingContent(meta));
if (!isCustom) {
// Remove duplicate words and clean up the name (case-insensitive)
const words = displayName.split(' ');
const uniqueWords: string[] = [];
const seenWords = new Set<string>();
for (const word of words) {
const lowerWord = word.toLowerCase();
if (!seenWords.has(lowerWord)) {
uniqueWords.push(word);
seenWords.add(lowerWord);
}
}
displayName = uniqueWords.join(' ');
// Get potentially custom display name; if customized, respect it as-is
const originalName = catalog.name || catalog.id;
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName);
const isCustom = displayName !== originalName;
// Add content type if not present
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
displayName = `${displayName} ${contentType}`;
if (!isCustom) {
// Remove duplicate words and clean up the name (case-insensitive)
const words = displayName.split(' ');
const uniqueWords: string[] = [];
const seenWords = new Set<string>();
for (const word of words) {
const lowerWord = word.toLowerCase();
if (!seenWords.has(lowerWord)) {
uniqueWords.push(word);
seenWords.add(lowerWord);
}
}
displayName = uniqueWords.join(' ');
return {
addon: addon.id,
type: catalog.type,
id: catalog.id,
name: displayName,
items
};
// Add content type if not present
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
displayName = `${displayName} ${contentType}`;
}
}
return null;
} catch (error) {
logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error);
return null;
return {
addon: addon.id,
type: catalog.type,
id: catalog.id,
name: displayName,
items
};
}
return null;
} catch (error) {
logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error);
return null;
}
}
async getHomeCatalogs(limitIds?: string[]): Promise<CatalogContent[]> {
// Determine which catalogs to actually fetch
const catalogsToFetch = await this.resolveHomeCatalogsToFetch(limitIds);
// Create promises for the selected catalogs
const catalogPromises = catalogsToFetch.map(async ({ addon, catalog }) => {
return this.fetchHomeCatalog(addon, catalog);
});
// Wait for all selected catalog fetch promises to resolve in parallel
@ -824,7 +835,7 @@ class CatalogService {
type: meta.type,
name: meta.name,
poster: posterUrl,
posterShape: 'poster',
posterShape: meta.posterShape || 'poster', // Use addon's shape or default to poster type
banner: meta.background,
logo: logoUrl,
imdbRating: meta.imdbRating,
@ -846,7 +857,7 @@ class CatalogService {
type: meta.type,
name: meta.name,
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
posterShape: 'poster',
posterShape: meta.posterShape || 'poster',
banner: meta.background,
// Use addon's logo if available, otherwise undefined
logo: (meta as any).logo || undefined,

File diff suppressed because it is too large Load diff

View file

@ -52,6 +52,8 @@ export interface TraktWatchedItem {
};
plays: number;
last_watched_at: string;
last_updated_at?: string; // Timestamp for syncing - only re-process if newer
reset_at?: string | null; // When user started re-watching - ignore episodes watched before this
seasons?: {
number: number;
episodes: {
@ -251,11 +253,25 @@ export interface TraktScrobbleResponse {
alreadyScrobbled?: boolean;
}
/**
* Content data for Trakt scrobbling.
*
* Required fields:
* - type: 'movie' or 'episode'
* - imdbId: A valid IMDb ID (with or without 'tt' prefix)
* - title: Non-empty content title
*
* Optional fields:
* - year: Release year (must be valid if provided, e.g., 1800-current year+10)
* - season/episode: Required for episode type
* - showTitle/showYear/showImdbId: Show metadata for episodes
*/
export interface TraktContentData {
type: 'movie' | 'episode';
imdbId: string;
title: string;
year: number;
/** Release year - optional as Trakt can often resolve content via IMDb ID alone */
year?: number;
season?: number;
episode?: number;
showTitle?: string;
@ -623,14 +639,14 @@ export class TraktService {
/**
* Get the current completion threshold (user-configured or default)
*/
private get completionThreshold(): number {
public get completionThreshold(): number {
return this._completionThreshold || this.DEFAULT_COMPLETION_THRESHOLD;
}
/**
* Set the completion threshold
*/
private set completionThreshold(value: number) {
public set completionThreshold(value: number) {
this._completionThreshold = value;
}
@ -1312,11 +1328,15 @@ export class TraktService {
try {
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
if (!traktId) {
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
return false;
}
logger.log(`[TraktService] Marking S${season}E${episode} as watched for show ${imdbId} (trakt: ${traktId})`);
// Use shows array with seasons/episodes structure per Trakt API docs
await this.apiRequest('/sync/history', 'POST', {
episodes: [
shows: [
{
ids: {
trakt: traktId
@ -1335,6 +1355,7 @@ export class TraktService {
}
]
});
logger.log(`[TraktService] Successfully marked S${season}E${episode} as watched`);
return true;
} catch (error) {
logger.error('[TraktService] Failed to mark episode as watched:', error);
@ -1342,6 +1363,194 @@ export class TraktService {
}
}
/**
* Mark an entire season as watched on Trakt
* @param imdbId - The IMDb ID of the show
* @param season - The season number to mark as watched
* @param watchedAt - Optional date when watched (defaults to now)
*/
public async markSeasonAsWatched(
imdbId: string,
season: number,
watchedAt: Date = new Date()
): Promise<boolean> {
try {
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
if (!traktId) {
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
return false;
}
logger.log(`[TraktService] Marking entire season ${season} as watched for show ${imdbId} (trakt: ${traktId})`);
// Mark entire season - Trakt will mark all episodes in the season
await this.apiRequest('/sync/history', 'POST', {
shows: [
{
ids: {
trakt: traktId
},
seasons: [
{
number: season,
watched_at: watchedAt.toISOString()
}
]
}
]
});
logger.log(`[TraktService] Successfully marked season ${season} as watched`);
return true;
} catch (error) {
logger.error('[TraktService] Failed to mark season as watched:', error);
return false;
}
}
/**
* Mark multiple episodes as watched on Trakt (batch operation)
* @param imdbId - The IMDb ID of the show
* @param episodes - Array of episodes to mark as watched
* @param watchedAt - Optional date when watched (defaults to now)
*/
public async markEpisodesAsWatched(
imdbId: string,
episodes: Array<{ season: number; episode: number }>,
watchedAt: Date = new Date()
): Promise<boolean> {
try {
if (episodes.length === 0) {
logger.warn('[TraktService] No episodes provided to mark as watched');
return false;
}
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
if (!traktId) {
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
return false;
}
logger.log(`[TraktService] Marking ${episodes.length} episodes as watched for show ${imdbId}`);
// Group episodes by season for the API call
const seasonMap = new Map<number, Array<{ number: number; watched_at: string }>>();
for (const ep of episodes) {
if (!seasonMap.has(ep.season)) {
seasonMap.set(ep.season, []);
}
seasonMap.get(ep.season)!.push({
number: ep.episode,
watched_at: watchedAt.toISOString()
});
}
const seasons = Array.from(seasonMap.entries()).map(([seasonNum, eps]) => ({
number: seasonNum,
episodes: eps
}));
await this.apiRequest('/sync/history', 'POST', {
shows: [
{
ids: {
trakt: traktId
},
seasons
}
]
});
logger.log(`[TraktService] Successfully marked ${episodes.length} episodes as watched`);
return true;
} catch (error) {
logger.error('[TraktService] Failed to mark episodes as watched:', error);
return false;
}
}
/**
* Mark entire show as watched on Trakt (all seasons and episodes)
* @param imdbId - The IMDb ID of the show
* @param watchedAt - Optional date when watched (defaults to now)
*/
public async markShowAsWatched(
imdbId: string,
watchedAt: Date = new Date()
): Promise<boolean> {
try {
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
if (!traktId) {
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
return false;
}
logger.log(`[TraktService] Marking entire show as watched: ${imdbId} (trakt: ${traktId})`);
// Mark entire show - Trakt will mark all episodes
await this.apiRequest('/sync/history', 'POST', {
shows: [
{
ids: {
trakt: traktId
},
watched_at: watchedAt.toISOString()
}
]
});
logger.log(`[TraktService] Successfully marked entire show as watched`);
return true;
} catch (error) {
logger.error('[TraktService] Failed to mark show as watched:', error);
return false;
}
}
/**
* Remove an entire season from watched history on Trakt
* @param imdbId - The IMDb ID of the show
* @param season - The season number to remove from history
*/
public async removeSeasonFromHistory(
imdbId: string,
season: number
): Promise<boolean> {
try {
logger.log(`[TraktService] Removing season ${season} from history for show: ${imdbId}`);
const fullImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
const payload: TraktHistoryRemovePayload = {
shows: [
{
ids: {
imdb: fullImdbId
},
seasons: [
{
number: season
}
]
}
]
};
logger.log(`[TraktService] Sending removeSeasonFromHistory payload:`, JSON.stringify(payload, null, 2));
const result = await this.removeFromHistory(payload);
if (result) {
const success = result.deleted.episodes > 0;
logger.log(`[TraktService] Season removal success: ${success} (${result.deleted.episodes} episodes deleted)`);
return success;
}
logger.log(`[TraktService] No result from removeSeasonFromHistory`);
return false;
} catch (error) {
logger.error('[TraktService] Failed to remove season from history:', error);
return false;
}
}
/**
* Check if a movie is in user's watched history
*/
@ -1430,7 +1639,14 @@ export class TraktService {
}
/**
* Pause watching content (scrobble pause)
* Pause watching content - saves playback progress
*
* NOTE: Trakt API does NOT have a /scrobble/pause endpoint.
* Instead, /scrobble/stop handles both cases:
* - Progress 1-79%: Treated as "pause", saves playback progress to /sync/playback
* - Progress 80%: Treated as "scrobble", marks as watched
*
* This method uses /scrobble/stop which automatically handles the pause/scrobble logic.
*/
public async pauseWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
try {
@ -1446,7 +1662,8 @@ export class TraktService {
return null;
}
return this.apiRequest<TraktScrobbleResponse>('/scrobble/pause', 'POST', payload);
// Use /scrobble/stop - Trakt automatically treats <80% as pause, ≥80% as scrobble
return this.apiRequest<TraktScrobbleResponse>('/scrobble/stop', 'POST', payload);
} catch (error) {
logger.error('[TraktService] Failed to pause watching:', error);
return null;
@ -1527,12 +1744,27 @@ export class TraktService {
/**
* Build scrobble payload for API requests
* Returns null if required data is missing or invalid
*/
private async buildScrobblePayload(contentData: TraktContentData, progress: number): Promise<any | null> {
try {
// Clamp progress between 0 and 100 and round to 2 decimals for API
const clampedProgress = Math.min(100, Math.max(0, Math.round(progress * 100) / 100));
// Helper function to validate year
const isValidYear = (year: number | undefined): year is number => {
if (year === undefined || year === null) return false;
if (typeof year !== 'number' || isNaN(year)) return false;
// Year must be between 1800 and current year + 10
const currentYear = new Date().getFullYear();
return year > 0 && year >= 1800 && year <= currentYear + 10;
};
// Helper function to validate title
const isValidTitle = (title: string | undefined): title is string => {
return typeof title === 'string' && title.trim().length > 0;
};
// Enhanced debug logging for payload building
logger.log('[TraktService] Building scrobble payload:', {
type: contentData.type,
@ -1548,9 +1780,14 @@ export class TraktService {
});
if (contentData.type === 'movie') {
if (!contentData.imdbId || !contentData.title) {
logger.error('[TraktService] Missing movie data for scrobbling:', {
imdbId: contentData.imdbId,
// Validate required movie fields
if (!contentData.imdbId || contentData.imdbId.trim() === '') {
logger.error('[TraktService] Missing movie imdbId for scrobbling');
return null;
}
if (!isValidTitle(contentData.title)) {
logger.error('[TraktService] Missing or empty movie title for scrobbling:', {
title: contentData.title
});
return null;
@ -1561,36 +1798,70 @@ export class TraktService {
? contentData.imdbId
: `tt${contentData.imdbId}`;
// Build movie payload - only include year if valid
const movieData: { title: string; year?: number; ids: { imdb: string } } = {
title: contentData.title.trim(),
ids: {
imdb: imdbIdWithPrefix
}
};
// Only add year if it's valid (prevents year: 0 or invalid years)
if (isValidYear(contentData.year)) {
movieData.year = contentData.year;
} else {
logger.warn('[TraktService] Movie year is missing or invalid, omitting from payload:', {
year: contentData.year
});
}
const payload = {
movie: {
title: contentData.title,
year: contentData.year,
ids: {
imdb: imdbIdWithPrefix
}
},
movie: movieData,
progress: clampedProgress
};
logger.log('[TraktService] Movie payload built:', payload);
return payload;
} else if (contentData.type === 'episode') {
if (!contentData.season || !contentData.episode || !contentData.showTitle || !contentData.showYear) {
logger.error('[TraktService] Missing episode data for scrobbling:', {
season: contentData.season,
episode: contentData.episode,
showTitle: contentData.showTitle,
showYear: contentData.showYear
// Validate season and episode numbers
if (contentData.season === undefined || contentData.season === null || contentData.season < 0) {
logger.error('[TraktService] Invalid season for episode scrobbling:', {
season: contentData.season
});
return null;
}
if (contentData.episode === undefined || contentData.episode === null || contentData.episode <= 0) {
logger.error('[TraktService] Invalid episode number for scrobbling:', {
episode: contentData.episode
});
return null;
}
if (!isValidTitle(contentData.showTitle)) {
logger.error('[TraktService] Missing or empty show title for episode scrobbling:', {
showTitle: contentData.showTitle
});
return null;
}
// Build show data - only include year if valid
const showData: { title: string; year?: number; ids: { imdb?: string } } = {
title: contentData.showTitle.trim(),
ids: {}
};
// Only add year if it's valid
if (isValidYear(contentData.showYear)) {
showData.year = contentData.showYear;
} else {
logger.warn('[TraktService] Show year is missing or invalid, omitting from payload:', {
showYear: contentData.showYear
});
}
const payload: any = {
show: {
title: contentData.showTitle,
year: contentData.showYear,
ids: {}
},
show: showData,
episode: {
season: contentData.season,
number: contentData.episode
@ -1599,7 +1870,7 @@ export class TraktService {
};
// Add show IMDB ID if available
if (contentData.showImdbId) {
if (contentData.showImdbId && contentData.showImdbId.trim() !== '') {
const showImdbWithPrefix = contentData.showImdbId.startsWith('tt')
? contentData.showImdbId
: `tt${contentData.showImdbId}`;
@ -1607,7 +1878,7 @@ export class TraktService {
}
// Add episode IMDB ID if available (for specific episode IDs)
if (contentData.imdbId && contentData.imdbId !== contentData.showImdbId) {
if (contentData.imdbId && contentData.imdbId.trim() !== '' && contentData.imdbId !== contentData.showImdbId) {
const episodeImdbWithPrefix = contentData.imdbId.startsWith('tt')
? contentData.imdbId
: `tt${contentData.imdbId}`;

View file

@ -0,0 +1,392 @@
import { TraktService } from './traktService';
import { storageService } from './storageService';
import { mmkvStorage } from './mmkvStorage';
import { logger } from '../utils/logger';
/**
* WatchedService - Manages "watched" status for movies, episodes, and seasons.
* Handles both local storage and Trakt sync transparently.
*
* When Trakt is authenticated, it syncs to Trakt.
* When not authenticated, it stores locally.
*/
class WatchedService {
private static instance: WatchedService;
private traktService: TraktService;
private constructor() {
this.traktService = TraktService.getInstance();
}
public static getInstance(): WatchedService {
if (!WatchedService.instance) {
WatchedService.instance = new WatchedService();
}
return WatchedService.instance;
}
/**
* Mark a movie as watched
* @param imdbId - The IMDb ID of the movie
* @param watchedAt - Optional date when watched
*/
public async markMovieAsWatched(
imdbId: string,
watchedAt: Date = new Date()
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try {
logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`);
// Check if Trakt is authenticated
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
if (isTraktAuth) {
// Sync to Trakt
syncedToTrakt = await this.traktService.addToWatchedMovies(imdbId, watchedAt);
logger.log(`[WatchedService] Trakt sync result for movie: ${syncedToTrakt}`);
}
// Also store locally as "completed" (100% progress)
await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt);
return { success: true, syncedToTrakt };
} catch (error) {
logger.error('[WatchedService] Failed to mark movie as watched:', error);
return { success: false, syncedToTrakt: false };
}
}
/**
* Mark a single episode as watched
* @param showImdbId - The IMDb ID of the show
* @param showId - The Stremio ID of the show (for local storage)
* @param season - Season number
* @param episode - Episode number
* @param watchedAt - Optional date when watched
*/
public async markEpisodeAsWatched(
showImdbId: string,
showId: string,
season: number,
episode: number,
watchedAt: Date = new Date()
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try {
logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`);
// Check if Trakt is authenticated
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
if (isTraktAuth) {
// Sync to Trakt
syncedToTrakt = await this.traktService.addToWatchedEpisodes(
showImdbId,
season,
episode,
watchedAt
);
logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`);
}
// Store locally as "completed"
const episodeId = `${showId}:${season}:${episode}`;
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
return { success: true, syncedToTrakt };
} catch (error) {
logger.error('[WatchedService] Failed to mark episode as watched:', error);
return { success: false, syncedToTrakt: false };
}
}
/**
* Mark multiple episodes as watched (batch operation)
* @param showImdbId - The IMDb ID of the show
* @param showId - The Stremio ID of the show (for local storage)
* @param episodes - Array of { season, episode } objects
* @param watchedAt - Optional date when watched
*/
public async markEpisodesAsWatched(
showImdbId: string,
showId: string,
episodes: Array<{ season: number; episode: number }>,
watchedAt: Date = new Date()
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
try {
if (episodes.length === 0) {
return { success: true, syncedToTrakt: false, count: 0 };
}
logger.log(`[WatchedService] Marking ${episodes.length} episodes as watched for ${showImdbId}`);
// Check if Trakt is authenticated
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
if (isTraktAuth) {
// Sync to Trakt (batch operation)
syncedToTrakt = await this.traktService.markEpisodesAsWatched(
showImdbId,
episodes,
watchedAt
);
logger.log(`[WatchedService] Trakt batch sync result: ${syncedToTrakt}`);
}
// Store locally as "completed" for each episode
for (const ep of episodes) {
const episodeId = `${showId}:${ep.season}:${ep.episode}`;
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
}
return { success: true, syncedToTrakt, count: episodes.length };
} catch (error) {
logger.error('[WatchedService] Failed to mark episodes as watched:', error);
return { success: false, syncedToTrakt: false, count: 0 };
}
}
/**
* Mark an entire season as watched
* @param showImdbId - The IMDb ID of the show
* @param showId - The Stremio ID of the show (for local storage)
* @param season - Season number
* @param episodeNumbers - Array of episode numbers in the season
* @param watchedAt - Optional date when watched
*/
public async markSeasonAsWatched(
showImdbId: string,
showId: string,
season: number,
episodeNumbers: number[],
watchedAt: Date = new Date()
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
try {
logger.log(`[WatchedService] Marking season ${season} as watched for ${showImdbId}`);
// Check if Trakt is authenticated
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
if (isTraktAuth) {
// Sync entire season to Trakt
syncedToTrakt = await this.traktService.markSeasonAsWatched(
showImdbId,
season,
watchedAt
);
logger.log(`[WatchedService] Trakt season sync result: ${syncedToTrakt}`);
}
// Store locally as "completed" for each episode in the season
for (const epNum of episodeNumbers) {
const episodeId = `${showId}:${season}:${epNum}`;
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
}
return { success: true, syncedToTrakt, count: episodeNumbers.length };
} catch (error) {
logger.error('[WatchedService] Failed to mark season as watched:', error);
return { success: false, syncedToTrakt: false, count: 0 };
}
}
/**
* Unmark a movie as watched (remove from history)
*/
public async unmarkMovieAsWatched(
imdbId: string
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try {
logger.log(`[WatchedService] Unmarking movie as watched: ${imdbId}`);
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
if (isTraktAuth) {
syncedToTrakt = await this.traktService.removeMovieFromHistory(imdbId);
logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`);
}
// Remove local progress
await storageService.removeWatchProgress(imdbId, 'movie');
await mmkvStorage.removeItem(`watched:movie:${imdbId}`);
return { success: true, syncedToTrakt };
} catch (error) {
logger.error('[WatchedService] Failed to unmark movie as watched:', error);
return { success: false, syncedToTrakt: false };
}
}
/**
* Unmark an episode as watched (remove from history)
*/
public async unmarkEpisodeAsWatched(
showImdbId: string,
showId: string,
season: number,
episode: number
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try {
logger.log(`[WatchedService] Unmarking episode as watched: ${showImdbId} S${season}E${episode}`);
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
if (isTraktAuth) {
syncedToTrakt = await this.traktService.removeEpisodeFromHistory(
showImdbId,
season,
episode
);
logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`);
}
// Remove local progress
const episodeId = `${showId}:${season}:${episode}`;
await storageService.removeWatchProgress(showId, 'series', episodeId);
return { success: true, syncedToTrakt };
} catch (error) {
logger.error('[WatchedService] Failed to unmark episode as watched:', error);
return { success: false, syncedToTrakt: false };
}
}
/**
* Unmark an entire season as watched (remove from history)
* @param showImdbId - The IMDb ID of the show
* @param showId - The Stremio ID of the show (for local storage)
* @param season - Season number
* @param episodeNumbers - Array of episode numbers in the season
*/
public async unmarkSeasonAsWatched(
showImdbId: string,
showId: string,
season: number,
episodeNumbers: number[]
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
try {
logger.log(`[WatchedService] Unmarking season ${season} as watched for ${showImdbId}`);
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
if (isTraktAuth) {
// Remove entire season from Trakt
syncedToTrakt = await this.traktService.removeSeasonFromHistory(
showImdbId,
season
);
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
}
// Remove local progress for each episode in the season
for (const epNum of episodeNumbers) {
const episodeId = `${showId}:${season}:${epNum}`;
await storageService.removeWatchProgress(showId, 'series', episodeId);
}
return { success: true, syncedToTrakt, count: episodeNumbers.length };
} catch (error) {
logger.error('[WatchedService] Failed to unmark season as watched:', error);
return { success: false, syncedToTrakt: false, count: 0 };
}
}
/**
* Check if a movie is marked as watched (locally)
*/
public async isMovieWatched(imdbId: string): Promise<boolean> {
try {
// First check local watched flag
const localWatched = await mmkvStorage.getItem(`watched:movie:${imdbId}`);
if (localWatched === 'true') {
return true;
}
// Check local progress
const progress = await storageService.getWatchProgress(imdbId, 'movie');
if (progress) {
const progressPercent = (progress.currentTime / progress.duration) * 100;
if (progressPercent >= 85) {
return true;
}
}
return false;
} catch (error) {
logger.error('[WatchedService] Error checking movie watched status:', error);
return false;
}
}
/**
* Check if an episode is marked as watched (locally)
*/
public async isEpisodeWatched(showId: string, season: number, episode: number): Promise<boolean> {
try {
const episodeId = `${showId}:${season}:${episode}`;
// Check local progress
const progress = await storageService.getWatchProgress(showId, 'series', episodeId);
if (progress) {
const progressPercent = (progress.currentTime / progress.duration) * 100;
if (progressPercent >= 85) {
return true;
}
}
return false;
} catch (error) {
logger.error('[WatchedService] Error checking episode watched status:', error);
return false;
}
}
/**
* Set local watched status by creating a "completed" progress entry
*/
private async setLocalWatchedStatus(
id: string,
type: 'movie' | 'series',
watched: boolean,
episodeId?: string,
watchedAt: Date = new Date()
): Promise<void> {
try {
if (watched) {
// Create a "completed" progress entry (100% watched)
const progress = {
currentTime: 1, // Minimal values to indicate completion
duration: 1,
lastUpdated: watchedAt.getTime(),
traktSynced: false, // Will be set to true if Trakt sync succeeded
traktProgress: 100,
};
await storageService.setWatchProgress(id, type, progress, episodeId, {
forceWrite: true,
forceNotify: true
});
// Also set the legacy watched flag for movies
if (type === 'movie') {
await mmkvStorage.setItem(`watched:${type}:${id}`, 'true');
}
} else {
// Remove progress
await storageService.removeWatchProgress(id, type, episodeId);
if (type === 'movie') {
await mmkvStorage.removeItem(`watched:${type}:${id}`);
}
}
} catch (error) {
logger.error('[WatchedService] Error setting local watched status:', error);
}
}
}
export const watchedService = WatchedService.getInstance();

View file

@ -11,42 +11,74 @@ export type RouteParams = {
episodeId?: string;
};
// Stream related types
// Stream related types - aligned with Stremio protocol
export interface Subtitle {
id: string; // Required per protocol
url: string;
lang: string;
fps?: number;
addon?: string;
addonName?: string;
format?: 'srt' | 'vtt' | 'ass' | 'ssa';
}
export interface Stream {
// Primary stream source - one of these must be provided per protocol
url?: string; // Direct HTTP URL (now optional)
ytId?: string; // YouTube video ID
infoHash?: string; // BitTorrent info hash
externalUrl?: string; // External URL to open in browser
// Display information
name?: string;
title?: string;
url: string;
description?: string;
// Addon identification
addon?: string;
addonId?: string;
addonName?: string;
behaviorHints?: {
cached?: boolean;
[key: string]: any;
};
// Stream properties
size?: number;
isFree?: boolean;
isDebrid?: boolean;
quality?: string;
type?: string;
lang?: string;
fileIdx?: number;
headers?: {
Referer?: string;
'User-Agent'?: string;
Origin?: string;
[key: string]: string | undefined;
};
files?: {
file: string;
type: string;
quality: string;
lang: string;
}[];
subtitles?: {
url: string;
lang: string;
}[];
addon?: string;
description?: string;
infoHash?: string;
fileIdx?: number;
size?: number;
isFree?: boolean;
isDebrid?: boolean;
subtitles?: Subtitle[];
sources?: string[];
behaviorHints?: {
bingeGroup?: string;
notWebReady?: boolean;
countryWhitelist?: string[];
cached?: boolean;
proxyHeaders?: {
request?: Record<string, string>;
response?: Record<string, string>;
};
videoHash?: string;
videoSize?: number;
filename?: string;
[key: string]: any;
};
}
export interface GroupedStreams {

View file

@ -1,34 +1,85 @@
export interface Stream {
name?: string;
title?: string;
// Source object for archive streams per protocol
export interface SourceObject {
url: string;
bytes?: number;
}
export interface Subtitle {
id: string; // Required per protocol
url: string;
lang: string;
fps?: number;
addon?: string;
addonName?: string;
format?: 'srt' | 'vtt' | 'ass' | 'ssa';
}
export interface Stream {
// Primary stream source - one of these must be provided
url?: string; // Direct HTTP(S)/FTP(S)/RTMP URL
ytId?: string; // YouTube video ID
infoHash?: string; // BitTorrent info hash
externalUrl?: string; // External URL to open in browser
nzbUrl?: string; // Usenet NZB file URL
rarUrls?: SourceObject[]; // RAR archive files
zipUrls?: SourceObject[]; // ZIP archive files
'7zipUrls'?: SourceObject[]; // 7z archive files
tgzUrls?: SourceObject[]; // TGZ archive files
tarUrls?: SourceObject[]; // TAR archive files
// Stream selection within archives/torrents
fileIdx?: number; // File index in archive/torrent
fileMustInclude?: string; // Regex for file matching in archives
servers?: string[]; // NNTP servers for nzbUrl
// Display information
name?: string; // Stream name (usually quality)
title?: string; // Stream title/description (deprecated for description)
description?: string; // Stream description
// Addon identification
addon?: string;
addonId?: string;
addonName?: string;
behaviorHints?: {
cached?: boolean;
[key: string]: any;
};
// Stream properties
size?: number;
isFree?: boolean;
isDebrid?: boolean;
quality?: string;
type?: string;
lang?: string;
headers?: { [key: string]: string };
headers?: Record<string, string>;
// Legacy files array (for compatibility)
files?: {
file: string;
type: string;
quality: string;
lang: string;
}[];
subtitles?: {
url: string;
lang: string;
}[];
addon?: string;
description?: string;
infoHash?: string;
fileIdx?: number;
size?: number;
isFree?: boolean;
isDebrid?: boolean;
// Embedded subtitles per protocol
subtitles?: Subtitle[];
// Additional tracker/DHT sources
sources?: string[];
// Complete behavior hints per protocol
behaviorHints?: {
bingeGroup?: string; // Group for binge watching
notWebReady?: boolean; // True if not HTTPS MP4
countryWhitelist?: string[]; // ISO 3166-1 alpha-3 codes (lowercase)
cached?: boolean; // Debrid cached status
proxyHeaders?: { // Custom headers for stream
request?: Record<string, string>;
response?: Record<string, string>;
};
videoHash?: string; // OpenSubtitles hash
videoSize?: number; // Video file size in bytes
filename?: string; // Video filename
[key: string]: any;
};
}
export interface GroupedStreams {

View file

@ -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.10';
export const APP_VERSION = '1.2.11';
export function getDisplayedAppVersion(): string {
return APP_VERSION;

View file

@ -0,0 +1,205 @@
const https = require('https');
const fs = require('fs');
const path = require('path');
const API_BLUEPRINT_URL = 'https://jsapi.apiary.io/apis/trakt.apib';
// Category mapping based on group names
const CATEGORIES = {
'introduction': { file: '01-introduction.md', title: 'Introduction' },
'authentication-oauth': { file: '02-authentication-oauth.md', title: 'Authentication - OAuth' },
'authentication-devices': { file: '03-authentication-devices.md', title: 'Authentication - Devices' },
'calendars': { file: '04-calendars.md', title: 'Calendars' },
'checkin': { file: '05-checkin.md', title: 'Checkin' },
'certifications': { file: '06-certifications.md', title: 'Certifications' },
'comments': { file: '07-comments.md', title: 'Comments' },
'countries': { file: '08-countries.md', title: 'Countries' },
'genres': { file: '09-genres.md', title: 'Genres' },
'languages': { file: '10-languages.md', title: 'Languages' },
'lists': { file: '11-lists.md', title: 'Lists' },
'movies': { file: '12-movies.md', title: 'Movies' },
'networks': { file: '13-networks.md', title: 'Networks' },
'notes': { file: '14-notes.md', title: 'Notes' },
'people': { file: '15-people.md', title: 'People' },
'recommendations': { file: '16-recommendations.md', title: 'Recommendations' },
'scrobble': { file: '17-scrobble.md', title: 'Scrobble' },
'search': { file: '18-search.md', title: 'Search' },
'shows': { file: '19-shows.md', title: 'Shows' },
'seasons': { file: '20-seasons.md', title: 'Seasons' },
'episodes': { file: '21-episodes.md', title: 'Episodes' },
'sync': { file: '22-sync.md', title: 'Sync' },
'users': { file: '23-users.md', title: 'Users' },
};
function fetchUrl(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(data));
res.on('error', reject);
}).on('error', reject);
});
}
function parseApiBlueprint(content) {
const sections = {};
let currentGroup = 'introduction';
let currentContent = [];
const lines = content.split('\n');
for (const line of lines) {
// Detect group headers like "# Group Authentication - OAuth"
const groupMatch = line.match(/^#\s+Group\s+(.+)$/i);
if (groupMatch) {
// Save previous group
if (currentContent.length > 0) {
if (!sections[currentGroup]) sections[currentGroup] = [];
sections[currentGroup].push(...currentContent);
}
// Start new group
const groupName = groupMatch[1].toLowerCase().replace(/\s+/g, '-');
currentGroup = groupName;
currentContent = [`# ${groupMatch[1]}\n`];
continue;
}
currentContent.push(line);
}
// Save last group
if (currentContent.length > 0) {
if (!sections[currentGroup]) sections[currentGroup] = [];
sections[currentGroup].push(...currentContent);
}
return sections;
}
function convertApiBlueprintToMarkdown(content) {
let md = content;
// Convert API Blueprint specific syntax to markdown
// Parameters section
md = md.replace(/\+ Parameters/g, '### Parameters');
// Request/Response sections
md = md.replace(/\+ Request \(([^)]+)\)/g, '### Request ($1)');
md = md.replace(/\+ Response (\d+)(?: \(([^)]+)\))?/g, (match, code, type) => {
return type ? `### Response ${code} (${type})` : `### Response ${code}`;
});
// Body sections
md = md.replace(/\+ Body/g, '**Body:**');
// Headers
md = md.replace(/\+ Headers/g, '**Headers:**');
// Attributes
md = md.replace(/\+ Attributes/g, '### Attributes');
// Clean up indentation for code blocks
md = md.replace(/^ /gm, ' ');
return md;
}
async function main() {
console.log('🔄 Fetching Trakt API Blueprint...');
try {
const content = await fetchUrl(API_BLUEPRINT_URL);
console.log(`✅ Fetched ${content.length} bytes`);
// Save raw blueprint
fs.writeFileSync(path.join(__dirname, 'raw-api-blueprint.apib'), content);
console.log('📝 Saved raw API Blueprint');
// Parse and organize by groups
const sections = parseApiBlueprint(content);
console.log(`📂 Found ${Object.keys(sections).length} sections`);
// Create markdown files for each category
for (const [groupKey, lines] of Object.entries(sections)) {
const category = CATEGORIES[groupKey];
const fileName = category ? category.file : `${groupKey}.md`;
const title = category ? category.title : groupKey;
let mdContent = lines.join('\n');
mdContent = convertApiBlueprintToMarkdown(mdContent);
// Add header if not present
if (!mdContent.startsWith('# ')) {
mdContent = `# ${title}\n\n${mdContent}`;
}
const filePath = path.join(__dirname, fileName);
fs.writeFileSync(filePath, mdContent);
console.log(`✅ Created ${fileName}`);
}
// Create README
const readme = generateReadme(Object.keys(sections));
fs.writeFileSync(path.join(__dirname, 'README.md'), readme);
console.log('✅ Created README.md');
console.log('\n🎉 Done! All documentation files created.');
} catch (error) {
console.error('❌ Error:', error.message);
process.exit(1);
}
}
function generateReadme(groups) {
let md = `# Trakt API Documentation
This folder contains the complete Trakt API documentation, scraped from [trakt.docs.apiary.io](https://trakt.docs.apiary.io/).
## API Base URL
\`\`\`
https://api.trakt.tv
\`\`\`
## Documentation Files
`;
for (const groupKey of groups) {
const category = CATEGORIES[groupKey];
if (category) {
md += `- [${category.title}](./${category.file})\n`;
} else {
md += `- [${groupKey}](./${groupKey}.md)\n`;
}
}
md += `
## Quick Reference
### Required Headers
| Header | Value |
|---|---|
| \`Content-Type\` | \`application/json\` |
| \`trakt-api-key\` | Your \`client_id\` |
| \`trakt-api-version\` | \`2\` |
| \`Authorization\` | \`Bearer [access_token]\` (for authenticated endpoints) |
### Useful Links
- [Create API App](https://trakt.tv/oauth/applications/new)
- [GitHub Developer Forum](https://github.com/trakt/api-help/issues)
- [API Blog](https://apiblog.trakt.tv)
---
*Generated on ${new Date().toISOString()}*
`;
return md;
}
main();