diff --git a/.gitignore b/.gitignore index f857a691..fd89a972 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,5 @@ node_modules expofs.md ios/sentry.properties android/sentry.properties +Stremio addons refer +trakt-docs \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 4bb7b500..d8870d1c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a76a5351..98e10195 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -3,5 +3,5 @@ contain false dark - 1.2.10 + 1.2.11 \ No newline at end of file diff --git a/android/sentry.properties b/android/sentry.properties index 8e272484..ae003a4b 100644 --- a/android/sentry.properties +++ b/android/sentry.properties @@ -1,4 +1,4 @@ defaults.url=https://sentry.io/ defaults.org=tapframe defaults.project=react-native -auth.token=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 \ No newline at end of file +auth.token=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 diff --git a/app.json b/app.json index a6843c69..b606a163 100644 --- a/app.json +++ b/app.json @@ -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" } } \ No newline at end of file diff --git a/ios/KSPlayerManager.m b/ios/KSPlayerManager.m index 5744e62d..708118ab 100644 --- a/ios/KSPlayerManager.m +++ b/ios/KSPlayerManager.m @@ -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 diff --git a/ios/KSPlayerModule.swift b/ios/KSPlayerModule.swift index 58ace7c1..27dd6142 100644 --- a/ios/KSPlayerModule.swift +++ b/ios/KSPlayerModule.swift @@ -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 { diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index ce6a175d..c83f2125 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -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"; diff --git a/ios/Nuvio/Info.plist b/ios/Nuvio/Info.plist index 40a35d5d..619dacdf 100644 --- a/ios/Nuvio/Info.plist +++ b/ios/Nuvio/Info.plist @@ -1,99 +1,103 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Nuvio - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.2.10 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - nuvio - com.nuvio.app - - - - CFBundleURLSchemes - - exp+nuvio - - - - CFBundleVersion - 25 - LSMinimumSystemVersion - 12.0 - LSRequiresIPhoneOS - - LSSupportsOpeningDocumentsInPlace - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSBonjourServices - - _http._tcp - _googlecast._tcp - _CC1AD845._googlecast._tcp - - RCTNewArchEnabled - - RCTRootViewBackgroundColor - 4278322180 - UIBackgroundModes - - audio - - UIFileSharingEnabled - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Dark - UIViewControllerBasedStatusBarAppearance - - - + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nuvio + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.11 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + nuvio + com.nuvio.app + + + + CFBundleURLSchemes + + exp+nuvio + + + + CFBundleVersion + 26 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _http._tcp + _googlecast._tcp + _CC1AD845._googlecast._tcp + + NSLocalNetworkUsageDescription + Allow $(PRODUCT_NAME) to access your local network + NSMicrophoneUsageDescription + This app does not require microphone access. + RCTNewArchEnabled + + RCTRootViewBackgroundColor + 4278322180 + UIBackgroundModes + + audio + + UIFileSharingEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Dark + UIViewControllerBasedStatusBarAppearance + + + \ No newline at end of file diff --git a/ios/Nuvio/NuvioRelease.entitlements b/ios/Nuvio/NuvioRelease.entitlements index 903def2a..a0bc443f 100644 --- a/ios/Nuvio/NuvioRelease.entitlements +++ b/ios/Nuvio/NuvioRelease.entitlements @@ -1,8 +1,10 @@ - - aps-environment - development - - + + aps-environment + development + com.apple.developer.associated-domains + + + \ No newline at end of file diff --git a/ios/Nuvio/Supporting/Expo.plist b/ios/Nuvio/Supporting/Expo.plist index c7cf5f80..acc66a18 100644 --- a/ios/Nuvio/Supporting/Expo.plist +++ b/ios/Nuvio/Supporting/Expo.plist @@ -9,7 +9,7 @@ EXUpdatesLaunchWaitMs 30000 EXUpdatesRuntimeVersion - 1.2.10 + 1.2.11 EXUpdatesURL https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest diff --git a/ios/Podfile b/ios/Podfile index 490153d6..7c61ff44 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -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()"]; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 61b7dad7..ed449bc5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 diff --git a/ios/sentry.properties b/ios/sentry.properties index 8e272484..ae003a4b 100644 --- a/ios/sentry.properties +++ b/ios/sentry.properties @@ -1,4 +1,4 @@ defaults.url=https://sentry.io/ defaults.org=tapframe defaults.project=react-native -auth.token=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 \ No newline at end of file +auth.token=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 diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 87e436af..0284f8e0 100644 --- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -222,11 +222,13 @@ public class ReactExoplayerView extends FrameLayout implements private ArrayList rootViewChildrenOriginalVisibility = new ArrayList(); /* - * When user is seeking first called is on onPositionDiscontinuity -> DISCONTINUITY_REASON_SEEK + * When user is seeking first called is on onPositionDiscontinuity -> + * DISCONTINUITY_REASON_SEEK * Then we set if to false when playback is back in onIsPlayingChanged -> true */ private boolean isSeeking = false; private long seekPosition = -1; + private boolean hasVideoEnded = false; // Props from React private Source source = new Source(); @@ -291,7 +293,8 @@ public class ReactExoplayerView extends FrameLayout implements lastPos = pos; lastBufferDuration = bufferedDuration; lastDuration = duration; - eventEmitter.onVideoProgress.invoke(pos, bufferedDuration, player.getDuration(), getPositionInFirstPeriodMsForCurrentWindow(pos)); + eventEmitter.onVideoProgress.invoke(pos, bufferedDuration, player.getDuration(), + getPositionInFirstPeriodMsForCurrentWindow(pos)); } } } @@ -309,7 +312,7 @@ public class ReactExoplayerView extends FrameLayout implements public double getPositionInFirstPeriodMsForCurrentWindow(long currentPosition) { Timeline.Window window = new Timeline.Window(); - if(!player.getCurrentTimeline().isEmpty()) { + if (!player.getCurrentTimeline().isEmpty()) { player.getCurrentTimeline().getWindow(player.getCurrentMediaItemIndex(), window); } return window.windowStartTimeMs + currentPosition; @@ -348,9 +351,9 @@ public class ReactExoplayerView extends FrameLayout implements LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); exoPlayerView = new ExoPlayerView(getContext()); - exoPlayerView.addOnLayoutChangeListener( (View v, int l, int t, int r, int b, int ol, int ot, int or, int ob) -> - PictureInPictureUtil.applySourceRectHint(themedReactContext, pictureInPictureParamsBuilder, exoPlayerView) - ); + exoPlayerView.addOnLayoutChangeListener( + (View v, int l, int t, int r, int b, int ol, int ot, int or, int ob) -> PictureInPictureUtil + .applySourceRectHint(themedReactContext, pictureInPictureParamsBuilder, exoPlayerView)); exoPlayerView.setLayoutParams(layoutParams); addView(exoPlayerView, 0, layoutParams); @@ -376,8 +379,10 @@ public class ReactExoplayerView extends FrameLayout implements public void onHostPause() { isInBackground = true; Activity activity = themedReactContext.getCurrentActivity(); - boolean isInPictureInPicture = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInPictureInPictureMode(); - boolean isInMultiWindowMode = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInMultiWindowMode(); + boolean isInPictureInPicture = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null + && activity.isInPictureInPictureMode(); + boolean isInMultiWindowMode = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null + && activity.isInMultiWindowMode(); if (playInBackground || isInPictureInPicture || isInMultiWindowMode) { return; } @@ -396,7 +401,7 @@ public class ReactExoplayerView extends FrameLayout implements viewHasDropped = true; } - //BandwidthMeter.EventListener implementation + // BandwidthMeter.EventListener implementation @Override public void onBandwidthSample(int elapsedMs, long bytes, long bitrate) { if (mReportBandwidth) { @@ -404,7 +409,8 @@ public class ReactExoplayerView extends FrameLayout implements eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, 0, 0, null); } else { Format videoFormat = player.getVideoFormat(); - boolean isRotatedContent = videoFormat != null && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270); + boolean isRotatedContent = videoFormat != null + && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270); int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0; int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0; String trackId = videoFormat != null ? videoFormat.id : null; @@ -419,7 +425,8 @@ public class ReactExoplayerView extends FrameLayout implements * Toggling the visibility of the player control view */ private void togglePlayerControlVisibility() { - if (player == null) return; + if (player == null) + return; if (exoPlayerView.isControllerVisible()) { exoPlayerView.hideController(); } else { @@ -429,7 +436,7 @@ public class ReactExoplayerView extends FrameLayout implements private void initializePlayerControl() { exoPlayerView.setPlayer(player); - + exoPlayerView.setControllerVisibilityListener(visibility -> { boolean isVisible = visibility == View.VISIBLE; eventEmitter.onControlsVisibilityChange.invoke(isVisible); @@ -443,26 +450,28 @@ public class ReactExoplayerView extends FrameLayout implements } private void updateControllerConfig() { - if (exoPlayerView == null) return; - + if (exoPlayerView == null) + return; + exoPlayerView.setControllerShowTimeoutMs(5000); - + exoPlayerView.setControllerAutoShow(true); exoPlayerView.setControllerHideOnTouch(true); - + updateControllerVisibility(); } private void updateControllerVisibility() { - if (exoPlayerView == null) return; - + if (exoPlayerView == null) + return; + exoPlayerView.setUseController(controls && !controlsConfig.getHideFullscreen()); } private void openSettings() { AlertDialog.Builder builder = new AlertDialog.Builder(themedReactContext); builder.setTitle(R.string.settings); - String[] settingsOptions = {themedReactContext.getString(R.string.playback_speed)}; + String[] settingsOptions = { themedReactContext.getString(R.string.playback_speed) }; builder.setItems(settingsOptions, (dialog, which) -> { if (which == 0) { showPlaybackSpeedOptions(); @@ -472,7 +481,7 @@ public class ReactExoplayerView extends FrameLayout implements } private void showPlaybackSpeedOptions() { - String[] speedOptions = {"0.5x", "1.0x", "1.5x", "2.0x"}; + String[] speedOptions = { "0.5x", "1.0x", "1.5x", "2.0x" }; AlertDialog.Builder builder = new AlertDialog.Builder(themedReactContext); builder.setTitle(R.string.select_playback_speed); @@ -490,8 +499,10 @@ public class ReactExoplayerView extends FrameLayout implements speed = 2.0f; break; default: - speed = 1.0f;; - }; + speed = 1.0f; + ; + } + ; setRateModifier(speed); }); builder.show(); @@ -503,24 +514,30 @@ public class ReactExoplayerView extends FrameLayout implements /** * Update the layout - * @param view view needs to update layout + * + * @param view view needs to update layout * - * This is a workaround for the open bug in react-native: ... + * This is a workaround for the open bug in react-native: ... */ private void reLayout(View view) { - if (view == null) return; + if (view == null) + return; view.measure(MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY)); view.layout(view.getLeft(), view.getTop(), view.getMeasuredWidth(), view.getMeasuredHeight()); } private void refreshControlsStyles() { - if (exoPlayerView == null || player == null || !controls) return; + if (exoPlayerView == null || player == null || !controls) + return; updateControllerVisibility(); } - // Note: The following methods for live content and button visibility are no longer needed - // as PlayerView handles controls automatically. Some functionality may need to be + // Note: The following methods for live content and button visibility are no + // longer needed + // as PlayerView handles controls automatically. Some functionality may need to + // be // reimplemented using PlayerView's APIs if custom behavior is required. private void reLayoutControls() { @@ -557,6 +574,7 @@ public class ReactExoplayerView extends FrameLayout implements private class RNVLoadControl extends DefaultLoadControl { private final int availableHeapInBytes; private final Runtime runtime; + public RNVLoadControl(DefaultAllocator allocator, BufferConfig config) { super(allocator, config.getMinBufferMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt() @@ -567,7 +585,7 @@ public class ReactExoplayerView extends FrameLayout implements : DefaultLoadControl.DEFAULT_MAX_BUFFER_MS, config.getBufferForPlaybackMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt() ? config.getBufferForPlaybackMs() - : DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS , + : DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, config.getBufferForPlaybackAfterRebufferMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt() ? config.getBufferForPlaybackAfterRebufferMs() : DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, @@ -578,10 +596,12 @@ public class ReactExoplayerView extends FrameLayout implements : DefaultLoadControl.DEFAULT_BACK_BUFFER_DURATION_MS, DefaultLoadControl.DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME); runtime = Runtime.getRuntime(); - ActivityManager activityManager = (ActivityManager) themedReactContext.getSystemService(ThemedReactContext.ACTIVITY_SERVICE); - double maxHeap = config.getMaxHeapAllocationPercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble() - ? config.getMaxHeapAllocationPercent() - : DEFAULT_MAX_HEAP_ALLOCATION_PERCENT; + ActivityManager activityManager = (ActivityManager) themedReactContext + .getSystemService(ThemedReactContext.ACTIVITY_SERVICE); + double maxHeap = config.getMaxHeapAllocationPercent() != BufferConfig.Companion + .getBufferConfigPropUnsetDouble() + ? config.getMaxHeapAllocationPercent() + : DEFAULT_MAX_HEAP_ALLOCATION_PERCENT; availableHeapInBytes = (int) Math.floor(activityManager.getMemoryClass() * maxHeap * 1024 * 1024); } @@ -599,13 +619,15 @@ public class ReactExoplayerView extends FrameLayout implements } long usedMemory = runtime.totalMemory() - runtime.freeMemory(); long freeMemory = runtime.maxMemory() - usedMemory; - double minBufferMemoryReservePercent = source.getBufferConfig().getMinBufferMemoryReservePercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble() - ? source.getBufferConfig().getMinBufferMemoryReservePercent() - : ReactExoplayerView.DEFAULT_MIN_BUFFER_MEMORY_RESERVE; + double minBufferMemoryReservePercent = source.getBufferConfig() + .getMinBufferMemoryReservePercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble() + ? source.getBufferConfig().getMinBufferMemoryReservePercent() + : ReactExoplayerView.DEFAULT_MIN_BUFFER_MEMORY_RESERVE; long reserveMemory = (long) minBufferMemoryReservePercent * runtime.maxMemory(); long bufferedMs = bufferedDurationUs / (long) 1000; if (reserveMemory > freeMemory && bufferedMs > 2000) { - // We don't have enough memory in reserve so we stop buffering to allow other components to use it instead + // We don't have enough memory in reserve so we stop buffering to allow other + // components to use it instead return false; } if (runtime.freeMemory() == 0) { @@ -639,13 +661,13 @@ public class ReactExoplayerView extends FrameLayout implements // Initialize core configuration and listeners initializePlayerCore(self); pipListenerUnsubscribe = PictureInPictureUtil.addLifecycleEventListener(themedReactContext, this); - PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave); + PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, + this.enterPictureInPictureOnLeave); } if (!source.isLocalAssetFile() && !source.isAsset() && source.getBufferConfig().getCacheSize() > 0) { RNVSimpleCache.INSTANCE.setSimpleCache( this.getContext(), - source.getBufferConfig().getCacheSize() - ); + source.getBufferConfig().getCacheSize()); useCache = true; } else { useCache = false; @@ -653,7 +675,8 @@ public class ReactExoplayerView extends FrameLayout implements if (playerNeedsSource) { // Will force display of shutter view if needed exoPlayerView.invalidateAspectRatio(); - // DRM session manager creation must be done on a different thread to prevent crashes so we start a new thread + // DRM session manager creation must be done on a different thread to prevent + // crashes so we start a new thread ExecutorService es = Executors.newSingleThreadExecutor(); es.execute(() -> { // DRM initialization must run on a different thread @@ -662,7 +685,8 @@ public class ReactExoplayerView extends FrameLayout implements } if (activity == null) { DebugLog.e(TAG, "Failed to initialize Player!, null activity"); - eventEmitter.onVideoError.invoke("Failed to initialize Player!", new Exception("Current Activity is null!"), "1001"); + eventEmitter.onVideoError.invoke("Failed to initialize Player!", + new Exception("Current Activity is null!"), "1001"); return; } @@ -715,8 +739,7 @@ public class ReactExoplayerView extends FrameLayout implements DefaultAllocator allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); RNVLoadControl loadControl = new RNVLoadControl( allocator, - source.getBufferConfig() - ); + source.getBufferConfig()); long initialBitrate = source.getBufferConfig().getInitialBitrate(); if (initialBitrate > 0) { @@ -724,15 +747,15 @@ public class ReactExoplayerView extends FrameLayout implements this.bandwidthMeter = config.getBandwidthMeter(); } - DefaultRenderersFactory renderersFactory = - new DefaultRenderersFactory(getContext()) - .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER) - .setEnableDecoderFallback(true) - .forceEnableMediaCodecAsynchronousQueueing(); + DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(getContext()) + .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER) + .setEnableDecoderFallback(true) + .forceEnableMediaCodecAsynchronousQueueing(); DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory); if (useCache && !disableCache) { - mediaSourceFactory.setDataSourceFactory(RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true))); + mediaSourceFactory + .setDataSourceFactory(RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true))); } mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView.getPlayerView()); @@ -759,7 +782,7 @@ public class ReactExoplayerView extends FrameLayout implements player.setPlaybackParameters(params); changeAudioOutput(this.audioOutput); - if(showNotificationControls) { + if (showNotificationControls) { setupPlaybackService(); } } @@ -771,8 +794,7 @@ public class ReactExoplayerView extends FrameLayout implements Uri adTagUrl = adProps.getAdTagUrl(); if (adTagUrl != null) { // Create an AdsLoader. - ImaAdsLoader.Builder imaLoaderBuilder = new ImaAdsLoader - .Builder(themedReactContext) + ImaAdsLoader.Builder imaLoaderBuilder = new ImaAdsLoader.Builder(themedReactContext) .setAdEventListener(this) .setAdErrorListener(this); @@ -804,7 +826,8 @@ public class ReactExoplayerView extends FrameLayout implements } try { - // First check if there's a custom DRM manager registered through the plugin system + // First check if there's a custom DRM manager registered through the plugin + // system DRMManagerSpec drmManager = ReactNativeVideoManager.Companion.getInstance().getDRMManager(); if (drmManager == null) { // If no custom manager is registered, use the default implementation @@ -813,11 +836,13 @@ public class ReactExoplayerView extends FrameLayout implements DrmSessionManager drmSessionManager = drmManager.buildDrmSessionManager(uuid, drmProps); if (drmSessionManager == null) { - eventEmitter.onVideoError.invoke("Failed to build DRM session manager", new Exception("DRM session manager is null"), "3007"); + eventEmitter.onVideoError.invoke("Failed to build DRM session manager", + new Exception("DRM session manager is null"), "3007"); } // Allow plugins to override the DrmSessionManager - DrmSessionManager overriddenManager = ReactNativeVideoManager.Companion.getInstance().overrideDrmSessionManager(source, drmSessionManager); + DrmSessionManager overriddenManager = ReactNativeVideoManager.Companion.getInstance() + .overrideDrmSessionManager(source, drmSessionManager); return overriddenManager != null ? overriddenManager : drmSessionManager; } catch (UnsupportedDrmException ex) { // Unsupported DRM exceptions are handled by the calling method @@ -835,7 +860,8 @@ public class ReactExoplayerView extends FrameLayout implements } /// init DRM DrmSessionManager drmSessionManager = initializePlayerDrm(); - if (drmSessionManager == null && runningSource.getDrmProps() != null && runningSource.getDrmProps().getDrmType() != null) { + if (drmSessionManager == null && runningSource.getDrmProps() != null + && runningSource.getDrmProps().getDrmType() != null) { // Failed to initialize DRM session manager - cannot continue DebugLog.e(TAG, "Failed to initialize DRM Session Manager Framework!"); return; @@ -892,7 +918,8 @@ public class ReactExoplayerView extends FrameLayout implements } catch (UnsupportedDrmException e) { int errorStringId = Util.SDK_INT < 18 ? R.string.error_drm_not_supported : (e.reason == UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME - ? R.string.error_drm_unsupported_scheme : R.string.error_drm_unknown); + ? R.string.error_drm_unsupported_scheme + : R.string.error_drm_unknown); eventEmitter.onVideoError.invoke(getResources().getString(errorStringId), e, "3003"); } } @@ -937,7 +964,8 @@ public class ReactExoplayerView extends FrameLayout implements if (playbackServiceBinder != null) { playbackServiceBinder.getService().unregisterPlayer(player); } - } catch (Exception ignored) {} + } catch (Exception ignored) { + } playbackServiceBinder = null; } @@ -969,21 +997,22 @@ public class ReactExoplayerView extends FrameLayout implements private void cleanupPlaybackService() { try { - if(player != null && playbackServiceBinder != null) { + if (player != null && playbackServiceBinder != null) { playbackServiceBinder.getService().unregisterPlayer(player); } playbackServiceBinder = null; - if(playbackServiceConnection != null) { + if (playbackServiceConnection != null) { themedReactContext.unbindService(playbackServiceConnection); } - } catch(Exception e) { + } catch (Exception e) { DebugLog.w(TAG, "Cloud not cleanup playback service"); } } - private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessionManager drmSessionManager, long cropStartMs, long cropEndMs) { + private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessionManager drmSessionManager, + long cropStartMs, long cropEndMs) { if (uri == null) { throw new IllegalStateException("Invalid video uri"); } @@ -1004,23 +1033,23 @@ public class ReactExoplayerView extends FrameLayout implements if (customMetadata != null) { mediaItemBuilder.setMediaMetadata(customMetadata); } - + // Add external subtitles to MediaItem List subtitleConfigurations = buildSubtitleConfigurations(); if (subtitleConfigurations != null) { mediaItemBuilder.setSubtitleConfigurations(subtitleConfigurations); } - + if (source.getAdsProps() != null) { Uri adTagUrl = source.getAdsProps().getAdTagUrl(); if (adTagUrl != null) { mediaItemBuilder.setAdsConfiguration( - new MediaItem.AdsConfiguration.Builder(adTagUrl).build() - ); + new MediaItem.AdsConfiguration.Builder(adTagUrl).build()); } } - MediaItem.LiveConfiguration.Builder liveConfiguration = ConfigurationUtils.getLiveConfiguration(source.getBufferConfig()); + MediaItem.LiveConfiguration.Builder liveConfiguration = ConfigurationUtils + .getLiveConfiguration(source.getBufferConfig()); mediaItemBuilder.setLiveConfiguration(liveConfiguration.build()); MediaSource.Factory mediaSourceFactory; @@ -1032,29 +1061,26 @@ public class ReactExoplayerView extends FrameLayout implements drmProvider = new DefaultDrmSessionManagerProvider(); } - switch (type) { case CONTENT_TYPE_SS: - if(!BuildConfig.USE_EXOPLAYER_SMOOTH_STREAMING) { + if (!BuildConfig.USE_EXOPLAYER_SMOOTH_STREAMING) { DebugLog.e("Exo Player Exception", "Smooth Streaming is not enabled!"); throw new IllegalStateException("Smooth Streaming is not enabled!"); } mediaSourceFactory = new SsMediaSource.Factory( new DefaultSsChunkSource.Factory(mediaDataSourceFactory), - buildDataSourceFactory(false) - ); + buildDataSourceFactory(false)); break; case CONTENT_TYPE_DASH: - if(!BuildConfig.USE_EXOPLAYER_DASH) { + if (!BuildConfig.USE_EXOPLAYER_DASH) { DebugLog.e("Exo Player Exception", "DASH is not enabled!"); throw new IllegalStateException("DASH is not enabled!"); } mediaSourceFactory = new DashMediaSource.Factory( new DefaultDashChunkSource.Factory(mediaDataSourceFactory), - buildDataSourceFactory(false) - ); + buildDataSourceFactory(false)); break; case CONTENT_TYPE_HLS: if (!BuildConfig.USE_EXOPLAYER_HLS) { @@ -1069,13 +1095,14 @@ public class ReactExoplayerView extends FrameLayout implements } mediaSourceFactory = new HlsMediaSource.Factory( - dataSourceFactory - ).setAllowChunklessPreparation(source.getTextTracksAllowChunklessPreparation()); + dataSourceFactory) + .setAllowChunklessPreparation(source.getTextTracksAllowChunklessPreparation()); break; case CONTENT_TYPE_OTHER: if ("asset".equals(uri.getScheme())) { try { - DataSource.Factory assetDataSourceFactory = DataSourceUtil.buildAssetDataSourceFactory(themedReactContext, uri); + DataSource.Factory assetDataSourceFactory = DataSourceUtil + .buildAssetDataSourceFactory(themedReactContext, uri); mediaSourceFactory = new ProgressiveMediaSource.Factory(assetDataSourceFactory); } catch (Exception e) { throw new IllegalStateException("cannot open input file:" + uri); @@ -1083,12 +1110,10 @@ public class ReactExoplayerView extends FrameLayout implements } else if ("file".equals(uri.getScheme()) || !useCache) { mediaSourceFactory = new ProgressiveMediaSource.Factory( - mediaDataSourceFactory - ); + mediaDataSourceFactory); } else { mediaSourceFactory = new ProgressiveMediaSource.Factory( - RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true)) - ); + RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true))); } break; @@ -1107,20 +1132,19 @@ public class ReactExoplayerView extends FrameLayout implements if (cmcdConfigurationFactory != null) { mediaSourceFactory = mediaSourceFactory.setCmcdConfigurationFactory( - cmcdConfigurationFactory::createCmcdConfiguration - ); + cmcdConfigurationFactory::createCmcdConfiguration); } mediaSourceFactory = Objects.requireNonNullElse( ReactNativeVideoManager.Companion.getInstance() .overrideMediaSourceFactory(source, mediaSourceFactory, mediaDataSourceFactory), - mediaSourceFactory - ); + mediaSourceFactory); mediaItemBuilder.setStreamKeys(streamKeys); @Nullable - final MediaItem.Builder overridenMediaItemBuilder = ReactNativeVideoManager.Companion.getInstance().overrideMediaItemBuilder(source, mediaItemBuilder); + final MediaItem.Builder overridenMediaItemBuilder = ReactNativeVideoManager.Companion.getInstance() + .overrideMediaItemBuilder(source, mediaItemBuilder); MediaItem mediaItem = overridenMediaItemBuilder != null ? overridenMediaItemBuilder.build() @@ -1129,8 +1153,7 @@ public class ReactExoplayerView extends FrameLayout implements MediaSource mediaSource = mediaSourceFactory .setDrmSessionManagerProvider(drmProvider) .setLoadErrorHandlingPolicy( - config.buildLoadErrorHandlingPolicy(source.getMinLoadRetryCount()) - ) + config.buildLoadErrorHandlingPolicy(source.getMinLoadRetryCount())) .createMediaSource(mediaItem); if (cropStartMs >= 0 && cropEndMs >= 0) { @@ -1164,32 +1187,36 @@ public class ReactExoplayerView extends FrameLayout implements label += " (" + track.getLanguage() + ")"; } } - - MediaItem.SubtitleConfiguration.Builder configBuilder = new MediaItem.SubtitleConfiguration.Builder(track.getUri()) + + MediaItem.SubtitleConfiguration.Builder configBuilder = new MediaItem.SubtitleConfiguration.Builder( + track.getUri()) .setId(trackId) .setMimeType(track.getType()) .setLabel(label) .setRoleFlags(C.ROLE_FLAG_SUBTITLE); - + // Set language if available if (track.getLanguage() != null && !track.getLanguage().isEmpty()) { configBuilder.setLanguage(track.getLanguage()); } - - // Set selection flags - make first track default if no specific track is selected + + // Set selection flags - make first track default if no specific track is + // selected if (trackIndex == 0 && (textTrackType == null || "disabled".equals(textTrackType))) { configBuilder.setSelectionFlags(C.SELECTION_FLAG_DEFAULT); } else { configBuilder.setSelectionFlags(0); } - + MediaItem.SubtitleConfiguration subtitleConfiguration = configBuilder.build(); subtitleConfigurations.add(subtitleConfiguration); - - DebugLog.d(TAG, "Created subtitle configuration: " + trackId + " - " + label + " (" + track.getType() + ")"); + + DebugLog.d(TAG, + "Created subtitle configuration: " + trackId + " - " + label + " (" + track.getType() + ")"); trackIndex++; } catch (Exception e) { - DebugLog.e(TAG, "Error creating SubtitleConfiguration for URI " + track.getUri() + ": " + e.getMessage()); + DebugLog.e(TAG, + "Error creating SubtitleConfiguration for URI " + track.getUri() + ": " + e.getMessage()); } } @@ -1202,7 +1229,7 @@ public class ReactExoplayerView extends FrameLayout implements private void releasePlayer() { if (player != null) { - if(playbackServiceBinder != null) { + if (playbackServiceBinder != null) { playbackServiceBinder.getService().unregisterPlayer(player); themedReactContext.unbindService(playbackServiceConnection); } @@ -1252,7 +1279,8 @@ public class ReactExoplayerView extends FrameLayout implements case AudioManager.AUDIOFOCUS_LOSS: view.hasAudioFocus = false; view.eventEmitter.onAudioFocusChanged.invoke(false); - // FIXME this pause can cause issue if content doesn't have pause capability (can happen on live channel) + // FIXME this pause can cause issue if content doesn't have pause capability + // (can happen on live channel) if (activity != null) { activity.runOnUiThread(view::pausePlayback); } @@ -1273,16 +1301,12 @@ public class ReactExoplayerView extends FrameLayout implements if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { // Lower the volume if (!view.muted) { - activity.runOnUiThread(() -> - view.player.setVolume(view.audioVolume * 0.8f) - ); + activity.runOnUiThread(() -> view.player.setVolume(view.audioVolume * 0.8f)); } } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { // Raise it back to normal if (!view.muted) { - activity.runOnUiThread(() -> - view.player.setVolume(view.audioVolume * 1) - ); + activity.runOnUiThread(() -> view.player.setVolume(view.audioVolume * 1)); } } } @@ -1355,7 +1379,8 @@ public class ReactExoplayerView extends FrameLayout implements /** * Returns a new DataSource factory. * - * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener to the new + * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener + * to the new * DataSource factory. * @return A new DataSource factory. */ @@ -1367,12 +1392,14 @@ public class ReactExoplayerView extends FrameLayout implements /** * Returns a new HttpDataSource factory. * - * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener to the new - * DataSource factory. + * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener + * to the new + * DataSource factory. * @return A new HttpDataSource factory. */ private HttpDataSource.Factory buildHttpDataSourceFactory(boolean useBandwidthMeter) { - return DataSourceUtil.getDefaultHttpDataSourceFactory(this.themedReactContext, useBandwidthMeter ? bandwidthMeter : null, source.getHeaders()); + return DataSourceUtil.getDefaultHttpDataSourceFactory(this.themedReactContext, + useBandwidthMeter ? bandwidthMeter : null, source.getHeaders()); } // AudioBecomingNoisyListener implementation @@ -1389,11 +1416,13 @@ public class ReactExoplayerView extends FrameLayout implements @Override public void onEvents(@NonNull Player player, Player.Events events) { - if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED) || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) { + if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED) + || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) { int playbackState = player.getPlaybackState(); boolean playWhenReady = player.getPlayWhenReady(); String text = "onStateChanged: playWhenReady=" + playWhenReady + ", playbackState="; - eventEmitter.onPlaybackRateChange.invoke(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f); + eventEmitter.onPlaybackRateChange + .invoke(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f); switch (playbackState) { case Player.STATE_IDLE: text += "idle"; @@ -1411,6 +1440,7 @@ public class ReactExoplayerView extends FrameLayout implements break; case Player.STATE_READY: text += "ready"; + hasVideoEnded = false; eventEmitter.onReadyForDisplay.invoke(); onBuffering(false); clearProgressMessageHandler(); // ensure there is no other message @@ -1429,7 +1459,10 @@ public class ReactExoplayerView extends FrameLayout implements case Player.STATE_ENDED: text += "ended"; updateProgress(); - eventEmitter.onVideoEnd.invoke(); + if (!hasVideoEnded) { + hasVideoEnded = true; + eventEmitter.onVideoEnd.invoke(); + } onStopPlayback(); setKeepScreenOn(false); break; @@ -1446,9 +1479,11 @@ public class ReactExoplayerView extends FrameLayout implements } /** - * The progress message handler will duplicate recursions of the onProgressMessage handler - * on change of player state from any state to STATE_READY with playWhenReady is true (when - * the video is not paused). This clears all existing messages. + * The progress message handler will duplicate recursions of the + * onProgressMessage handler + * on change of player state from any state to STATE_READY with playWhenReady is + * true (when + * the video is not paused). This clears all existing messages. */ private void clearProgressMessageHandler() { progressHandler.removeMessages(SHOW_PROGRESS); @@ -1467,7 +1502,8 @@ public class ReactExoplayerView extends FrameLayout implements setSelectedTextTrack(textTrackType, textTrackValue); } Format videoFormat = player.getVideoFormat(); - boolean isRotatedContent = videoFormat != null && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270); + boolean isRotatedContent = videoFormat != null + && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270); int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0; int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0; String trackId = videoFormat != null ? videoFormat.id : null; @@ -1476,19 +1512,20 @@ public class ReactExoplayerView extends FrameLayout implements long duration = player.getDuration(); long currentPosition = player.getCurrentPosition(); ArrayList audioTracks = getAudioTrackInfo(); - ArrayList textTracks = getTextTrackInfo(); + ArrayList textTracks = getTextTrackInfo(); if (source.getContentStartTime() != -1) { ExecutorService es = Executors.newSingleThreadExecutor(); es.execute(() -> { - // To prevent ANRs caused by getVideoTrackInfo we run this on a different thread and notify the player only when we're done + // To prevent ANRs caused by getVideoTrackInfo we run this on a different thread + // and notify the player only when we're done ArrayList videoTracks = getVideoTrackInfoFromManifest(); if (videoTracks != null) { isUsingContentResolution = true; } eventEmitter.onVideoLoad.invoke(duration, currentPosition, width, height, - audioTracks, textTracks, videoTracks, trackId ); - + audioTracks, textTracks, videoTracks, trackId); + updateSubtitleButtonVisibility(); }); return; @@ -1505,9 +1542,9 @@ public class ReactExoplayerView extends FrameLayout implements } private static boolean isTrackSelected(TrackSelection selection, TrackGroup group, - int trackIndex){ + int trackIndex) { return selection != null && selection.getTrackGroup() == group - && selection.indexOf( trackIndex ) != C.INDEX_UNSET; + && selection.indexOf(trackIndex) != C.INDEX_UNSET; } private ArrayList getAudioTrackInfo() { @@ -1525,23 +1562,22 @@ public class ReactExoplayerView extends FrameLayout implements TrackSelectionArray selectionArray = player.getCurrentTrackSelections(); TrackSelection selection = selectionArray.get(C.TRACK_TYPE_AUDIO); - for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) { TrackGroup group = groups.get(groupIndex); Format format = group.getFormat(0); - + // Check if this specific group is the currently selected one boolean isSelected = false; if (selection != null && selection.getTrackGroup() == group) { isSelected = true; } - + Track audioTrack = exoplayerTrackToGenericTrack(format, groupIndex, selection, group); audioTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); audioTrack.setSelected(isSelected); audioTracks.add(audioTrack); } - + return audioTracks; } @@ -1551,7 +1587,8 @@ public class ReactExoplayerView extends FrameLayout implements videoTrack.setHeight(format.height == Format.NO_VALUE ? 0 : format.height); videoTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); videoTrack.setRotation(format.rotationDegrees); - if (format.codecs != null) videoTrack.setCodecs(format.codecs); + if (format.codecs != null) + videoTrack.setCodecs(format.codecs); videoTrack.setTrackId(format.id == null ? String.valueOf(trackIndex) : format.id); videoTrack.setIndex(trackIndex); return videoTrack; @@ -1588,7 +1625,8 @@ public class ReactExoplayerView extends FrameLayout implements return this.getVideoTrackInfoFromManifest(0); } - // We need retry count to in case where minefest request fails from poor network conditions + // We need retry count to in case where minefest request fails from poor network + // conditions @WorkerThread private ArrayList getVideoTrackInfoFromManifest(int retryCount) { ExecutorService es = Executors.newSingleThreadExecutor(); @@ -1603,18 +1641,20 @@ public class ReactExoplayerView extends FrameLayout implements public ArrayList call() { ArrayList videoTracks = new ArrayList<>(); - try { + try { DashManifest manifest = DashUtil.loadManifest(this.ds, this.uri); int periodCount = manifest.getPeriodCount(); for (int i = 0; i < periodCount; i++) { Period period = manifest.getPeriod(i); - for (int adaptationIndex = 0; adaptationIndex < period.adaptationSets.size(); adaptationIndex++) { + for (int adaptationIndex = 0; adaptationIndex < period.adaptationSets + .size(); adaptationIndex++) { AdaptationSet adaptation = period.adaptationSets.get(adaptationIndex); if (adaptation.type != C.TRACK_TYPE_VIDEO) { continue; } boolean hasFoundContentPeriod = false; - for (int representationIndex = 0; representationIndex < adaptation.representations.size(); representationIndex++) { + for (int representationIndex = 0; representationIndex < adaptation.representations + .size(); representationIndex++) { Representation representation = adaptation.representations.get(representationIndex); Format format = representation.format; if (isFormatSupported(format)) { @@ -1622,7 +1662,8 @@ public class ReactExoplayerView extends FrameLayout implements break; } hasFoundContentPeriod = true; - VideoTrack videoTrack = exoplayerVideoTrackToGenericVideoTrack(format, representationIndex); + VideoTrack videoTrack = exoplayerVideoTrackToGenericVideoTrack(format, + representationIndex); videoTracks.add(videoTrack); } } @@ -1652,12 +1693,16 @@ public class ReactExoplayerView extends FrameLayout implements return null; } - private Track exoplayerTrackToGenericTrack(Format format, int trackIndex, TrackSelection selection, TrackGroup group) { + private Track exoplayerTrackToGenericTrack(Format format, int trackIndex, TrackSelection selection, + TrackGroup group) { Track track = new Track(); track.setIndex(trackIndex); - if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType); - if (format.language != null) track.setLanguage(format.language); - if (format.label != null) track.setTitle(format.label); + if (format.sampleMimeType != null) + track.setMimeType(format.sampleMimeType); + if (format.language != null) + track.setLanguage(format.language); + if (format.label != null) + track.setTitle(format.label); track.setSelected(isTrackSelected(selection, group, trackIndex)); return track; } @@ -1667,13 +1712,13 @@ public class ReactExoplayerView extends FrameLayout implements if (trackSelector == null) { return textTracks; } - + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); int index = getTrackRendererIndex(C.TRACK_TYPE_TEXT); if (info == null || index == C.INDEX_UNSET) { return textTracks; } - + TrackSelectionArray selectionArray = player.getCurrentTrackSelections(); TrackSelection selection = selectionArray.get(C.TRACK_TYPE_TEXT); TrackGroupArray groups = info.getTrackGroups(index); @@ -1683,12 +1728,12 @@ public class ReactExoplayerView extends FrameLayout implements for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { Format format = group.getFormat(trackIndex); Track textTrack = exoplayerTrackToGenericTrack(format, trackIndex, selection, group); - + boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-"); boolean isSelected = isTrackSelected(selection, group, trackIndex); - + textTrack.setIndex(textTracks.size()); - + if (textTrack.getTitle() == null || textTrack.getTitle().isEmpty()) { if (isExternal) { textTrack.setTitle("External " + (trackIndex + 1)); @@ -1696,7 +1741,7 @@ public class ReactExoplayerView extends FrameLayout implements textTrack.setTitle("Track " + (textTracks.size() + 1)); } } - + textTracks.add(textTrack); } } @@ -1716,23 +1761,24 @@ public class ReactExoplayerView extends FrameLayout implements } TrackGroupArray groups = info.getTrackGroups(index); - + for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) { TrackGroup group = groups.get(groupIndex); Format format = group.getFormat(0); - + // Create track without trying to determine selection status Track track = new Track(); track.setIndex(groupIndex); track.setLanguage(format.language != null ? format.language : "unknown"); track.setTitle(format.label != null ? format.label : "Track " + (groupIndex + 1)); track.setSelected(false); // Don't report selection status - let PlayerView handle it - if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType); + if (format.sampleMimeType != null) + track.setMimeType(format.sampleMimeType); track.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); - + tracks.add(track); } - + DebugLog.d(TAG, "getBasicAudioTrackInfo: returning " + tracks.size() + " audio tracks (no selection status)"); return tracks; } @@ -1742,27 +1788,29 @@ public class ReactExoplayerView extends FrameLayout implements if (trackSelector == null) { return textTracks; } - + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); int index = getTrackRendererIndex(C.TRACK_TYPE_TEXT); if (info == null || index == C.INDEX_UNSET) { return textTracks; } - + TrackGroupArray groups = info.getTrackGroups(index); for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) { TrackGroup group = groups.get(groupIndex); for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { Format format = group.getFormat(trackIndex); - + Track textTrack = new Track(); textTrack.setIndex(textTracks.size()); - if (format.sampleMimeType != null) textTrack.setMimeType(format.sampleMimeType); - if (format.language != null) textTrack.setLanguage(format.language); - + if (format.sampleMimeType != null) + textTrack.setMimeType(format.sampleMimeType); + if (format.language != null) + textTrack.setLanguage(format.language); + boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-"); - + if (format.label != null && !format.label.isEmpty()) { textTrack.setTitle(format.label); } else if (isExternal) { @@ -1770,7 +1818,7 @@ public class ReactExoplayerView extends FrameLayout implements } else { textTrack.setTitle("Track " + (textTracks.size() + 1)); } - + textTrack.setSelected(false); // Don't report selection status - let PlayerView handle it textTracks.add(textTrack); } @@ -1793,33 +1841,42 @@ public class ReactExoplayerView extends FrameLayout implements } @Override - public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, @Player.DiscontinuityReason int reason) { + public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, + @NonNull Player.PositionInfo newPosition, @Player.DiscontinuityReason int reason) { if (reason == Player.DISCONTINUITY_REASON_SEEK) { isSeeking = true; seekPosition = newPosition.positionMs; if (isUsingContentResolution) { - // We need to update the selected track to make sure that it still matches user selection if track list has changed in this period + // We need to update the selected track to make sure that it still matches user + // selection if track list has changed in this period setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); } } if (playerNeedsSource) { - // This will only occur if the user has performed a seek whilst in the error state. Update the - // resume position so that if the user then retries, playback will resume from the position to + // This will only occur if the user has performed a seek whilst in the error + // state. Update the + // resume position so that if the user then retries, playback will resume from + // the position to // which they seeked. updateResumePosition(); } if (isUsingContentResolution) { - // Discontinuity events might have a different track list so we update the selected track + // Discontinuity events might have a different track list so we update the + // selected track setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); selectTrackWhenReady = true; } - // When repeat is turned on, reaching the end of the video will not cause a state change + // When repeat is turned on, reaching the end of the video will not cause a + // state change // so we need to explicitly detect it. if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION && player.getRepeatMode() == Player.REPEAT_MODE_ONE) { updateProgress(); - eventEmitter.onVideoEnd.invoke(); + if (!hasVideoEnded) { + hasVideoEnded = true; + eventEmitter.onVideoEnd.invoke(); + } } } @@ -1831,12 +1888,12 @@ public class ReactExoplayerView extends FrameLayout implements @Override public void onTracksChanged(@NonNull Tracks tracks) { DebugLog.d(TAG, "onTracksChanged called - updating track information, controls=" + controls); - + if (controls) { ArrayList textTracks = getBasicTextTrackInfo(); - ArrayList audioTracks = getBasicAudioTrackInfo(); + ArrayList audioTracks = getBasicAudioTrackInfo(); ArrayList videoTracks = getVideoTrackInfo(); - + eventEmitter.onTextTracks.invoke(textTracks); eventEmitter.onAudioTracks.invoke(audioTracks); eventEmitter.onVideoTracks.invoke(videoTracks); @@ -1844,7 +1901,7 @@ public class ReactExoplayerView extends FrameLayout implements ArrayList textTracks = getTextTrackInfo(); ArrayList audioTracks = getAudioTrackInfo(); ArrayList videoTracks = getVideoTrackInfo(); - + eventEmitter.onTextTracks.invoke(textTracks); eventEmitter.onAudioTracks.invoke(audioTracks); eventEmitter.onVideoTracks.invoke(videoTracks); @@ -1855,22 +1912,24 @@ public class ReactExoplayerView extends FrameLayout implements } } } - + updateSubtitleButtonVisibility(); } - - + private boolean hasBuiltInTextTracks() { - if (player == null || trackSelector == null) return false; - + if (player == null || trackSelector == null) + return false; + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); - if (info == null) return false; - + if (info == null) + return false; + int textRendererIndex = getTrackRendererIndex(C.TRACK_TYPE_TEXT); - if (textRendererIndex == C.INDEX_UNSET) return false; - + if (textRendererIndex == C.INDEX_UNSET) + return false; + TrackGroupArray groups = info.getTrackGroups(textRendererIndex); - + // Check if any groups have tracks that are NOT external subtitles for (int i = 0; i < groups.length; i++) { TrackGroup group = groups.get(i); @@ -1882,17 +1941,18 @@ public class ReactExoplayerView extends FrameLayout implements } } } - + return false; } private void updateSubtitleButtonVisibility() { - if (exoPlayerView == null) return; - - boolean hasTextTracks = (source.getSideLoadedTextTracks() != null && - !source.getSideLoadedTextTracks().getTracks().isEmpty()) || - hasBuiltInTextTracks(); - + if (exoPlayerView == null) + return; + + boolean hasTextTracks = (source.getSideLoadedTextTracks() != null && + !source.getSideLoadedTextTracks().getTracks().isEmpty()) || + hasBuiltInTextTracks(); + exoPlayerView.setShowSubtitleButton(hasTextTracks); } @@ -1911,7 +1971,8 @@ public class ReactExoplayerView extends FrameLayout implements if (isPlaying && isSeeking) { eventEmitter.onVideoSeek.invoke(player.getCurrentPosition(), seekPosition); } - PictureInPictureUtil.applyPlayingStatus(themedReactContext, pictureInPictureParamsBuilder, pictureInPictureReceiver, !isPlaying); + PictureInPictureUtil.applyPlayingStatus(themedReactContext, pictureInPictureParamsBuilder, + pictureInPictureReceiver, !isPlaying); eventEmitter.onVideoPlaybackStateChanged.invoke(isPlaying, isSeeking); if (isPlaying) { @@ -1923,14 +1984,15 @@ public class ReactExoplayerView extends FrameLayout implements public void onPlayerError(@NonNull PlaybackException e) { String errorString = "ExoPlaybackException: " + PlaybackException.getErrorCodeName(e.errorCode); String errorCode = "2" + e.errorCode; - switch(e.errorCode) { + switch (e.errorCode) { case PlaybackException.ERROR_CODE_DRM_DEVICE_REVOKED: case PlaybackException.ERROR_CODE_DRM_LICENSE_ACQUISITION_FAILED: case PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED: case PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR: case PlaybackException.ERROR_CODE_DRM_UNSPECIFIED: if (!hasDrmFailed) { - // When DRM fails to reach the app level certificate server it will fail with a source error so we assume that it is DRM related and try one more time + // When DRM fails to reach the app level certificate server it will fail with a + // source error so we assume that it is DRM related and try one more time hasDrmFailed = true; playerNeedsSource = true; updateResumePosition(); @@ -2012,14 +2074,16 @@ public class ReactExoplayerView extends FrameLayout implements boolean isSourceEqual = source.isEquals(this.source); hasDrmFailed = false; this.source = source; - final DataSource.Factory tmpMediaDataSourceFactory = - DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter, - source.getHeaders()); + final DataSource.Factory tmpMediaDataSourceFactory = DataSourceUtil.getDefaultDataSourceFactory( + this.themedReactContext, bandwidthMeter, + source.getHeaders()); @Nullable - final DataSource.Factory overriddenMediaDataSourceFactory = ReactNativeVideoManager.Companion.getInstance().overrideMediaDataSourceFactory(source, tmpMediaDataSourceFactory); + final DataSource.Factory overriddenMediaDataSourceFactory = ReactNativeVideoManager.Companion.getInstance() + .overrideMediaDataSourceFactory(source, tmpMediaDataSourceFactory); - this.mediaDataSourceFactory = Objects.requireNonNullElse(overriddenMediaDataSourceFactory, tmpMediaDataSourceFactory); + this.mediaDataSourceFactory = Objects.requireNonNullElse(overriddenMediaDataSourceFactory, + tmpMediaDataSourceFactory); if (source.getCmcdProps() != null) { CMCDConfig cmcdConfig = new CMCDConfig(source.getCmcdProps()); @@ -2030,6 +2094,7 @@ public class ReactExoplayerView extends FrameLayout implements } if (!isSourceEqual) { + hasVideoEnded = false; playerNeedsSource = true; initializePlayer(); } @@ -2037,6 +2102,7 @@ public class ReactExoplayerView extends FrameLayout implements clearSrc(); } } + public void clearSrc() { if (source.getUri() != null) { if (player != null) { @@ -2085,8 +2151,9 @@ public class ReactExoplayerView extends FrameLayout implements } public void disableTrack(int rendererIndex) { - if (trackSelector == null) return; - + if (trackSelector == null) + return; + DefaultTrackSelector.Parameters disableParameters = trackSelector.getParameters() .buildUpon() .setRendererDisabled(rendererIndex, true) @@ -2095,31 +2162,32 @@ public class ReactExoplayerView extends FrameLayout implements } private void selectTextTrackInternal(String type, String value) { - if (player == null || trackSelector == null) return; - + if (player == null || trackSelector == null) + return; + DebugLog.d(TAG, "selectTextTrackInternal: type=" + type + ", value=" + value); - + DefaultTrackSelector.Parameters.Builder parametersBuilder = trackSelector.getParameters().buildUpon(); - + if ("disabled".equals(type) || value == null) { parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true); } else { parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false); - + parametersBuilder.clearOverridesOfType(C.TRACK_TYPE_TEXT); - + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); if (info != null) { int textRendererIndex = getTrackRendererIndex(C.TRACK_TYPE_TEXT); if (textRendererIndex != C.INDEX_UNSET) { TrackGroupArray groups = info.getTrackGroups(textRendererIndex); boolean trackFound = false; - + for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { TrackGroup group = groups.get(groupIndex); for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { Format format = group.getFormat(trackIndex); - + boolean isMatch = false; if ("language".equals(type) && format.language != null && format.language.equals(value)) { isMatch = true; @@ -2131,29 +2199,30 @@ public class ReactExoplayerView extends FrameLayout implements isMatch = true; } } - + if (isMatch) { - TrackSelectionOverride override = new TrackSelectionOverride(group, - java.util.Arrays.asList(trackIndex)); + TrackSelectionOverride override = new TrackSelectionOverride(group, + java.util.Arrays.asList(trackIndex)); parametersBuilder.addOverride(override); trackFound = true; break; } } - if (trackFound) break; + if (trackFound) + break; } - + if (!trackFound) { - DebugLog.w(TAG, "Text track not found for type=" + type + ", value=" + value + - ". Keeping current selection."); + DebugLog.w(TAG, "Text track not found for type=" + type + ", value=" + value + + ". Keeping current selection."); } } } } - + try { trackSelector.setParameters(parametersBuilder.build()); - + // Give PlayerView time to update its controls mainHandler.postDelayed(() -> { if (exoPlayerView != null) { @@ -2166,17 +2235,18 @@ public class ReactExoplayerView extends FrameLayout implements } public void setSelectedTrack(int trackType, String type, String value) { - if (player == null || trackSelector == null) return; - + if (player == null || trackSelector == null) + return; + if (controls) { return; } - + int rendererIndex = getTrackRendererIndex(trackType); if (rendererIndex == C.INDEX_UNSET) { return; } - + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); if (info == null) { return; @@ -2240,9 +2310,11 @@ public class ReactExoplayerView extends FrameLayout implements usingExactMatch = true; break; } else if (isUsingContentResolution) { - // When using content resolution rather than ads, we need to try and find the closest match if there is no exact match + // When using content resolution rather than ads, we need to try and find the + // closest match if there is no exact match if (closestFormat != null) { - if ((format.bitrate > closestFormat.bitrate || format.height > closestFormat.height) && format.height < height) { + if ((format.bitrate > closestFormat.bitrate || format.height > closestFormat.height) + && format.height < height) { // Higher quality match closestFormat = format; closestTrackIndex = j; @@ -2253,7 +2325,8 @@ public class ReactExoplayerView extends FrameLayout implements } } } - // This is a fallback if the new period contains only higher resolutions than the user has selected + // This is a fallback if the new period contains only higher resolutions than + // the user has selected if (closestFormat == null && isUsingContentResolution && !usingExactMatch) { // No close match found - so we pick the lowest quality int minHeight = Integer.MAX_VALUE; @@ -2276,8 +2349,8 @@ public class ReactExoplayerView extends FrameLayout implements } } else if (trackType == C.TRACK_TYPE_TEXT && Util.SDK_INT > 18) { // Text default // Use system settings if possible - CaptioningManager captioningManager - = (CaptioningManager)themedReactContext.getSystemService(Context.CAPTIONING_SERVICE); + CaptioningManager captioningManager = (CaptioningManager) themedReactContext + .getSystemService(Context.CAPTIONING_SERVICE); if (captioningManager != null && captioningManager.isEnabled()) { groupIndex = getGroupIndexForDefaultLocale(groups); } @@ -2306,7 +2379,7 @@ public class ReactExoplayerView extends FrameLayout implements // With only one tracks we can't remove any tracks so attempt to play it anyway tracks = allTracks; } else { - tracks = new ArrayList<>(supportedFormatLength + 1); + tracks = new ArrayList<>(supportedFormatLength + 1); for (int k = 0; k < allTracks.size(); k++) { Format format = group.getFormat(k); if (isFormatSupported(format)) { @@ -2331,8 +2404,9 @@ public class ReactExoplayerView extends FrameLayout implements .setExceedVideoConstraintsIfNecessary(true) .setRendererDisabled(rendererIndex, false); - // Clear existing overrides for this track type to avoid conflicts - // But be careful with audio tracks - don't clear unless explicitly selecting a different track + // Clear existing overrides for this track type to avoid conflicts + // But be careful with audio tracks - don't clear unless explicitly selecting a + // different track if (trackType != C.TRACK_TYPE_AUDIO || !type.equals("default")) { selectionParameters.clearOverridesOfType(selectionOverride.getType()); } @@ -2347,8 +2421,8 @@ public class ReactExoplayerView extends FrameLayout implements if (trackType == C.TRACK_TYPE_AUDIO) { selectionParameters.setForceHighestSupportedBitrate(false); selectionParameters.setForceLowestBitrate(false); - DebugLog.d(TAG, "Audio track selection: group=" + groupIndex + ", tracks=" + tracks + - ", override=" + selectionOverride); + DebugLog.d(TAG, "Audio track selection: group=" + groupIndex + ", tracks=" + tracks + + ", override=" + selectionOverride); } trackSelector.setParameters(selectionParameters.build()); @@ -2379,7 +2453,7 @@ public class ReactExoplayerView extends FrameLayout implements } private int getGroupIndexForDefaultLocale(TrackGroupArray groups) { - if (groups.length == 0){ + if (groups.length == 0) { return C.INDEX_UNSET; } @@ -2400,13 +2474,14 @@ public class ReactExoplayerView extends FrameLayout implements public void setSelectedVideoTrack(String type, String value) { videoTrackType = type; videoTrackValue = value; - if (!loadVideoStarted) setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); + if (!loadVideoStarted) + setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); } public void setSelectedAudioTrack(String type, String value) { audioTrackType = type; audioTrackValue = value; - + if (!controls && player != null && trackSelector != null) { setSelectedTrack(C.TRACK_TYPE_AUDIO, audioTrackType, audioTrackValue); } @@ -2415,7 +2490,7 @@ public class ReactExoplayerView extends FrameLayout implements public void setSelectedTextTrack(String type, String value) { textTrackType = type; textTrackValue = value; - + selectTextTrackInternal(type, value); } @@ -2431,9 +2506,11 @@ public class ReactExoplayerView extends FrameLayout implements } public void setEnterPictureInPictureOnLeave(boolean enterPictureInPictureOnLeave) { - this.enterPictureInPictureOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && enterPictureInPictureOnLeave; + this.enterPictureInPictureOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + && enterPictureInPictureOnLeave; if (player != null) { - PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave); + PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, + this.enterPictureInPictureOnLeave); } } @@ -2441,12 +2518,14 @@ public class ReactExoplayerView extends FrameLayout implements eventEmitter.onPictureInPictureStatusChanged.invoke(isInPictureInPicture); if (fullScreenPlayerView != null && fullScreenPlayerView.isShowing()) { - if (isInPictureInPicture) fullScreenPlayerView.hideWithoutPlayer(); + if (isInPictureInPicture) + fullScreenPlayerView.hideWithoutPlayer(); return; } Activity currentActivity = themedReactContext.getCurrentActivity(); - if (currentActivity == null) return; + if (currentActivity == null) + return; View decorView = currentActivity.getWindow().getDecorView(); ViewGroup rootView = decorView.findViewById(android.R.id.content); @@ -2456,7 +2535,7 @@ public class ReactExoplayerView extends FrameLayout implements LayoutParams.MATCH_PARENT); if (isInPictureInPicture) { - ViewGroup parent = (ViewGroup)exoPlayerView.getParent(); + ViewGroup parent = (ViewGroup) exoPlayerView.getParent(); if (parent != null) { parent.removeView(exoPlayerView); } @@ -2482,10 +2561,12 @@ public class ReactExoplayerView extends FrameLayout implements public void enterPictureInPictureMode() { PictureInPictureParams _pipParams = null; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - ArrayList actions = PictureInPictureUtil.getPictureInPictureActions(themedReactContext, isPaused, pictureInPictureReceiver); + ArrayList actions = PictureInPictureUtil.getPictureInPictureActions(themedReactContext, + isPaused, pictureInPictureReceiver); pictureInPictureParamsBuilder.setActions(actions); if (player.getPlaybackState() == Player.STATE_READY) { - pictureInPictureParamsBuilder.setAspectRatio(PictureInPictureUtil.calcPictureInPictureAspectRatio(player)); + pictureInPictureParamsBuilder + .setAspectRatio(PictureInPictureUtil.calcPictureInPictureAspectRatio(player)); } _pipParams = pictureInPictureParamsBuilder.build(); } @@ -2494,13 +2575,15 @@ public class ReactExoplayerView extends FrameLayout implements public void exitPictureInPictureMode() { Activity currentActivity = themedReactContext.getCurrentActivity(); - if (currentActivity == null) return; + if (currentActivity == null) + return; View decorView = currentActivity.getWindow().getDecorView(); ViewGroup rootView = decorView.findViewById(android.R.id.content); if (!rootViewChildrenOriginalVisibility.isEmpty()) { - if (exoPlayerView.getParent().equals(rootView)) rootView.removeView(exoPlayerView); + if (exoPlayerView.getParent().equals(rootView)) + rootView.removeView(exoPlayerView); for (int i = 0; i < rootView.getChildCount(); i++) { rootView.getChildAt(i).setVisibility(rootViewChildrenOriginalVisibility.get(i)); } @@ -2598,7 +2681,7 @@ public class ReactExoplayerView extends FrameLayout implements if (playbackServiceConnection == null && showNotificationControls) { setupPlaybackService(); - } else if(!showNotificationControls && playbackServiceConnection != null) { + } else if (!showNotificationControls && playbackServiceConnection != null) { cleanupPlaybackService(); } } @@ -2627,12 +2710,13 @@ public class ReactExoplayerView extends FrameLayout implements } if (isFullscreen) { - fullScreenPlayerView = new FullScreenPlayerView(getContext(), exoPlayerView, this, null, new OnBackPressedCallback(true) { - @Override - public void handleOnBackPressed() { - setFullscreen(false); - } - }, controlsConfig); + fullScreenPlayerView = new FullScreenPlayerView(getContext(), exoPlayerView, this, null, + new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + setFullscreen(false); + } + }, controlsConfig); eventEmitter.onVideoFullscreenPlayerWillPresent.invoke(); if (fullScreenPlayerView != null) { fullScreenPlayerView.show(); @@ -2669,7 +2753,8 @@ public class ReactExoplayerView extends FrameLayout implements } @Override - public void onDrmSessionManagerError(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, @NonNull Exception e) { + public void onDrmSessionManagerError(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, + @NonNull Exception e) { DebugLog.d("DRM Info", "onDrmSessionManagerError"); eventEmitter.onVideoError.invoke("onDrmSessionManagerError", e, "3002"); } @@ -2687,7 +2772,7 @@ public class ReactExoplayerView extends FrameLayout implements /** * Handling controls prop * - * @param controls Controls prop, if true enable controls, if false disable them + * @param controls Controls prop, if true enable controls, if false disable them */ public void setControls(boolean controls) { this.controls = controls; @@ -2696,7 +2781,7 @@ public class ReactExoplayerView extends FrameLayout implements // Additional configuration for proper touch handling if (controls) { exoPlayerView.setControllerAutoShow(true); - exoPlayerView.setControllerHideOnTouch(true); // Show controls on touch, don't hide + exoPlayerView.setControllerHideOnTouch(true); // Show controls on touch, don't hide exoPlayerView.setControllerShowTimeoutMs(5000); } } @@ -2729,8 +2814,7 @@ public class ReactExoplayerView extends FrameLayout implements Map errMap = Map.of( "message", error.getMessage(), "code", String.valueOf(error.getErrorCode()), - "type", String.valueOf(error.getErrorType()) - ); + "type", String.valueOf(error.getErrorType())); eventEmitter.onReceiveAdEvent.invoke("ERROR", errMap); } diff --git a/nuvio-source.json b/nuvio-source.json index 49bda03b..23d19eec 100644 --- a/nuvio-source.json +++ b/nuvio-source.json @@ -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": [] -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 665e72e6..9f09ce79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -1630,9 +1629,9 @@ } }, "node_modules/@bottom-tabs/react-navigation": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bottom-tabs/react-navigation/-/react-navigation-1.0.2.tgz", - "integrity": "sha512-OrCw8s2NzFxO1TO5W2vyr7HNvh1Yjy00f72D/0BIPtImc0aj5CRrT9nFRE7YP0FWZb0AY5+0QU9jaoph1rBlSg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@bottom-tabs/react-navigation/-/react-navigation-1.1.0.tgz", + "integrity": "sha512-+4YppCodABcSNIgJiq95QUQ+3ClVBG+rLG3WmYI0+/nbxqKbCz6luFBep4KFOj98Iplj1JY2Ki6ix8CcOZVQ/Q==", "license": "MIT", "dependencies": { "color": "^5.0.0" @@ -1692,27 +1691,26 @@ } }, "node_modules/@expo/cli": { - "version": "54.0.16", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.16.tgz", - "integrity": "sha512-hY/OdRaJMs5WsVPuVSZ+RLH3VObJmL/pv5CGCHEZHN2PxZjSZSdctyKV8UcFBXTF0yIKNAJ9XLs1dlNYXHh4Cw==", + "version": "54.0.19", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.19.tgz", + "integrity": "sha512-Za+Ena29uYkq2c1Lbh+r3VrooR/mW7c9dahoH4WvL1T9ttbfAeu7sJmCuWZo88bZ4bFsOpE5fYne71DK11iSrQ==", "license": "MIT", "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.5", - "@expo/config": "~12.0.10", - "@expo/config-plugins": "~54.0.2", - "@expo/devcert": "^1.1.2", - "@expo/env": "~2.0.7", - "@expo/image-utils": "^0.8.7", - "@expo/json-file": "^10.0.7", - "@expo/mcp-tunnel": "~0.1.0", + "@expo/config": "~12.0.12", + "@expo/config-plugins": "~54.0.3", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.0.8", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", "@expo/metro": "~54.1.0", - "@expo/metro-config": "~54.0.9", - "@expo/osascript": "^2.3.7", - "@expo/package-manager": "^1.9.8", - "@expo/plist": "^0.4.7", - "@expo/prebuild-config": "^54.0.6", - "@expo/schema-utils": "^0.1.7", + "@expo/metro-config": "~54.0.11", + "@expo/osascript": "^2.3.8", + "@expo/package-manager": "^1.9.9", + "@expo/plist": "^0.4.8", + "@expo/prebuild-config": "^54.0.7", + "@expo/schema-utils": "^0.1.8", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", @@ -1730,10 +1728,10 @@ "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", - "expo-server": "^1.0.4", + "expo-server": "^1.0.5", "freeport-async": "^2.0.0", "getenv": "^2.0.0", - "glob": "^10.4.2", + "glob": "^13.0.0", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", @@ -1756,7 +1754,7 @@ "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", - "tar": "^7.4.3", + "tar": "^7.5.2", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", @@ -1802,40 +1800,40 @@ } }, "node_modules/@expo/config": { - "version": "12.0.10", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.10.tgz", - "integrity": "sha512-lJMof5Nqakq1DxGYlghYB/ogSBjmv4Fxn1ovyDmcjlRsQdFCXgu06gEUogkhPtc9wBt9WlTTfqENln5HHyLW6w==", + "version": "12.0.12", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.12.tgz", + "integrity": "sha512-X2MW86+ulLpMGvdgfvpl2EOBAKUlwvnvoPwdaZeeyWufGopn1nTUeh4C9gMsplDaP1kXv9sLXVhOoUoX6g9PvQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~54.0.2", - "@expo/config-types": "^54.0.8", - "@expo/json-file": "^10.0.7", + "@expo/config-plugins": "~54.0.3", + "@expo/config-types": "^54.0.10", + "@expo/json-file": "^10.0.8", "deepmerge": "^4.3.1", "getenv": "^2.0.0", - "glob": "^10.4.2", + "glob": "^13.0.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", - "sucrase": "3.35.0" + "sucrase": "~3.35.1" } }, "node_modules/@expo/config-plugins": { - "version": "54.0.2", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.2.tgz", - "integrity": "sha512-jD4qxFcURQUVsUFGMcbo63a/AnviK8WUGard+yrdQE3ZrB/aurn68SlApjirQQLEizhjI5Ar2ufqflOBlNpyPg==", + "version": "54.0.4", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", + "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", "license": "MIT", "dependencies": { - "@expo/config-types": "^54.0.8", - "@expo/json-file": "~10.0.7", - "@expo/plist": "^0.4.7", + "@expo/config-types": "^54.0.10", + "@expo/json-file": "~10.0.8", + "@expo/plist": "^0.4.8", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", - "glob": "^10.4.2", + "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", @@ -1857,9 +1855,9 @@ } }, "node_modules/@expo/config-types": { - "version": "54.0.8", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.8.tgz", - "integrity": "sha512-lyIn/x/Yz0SgHL7IGWtgTLg6TJWC9vL7489++0hzCHZ4iGjVcfZmPTUfiragZ3HycFFj899qN0jlhl49IHa94A==", + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", + "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", "license": "MIT" }, "node_modules/@expo/config/node_modules/@babel/code-frame": { @@ -1884,14 +1882,13 @@ } }, "node_modules/@expo/devcert": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.0.tgz", - "integrity": "sha512-Uilcv3xGELD5t/b0eM4cxBFEKQRIivB3v7i+VhWLV/gL98aw810unLKKJbGAxAIhY6Ipyz8ChWibFsKFXYwstA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", "license": "MIT", "dependencies": { "@expo/sudo-prompt": "^9.3.1", - "debug": "^3.1.0", - "glob": "^10.4.2" + "debug": "^3.1.0" } }, "node_modules/@expo/devcert/node_modules/debug": { @@ -1904,9 +1901,9 @@ } }, "node_modules/@expo/devtools": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.7.tgz", - "integrity": "sha512-dfIa9qMyXN+0RfU6SN4rKeXZyzKWsnz6xBSDccjL4IRiE+fQ0t84zg0yxgN4t/WK2JU5v6v4fby7W7Crv9gJvA==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.8.tgz", + "integrity": "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ==", "license": "MIT", "dependencies": { "chalk": "^4.1.2" @@ -1925,9 +1922,9 @@ } }, "node_modules/@expo/env": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.7.tgz", - "integrity": "sha512-BNETbLEohk3HQ2LxwwezpG8pq+h7Fs7/vAMP3eAtFT1BCpprLYoBBFZH7gW4aqGfqOcVP4Lc91j014verrYNGg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.8.tgz", + "integrity": "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA==", "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -1938,9 +1935,9 @@ } }, "node_modules/@expo/fingerprint": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.3.tgz", - "integrity": "sha512-8YPJpEYlmV171fi+t+cSLMX1nC5ngY9j2FiN70dHldLpd6Ct6ouGhk96svJ4BQZwsqwII2pokwzrDAwqo4Z0FQ==", + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.4.tgz", + "integrity": "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -1948,7 +1945,7 @@ "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", - "glob": "^10.4.2", + "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^9.0.0", "p-limit": "^3.1.0", @@ -1972,9 +1969,9 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.8.7", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.7.tgz", - "integrity": "sha512-SXOww4Wq3RVXLyOaXiCCuQFguCDh8mmaHBv54h/R29wGl4jRY8GEyQEx8SypV/iHt1FbzsU/X3Qbcd9afm2W2w==", + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.8.tgz", + "integrity": "sha512-HHHaG4J4nKjTtVa1GG9PCh763xlETScfEyNxxOvfTRr8IKPJckjTyqSLEtdJoFNJ1vqiABEjW7tqGhqGibZLeA==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -2002,9 +1999,9 @@ } }, "node_modules/@expo/json-file": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.7.tgz", - "integrity": "sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw==", + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", + "integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "~7.10.4", @@ -2020,25 +2017,6 @@ "@babel/highlight": "^7.10.4" } }, - "node_modules/@expo/mcp-tunnel": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@expo/mcp-tunnel/-/mcp-tunnel-0.1.0.tgz", - "integrity": "sha512-rJ6hl0GnIZj9+ssaJvFsC7fwyrmndcGz+RGFzu+0gnlm78X01957yjtHgjcmnQAgL5hWEOR6pkT0ijY5nU5AWw==", - "license": "MIT", - "dependencies": { - "ws": "^8.18.3", - "zod": "^3.25.76", - "zod-to-json-schema": "^3.24.6" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.13.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, "node_modules/@expo/metro": { "version": "54.1.0", "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.1.0.tgz", @@ -2060,17 +2038,17 @@ } }, "node_modules/@expo/metro-config": { - "version": "54.0.9", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.9.tgz", - "integrity": "sha512-CRI4WgFXrQ2Owyr8q0liEBJveUIF9DcYAKadMRsJV7NxGNBdrIIKzKvqreDfsGiRqivbLsw6UoNb3UE7/SvPfg==", + "version": "54.0.11", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.11.tgz", + "integrity": "sha512-Bmht6VW9w6Wk49EFqkMzYpICV++Q3Kuqh2KygjH/e5mj/9wHSCWLkmJYmUn0XaOo4bm6BwOp/hO3r5YNKP3AeQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", - "@expo/config": "~12.0.10", - "@expo/env": "~2.0.7", - "@expo/json-file": "~10.0.7", + "@expo/config": "~12.0.12", + "@expo/env": "~2.0.8", + "@expo/json-file": "~10.0.8", "@expo/metro": "~54.1.0", "@expo/spawn-async": "^1.7.2", "browserslist": "^4.25.0", @@ -2079,7 +2057,7 @@ "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", - "glob": "^10.4.2", + "glob": "^13.0.0", "hermes-parser": "^0.29.1", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", @@ -2120,9 +2098,9 @@ } }, "node_modules/@expo/osascript": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.3.7.tgz", - "integrity": "sha512-IClSOXxR0YUFxIriUJVqyYki7lLMIHrrzOaP01yxAL1G8pj2DWV5eW1y5jSzIcIfSCNhtGsshGd1tU/AYup5iQ==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.3.8.tgz", + "integrity": "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -2133,12 +2111,12 @@ } }, "node_modules/@expo/package-manager": { - "version": "1.9.8", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.8.tgz", - "integrity": "sha512-4/I6OWquKXYnzo38pkISHCOCOXxfeEmu4uDoERq1Ei/9Ur/s9y3kLbAamEkitUkDC7gHk1INxRWEfFNzGbmOrA==", + "version": "1.9.9", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.9.tgz", + "integrity": "sha512-Nv5THOwXzPprMJwbnXU01iXSrCp3vJqly9M4EJ2GkKko9Ifer2ucpg7x6OUsE09/lw+npaoUnHMXwkw7gcKxlg==", "license": "MIT", "dependencies": { - "@expo/json-file": "^10.0.7", + "@expo/json-file": "^10.0.8", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", @@ -2147,9 +2125,9 @@ } }, "node_modules/@expo/plist": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.7.tgz", - "integrity": "sha512-dGxqHPvCZKeRKDU1sJZMmuyVtcASuSYh1LPFVaM1DuffqPL36n6FMEL0iUqq2Tx3xhWk8wCnWl34IKplUjJDdA==", + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz", + "integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==", "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.8.8", @@ -2158,16 +2136,16 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "54.0.6", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.6.tgz", - "integrity": "sha512-xowuMmyPNy+WTNq+YX0m0EFO/Knc68swjThk4dKivgZa8zI1UjvFXOBIOp8RX4ljCXLzwxQJM5oBBTvyn+59ZA==", + "version": "54.0.7", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.7.tgz", + "integrity": "sha512-cKqBsiwcFFzpDWgtvemrCqJULJRLDLKo2QMF74NusoGNpfPI3vQVry1iwnYLeGht02AeD3dvfhpqBczD3wchxA==", "license": "MIT", "dependencies": { - "@expo/config": "~12.0.10", - "@expo/config-plugins": "~54.0.2", - "@expo/config-types": "^54.0.8", - "@expo/image-utils": "^0.8.7", - "@expo/json-file": "^10.0.7", + "@expo/config": "~12.0.11", + "@expo/config-plugins": "~54.0.3", + "@expo/config-types": "^54.0.9", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.1", "resolve-from": "^5.0.0", @@ -2191,9 +2169,9 @@ } }, "node_modules/@expo/schema-utils": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.7.tgz", - "integrity": "sha512-jWHoSuwRb5ZczjahrychMJ3GWZu54jK9ulNdh1d4OzAEq672K9E5yOlnlBsfIHWHGzUAT+0CL7Yt1INiXTz68g==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", + "integrity": "sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==", "license": "MIT" }, "node_modules/@expo/sdk-runtime-versions": { @@ -2262,9 +2240,9 @@ } }, "node_modules/@gorhom/bottom-sheet": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.6.tgz", - "integrity": "sha512-vmruJxdiUGDg+ZYcDmS30XDhq/h/+QkINOI5LY/uGjx8cPGwgJW0H6AB902gNTKtccbiKe/rr94EwdmIEz+LAQ==", + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz", + "integrity": "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA==", "license": "MIT", "dependencies": { "@gorhom/portal": "1.0.14", @@ -2327,52 +2305,6 @@ "node": "20 || >=22" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -2794,9 +2726,9 @@ } }, "node_modules/@legendapp/list": { - "version": "2.0.15", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-2.0.15.tgz", - "integrity": "sha512-t39c6TGWOzV8Ec7SxyKKT15+FQTyBgjvbKFnRzT2CUQP8o43Zets6qt1gVULImE7VXTTqNhRe+3FzHmF3EVB5g==", + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-2.0.18.tgz", + "integrity": "sha512-Uo51s+9u+QvQCathLFEckb+OK2eXECuhHo+e+Gn+GlSR4V8tClvCSHOOdagsT/Dsto05jC9Yt5onhqxjLENn7A==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" @@ -2807,37 +2739,27 @@ } }, "node_modules/@lottiefiles/dotlottie-react": { - "version": "0.17.7", - "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.17.7.tgz", - "integrity": "sha512-A6wO3zqkDx/t0ULfctcr1Bmb1f1hc4zUV3NcbKQOsBGAOIx1vABV/fRabFYElvbJl9lmOR24yMh//Z0fvvJV+Q==", + "version": "0.17.10", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.17.10.tgz", + "integrity": "sha512-ikrN05/q0/KjqIU+n48uNwmE7DeZIC9y3Nd19httcKqe273zoOeNYycEaQzLSdcpEGnWLmHaZpgtoo07aQZAXg==", "license": "MIT", "dependencies": { - "@lottiefiles/dotlottie-web": "0.56.0" + "@lottiefiles/dotlottie-web": "0.58.1" }, "peerDependencies": { "react": "^17 || ^18 || ^19" } }, "node_modules/@lottiefiles/dotlottie-web": { - "version": "0.56.0", - "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.56.0.tgz", - "integrity": "sha512-bWHRIGzjZs3Hjkz0JRsCMX2ya9a1tGU4atdrlfM3UoN0iamsDE64kSCMfGuchCwGAxg0xEh84CkF+SVV1NU9ow==", + "version": "0.58.1", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.58.1.tgz", + "integrity": "sha512-YC4pmScrV0R3rd11gU5xHrjeNczlCic69zlnMH/buDIzYxIbpR88oPUhGtKgu5ln7EJchoLpeRJbA3uLCzSeTA==", "license": "MIT" }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@posthog/core": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.5.2.tgz", - "integrity": "sha512-iedUP3EnOPPxTA2VaIrsrd29lSZnUV+ZrMnvY56timRVeZAXoYCkmjfIs3KBAsF8OUT5h1GXLSkoQdrV0r31OQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.7.1.tgz", + "integrity": "sha512-kjK0eFMIpKo9GXIbts8VtAknsoZ18oZorANdtuTj1CbgS28t4ZVq//HAWhnxEuXRTrtkd+SUJ6Ux3j2Af8NCuA==", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.6" @@ -3187,17 +3109,17 @@ } }, "node_modules/@react-navigation/bottom-tabs": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.8.5.tgz", - "integrity": "sha512-Zm9UOTfEtBLL7Wm+JBc0v/lh72cen9a8WVN5KSCEN7EtiQIPXbQUZg1ktEzme600HhxvaNZzzSz0X+w2E5nG5w==", + "version": "7.8.12", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.8.12.tgz", + "integrity": "sha512-efVt5ydHK+b4ZtjmN81iduaO5dPCmzhLBFwjCR8pV4x4VzUfJmtUJizLqTXpT3WatHdeon2gDPwhhoelsvu/JA==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.8.2", + "@react-navigation/elements": "^2.9.2", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { - "@react-navigation/native": "^7.1.20", + "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -3228,12 +3150,12 @@ } }, "node_modules/@react-navigation/core": { - "version": "7.13.1", - "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.13.1.tgz", - "integrity": "sha512-aPf1vjQhMytPC9CmJu28hT5eTaBJuqIf9T6IRICtap5HHgFLrsYizLZrg3D0H2AoPyOoijMPWzwf7VCBzfGvrg==", + "version": "7.13.6", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.13.6.tgz", + "integrity": "sha512-7QG29HAWOR8wYuPkfTN8L2Po+kE1xn3nsi2sS35sGngq8HYZRHfXvxrhrAZYfFnFq2hUtOhcXnSS6vEWU/5rmA==", "license": "MIT", "dependencies": { - "@react-navigation/routers": "^7.5.1", + "@react-navigation/routers": "^7.5.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", @@ -3247,9 +3169,9 @@ } }, "node_modules/@react-navigation/elements": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.8.2.tgz", - "integrity": "sha512-K5NWIMar81oAoRAgLwrWcLpXzY2K5yG3gNU/56uyC12u+i5SyIVAv+ygP36UXvrNLzDigg8OdRSdEBb8ePqQtA==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.2.tgz", + "integrity": "sha512-J1GltOAGowNLznEphV/kr4zs0U7mUBO1wVA2CqpkN8ePBsoxrAmsd+T5sEYUCXN9KgTDFvc6IfcDqrGSQngd/g==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -3258,7 +3180,7 @@ }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.1.20", + "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" @@ -3293,12 +3215,12 @@ } }, "node_modules/@react-navigation/native": { - "version": "7.1.20", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.20.tgz", - "integrity": "sha512-15luFq+35M2IOMHgbTJ0XDkPY7gm3YlR3yQKTuOTOHs+EeAUX71DlUuqcWMRqB0tt+OT6HimDQR7OboTB0N30g==", + "version": "7.1.25", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.25.tgz", + "integrity": "sha512-zQeWK9txDePWbYfqTs0C6jeRdJTm/7VhQtW/1IbJNDi9/rFIRzZule8bdQPAnf8QWUsNujRmi1J9OG/hhfbalg==", "license": "MIT", "dependencies": { - "@react-navigation/core": "^7.13.1", + "@react-navigation/core": "^7.13.6", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", @@ -3310,18 +3232,18 @@ } }, "node_modules/@react-navigation/native-stack": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.6.3.tgz", - "integrity": "sha512-F0f0+3K1mVWiQEZbyZen0LAl7Gc4qpbWM4Tpva5aCqBAECZyn7/uLbVhSXtC/EwzMqQ+ojPLtceFQhXhJqfqfg==", + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.8.6.tgz", + "integrity": "sha512-eBY92xb4H53c9jiWriKMOZmQ/Tu9w1qcUrgOA/qjQOvJFbgKF9D6y3e4UuBaDQzjWjLEDZLaiwXe8cwXRb46mg==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.8.2", + "@react-navigation/elements": "^2.9.2", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { - "@react-navigation/native": "^7.1.20", + "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -3352,26 +3274,26 @@ } }, "node_modules/@react-navigation/routers": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.1.tgz", - "integrity": "sha512-pxipMW/iEBSUrjxz2cDD7fNwkqR4xoi0E/PcfTQGCcdJwLoaxzab5kSadBLj1MTJyT0YRrOXL9umHpXtp+Dv4w==", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.2.tgz", + "integrity": "sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw==", "license": "MIT", "dependencies": { "nanoid": "^3.3.11" } }, "node_modules/@react-navigation/stack": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.6.4.tgz", - "integrity": "sha512-KweDIIFcSyG8x2ylyC1V+u6T5GpykPra9WoOOH7Ijoumvxuda6UETOoJPX5h/DUZKM5ve7mIfv7oXpbH9Ik/Jg==", + "version": "7.6.12", + "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.6.12.tgz", + "integrity": "sha512-hq5d+lWUwBnwPcBNyUYHiirzRuiD5YhQDIgZWzRConfcRwI/qwFW5+5bCCJ3fQZnNlP05UA4ZlI6r1ysU6y6ww==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.8.2", + "@react-navigation/elements": "^2.9.2", "color": "^4.2.3", "use-latest-callback": "^0.2.4" }, "peerDependencies": { - "@react-navigation/native": "^7.1.20", + "@react-navigation/native": "^7.1.25", "react": ">= 18.2.0", "react-native": "*", "react-native-gesture-handler": ">= 2.0.0", @@ -3403,84 +3325,84 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "10.24.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.24.0.tgz", - "integrity": "sha512-2nLj5TgPc/KkGy7LCW9sBGJT0CT+9U+Vlqa8yl7APd5agzxrpRyTcm4hPBBOw5tw7D4NWWUMulFxtZKZzT/Rcw==", + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.26.0.tgz", + "integrity": "sha512-rPg1+JZlfp912pZONQAWZzbSaZ9L6R2VrMcCEa+2e2Gqk9um4b+LqF5RQWZsbt5Z0n0azSy/KQ6zAe/zTPXSOg==", "license": "MIT", "dependencies": { - "@sentry/core": "10.24.0" + "@sentry/core": "10.26.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/feedback": { - "version": "10.24.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.24.0.tgz", - "integrity": "sha512-leYFQfgax50sYTEgkcEzPP8lTvtE12nryJSsdtPNym6gmQgA2SN20oSRNlxo1AitNpwNnTkj+ow/Y9ytrJlXUQ==", + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.26.0.tgz", + "integrity": "sha512-0vk9eQP0CXD7Y2WkcCIWHaAqnXOAi18/GupgWLnbB2kuQVYVtStWxtW+OWRe8W/XwSnZ5m6JBTVeokuk/O16DQ==", "license": "MIT", "dependencies": { - "@sentry/core": "10.24.0" + "@sentry/core": "10.26.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay": { - "version": "10.24.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.24.0.tgz", - "integrity": "sha512-xqSw3sCu5yxDQFpo/42t1zzxe+6kn542DRwHNBqIBd0CWN7un/j5YIW1Sq/+TdHYGbeG8LzD5UOuvZsT4zF2nQ==", + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.26.0.tgz", + "integrity": "sha512-FMySQnY2/p0dVtFUBgUO+aMdK2ovqnd7Q/AkvMQUsN/5ulyj6KZx3JX3CqOqRtAr1izoCe4Kh2pi5t//sQmvsg==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.24.0", - "@sentry/core": "10.24.0" + "@sentry-internal/browser-utils": "10.26.0", + "@sentry/core": "10.26.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "10.24.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.24.0.tgz", - "integrity": "sha512-pjNZ+/L/ct0huutkTQrcR+V/v3ICf5wKE8OOB2Dt3DcjNsbLKtUsy9Um6bCbSz/fRI8K/ZFlVLjiIQkMW+WX0Q==", + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.26.0.tgz", + "integrity": "sha512-vs7d/P+8M1L1JVAhhJx2wo15QDhqAipnEQvuRZ6PV7LUcS1un9/Vx49FMxpIkx6JcKADJVwtXrS1sX2hoNT/kw==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "10.24.0", - "@sentry/core": "10.24.0" + "@sentry-internal/replay": "10.26.0", + "@sentry/core": "10.26.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/babel-plugin-component-annotate": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.6.0.tgz", - "integrity": "sha512-3soTX50JPQQ51FSbb4qvNBf4z/yP7jTdn43vMTp9E4IxvJ9HKJR7OEuKkCMszrZmWsVABXl02msqO7QisePdiQ==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.6.1.tgz", + "integrity": "sha512-aSIk0vgBqv7PhX6/Eov+vlI4puCE0bRXzUG5HdCsHBpAfeMkI8Hva6kSOusnzKqs8bf04hU7s3Sf0XxGTj/1AA==", "license": "MIT", "engines": { "node": ">= 14" } }, "node_modules/@sentry/browser": { - "version": "10.24.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.24.0.tgz", - "integrity": "sha512-kKSNYupPIIk02+4OVR13qpJ8/uzsf6SrCzgxr6EvdK8qEuGYLJyM6lLJze/C5BZTSsam6UGAfahrSI1K5il8oQ==", + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.26.0.tgz", + "integrity": "sha512-uvV4hnkt8bh8yP0disJ0fszy8FdnkyGtzyIVKdeQZbNUefwbDhd3H0KJrAHhJ5ocULMH3B+dipdPmw2QXbEflg==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.24.0", - "@sentry-internal/feedback": "10.24.0", - "@sentry-internal/replay": "10.24.0", - "@sentry-internal/replay-canvas": "10.24.0", - "@sentry/core": "10.24.0" + "@sentry-internal/browser-utils": "10.26.0", + "@sentry-internal/feedback": "10.26.0", + "@sentry-internal/replay": "10.26.0", + "@sentry-internal/replay-canvas": "10.26.0", + "@sentry/core": "10.26.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/cli": { - "version": "2.58.0", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.0.tgz", - "integrity": "sha512-ywfV2uYkNaW5BGFBgIEX+urkxWtY03GYKN08OLYJpfJeOWl5tzxAKKg+AkMZqnqsDqjCf8gLjZh7sF4jY+ZE1Q==", + "version": "2.58.2", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.2.tgz", + "integrity": "sha512-U4u62V4vaTWF+o40Mih8aOpQKqKUbZQt9A3LorIJwaE3tO3XFLRI70eWtW2se1Qmy0RZ74zB14nYcFNFl2t4Rw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -3497,20 +3419,20 @@ "node": ">= 10" }, "optionalDependencies": { - "@sentry/cli-darwin": "2.58.0", - "@sentry/cli-linux-arm": "2.58.0", - "@sentry/cli-linux-arm64": "2.58.0", - "@sentry/cli-linux-i686": "2.58.0", - "@sentry/cli-linux-x64": "2.58.0", - "@sentry/cli-win32-arm64": "2.58.0", - "@sentry/cli-win32-i686": "2.58.0", - "@sentry/cli-win32-x64": "2.58.0" + "@sentry/cli-darwin": "2.58.2", + "@sentry/cli-linux-arm": "2.58.2", + "@sentry/cli-linux-arm64": "2.58.2", + "@sentry/cli-linux-i686": "2.58.2", + "@sentry/cli-linux-x64": "2.58.2", + "@sentry/cli-win32-arm64": "2.58.2", + "@sentry/cli-win32-i686": "2.58.2", + "@sentry/cli-win32-x64": "2.58.2" } }, "node_modules/@sentry/cli-darwin": { - "version": "2.58.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.0.tgz", - "integrity": "sha512-dI8+85N2xNsQeJZBbfGkjFScYH0xP/8+TDgoA5YiWWxsD/qSlWv1pf2VCR83smMyfcjIkDiPYIxBDticD67skQ==", + "version": "2.58.2", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.2.tgz", + "integrity": "sha512-MArsb3zLhA2/cbd4rTm09SmTpnEuZCoZOpuZYkrpDw1qzBVJmRFA1W1hGAQ9puzBIk/ubY3EUhhzuU3zN2uD6w==", "license": "BSD-3-Clause", "optional": true, "os": [ @@ -3521,9 +3443,9 @@ } }, "node_modules/@sentry/cli-linux-arm": { - "version": "2.58.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.0.tgz", - "integrity": "sha512-QxBWSQkm2OL8d0XXTUOcX5RYZzZGkMw48ubU4g/c4rlT06PuJV56Z03jsMQdJWUDzKmVYoJdvFV/whxYIkwmWw==", + "version": "2.58.2", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.2.tgz", + "integrity": "sha512-HU9lTCzcHqCz/7Mt5n+cv+nFuJdc1hGD2h35Uo92GgxX3/IujNvOUfF+nMX9j6BXH6hUt73R5c0Ycq9+a3Parg==", "cpu": [ "arm" ], @@ -3539,9 +3461,9 @@ } }, "node_modules/@sentry/cli-linux-arm64": { - "version": "2.58.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.0.tgz", - "integrity": "sha512-Fso5GImxQOigZqLHAHhz85w71zxS1bvL52PI/tcjadmKrIaJdD3ANukC0UcKyKuj9xhr/k1ufNR7V+2BD16kmg==", + "version": "2.58.2", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.2.tgz", + "integrity": "sha512-ay3OeObnbbPrt45cjeUyQjsx5ain1laj1tRszWj37NkKu55NZSp4QCg1gGBZ0gBGhckI9nInEsmKtix00alw2g==", "cpu": [ "arm64" ], @@ -3557,9 +3479,9 @@ } }, "node_modules/@sentry/cli-linux-i686": { - "version": "2.58.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.0.tgz", - "integrity": "sha512-Av+T5YwuTtbOpe/Fyr/lsbl5XIZTFspHCiAt4Kgtllme6T1ASIDhQDXDh/OVJ8So4pHkToTn3iH8mm8vLqBqOA==", + "version": "2.58.2", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.2.tgz", + "integrity": "sha512-CN9p0nfDFsAT1tTGBbzOUGkIllwS3hygOUyTK7LIm9z+UHw5uNgNVqdM/3Vg+02ymjkjISNB3/+mqEM5osGXdA==", "cpu": [ "x86", "ia32" @@ -3576,9 +3498,9 @@ } }, "node_modules/@sentry/cli-linux-x64": { - "version": "2.58.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.0.tgz", - "integrity": "sha512-AxK0eqZbHn0NGWsAE8bzt/iRMMUlqsx77kru/TIBQy9cMMJaq+rLb63W7HWXln4ER32nPZYx+JuhHD9UNiAFHA==", + "version": "2.58.2", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.2.tgz", + "integrity": "sha512-oX/LLfvWaJO50oBVOn4ZvG2SDWPq0MN8SV9eg5tt2nviq+Ryltfr7Rtoo+HfV+eyOlx1/ZXhq9Wm7OT3cQuz+A==", "cpu": [ "x64" ], @@ -3594,9 +3516,9 @@ } }, "node_modules/@sentry/cli-win32-arm64": { - "version": "2.58.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.0.tgz", - "integrity": "sha512-lIRTfGjD1TQIOuFh4rJGWt3zXyeXAlfoYYQbzG/rP6gXstiGENQtfEXZyKT+wlIGSqtbBGVfL8xp65ryjbXSgQ==", + "version": "2.58.2", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.2.tgz", + "integrity": "sha512-+cl3x2HPVMpoSVGVM1IDWlAEREZrrVQj4xBb0TRKII7g3hUxRsAIcsrr7+tSkie++0FuH4go/b5fGAv51OEF3w==", "cpu": [ "arm64" ], @@ -3610,9 +3532,9 @@ } }, "node_modules/@sentry/cli-win32-i686": { - "version": "2.58.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.0.tgz", - "integrity": "sha512-7VdB3QZ/3t2FABgIwRP2SoJcDmZaPPPZofVmJem+FgeONeLOUvHQw9WSLG4y5Dfc9yi5wO31H1ClW4uxv8EtuA==", + "version": "2.58.2", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.2.tgz", + "integrity": "sha512-omFVr0FhzJ8oTJSg1Kf+gjLgzpYklY0XPfLxZ5iiMiYUKwF5uo1RJRdkUOiEAv0IqpUKnmKcmVCLaDxsWclB7Q==", "cpu": [ "x86", "ia32" @@ -3627,9 +3549,9 @@ } }, "node_modules/@sentry/cli-win32-x64": { - "version": "2.58.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.0.tgz", - "integrity": "sha512-uItx4P4v9cKbgVbOpuShvIV8g42qLmZorPHwg3pYUu78c85xAWrmiXL+0JKNUf5JVBEHeHB+rIu08AZfDMhxig==", + "version": "2.58.2", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.2.tgz", + "integrity": "sha512-2NAFs9UxVbRztQbgJSP5i8TB9eJQ7xraciwj/93djrSMHSEbJ0vC47TME0iifgvhlHMs5vqETOKJtfbbpQAQFA==", "cpu": [ "x64" ], @@ -3643,22 +3565,22 @@ } }, "node_modules/@sentry/core": { - "version": "10.24.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.24.0.tgz", - "integrity": "sha512-apJ1NtCK/Kt5uTytee+4rhhcTm4u3+z0bESH8GNMXMcuJ/A3Rvy3HUh+gqCx4BTOR0Sa44dbMvJcm/ewO+mzVg==", + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.26.0.tgz", + "integrity": "sha512-TjDe5QI37SLuV0q3nMOH8JcPZhv2e85FALaQMIhRILH9Ce6G7xW5GSjmH91NUVq8yc3XtiqYlz/EenEZActc4Q==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@sentry/react": { - "version": "10.24.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.24.0.tgz", - "integrity": "sha512-HW83v7LC5E06H/cYtU4fnlOV5fykNl5QkrOoZzKrYfAUCh4T11gjd4RvlvI+WaXb6nhD+gW2YLu95iIRHid/TA==", + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.26.0.tgz", + "integrity": "sha512-Qi0/FVXAalwQNr8zp0tocViH3+MRelW8ePqj3TdMzapkbXRuh07czdGgw8Zgobqcb7l4rRCRAUo2sl/H3KVkIw==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.24.0", - "@sentry/core": "10.24.0", + "@sentry/browser": "10.26.0", + "@sentry/core": "10.26.0", "hoist-non-react-statics": "^3.3.2" }, "engines": { @@ -3669,17 +3591,17 @@ } }, "node_modules/@sentry/react-native": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-7.6.0.tgz", - "integrity": "sha512-oL6Tl6B+vHP/OtHt9LkhZMg+mntjn2mFTzqnPCggXDIPxn5cqZ41154wA7d33i6JLKiXiK02EHJlnImdb4s06w==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-7.7.0.tgz", + "integrity": "sha512-D+gqiw88mOnouY+Pd8A3wcUDOilPOIcypBPw7WL9v+K1jM12Snf6sosEG4xgFFMXoK+GSsYAeC5MR0skD/b+Zg==", "license": "MIT", "dependencies": { - "@sentry/babel-plugin-component-annotate": "4.6.0", - "@sentry/browser": "10.24.0", - "@sentry/cli": "2.58.0", - "@sentry/core": "10.24.0", - "@sentry/react": "10.24.0", - "@sentry/types": "10.24.0" + "@sentry/babel-plugin-component-annotate": "4.6.1", + "@sentry/browser": "10.26.0", + "@sentry/cli": "2.58.2", + "@sentry/core": "10.26.0", + "@sentry/react": "10.26.0", + "@sentry/types": "10.26.0" }, "bin": { "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" @@ -3696,12 +3618,12 @@ } }, "node_modules/@sentry/types": { - "version": "10.24.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-10.24.0.tgz", - "integrity": "sha512-hLcLS9mFVqZGbkVgkvnkFvwbqkxSv2vKI6zYNJ+3ZW6PWyS82KBEHgedwxtg2F6lCGWQHQxINKjp0GZYKxtRjg==", + "version": "10.26.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-10.26.0.tgz", + "integrity": "sha512-mDpG7lnOJppbk9iKrnuvkuiCTbh3aBAlUK4NZxZNLOSI0SeefYXHRAcri89BqWZ/MT98sQLU+Hf+rlwrwq38/A==", "license": "MIT", "dependencies": { - "@sentry/core": "10.24.0" + "@sentry/core": "10.26.0" }, "engines": { "node": ">=18" @@ -3718,33 +3640,6 @@ "react-native": "*" } }, - "node_modules/@shopify/react-native-skia": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.3.13.tgz", - "integrity": "sha512-gXlD85hqDDC0C1e2on0pTJoV6pOL3ZJBRDbAzAqSa8Q6Y76tHWqsNTTBuipTjzr/9yFxqPp96p/i/P6lMMsvLg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "canvaskit-wasm": "0.40.0", - "react-reconciler": "0.31.0" - }, - "bin": { - "setup-skia-web": "scripts/setup-canvaskit.js" - }, - "peerDependencies": { - "react": ">=19.0", - "react-native": ">=0.78", - "react-native-reanimated": ">=3.19.1" - }, - "peerDependenciesMeta": { - "react-native": { - "optional": true - }, - "react-native-reanimated": { - "optional": true - } - } - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -4133,15 +4028,15 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "25.0.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz", + "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -4154,13 +4049,13 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.26", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", - "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", "dependencies": { "@types/prop-types": "*", - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-native": { @@ -4196,12 +4091,14 @@ } }, "node_modules/@types/react-native-video": { - "version": "5.0.20", - "resolved": "https://registry.npmjs.org/@types/react-native-video/-/react-native-video-5.0.20.tgz", - "integrity": "sha512-CdD4T43uEKzTNJ/RylTDViNuGuyOPWQUEuA1Y9GY8T+HiE9cwYw1zQNqk8a7zz9GHamlPfJQ+bYoEE9OWjZ/6g==", + "version": "5.0.21", + "resolved": "https://registry.npmjs.org/@types/react-native-video/-/react-native-video-5.0.21.tgz", + "integrity": "sha512-6C/9uv12x+QNAjbYFKUWciwSIksNj+xhL8/EZXIN+H/bHYmSMWKZ1oxfIrd+IfXx3edcDZmyR3ByY7r4ptxZow==", "license": "MIT", "dependencies": { - "@types/react": "*", + "@types/react": "*" + }, + "peerDependencies": { "react-native": "*" } }, @@ -4405,12 +4302,6 @@ "url": "https://github.com/sponsors/crutchcorn" } }, - "node_modules/@webgpu/types": { - "version": "0.1.21", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz", - "integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==", - "license": "BSD-3-Clause" - }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -4493,16 +4384,15 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "optional": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -4710,12 +4600,12 @@ } }, "node_modules/axios-cookiejar-support": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-6.0.4.tgz", - "integrity": "sha512-4Bzj+l63eGwnWDBFdJHeGS6Ij3ytpyqvo//ocsb5kCLN/rKthzk27Afh2iSkZtuudOBkHUWWIcyCb4GKhXqovQ==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-6.0.5.tgz", + "integrity": "sha512-ldPOQCJWB0ipugkTNVB8QRl/5L2UgfmVNVQtS9en1JQJ1wW588PqAmymnwmmgc12HLDzDtsJ28xE2ppj4rD4ng==", "license": "MIT", "dependencies": { - "http-cookie-agent": "^7.0.2" + "http-cookie-agent": "^7.0.3" }, "engines": { "node": ">=20.0.0" @@ -4886,9 +4776,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "54.0.7", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.7.tgz", - "integrity": "sha512-JENWk0bvxW4I1ftveO8GRtX2t2TH6N4Z0TPvIHxroZ/4SswUfyNsUNbbP7Fm4erj3ar/JHGri5kTZ+s3xdjHZw==", + "version": "54.0.8", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.8.tgz", + "integrity": "sha512-3ZJ4Q7uQpm8IR/C9xbKhE/IUjGpLm+OIjF8YCedLgqoe/wN1Ns2wLT7HwG6ZXXb6/rzN8IMCiKFQ2F93qlN6GA==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", @@ -4977,9 +4867,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.28", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz", - "integrity": "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==", + "version": "2.9.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", + "integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -5088,9 +4978,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -5107,11 +4997,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -5247,9 +5137,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001755", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", - "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", "funding": [ { "type": "opencollective", @@ -5266,15 +5156,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvaskit-wasm": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", - "integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==", - "license": "BSD-3-Clause", - "dependencies": { - "@webgpu/types": "0.1.21" - } - }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -5411,26 +5292,6 @@ "node": ">=12" } }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cliui/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -5643,12 +5504,12 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.46.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", - "integrity": "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==", + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", + "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", "license": "MIT", "dependencies": { - "browserslist": "^4.26.3" + "browserslist": "^4.28.0" }, "funding": { "type": "opencollective", @@ -5843,9 +5704,9 @@ } }, "node_modules/csstype": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.2.tgz", - "integrity": "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/dashdash": { @@ -6100,12 +5961,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -6124,15 +5979,15 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.254", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz", - "integrity": "sha512-DcUsWpVhv9svsKRxnSCZ86SjD+sp32SGidNB37KpqXJncp1mfUgKbHvBomE89WJDbfVKw1mdv5+ikrvd43r+Bg==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "license": "ISC" }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/encodeurl": { @@ -6357,29 +6212,29 @@ "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" }, "node_modules/expo": { - "version": "54.0.23", - "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.23.tgz", - "integrity": "sha512-b4uQoiRwQ6nwqsT2709RS15CWYNGF3eJtyr1KyLw9WuMAK7u4jjofkhRiO0+3o1C2NbV+WooyYTOZGubQQMBaQ==", + "version": "54.0.29", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.29.tgz", + "integrity": "sha512-9C90gyOzV83y2S3XzCbRDCuKYNaiyCzuP9ketv46acHCEZn+QTamPK/DobdghoSiofCmlfoaiD6/SzfxDiHMnw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "54.0.16", - "@expo/config": "~12.0.10", - "@expo/config-plugins": "~54.0.2", - "@expo/devtools": "0.1.7", - "@expo/fingerprint": "0.15.3", + "@expo/cli": "54.0.19", + "@expo/config": "~12.0.12", + "@expo/config-plugins": "~54.0.4", + "@expo/devtools": "0.1.8", + "@expo/fingerprint": "0.15.4", "@expo/metro": "~54.1.0", - "@expo/metro-config": "54.0.9", + "@expo/metro-config": "54.0.11", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~54.0.7", - "expo-asset": "~12.0.9", - "expo-constants": "~18.0.10", - "expo-file-system": "~19.0.17", - "expo-font": "~14.0.9", - "expo-keep-awake": "~15.0.7", - "expo-modules-autolinking": "3.0.21", - "expo-modules-core": "3.0.25", + "babel-preset-expo": "~54.0.8", + "expo-asset": "~12.0.11", + "expo-constants": "~18.0.12", + "expo-file-system": "~19.0.21", + "expo-font": "~14.0.10", + "expo-keep-awake": "~15.0.8", + "expo-modules-autolinking": "3.0.23", + "expo-modules-core": "3.0.29", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" @@ -6409,22 +6264,22 @@ } }, "node_modules/expo-application": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.7.tgz", - "integrity": "sha512-Jt1/qqnoDUbZ+bK91+dHaZ1vrPDtRBOltRa681EeedkisqguuEeUx4UHqwVyDK2oHWsK6lO3ojetoA4h8OmNcg==", + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", + "integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-asset": { - "version": "12.0.9", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.9.tgz", - "integrity": "sha512-vrdRoyhGhBmd0nJcssTSk1Ypx3Mbn/eXaaBCQVkL0MJ8IOZpAObAjfD5CTy8+8RofcHEQdh3wwZVCs7crvfOeg==", + "version": "12.0.11", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.11.tgz", + "integrity": "sha512-pnK/gQ5iritDPBeK54BV35ZpG7yeW5DtgGvJHruIXkyDT9BCoQq3i0AAxfcWG/e4eiRmTzAt5kNVYFJi48uo+A==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.8.7", - "expo-constants": "~18.0.9" + "@expo/image-utils": "^0.8.8", + "expo-constants": "~18.0.11" }, "peerDependencies": { "expo": "*", @@ -6433,16 +6288,16 @@ } }, "node_modules/expo-auth-session": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.8.tgz", - "integrity": "sha512-kpo2Jva+6uVjk6TmNqWAoqTnULXZaEVa9l4uf8JH32uDMt/iZQhM0fauy7Ww+y910Euhv5djCP7cPj8KWv6cmQ==", + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.10.tgz", + "integrity": "sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==", "license": "MIT", "dependencies": { - "expo-application": "~7.0.7", - "expo-constants": "~18.0.8", - "expo-crypto": "~15.0.7", - "expo-linking": "~8.0.8", - "expo-web-browser": "~15.0.7", + "expo-application": "~7.0.8", + "expo-constants": "~18.0.11", + "expo-crypto": "~15.0.8", + "expo-linking": "~8.0.10", + "expo-web-browser": "~15.0.10", "invariant": "^2.2.4" }, "peerDependencies": { @@ -6451,9 +6306,9 @@ } }, "node_modules/expo-blur": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-15.0.7.tgz", - "integrity": "sha512-SugQQbQd+zRPy8z2G5qDD4NqhcD7srBF7fN7O7yq6q7ZFK59VWvpDxtMoUkmSfdxgqONsrBN/rLdk00USADrMg==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-15.0.8.tgz", + "integrity": "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6462,9 +6317,9 @@ } }, "node_modules/expo-brightness": { - "version": "14.0.7", - "resolved": "https://registry.npmjs.org/expo-brightness/-/expo-brightness-14.0.7.tgz", - "integrity": "sha512-wccb/NdQEd45UF0lgNEksZt3E8uzlIcxIx1ZqZYWbHyNvcS3LUj5wxB6+ZgKTLeWu4vLQ+oHe+F0QrkC9ojrig==", + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-brightness/-/expo-brightness-14.0.8.tgz", + "integrity": "sha512-WOg3UxzkHFTKBW3XvROlrVRmnJmZLhGBGd1RdzTfrtt2/MdSzvVmCevqWh4bohkeLABh0Yc9YRo1vFgfT73DWw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6472,13 +6327,13 @@ } }, "node_modules/expo-constants": { - "version": "18.0.10", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz", - "integrity": "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw==", + "version": "18.0.12", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz", + "integrity": "sha512-WzcKYMVNRRu4NcSzfIVRD5aUQFnSpTZgXFrlWmm19xJoDa4S3/PQNi6PNTBRc49xz9h8FT7HMxRKaC8lr0gflA==", "license": "MIT", "dependencies": { - "@expo/config": "~12.0.10", - "@expo/env": "~2.0.7" + "@expo/config": "~12.0.12", + "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", @@ -6486,9 +6341,9 @@ } }, "node_modules/expo-crypto": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.7.tgz", - "integrity": "sha512-FUo41TwwGT2e5rA45PsjezI868Ch3M6wbCZsmqTWdF/hr+HyPcrp1L//dsh/hsrsyrQdpY/U96Lu71/wXePJeg==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz", + "integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==", "license": "MIT", "dependencies": { "base64-js": "^1.3.0" @@ -6498,15 +6353,15 @@ } }, "node_modules/expo-dev-client": { - "version": "6.0.17", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.17.tgz", - "integrity": "sha512-zVilIum3sqXFbhYhPT6TuxR3ddH/IfHL82FiOTqJUiYaTQqun1I6ogSvU1djhY1eXUYhfYIBieQNWMVjXPxMvw==", + "version": "6.0.20", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.20.tgz", + "integrity": "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "6.0.17", - "expo-dev-menu": "7.0.16", + "expo-dev-launcher": "6.0.20", + "expo-dev-menu": "7.0.18", "expo-dev-menu-interface": "2.0.0", - "expo-manifests": "~1.0.8", + "expo-manifests": "~1.0.10", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { @@ -6514,22 +6369,23 @@ } }, "node_modules/expo-dev-launcher": { - "version": "6.0.17", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.17.tgz", - "integrity": "sha512-riLxFXaw6Nvgb27TiQtUvoHkW/zTz0aO7M+qxDBBaEbJMJSFl51KSwOJJBTItVQIE9f9jB8x5L1CfLw81/McZw==", + "version": "6.0.20", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.20.tgz", + "integrity": "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA==", "license": "MIT", "dependencies": { - "expo-dev-menu": "7.0.16", - "expo-manifests": "~1.0.8" + "ajv": "^8.11.0", + "expo-dev-menu": "7.0.18", + "expo-manifests": "~1.0.10" }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-dev-menu": { - "version": "7.0.16", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.16.tgz", - "integrity": "sha512-/kjTjk5tcZV0ixYnV3JyzPXKlMimpBNYaDo4XxBbRFIkTf/vmb/9e1BTR2nALnoa/D3MRwtR43gZYT+W/wfKXw==", + "version": "7.0.18", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.18.tgz", + "integrity": "sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA==", "license": "MIT", "dependencies": { "expo-dev-menu-interface": "2.0.0" @@ -6548,9 +6404,9 @@ } }, "node_modules/expo-device": { - "version": "8.0.9", - "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.9.tgz", - "integrity": "sha512-XqRpaljDNAYZGZzMpC+b9KZfzfydtkwx3pJAp6ODDH+O/5wjAw+mLc5wQMGJCx8/aqVmMsAokec7iebxDPFZDA==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", + "integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==", "license": "MIT", "dependencies": { "ua-parser-js": "^0.7.33" @@ -6560,24 +6416,24 @@ } }, "node_modules/expo-document-picker": { - "version": "14.0.7", - "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.7.tgz", - "integrity": "sha512-81Jh8RDD0GYBUoSTmIBq30hXXjmkDV1ZY2BNIp1+3HR5PDSh2WmdhD/Ezz5YFsv46hIXHsQc+Kh1q8vn6OLT9Q==", + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz", + "integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-eas-client": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.7.tgz", - "integrity": "sha512-Q/b1X0fM+3beqqvffok14pjxMF600NxopdSr9WJY61fF4xllcVnALS0kEudffp9ihMOfcb5xWYqzKj6jMqYDIw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz", + "integrity": "sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==", "license": "MIT" }, "node_modules/expo-file-system": { - "version": "19.0.17", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.17.tgz", - "integrity": "sha512-WwaS01SUFrxBnExn87pg0sCTJjZpf2KAOzfImG0o8yhkU7fbYpihpl/oocXBEsNbj58a8hVt1Y4CVV5c1tzu/g==", + "version": "19.0.21", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", + "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6585,9 +6441,9 @@ } }, "node_modules/expo-font": { - "version": "14.0.9", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.9.tgz", - "integrity": "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg==", + "version": "14.0.10", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz", + "integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==", "license": "MIT", "dependencies": { "fontfaceobserver": "^2.1.0" @@ -6599,9 +6455,9 @@ } }, "node_modules/expo-glass-effect": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-0.1.7.tgz", - "integrity": "sha512-DxminueyL6TWoC9A3omka57XzpxUXLEGpDi/tnlvYwPSihB6lvGp2my+0k97lUKsBHbJg29weMCEQxNa/AyRHA==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-0.1.8.tgz", + "integrity": "sha512-9Cp17ax0Fpugue8+Bd7Ndl/dSAvGmt4bQ5mQLw9zc1A2lctUse3cEg9nI7TnDJiwKf+A/VAPN6+3K12JVMYgZg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6610,18 +6466,18 @@ } }, "node_modules/expo-haptics": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.7.tgz", - "integrity": "sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz", + "integrity": "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-intent-launcher": { - "version": "13.0.7", - "resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-13.0.7.tgz", - "integrity": "sha512-4em7utK59gftgBwokpw+TQkyY27C5JH28LLrM/ZTABIsAMRUEqS+Inzd/xtN0hvxo2Z8aTsd+N1WRcCdOehYdg==", + "version": "13.0.8", + "resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-13.0.8.tgz", + "integrity": "sha512-sgGFotttKKN6dIatjOEJT8M6Arfakus7vIxgshg5VkxarVhZBGJzOJam7rbUlB1O/gQ8em9G8vhEU9AfjEIe7A==", "license": "MIT", "peerDependencies": { "expo": "*" @@ -6634,9 +6490,9 @@ "license": "MIT" }, "node_modules/expo-keep-awake": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.7.tgz", - "integrity": "sha512-CgBNcWVPnrIVII5G54QDqoE125l+zmqR4HR8q+MQaCfHet+dYpS5vX5zii/RMayzGN4jPgA4XYIQ28ePKFjHoA==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", + "integrity": "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6644,9 +6500,9 @@ } }, "node_modules/expo-libvlc-player": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/expo-libvlc-player/-/expo-libvlc-player-2.2.3.tgz", - "integrity": "sha512-HuTmcawtYACeYfX+Ft0RbWRFOh/Wu2OswS4HjSICEW909UY/ZvtHOqPRLym47VjA1oulLHGB7SGGvSgvPd2/4A==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/expo-libvlc-player/-/expo-libvlc-player-2.2.5.tgz", + "integrity": "sha512-Hl0XiRNK5iwPMDRWYouA7+Xzf804GZ/AMVTU87ktUlQMU5bgTUFgmi8QjlOLGEF5LpVp7LDFfQwsDpXP1ggpag==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6655,9 +6511,9 @@ } }, "node_modules/expo-linear-gradient": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz", - "integrity": "sha512-yF+y+9Shpr/OQFfy/wglB/0bykFMbwHBTuMRa5Of/r2P1wbkcacx8rg0JsUWkXH/rn2i2iWdubyqlxSJa3ggZA==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz", + "integrity": "sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6666,12 +6522,12 @@ } }, "node_modules/expo-linking": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.8.tgz", - "integrity": "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.10.tgz", + "integrity": "sha512-0EKtn4Sk6OYmb/5ZqK8riO0k1Ic+wyT3xExbmDvUYhT7p/cKqlVUExMuOIAt3Cx3KUUU1WCgGmdd493W/D5XjA==", "license": "MIT", "dependencies": { - "expo-constants": "~18.0.8", + "expo-constants": "~18.0.11", "invariant": "^2.2.4" }, "peerDependencies": { @@ -6680,9 +6536,9 @@ } }, "node_modules/expo-localization": { - "version": "17.0.7", - "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.7.tgz", - "integrity": "sha512-ACg1B0tJLNa+f8mZfAaNrMyNzrrzHAARVH1sHHvh+LolKdQpgSKX69Uroz1Llv4C71furpwBklVStbNcEwVVVA==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz", + "integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==", "license": "MIT", "dependencies": { "rtl-detect": "^1.0.2" @@ -6693,12 +6549,12 @@ } }, "node_modules/expo-manifests": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.8.tgz", - "integrity": "sha512-nA5PwU2uiUd+2nkDWf9e71AuFAtbrb330g/ecvuu52bmaXtN8J8oiilc9BDvAX0gg2fbtOaZdEdjBYopt1jdlQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz", + "integrity": "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ==", "license": "MIT", "dependencies": { - "@expo/config": "~12.0.8", + "@expo/config": "~12.0.11", "expo-json-utils": "~0.15.0" }, "peerDependencies": { @@ -6706,9 +6562,9 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.21.tgz", - "integrity": "sha512-pOtPDLln3Ju8DW1zRW4OwZ702YqZ8g+kM/tEY1sWfv22kWUtxkvK+ytRDRpRdnKEnC28okbhWqeMnmVkSFzP6Q==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.23.tgz", + "integrity": "sha512-YZnaE0G+52xftjH5nsIRaWsoVBY38SQCECclpdgLisdbRY/6Mzo7ndokjauOv3mpFmzMZACHyJNu1YSAffQwTg==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -6722,9 +6578,9 @@ } }, "node_modules/expo-modules-core": { - "version": "3.0.25", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.25.tgz", - "integrity": "sha512-0P8PT8UV6c5/+p8zeVM/FXvBgn/ErtGcMaasqUgbzzBUg94ktbkIrij9t9reGCrir03BYt/Bcpv+EQtYC8JOug==", + "version": "3.0.29", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.29.tgz", + "integrity": "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==", "license": "MIT", "dependencies": { "invariant": "^2.2.4" @@ -6735,18 +6591,18 @@ } }, "node_modules/expo-notifications": { - "version": "0.32.12", - "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.12.tgz", - "integrity": "sha512-FVJ5W4rOpKvmrLJ1Sd5pxiVTV4a7ApgTlKro+E5X8M2TBbXmEVOjs09klzdalXTjlzmU/Gu8aRw9xr7Ea/gZdw==", + "version": "0.32.15", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.15.tgz", + "integrity": "sha512-gnJcauheC2S0Wl0RuJaFkaBRVzCG011j5hlG0TEbsuOCPBuB/F30YEk8yurK8Psv+zHkVfeiJ5AC+nL0LWk0WA==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.8.7", + "@expo/image-utils": "^0.8.8", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", - "expo-application": "~7.0.7", - "expo-constants": "~18.0.9" + "expo-application": "~7.0.8", + "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", @@ -6768,9 +6624,9 @@ } }, "node_modules/expo-screen-orientation": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-9.0.7.tgz", - "integrity": "sha512-UH/XlB9eMw+I2cyHSkXhAHRAPk83WyA3k5bst7GLu14wRuWiTch9fb6I7qEJK5CN6+XelcWxlBJymys6Fr/FKA==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-9.0.8.tgz", + "integrity": "sha512-qRoPi3E893o3vQHT4h1NKo51+7g2hjRSbDeg1fsSo/u2pOW5s6FCeoacLvD+xofOP33cH2MkE4ua54aWWO7Icw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6778,27 +6634,27 @@ } }, "node_modules/expo-server": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.4.tgz", - "integrity": "sha512-IN06r3oPxFh3plSXdvBL7dx0x6k+0/g0bgxJlNISs6qL5Z+gyPuWS750dpTzOeu37KyBG0RcyO9cXUKzjYgd4A==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", + "integrity": "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==", "license": "MIT", "engines": { "node": ">=20.16.0" } }, "node_modules/expo-sharing": { - "version": "14.0.7", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.7.tgz", - "integrity": "sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g==", + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz", + "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-status-bar": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.8.tgz", - "integrity": "sha512-L248XKPhum7tvREoS1VfE0H6dPCaGtoUWzRsUv7hGKdiB4cus33Rc0sxkWkoQ77wE8stlnUlL5lvmT0oqZ3ZBw==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", + "integrity": "sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" @@ -6815,9 +6671,9 @@ "license": "MIT" }, "node_modules/expo-system-ui": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.8.tgz", - "integrity": "sha512-DzJYqG2fibBSLzPDL4BybGCiilYOtnI1OWhcYFwoM4k0pnEzMBt1Vj8Z67bXglDDuz2HCQPGNtB3tQft5saKqQ==", + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.9.tgz", + "integrity": "sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg==", "license": "MIT", "dependencies": { "@react-native/normalize-colors": "0.81.5", @@ -6835,23 +6691,23 @@ } }, "node_modules/expo-updates": { - "version": "29.0.12", - "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.12.tgz", - "integrity": "sha512-gE3bU6qi5g8Y1TtBzoeHac3utR0i1Wj1ufThh+zpDyFjFbegFm+gwvNLVCBagZUClYKk/4CKxh5ytnwZmPzH+g==", + "version": "29.0.15", + "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.15.tgz", + "integrity": "sha512-6Qj+g56nnCksKKnEPQFm19dfWvYB5EggQNN3SaLbIj4LI40k/pjQwqYStEuwTU+Ow+PG0AqxIhQ3NvgVPEzLvg==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "0.0.5", - "@expo/plist": "^0.4.7", + "@expo/plist": "^0.4.8", "@expo/spawn-async": "^1.7.2", "arg": "4.1.0", "chalk": "^4.1.2", "debug": "^4.3.4", - "expo-eas-client": "~1.0.7", - "expo-manifests": "~1.0.8", + "expo-eas-client": "~1.0.8", + "expo-manifests": "~1.0.10", "expo-structured-headers": "~5.0.0", "expo-updates-interface": "~2.0.0", "getenv": "^2.0.0", - "glob": "^10.4.2", + "glob": "^13.0.0", "ignore": "^5.3.1", "resolve-from": "^5.0.0" }, @@ -6880,9 +6736,9 @@ "license": "MIT" }, "node_modules/expo-web-browser": { - "version": "15.0.9", - "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.9.tgz", - "integrity": "sha512-Dj8kNFO+oXsxqCDNlUT/GhOrJnm10kAElH++3RplLydogFm5jTzXYWDEeNIDmV+F+BzGYs+sIhxiBf7RyaxXZg==", + "version": "15.0.10", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz", + "integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6937,6 +6793,22 @@ "license": "MIT", "optional": true }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -7002,6 +6874,23 @@ "node": "*" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-type": { "version": "16.5.4", "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", @@ -7146,22 +7035,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -7173,9 +7046,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -7353,20 +7226,32 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "license": "ISC", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7427,6 +7312,30 @@ "node": ">=6" } }, + "node_modules/har-validator/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/har-validator/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT", + "optional": true + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -8007,21 +7916,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -8286,11 +8180,10 @@ "optional": true }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT", - "optional": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/json-stable-stringify": { "version": "1.3.0", @@ -9537,9 +9430,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" @@ -9823,15 +9716,6 @@ "node": ">=6" } }, - "node_modules/ora/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/ora/node_modules/ansi-styles": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", @@ -9891,18 +9775,6 @@ "node": ">=4" } }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/ora/node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -9954,12 +9826,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -10119,26 +9985,29 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/path-type": { "version": "4.0.0", @@ -10276,12 +10145,12 @@ "license": "MIT" }, "node_modules/posthog-react-native": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.11.0.tgz", - "integrity": "sha512-f/QCyUrW0qWTfiH78IHpDOf5KQVX/p0POa2Pad4THBM0gfkHXd7jTNoz7sVoRLbfBRyTFFjsXlZKqXUt1mfKtg==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.14.3.tgz", + "integrity": "sha512-oooOqCcWSRmychTrU5CS6lIZmIkHmk7cIw3py5G6ZRELkWN5qgGc0efflb3FENg9FLOAiwXcxMDzMSehSEKtuw==", "license": "MIT", "dependencies": { - "@posthog/core": "1.5.2" + "@posthog/core": "1.7.1" }, "peerDependencies": { "@react-native-async-storage/async-storage": ">=1.0.0", @@ -10608,9 +10477,9 @@ } }, "node_modules/react-is": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", - "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", + "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", "license": "MIT" }, "node_modules/react-native": { @@ -10702,9 +10571,9 @@ } }, "node_modules/react-native-bottom-tabs": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/react-native-bottom-tabs/-/react-native-bottom-tabs-1.0.2.tgz", - "integrity": "sha512-eWNuTpJVefKRaROda4ZeWHvW1cUEb0mw8L7FyLEcPPsd7Tp3rfLRrhptl/O/3mAki9gvpzYE8ASE3GwUrjfp+Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/react-native-bottom-tabs/-/react-native-bottom-tabs-1.1.0.tgz", + "integrity": "sha512-Uu1gvM3i1Hb4DjVvR/38J1QVQEs0RkPc7K6yon99HgvRWWOyLs7kjPDhUswtb8ije4pKW712skIXWJ0lgKzbyQ==", "license": "MIT", "dependencies": { "react-freeze": "^1.0.0", @@ -10769,9 +10638,9 @@ } }, "node_modules/react-native-image-colors": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/react-native-image-colors/-/react-native-image-colors-2.5.0.tgz", - "integrity": "sha512-3zSDgNj5HaZ0PDWaXkc4BpWpZRM5N4gBsoPC7DBfM/+op69Yvwbc0S1T7CnxBWbvShtOvRE+b2BUBadVn+6z/g==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/react-native-image-colors/-/react-native-image-colors-2.5.1.tgz", + "integrity": "sha512-7+M1pu9Q1TDEGSbXfSwFIFUoGW1Ffmwfjbx2QQM895C2gvOzUsdwSS1ae856l6vvj7UWFbGZr1LpQi0VK6Xl4w==", "license": "MIT", "dependencies": { "node-vibrant": "^4.0.3" @@ -10818,10 +10687,9 @@ } }, "node_modules/react-native-mmkv": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/react-native-mmkv/-/react-native-mmkv-4.0.0.tgz", - "integrity": "sha512-Osoy8as2ZLzO1TTsKxc4tX14Qk19qRVMWnS4ZVBwxie9Re5cjt7rqlpDkJczK3H/y3z70EQ6rmKI/cNMCLGAYQ==", - "hasInstallScript": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/react-native-mmkv/-/react-native-mmkv-4.1.0.tgz", + "integrity": "sha512-ia76WnU6dkLZxFkSSflxqFgHT2pIaML763aucEu7nMglF41oEWTdTtBu0o8a1cxbhZOaONk6KF8RQp5fLvPitA==", "license": "MIT", "peerDependencies": { "react": "*", @@ -10830,9 +10698,9 @@ } }, "node_modules/react-native-nitro-modules": { - "version": "0.31.6", - "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.31.6.tgz", - "integrity": "sha512-EcKuLgYwOPrWSGC0VqxqTXjxsQBLCjl/1+ryuKwKDA4iyLr9C/8CBCn6sDS3wbmZDlNl+EPWTPU+VWC6SnPW8Q==", + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.31.10.tgz", + "integrity": "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ==", "hasInstallScript": true, "license": "MIT", "peerDependencies": { @@ -10896,19 +10764,18 @@ } }, "node_modules/react-native-reanimated": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.5.tgz", - "integrity": "sha512-UA6VUbxwhRjEw2gSNrvhkusUq3upfD3Cv+AnB07V+kC8kpvwRVI+ivwY95ePbWNFkFpP+Y2Sdw1WHpHWEV+P2Q==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.0.tgz", + "integrity": "sha512-frhu5b8/m/VvaMWz48V8RxcsXnE3hrlErQ5chr21MzAeDCpY4X14sQjvm+jvu3aOI+7Cz2atdRpyhhIuqxVaXg==", "license": "MIT", "dependencies": { - "react-native-is-edge-to-edge": "^1.2.1", - "semver": "7.7.2" + "react-native-is-edge-to-edge": "1.2.1", + "semver": "7.7.3" }, "peerDependencies": { - "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*", - "react-native-worklets": ">=0.5.0" + "react-native-worklets": ">=0.7.0" } }, "node_modules/react-native-reanimated-carousel": { @@ -10924,9 +10791,9 @@ } }, "node_modules/react-native-reanimated/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10960,9 +10827,9 @@ } }, "node_modules/react-native-svg": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.0.tgz", - "integrity": "sha512-/Wx6F/IZ88B/GcF88bK8K7ZseJDYt+7WGaiggyzLvTowChQ8BM5idmcd4pK+6QJP6a6DmzL2sfOMukFUn/NArg==", + "version": "15.15.1", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.1.tgz", + "integrity": "sha512-ZUD1xwc3Hwo4cOmOLumjJVoc7lEf9oQFlHnLmgccLC19fNm6LVEdtB+Cnip6gEi0PG3wfvVzskViEtrySQP8Fw==", "license": "MIT", "dependencies": { "css-select": "^5.1.0", @@ -11138,26 +11005,6 @@ "wrap-ansi": "^7.0.0" } }, - "node_modules/react-native-vector-icons/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/react-native-vector-icons/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/react-native-vector-icons/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -11198,9 +11045,9 @@ } }, "node_modules/react-native-video": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/react-native-video/-/react-native-video-6.17.0.tgz", - "integrity": "sha512-sslmQo5paSNLmSJ93t3lkC7kl4yFGIc+LWoBdu44hkL4EKG7OIZNd5iaX+/SedAdqecifJrOZHAozxHzVh3TsQ==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/react-native-video/-/react-native-video-6.18.0.tgz", + "integrity": "sha512-9BjAtAh1uGq6h/GNCCh5yzb/iI9qJHuflwNGExyhoUxbhPD1s+15h+CdpJ2MKKJTXw6J7w+nQOp1Ywa54R8w7Q==", "license": "MIT", "peerDependencies": { "react": "*", @@ -11249,33 +11096,68 @@ } }, "node_modules/react-native-worklets": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.6.1.tgz", - "integrity": "sha512-URca8l7c7Uog7gv4mcg9KILdJlnbvwdS5yfXQYf5TDkD2W1VY1sduEKrD+sA3lUPXH/TG1vmXAvNxCNwPMYgGg==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.1.tgz", + "integrity": "sha512-KNsvR48ULg73QhTlmwPbdJLPsWcyBotrGPsrDRDswb5FYpQaJEThUKc2ncXE4UM5dn/ewLoQHjSjLaKUVPxPhA==", "license": "MIT", "dependencies": { - "@babel/plugin-transform-arrow-functions": "^7.0.0-0", - "@babel/plugin-transform-class-properties": "^7.0.0-0", - "@babel/plugin-transform-classes": "^7.0.0-0", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", - "@babel/plugin-transform-optional-chaining": "^7.0.0-0", - "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", - "@babel/plugin-transform-template-literals": "^7.0.0-0", - "@babel/plugin-transform-unicode-regex": "^7.0.0-0", - "@babel/preset-typescript": "^7.16.7", - "convert-source-map": "^2.0.0", - "semver": "7.7.2" + "@babel/plugin-transform-arrow-functions": "7.27.1", + "@babel/plugin-transform-class-properties": "7.27.1", + "@babel/plugin-transform-classes": "7.28.4", + "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", + "@babel/plugin-transform-optional-chaining": "7.27.1", + "@babel/plugin-transform-shorthand-properties": "7.27.1", + "@babel/plugin-transform-template-literals": "7.27.1", + "@babel/plugin-transform-unicode-regex": "7.27.1", + "@babel/preset-typescript": "7.27.1", + "convert-source-map": "2.0.0", + "semver": "7.7.3" }, "peerDependencies": { - "@babel/core": "^7.0.0-0", + "@babel/core": "*", "react": "*", "react-native": "*" } }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/react-native-worklets/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11407,27 +11289,6 @@ "async-limiter": "~1.0.0" } }, - "node_modules/react-reconciler": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz", - "integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.25.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^19.0.0" - } - }, - "node_modules/react-reconciler/node_modules/scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", - "license": "MIT" - }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -11745,12 +11606,6 @@ "node": ">=4" } }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -12082,9 +11937,9 @@ "license": "ISC" }, "node_modules/sf-symbols-typescript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.1.0.tgz", - "integrity": "sha512-ezT7gu/SHTPIOEEoG6TF+O0m5eewl0ZDAO4AtdBi5HjsrUI6JdCG17+Q8+aKp0heM06wZKApRCn5olNbs0Wb/A==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz", + "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==", "license": "MIT", "engines": { "node": ">=10" @@ -12124,16 +11979,10 @@ } }, "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" }, "node_modules/simple-plist": { "version": "1.3.1", @@ -12359,24 +12208,6 @@ "license": "MIT" }, "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", @@ -12390,13 +12221,7 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { + "node_modules/string-width/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -12409,43 +12234,24 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^4.1.0" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "node": ">=6" } }, "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=6" } }, "node_modules/strip-json-comments": { @@ -12487,17 +12293,17 @@ "license": "MIT" }, "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", - "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { @@ -12912,6 +12718,34 @@ "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -13183,9 +13017,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", "funding": [ { "type": "opencollective", @@ -13489,76 +13323,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -13590,12 +13354,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -13693,15 +13451,18 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { @@ -13731,38 +13492,6 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -13774,24 +13503,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.24.1" - } } } } diff --git a/package.json b/package.json index 0ec45496..e74ba9bd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/CustomAlert.tsx b/src/components/CustomAlert.tsx index 4a0588f1..14fb9936 100644 --- a/src/components/CustomAlert.tsx +++ b/src/components/CustomAlert.tsx @@ -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 = ({ @@ -100,23 +101,22 @@ export const CustomAlert = ({ {/* Title */} - + {title} {/* Message */} - + {message} {/* Actions */} - + {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 = ({ {action.label} @@ -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, diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 9fb85ce9..8fca9f44 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -611,7 +611,7 @@ const AppleTVHero: React.FC = ({ // 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 = ({ 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 = ({ .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 = ({ style={logoAnimatedStyle} > {currentItem.logo && !logoError[currentIndex] ? ( - { - if (currentItem) { - navigation.navigate('Metadata', { - id: currentItem.id, - type: currentItem.type, - }); - } + { + if (currentItem) { + navigation.navigate('Metadata', { + id: currentItem.id, + type: currentItem.type, + }); + } + }} + > + { + const { height } = event.nativeEvent.layout; + setLogoHeights((prev) => ({ ...prev, [currentIndex]: height })); }} > - { - const { height } = event.nativeEvent.layout; - setLogoHeights((prev) => ({ ...prev, [currentIndex]: height })); + setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))} + onError={() => { + setLogoError((prev) => ({ ...prev, [currentIndex]: true })); + logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo); }} - > - setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))} - onError={() => { - setLogoError((prev) => ({ ...prev, [currentIndex]: true })); - logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo); - }} - /> - - - ) : ( - { - if (currentItem) { - navigation.navigate('Metadata', { - id: currentItem.id, - type: currentItem.type, - }); - } - }} - > - - - {currentItem.name} - - - - )} - + /> + + + ) : ( + { + if (currentItem) { + navigation.navigate('Metadata', { + id: currentItem.id, + type: currentItem.type, + }); + } + }} + > + + + {currentItem.name} + + + + )} + {/* Metadata Badge - Always Visible */} @@ -1231,7 +1250,7 @@ const AppleTVHero: React.FC = ({ - {/* Action Buttons - Play and Save buttons */} + {/* Action Buttons - Play and Save buttons */} {/* Play Button */} { 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 ( - ); @@ -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 ( - { /> + onPress={() => navigation.navigate('Catalog', { id: catalog.id, type: catalog.type, @@ -176,7 +162,7 @@ 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} /> - + ); }; @@ -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 ) diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index ef6aeb9e..a7e4e60d 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -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(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 ( - + - {/* Reserve space for title to keep section spacing stable */} ); @@ -287,24 +301,24 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe return ( <> - + - + {/* Image with FastImage for aggressive caching */} {item.poster ? ( { setImageError(false); @@ -316,14 +330,14 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe /> ) : ( // Show placeholder for items without posters - + {item.name.substring(0, 20)}... )} {imageError && ( - + )} @@ -350,14 +364,14 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe {settings.showPosterTitles && ( - {item.name} diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 40b42516..7cbfa49d 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -293,51 +293,50 @@ const ContinueWatchingSection = React.forwardRef((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(); + // 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(); - 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((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((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 = {}; + 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(); // 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 = {}; 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((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((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((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((props, re } return ( - @@ -1207,7 +1307,7 @@ const ContinueWatchingSection = React.forwardRef((props, re actions={alertActions} onClose={() => setAlertVisible(false)} /> - + ); }); diff --git a/src/components/home/HeroCarousel.tsx b/src/components/home/HeroCarousel.tsx index 9ac167c0..96efe6f1 100644 --- a/src/components/home/HeroCarousel.tsx +++ b/src/components/home/HeroCarousel.tsx @@ -83,6 +83,7 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = const [activeIndex, setActiveIndex] = useState(0); const [failedLogoIds, setFailedLogoIds] = useState>(new Set()); const scrollViewRef = useRef(null); + const [isScrollReady, setIsScrollReady] = useState(false); const [flippedMap, setFlippedMap] = useState>({}); const toggleFlipById = useCallback((id: string) => { setFlippedMap((prev) => ({ ...prev, [id]: !prev[id] })); @@ -95,9 +96,9 @@ const HeroCarousel: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ items, loading = false }) = height: cardHeight, } ] as StyleProp}> - - - - - - } /> - } /> - - } /> - } /> - - + ))} @@ -289,11 +279,11 @@ const HeroCarousel: React.FC = ({ 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 = ({ items, loading = false }) = ) : ( <> = ({ items, loading = false }) = if (!hasData) return null; return ( - + {/* Removed preload images for performance - let FastImage cache handle it naturally */} - {settings.enableHomeHeroBackground && data[activeIndex] && ( - - )} + {settings.enableHomeHeroBackground && data[activeIndex] && ( + + )} {/* Bottom blend to HomeScreen background (not the card) */} {settings.enableHomeHeroBackground && ( = ({ 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 = ({ items, loading = false }) = }} > {(loopingEnabled ? loopData : data).map((item, index) => ( + /* TEST 5: ORIGINAL CARD WITHOUT LINEAR GRADIENT */ = ({ items, loading = false }) = }} /> - + ); }; +// MINIMAL ANIMATED CARD FOR PERFORMANCE TESTING +interface AnimatedCardWrapperProps { + item: StreamingContent; + index: number; + scrollX: SharedValue; + interval: number; + cardWidth: number; + cardHeight: number; + colors: any; + isTablet: boolean; +} + +const AnimatedCardWrapper: React.FC = 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 ( + + + + + {item.logo && ( + + + setLogoLoaded(true)} + /> + + + )} + {/* TEST 4: GENRES with overlayAnimatedStyle */} + {item.genres && ( + + + {item.genres.slice(0, 3).join(' • ')} + + + )} + + + ); +}); + interface CarouselCardProps { item: StreamingContent; colors: any; @@ -467,13 +612,13 @@ interface CarouselCardProps { const CarouselCard: React.FC = 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 = 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 = 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 = 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 = 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 = memo(({ item, colors, logoFail onLoad={() => setBannerLoaded(true)} /> - + {/* Overlay removed for performance - readability via text shadows */} {item.logo && !logoFailed ? ( @@ -733,11 +874,7 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail onLoad={() => setBannerLoaded(true)} /> - + {/* Overlay removed for performance - readability via text shadows */} {item.logo && !logoFailed ? ( @@ -757,23 +894,23 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail ) : ( - + {item.name} - + )} {item.genres && ( - + {item.genres.slice(0, 3).join(' • ')} - + )} @@ -787,11 +924,7 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail style={styles.banner as any} resizeMode={FastImage.resizeMode.cover} /> - + {/* Overlay removed for performance - readability via text shadows */} {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, diff --git a/src/components/loading/MetadataLoadingScreen.tsx b/src/components/loading/MetadataLoadingScreen.tsx index 344f838f..75a24343 100644 --- a/src/components/loading/MetadataLoadingScreen.tsx +++ b/src/components/loading/MetadataLoadingScreen.tsx @@ -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; + baseColor: string; + highlightColor: string; +}) => { + const animatedStyle = useAnimatedStyle(() => { + const translateX = interpolate( + shimmerProgress.value, + [0, 1], + [-width, width] + ); + return { + transform: [{ translateX }], + }; + }); + + return ( + + + + + + ); +}; + export const MetadataLoadingScreen = forwardRef(({ 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 { - // 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; - }) => ( - - {/* Pulsating overlay removed */} - {/* Shimmer overlay removed */} - - ); + 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 ( - - - - {/* Hero Skeleton */} - - + {/* Hero Section Skeleton */} + + - - {/* Overlay content on hero */} + + {/* Back Button Skeleton */} + + + + + {/* Gradient overlay */} - {/* Bottom hero content skeleton */} - - - - - - - + {/* Hero bottom content - Matches HeroSection.tsx structure */} + + {/* Logo placeholder - Centered and larger */} + + - - - + + {/* Watch Progress Placeholder - Centered Glass Bar */} + + + + + {/* Genre Info Row - Centered */} + + + + + + + + + {/* Action buttons row - Play, Save, Collection, Rates */} + + {/* Play Button */} + + + {/* Save Button */} + + + {/* Collection Icon */} + + + {/* Ratings Icon (if series) - Always show for skeleton consistency */} + - + - {/* Content Section Skeletons */} - - {/* Synopsis skeleton */} - - - - - + {/* Content Section */} + + {/* Description skeleton */} + + + + + - {/* Cast section skeleton */} - - - - {[1, 2, 3, 4].map((item) => ( - - - - - - ))} - + {/* Cast Section */} + + + + {[1, 2, 3, 4, 5].map((item) => ( + + + + + ))} + - {/* Episodes/Details skeleton based on type */} - {type === 'series' ? ( - - - + {/* Episodes/Recommendations Section */} + {type === 'series' ? ( + + + {/* Season selector */} + + {/* Episode cards */} + {[1, 2, 3].map((item) => ( - - + + - - - + + ))} - ) : ( - - - - - - + + ) : ( + + + + {[1, 2, 3, 4].map((item) => ( + + ))} - )} - + + )} ); @@ -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, }, }); diff --git a/src/components/metadata/CommentsSection.tsx b/src/components/metadata/CommentsSection.tsx index db1cf2ab..b443aa76 100644 --- a/src/components/metadata/CommentsSection.tsx +++ b/src/components/metadata/CommentsSection.tsx @@ -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 */} - - - - - {/* Header Section - Fixed at top */} - - - - {username} - - {user.vip && ( - - VIP - - )} + {/* Trakt Icon - Top Right Corner */} + + - - {/* Rating - Show stars */} - {comment.user_stats?.rating && ( + {/* Header Section - Fixed at top */} - {renderCompactStars(comment.user_stats.rating)} - + + {username} + + {user.vip && ( + + VIP + + )} + + + + {/* Rating - Show stars */} + {comment.user_stats?.rating && ( + - {comment.user_stats.rating}/10 - - - )} + {renderCompactStars(comment.user_stats.rating)} + + {comment.user_stats.rating}/10 + + + )} - {/* Comment Preview - Flexible area that fills space */} - - {shouldBlurContent ? ( - āš ļø This comment contains spoilers. Tap to reveal. - ) : ( - + {shouldBlurContent ? ( + - )} - + ]}>āš ļø This comment contains spoilers. Tap to reveal. + ) : ( + + )} + - {/* Meta Info - Fixed at bottom */} - - - {comment.spoiler && ( + {/* Meta Info - Fixed at bottom */} + + + {comment.spoiler && ( + Spoiler + )} + + Spoiler - )} - - - - {formatRelativeTime(comment.created_at)} - - {comment.likes > 0 && ( - - šŸ‘ {comment.likes} + {formatRelativeTime(comment.created_at)} - )} - {comment.replies > 0 && ( - - šŸ’¬ {comment.replies} - - )} + {comment.likes > 0 && ( + + šŸ‘ {comment.likes} + + )} + {comment.replies > 0 && ( + + šŸ’¬ {comment.replies} + + )} + - ); @@ -614,105 +614,105 @@ const ExpandedCommentBottomSheet: React.FC<{ nestedScrollEnabled keyboardShouldPersistTaps="handled" > - {/* Close Button */} - - - + {/* Close Button */} + + + - {/* User Info */} - - - - {username} - - {user.vip && ( - - VIP - - )} - - {(() => { - const { datePart, timePart } = formatDateParts(comment.created_at); - return ( - - - {datePart} - - {!!timePart && ( - - {timePart} - - )} - - ); - })()} - - - {/* Rating */} - {comment.user_stats?.rating && ( - - {renderStars(comment.user_stats.rating)} - - {comment.user_stats.rating}/10 - - - )} - - {/* Full Comment (Markdown with inline spoilers) */} - {shouldBlurModalContent ? ( - - - + {/* User Info */} + + + + {username} + + {user.vip && ( + + VIP - Contains spoilers - - - Reveal - - - ) : ( - - - - )} - - {/* Comment Meta */} - - {comment.spoiler && ( - Spoiler )} - - {comment.likes > 0 && ( - - - - {comment.likes} - - - )} - {comment.replies > 0 && ( - - - - {comment.replies} - - - )} - + {(() => { + const { datePart, timePart } = formatDateParts(comment.created_at); + return ( + + + {datePart} + + {!!timePart && ( + + {timePart} + + )} + + ); + })()} + + + {/* Rating */} + {comment.user_stats?.rating && ( + + {renderStars(comment.user_stats.rating)} + + {comment.user_stats.rating}/10 + + + )} + + {/* Full Comment (Markdown with inline spoilers) */} + {shouldBlurModalContent ? ( + + + + + Contains spoilers + + + Reveal + + + ) : ( + + + + )} + + {/* Comment Meta */} + + {comment.spoiler && ( + Spoiler + )} + + {comment.likes > 0 && ( + + + + {comment.likes} + + + )} + {comment.replies > 0 && ( + + + + {comment.replies} + + + )} + + ); @@ -732,7 +732,7 @@ export const CommentsSection: 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'; @@ -740,13 +740,13 @@ export const CommentsSection: 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 spacing and padding const horizontalPadding = useMemo(() => { switch (deviceType) { @@ -772,7 +772,7 @@ export const CommentsSection: React.FC = ({ } = 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 = ({ } ]}> = ({ ) : ( <> - + Load More @@ -1022,15 +1022,19 @@ export const CommentBottomSheet: React.FC<{ }> = ({ comment, visible, onClose, theme, isSpoilerRevealed, onSpoilerPress }) => { const bottomSheetRef = useRef(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 */} - - - - {username} - - {user.vip && ( - - VIP - - )} - - {(() => { - const { datePart, timePart } = formatDateParts(comment.created_at); - return ( - - - {datePart} - - {!!timePart && ( - - {timePart} - - )} - - ); - })()} - - - {/* Rating */} - {comment.user_stats?.rating && ( - - {renderStars(comment.user_stats.rating)} - - {comment.user_stats.rating}/10 - - - )} - - {/* Full Comment (Markdown with inline spoilers) */} - {shouldBlurModalContent ? ( - - - + {/* User Info */} + + + + {username} + + {user.vip && ( + + VIP - Contains spoilers - - - Reveal - - - ) : ( - - - - )} - - {/* Comment Meta */} - - {comment.spoiler && ( - Spoiler )} - - {comment.likes > 0 && ( - - - - {comment.likes} - - - )} - {comment.replies > 0 && ( - - - - {comment.replies} - - - )} - + {(() => { + const { datePart, timePart } = formatDateParts(comment.created_at); + return ( + + + {datePart} + + {!!timePart && ( + + {timePart} + + )} + + ); + })()} + + + {/* Rating */} + {comment.user_stats?.rating && ( + + {renderStars(comment.user_stats.rating)} + + {comment.user_stats.rating}/10 + + + )} + + {/* Full Comment (Markdown with inline spoilers) */} + {shouldBlurModalContent ? ( + + + + + Contains spoilers + + + Reveal + + + ) : ( + + + + )} + + {/* Comment Meta */} + + {comment.spoiler && ( + Spoiler + )} + + {comment.likes > 0 && ( + + + + {comment.likes} + + + )} + {comment.replies > 0 && ( + + + + {comment.replies} + + + )} + + ); diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx index 7855dd94..666a9b14 100644 --- a/src/components/metadata/MetadataDetails.tsx +++ b/src/components/metadata/MetadataDetails.tsx @@ -59,7 +59,7 @@ const MetadataDetails: 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'; @@ -67,13 +67,13 @@ const MetadataDetails: 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 spacing and padding const horizontalPadding = useMemo(() => { switch (deviceType) { @@ -89,8 +89,11 @@ const MetadataDetails: React.FC = ({ }, [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 = ({ setIsMDBEnabled(false); // Default to disabled if there's an error } }; - + checkMDBListEnabled(); }, []); @@ -114,6 +117,12 @@ const MetadataDetails: React.FC = ({ 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 = ({ 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 */} {metadata.year && ( Director{metadata.directors.length > 1 ? 's' : ''}: Creator{metadata.creators.length > 1 ? 's' : ''}: - {/* Description */} - {metadata.description && ( + {/* Description - Show skeleton if no description yet to prevent layout shift */} + {metadata.description ? ( + ) : ( + /* Skeleton placeholder for description to prevent layout shift */ + + + + + + + )} ); @@ -491,6 +518,12 @@ const styles = StyleSheet.create({ fontSize: 14, marginRight: 4, }, + descriptionSkeleton: { + borderRadius: 4, + }, + skeletonLine: { + borderRadius: 4, + }, }); export default React.memo(MetadataDetails); \ No newline at end of file diff --git a/src/components/metadata/MovieContent.tsx b/src/components/metadata/MovieContent.tsx index 458caa8e..8929ffed 100644 --- a/src/components/metadata/MovieContent.tsx +++ b/src/components/metadata/MovieContent.tsx @@ -8,31 +8,7 @@ interface MovieContentProps { } export const MovieContent: React.FC = ({ metadata }) => { - const { currentTheme } = useTheme(); - const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0; - const castDisplay = hasCast ? metadata.cast!.slice(0, 5).join(', ') : ''; - - return ( - - {/* Additional metadata */} - - {metadata.director && ( - - Director: - {metadata.director} - - )} - - - {hasCast && ( - - Cast: - {castDisplay} - - )} - - - ); + return null; }; const styles = StyleSheet.create({ diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 152ddbb1..029e8414 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -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 = ({ onSeasonChange, onSelectEpisode, groupedEpisodes = {}, - metadata + metadata, + imdbId }) => { const { currentTheme } = useTheme(); const { settings } = useSettings(); @@ -180,6 +184,11 @@ const SeriesContentComponent: React.FC = ({ const [posterViewVisible, setPosterViewVisible] = useState(true); const [textViewVisible, setTextViewVisible] = useState(false); + // Episode action menu state + const [episodeActionMenuVisible, setEpisodeActionMenuVisible] = useState(false); + const [selectedEpisodeForAction, setSelectedEpisodeForAction] = useState(null); + const [markingAsWatched, setMarkingAsWatched] = useState(false); + // Add refs for the scroll views const seasonScrollViewRef = useRef(null); const episodeScrollViewRef = useRef>(null); @@ -517,6 +526,207 @@ const SeriesContentComponent: React.FC = ({ 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 ( @@ -543,7 +753,11 @@ const SeriesContentComponent: React.FC = ({ - 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 ( = ({ { color: currentTheme.colors.highEmphasis } ] ]} numberOfLines={1}> - Season {season} + {season === 0 ? 'Specials' : `Season ${season}`} @@ -723,7 +937,7 @@ const SeriesContentComponent: React.FC = ({ ] ]} > - Season {season} + {season === 0 ? 'Specials' : `Season ${season}`} @@ -822,6 +1036,8 @@ const SeriesContentComponent: React.FC = ({ } ]} onPress={() => onSelectEpisode(episode)} + onLongPress={() => handleEpisodeLongPress(episode)} + delayLongPress={400} activeOpacity={0.7} > = ({ } ]} onPress={() => onSelectEpisode(episode)} + onLongPress={() => handleEpisodeLongPress(episode)} + delayLongPress={400} activeOpacity={0.85} > {/* Solid outline replaces gradient border */} @@ -1434,6 +1652,205 @@ const SeriesContentComponent: React.FC = ({ ) )} + + {/* Episode Action Menu Modal */} + + + e.stopPropagation()} + > + {/* Header */} + + + {selectedEpisodeForAction ? `S${selectedEpisodeForAction.season_number}E${selectedEpisodeForAction.episode_number}` : ''} + + + {selectedEpisodeForAction?.name || ''} + + + + {/* Action buttons */} + + {/* Mark as Watched / Unwatched */} + {selectedEpisodeForAction && ( + isEpisodeWatched(selectedEpisodeForAction) ? ( + + + + {markingAsWatched ? 'Removing...' : 'Mark as Unwatched'} + + + ) : ( + + + + {markingAsWatched ? 'Marking...' : 'Mark as Watched'} + + + ) + )} + + {/* Mark Season as Watched / Unwatched */} + {isSeasonWatched() ? ( + + + + {markingAsWatched ? 'Removing...' : `Unmark Season ${selectedSeason}`} + + + ) : ( + + + + {markingAsWatched ? 'Marking...' : `Mark Season ${selectedSeason}`} + + + )} + + {/* Cancel */} + + + Cancel + + + + + + ); }; diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 7e430731..09583bc4 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -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) */} { > @@ -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) && ( + + {/* Dynamic Icon */} + + + + + {/* Text Label: Shows "Muted" or percentage */} + + {/* 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 + } + + + )} + {showPauseOverlay && ( { controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 120 : 100} /> - {/* Volume Overlay */} - {gestureControls.showVolumeOverlay && ( - - - - - {/* Horizontal Dotted Progress Bar */} - - {/* Dotted background */} - - {Array.from({ length: 16 }, (_, i) => ( - - ))} - - - {/* Progress fill */} - - - - - {Math.round(volume * 100)}% - - - - )} - - {/* Brightness Overlay */} - {gestureControls.showBrightnessOverlay && ( - - - - - {/* Horizontal Dotted Progress Bar */} - - {/* Dotted background */} - - {Array.from({ length: 16 }, (_, i) => ( - - ))} - - - {/* Progress fill */} - - - - - {Math.round(brightness * 100)}% - - - - )} - {/* Speed Activated Overlay */} {showSpeedActivatedOverlay && ( { fontWeight: '600', letterSpacing: 0.5, }}> - {holdToSpeedValue}x Speed Activated + {holdToSpeedValue}x Speed @@ -4183,4 +4064,36 @@ const AndroidVideoPlayer: React.FC = () => { ); }; -export default AndroidVideoPlayer; \ No newline at end of file +// 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; diff --git a/src/components/player/KSPlayerComponent.tsx b/src/components/player/KSPlayerComponent.tsx index 55d3cf8f..569ca064 100644 --- a/src/components/player/KSPlayerComponent.tsx +++ b/src/components/player/KSPlayerComponent.tsx @@ -130,7 +130,9 @@ const KSPlayer = forwardRef((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((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'); } diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index ede1699d..f201fda6 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -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 diff --git a/src/hooks/useFeaturedContent.ts b/src/hooks/useFeaturedContent.ts index 9326af1b..cd4c27c5 100644 --- a/src/hooks/useFeaturedContent.ts +++ b/src/hooks/useFeaturedContent.ts @@ -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(); + 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 => { + 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 => { - 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 => { + 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(); - 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); } } diff --git a/src/hooks/useGithubMajorUpdate.ts b/src/hooks/useGithubMajorUpdate.ts index af3b51f0..00e0e4ba 100644 --- a/src/hooks/useGithubMajorUpdate.ts +++ b/src/hooks/useGithubMajorUpdate.ts @@ -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(); 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(); diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index fd68c0c2..ac7192ee 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -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) { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index c0d97c2a..d893569f 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -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, diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index fa181e1d..4200e1cb 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -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(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})`); diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index 0a585f5b..b101bbab 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -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([]); const [continueWatching, setContinueWatching] = useState([]); const [ratedContent, setRatedContent] = useState([]); - const [lastAuthCheck, setLastAuthCheck] = useState(Date.now()); - + // State for real-time status tracking const [watchlistItems, setWatchlistItems] = useState>(new Set()); const [collectionItems, setCollectionItems] = useState>(new Set()); @@ -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(); const newCollectionItems = new Set(); - + // Add movies to sets watchlistMovies.forEach(item => { if (item.movie?.ids?.imdb) { newWatchlistItems.add(`movie:${item.movie.ids.imdb}`); } }); - + collectionMovies.forEach(item => { if (item.movie?.ids?.imdb) { newCollectionItems.add(`movie:${item.movie.ids.imdb}`); } }); - + // Add shows to sets watchlistShows.forEach(item => { if (item.show?.ids?.imdb) { newWatchlistItems.add(`show:${item.show.ids.imdb}`); } }); - + collectionShows.forEach(item => { if (item.show?.ids?.imdb) { newCollectionItems.add(`show:${item.show.ids.imdb}`); } }); - + setWatchlistItems(newWatchlistItems); setCollectionItems(newCollectionItems); } catch (error) { @@ -155,7 +153,7 @@ export function useTraktIntegration() { // Check if a movie is watched const isMovieWatched = useCallback(async (imdbId: string): Promise => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { // 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 => { 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[] = []; - + // 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 => { diff --git a/src/hooks/useUpdatePopup.ts b/src/hooks/useUpdatePopup.ts index b7caaea8..0c5c5468 100644 --- a/src/hooks/useUpdatePopup.ts +++ b/src/hooks/useUpdatePopup.ts @@ -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); } diff --git a/src/hooks/useWatchProgress.ts b/src/hooks/useWatchProgress.ts index 7e0f5a05..daa9e8fd 100644 --- a/src/hooks/useWatchProgress.ts +++ b/src/hooks/useWatchProgress.ts @@ -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(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, diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 142e53a7..ef1e3956 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -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', diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index 57a658d1..a81b4ebf 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -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[]); diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index aa74637f..59329800 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -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 = ({ route, navigation }) => { @@ -253,6 +285,11 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { }); const [mobileColumnsPref, setMobileColumnsPref] = useState<'auto' | 2 | 3>('auto'); const [nowPlayingMovies, setNowPlayingMovies] = useState>(new Set()); + // Filter state for catalog extra properties per protocol + const [catalogExtras, setCatalogExtras] = useState([]); + const [selectedFilters, setSelectedFilters] = useState>({}); + const [activeGenreFilter, setActiveGenreFilter] = useState(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 = ({ 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 = ({ 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 = ({ route, navigation }) => { type, id, dataSource, - genreFilter + activeGenreFilter }); try { if (shouldRefresh) { @@ -383,9 +435,9 @@ const CatalogScreen: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ route, navigation }) => { }); } } - + if (!foundItems) { InteractionManager.runAfterInteractions(() => { setError("No content found for the selected filters"); @@ -630,7 +685,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { }); }); } - }, [addonId, type, id, genreFilter, dataSource]); + }, [addonId, type, id, activeGenreFilter, dataSource]); useEffect(() => { loadItems(true, 1); @@ -641,6 +696,28 @@ const CatalogScreen: React.FC = ({ 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 = ({ 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 = ({ 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 ( = ({ route, navigation }) => { > @@ -739,9 +820,26 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { ) )} + + {/* Poster Title */} + {showTitles && ( + + {item.name} + + )} ); - }, [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 = () => ( @@ -787,7 +885,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { - navigation.goBack()} > @@ -806,7 +904,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { - navigation.goBack()} > @@ -824,7 +922,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { - navigation.goBack()} > @@ -833,7 +931,54 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { {displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} - + + {/* Filter chip bar - shows when catalog has filterable extras */} + {catalogExtras.length > 0 && ( + + + {catalogExtras.map(extra => ( + + {/* All option - clears filter */} + handleFilterChange(extra.name, undefined)} + > + All + + + {/* Filter options from catalog extra */} + {extra.options?.map(option => { + const isActive = extra.name === 'genre' + ? activeGenreFilter === option + : selectedFilters[extra.name] === option; + return ( + handleFilterChange(extra.name, option)} + > + + {option} + + + ); + })} + + ))} + + + )} + {items.length > 0 ? ( 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([]); const [groupedSettings, setGroupedSettings] = useState({}); 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(); - + 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 = () => { Catalogs - + {/* 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 = () => { Applies to phones only. Tablets keep adaptive layout. + + {/* Show Titles Toggle */} + + + Show Poster Titles + Display title text below each poster + + { + 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" + /> + )} @@ -596,9 +621,9 @@ const CatalogSettingsScreen = () => { {group.name.toUpperCase()} - + - toggleExpansion(addonId)} activeOpacity={0.7} @@ -608,14 +633,14 @@ const CatalogSettingsScreen = () => { {group.enabledCount} of {group.catalogs.length} enabled - - + {group.expanded && ( <> @@ -623,30 +648,30 @@ const CatalogSettingsScreen = () => { Long-press a catalog to rename {group.catalogs.map((setting, index) => ( - handleLongPress(setting)} // Added long press handler - style={({ pressed }) => [ - styles.catalogItem, - pressed && styles.catalogItemPressed, // Optional pressed style - ]} - > - - - {setting.customName || setting.name} {/* Display custom or default name */} - - - {setting.type.charAt(0).toUpperCase() + setting.type.slice(1)} - - - toggleCatalog(addonId, index)} - trackColor={{ false: '#505050', true: colors.primary }} - thumbColor={Platform.OS === 'android' ? colors.white : undefined} - ios_backgroundColor="#505050" - /> - + handleLongPress(setting)} // Added long press handler + style={({ pressed }) => [ + styles.catalogItem, + pressed && styles.catalogItemPressed, // Optional pressed style + ]} + > + + + {setting.customName || setting.name} {/* Display custom or default name */} + + + {setting.type.charAt(0).toUpperCase() + setting.type.slice(1)} + + + toggleCatalog(addonId, index)} + trackColor={{ false: '#505050', true: colors.primary }} + thumbColor={Platform.OS === 'android' ? colors.white : undefined} + ios_backgroundColor="#505050" + /> + ))} )} @@ -706,8 +731,8 @@ const CatalogSettingsScreen = () => { )} ) : ( - setIsRenameModalVisible(false)}> - e.stopPropagation()}> + setIsRenameModalVisible(false)}> + e.stopPropagation()}> Rename Catalog { Powered by diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index fbb2c6e4..e748dde6 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -135,7 +135,7 @@ const HomeScreen = () => { const [hasAddons, setHasAddons] = useState(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 = {}; 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)[] = []; - + // 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(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 ( - + { - + ); case 'welcome': return ; @@ -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 ( - + { 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({ }, placeholderPoster: { width: POSTER_WIDTH, - aspectRatio: 2/3, + aspectRatio: 2 / 3, borderRadius: 12, marginRight: 2, }, @@ -1203,7 +1251,7 @@ const styles = StyleSheet.create({ }, contentItem: { width: POSTER_WIDTH, - aspectRatio: 2/3, + aspectRatio: 2 / 3, margin: 0, borderRadius: 4, overflow: 'hidden', diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 83d11fd7..eaf75d3c 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -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 }) => ( - navigation.navigate('Metadata', { id: item.id, type: item.type })} - onLongPress={() => { - setSelectedItem(item); - setMenuVisible(true); - }} - activeOpacity={0.7} - > - - - - {item.watched && ( - - - - )} - {item.progress !== undefined && item.progress < 1 && ( - - - - )} - - {settings.showPosterTitles && ( + const renderItem = ({ item }: { item: LibraryItem }) => { + const aspectRatio = item.posterShape === 'landscape' ? 16 / 9 : (item.posterShape === 'square' ? 1 : 2 / 3); + + return ( + navigation.navigate('Metadata', { id: item.id, type: item.type })} + onLongPress={() => { + setSelectedItem(item); + setMenuVisible(true); + }} + activeOpacity={0.7} + > + + + + {item.watched && ( + + + + )} + {item.progress !== undefined && item.progress < 1 && ( + + + + )} + {item.name} - )} - - - ); + + + ); + }; const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => ( { return ( - @@ -1270,6 +1274,7 @@ const MetadataScreen: React.FC = () => { onSelectEpisode={handleEpisodeSelect} groupedEpisodes={groupedEpisodes} metadata={metadata || undefined} + imdbId={imdbId || undefined} /> ) : ( metadata && @@ -1417,7 +1422,7 @@ const MetadataScreen: React.FC = () => { isSpoilerRevealed={selectedComment ? revealedSpoilers.has(selectedComment.id.toString()) : false} onSpoilerPress={() => selectedComment && handleSpoilerPress(selectedComment)} /> - + ); }; diff --git a/src/screens/PluginsScreen.tsx b/src/screens/PluginsScreen.tsx index 9a08a264..f7e6cd17 100644 --- a/src/screens/PluginsScreen.tsx +++ b/src/screens/PluginsScreen.tsx @@ -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<{ {title} - {isExpanded && {children}} @@ -803,7 +817,7 @@ const StatusBadge: React.FC<{ }; const config = getStatusConfig(); - + return ( { 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(''); const [showboxScraperId, setShowboxScraperId] = useState(null); const [showboxTokenVisible, setShowboxTokenVisible] = useState(false); - + // Multiple repositories state const [repositories, setRepositories] = useState([]); const [currentRepositoryId, setCurrentRepositoryId] = useState(''); const [showAddRepositoryModal, setShowAddRepositoryModal] = useState(false); const [newRepositoryUrl, setNewRepositoryUrl] = useState(''); const [switchingRepository, setSwitchingRepository] = useState(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 ( - + {/* Header */} { Settings - + {/* Help Button */} { - + Plugins { Manage multiple scraper repositories. Switch between repositories to access different sets of scrapers. - + {/* Current Repository */} {currentRepositoryId && ( @@ -1438,7 +1452,7 @@ const PluginsScreen: React.FC = () => { {repositoryUrl} )} - + {/* Repository List */} {repositories.length > 0 && ( @@ -1467,8 +1481,8 @@ const PluginsScreen: React.FC = () => { {repo.url} {repo.scraperCount || 0} scrapers • Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'} - - + + {repo.id !== currentRepositoryId && ( { Remove - + ))} )} @@ -1541,9 +1555,9 @@ const PluginsScreen: React.FC = () => { {searchQuery.length > 0 && ( setSearchQuery('')}> - - )} - + + )} + {/* Filter Chips */} @@ -1561,7 +1575,7 @@ const PluginsScreen: React.FC = () => { selectedFilter === filter && styles.filterChipTextSelected ]}> {filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'} - + ))} @@ -1590,17 +1604,17 @@ const PluginsScreen: React.FC = () => { {filteredScrapers.length === 0 ? ( - {searchQuery ? 'No Scrapers Found' : 'No Scrapers Available'} - + - {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 = () => { Clear Search )} - - ) : ( - + + ) : ( + {filteredScrapers.map((scraper) => ( - {scraper.logo ? ( - (scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? ( - - ) : ( - - ) - ) : ( + {scraper.logo ? ( + (scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? ( + + ) : ( + + ) + ) : ( )} {scraper.name} - - {scraper.description} - - 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'))} - /> - + {scraper.description} + + 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'))} + /> + + @@ -1682,62 +1696,62 @@ const PluginsScreen: React.FC = () => { {/* ShowBox Settings - only visible when ShowBox scraper is available */} - {showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && ( + {showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && ( - ShowBox UI Token - - 0 && !showboxTokenVisible} - multiline={false} - numberOfLines={1} - /> - {showboxSavedToken.length > 0 && ( - setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}> - - - )} - - - {showboxUiToken !== showboxSavedToken && ( - { - if (showboxScraperId) { - await pluginService.setScraperSettings(showboxScraperId, { uiToken: showboxUiToken }); - } - setShowboxSavedToken(showboxUiToken); - openAlert('Saved', 'ShowBox settings updated'); - }} - > - Save - - )} - { - setShowboxUiToken(''); - setShowboxSavedToken(''); - if (showboxScraperId) { - await pluginService.setScraperSettings(showboxScraperId, {}); - } - }} - > - Clear - - - - )} - + ShowBox UI Token + + 0 && !showboxTokenVisible} + multiline={false} + numberOfLines={1} + /> + {showboxSavedToken.length > 0 && ( + setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}> + + + )} + + + {showboxUiToken !== showboxSavedToken && ( + { + if (showboxScraperId) { + await pluginService.setScraperSettings(showboxScraperId, { uiToken: showboxUiToken }); + } + setShowboxSavedToken(showboxUiToken); + openAlert('Saved', 'ShowBox settings updated'); + }} + > + Save + + )} + { + setShowboxUiToken(''); + setShowboxSavedToken(''); + if (showboxScraperId) { + await pluginService.setScraperSettings(showboxScraperId, {}); + } + }} + > + Clear + + + + )} + ))} - - )} + + )} {/* Additional Settings */} @@ -1763,7 +1777,7 @@ const PluginsScreen: React.FC = () => { disabled={!settings.enableLocalScrapers} /> - + Group Plugin Streams @@ -1772,20 +1786,20 @@ const PluginsScreen: React.FC = () => { { - 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} + /> - + Sort by Quality First @@ -1794,14 +1808,14 @@ const PluginsScreen: React.FC = () => { 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'} + /> - + Show Scraper Logos @@ -1810,12 +1824,12 @@ const PluginsScreen: React.FC = () => { 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} + /> @@ -1830,7 +1844,7 @@ const PluginsScreen: React.FC = () => { Exclude specific video qualities from search results. Tap on a quality to exclude it from plugin results. - + {qualityOptions.map((quality) => { const isExcluded = (settings.excludedQualities || []).includes(quality); @@ -1856,7 +1870,7 @@ const PluginsScreen: React.FC = () => { ); })} - + {(settings.excludedQualities || []).length > 0 && ( Excluded qualities: {(settings.excludedQualities || []).join(', ')} @@ -1875,11 +1889,11 @@ const PluginsScreen: React.FC = () => { Exclude specific languages from search results. Tap on a language to exclude it from plugin results. - + Note: This filter only applies to providers that include language information in their stream names. It does not affect other providers. - + {languageOptions.map((language) => { const isExcluded = (settings.excludedLanguages || []).includes(language); @@ -1905,7 +1919,7 @@ const PluginsScreen: React.FC = () => { ); })} - + {(settings.excludedLanguages || []).length > 0 && ( Excluded languages: {(settings.excludedLanguages || []).join(', ')} @@ -1988,36 +2002,36 @@ const PluginsScreen: React.FC = () => { /> - {/* Format Hint */} - - Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch - + {/* Format Hint */} + + Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch + - {/* Action Buttons */} - - { - setShowAddRepositoryModal(false); - setNewRepositoryUrl(''); - }} - > - Cancel - + {/* Action Buttons */} + + { + setShowAddRepositoryModal(false); + setNewRepositoryUrl(''); + }} + > + Cancel + - - {isLoading ? ( - - ) : ( - Add - )} - - - + + {isLoading ? ( + + ) : ( + Add + )} + + + diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 13d9a5cc..82a27115 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -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>({}); // 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(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 = {}; + 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 = {}; - 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 ( - Recent Searches {recentSearches.map((search, index) => ( - { setQuery(search); Keyboard.dismiss(); }} - entering={FadeIn.duration(300).delay(index * 50)} > { > - + ))} - + ); }; @@ -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 ( - { 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} > @@ -634,7 +716,7 @@ const SearchScreen = () => { {item.year} )} - + ); }; @@ -664,7 +746,7 @@ const SearchScreen = () => { ); return ( - + {/* Addon Header */} @@ -679,7 +761,7 @@ const SearchScreen = () => { {/* Movies */} {movieResults.length > 0 && ( - + { showsHorizontalScrollIndicator={false} contentContainerStyle={styles.horizontalListContent} /> - + )} {/* TV Shows */} {seriesResults.length > 0 && ( - + { showsHorizontalScrollIndicator={false} contentContainerStyle={styles.horizontalListContent} /> - + )} {/* Other types */} {otherResults.length > 0 && ( - + { showsHorizontalScrollIndicator={false} contentContainerStyle={styles.horizontalListContent} /> - + )} - + ); }, (prev, next) => { // Only re-render if this section's reference changed @@ -804,13 +886,8 @@ const SearchScreen = () => { }, []); return ( - { /> ) : query.trim().length === 1 ? ( - { Type at least 2 characters to search - + ) : searched && !hasResultsToShow ? ( - { Try different keywords or check your spelling - + ) : ( - {!query.trim() && renderRecentSearches()} @@ -935,7 +1009,7 @@ const SearchScreen = () => { addonIndex={addonIndex} /> ))} - + )} {/* DropUpMenu integration for search results */} @@ -981,7 +1055,7 @@ const SearchScreen = () => { }} /> )} - + ); }; diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 791545dd..bb232704 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -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 }) => { + // 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 | undefined): Record | undefined => { if (!headers) return undefined; // Only keep essential headers for Vidrock const essentialHeaders: Record = {}; + // @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(() => { diff --git a/src/screens/UpdateScreen.tsx b/src/screens/UpdateScreen.tsx index f3345e87..92735428 100644 --- a/src/screens/UpdateScreen.tsx +++ b/src/screens/UpdateScreen.tsx @@ -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 = ({ children, title, isTablet = false }) => { const { currentTheme } = useTheme(); - + return ( - { const [updateProgress, setUpdateProgress] = useState(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 } ]}> - + { Settings - + {/* Empty for now, but ready for future actions */} - + App Updates - - - - {/* Main Update Card */} - - {/* Status Section */} - - - {getStatusIcon()} - - - - {getStatusText()} - - - {lastOperation || 'Ready to check for updates'} - - + + + + {/* Main Update Card */} + + {/* Status Section */} + + + {getStatusIcon()} - - {/* Progress Section */} - {(updateStatus === 'downloading' || updateStatus === 'installing') && ( - - - - {updateStatus === 'downloading' ? 'Downloading' : 'Installing'} - - - {Math.round(updateProgress)}% - - - - - - - )} - - {/* Action Section */} - - - {isChecking ? ( - - ) : ( - - )} - - {isChecking ? 'Checking...' : 'Check for Updates'} - - - - {updateInfo?.isAvailable && updateStatus !== 'success' && ( - - {isInstalling ? ( - - ) : ( - - )} - - {isInstalling ? 'Installing...' : 'Install Update'} - - - )} - + + + {getStatusText()} + + + {lastOperation || 'Ready to check for updates'} + - {/* Release Notes */} - {updateInfo?.isAvailable && !!getReleaseNotes() && ( - - - - - - Release notes: + {/* Progress Section */} + {(updateStatus === 'downloading' || updateStatus === 'installing') && ( + + + + {updateStatus === 'downloading' ? 'Downloading' : 'Installing'} + + + {Math.round(updateProgress)}% + + + + - {getReleaseNotes()} )} - {/* Info Section */} - - - - - - Version: - - {updateInfo?.manifest?.id ? `${updateInfo.manifest.id.substring(0, 8)}...` : 'Unknown'} + {/* Action Section */} + + + {isChecking ? ( + + ) : ( + + )} + + {isChecking ? 'Checking...' : 'Check for Updates'} - - - {lastChecked && ( - - - - - Last checked: - - {formatDate(lastChecked)} - - - )} - + - {/* Current Version Section */} - - - - - - Current version: - - {currentInfo?.manifest?.id || (currentInfo?.isEmbeddedLaunch === false ? 'Unknown' : 'Embedded')} - - - - {!!getCurrentReleaseNotes() && ( - - - - - - Current release notes: - - - {getCurrentReleaseNotes()} - - - )} - - - {/* Developer Logs removed */} - - - {/* GitHub Release (compact) – only show when update is available */} - {github.latestTag && isAnyUpgrade(getDisplayedAppVersion(), github.latestTag) ? ( - - - - - - - Current: - - {getDisplayedAppVersion()} - - - - - - - - Latest: - - {github.latestTag} - - - - {github.releaseNotes ? ( - - Notes: - - {github.releaseNotes} - - - ) : null} - - - - github.releaseUrl ? Linking.openURL(github.releaseUrl as string) : null} - activeOpacity={0.8} - > - - View Release - - - - - - ) : null} - - {false && ( - - - - - Update Service Logs - - - - - - - - - {/* Test log removed */} - {/* Copy all logs removed */} - {/* Refresh logs removed */} - {/* Clear logs removed */} - - - - - {false ? ( - No logs available + {isInstalling ? ( + ) : ( - ([] as string[]).map((log, index) => { - const isError = log.indexOf('[ERROR]') !== -1; - const isWarning = log.indexOf('[WARN]') !== -1; - - return ( - {}} - activeOpacity={0.7} - > - - - {log} - - - - - ); - }) + )} - + + {isInstalling ? 'Installing...' : 'Install Update'} + + + )} + + + + + {/* Release Notes */} + {updateInfo?.isAvailable && !!getReleaseNotes() && ( + + + + + + Release notes: - + {getReleaseNotes()} + )} - - + + {/* Info Section */} + + + + + + Version: + + {updateInfo?.manifest?.id ? `${updateInfo.manifest.id.substring(0, 8)}...` : 'Unknown'} + + + + {lastChecked && ( + + + + + Last checked: + + {formatDate(lastChecked)} + + + )} + + + {/* Current Version Section */} + + + + + + Current version: + + {currentInfo?.manifest?.id || (currentInfo?.isEmbeddedLaunch === false ? 'Unknown' : 'Embedded')} + + + + {!!getCurrentReleaseNotes() && ( + + + + + + Current release notes: + + + {getCurrentReleaseNotes()} + + + )} + + + {/* Developer Logs removed */} + + + {/* GitHub Release (compact) – only show when update is available */} + {github.latestTag && isAnyUpgrade(getDisplayedAppVersion(), github.latestTag) ? ( + + + + + + + Current: + + {getDisplayedAppVersion()} + + + + + + + + Latest: + + {github.latestTag} + + + + {github.releaseNotes ? ( + + Notes: + + {github.releaseNotes} + + + ) : null} + + + + github.releaseUrl ? Linking.openURL(github.releaseUrl as string) : null} + activeOpacity={0.8} + > + + View Release + + + + + + ) : null} + + {/* Update Notification Settings */} + + {/* OTA Updates Toggle */} + + + + OTA Update Alerts + + + Show notifications for over-the-air updates + + + + + + {/* Major Updates Toggle */} + + + + Major Update Alerts + + + Show notifications for new app versions on GitHub + + + + + + {/* Warning note */} + + + + + + Keeping alerts enabled ensures you receive bug fixes and can provide accurate crash reports. + + + + + {false && ( + + + + + Update Service Logs + + + + + + + + + {/* Test log removed */} + {/* Copy all logs removed */} + {/* Refresh logs removed */} + {/* Clear logs removed */} + + + + + {false ? ( + No logs available + ) : ( + ([] as string[]).map((log, index) => { + const isError = log.indexOf('[ERROR]') !== -1; + const isWarning = log.indexOf('[WARN]') !== -1; + + return ( + { }} + activeOpacity={0.7} + > + + + {log} + + + + + ); + }) + )} + + + + )} + + { + 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 { + 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(); - 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(); + 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 { + // 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, diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 40c789f8..378585e5 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -20,6 +20,7 @@ export interface Meta { type: string; name: string; poster?: string; + posterShape?: 'poster' | 'square' | 'landscape'; // For variable aspect ratios background?: string; logo?: string; description?: string; @@ -61,33 +62,77 @@ export interface Meta { } export interface Subtitle { - id: string; + id: string; // Required per protocol url: string; lang: string; fps?: number; addon?: string; addonName?: string; - format?: 'srt' | 'vtt' | 'ass' | 'ssa'; // Format hint + format?: 'srt' | 'vtt' | 'ass' | 'ssa'; +} + +// Source object for archive streams per protocol +export interface SourceObject { + url: string; + bytes?: number; } export interface Stream { - name?: string; - title?: string; - url: string; + // 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; - description?: string; - infoHash?: string; - fileIdx?: number; - behaviorHints?: { - bingeGroup?: string; - notWebReady?: boolean; - [key: string]: any; - }; + + // Stream properties size?: number; isFree?: boolean; isDebrid?: boolean; + quality?: string; + headers?: Record; + + // 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; + response?: Record; + }; + videoHash?: string; // OpenSubtitles hash + videoSize?: number; // Video file size in bytes + filename?: string; // Video filename + [key: string]: any; + }; } export interface StreamResponse { @@ -119,6 +164,16 @@ interface Catalog { extraSupported?: string[]; extraRequired?: string[]; itemCount?: number; + // Per Stremio protocol - extra properties for filtering + extra?: CatalogExtra[]; +} + +// Extra property definition per protocol +export interface CatalogExtra { + name: string; // Property name (e.g., 'genre', 'search', 'skip') + isRequired?: boolean; // If true, must always be provided + options?: string[]; // Available options (e.g., genre list) + optionsLimit?: number; // Max selections allowed (default 1) } interface ResourceObject { @@ -143,7 +198,32 @@ export interface Manifest { queryParams?: string; behaviorHints?: { configurable?: boolean; + configurationRequired?: boolean; // Per protocol + adult?: boolean; // Adult content flag + p2p?: boolean; // P2P content flag }; + config?: ConfigObject[]; // User configuration + addonCatalogs?: Catalog[]; // Addon catalogs + background?: string; // Background image URL + logo?: string; // Logo URL + contactEmail?: string; // Contact email +} + +// Config object for addon configuration per protocol +interface ConfigObject { + key: string; + type: 'text' | 'number' | 'password' | 'checkbox' | 'select'; + default?: string; + title?: string; + options?: string[]; + required?: boolean; +} + +// Meta Link object per protocol +export interface MetaLink { + name: string; + category: string; // 'actor', 'director', 'writer', etc. + url: string; // External URL or stremio:/// deep link } export interface MetaDetails extends Meta { @@ -153,7 +233,13 @@ export interface MetaDetails extends Meta { released: string; season?: number; episode?: number; + thumbnail?: string; + streams?: Stream[]; // Embedded streams (used by PPV-style addons) + available?: boolean; // Availability flag per protocol + overview?: string; // Episode summary per protocol + trailers?: Stream[]; // Trailer streams per protocol }[]; + links?: MetaLink[]; // Actor/Director/Genre links per protocol } export interface AddonCapabilities { @@ -180,7 +266,7 @@ class StremioService { private readonly STORAGE_KEY = 'stremio-addons'; private readonly ADDON_ORDER_KEY = 'stremio-addon-order'; private readonly MAX_CONCURRENT_REQUESTS = 3; - private readonly DEFAULT_PAGE_SIZE = 50; + private readonly DEFAULT_PAGE_SIZE = 100; // Protocol standard page size private initialized: boolean = false; private initializationPromise: Promise | null = null; private catalogHasMore: Map = new Map(); @@ -194,26 +280,26 @@ class StremioService { public async isValidContentId(type: string, id: string | null | undefined): Promise { // Ensure addons are initialized before checking types await this.ensureInitialized(); - + // Get all supported types from installed addons const supportedTypes = this.getAllSupportedTypes(); const isValidType = supportedTypes.includes(type); - + const lowerId = (id || '').toLowerCase(); const isNullishId = !id || lowerId === 'null' || lowerId === 'undefined'; const providerLikeIds = new Set(['moviebox', 'torbox']); const isProviderSlug = providerLikeIds.has(lowerId); if (!isValidType || isNullishId || isProviderSlug) return false; - + // Get all supported ID prefixes from installed addons const supportedPrefixes = this.getAllSupportedIdPrefixes(type); - + // If no addons declare specific prefixes, allow any non-empty string if (supportedPrefixes.length === 0) { return true; } - + // Check if the ID matches any supported prefix return supportedPrefixes.some(prefix => lowerId.startsWith(prefix.toLowerCase())); } @@ -222,13 +308,13 @@ class StremioService { public getAllSupportedTypes(): string[] { const addons = this.getInstalledAddons(); const types = new Set(); - + for (const addon of addons) { // Check addon-level types if (addon.types && Array.isArray(addon.types)) { addon.types.forEach(type => types.add(type)); } - + // Check resource-level types if (addon.resources && Array.isArray(addon.resources)) { for (const resource of addon.resources) { @@ -240,7 +326,7 @@ class StremioService { } } } - + // Check catalog-level types if (addon.catalogs && Array.isArray(addon.catalogs)) { for (const catalog of addon.catalogs) { @@ -250,7 +336,7 @@ class StremioService { } } } - + return Array.from(types); } @@ -258,13 +344,13 @@ class StremioService { public getAllSupportedIdPrefixes(type: string): string[] { const addons = this.getInstalledAddons(); const prefixes = new Set(); - + for (const addon of addons) { // Check addon-level idPrefixes if (addon.idPrefixes && Array.isArray(addon.idPrefixes)) { addon.idPrefixes.forEach(prefix => prefixes.add(prefix)); } - + // Check resource-level idPrefixes if (addon.resources && Array.isArray(addon.resources)) { for (const resource of addon.resources) { @@ -280,34 +366,34 @@ class StremioService { } } } - + return Array.from(prefixes); } // Check if a content ID belongs to a collection addon public isCollectionContent(id: string): { isCollection: boolean; addon?: Manifest } { const addons = this.getInstalledAddons(); - + for (const addon of addons) { // Check if this addon supports collections - const supportsCollections = addon.types?.includes('collections') || - addon.catalogs?.some(catalog => catalog.type === 'collections'); - + const supportsCollections = addon.types?.includes('collections') || + addon.catalogs?.some(catalog => catalog.type === 'collections'); + if (!supportsCollections) continue; - + // Check if our ID matches this addon's prefixes const addonPrefixes = addon.idPrefixes || []; const resourcePrefixes = addon.resources ?.filter(resource => typeof resource === 'object' && resource !== null && 'name' in resource) ?.filter(resource => (resource as any).name === 'meta' || (resource as any).name === 'catalog') ?.flatMap(resource => (resource as any).idPrefixes || []) || []; - + const allPrefixes = [...addonPrefixes, ...resourcePrefixes]; if (allPrefixes.some(prefix => id.startsWith(prefix))) { return { isCollection: true, addon }; } } - + return { isCollection: false }; } @@ -320,17 +406,17 @@ class StremioService { private async initialize(): Promise { if (this.initialized) return; - + try { const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; // Prefer scoped storage, but fall back to legacy keys to preserve older installs let storedAddons = await mmkvStorage.getItem(`@user:${scope}:${this.STORAGE_KEY}`); if (!storedAddons) storedAddons = await mmkvStorage.getItem(this.STORAGE_KEY); if (!storedAddons) storedAddons = await mmkvStorage.getItem(`@user:local:${this.STORAGE_KEY}`); - + if (storedAddons) { const parsed = JSON.parse(storedAddons); - + // Convert to Map this.installedAddons = new Map(); for (const addon of parsed) { @@ -339,11 +425,11 @@ class StremioService { } } } - + // Install Cinemeta for new users, but allow existing users to uninstall it const cinemetaId = 'com.linvo.cinemeta'; const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId); - + if (!this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemeta) { try { const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json'); @@ -395,7 +481,7 @@ class StremioService { // Install OpenSubtitles v3 by default unless user has explicitly removed it const opensubsId = 'org.stremio.opensubtitlesv3'; const hasUserRemovedOpenSubtitles = await this.hasUserRemovedAddon(opensubsId); - + if (!this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitles) { try { const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json'); @@ -424,7 +510,7 @@ class StremioService { this.installedAddons.set(opensubsId, fallbackManifest); } } - + // Load addon order if exists (scoped first, then legacy, then @user:local for migration safety) let storedOrder = await mmkvStorage.getItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`); if (!storedOrder) storedOrder = await mmkvStorage.getItem(this.ADDON_ORDER_KEY); @@ -434,28 +520,28 @@ class StremioService { // Filter out any ids that aren't in installedAddons this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id)); } - + // Add Cinemeta to order only if user hasn't removed it const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId); if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemetaOrder) { this.addonOrder.push(cinemetaId); } - + // Only add OpenSubtitles to order if user hasn't removed it const hasUserRemovedOpenSubtitlesOrder = await this.hasUserRemovedAddon(opensubsId); if (!this.addonOrder.includes(opensubsId) && this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitlesOrder) { this.addonOrder.push(opensubsId); } - + // Add any missing addons to the order const installedIds = Array.from(this.installedAddons.keys()); const missingIds = installedIds.filter(id => !this.addonOrder.includes(id)); this.addonOrder = [...this.addonOrder, ...missingIds]; - + // Ensure order and addons are saved await this.saveAddonOrder(); await this.saveInstalledAddons(); - + this.initialized = true; } catch (error) { // Initialize with empty state on error @@ -479,12 +565,12 @@ class StremioService { return await request(); } catch (error: any) { lastError = error; - + // Don't retry on 404 errors (content not found) - these are expected for some content if (error.response?.status === 404) { throw error; } - + // Only log warnings for non-404 errors to reduce noise if (error.response?.status !== 404) { logger.warn(`Request failed (attempt ${attempt + 1}/${retries + 1}):`, { @@ -494,7 +580,7 @@ class StremioService { status: error.response?.status, }); } - + if (attempt < retries) { const backoffDelay = delay * Math.pow(2, attempt); logger.log(`Retrying in ${backoffDelay}ms...`); @@ -535,25 +621,25 @@ class StremioService { async getManifest(url: string): Promise { try { // Clean up URL - ensure it ends with manifest.json - const manifestUrl = url.endsWith('manifest.json') - ? url + const manifestUrl = url.endsWith('manifest.json') + ? url : `${url.replace(/\/$/, '')}/manifest.json`; - + const response = await this.retryRequest(async () => { return await axios.get(manifestUrl); }); - + const manifest = response.data; - + // Add some extra fields for internal use manifest.originalUrl = url; manifest.url = url.replace(/manifest\.json$/, ''); - + // Ensure ID exists if (!manifest.id) { manifest.id = this.formatId(url); } - + return manifest; } catch (error) { logger.error(`Failed to fetch manifest from ${url}:`, error); @@ -565,16 +651,16 @@ class StremioService { const manifest = await this.getManifest(url); if (manifest && manifest.id) { this.installedAddons.set(manifest.id, manifest); - + // If addon was previously removed by user, unmark it on reinstall and clean up await this.unmarkAddonAsRemovedByUser(manifest.id); await this.cleanupRemovedAddonFromStorage(manifest.id); - + // Add to order if not already present (new addons go to the end) if (!this.addonOrder.includes(manifest.id)) { this.addonOrder.push(manifest.id); } - + await this.saveInstalledAddons(); await this.saveAddonOrder(); // Emit an event that an addon was added @@ -641,7 +727,7 @@ class StremioService { const removedAddons = await mmkvStorage.getItem('user_removed_addons'); let removedList = removedAddons ? JSON.parse(removedAddons) : []; if (!Array.isArray(removedList)) removedList = []; - + if (!removedList.includes(addonId)) { removedList.push(addonId); await mmkvStorage.setItem('user_removed_addons', JSON.stringify(removedList)); @@ -656,10 +742,10 @@ class StremioService { try { const removedAddons = await mmkvStorage.getItem('user_removed_addons'); if (!removedAddons) return; - + let removedList = JSON.parse(removedAddons); if (!Array.isArray(removedList)) return; - + const updatedList = removedList.filter(id => id !== addonId); await mmkvStorage.setItem('user_removed_addons', JSON.stringify(updatedList)); } catch (error) { @@ -671,14 +757,14 @@ class StremioService { private async cleanupRemovedAddonFromStorage(addonId: string): Promise { try { const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; - + // Remove from all possible addon order storage keys const keys = [ `@user:${scope}:${this.ADDON_ORDER_KEY}`, this.ADDON_ORDER_KEY, `@user:local:${this.ADDON_ORDER_KEY}` ]; - + for (const key of keys) { const storedOrder = await mmkvStorage.getItem(key); if (storedOrder) { @@ -701,12 +787,12 @@ class StremioService { async getAllCatalogs(): Promise<{ [addonId: string]: Meta[] }> { const result: { [addonId: string]: Meta[] } = {}; const addons = this.getInstalledAddons(); - + const promises = addons.map(async (addon) => { if (!addon.catalogs || addon.catalogs.length === 0) return; - + const catalog = addon.catalogs[0]; // Just take the first catalog for now - + try { const items = await this.getCatalog(addon, catalog.type, catalog.id); if (items.length > 0) { @@ -716,7 +802,7 @@ class StremioService { logger.error(`Failed to fetch catalog from ${addon.name}:`, error); } }); - + await Promise.all(promises); return result; } @@ -724,53 +810,100 @@ class StremioService { private getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } { // Extract query parameters if they exist const [baseUrl, queryString] = url.split('?'); - + // Remove trailing manifest.json and slashes let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, ''); - + // Ensure URL has protocol if (!cleanBaseUrl.startsWith('http')) { cleanBaseUrl = `https://${cleanBaseUrl}`; } - + return { baseUrl: cleanBaseUrl, queryParams: queryString }; } async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise { - // Build URLs (path-style skip and query-style skip) and try both for broad addon support + // Build URLs per Stremio protocol: /{resource}/{type}/{id}/{extraArgs}.json + // Extra args (search, genre, skip) go in path segment, NOT query params const encodedId = encodeURIComponent(id); const pageSkip = (page - 1) * this.DEFAULT_PAGE_SIZE; - const filterQuery = (filters || []) - .filter(f => f && f.value) - .map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`) - .join(''); - + // For all addons if (!manifest.url) { throw new Error('Addon URL is missing'); } - - try { - const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url); - // Candidate 1: Path-style skip URL: /catalog/{type}/{id}/skip={N}.json - const urlPathStyle = `${baseUrl}/catalog/${type}/${encodedId}/skip=${pageSkip}.json${queryParams ? `?${queryParams}` : ''}`; - // Add filters to path style (append with & or ? based on presence of queryParams) - const urlPathWithFilters = urlPathStyle + (urlPathStyle.includes('?') ? filterQuery : (filterQuery ? `?${filterQuery.slice(1)}` : '')); - // Candidate 2: Query-style skip URL: /catalog/{type}/{id}.json?skip={N}&limit={PAGE_SIZE} + try { + if (__DEV__) console.log(`šŸ” [getCatalog] Manifest URL for ${manifest.name}: ${manifest.url}`); + const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url); + + // Build extraArgs as combined path segment per protocol + // Format: /catalog/{type}/{id}/{extraArgs}.json where extraArgs is like "genre=Action&skip=100" + const extraParts: string[] = []; + + // Add filters to extra args (genre, search, etc.) + if (filters && filters.length > 0) { + filters.filter(f => f && f.value).forEach(f => { + extraParts.push(`${encodeURIComponent(f.title)}=${encodeURIComponent(f.value)}`); + }); + } + + // Add skip for pagination (only if not page 1) + if (pageSkip > 0) { + extraParts.push(`skip=${pageSkip}`); + } + + // Build the extraArgs path segment + const extraArgsPath = extraParts.length > 0 ? `/${extraParts.join('&')}` : ''; + + // Construct URLs per protocol + // Primary: Path-style with extra args in path segment + const urlPathStyle = `${baseUrl}/catalog/${type}/${encodedId}${extraArgsPath}.json${queryParams ? `?${queryParams}` : ''}`; + + // Fallback for page 1 without filters: simple URL + const urlSimple = `${baseUrl}/catalog/${type}/${encodedId}.json${queryParams ? `?${queryParams}` : ''}`; + + // Legacy fallback: Query-style URL (for older addons) + const legacyFilterQuery = (filters || []) + .filter(f => f && f.value) + .map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`) + .join(''); let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${this.DEFAULT_PAGE_SIZE}`; if (queryParams) urlQueryStyle += `&${queryParams}`; - urlQueryStyle += filterQuery; + urlQueryStyle += legacyFilterQuery; - // Try path-style first, then fallback to query-style + // Try URLs in order of compatibility let response; try { - response = await this.retryRequest(async () => axios.get(urlPathWithFilters)); + // For page 1 without filters, try simple URL first (best compatibility) + if (pageSkip === 0 && extraParts.length === 0) { + if (__DEV__) console.log(`šŸ” [getCatalog] Trying simple URL for ${manifest.name}: ${urlSimple}`); + response = await this.retryRequest(async () => axios.get(urlSimple)); + // Check if we got valid metas - if empty, try other styles + if (!response?.data?.metas || response.data.metas.length === 0) { + throw new Error('Empty response from simple URL'); + } + } else { + throw new Error('Has extra args, use path-style'); + } } catch (e) { try { - response = await this.retryRequest(async () => axios.get(urlQueryStyle)); + // Try path-style URL (correct per protocol) + if (__DEV__) console.log(`šŸ” [getCatalog] Trying path-style URL for ${manifest.name}: ${urlPathStyle}`); + response = await this.retryRequest(async () => axios.get(urlPathStyle)); + // Check if we got valid metas - if empty, try query-style + if (!response?.data?.metas || response.data.metas.length === 0) { + throw new Error('Empty response from path-style URL'); + } } catch (e2) { - throw e2; + try { + // Try legacy query-style URL as last resort + if (__DEV__) console.log(`šŸ” [getCatalog] Trying query-style URL for ${manifest.name}: ${urlQueryStyle}`); + response = await this.retryRequest(async () => axios.get(urlQueryStyle)); + } catch (e3) { + if (__DEV__) console.log(`āŒ [getCatalog] All URL styles failed for ${manifest.name}`); + throw e3; + } } } @@ -779,7 +912,7 @@ class StremioService { try { const key = `${manifest.id}|${type}|${id}`; if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore); - } catch {} + } catch { } if (response.data.metas && Array.isArray(response.data.metas)) { return response.data.metas; } @@ -800,13 +933,13 @@ class StremioService { try { // Validate content ID first const isValidId = await this.isValidContentId(type, id); - + if (!isValidId) { return null; } - + const addons = this.getInstalledAddons(); - + // If a preferred addon is specified, try it first if (preferredAddonId) { const preferredAddon = addons.find(addon => addon.id === preferredAddonId); @@ -820,14 +953,14 @@ class StremioService { // Check if addon supports meta resource for this type let hasMetaSupport = false; let supportsIdPrefix = false; - + for (const resource of preferredAddon.resources) { // Check if the current element is a ResourceObject if (typeof resource === 'object' && resource !== null && 'name' in resource) { const typedResource = resource as ResourceObject; - if (typedResource.name === 'meta' && - Array.isArray(typedResource.types) && - typedResource.types.includes(type)) { + if (typedResource.name === 'meta' && + Array.isArray(typedResource.types) && + typedResource.types.includes(type)) { hasMetaSupport = true; // Check idPrefix support if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { @@ -837,7 +970,7 @@ class StremioService { } break; } - } + } // Check if the element is the simple string "meta" AND the addon has a top-level types array else if (typeof resource === 'string' && resource === 'meta' && preferredAddon.types) { if (Array.isArray(preferredAddon.types) && preferredAddon.types.includes(type)) { @@ -852,19 +985,19 @@ class StremioService { } } } - - + + // Only require ID prefix compatibility if the addon has declared specific prefixes const requiresIdPrefix = preferredAddon.idPrefixes && preferredAddon.idPrefixes.length > 0; const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix); - + if (isSupported) { try { const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); }); - - + + if (response.data && response.data.meta) { return response.data.meta; } else { @@ -876,25 +1009,25 @@ class StremioService { } } } - + // Try Cinemeta with different base URLs const cinemetaUrls = [ 'https://v3-cinemeta.strem.io', 'http://v3-cinemeta.strem.io' ]; - - + + for (const baseUrl of cinemetaUrls) { try { const encodedId = encodeURIComponent(id); const url = `${baseUrl}/meta/${type}/${encodedId}.json`; - + const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); }); - - + + if (response.data && response.data.meta) { return response.data.meta; } else { @@ -907,18 +1040,18 @@ class StremioService { // If Cinemeta fails, try other addons (excluding the preferred one already tried) for (const addon of addons) { if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) continue; - + // Check if addon supports meta resource for this type AND idPrefix (handles both string and object formats) let hasMetaSupport = false; let supportsIdPrefix = false; - + for (const resource of addon.resources) { // Check if the current element is a ResourceObject if (typeof resource === 'object' && resource !== null && 'name' in resource) { const typedResource = resource as ResourceObject; - if (typedResource.name === 'meta' && - Array.isArray(typedResource.types) && - typedResource.types.includes(type)) { + if (typedResource.name === 'meta' && + Array.isArray(typedResource.types) && + typedResource.types.includes(type)) { hasMetaSupport = true; // Match idPrefixes if present; otherwise assume support if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { @@ -928,7 +1061,7 @@ class StremioService { } break; } - } + } // Check if the element is the simple string "meta" AND the addon has a top-level types array else if (typeof resource === 'string' && resource === 'meta' && addon.types) { if (Array.isArray(addon.types) && addon.types.includes(type)) { @@ -943,28 +1076,28 @@ class StremioService { } } } - + // Require meta support, but allow any ID if addon doesn't declare specific prefixes - + // Only require ID prefix compatibility if the addon has declared specific prefixes const requiresIdPrefix = addon.idPrefixes && addon.idPrefixes.length > 0; const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix); - + if (!isSupported) { continue; } - + try { const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || ''); const encodedId = encodeURIComponent(id); const url = queryParams ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/meta/${type}/${encodedId}.json`; - + const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); }); - - + + if (response.data && response.data.meta) { return response.data.meta; } else { @@ -973,7 +1106,7 @@ class StremioService { continue; // Try next addon } } - + return null; } catch (error) { logger.error('Error in getMetaDetails:', error); @@ -986,8 +1119,8 @@ class StremioService { * This prevents over-fetching all episode data and reduces memory consumption */ async getUpcomingEpisodes( - type: string, - id: string, + type: string, + id: string, options: { daysBack?: number; daysAhead?: number; @@ -996,7 +1129,7 @@ class StremioService { } = {} ): Promise<{ seriesName: string; poster: string; episodes: any[] } | null> { const { daysBack = 14, daysAhead = 28, maxEpisodes = 50, preferredAddonId } = options; - + try { // Get metadata first (this is lightweight compared to episodes) const metadata = await this.getMetaDetails(type, id, preferredAddonId); @@ -1048,10 +1181,9 @@ class StremioService { // Modify getStreams to use this.getInstalledAddons() instead of getEnabledAddons async getStreams(type: string, id: string, callback?: StreamCallback): Promise { await this.ensureInitialized(); - + const addons = this.getInstalledAddons(); - logger.log('šŸ“Œ [getStreams] Installed addons:', addons.map(a => ({ id: a.id, name: a.name, url: a.url }))); - + // Check if local scrapers are enabled and execute them first try { // Load settings from AsyncStorage directly (scoped with fallback) @@ -1060,25 +1192,25 @@ class StremioService { || (await mmkvStorage.getItem('app_settings')); const rawSettings = settingsJson ? JSON.parse(settingsJson) : {}; const settings: AppSettings = { ...DEFAULT_SETTINGS, ...rawSettings }; - + if (settings.enableLocalScrapers) { const hasScrapers = await localScraperService.hasScrapers(); if (hasScrapers) { logger.log('šŸ”§ [getStreams] Executing local scrapers for', type, id); - + // Map Stremio types to local scraper types const scraperType = type === 'series' ? 'tv' : type; - + // Parse the Stremio ID to extract ID and season/episode info let tmdbId: string | null = null; let season: number | undefined = undefined; let episode: number | undefined = undefined; let idType: 'imdb' | 'kitsu' | 'tmdb' = 'imdb'; - + try { const idParts = id.split(':'); let baseId: string; - + // Handle different episode ID formats if (idParts[0] === 'series') { // Format: series:imdbId:season:episode or series:kitsu:7442:season:episode @@ -1128,7 +1260,7 @@ class StremioService { episode = parseInt(idParts[2], 10); } } - + // Handle ID conversion for local scrapers (they need TMDB ID) if (idType === 'imdb') { // Convert IMDb ID to TMDB ID @@ -1154,7 +1286,7 @@ class StremioService { } catch (error) { logger.warn('šŸ”§ [getStreams] Skipping local scrapers due to ID parsing error:', error); } - + // Execute local scrapers asynchronously with TMDB ID (when available) if (tmdbId) { localScraperService.getStreams(scraperType, tmdbId, season, episode, (streams, scraperId, scraperName, error) => { @@ -1191,13 +1323,13 @@ class StremioService { } catch (error) { // Continue even if local scrapers fail } - + // Check specifically for TMDB Embed addon const tmdbEmbed = addons.find(addon => addon.id === 'org.tmdbembedapi'); if (!tmdbEmbed) { // TMDB Embed addon not found } - + // Find addons that provide streams and sort them by installation order const streamAddons = addons .filter(addon => { @@ -1205,35 +1337,30 @@ class StremioService { logger.log(`āš ļø [getStreams] Addon ${addon.id} has no valid resources array`); return false; } - - // Log the detailed resources structure for debugging - logger.log(`šŸ“‹ [getStreams] Checking addon ${addon.id} resources:`, JSON.stringify(addon.resources)); - + let hasStreamResource = false; let supportsIdPrefix = false; - + // Iterate through the resources array, checking each element for (const resource of addon.resources) { // Check if the current element is a ResourceObject if (typeof resource === 'object' && resource !== null && 'name' in resource) { const typedResource = resource as ResourceObject; - if (typedResource.name === 'stream' && - Array.isArray(typedResource.types) && - typedResource.types.includes(type)) { + if (typedResource.name === 'stream' && + Array.isArray(typedResource.types) && + typedResource.types.includes(type)) { hasStreamResource = true; - + // Check if this addon supports the ID prefix (generic: any prefix that matches start of id) if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p)); - logger.log(`šŸ” [getStreams] Addon ${addon.id} supports prefixes: ${typedResource.idPrefixes.join(', ')} → matches=${supportsIdPrefix}`); } else { // If no idPrefixes specified, assume it supports all prefixes supportsIdPrefix = true; - logger.log(`šŸ” [getStreams] Addon ${addon.id} has no prefix restrictions, assuming support`); } break; // Found the stream resource object, no need to check further } - } + } // Check if the element is the simple string "stream" AND the addon has a top-level types array else if (typeof resource === 'string' && resource === 'stream' && addon.types) { if (Array.isArray(addon.types) && addon.types.includes(type)) { @@ -1241,32 +1368,22 @@ class StremioService { // For simple string resources, check addon-level idPrefixes (generic) if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) { supportsIdPrefix = addon.idPrefixes.some(p => id.startsWith(p)); - logger.log(`šŸ” [getStreams] Addon ${addon.id} supports prefixes: ${addon.idPrefixes.join(', ')} → matches=${supportsIdPrefix}`); } else { // If no idPrefixes specified, assume it supports all prefixes supportsIdPrefix = true; - logger.log(`šŸ” [getStreams] Addon ${addon.id} has no prefix restrictions, assuming support`); } break; // Found the simple stream resource string and type support } } } - + const canHandleRequest = hasStreamResource && supportsIdPrefix; - - if (!hasStreamResource) { - logger.log(`āŒ [getStreams] Addon ${addon.id} does not support streaming ${type}`); - } else if (!supportsIdPrefix) { - logger.log(`āŒ [getStreams] Addon ${addon.id} supports ${type} but its idPrefixes did not match id=${id}`); - } else { - logger.log(`āœ… [getStreams] Addon ${addon.id} supports streaming ${type} for id=${id}`); - } - + return canHandleRequest; }); - - logger.log('šŸ“Š [getStreams] Stream capable addons:', streamAddons.map(a => a.id)); - + + + if (streamAddons.length === 0) { logger.warn('āš ļø [getStreams] No addons found that can provide streams'); // Optionally call callback with an empty result or specific status? @@ -1276,7 +1393,7 @@ class StremioService { // Process each addon and call the callback individually streamAddons.forEach(addon => { - // Use an IIFE to create scope for async operation inside forEach + // Use an IIFE to create scope for async operation inside forEach (async () => { try { if (!addon.url) { @@ -1288,9 +1405,9 @@ class StremioService { const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); const encodedId = encodeURIComponent(id); const url = queryParams ? `${baseUrl}/stream/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${type}/${encodedId}.json`; - + logger.log(`šŸ”— [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`); - + const response = await this.retryRequest(async () => { return await axios.get(url); }); @@ -1301,7 +1418,7 @@ class StremioService { processedStreams = this.processStreams(response.data.streams, addon); logger.log(`āœ… [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id})`); } else { - logger.log(`āš ļø [getStreams] No streams found in response from ${addon.name} (${addon.id})`); + logger.log(`āš ļø [getStreams] No streams found in response from ${addon.name} (${addon.id})`); } if (callback) { @@ -1328,21 +1445,21 @@ class StremioService { logger.warn(`Addon ${addon.id} has no URL defined`); return null; } - + const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); const encodedId = encodeURIComponent(id); const streamPath = `/stream/${type}/${encodedId}.json`; const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`; - + logger.log(`Fetching streams from URL: ${url}`); - + try { // Increase timeout for debrid services const timeout = addon.id.toLowerCase().includes('torrentio') ? 60000 : 10000; - + const response = await this.retryRequest(async () => { logger.log(`Making request to ${url} with timeout ${timeout}ms`); - return await axios.get(url, { + return await axios.get(url, { timeout, headers: { 'Accept': 'application/json', @@ -1350,11 +1467,11 @@ class StremioService { } }); }, 5); // Increase retries for stream fetching - + if (response.data && response.data.streams && Array.isArray(response.data.streams)) { const streams = this.processStreams(response.data.streams, addon); logger.log(`Successfully processed ${streams.length} streams from ${addon.id}`); - + return { streams, addon: addon.id, @@ -1377,7 +1494,7 @@ class StremioService { // Re-throw the error with more context throw new Error(`Failed to fetch streams from ${addon.name}: ${error.message}`); } - + return null; } @@ -1396,6 +1513,11 @@ class StremioService { return stream.url.url; } + // Handle YouTube video ID per protocol + if (stream.ytId) { + return `https://www.youtube.com/watch?v=${stream.ytId}`; + } + if (stream.infoHash) { const trackers = [ 'udp://tracker.opentrackr.org:1337/announce', @@ -1407,7 +1529,12 @@ class StremioService { 'udp://tracker.coppersurfer.tk:6969/announce', 'udp://tracker.internetwarriors.net:1337/announce' ]; - const trackersString = trackers.map(t => `&tr=${encodeURIComponent(t)}`).join(''); + // Add sources from stream if available per protocol + const additionalTrackers = (stream.sources || []) + .filter((s: string) => s.startsWith('tracker:')) + .map((s: string) => s.replace('tracker:', '')); + const allTrackers = [...trackers, ...additionalTrackers]; + const trackersString = allTrackers.map(t => `&tr=${encodeURIComponent(t)}`).join(''); const encodedTitle = encodeURIComponent(stream.title || stream.name || 'Unknown'); return `magnet:?xt=urn:btih:${stream.infoHash}&dn=${encodedTitle}${trackersString}`; } @@ -1418,8 +1545,20 @@ class StremioService { private processStreams(streams: any[], addon: Manifest): Stream[] { return streams .filter(stream => { - // Basic filtering - ensure there's a way to play (URL or infoHash) and identify (title/name) - const hasPlayableLink = !!(stream.url || stream.infoHash); + // Basic filtering - ensure there's a way to play per protocol + // One of: url, ytId, infoHash, externalUrl, nzbUrl, or archive arrays + const hasPlayableLink = !!( + stream.url || + stream.infoHash || + stream.ytId || + stream.externalUrl || + stream.nzbUrl || + (stream.rarUrls && stream.rarUrls.length > 0) || + (stream.zipUrls && stream.zipUrls.length > 0) || + (stream['7zipUrls'] && stream['7zipUrls'].length > 0) || + (stream.tgzUrls && stream.tgzUrls.length > 0) || + (stream.tarUrls && stream.tarUrls.length > 0) + ); const hasIdentifier = !!(stream.title || stream.name); return stream && hasPlayableLink && hasIdentifier; }) @@ -1427,6 +1566,8 @@ class StremioService { const streamUrl = this.getStreamUrl(stream); const isDirectStreamingUrl = this.isDirectStreamingUrl(streamUrl); const isMagnetStream = streamUrl?.startsWith('magnet:'); + const isExternalUrl = !!stream.externalUrl; + const isYouTube = !!stream.ytId; // Prefer full, untruncated text to preserve complete addon details let displayTitle = stream.title || stream.name || 'Unnamed Stream'; @@ -1441,12 +1582,20 @@ class StremioService { // Extract size: Prefer behaviorHints.videoSize, fallback to top-level size const sizeInBytes = stream.behaviorHints?.videoSize || stream.size || undefined; - // Memory optimization: Minimize behaviorHints to essential data only + // Preserve complete behaviorHints per protocol const behaviorHints: Stream['behaviorHints'] = { - notWebReady: !isDirectStreamingUrl, + notWebReady: !isDirectStreamingUrl || isExternalUrl, cached: stream.behaviorHints?.cached || undefined, bingeGroup: stream.behaviorHints?.bingeGroup || undefined, - // Only include essential torrent data for magnet streams + // Per protocol: Country whitelist for geo-restrictions + countryWhitelist: stream.behaviorHints?.countryWhitelist || undefined, + // Per protocol: Proxy headers for custom stream headers + proxyHeaders: stream.behaviorHints?.proxyHeaders || undefined, + // Per protocol: Video metadata for subtitle matching + videoHash: stream.behaviorHints?.videoHash || undefined, + videoSize: stream.behaviorHints?.videoSize || undefined, + filename: stream.behaviorHints?.filename || undefined, + // Include essential torrent data for magnet streams ...(isMagnetStream ? { infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1], fileIdx: stream.fileIdx, @@ -1454,20 +1603,49 @@ class StremioService { } : {}), }; - // Explicitly construct the final Stream object with minimal data + // Explicitly construct the final Stream object with all protocol fields const processedStream: Stream = { - url: streamUrl, + // Primary URL (may be empty for ytId/externalUrl streams) + url: streamUrl || undefined, name: name, title: displayTitle, addonName: addon.name, addonId: addon.id, + // Include description as-is to preserve full details description: stream.description, + + // Alternative source types per protocol + ytId: stream.ytId || undefined, + externalUrl: stream.externalUrl || undefined, + nzbUrl: stream.nzbUrl || undefined, + rarUrls: stream.rarUrls || undefined, + zipUrls: stream.zipUrls || undefined, + '7zipUrls': stream['7zipUrls'] || undefined, + tgzUrls: stream.tgzUrls || undefined, + tarUrls: stream.tarUrls || undefined, + servers: stream.servers || undefined, + + // Torrent/archive file selection infoHash: stream.infoHash || undefined, fileIdx: stream.fileIdx, + fileMustInclude: stream.fileMustInclude || undefined, + + // Stream metadata size: sizeInBytes, isFree: stream.isFree, isDebrid: !!(stream.behaviorHints?.cached), + + // Embedded subtitles per protocol + subtitles: stream.subtitles?.map((sub: any, index: number) => ({ + id: sub.id || `${addon.id}-${sub.lang || 'unknown'}-${index}`, + ...sub, + })) || undefined, + + // Additional tracker/DHT sources per protocol + sources: stream.sources || undefined, + + // Complete behavior hints behaviorHints: behaviorHints, }; @@ -1495,11 +1673,11 @@ class StremioService { items: Meta[]; }> { const addon = this.getInstalledAddons().find(a => a.id === addonId); - + if (!addon) { throw new Error(`Addon ${addonId} not found`); } - + const items = await this.getCatalog(addon, type, id); return { addon: addonId, @@ -1541,7 +1719,9 @@ class StremioService { logger.log(`Fetching subtitles from ${addon.name}: ${url}`); const response = await this.retryRequest(async () => axios.get(url, { timeout: 10000 })); if (response.data && Array.isArray(response.data.subtitles)) { - return response.data.subtitles.map((sub: any) => ({ + return response.data.subtitles.map((sub: any, index: number) => ({ + // Ensure ID is always present per protocol (required field) + id: sub.id || `${addon.id}-${sub.lang || 'unknown'}-${index}`, ...sub, addon: addon.id, addonName: addon.name, @@ -1596,22 +1776,48 @@ class StremioService { return false; } - // Check if any installed addons can provide streams - async hasStreamProviders(): Promise { + // Check if any installed addons can provide streams (including embedded streams in metadata) + async hasStreamProviders(type?: string): Promise { await this.ensureInitialized(); const addons = Array.from(this.installedAddons.values()); for (const addon of addons) { if (addon.resources && Array.isArray(addon.resources)) { - // Check for 'stream' resource in the modern format - const hasStreamResource = addon.resources.some(resource => - typeof resource === 'string' - ? resource === 'stream' - : resource.name === 'stream' + // Check for explicit 'stream' resource + const hasStreamResource = addon.resources.some(resource => + typeof resource === 'string' + ? resource === 'stream' + : (resource as any).name === 'stream' ); if (hasStreamResource) { - return true; + // If type specified, also check if addon supports this type + if (type) { + const supportsType = addon.types?.includes(type) || + addon.resources.some(resource => + typeof resource === 'object' && + (resource as any).name === 'stream' && + (resource as any).types?.includes(type) + ); + if (supportsType) return true; + } else { + return true; + } + } + + // Also check for addons with meta resource that support the type + // These addons might provide embedded streams within metadata + if (type) { + const hasMetaResource = addon.resources.some(resource => + typeof resource === 'string' + ? resource === 'meta' + : (resource as any).name === 'meta' + ); + + if (hasMetaResource && addon.types?.includes(type)) { + // This addon provides meta for the type - might have embedded streams + return true; + } } } } @@ -1619,6 +1825,54 @@ class StremioService { return false; } + /** + * Fetch addon catalogs from addons that provide the addon_catalog resource per protocol. + * Returns a list of other addon manifests that can be installed. + */ + async getAddonCatalogs(type: string, id: string): Promise { + await this.ensureInitialized(); + + // Find addons that provide addon_catalog resource + const addons = this.getInstalledAddons().filter(addon => { + if (!addon.resources) return false; + return addon.resources.some(r => + typeof r === 'string' ? r === 'addon_catalog' : (r as any).name === 'addon_catalog' + ); + }); + + if (addons.length === 0) { + logger.log('[getAddonCatalogs] No addons provide addon_catalog resource'); + return []; + } + + const results: AddonCatalogItem[] = []; + + for (const addon of addons) { + try { + const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || ''); + const url = `${baseUrl}/addon_catalog/${type}/${encodeURIComponent(id)}.json${queryParams ? `?${queryParams}` : ''}`; + + logger.log(`[getAddonCatalogs] Fetching from ${addon.name}: ${url}`); + const response = await this.retryRequest(() => axios.get(url, { timeout: 10000 })); + + if (response.data?.addons && Array.isArray(response.data.addons)) { + results.push(...response.data.addons); + } + } catch (error) { + logger.warn(`[getAddonCatalogs] Failed to fetch from ${addon.name}:`, error); + } + } + + return results; + } + +} + +// Addon catalog item per protocol +export interface AddonCatalogItem { + transportName: string; // 'http' + transportUrl: string; // URL to manifest.json + manifest: Manifest; } export const stremioService = StremioService.getInstance(); diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 1bf55818..f3874a4a 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -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 { + 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 { + 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>(); + 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 { + 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 { + 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 { try { @@ -1446,7 +1662,8 @@ export class TraktService { return null; } - return this.apiRequest('/scrobble/pause', 'POST', payload); + // Use /scrobble/stop - Trakt automatically treats <80% as pause, ≄80% as scrobble + return this.apiRequest('/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 { 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}`; diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts new file mode 100644 index 00000000..34364960 --- /dev/null +++ b/src/services/watchedService.ts @@ -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 { + 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 { + 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 { + 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(); diff --git a/src/types/metadata.ts b/src/types/metadata.ts index be8bc396..06805549 100644 --- a/src/types/metadata.ts +++ b/src/types/metadata.ts @@ -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; + response?: Record; + }; + videoHash?: string; + videoSize?: number; + filename?: string; + [key: string]: any; + }; } export interface GroupedStreams { diff --git a/src/types/streams.ts b/src/types/streams.ts index 0b0d6090..1c038f29 100644 --- a/src/types/streams.ts +++ b/src/types/streams.ts @@ -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; + + // 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; + response?: Record; + }; + videoHash?: string; // OpenSubtitles hash + videoSize?: number; // Video file size in bytes + filename?: string; // Video filename + [key: string]: any; + }; } export interface GroupedStreams { diff --git a/src/utils/version.ts b/src/utils/version.ts index 5a958df0..7c232bef 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -1,7 +1,7 @@ // Single source of truth for the app version displayed in Settings // Update this when bumping app version -export const APP_VERSION = '1.2.10'; +export const APP_VERSION = '1.2.11'; export function getDisplayedAppVersion(): string { return APP_VERSION; diff --git a/trakt-docs/scrape-trakt-docs.js b/trakt-docs/scrape-trakt-docs.js new file mode 100644 index 00000000..7b5d44ca --- /dev/null +++ b/trakt-docs/scrape-trakt-docs.js @@ -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();