mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-20 08:12:05 +00:00
Merge branch 'main' into patch-5
This commit is contained in:
commit
104d0f4516
60 changed files with 6910 additions and 4270 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -85,3 +85,5 @@ node_modules
|
|||
expofs.md
|
||||
ios/sentry.properties
|
||||
android/sentry.properties
|
||||
Stremio addons refer
|
||||
trakt-docs
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@
|
|||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
|
||||
<string name="expo_runtime_version">1.2.10</string>
|
||||
<string name="expo_runtime_version">1.2.11</string>
|
||||
</resources>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
defaults.url=https://sentry.io/
|
||||
defaults.org=tapframe
|
||||
defaults.project=react-native
|
||||
auth.token=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
|
||||
auth.token=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
|
||||
|
|
|
|||
8
app.json
8
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1,99 +1,103 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Nuvio</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.10</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>nuvio</string>
|
||||
<string>com.nuvio.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>exp+nuvio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>25</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_http._tcp</string>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>RCTRootViewBackgroundColor</key>
|
||||
<integer>4278322180</integer>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>SplashScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleDefault</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Dark</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Nuvio</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.11</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>nuvio</string>
|
||||
<string>com.nuvio.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>exp+nuvio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>26</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_http._tcp</string>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Allow $(PRODUCT_NAME) to access your local network</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>This app does not require microphone access.</string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>RCTRootViewBackgroundColor</key>
|
||||
<integer>4278322180</integer>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>SplashScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleDefault</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Dark</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
<key>EXUpdatesLaunchWaitMs</key>
|
||||
<integer>30000</integer>
|
||||
<key>EXUpdatesRuntimeVersion</key>
|
||||
<string>1.2.10</string>
|
||||
<string>1.2.11</string>
|
||||
<key>EXUpdatesURL</key>
|
||||
<string>https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest</string>
|
||||
</dict>
|
||||
|
|
|
|||
|
|
@ -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()"];
|
||||
|
|
|
|||
254
ios/Podfile.lock
254
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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
defaults.url=https://sentry.io/
|
||||
defaults.org=tapframe
|
||||
defaults.project=react-native
|
||||
auth.token=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
|
||||
auth.token=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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,14 @@
|
|||
"https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true"
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.11",
|
||||
"buildVersion": "27",
|
||||
"date": "2025-12-15",
|
||||
"localizedDescription": "# Nuvio Media Hub – v1.2.11 \n\n## Update Notes\n- **Dependency updates** for improved stability \n- **Android animation improvements** for smoother UI interactions \n- Multiple **backend bug fixes** \n\n## Note for iOS Users\n- iOS users can continue using the **TestFlight build** \n- It may show version **1.2.10 (27)**, but the **build is the same as 1.2.11** \n- This is intentional, as bumping the version would require a manual TestFlight review",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.2.11/Stable_1-2-11.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.2.10",
|
||||
"buildVersion": "25",
|
||||
|
|
@ -208,4 +216,4 @@
|
|||
}
|
||||
],
|
||||
"news": []
|
||||
}
|
||||
}
|
||||
1623
package-lock.json
generated
1623
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import Animated, {
|
|||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { Portal, Dialog, Button } from 'react-native-paper';
|
||||
import { Portal } from 'react-native-paper';
|
||||
|
||||
interface CustomAlertProps {
|
||||
visible: boolean;
|
||||
|
|
@ -40,8 +40,8 @@ export const CustomAlert = ({
|
|||
}: CustomAlertProps) => {
|
||||
const opacity = useSharedValue(0);
|
||||
const scale = useSharedValue(0.95);
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
const { currentTheme } = useTheme();
|
||||
// Using hardcoded dark theme values to match SeriesContent modal
|
||||
const themeColors = currentTheme.colors;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -68,10 +68,11 @@ export const CustomAlert = ({
|
|||
const handleActionPress = useCallback((action: { label: string; onPress: () => void; style?: object }) => {
|
||||
try {
|
||||
action.onPress();
|
||||
// Don't auto-close here if the action handles it, or check if we should
|
||||
// Standard behavior is to close
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.warn('[CustomAlert] Error in action handler:', error);
|
||||
// Still close the alert even if action fails
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
|
@ -91,7 +92,7 @@ export const CustomAlert = ({
|
|||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ backgroundColor: 'rgba(0,0,0,0.6)' },
|
||||
{ backgroundColor: 'rgba(0, 0, 0, 0.85)' },
|
||||
overlayStyle
|
||||
]}
|
||||
>
|
||||
|
|
@ -100,23 +101,22 @@ export const CustomAlert = ({
|
|||
<Animated.View style={[
|
||||
styles.alertContainer,
|
||||
alertStyle,
|
||||
{
|
||||
backgroundColor: themeColors.darkBackground,
|
||||
borderColor: themeColors.primary,
|
||||
}
|
||||
]}>
|
||||
{/* Title */}
|
||||
<Text style={[styles.title, { color: themeColors.highEmphasis }]}>
|
||||
<Text style={styles.title}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* Message */}
|
||||
<Text style={[styles.message, { color: themeColors.mediumEmphasis }]}>
|
||||
<Text style={styles.message}>
|
||||
{message}
|
||||
</Text>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actionsRow}>
|
||||
<View style={[
|
||||
styles.actionsRow,
|
||||
actions.length === 1 && { justifyContent: 'center' }
|
||||
]}>
|
||||
{actions.map((action, idx) => {
|
||||
const isPrimary = idx === actions.length - 1;
|
||||
return (
|
||||
|
|
@ -125,9 +125,10 @@ export const CustomAlert = ({
|
|||
style={[
|
||||
styles.actionButton,
|
||||
isPrimary
|
||||
? { ...styles.primaryButton, backgroundColor: themeColors.primary }
|
||||
? { backgroundColor: themeColors.primary }
|
||||
: styles.secondaryButton,
|
||||
action.style
|
||||
action.style,
|
||||
actions.length === 1 && { minWidth: 120, maxWidth: '100%' }
|
||||
]}
|
||||
onPress={() => handleActionPress(action)}
|
||||
activeOpacity={0.7}
|
||||
|
|
@ -135,8 +136,8 @@ export const CustomAlert = ({
|
|||
<Text style={[
|
||||
styles.actionText,
|
||||
isPrimary
|
||||
? { color: themeColors.white }
|
||||
: { color: themeColors.primary }
|
||||
? { color: '#FFFFFF' }
|
||||
: { color: '#FFFFFF' }
|
||||
]}>
|
||||
{action.label}
|
||||
</Text>
|
||||
|
|
@ -157,6 +158,7 @@ const styles = StyleSheet.create({
|
|||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 9999,
|
||||
},
|
||||
overlayPressable: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
|
|
@ -165,29 +167,32 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingHorizontal: 20,
|
||||
width: '100%',
|
||||
},
|
||||
alertContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 340,
|
||||
borderRadius: 24,
|
||||
padding: 28,
|
||||
maxWidth: 400,
|
||||
backgroundColor: '#1E1E1E', // Solid opaque dark background
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: '#007AFF', // iOS blue - will be overridden by theme
|
||||
overflow: 'hidden', // Ensure background fills entire card
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
overflow: 'hidden',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 24,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.51,
|
||||
shadowRadius: 13.16,
|
||||
},
|
||||
android: {
|
||||
elevation: 12,
|
||||
elevation: 20,
|
||||
},
|
||||
}),
|
||||
},
|
||||
title: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
|
|
@ -195,6 +200,7 @@ const styles = StyleSheet.create({
|
|||
letterSpacing: 0.2,
|
||||
},
|
||||
message: {
|
||||
color: '#AAAAAA',
|
||||
fontSize: 15,
|
||||
marginBottom: 24,
|
||||
textAlign: 'center',
|
||||
|
|
@ -209,17 +215,16 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
actionButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 11,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
primaryButton: {
|
||||
// Background color set dynamically via theme
|
||||
flex: 1, // Distribute space
|
||||
maxWidth: 200, // But limit width
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
actionText: {
|
||||
fontSize: 16,
|
||||
|
|
|
|||
|
|
@ -611,7 +611,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
// Stop any playing trailer
|
||||
try {
|
||||
setTrailerPlaying(false);
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
// Check if we should resume based on watch progress
|
||||
const shouldResume = watchProgress &&
|
||||
|
|
@ -744,13 +744,32 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const timeSinceInteraction = Date.now() - lastInteractionRef.current;
|
||||
// Only auto-advance if user hasn't interacted recently (5 seconds) and no trailer playing
|
||||
if (timeSinceInteraction >= 5000 && (!globalTrailerPlaying || !trailerReady)) {
|
||||
setCurrentIndex((prev) => (prev + 1) % items.length);
|
||||
// Set next index preview for crossfade
|
||||
const nextIdx = (currentIndex + 1) % items.length;
|
||||
setNextIndex(nextIdx);
|
||||
|
||||
// Set drag direction for slide animation (left/next)
|
||||
dragDirection.value = -1;
|
||||
|
||||
// Animate crossfade before changing index
|
||||
dragProgress.value = withTiming(
|
||||
1,
|
||||
{
|
||||
duration: 500,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
},
|
||||
(finished) => {
|
||||
if (finished) {
|
||||
runOnJS(setCurrentIndex)(nextIdx);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Retry after remaining time
|
||||
startAutoPlay();
|
||||
}
|
||||
}, 25000); // Auto-advance every 25 seconds
|
||||
}, [items.length, globalTrailerPlaying, trailerReady]);
|
||||
}, [items.length, globalTrailerPlaying, trailerReady, currentIndex, dragDirection, dragProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
startAutoPlay();
|
||||
|
|
@ -852,7 +871,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
.onEnd((event) => {
|
||||
const velocity = event.velocityX;
|
||||
const translationX = event.translationX;
|
||||
const swipeThreshold = width * 0.05; // Very small threshold - minimal swipe needed
|
||||
const swipeThreshold = width * 0.16; // 16% threshold for swipe detection
|
||||
|
||||
if (Math.abs(translationX) > swipeThreshold || Math.abs(velocity) > 300) {
|
||||
// Complete the swipe - animate to full opacity before navigation
|
||||
|
|
@ -1159,61 +1178,61 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
style={logoAnimatedStyle}
|
||||
>
|
||||
{currentItem.logo && !logoError[currentIndex] ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
if (currentItem) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
});
|
||||
}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
if (currentItem) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.logoContainer,
|
||||
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
|
||||
? { marginBottom: 4 } // Minimal spacing for small logos
|
||||
: { marginBottom: 8 } // Small spacing for normal logos
|
||||
]}
|
||||
onLayout={(event) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.logoContainer,
|
||||
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
|
||||
? { marginBottom: 4 } // Minimal spacing for small logos
|
||||
: { marginBottom: 8 } // Small spacing for normal logos
|
||||
]}
|
||||
onLayout={(event) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
|
||||
<Image
|
||||
source={{ uri: currentItem.logo }}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
onLoad={() => setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
|
||||
onError={() => {
|
||||
setLogoError((prev) => ({ ...prev, [currentIndex]: true }));
|
||||
logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo);
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: currentItem.logo }}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
onLoad={() => setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
|
||||
onError={() => {
|
||||
setLogoError((prev) => ({ ...prev, [currentIndex]: true }));
|
||||
logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => {
|
||||
if (currentItem) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{currentItem.name}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => {
|
||||
if (currentItem) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{currentItem.name}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Metadata Badge - Always Visible */}
|
||||
<View style={styles.metadataContainer}>
|
||||
|
|
@ -1231,7 +1250,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons - Play and Save buttons */}
|
||||
{/* Action Buttons - Play and Save buttons */}
|
||||
<View style={styles.buttonsContainer}>
|
||||
{/* Play Button */}
|
||||
<TouchableOpacity
|
||||
|
|
|
|||
|
|
@ -41,13 +41,13 @@ const calculatePosterLayout = (screenWidth: number) => {
|
|||
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
|
||||
const LEFT_PADDING = 16; // Left padding
|
||||
const SPACING = 8; // Space between posters
|
||||
|
||||
|
||||
// Calculate available width for posters (reserve space for left padding)
|
||||
const availableWidth = screenWidth - LEFT_PADDING;
|
||||
|
||||
|
||||
// Try different numbers of full posters to find the best fit
|
||||
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
|
||||
|
||||
|
||||
for (let n = 3; n <= 6; n++) {
|
||||
// Calculate poster width needed for N full posters + 0.25 partial poster
|
||||
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
|
||||
|
|
@ -55,12 +55,12 @@ const calculatePosterLayout = (screenWidth: number) => {
|
|||
// We'll use minimal right padding (8px) to maximize space
|
||||
const usableWidth = availableWidth - 8;
|
||||
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
|
||||
|
||||
|
||||
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
|
||||
bestLayout = { numFullPosters: n, posterWidth };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
numFullPosters: bestLayout.numFullPosters,
|
||||
posterWidth: bestLayout.posterWidth,
|
||||
|
|
@ -82,8 +82,8 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
|
||||
const renderContentItem = useCallback(({ item }: { item: StreamingContent, index: number }) => {
|
||||
return (
|
||||
<ContentItem
|
||||
item={item}
|
||||
<ContentItem
|
||||
item={item}
|
||||
onPress={handleContentPress}
|
||||
/>
|
||||
);
|
||||
|
|
@ -96,25 +96,11 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
// Memoize the keyExtractor to prevent re-creation
|
||||
const keyExtractor = useCallback((item: StreamingContent) => `${item.id}-${item.type}`, []);
|
||||
|
||||
// Calculate item width for getItemLayout - use base POSTER_WIDTH for consistent spacing
|
||||
// Note: ContentItem may apply size multipliers based on settings, but base width ensures consistent layout
|
||||
const itemWidth = useMemo(() => POSTER_WIDTH, []);
|
||||
|
||||
// getItemLayout for consistent spacing and better performance
|
||||
const getItemLayout = useCallback((data: any, index: number) => {
|
||||
const length = itemWidth + separatorWidth;
|
||||
const paddingHorizontal = isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 16;
|
||||
return {
|
||||
length,
|
||||
offset: paddingHorizontal + (length * index),
|
||||
index,
|
||||
};
|
||||
}, [itemWidth, separatorWidth, isTV, isLargeTablet, isTablet]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<View
|
||||
style={styles.catalogContainer}
|
||||
entering={FadeIn.duration(400)}
|
||||
>
|
||||
<View style={[
|
||||
styles.catalogHeader,
|
||||
|
|
@ -145,7 +131,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
onPress={() =>
|
||||
navigation.navigate('Catalog', {
|
||||
id: catalog.id,
|
||||
type: catalog.type,
|
||||
|
|
@ -176,7 +162,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
||||
<FlatList
|
||||
data={catalog.items}
|
||||
renderItem={renderContentItem}
|
||||
|
|
@ -195,14 +181,13 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
}
|
||||
])}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
getItemLayout={getItemLayout}
|
||||
removeClippedSubviews={true}
|
||||
initialNumToRender={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3}
|
||||
maxToRenderPerBatch={isTV ? 4 : isLargeTablet ? 4 : 3}
|
||||
windowSize={isTV ? 4 : isLargeTablet ? 4 : 3}
|
||||
updateCellsBatchingPeriod={50}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -262,8 +247,8 @@ export default React.memo(CatalogSection, (prevProps, nextProps) => {
|
|||
prevProps.catalog.name === nextProps.catalog.name &&
|
||||
prevProps.catalog.items.length === nextProps.catalog.items.length &&
|
||||
// Deep compare the first few items to detect changes
|
||||
prevProps.catalog.items.slice(0, 3).every((item, index) =>
|
||||
nextProps.catalog.items[index] &&
|
||||
prevProps.catalog.items.slice(0, 3).every((item, index) =>
|
||||
nextProps.catalog.items[index] &&
|
||||
item.id === nextProps.catalog.items[index].id &&
|
||||
item.poster === nextProps.catalog.items[index].poster
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const getDeviceType = (screenWidth: number) => {
|
|||
// Dynamic poster calculation based on screen width - show 1/4 of next poster
|
||||
const calculatePosterLayout = (screenWidth: number) => {
|
||||
const deviceType = getDeviceType(screenWidth);
|
||||
|
||||
|
||||
// Responsive sizing based on device type
|
||||
const MIN_POSTER_WIDTH = deviceType === 'tv' ? 180 : deviceType === 'largeTablet' ? 160 : deviceType === 'tablet' ? 140 : 100;
|
||||
const MAX_POSTER_WIDTH = deviceType === 'tv' ? 220 : deviceType === 'largeTablet' ? 200 : deviceType === 'tablet' ? 180 : 130;
|
||||
|
|
@ -52,9 +52,9 @@ const calculatePosterLayout = (screenWidth: number) => {
|
|||
const availableWidth = screenWidth - LEFT_PADDING;
|
||||
|
||||
// Try different numbers of full posters to find the best fit
|
||||
let bestLayout = {
|
||||
numFullPosters: 3,
|
||||
posterWidth: deviceType === 'tv' ? 200 : deviceType === 'largeTablet' ? 180 : deviceType === 'tablet' ? 160 : 120
|
||||
let bestLayout = {
|
||||
numFullPosters: 3,
|
||||
posterWidth: deviceType === 'tv' ? 200 : deviceType === 'largeTablet' ? 180 : deviceType === 'tablet' ? 160 : 120
|
||||
};
|
||||
|
||||
for (let n = 3; n <= 6; n++) {
|
||||
|
|
@ -96,7 +96,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
return () => unsubscribe();
|
||||
}, [item.id, item.type]);
|
||||
|
||||
// Load watched state from AsyncStorage when item changes
|
||||
// Load watched state from AsyncStorage when item changes
|
||||
useEffect(() => {
|
||||
const updateWatched = () => {
|
||||
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then((val: string | null) => setIsWatched(val === 'true'));
|
||||
|
|
@ -126,7 +126,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
const posterWidth = React.useMemo(() => {
|
||||
const deviceType = getDeviceType(width);
|
||||
const sizeMultiplier = deviceType === 'tv' ? 1.2 : deviceType === 'largeTablet' ? 1.1 : deviceType === 'tablet' ? 1.0 : 0.9;
|
||||
|
||||
|
||||
switch (settings.posterSize) {
|
||||
case 'small':
|
||||
return Math.max(90, POSTER_WIDTH - 15) * sizeMultiplier;
|
||||
|
|
@ -139,6 +139,30 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
}
|
||||
}, [settings.posterSize, width]);
|
||||
|
||||
// Determine dimensions based on poster shape
|
||||
const { finalWidth, finalAspectRatio, borderRadius } = React.useMemo(() => {
|
||||
const shape = item.posterShape || 'poster';
|
||||
const baseHeight = posterWidth / (2 / 3); // Standard height derived from portrait width
|
||||
|
||||
let w = posterWidth;
|
||||
let ratio = 2 / 3;
|
||||
|
||||
if (shape === 'landscape') {
|
||||
ratio = 16 / 9;
|
||||
// Maintain same height as portrait posters
|
||||
w = baseHeight * ratio;
|
||||
} else if (shape === 'square') {
|
||||
ratio = 1;
|
||||
w = baseHeight;
|
||||
}
|
||||
|
||||
return {
|
||||
finalWidth: w,
|
||||
finalAspectRatio: ratio,
|
||||
borderRadius: typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12
|
||||
};
|
||||
}, [posterWidth, item.posterShape, settings.posterBorderRadius]);
|
||||
|
||||
// Intersection observer simulation for lazy loading
|
||||
const itemRef = useRef<View>(null);
|
||||
|
||||
|
|
@ -169,7 +193,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
setIsWatched(targetWatched);
|
||||
try {
|
||||
await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
|
||||
} catch {}
|
||||
} catch { }
|
||||
showInfo(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched', targetWatched ? 'Item marked as watched' : 'Item marked as unwatched');
|
||||
setTimeout(() => {
|
||||
DeviceEventEmitter.emit('watchedStatusChanged');
|
||||
|
|
@ -185,7 +209,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
undefined,
|
||||
{ forceNotify: true, forceWrite: true }
|
||||
);
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
if (item.type === 'movie') {
|
||||
try {
|
||||
|
|
@ -194,9 +218,9 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
await trakt.addToWatchedMovies(item.id);
|
||||
try {
|
||||
await storageService.updateTraktSyncStatus(item.id, item.type, true, 100);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
setMenuVisible(false);
|
||||
|
|
@ -242,44 +266,34 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
setMenuVisible(false);
|
||||
}, []);
|
||||
|
||||
|
||||
// Memoize optimized poster URL to prevent recalculating
|
||||
const optimizedPosterUrl = React.useMemo(() => {
|
||||
if (!item.poster || item.poster.includes('placeholder')) {
|
||||
return 'https://via.placeholder.com/154x231/333/666?text=No+Image';
|
||||
}
|
||||
|
||||
// For TMDB images, use smaller sizes
|
||||
if (item.poster.includes('image.tmdb.org')) {
|
||||
// Replace any size with w154 (fits 100-130px tiles perfectly)
|
||||
return item.poster.replace(/\/w\d+\//, '/w154/');
|
||||
}
|
||||
|
||||
// For metahub images, use smaller sizes
|
||||
if (item.poster.includes('placeholder')) {
|
||||
return item.poster.replace('/medium/', '/small/');
|
||||
}
|
||||
|
||||
// Return original URL for other sources to avoid breaking them
|
||||
return item.poster;
|
||||
}, [item.poster, item.id]);
|
||||
|
||||
// While settings load, render a placeholder with reserved space (poster aspect + title)
|
||||
if (!isLoaded) {
|
||||
const placeholderRadius = 12;
|
||||
return (
|
||||
<View style={[styles.itemContainer, { width: posterWidth }]}>
|
||||
<View style={[styles.itemContainer, { width: finalWidth }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.contentItem,
|
||||
{
|
||||
width: posterWidth,
|
||||
borderRadius: placeholderRadius,
|
||||
width: finalWidth,
|
||||
aspectRatio: finalAspectRatio,
|
||||
borderRadius,
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Reserve space for title to keep section spacing stable */}
|
||||
<View style={{ height: 18, marginTop: 4 }} />
|
||||
</View>
|
||||
);
|
||||
|
|
@ -287,24 +301,24 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
|
||||
return (
|
||||
<>
|
||||
<Animated.View style={[styles.itemContainer, { width: posterWidth }]} entering={FadeIn.duration(300)}>
|
||||
<Animated.View style={[styles.itemContainer, { width: finalWidth }]} entering={FadeIn.duration(300)}>
|
||||
<TouchableOpacity
|
||||
style={[styles.contentItem, { width: posterWidth, borderRadius: posterRadius }]}
|
||||
style={[styles.contentItem, { width: finalWidth, aspectRatio: finalAspectRatio, borderRadius }]}
|
||||
activeOpacity={0.7}
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={300}
|
||||
>
|
||||
<View ref={itemRef} style={[styles.contentItemContainer, { borderRadius: posterRadius }] }>
|
||||
<View ref={itemRef} style={[styles.contentItemContainer, { borderRadius }]}>
|
||||
{/* Image with FastImage for aggressive caching */}
|
||||
{item.poster ? (
|
||||
<FastImage
|
||||
source={{
|
||||
source={{
|
||||
uri: optimizedPosterUrl,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius: posterRadius }]}
|
||||
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius }]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
onLoad={() => {
|
||||
setImageError(false);
|
||||
|
|
@ -316,14 +330,14 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
/>
|
||||
) : (
|
||||
// Show placeholder for items without posters
|
||||
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center', borderRadius: posterRadius }] }>
|
||||
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center', borderRadius: posterRadius }]}>
|
||||
<Text style={{ color: currentTheme.colors.textMuted, fontSize: 10, textAlign: 'center' }}>
|
||||
{item.name.substring(0, 20)}...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{imageError && (
|
||||
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<MaterialIcons name="broken-image" size={24} color={currentTheme.colors.textMuted} />
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -350,14 +364,14 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
</View>
|
||||
</TouchableOpacity>
|
||||
{settings.showPosterTitles && (
|
||||
<Text
|
||||
<Text
|
||||
style={[
|
||||
styles.title,
|
||||
{
|
||||
styles.title,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: getDeviceType(width) === 'tv' ? 16 : getDeviceType(width) === 'largeTablet' ? 15 : getDeviceType(width) === 'tablet' ? 14 : 13
|
||||
}
|
||||
]}
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.name}
|
||||
|
|
|
|||
|
|
@ -293,51 +293,50 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const mergeBatchIntoState = async (batch: ContinueWatchingItem[]) => {
|
||||
if (!batch || batch.length === 0) return;
|
||||
|
||||
// 1. Filter items first (async checks) - do this BEFORE any state updates
|
||||
const validItems: ContinueWatchingItem[] = [];
|
||||
for (const it of batch) {
|
||||
const key = `${it.type}:${it.id}`;
|
||||
|
||||
// Skip recently removed items
|
||||
if (recentlyRemovedRef.current.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip persistently removed items
|
||||
const isRemoved = await storageService.isContinueWatchingRemoved(it.id, it.type);
|
||||
if (isRemoved) {
|
||||
continue;
|
||||
}
|
||||
|
||||
validItems.push(it);
|
||||
}
|
||||
|
||||
if (validItems.length === 0) return;
|
||||
|
||||
// 2. Single state update for the entire batch
|
||||
setContinueWatchingItems((prev) => {
|
||||
const map = new Map<string, ContinueWatchingItem>();
|
||||
// Add existing items
|
||||
for (const it of prev) {
|
||||
map.set(`${it.type}:${it.id}`, it);
|
||||
}
|
||||
|
||||
// Merge new valid items
|
||||
for (const it of validItems) {
|
||||
const key = `${it.type}:${it.id}`;
|
||||
const existing = map.get(key);
|
||||
// Only update if newer or doesn't exist
|
||||
if (!existing || (it.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
|
||||
map.set(key, it);
|
||||
}
|
||||
}
|
||||
|
||||
const merged = Array.from(map.values());
|
||||
merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
|
||||
|
||||
return merged;
|
||||
});
|
||||
|
||||
// Process batch items asynchronously to check removal status
|
||||
for (const it of batch) {
|
||||
const key = `${it.type}:${it.id}`;
|
||||
|
||||
// Skip recently removed items to prevent immediate re-addition
|
||||
if (recentlyRemovedRef.current.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip items that have been persistently marked as removed
|
||||
const isRemoved = await storageService.isContinueWatchingRemoved(it.id, it.type);
|
||||
if (isRemoved) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add the item to state
|
||||
setContinueWatchingItems((prev) => {
|
||||
const map = new Map<string, ContinueWatchingItem>();
|
||||
for (const existing of prev) {
|
||||
map.set(`${existing.type}:${existing.id}`, existing);
|
||||
}
|
||||
|
||||
const existing = map.get(key);
|
||||
if (!existing || (it.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
|
||||
map.set(key, it);
|
||||
const merged = Array.from(map.values());
|
||||
merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
|
||||
return merged;
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -604,7 +603,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
});
|
||||
|
||||
// TRÅKT: fetch history and merge incrementally as well
|
||||
// TRAKT: fetch playback progress (in-progress items) and history, merge incrementally
|
||||
const traktMergePromise = (async () => {
|
||||
try {
|
||||
const traktService = TraktService.getInstance();
|
||||
|
|
@ -619,28 +618,132 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
|
||||
lastTraktSyncRef.current = now;
|
||||
const historyItems = await traktService.getWatchedEpisodesHistory(1, 200);
|
||||
|
||||
// Fetch both playback progress (paused items) and watch history in parallel
|
||||
const [playbackItems, historyItems, watchedShows] = await Promise.all([
|
||||
traktService.getPlaybackProgress(), // Items with actual progress %
|
||||
traktService.getWatchedEpisodesHistory(1, 200), // Completed episodes
|
||||
traktService.getWatchedShows(), // For reset_at handling
|
||||
]);
|
||||
|
||||
// Build a map of shows with reset_at for re-watching support
|
||||
const showResetMap: Record<string, number> = {};
|
||||
for (const show of watchedShows) {
|
||||
if (show.show?.ids?.imdb && show.reset_at) {
|
||||
const imdbId = show.show.ids.imdb.startsWith('tt')
|
||||
? show.show.ids.imdb
|
||||
: `tt${show.show.ids.imdb}`;
|
||||
showResetMap[imdbId] = new Date(show.reset_at).getTime();
|
||||
}
|
||||
}
|
||||
|
||||
const traktBatch: ContinueWatchingItem[] = [];
|
||||
const processedShows = new Set<string>(); // Track which shows we've added
|
||||
|
||||
// STEP 1: Process playback progress items (in-progress, paused)
|
||||
// These have actual progress percentage from Trakt
|
||||
for (const item of playbackItems) {
|
||||
try {
|
||||
// Skip items with very low or very high progress
|
||||
if (item.progress <= 0 || item.progress >= 85) continue;
|
||||
|
||||
if (item.type === 'movie' && item.movie?.ids?.imdb) {
|
||||
const imdbId = item.movie.ids.imdb.startsWith('tt')
|
||||
? item.movie.ids.imdb
|
||||
: `tt${item.movie.ids.imdb}`;
|
||||
|
||||
// Check if recently removed
|
||||
const movieKey = `movie:${imdbId}`;
|
||||
if (recentlyRemovedRef.current.has(movieKey)) continue;
|
||||
|
||||
const cachedData = await getCachedMetadata('movie', imdbId);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
|
||||
const pausedAt = new Date(item.paused_at).getTime();
|
||||
traktBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: imdbId,
|
||||
type: 'movie',
|
||||
progress: item.progress,
|
||||
lastUpdated: pausedAt,
|
||||
} as ContinueWatchingItem);
|
||||
|
||||
logger.log(`📺 [TraktPlayback] Adding movie ${item.movie.title} with ${item.progress.toFixed(1)}% progress`);
|
||||
|
||||
} else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) {
|
||||
const showImdb = item.show.ids.imdb.startsWith('tt')
|
||||
? item.show.ids.imdb
|
||||
: `tt${item.show.ids.imdb}`;
|
||||
|
||||
// Check if recently removed
|
||||
const showKey = `series:${showImdb}`;
|
||||
if (recentlyRemovedRef.current.has(showKey)) continue;
|
||||
|
||||
// Check reset_at - skip if this was paused before re-watch started
|
||||
const resetTime = showResetMap[showImdb];
|
||||
const pausedAt = new Date(item.paused_at).getTime();
|
||||
if (resetTime && pausedAt < resetTime) {
|
||||
logger.log(`🔄 [TraktPlayback] Skipping ${showImdb} S${item.episode.season}E${item.episode.number} - paused before reset_at`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cachedData = await getCachedMetadata('series', showImdb);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
|
||||
traktBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: showImdb,
|
||||
type: 'series',
|
||||
progress: item.progress,
|
||||
lastUpdated: pausedAt,
|
||||
season: item.episode.season,
|
||||
episode: item.episode.number,
|
||||
episodeTitle: item.episode.title || `Episode ${item.episode.number}`,
|
||||
} as ContinueWatchingItem);
|
||||
|
||||
processedShows.add(showImdb);
|
||||
logger.log(`📺 [TraktPlayback] Adding ${item.show.title} S${item.episode.season}E${item.episode.number} with ${item.progress.toFixed(1)}% progress`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Continue with other items
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 2: Process watch history for shows NOT in playback progress
|
||||
// Find the next episode for completed shows
|
||||
const latestWatchedByShow: Record<string, { season: number; episode: number; watchedAt: number }> = {};
|
||||
for (const item of historyItems) {
|
||||
if (item.type !== 'episode') continue;
|
||||
const showImdb = item.show?.ids?.imdb ? `tt${item.show.ids.imdb.replace(/^tt/, '')}` : null;
|
||||
const showImdb = item.show?.ids?.imdb
|
||||
? (item.show.ids.imdb.startsWith('tt') ? item.show.ids.imdb : `tt${item.show.ids.imdb}`)
|
||||
: null;
|
||||
if (!showImdb) continue;
|
||||
|
||||
// Skip if we already have an in-progress episode for this show
|
||||
if (processedShows.has(showImdb)) continue;
|
||||
|
||||
const season = item.episode?.season;
|
||||
const epNum = item.episode?.number;
|
||||
if (season === undefined || epNum === undefined) continue;
|
||||
|
||||
const watchedAt = new Date(item.watched_at).getTime();
|
||||
|
||||
// Check reset_at - skip episodes watched before re-watch started
|
||||
const resetTime = showResetMap[showImdb];
|
||||
if (resetTime && watchedAt < resetTime) {
|
||||
continue; // This was watched in a previous viewing
|
||||
}
|
||||
|
||||
const existing = latestWatchedByShow[showImdb];
|
||||
if (!existing || existing.watchedAt < watchedAt) {
|
||||
latestWatchedByShow[showImdb] = { season, episode: epNum, watchedAt };
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all valid Trakt items first, then merge as a batch
|
||||
const traktBatch: ContinueWatchingItem[] = [];
|
||||
|
||||
// Add next episodes for completed shows
|
||||
for (const [showId, info] of Object.entries(latestWatchedByShow)) {
|
||||
try {
|
||||
// Check if this show was recently removed by the user
|
||||
// Check if this show was recently removed
|
||||
const showKey = `series:${showId}`;
|
||||
if (recentlyRemovedRef.current.has(showKey)) {
|
||||
logger.log(`🚫 [TraktSync] Skipping recently removed show: ${showKey}`);
|
||||
|
|
@ -659,7 +762,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
...basicContent,
|
||||
id: showId,
|
||||
type: 'series',
|
||||
progress: 0,
|
||||
progress: 0, // Next episode, not started
|
||||
lastUpdated: info.watchedAt,
|
||||
season: nextEpisodeVideo.season,
|
||||
episode: nextEpisodeVideo.episode,
|
||||
|
|
@ -668,13 +771,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
}
|
||||
|
||||
// Persist "watched" progress for the episode that Trakt reported (only if not recently removed)
|
||||
// Persist "watched" progress for the episode that Trakt reported
|
||||
if (!recentlyRemovedRef.current.has(showKey)) {
|
||||
const watchedEpisodeId = `${showId}:${info.season}:${info.episode}`;
|
||||
const existingProgress = allProgress[`series:${showId}:${watchedEpisodeId}`];
|
||||
const existingPercent = existingProgress ? (existingProgress.currentTime / existingProgress.duration) * 100 : 0;
|
||||
if (!existingProgress || existingPercent < 85) {
|
||||
logger.log(`💾 [TraktSync] Adding local progress for ${showId}: S${info.season}E${info.episode}`);
|
||||
await storageService.setWatchProgress(
|
||||
showId,
|
||||
'series',
|
||||
|
|
@ -688,20 +790,19 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
`${info.season}:${info.episode}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.log(`🚫 [TraktSync] Skipping local progress for recently removed show: ${showKey}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Continue with other shows even if one fails
|
||||
// Continue with other shows
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all Trakt items as a single batch to ensure proper sorting
|
||||
if (traktBatch.length > 0) {
|
||||
logger.log(`📋 [TraktSync] Merging ${traktBatch.length} items from Trakt (playback + history)`);
|
||||
await mergeBatchIntoState(traktBatch);
|
||||
}
|
||||
} catch (err) {
|
||||
// Continue even if Trakt history merge fails
|
||||
logger.error('[TraktSync] Error in Trakt merge:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
@ -1157,9 +1258,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<View
|
||||
style={styles.container}
|
||||
entering={FadeIn.duration(350)}
|
||||
>
|
||||
<View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
|
||||
<View style={styles.titleContainer}>
|
||||
|
|
@ -1207,7 +1307,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
actions={alertActions}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [failedLogoIds, setFailedLogoIds] = useState<Set<string>>(new Set());
|
||||
const scrollViewRef = useRef<any>(null);
|
||||
const [isScrollReady, setIsScrollReady] = useState(false);
|
||||
const [flippedMap, setFlippedMap] = useState<Record<string, boolean>>({});
|
||||
const toggleFlipById = useCallback((id: string) => {
|
||||
setFlippedMap((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
|
|
@ -95,9 +96,9 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
// Optimized: update background as soon as scroll starts, without waiting for momentum end
|
||||
const scrollX = useSharedValue(0);
|
||||
const paginationProgress = useSharedValue(0);
|
||||
|
||||
|
||||
// Parallel image prefetch: start fetching banners and logos as soon as data arrives
|
||||
const itemsToPreload = useMemo(() => data.slice(0, 12), [data]);
|
||||
const itemsToPreload = useMemo(() => data.slice(0, 3), [data]);
|
||||
useEffect(() => {
|
||||
if (!itemsToPreload.length) return;
|
||||
try {
|
||||
|
|
@ -121,17 +122,19 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
// no-op: prefetch is best-effort
|
||||
}
|
||||
}, [itemsToPreload]);
|
||||
|
||||
|
||||
// Comprehensive reset when component mounts/remounts to prevent glitching
|
||||
useEffect(() => {
|
||||
// Start at the first real item for looping
|
||||
scrollX.value = loopingEnabled ? interval : 0;
|
||||
setActiveIndex(0);
|
||||
setIsScrollReady(false);
|
||||
|
||||
// Scroll to position 0 after a brief delay to ensure ScrollView is ready
|
||||
// Scroll to position and mark ready after layout
|
||||
const timer = setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({ x: loopingEnabled ? interval : 0, y: 0, animated: false });
|
||||
}, 50);
|
||||
setIsScrollReady(true);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
|
@ -141,10 +144,12 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
if (data.length > 0) {
|
||||
scrollX.value = loopingEnabled ? interval : 0;
|
||||
setActiveIndex(0);
|
||||
setIsScrollReady(false);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({ x: loopingEnabled ? interval : 0, y: 0, animated: false });
|
||||
}, 100);
|
||||
setIsScrollReady(true);
|
||||
}, 150);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
|
@ -158,7 +163,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [windowWidth, windowHeight, interval, loopingEnabled]);
|
||||
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll: (event) => {
|
||||
scrollX.value = event.contentOffset.x;
|
||||
|
|
@ -192,12 +197,12 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
},
|
||||
(idx, prevIdx) => {
|
||||
if (idx == null || idx === prevIdx) return;
|
||||
|
||||
|
||||
// Debounce updates to reduce JS bridge crossings
|
||||
const now = Date.now();
|
||||
if (now - lastIndexUpdateRef.current < 100) return; // 100ms debounce
|
||||
lastIndexUpdateRef.current = now;
|
||||
|
||||
|
||||
// Clamp to bounds to avoid out-of-range access
|
||||
const clamped = Math.max(0, Math.min(idx, data.length - 1));
|
||||
runOnJS(setActiveIndex)(clamped);
|
||||
|
|
@ -263,22 +268,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
height: cardHeight,
|
||||
}
|
||||
] as StyleProp<ViewStyle>}>
|
||||
<View style={styles.bannerContainer as ViewStyle}>
|
||||
<View style={styles.skeletonBannerFull as ViewStyle} />
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.25)"]}
|
||||
locations={[0.6, 1]}
|
||||
style={styles.bannerOverlay as ViewStyle}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.info as ViewStyle}>
|
||||
<View style={[styles.skeletonLine, { width: '62%' }] as StyleProp<ViewStyle>} />
|
||||
<View style={[styles.skeletonLine, { width: '44%', marginTop: 6 }] as StyleProp<ViewStyle>} />
|
||||
<View style={styles.skeletonActions as ViewStyle}>
|
||||
<View style={[styles.skeletonPill, { width: 96 }] as StyleProp<ViewStyle>} />
|
||||
<View style={[styles.skeletonPill, { width: 80 }] as StyleProp<ViewStyle>} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.skeletonBannerFull as ViewStyle} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
|
@ -289,11 +279,11 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
}
|
||||
|
||||
// Memoized background component with improved timing
|
||||
const BackgroundImage = React.memo(({
|
||||
item,
|
||||
const BackgroundImage = React.memo(({
|
||||
item,
|
||||
insets
|
||||
}: {
|
||||
item: StreamingContent;
|
||||
}: {
|
||||
item: StreamingContent;
|
||||
insets: any;
|
||||
}) => {
|
||||
return (
|
||||
|
|
@ -317,7 +307,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
) : (
|
||||
<>
|
||||
<FastImage
|
||||
source={{
|
||||
source={{
|
||||
uri: item.banner || item.poster,
|
||||
priority: FastImage.priority.low,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
|
|
@ -352,15 +342,15 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
if (!hasData) return null;
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeIn.duration(150).easing(Easing.out(Easing.cubic))}>
|
||||
<View>
|
||||
<Animated.View style={[styles.container as ViewStyle, { paddingTop: 12 + effectiveTopOffset }]}>
|
||||
{/* Removed preload images for performance - let FastImage cache handle it naturally */}
|
||||
{settings.enableHomeHeroBackground && data[activeIndex] && (
|
||||
<BackgroundImage
|
||||
item={data[activeIndex]}
|
||||
insets={insets}
|
||||
/>
|
||||
)}
|
||||
{settings.enableHomeHeroBackground && data[activeIndex] && (
|
||||
<BackgroundImage
|
||||
item={data[activeIndex]}
|
||||
insets={insets}
|
||||
/>
|
||||
)}
|
||||
{/* Bottom blend to HomeScreen background (not the card) */}
|
||||
{settings.enableHomeHeroBackground && (
|
||||
<LinearGradient
|
||||
|
|
@ -383,6 +373,8 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
pagingEnabled={false}
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
style={{ opacity: isScrollReady ? 1 : 0 }}
|
||||
contentOffset={{ x: loopingEnabled ? interval : 0, y: 0 }}
|
||||
onMomentumScrollEnd={(e) => {
|
||||
if (!loopingEnabled) return;
|
||||
// Determine current page index in cloned space
|
||||
|
|
@ -400,6 +392,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
}}
|
||||
>
|
||||
{(loopingEnabled ? loopData : data).map((item, index) => (
|
||||
/* TEST 5: ORIGINAL CARD WITHOUT LINEAR GRADIENT */
|
||||
<CarouselCard
|
||||
key={`${item.id}-${index}-${loopingEnabled ? 'loop' : 'base'}`}
|
||||
item={item}
|
||||
|
|
@ -444,10 +437,162 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
}}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// MINIMAL ANIMATED CARD FOR PERFORMANCE TESTING
|
||||
interface AnimatedCardWrapperProps {
|
||||
item: StreamingContent;
|
||||
index: number;
|
||||
scrollX: SharedValue<number>;
|
||||
interval: number;
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
colors: any;
|
||||
isTablet: boolean;
|
||||
}
|
||||
|
||||
const AnimatedCardWrapper: React.FC<AnimatedCardWrapperProps> = memo(({
|
||||
item, index, scrollX, interval, cardWidth, cardHeight, colors, isTablet
|
||||
}) => {
|
||||
const cardAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * interval;
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
|
||||
if (distance > interval * 1.5) {
|
||||
return {
|
||||
transform: [{ scale: isTablet ? 0.95 : 0.9 }],
|
||||
opacity: isTablet ? 0.85 : 0.7
|
||||
};
|
||||
}
|
||||
|
||||
const maxDistance = interval;
|
||||
const scale = 1 - (distance / maxDistance) * 0.1;
|
||||
const clampedScale = Math.max(isTablet ? 0.95 : 0.9, Math.min(1, scale));
|
||||
const opacity = 1 - (distance / maxDistance) * 0.3;
|
||||
const clampedOpacity = Math.max(isTablet ? 0.85 : 0.7, Math.min(1, opacity));
|
||||
|
||||
return {
|
||||
transform: [{ scale: clampedScale }],
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
const logoOpacity = useSharedValue(0);
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
const isFlipped = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (logoLoaded) {
|
||||
logoOpacity.value = withTiming(1, { duration: 300, easing: Easing.out(Easing.ease) });
|
||||
}
|
||||
}, [logoLoaded]);
|
||||
|
||||
const logoAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: logoOpacity.value,
|
||||
}));
|
||||
|
||||
// TEST 4: FLIP STYLES
|
||||
const frontFlipStyle = useAnimatedStyle(() => {
|
||||
const rotate = interpolate(isFlipped.value, [0, 1], [0, 180]);
|
||||
return {
|
||||
transform: [
|
||||
{ perspective: 1000 },
|
||||
{ rotateY: `${rotate}deg` },
|
||||
],
|
||||
} as any;
|
||||
});
|
||||
|
||||
const backFlipStyle = useAnimatedStyle(() => {
|
||||
const rotate = interpolate(isFlipped.value, [0, 1], [-180, 0]);
|
||||
return {
|
||||
transform: [
|
||||
{ perspective: 1000 },
|
||||
{ rotateY: `${rotate}deg` },
|
||||
],
|
||||
} as any;
|
||||
});
|
||||
|
||||
// TEST 4: OVERLAY ANIMATED STYLE (genres opacity on scroll)
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * interval;
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
|
||||
if (distance > interval * 1.2) {
|
||||
return { opacity: 0 };
|
||||
}
|
||||
|
||||
const maxDistance = interval * 0.5;
|
||||
const progress = Math.min(distance / maxDistance, 1);
|
||||
const opacity = 1 - progress;
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
|
||||
return {
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{ width: cardWidth + 16 }}>
|
||||
<Animated.View style={[
|
||||
{
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
backgroundColor: colors.elevation1,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cardAnimatedStyle
|
||||
]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.banner || item.poster,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={{ width: '100%', height: '100%', position: 'absolute' }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.6)"]}
|
||||
locations={[0.4, 0.7, 1]}
|
||||
style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }}
|
||||
/>
|
||||
{item.logo && (
|
||||
<View style={{ position: 'absolute', left: 0, right: 0, bottom: 40, alignItems: 'center' }}>
|
||||
<Animated.View style={logoAnimatedStyle}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.logo,
|
||||
priority: FastImage.priority.high,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={{ width: Math.round(cardWidth * 0.72), height: 64 }}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
onLoad={() => setLogoLoaded(true)}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
{/* TEST 4: GENRES with overlayAnimatedStyle */}
|
||||
{item.genres && (
|
||||
<View style={{ position: 'absolute', left: 0, right: 0, bottom: 12, alignItems: 'center' }}>
|
||||
<Animated.Text
|
||||
style={[{ color: 'rgba(255,255,255,0.7)', fontSize: 13, textAlign: 'center' }, overlayAnimatedStyle]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.genres.slice(0, 3).join(' • ')}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
interface CarouselCardProps {
|
||||
item: StreamingContent;
|
||||
colors: any;
|
||||
|
|
@ -467,13 +612,13 @@ interface CarouselCardProps {
|
|||
const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index, flipped, onToggleFlip, interval, cardWidth, cardHeight, isTablet }) => {
|
||||
const [bannerLoaded, setBannerLoaded] = useState(false);
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
|
||||
|
||||
const bannerOpacity = useSharedValue(0);
|
||||
const logoOpacity = useSharedValue(0);
|
||||
const genresOpacity = useSharedValue(0);
|
||||
const actionsOpacity = useSharedValue(0);
|
||||
const isFlipped = useSharedValue(flipped ? 1 : 0);
|
||||
|
||||
|
||||
// Reset animations when component mounts/remounts to prevent glitching
|
||||
useEffect(() => {
|
||||
bannerOpacity.value = 0;
|
||||
|
|
@ -484,17 +629,17 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
setBannerLoaded(false);
|
||||
setLogoLoaded(false);
|
||||
}, [item.id]);
|
||||
|
||||
|
||||
const inputRange = [
|
||||
(index - 1) * interval,
|
||||
index * interval,
|
||||
(index + 1) * interval,
|
||||
];
|
||||
|
||||
|
||||
const bannerAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: bannerOpacity.value,
|
||||
}));
|
||||
|
||||
|
||||
const logoAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: logoOpacity.value,
|
||||
}));
|
||||
|
|
@ -538,52 +683,52 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
const translateX = scrollX.value;
|
||||
const cardOffset = index * interval;
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
|
||||
|
||||
// AGGRESSIVE early exit for cards far from center
|
||||
if (distance > interval * 1.2) {
|
||||
return { opacity: 0 };
|
||||
}
|
||||
|
||||
|
||||
const maxDistance = interval * 0.5;
|
||||
const progress = Math.min(distance / maxDistance, 1);
|
||||
const opacity = 1 - progress;
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
|
||||
|
||||
return {
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// ULTRA-OPTIMIZED: Only animate center card and ±1 neighbors
|
||||
const cardAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * interval;
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
|
||||
|
||||
// AGGRESSIVE early exit for cards far from center
|
||||
if (distance > interval * 1.5) {
|
||||
return {
|
||||
transform: [{ scale: isTablet ? 0.95 : 0.9 }],
|
||||
opacity: isTablet ? 0.85 : 0.7
|
||||
return {
|
||||
transform: [{ scale: isTablet ? 0.95 : 0.9 }],
|
||||
opacity: isTablet ? 0.85 : 0.7
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const maxDistance = interval;
|
||||
|
||||
|
||||
// Scale animation based on distance from center
|
||||
const scale = 1 - (distance / maxDistance) * 0.1;
|
||||
const clampedScale = Math.max(isTablet ? 0.95 : 0.9, Math.min(1, scale));
|
||||
|
||||
|
||||
// Opacity animation for cards that are far from center
|
||||
const opacity = 1 - (distance / maxDistance) * 0.3;
|
||||
const clampedOpacity = Math.max(isTablet ? 0.85 : 0.7, Math.min(1, opacity));
|
||||
|
||||
|
||||
return {
|
||||
transform: [{ scale: clampedScale }],
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// TEMPORARILY DISABLED FOR PERFORMANCE TESTING
|
||||
// const bannerParallaxStyle = useAnimatedStyle(() => {
|
||||
// const translateX = scrollX.value;
|
||||
|
|
@ -597,7 +742,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
// transform: [{ translateX: parallaxOffset }],
|
||||
// };
|
||||
// });
|
||||
|
||||
|
||||
// TEMPORARILY DISABLED FOR PERFORMANCE TESTING
|
||||
// const infoParallaxStyle = useAnimatedStyle(() => {
|
||||
// const translateX = scrollX.value;
|
||||
|
|
@ -618,21 +763,21 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
// opacity: clampedOpacity,
|
||||
// };
|
||||
// });
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (bannerLoaded) {
|
||||
bannerOpacity.value = withTiming(1, {
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.ease)
|
||||
bannerOpacity.value = withTiming(1, {
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.ease)
|
||||
});
|
||||
}
|
||||
}, [bannerLoaded]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (logoLoaded) {
|
||||
logoOpacity.value = withTiming(1, {
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.ease)
|
||||
logoOpacity.value = withTiming(1, {
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.ease)
|
||||
});
|
||||
}
|
||||
}, [logoLoaded]);
|
||||
|
|
@ -669,11 +814,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
onLoad={() => setBannerLoaded(true)}
|
||||
/>
|
||||
</Animated.View>
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,0.18)", "rgba(0,0,0,0.72)"]}
|
||||
locations={[0.3, 1]}
|
||||
style={styles.bannerGradient as ViewStyle}
|
||||
/>
|
||||
{/* Overlay removed for performance - readability via text shadows */}
|
||||
</View>
|
||||
<View style={styles.backContent as ViewStyle}>
|
||||
{item.logo && !logoFailed ? (
|
||||
|
|
@ -733,11 +874,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
onLoad={() => setBannerLoaded(true)}
|
||||
/>
|
||||
</Animated.View>
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.6)"]}
|
||||
locations={[0.4, 0.7, 1]}
|
||||
style={styles.bannerGradient as ViewStyle}
|
||||
/>
|
||||
{/* Overlay removed for performance - readability via text shadows */}
|
||||
</View>
|
||||
{item.logo && !logoFailed ? (
|
||||
<View style={styles.logoOverlay as ViewStyle} pointerEvents="none">
|
||||
|
|
@ -757,23 +894,23 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
</View>
|
||||
) : (
|
||||
<View style={styles.titleOverlay as ViewStyle} pointerEvents="none">
|
||||
<Animated.View entering={FadeIn.duration(300)}>
|
||||
<View>
|
||||
<Text style={[styles.title as TextStyle, { color: colors.highEmphasis, textAlign: 'center' }]} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{item.genres && (
|
||||
<View style={styles.genresOverlay as ViewStyle} pointerEvents="none">
|
||||
<Animated.View entering={FadeIn.duration(400).delay(100)}>
|
||||
<View>
|
||||
<Animated.Text
|
||||
style={[styles.genres as TextStyle, { color: colors.mediumEmphasis, textAlign: 'center' }, overlayAnimatedStyle]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.genres.slice(0, 3).join(' • ')}
|
||||
</Animated.Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
|
@ -787,11 +924,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
style={styles.banner as any}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,0.25)", "rgba(0,0,0,0.85)"]}
|
||||
locations={[0.3, 1]}
|
||||
style={styles.bannerGradient as ViewStyle}
|
||||
/>
|
||||
{/* Overlay removed for performance - readability via text shadows */}
|
||||
</View>
|
||||
<View style={styles.backContent as ViewStyle}>
|
||||
{item.logo && !logoFailed ? (
|
||||
|
|
@ -980,7 +1113,7 @@ const styles = StyleSheet.create({
|
|||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.18)'
|
||||
},
|
||||
|
||||
|
||||
info: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,38 @@
|
|||
import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import React, { useEffect, useRef, forwardRef, useImperativeHandle, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
Animated,
|
||||
StatusBar,
|
||||
Easing,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
withSequence,
|
||||
withDelay,
|
||||
Easing,
|
||||
interpolate,
|
||||
cancelAnimation,
|
||||
runOnJS,
|
||||
SharedValue,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
// Responsive breakpoints
|
||||
const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
};
|
||||
|
||||
interface MetadataLoadingScreenProps {
|
||||
type?: 'movie' | 'series';
|
||||
onExitComplete?: () => void;
|
||||
|
|
@ -23,44 +42,120 @@ export interface MetadataLoadingScreenRef {
|
|||
exit: () => void;
|
||||
}
|
||||
|
||||
// Animated shimmer skeleton component
|
||||
const ShimmerSkeleton = ({
|
||||
width: elementWidth,
|
||||
height: elementHeight,
|
||||
borderRadius = 8,
|
||||
marginBottom = 8,
|
||||
style = {},
|
||||
delay = 0,
|
||||
shimmerProgress,
|
||||
baseColor,
|
||||
highlightColor,
|
||||
}: {
|
||||
width: number | string;
|
||||
height: number;
|
||||
borderRadius?: number;
|
||||
marginBottom?: number;
|
||||
style?: any;
|
||||
delay?: number;
|
||||
shimmerProgress: SharedValue<number>;
|
||||
baseColor: string;
|
||||
highlightColor: string;
|
||||
}) => {
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = interpolate(
|
||||
shimmerProgress.value,
|
||||
[0, 1],
|
||||
[-width, width]
|
||||
);
|
||||
return {
|
||||
transform: [{ translateX }],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
{
|
||||
width: elementWidth,
|
||||
height: elementHeight,
|
||||
borderRadius,
|
||||
marginBottom,
|
||||
backgroundColor: baseColor,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
style
|
||||
]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'transparent',
|
||||
highlightColor,
|
||||
highlightColor,
|
||||
'transparent',
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[StyleSheet.absoluteFill, { width: width * 2 }]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, MetadataLoadingScreenProps>(({
|
||||
type = 'movie',
|
||||
onExitComplete
|
||||
}, ref) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Animation values - shimmer removed
|
||||
|
||||
// Scene transition animation values (matching tab navigator)
|
||||
const sceneOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sceneScale = useRef(new Animated.Value(0.95)).current;
|
||||
const sceneTranslateY = useRef(new Animated.Value(8)).current;
|
||||
|
||||
// Responsive sizing
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const deviceType = useMemo(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
|
||||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
|
||||
const isTV = deviceType === 'tv';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTablet = deviceType === 'tablet';
|
||||
|
||||
const horizontalPadding = isTV ? 48 : isLargeTablet ? 32 : isTablet ? 24 : 16;
|
||||
|
||||
|
||||
// Shimmer animation
|
||||
const shimmerProgress = useSharedValue(0);
|
||||
|
||||
// Staggered fade-in for sections
|
||||
const heroOpacity = useSharedValue(0);
|
||||
const contentOpacity = useSharedValue(0);
|
||||
const castOpacity = useSharedValue(0);
|
||||
|
||||
// Exit animation value
|
||||
const exitProgress = useSharedValue(0);
|
||||
|
||||
// Colors for skeleton
|
||||
const baseColor = currentTheme.colors.elevation1 || 'rgba(255,255,255,0.08)';
|
||||
const highlightColor = 'rgba(255,255,255,0.12)';
|
||||
|
||||
// Exit animation function
|
||||
const exit = () => {
|
||||
const exitAnimation = Animated.parallel([
|
||||
Animated.timing(sceneOpacity, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sceneScale, {
|
||||
toValue: 0.95,
|
||||
duration: 100,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sceneTranslateY, {
|
||||
toValue: 8,
|
||||
duration: 100,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
exitAnimation.start(() => {
|
||||
onExitComplete?.();
|
||||
exitProgress.value = withTiming(1, {
|
||||
duration: 200,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
}, (finished) => {
|
||||
'worklet';
|
||||
if (finished && onExitComplete) {
|
||||
runOnJS(onExitComplete)();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -70,70 +165,57 @@ export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, Metada
|
|||
}));
|
||||
|
||||
useEffect(() => {
|
||||
// Scene entrance animation (matching tab navigator)
|
||||
const sceneAnimation = Animated.parallel([
|
||||
Animated.timing(sceneOpacity, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
useNativeDriver: true,
|
||||
// Start shimmer animation
|
||||
shimmerProgress.value = withRepeat(
|
||||
withTiming(1, {
|
||||
duration: 1500,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0)
|
||||
}),
|
||||
Animated.timing(sceneScale, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sceneTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]);
|
||||
-1, // infinite
|
||||
false
|
||||
);
|
||||
|
||||
sceneAnimation.start();
|
||||
|
||||
// Shimmer effect removed
|
||||
// Staggered entrance animations
|
||||
heroOpacity.value = withTiming(1, { duration: 300 });
|
||||
contentOpacity.value = withDelay(100, withTiming(1, { duration: 300 }));
|
||||
castOpacity.value = withDelay(200, withTiming(1, { duration: 300 }));
|
||||
|
||||
return () => {
|
||||
sceneAnimation.stop();
|
||||
cancelAnimation(shimmerProgress);
|
||||
cancelAnimation(heroOpacity);
|
||||
cancelAnimation(contentOpacity);
|
||||
cancelAnimation(castOpacity);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Shimmer translate removed
|
||||
// Animated styles
|
||||
const containerStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(exitProgress.value, [0, 1], [1, 0]),
|
||||
transform: [
|
||||
{ scale: interpolate(exitProgress.value, [0, 1], [1, 0.98]) },
|
||||
],
|
||||
}));
|
||||
|
||||
const SkeletonElement = ({
|
||||
width: elementWidth,
|
||||
height: elementHeight,
|
||||
borderRadius = 8,
|
||||
marginBottom = 8,
|
||||
style = {},
|
||||
}: {
|
||||
width: number | string;
|
||||
height: number;
|
||||
borderRadius?: number;
|
||||
marginBottom?: number;
|
||||
style?: any;
|
||||
}) => (
|
||||
<View style={[
|
||||
{
|
||||
width: elementWidth,
|
||||
height: elementHeight,
|
||||
borderRadius,
|
||||
marginBottom,
|
||||
backgroundColor: currentTheme.colors.card,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
style
|
||||
]}>
|
||||
{/* Pulsating overlay removed */}
|
||||
{/* Shimmer overlay removed */}
|
||||
</View>
|
||||
);
|
||||
const heroStyle = useAnimatedStyle(() => ({
|
||||
opacity: heroOpacity.value,
|
||||
}));
|
||||
|
||||
const contentStyle = useAnimatedStyle(() => ({
|
||||
opacity: contentOpacity.value,
|
||||
transform: [
|
||||
{ translateY: interpolate(contentOpacity.value, [0, 1], [10, 0]) },
|
||||
],
|
||||
}));
|
||||
|
||||
const castStyle = useAnimatedStyle(() => ({
|
||||
opacity: castOpacity.value,
|
||||
transform: [
|
||||
{ translateY: interpolate(castOpacity.value, [0, 1], [10, 0]) },
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
<SafeAreaView
|
||||
style={[styles.container, {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
}]}
|
||||
|
|
@ -144,107 +226,325 @@ export const MetadataLoadingScreen = forwardRef<MetadataLoadingScreenRef, Metada
|
|||
backgroundColor="transparent"
|
||||
barStyle="light-content"
|
||||
/>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
opacity: sceneOpacity,
|
||||
transform: [
|
||||
{ scale: sceneScale },
|
||||
{ translateY: sceneTranslateY }
|
||||
],
|
||||
}
|
||||
]}
|
||||
>
|
||||
{/* Hero Skeleton */}
|
||||
<View style={styles.heroSection}>
|
||||
<SkeletonElement
|
||||
width="100%"
|
||||
height={height * 0.6}
|
||||
|
||||
<Animated.View style={[styles.content, containerStyle]}>
|
||||
{/* Hero Section Skeleton */}
|
||||
<Animated.View style={[styles.heroSection, { height: height * 0.65 }, heroStyle]}>
|
||||
<ShimmerSkeleton
|
||||
width="100%"
|
||||
height={height * 0.65}
|
||||
borderRadius={0}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
|
||||
{/* Overlay content on hero */}
|
||||
|
||||
{/* Back Button Skeleton */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'android' ? 40 : 50,
|
||||
left: isTablet ? 32 : 16,
|
||||
zIndex: 10
|
||||
}}>
|
||||
<ShimmerSkeleton
|
||||
width={40}
|
||||
height={40}
|
||||
borderRadius={20}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Gradient overlay */}
|
||||
<View style={styles.heroOverlay}>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'transparent',
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.8)',
|
||||
'rgba(0,0,0,0.05)',
|
||||
'rgba(0,0,0,0.15)',
|
||||
'rgba(0,0,0,0.35)',
|
||||
'rgba(0,0,0,0.65)',
|
||||
currentTheme.colors.darkBackground,
|
||||
]}
|
||||
locations={[0, 0.3, 0.55, 0.75, 0.9, 1]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
{/* Bottom hero content skeleton */}
|
||||
<View style={styles.heroBottomContent}>
|
||||
<SkeletonElement width="60%" height={32} borderRadius={16} />
|
||||
<SkeletonElement width="40%" height={20} borderRadius={10} />
|
||||
<View style={styles.genresRow}>
|
||||
<SkeletonElement width={80} height={24} borderRadius={12} marginBottom={0} style={{ marginRight: 8 }} />
|
||||
<SkeletonElement width={90} height={24} borderRadius={12} marginBottom={0} style={{ marginRight: 8 }} />
|
||||
<SkeletonElement width={70} height={24} borderRadius={12} marginBottom={0} />
|
||||
{/* Hero bottom content - Matches HeroSection.tsx structure */}
|
||||
<View style={[styles.heroBottomContent, { paddingHorizontal: horizontalPadding }]}>
|
||||
{/* Logo placeholder - Centered and larger */}
|
||||
<View style={{ alignItems: 'center', width: '100%', marginBottom: 16 }}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 400 : isLargeTablet ? 300 : width * 0.65}
|
||||
height={isTV ? 120 : isLargeTablet ? 100 : 90}
|
||||
borderRadius={12}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.buttonsRow}>
|
||||
<SkeletonElement width={120} height={44} borderRadius={22} marginBottom={0} style={{ marginRight: 12 }} />
|
||||
<SkeletonElement width={100} height={44} borderRadius={22} marginBottom={0} />
|
||||
|
||||
{/* Watch Progress Placeholder - Centered Glass Bar */}
|
||||
<View style={{ alignItems: 'center', width: '100%', marginBottom: 16 }}>
|
||||
<ShimmerSkeleton
|
||||
width="75%"
|
||||
height={45} // Matches glass background height + padding
|
||||
borderRadius={12}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
style={{ opacity: 0.5 }} // Slight transparency for glass effect
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Genre Info Row - Centered */}
|
||||
<View style={[styles.metaRow, { justifyContent: 'center', marginBottom: 20 }]}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 60 : 50}
|
||||
height={12}
|
||||
borderRadius={6}
|
||||
marginBottom={0}
|
||||
style={{ marginRight: 8 }}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)', marginRight: 8 }} />
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 80 : 70}
|
||||
height={12}
|
||||
borderRadius={6}
|
||||
marginBottom={0}
|
||||
style={{ marginRight: 8 }}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.3)', marginRight: 8 }} />
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 50 : 40}
|
||||
height={12}
|
||||
borderRadius={6}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Action buttons row - Play, Save, Collection, Rates */}
|
||||
<View style={[styles.buttonsRow, { justifyContent: 'center', gap: 6 }]}>
|
||||
{/* Play Button */}
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 180 : isLargeTablet ? 160 : isTablet ? 150 : (width - 32 - 100 - 24) / 2} // Calc based on screen width
|
||||
height={isTV ? 52 : isLargeTablet ? 48 : 46}
|
||||
borderRadius={isTV ? 26 : 23}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
|
||||
{/* Save Button */}
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 180 : isLargeTablet ? 160 : isTablet ? 150 : (width - 32 - 100 - 24) / 2}
|
||||
height={isTV ? 52 : isLargeTablet ? 48 : 46}
|
||||
borderRadius={isTV ? 26 : 23}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
|
||||
{/* Collection Icon */}
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 52 : isLargeTablet ? 48 : 46}
|
||||
height={isTV ? 52 : isLargeTablet ? 48 : 46}
|
||||
borderRadius={isTV ? 26 : 23}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
|
||||
{/* Ratings Icon (if series) - Always show for skeleton consistency */}
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 52 : isLargeTablet ? 48 : 46}
|
||||
height={isTV ? 52 : isLargeTablet ? 48 : 46}
|
||||
borderRadius={isTV ? 26 : 23}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Content Section Skeletons */}
|
||||
<View style={styles.contentSection}>
|
||||
{/* Synopsis skeleton */}
|
||||
<View style={styles.synopsisSection}>
|
||||
<SkeletonElement width="30%" height={24} borderRadius={12} />
|
||||
<SkeletonElement width="100%" height={16} borderRadius={8} />
|
||||
<SkeletonElement width="95%" height={16} borderRadius={8} />
|
||||
<SkeletonElement width="80%" height={16} borderRadius={8} />
|
||||
{/* Content Section */}
|
||||
<Animated.View style={[styles.contentSection, { paddingHorizontal: horizontalPadding }, contentStyle]}>
|
||||
{/* Description skeleton */}
|
||||
<View style={styles.descriptionSection}>
|
||||
<ShimmerSkeleton
|
||||
width="100%"
|
||||
height={isTV ? 18 : 15}
|
||||
borderRadius={4}
|
||||
marginBottom={10}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<ShimmerSkeleton
|
||||
width="95%"
|
||||
height={isTV ? 18 : 15}
|
||||
borderRadius={4}
|
||||
marginBottom={10}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<ShimmerSkeleton
|
||||
width="75%"
|
||||
height={isTV ? 18 : 15}
|
||||
borderRadius={4}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Cast section skeleton */}
|
||||
<View style={styles.castSection}>
|
||||
<SkeletonElement width="20%" height={24} borderRadius={12} />
|
||||
<View style={styles.castRow}>
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<View key={item} style={styles.castItem}>
|
||||
<SkeletonElement width={80} height={80} borderRadius={40} marginBottom={8} />
|
||||
<SkeletonElement width={60} height={12} borderRadius={6} marginBottom={4} />
|
||||
<SkeletonElement width={70} height={10} borderRadius={5} marginBottom={0} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{/* Cast Section */}
|
||||
<Animated.View style={[styles.castSection, { paddingHorizontal: horizontalPadding }, castStyle]}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 80 : 60}
|
||||
height={isTV ? 24 : 20}
|
||||
borderRadius={4}
|
||||
marginBottom={16}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<View style={styles.castRow}>
|
||||
{[1, 2, 3, 4, 5].map((item) => (
|
||||
<View key={item} style={styles.castItem}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 100 : isLargeTablet ? 90 : isTablet ? 85 : 80}
|
||||
height={isTV ? 100 : isLargeTablet ? 90 : isTablet ? 85 : 80}
|
||||
borderRadius={isTV ? 50 : isLargeTablet ? 45 : isTablet ? 42 : 40}
|
||||
marginBottom={8}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 70 : 60}
|
||||
height={isTV ? 14 : 12}
|
||||
borderRadius={4}
|
||||
marginBottom={4}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Episodes/Details skeleton based on type */}
|
||||
{type === 'series' ? (
|
||||
<View style={styles.episodesSection}>
|
||||
<SkeletonElement width="25%" height={24} borderRadius={12} />
|
||||
<SkeletonElement width={150} height={36} borderRadius={18} />
|
||||
{/* Episodes/Recommendations Section */}
|
||||
{type === 'series' ? (
|
||||
<Animated.View style={[styles.episodesSection, { paddingHorizontal: horizontalPadding }, castStyle]}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 120 : 100}
|
||||
height={isTV ? 24 : 20}
|
||||
borderRadius={4}
|
||||
marginBottom={16}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
{/* Season selector */}
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 180 : 140}
|
||||
height={isTV ? 40 : 36}
|
||||
borderRadius={20}
|
||||
marginBottom={20}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
{/* Episode cards */}
|
||||
<View style={styles.episodeList}>
|
||||
{[1, 2, 3].map((item) => (
|
||||
<View key={item} style={styles.episodeItem}>
|
||||
<SkeletonElement width={120} height={68} borderRadius={8} marginBottom={0} style={{ marginRight: 12 }} />
|
||||
<View key={item} style={styles.episodeCard}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 140}
|
||||
height={isTV ? 112 : isLargeTablet ? 100 : isTablet ? 90 : 80}
|
||||
borderRadius={8}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<View style={styles.episodeInfo}>
|
||||
<SkeletonElement width="80%" height={16} borderRadius={8} />
|
||||
<SkeletonElement width="60%" height={14} borderRadius={7} />
|
||||
<SkeletonElement width="90%" height={12} borderRadius={6} />
|
||||
<ShimmerSkeleton
|
||||
width="80%"
|
||||
height={isTV ? 16 : 14}
|
||||
borderRadius={4}
|
||||
marginBottom={6}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<ShimmerSkeleton
|
||||
width="60%"
|
||||
height={isTV ? 14 : 12}
|
||||
borderRadius={4}
|
||||
marginBottom={0}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.detailsSection}>
|
||||
<SkeletonElement width="25%" height={24} borderRadius={12} />
|
||||
<View style={styles.detailsGrid}>
|
||||
<SkeletonElement width="48%" height={60} borderRadius={8} />
|
||||
<SkeletonElement width="48%" height={60} borderRadius={8} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View style={[styles.recommendationsSection, { paddingHorizontal: horizontalPadding }, castStyle]}>
|
||||
<ShimmerSkeleton
|
||||
width={isTV ? 140 : 110}
|
||||
height={isTV ? 24 : 20}
|
||||
borderRadius={4}
|
||||
marginBottom={16}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
<View style={styles.posterRow}>
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<ShimmerSkeleton
|
||||
key={item}
|
||||
width={isTV ? 140 : isLargeTablet ? 120 : isTablet ? 110 : 100}
|
||||
height={isTV ? 210 : isLargeTablet ? 180 : isTablet ? 165 : 150}
|
||||
borderRadius={8}
|
||||
marginBottom={0}
|
||||
style={{ marginRight: 12 }}
|
||||
shimmerProgress={shimmerProgress}
|
||||
baseColor={baseColor}
|
||||
highlightColor={highlightColor}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
|
@ -258,7 +558,6 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
},
|
||||
heroSection: {
|
||||
height: height * 0.6,
|
||||
position: 'relative',
|
||||
},
|
||||
heroOverlay: {
|
||||
|
|
@ -266,54 +565,52 @@ const styles = StyleSheet.create({
|
|||
justifyContent: 'flex-end',
|
||||
},
|
||||
heroBottomContent: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
genresRow: {
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
buttonsRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
contentSection: {
|
||||
padding: 20,
|
||||
paddingTop: 16,
|
||||
},
|
||||
synopsisSection: {
|
||||
marginBottom: 32,
|
||||
descriptionSection: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
castSection: {
|
||||
marginBottom: 32,
|
||||
marginBottom: 24,
|
||||
},
|
||||
castRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 16,
|
||||
},
|
||||
castItem: {
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
episodesSection: {
|
||||
marginBottom: 32,
|
||||
marginBottom: 24,
|
||||
},
|
||||
episodeItem: {
|
||||
episodeList: {
|
||||
gap: 16,
|
||||
},
|
||||
episodeCard: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
episodeInfo: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
detailsSection: {
|
||||
marginBottom: 32,
|
||||
recommendationsSection: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
detailsGrid: {
|
||||
posterRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 16,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ const CompactCommentCard: React.FC<{
|
|||
// Enhanced responsive sizing for tablets and TV screens
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const deviceHeight = Dimensions.get('window').height;
|
||||
|
||||
|
||||
// Determine device type based on width
|
||||
const getDeviceType = useCallback(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
|
|
@ -208,13 +208,13 @@ const CompactCommentCard: React.FC<{
|
|||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
|
||||
|
||||
const deviceType = getDeviceType();
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
const isLargeScreen = isTablet || isLargeTablet || isTV;
|
||||
|
||||
|
||||
// Enhanced comment card sizing
|
||||
const commentCardWidth = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -228,7 +228,7 @@ const CompactCommentCard: React.FC<{
|
|||
return 280; // phone
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
const commentCardHeight = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
|
|
@ -241,7 +241,7 @@ const CompactCommentCard: React.FC<{
|
|||
return 170; // phone
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
const commentCardSpacing = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
|
|
@ -354,156 +354,156 @@ const CompactCommentCard: React.FC<{
|
|||
}}
|
||||
activeOpacity={1}
|
||||
>
|
||||
{/* Trakt Icon - Top Right Corner */}
|
||||
<View style={styles.traktIconContainer}>
|
||||
<TraktIcon width={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} height={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} />
|
||||
</View>
|
||||
|
||||
{/* Header Section - Fixed at top */}
|
||||
<View style={[
|
||||
styles.compactHeader,
|
||||
{
|
||||
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
|
||||
}
|
||||
]}>
|
||||
<View style={styles.usernameContainer}>
|
||||
<Text style={[
|
||||
styles.compactUsername,
|
||||
{
|
||||
color: theme.colors.highEmphasis,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={[
|
||||
styles.miniVipBadge,
|
||||
{
|
||||
paddingHorizontal: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
|
||||
paddingVertical: isTV ? 2 : isLargeTablet ? 2 : isTablet ? 1 : 1,
|
||||
borderRadius: isTV ? 8 : isLargeTablet ? 7 : isTablet ? 6 : 6
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.miniVipText,
|
||||
{
|
||||
fontSize: isTV ? 11 : isLargeTablet ? 10 : isTablet ? 9 : 9
|
||||
}
|
||||
]}>VIP</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Trakt Icon - Top Right Corner */}
|
||||
<View style={styles.traktIconContainer}>
|
||||
<TraktIcon width={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} height={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Rating - Show stars */}
|
||||
{comment.user_stats?.rating && (
|
||||
{/* Header Section - Fixed at top */}
|
||||
<View style={[
|
||||
styles.compactRating,
|
||||
styles.compactHeader,
|
||||
{
|
||||
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
|
||||
}
|
||||
]}>
|
||||
{renderCompactStars(comment.user_stats.rating)}
|
||||
<Text style={[
|
||||
styles.compactRatingText,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
|
||||
<View style={styles.usernameContainer}>
|
||||
<Text style={[
|
||||
styles.compactUsername,
|
||||
{
|
||||
color: theme.colors.highEmphasis,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={[
|
||||
styles.miniVipBadge,
|
||||
{
|
||||
paddingHorizontal: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
|
||||
paddingVertical: isTV ? 2 : isLargeTablet ? 2 : isTablet ? 1 : 1,
|
||||
borderRadius: isTV ? 8 : isLargeTablet ? 7 : isTablet ? 6 : 6
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.miniVipText,
|
||||
{
|
||||
fontSize: isTV ? 11 : isLargeTablet ? 10 : isTablet ? 9 : 9
|
||||
}
|
||||
]}>VIP</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Rating - Show stars */}
|
||||
{comment.user_stats?.rating && (
|
||||
<View style={[
|
||||
styles.compactRating,
|
||||
{
|
||||
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
|
||||
}
|
||||
]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{renderCompactStars(comment.user_stats.rating)}
|
||||
<Text style={[
|
||||
styles.compactRatingText,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
|
||||
}
|
||||
]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Comment Preview - Flexible area that fills space */}
|
||||
<View style={[
|
||||
styles.commentContainer,
|
||||
shouldBlurContent ? styles.blurredContent : undefined,
|
||||
{
|
||||
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
|
||||
}
|
||||
]}>
|
||||
{shouldBlurContent ? (
|
||||
<Text style={[
|
||||
styles.compactComment,
|
||||
{
|
||||
color: theme.colors.highEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
|
||||
}
|
||||
]}>⚠️ This comment contains spoilers. Tap to reveal.</Text>
|
||||
) : (
|
||||
<MarkdownText
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
numberOfLines={isLargeScreen ? 4 : 3}
|
||||
revealedInlineSpoilers={isSpoilerRevealed}
|
||||
onSpoilerPress={onSpoilerPress}
|
||||
textStyle={[
|
||||
{/* Comment Preview - Flexible area that fills space */}
|
||||
<View style={[
|
||||
styles.commentContainer,
|
||||
shouldBlurContent ? styles.blurredContent : undefined,
|
||||
{
|
||||
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
|
||||
}
|
||||
]}>
|
||||
{shouldBlurContent ? (
|
||||
<Text style={[
|
||||
styles.compactComment,
|
||||
{
|
||||
color: theme.colors.highEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
]}>⚠️ This comment contains spoilers. Tap to reveal.</Text>
|
||||
) : (
|
||||
<MarkdownText
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
numberOfLines={isLargeScreen ? 4 : 3}
|
||||
revealedInlineSpoilers={isSpoilerRevealed}
|
||||
onSpoilerPress={onSpoilerPress}
|
||||
textStyle={[
|
||||
styles.compactComment,
|
||||
{
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Meta Info - Fixed at bottom */}
|
||||
<View style={[
|
||||
styles.compactMeta,
|
||||
{
|
||||
paddingTop: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 6 : 6
|
||||
}
|
||||
]}>
|
||||
<View style={styles.compactBadges}>
|
||||
{comment.spoiler && (
|
||||
{/* Meta Info - Fixed at bottom */}
|
||||
<View style={[
|
||||
styles.compactMeta,
|
||||
{
|
||||
paddingTop: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 6 : 6
|
||||
}
|
||||
]}>
|
||||
<View style={styles.compactBadges}>
|
||||
{comment.spoiler && (
|
||||
<Text style={[
|
||||
styles.spoilerMiniText,
|
||||
{
|
||||
color: theme.colors.error,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
|
||||
}
|
||||
]}>Spoiler</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.compactStats}>
|
||||
<Text style={[
|
||||
styles.spoilerMiniText,
|
||||
{
|
||||
color: theme.colors.error,
|
||||
styles.compactTime,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
|
||||
}
|
||||
]}>Spoiler</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.compactStats}>
|
||||
<Text style={[
|
||||
styles.compactTime,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
|
||||
}
|
||||
]}>
|
||||
{formatRelativeTime(comment.created_at)}
|
||||
</Text>
|
||||
{comment.likes > 0 && (
|
||||
<Text style={[
|
||||
styles.compactStat,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>
|
||||
👍 {comment.likes}
|
||||
{formatRelativeTime(comment.created_at)}
|
||||
</Text>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<Text style={[
|
||||
styles.compactStat,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>
|
||||
💬 {comment.replies}
|
||||
</Text>
|
||||
)}
|
||||
{comment.likes > 0 && (
|
||||
<Text style={[
|
||||
styles.compactStat,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>
|
||||
👍 {comment.likes}
|
||||
</Text>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<Text style={[
|
||||
styles.compactStat,
|
||||
{
|
||||
color: theme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>
|
||||
💬 {comment.replies}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
|
|
@ -614,105 +614,105 @@ const ExpandedCommentBottomSheet: React.FC<{
|
|||
nestedScrollEnabled
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Close Button */}
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.highEmphasis} />
|
||||
</TouchableOpacity>
|
||||
{/* Close Button */}
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.highEmphasis} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* User Info */}
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={styles.userInfo}>
|
||||
<Text
|
||||
style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={styles.vipBadge}>
|
||||
<Text style={styles.vipText}>VIP</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{(() => {
|
||||
const { datePart, timePart } = formatDateParts(comment.created_at);
|
||||
return (
|
||||
<View style={styles.dateTimeContainer}>
|
||||
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
|
||||
{datePart}
|
||||
</Text>
|
||||
{!!timePart && (
|
||||
<Text style={[styles.modalTime, { color: theme.colors.mediumEmphasis }]}>
|
||||
{timePart}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Rating */}
|
||||
{comment.user_stats?.rating && (
|
||||
<View style={styles.modalRating}>
|
||||
{renderStars(comment.user_stats.rating)}
|
||||
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Full Comment (Markdown with inline spoilers) */}
|
||||
{shouldBlurModalContent ? (
|
||||
<View style={styles.spoilerContainer}>
|
||||
<View style={[styles.spoilerIcon, { backgroundColor: theme.colors.card }]}>
|
||||
<MaterialIcons name="visibility-off" size={20} color={theme.colors.mediumEmphasis} />
|
||||
{/* User Info */}
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={styles.userInfo}>
|
||||
<Text
|
||||
style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={styles.vipBadge}>
|
||||
<Text style={styles.vipText}>VIP</Text>
|
||||
</View>
|
||||
<Text style={[styles.spoilerTitle, { color: theme.colors.highEmphasis }]}>Contains spoilers</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.revealButton, { borderColor: theme.colors.primary }]}
|
||||
onPress={onSpoilerPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<MaterialIcons name="visibility" size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.revealButtonText, { color: theme.colors.primary }]}>Reveal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<MarkdownText
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
revealedInlineSpoilers={true}
|
||||
textStyle={styles.modalComment}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Comment Meta */}
|
||||
<View style={styles.modalMeta}>
|
||||
{comment.spoiler && (
|
||||
<Text style={[styles.spoilerText, { color: theme.colors.error }]}>Spoiler</Text>
|
||||
)}
|
||||
<View style={styles.modalStats}>
|
||||
{comment.likes > 0 && (
|
||||
<View style={styles.likesContainer}>
|
||||
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.likes}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<View style={styles.repliesContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.replies}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{(() => {
|
||||
const { datePart, timePart } = formatDateParts(comment.created_at);
|
||||
return (
|
||||
<View style={styles.dateTimeContainer}>
|
||||
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
|
||||
{datePart}
|
||||
</Text>
|
||||
{!!timePart && (
|
||||
<Text style={[styles.modalTime, { color: theme.colors.mediumEmphasis }]}>
|
||||
{timePart}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Rating */}
|
||||
{comment.user_stats?.rating && (
|
||||
<View style={styles.modalRating}>
|
||||
{renderStars(comment.user_stats.rating)}
|
||||
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Full Comment (Markdown with inline spoilers) */}
|
||||
{shouldBlurModalContent ? (
|
||||
<View style={styles.spoilerContainer}>
|
||||
<View style={[styles.spoilerIcon, { backgroundColor: theme.colors.card }]}>
|
||||
<MaterialIcons name="visibility-off" size={20} color={theme.colors.mediumEmphasis} />
|
||||
</View>
|
||||
<Text style={[styles.spoilerTitle, { color: theme.colors.highEmphasis }]}>Contains spoilers</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.revealButton, { borderColor: theme.colors.primary }]}
|
||||
onPress={onSpoilerPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<MaterialIcons name="visibility" size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.revealButtonText, { color: theme.colors.primary }]}>Reveal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<MarkdownText
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
revealedInlineSpoilers={true}
|
||||
textStyle={styles.modalComment}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Comment Meta */}
|
||||
<View style={styles.modalMeta}>
|
||||
{comment.spoiler && (
|
||||
<Text style={[styles.spoilerText, { color: theme.colors.error }]}>Spoiler</Text>
|
||||
)}
|
||||
<View style={styles.modalStats}>
|
||||
{comment.likes > 0 && (
|
||||
<View style={styles.likesContainer}>
|
||||
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.likes}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<View style={styles.repliesContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.replies}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
|
@ -732,7 +732,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
// Enhanced responsive sizing for tablets and TV screens
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const deviceHeight = Dimensions.get('window').height;
|
||||
|
||||
|
||||
// Determine device type based on width
|
||||
const getDeviceType = useCallback(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
|
|
@ -740,13 +740,13 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
|
||||
|
||||
const deviceType = getDeviceType();
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
const isLargeScreen = isTablet || isLargeTablet || isTV;
|
||||
|
||||
|
||||
// Enhanced spacing and padding
|
||||
const horizontalPadding = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -772,7 +772,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
} = useTraktComments({
|
||||
imdbId,
|
||||
type: type === 'show' ? (season !== undefined && episode !== undefined ? 'episode' :
|
||||
season !== undefined ? 'season' : 'show') : 'movie',
|
||||
season !== undefined ? 'season' : 'show') : 'movie',
|
||||
season,
|
||||
episode,
|
||||
enabled: true,
|
||||
|
|
@ -924,8 +924,8 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.title,
|
||||
{
|
||||
styles.title,
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
|
||||
}
|
||||
|
|
@ -992,7 +992,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
|
||||
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
|
||||
Load More
|
||||
</Text>
|
||||
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.primary} />
|
||||
|
|
@ -1022,15 +1022,19 @@ export const CommentBottomSheet: React.FC<{
|
|||
}> = ({ comment, visible, onClose, theme, isSpoilerRevealed, onSpoilerPress }) => {
|
||||
const bottomSheetRef = useRef<BottomSheet>(null);
|
||||
|
||||
// Early return before any Reanimated components are rendered
|
||||
// This prevents the BottomSheet from initializing when not needed
|
||||
if (!visible || !comment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('CommentBottomSheet: Rendered with visible:', visible, 'comment:', comment?.id);
|
||||
|
||||
// Calculate the index based on visibility - start at medium height (50%)
|
||||
const sheetIndex = visible && comment ? 1 : -1;
|
||||
const sheetIndex = 1; // Always 1 when visible and comment are truthy
|
||||
|
||||
console.log('CommentBottomSheet: Calculated sheetIndex:', sheetIndex);
|
||||
|
||||
if (!comment) return null;
|
||||
|
||||
const user = comment.user || {};
|
||||
const username = user.name || user.username || 'Anonymous User';
|
||||
const hasSpoiler = comment.spoiler;
|
||||
|
|
@ -1115,100 +1119,100 @@ export const CommentBottomSheet: React.FC<{
|
|||
nestedScrollEnabled
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* User Info */}
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={styles.userInfo}>
|
||||
<Text
|
||||
style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={styles.vipBadge}>
|
||||
<Text style={styles.vipText}>VIP</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{(() => {
|
||||
const { datePart, timePart } = formatDateParts(comment.created_at);
|
||||
return (
|
||||
<View style={styles.dateTimeContainer}>
|
||||
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
|
||||
{datePart}
|
||||
</Text>
|
||||
{!!timePart && (
|
||||
<Text style={[styles.modalTime, { color: theme.colors.mediumEmphasis }]}>
|
||||
{timePart}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Rating */}
|
||||
{comment.user_stats?.rating && (
|
||||
<View style={styles.modalRating}>
|
||||
{renderStars(comment.user_stats.rating)}
|
||||
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Full Comment (Markdown with inline spoilers) */}
|
||||
{shouldBlurModalContent ? (
|
||||
<View style={styles.spoilerContainer}>
|
||||
<View style={[styles.spoilerIcon, { backgroundColor: theme.colors.card }]}>
|
||||
<MaterialIcons name="visibility-off" size={20} color={theme.colors.mediumEmphasis} />
|
||||
{/* User Info */}
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={styles.userInfo}>
|
||||
<Text
|
||||
style={[styles.modalUsername, { color: theme.colors.highEmphasis }]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{username}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={styles.vipBadge}>
|
||||
<Text style={styles.vipText}>VIP</Text>
|
||||
</View>
|
||||
<Text style={[styles.spoilerTitle, { color: theme.colors.highEmphasis }]}>Contains spoilers</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.revealButton, { borderColor: theme.colors.primary }]}
|
||||
onPress={onSpoilerPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<MaterialIcons name="visibility" size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.revealButtonText, { color: theme.colors.primary }]}>Reveal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<MarkdownText
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
revealedInlineSpoilers={true}
|
||||
textStyle={styles.modalComment}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Comment Meta */}
|
||||
<View style={styles.modalMeta}>
|
||||
{comment.spoiler && (
|
||||
<Text style={[styles.spoilerText, { color: theme.colors.error }]}>Spoiler</Text>
|
||||
)}
|
||||
<View style={styles.modalStats}>
|
||||
{comment.likes > 0 && (
|
||||
<View style={styles.likesContainer}>
|
||||
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.likes}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<View style={styles.repliesContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.replies}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{(() => {
|
||||
const { datePart, timePart } = formatDateParts(comment.created_at);
|
||||
return (
|
||||
<View style={styles.dateTimeContainer}>
|
||||
<Text style={[styles.modalDate, { color: theme.colors.mediumEmphasis }]}>
|
||||
{datePart}
|
||||
</Text>
|
||||
{!!timePart && (
|
||||
<Text style={[styles.modalTime, { color: theme.colors.mediumEmphasis }]}>
|
||||
{timePart}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Rating */}
|
||||
{comment.user_stats?.rating && (
|
||||
<View style={styles.modalRating}>
|
||||
{renderStars(comment.user_stats.rating)}
|
||||
<Text style={[styles.modalRatingText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.user_stats.rating}/10
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Full Comment (Markdown with inline spoilers) */}
|
||||
{shouldBlurModalContent ? (
|
||||
<View style={styles.spoilerContainer}>
|
||||
<View style={[styles.spoilerIcon, { backgroundColor: theme.colors.card }]}>
|
||||
<MaterialIcons name="visibility-off" size={20} color={theme.colors.mediumEmphasis} />
|
||||
</View>
|
||||
<Text style={[styles.spoilerTitle, { color: theme.colors.highEmphasis }]}>Contains spoilers</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.revealButton, { borderColor: theme.colors.primary }]}
|
||||
onPress={onSpoilerPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<MaterialIcons name="visibility" size={18} color={theme.colors.primary} />
|
||||
<Text style={[styles.revealButtonText, { color: theme.colors.primary }]}>Reveal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<MarkdownText
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
revealedInlineSpoilers={true}
|
||||
textStyle={styles.modalComment}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Comment Meta */}
|
||||
<View style={styles.modalMeta}>
|
||||
{comment.spoiler && (
|
||||
<Text style={[styles.spoilerText, { color: theme.colors.error }]}>Spoiler</Text>
|
||||
)}
|
||||
<View style={styles.modalStats}>
|
||||
{comment.likes > 0 && (
|
||||
<View style={styles.likesContainer}>
|
||||
<MaterialIcons name="thumb-up" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.likesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.likes}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{comment.replies > 0 && (
|
||||
<View style={styles.repliesContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={16} color={theme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.repliesText, { color: theme.colors.mediumEmphasis }]}>
|
||||
{comment.replies}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
// Enhanced responsive sizing for tablets and TV screens
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const deviceHeight = Dimensions.get('window').height;
|
||||
|
||||
|
||||
// Determine device type based on width
|
||||
const getDeviceType = useCallback(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
|
|
@ -67,13 +67,13 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
|
||||
|
||||
const deviceType = getDeviceType();
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
const isLargeScreen = isTablet || isLargeTablet || isTV;
|
||||
|
||||
|
||||
// Enhanced spacing and padding
|
||||
const horizontalPadding = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -89,8 +89,11 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
}, [deviceType]);
|
||||
|
||||
// Animation values for smooth height transition
|
||||
const animatedHeight = useSharedValue(0);
|
||||
// Start with a reasonable default height (3 lines * 24px line height = 72px) to prevent layout shift
|
||||
const defaultCollapsedHeight = isTV ? 84 : isLargeTablet ? 78 : isTablet ? 72 : 72;
|
||||
const animatedHeight = useSharedValue(defaultCollapsedHeight);
|
||||
const [measuredHeights, setMeasuredHeights] = useState({ collapsed: 0, expanded: 0 });
|
||||
const [hasInitialMeasurement, setHasInitialMeasurement] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMDBListEnabled = async () => {
|
||||
|
|
@ -101,7 +104,7 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
setIsMDBEnabled(false); // Default to disabled if there's an error
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
checkMDBListEnabled();
|
||||
}, []);
|
||||
|
||||
|
|
@ -114,6 +117,12 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
const handleCollapsedTextLayout = (event: any) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setMeasuredHeights(prev => ({ ...prev, collapsed: height }));
|
||||
// Only set initial measurement flag once we have a valid height
|
||||
if (height > 0 && !hasInitialMeasurement) {
|
||||
setHasInitialMeasurement(true);
|
||||
// Update animated height immediately without animation for first measurement
|
||||
animatedHeight.value = height;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpandedTextLayout = (event: any) => {
|
||||
|
|
@ -128,49 +137,53 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
setIsFullDescriptionOpen(!isFullDescriptionOpen);
|
||||
};
|
||||
|
||||
// Initialize height when component mounts or text changes
|
||||
// Update height when measurements change (only after initial measurement)
|
||||
useEffect(() => {
|
||||
if (measuredHeights.collapsed > 0) {
|
||||
animatedHeight.value = measuredHeights.collapsed;
|
||||
if (measuredHeights.collapsed > 0 && hasInitialMeasurement && !isFullDescriptionOpen) {
|
||||
// Only animate if the height actually changed significantly
|
||||
const currentHeight = animatedHeight.value;
|
||||
if (Math.abs(currentHeight - measuredHeights.collapsed) > 5) {
|
||||
animatedHeight.value = measuredHeights.collapsed;
|
||||
}
|
||||
}
|
||||
}, [measuredHeights.collapsed]);
|
||||
}, [measuredHeights.collapsed, hasInitialMeasurement, isFullDescriptionOpen]);
|
||||
|
||||
// Animated style for smooth height transition
|
||||
// Animated style for smooth height transition - use minHeight to prevent collapse to 0
|
||||
const animatedDescriptionStyle = useAnimatedStyle(() => ({
|
||||
height: animatedHeight.value,
|
||||
height: animatedHeight.value > 0 ? animatedHeight.value : defaultCollapsedHeight,
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
function formatRuntime(runtime: string): string {
|
||||
// Try to match formats like "1h55min", "2h 7min", "125 min", etc.
|
||||
const match = runtime.match(/(?:(\d+)\s*h\s*)?(\d+)\s*min/i);
|
||||
if (match) {
|
||||
const h = match[1] ? parseInt(match[1], 10) : 0;
|
||||
const m = match[2] ? parseInt(match[2], 10) : 0;
|
||||
if (h > 0) {
|
||||
return `${h}H ${m}M`;
|
||||
function formatRuntime(runtime: string): string {
|
||||
// Try to match formats like "1h55min", "2h 7min", "125 min", etc.
|
||||
const match = runtime.match(/(?:(\d+)\s*h\s*)?(\d+)\s*min/i);
|
||||
if (match) {
|
||||
const h = match[1] ? parseInt(match[1], 10) : 0;
|
||||
const m = match[2] ? parseInt(match[2], 10) : 0;
|
||||
if (h > 0) {
|
||||
return `${h}H ${m}M`;
|
||||
}
|
||||
if (m < 60) {
|
||||
return `${m} MIN`;
|
||||
}
|
||||
const hours = Math.floor(m / 60);
|
||||
const mins = m % 60;
|
||||
return hours > 0 ? `${hours}H ${mins}M` : `${mins} MIN`;
|
||||
}
|
||||
if (m < 60) {
|
||||
return `${m} MIN`;
|
||||
|
||||
// Fallback: treat as minutes if it's a number
|
||||
const r = parseInt(runtime, 10);
|
||||
if (!isNaN(r)) {
|
||||
if (r < 60) return `${r} MIN`;
|
||||
const h = Math.floor(r / 60);
|
||||
const m = r % 60;
|
||||
return h > 0 ? `${h}H ${m}M` : `${m} MIN`;
|
||||
}
|
||||
const hours = Math.floor(m / 60);
|
||||
const mins = m % 60;
|
||||
return hours > 0 ? `${hours}H ${mins}M` : `${mins} MIN`;
|
||||
}
|
||||
|
||||
// Fallback: treat as minutes if it's a number
|
||||
const r = parseInt(runtime, 10);
|
||||
if (!isNaN(r)) {
|
||||
if (r < 60) return `${r} MIN`;
|
||||
const h = Math.floor(r / 60);
|
||||
const m = r % 60;
|
||||
return h > 0 ? `${h}H ${m}M` : `${m} MIN`;
|
||||
}
|
||||
// If not matched, return as is
|
||||
return runtime;
|
||||
|
||||
// If not matched, return as is
|
||||
return runtime;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -188,17 +201,17 @@ function formatRuntime(runtime: string): string {
|
|||
|
||||
{/* Meta Info */}
|
||||
<View style={[
|
||||
styles.metaInfo,
|
||||
styles.metaInfo,
|
||||
loadingMetadata && styles.dimmed,
|
||||
{
|
||||
{
|
||||
paddingHorizontal: horizontalPadding,
|
||||
gap: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18
|
||||
}
|
||||
]}>
|
||||
{metadata.year && (
|
||||
<Text style={[
|
||||
styles.metaText,
|
||||
{
|
||||
styles.metaText,
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
|
||||
}
|
||||
|
|
@ -206,8 +219,8 @@ function formatRuntime(runtime: string): string {
|
|||
)}
|
||||
{metadata.runtime && (
|
||||
<Text style={[
|
||||
styles.metaText,
|
||||
{
|
||||
styles.metaText,
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
|
||||
}
|
||||
|
|
@ -232,8 +245,8 @@ function formatRuntime(runtime: string): string {
|
|||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.ratingText,
|
||||
{
|
||||
styles.ratingText,
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
|
||||
}
|
||||
|
|
@ -249,7 +262,7 @@ function formatRuntime(runtime: string): string {
|
|||
<Animated.View
|
||||
entering={FadeIn.duration(300).delay(100)}
|
||||
style={[
|
||||
styles.creatorContainer,
|
||||
styles.creatorContainer,
|
||||
loadingMetadata && styles.dimmed,
|
||||
{ paddingHorizontal: horizontalPadding }
|
||||
]}
|
||||
|
|
@ -263,16 +276,16 @@ function formatRuntime(runtime: string): string {
|
|||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.creatorLabel,
|
||||
{
|
||||
styles.creatorLabel,
|
||||
{
|
||||
color: currentTheme.colors.white,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
|
||||
}
|
||||
]}>Director{metadata.directors.length > 1 ? 's' : ''}:</Text>
|
||||
<Text style={[
|
||||
styles.creatorText,
|
||||
{
|
||||
styles.creatorText,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
|
||||
|
|
@ -289,16 +302,16 @@ function formatRuntime(runtime: string): string {
|
|||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.creatorLabel,
|
||||
{
|
||||
styles.creatorLabel,
|
||||
{
|
||||
color: currentTheme.colors.white,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
|
||||
}
|
||||
]}>Creator{metadata.creators.length > 1 ? 's' : ''}:</Text>
|
||||
<Text style={[
|
||||
styles.creatorText,
|
||||
{
|
||||
styles.creatorText,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
|
||||
|
|
@ -308,11 +321,11 @@ function formatRuntime(runtime: string): string {
|
|||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Description */}
|
||||
{metadata.description && (
|
||||
{/* Description - Show skeleton if no description yet to prevent layout shift */}
|
||||
{metadata.description ? (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.descriptionContainer,
|
||||
styles.descriptionContainer,
|
||||
loadingMetadata && styles.dimmed,
|
||||
{ paddingHorizontal: horizontalPadding }
|
||||
]}
|
||||
|
|
@ -321,10 +334,10 @@ function formatRuntime(runtime: string): string {
|
|||
{/* Hidden text elements to measure heights */}
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
position: 'absolute',
|
||||
styles.description,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
|
||||
lineHeight: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24
|
||||
|
|
@ -337,10 +350,10 @@ function formatRuntime(runtime: string): string {
|
|||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
position: 'absolute',
|
||||
styles.description,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
|
||||
lineHeight: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24
|
||||
|
|
@ -359,8 +372,8 @@ function formatRuntime(runtime: string): string {
|
|||
<Animated.View style={animatedDescriptionStyle}>
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{
|
||||
styles.description,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
|
||||
lineHeight: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24
|
||||
|
|
@ -381,8 +394,8 @@ function formatRuntime(runtime: string): string {
|
|||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.showMoreText,
|
||||
{
|
||||
styles.showMoreText,
|
||||
{
|
||||
color: currentTheme.colors.textMuted,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
|
||||
}
|
||||
|
|
@ -398,6 +411,20 @@ function formatRuntime(runtime: string): string {
|
|||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
) : (
|
||||
/* Skeleton placeholder for description to prevent layout shift */
|
||||
<View
|
||||
style={[
|
||||
styles.descriptionContainer,
|
||||
{ paddingHorizontal: horizontalPadding, minHeight: defaultCollapsedHeight }
|
||||
]}
|
||||
>
|
||||
<View style={[styles.descriptionSkeleton, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<View style={[styles.skeletonLine, { width: '100%', height: isTV ? 18 : 15, backgroundColor: currentTheme.colors.elevation1, marginBottom: 8 }]} />
|
||||
<View style={[styles.skeletonLine, { width: '95%', height: isTV ? 18 : 15, backgroundColor: currentTheme.colors.elevation1, marginBottom: 8 }]} />
|
||||
<View style={[styles.skeletonLine, { width: '80%', height: isTV ? 18 : 15, backgroundColor: currentTheme.colors.elevation1 }]} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -491,6 +518,12 @@ const styles = StyleSheet.create({
|
|||
fontSize: 14,
|
||||
marginRight: 4,
|
||||
},
|
||||
descriptionSkeleton: {
|
||||
borderRadius: 4,
|
||||
},
|
||||
skeletonLine: {
|
||||
borderRadius: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(MetadataDetails);
|
||||
|
|
@ -8,31 +8,7 @@ interface MovieContentProps {
|
|||
}
|
||||
|
||||
export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0;
|
||||
const castDisplay = hasCast ? metadata.cast!.slice(0, 5).join(', ') : '';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Additional metadata */}
|
||||
<View style={styles.additionalInfo}>
|
||||
{metadata.director && (
|
||||
<View style={styles.metadataRow}>
|
||||
<Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Director:</Text>
|
||||
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{metadata.director}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
{hasCast && (
|
||||
<View style={styles.metadataRow}>
|
||||
<Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Cast:</Text>
|
||||
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{castDisplay}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState, useRef, useCallback, useMemo, memo } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList } from 'react-native';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList, Modal, Pressable } from 'react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
|
@ -12,6 +13,7 @@ import { storageService } from '../../services/storageService';
|
|||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated';
|
||||
import { TraktService } from '../../services/traktService';
|
||||
import { watchedService } from '../../services/watchedService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { mmkvStorage } from '../../services/mmkvStorage';
|
||||
|
||||
|
|
@ -31,6 +33,7 @@ interface SeriesContentProps {
|
|||
onSelectEpisode: (episode: Episode) => void;
|
||||
groupedEpisodes?: { [seasonNumber: number]: Episode[] };
|
||||
metadata?: { poster?: string; id?: string };
|
||||
imdbId?: string; // IMDb ID for Trakt sync
|
||||
}
|
||||
|
||||
// Add placeholder constant at the top
|
||||
|
|
@ -46,7 +49,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
onSeasonChange,
|
||||
onSelectEpisode,
|
||||
groupedEpisodes = {},
|
||||
metadata
|
||||
metadata,
|
||||
imdbId
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
|
|
@ -180,6 +184,11 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
const [posterViewVisible, setPosterViewVisible] = useState(true);
|
||||
const [textViewVisible, setTextViewVisible] = useState(false);
|
||||
|
||||
// Episode action menu state
|
||||
const [episodeActionMenuVisible, setEpisodeActionMenuVisible] = useState(false);
|
||||
const [selectedEpisodeForAction, setSelectedEpisodeForAction] = useState<Episode | null>(null);
|
||||
const [markingAsWatched, setMarkingAsWatched] = useState(false);
|
||||
|
||||
// Add refs for the scroll views
|
||||
const seasonScrollViewRef = useRef<ScrollView | null>(null);
|
||||
const episodeScrollViewRef = useRef<FlashListRef<Episode>>(null);
|
||||
|
|
@ -517,6 +526,207 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
return rating ?? null;
|
||||
}, [imdbRatingsMap]);
|
||||
|
||||
// Handle long press on episode to show action menu
|
||||
const handleEpisodeLongPress = useCallback((episode: Episode) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
setSelectedEpisodeForAction(episode);
|
||||
setEpisodeActionMenuVisible(true);
|
||||
}, []);
|
||||
|
||||
// Check if an episode is watched (>= 85% progress)
|
||||
const isEpisodeWatched = useCallback((episode: Episode): boolean => {
|
||||
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
|
||||
const progress = episodeProgress[episodeId];
|
||||
if (!progress) return false;
|
||||
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||
return progressPercent >= 85;
|
||||
}, [episodeProgress, metadata?.id]);
|
||||
|
||||
// Mark episode as watched
|
||||
const handleMarkAsWatched = useCallback(async () => {
|
||||
if (!selectedEpisodeForAction || !metadata?.id) return;
|
||||
|
||||
const episode = selectedEpisodeForAction; // Capture for closure
|
||||
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
|
||||
|
||||
// 1. Optimistic UI Update
|
||||
setEpisodeProgress(prev => ({
|
||||
...prev,
|
||||
[episodeId]: { currentTime: 1, duration: 1, lastUpdated: Date.now() } // 100% progress
|
||||
}));
|
||||
|
||||
// 2. Instant Feedback
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setEpisodeActionMenuVisible(false);
|
||||
setSelectedEpisodeForAction(null);
|
||||
|
||||
// 3. Background Async Operation
|
||||
const showImdbId = imdbId || metadata.id;
|
||||
try {
|
||||
const result = await watchedService.markEpisodeAsWatched(
|
||||
showImdbId,
|
||||
metadata.id,
|
||||
episode.season_number,
|
||||
episode.episode_number
|
||||
);
|
||||
|
||||
// Reload to ensure consistency (e.g. if optimistic update was slightly off or for other effects)
|
||||
// But we don't strictly *need* to wait for this to update UI
|
||||
loadEpisodesProgress();
|
||||
|
||||
logger.log(`[SeriesContent] Mark as watched result:`, result);
|
||||
} catch (error) {
|
||||
logger.error('[SeriesContent] Error marking episode as watched:', error);
|
||||
// Ideally revert state here, but simple error logging is often enough for non-critical non-transactional actions
|
||||
loadEpisodesProgress(); // Reload to revert to source of truth
|
||||
}
|
||||
}, [selectedEpisodeForAction, metadata?.id, imdbId]);
|
||||
|
||||
// Mark episode as unwatched
|
||||
const handleMarkAsUnwatched = useCallback(async () => {
|
||||
if (!selectedEpisodeForAction || !metadata?.id) return;
|
||||
|
||||
const episode = selectedEpisodeForAction;
|
||||
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
|
||||
|
||||
// 1. Optimistic UI Update - Remove from progress map
|
||||
setEpisodeProgress(prev => {
|
||||
const newState = { ...prev };
|
||||
delete newState[episodeId];
|
||||
return newState;
|
||||
});
|
||||
|
||||
// 2. Instant Feedback
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setEpisodeActionMenuVisible(false);
|
||||
setSelectedEpisodeForAction(null);
|
||||
|
||||
// 3. Background Async Operation
|
||||
const showImdbId = imdbId || metadata.id;
|
||||
try {
|
||||
const result = await watchedService.unmarkEpisodeAsWatched(
|
||||
showImdbId,
|
||||
metadata.id,
|
||||
episode.season_number,
|
||||
episode.episode_number
|
||||
);
|
||||
|
||||
loadEpisodesProgress(); // Sync with source of truth
|
||||
logger.log(`[SeriesContent] Unmark watched result:`, result);
|
||||
} catch (error) {
|
||||
logger.error('[SeriesContent] Error unmarking episode as watched:', error);
|
||||
loadEpisodesProgress(); // Revert
|
||||
}
|
||||
}, [selectedEpisodeForAction, metadata?.id, imdbId]);
|
||||
|
||||
// Mark entire season as watched
|
||||
const handleMarkSeasonAsWatched = useCallback(async () => {
|
||||
if (!metadata?.id) return;
|
||||
|
||||
// Capture values
|
||||
const currentSeason = selectedSeason;
|
||||
const seasonEpisodes = groupedEpisodes[currentSeason] || [];
|
||||
const episodeNumbers = seasonEpisodes.map(ep => ep.episode_number);
|
||||
|
||||
// 1. Optimistic UI Update
|
||||
setEpisodeProgress(prev => {
|
||||
const next = { ...prev };
|
||||
seasonEpisodes.forEach(ep => {
|
||||
const id = ep.stremioId || `${metadata.id}:${ep.season_number}:${ep.episode_number}`;
|
||||
next[id] = { currentTime: 1, duration: 1, lastUpdated: Date.now() };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
// 2. Instant Feedback
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setEpisodeActionMenuVisible(false);
|
||||
setSelectedEpisodeForAction(null);
|
||||
|
||||
// 3. Background Async Operation
|
||||
const showImdbId = imdbId || metadata.id;
|
||||
try {
|
||||
const result = await watchedService.markSeasonAsWatched(
|
||||
showImdbId,
|
||||
metadata.id,
|
||||
currentSeason,
|
||||
episodeNumbers
|
||||
);
|
||||
|
||||
// Re-sync with source of truth
|
||||
loadEpisodesProgress();
|
||||
|
||||
logger.log(`[SeriesContent] Mark season as watched result:`, result);
|
||||
} catch (error) {
|
||||
logger.error('[SeriesContent] Error marking season as watched:', error);
|
||||
loadEpisodesProgress(); // Revert
|
||||
}
|
||||
}, [metadata?.id, imdbId, selectedSeason, groupedEpisodes]);
|
||||
|
||||
// Check if entire season is watched
|
||||
const isSeasonWatched = useCallback((): boolean => {
|
||||
const seasonEpisodes = groupedEpisodes[selectedSeason] || [];
|
||||
if (seasonEpisodes.length === 0) return false;
|
||||
|
||||
return seasonEpisodes.every(ep => {
|
||||
const episodeId = ep.stremioId || `${metadata?.id}:${ep.season_number}:${ep.episode_number}`;
|
||||
const progress = episodeProgress[episodeId];
|
||||
if (!progress) return false;
|
||||
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||
return progressPercent >= 85;
|
||||
});
|
||||
}, [groupedEpisodes, selectedSeason, episodeProgress, metadata?.id]);
|
||||
|
||||
// Unmark entire season as watched
|
||||
const handleMarkSeasonAsUnwatched = useCallback(async () => {
|
||||
if (!metadata?.id) return;
|
||||
|
||||
// Capture values
|
||||
const currentSeason = selectedSeason;
|
||||
const seasonEpisodes = groupedEpisodes[currentSeason] || [];
|
||||
const episodeNumbers = seasonEpisodes.map(ep => ep.episode_number);
|
||||
|
||||
// 1. Optimistic UI Update - Remove all episodes of season from progress
|
||||
setEpisodeProgress(prev => {
|
||||
const next = { ...prev };
|
||||
seasonEpisodes.forEach(ep => {
|
||||
const id = ep.stremioId || `${metadata.id}:${ep.season_number}:${ep.episode_number}`;
|
||||
delete next[id];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
// 2. Instant Feedback
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setEpisodeActionMenuVisible(false);
|
||||
setSelectedEpisodeForAction(null);
|
||||
|
||||
// 3. Background Async Operation
|
||||
const showImdbId = imdbId || metadata.id;
|
||||
try {
|
||||
const result = await watchedService.unmarkSeasonAsWatched(
|
||||
showImdbId,
|
||||
metadata.id,
|
||||
currentSeason,
|
||||
episodeNumbers
|
||||
);
|
||||
|
||||
// Re-sync
|
||||
loadEpisodesProgress();
|
||||
|
||||
logger.log(`[SeriesContent] Unmark season as watched result:`, result);
|
||||
} catch (error) {
|
||||
logger.error('[SeriesContent] Error unmarking season as watched:', error);
|
||||
loadEpisodesProgress(); // Revert
|
||||
}
|
||||
}, [metadata?.id, imdbId, selectedSeason, groupedEpisodes]);
|
||||
|
||||
// Close action menu
|
||||
const closeEpisodeActionMenu = useCallback(() => {
|
||||
setEpisodeActionMenuVisible(false);
|
||||
setSelectedEpisodeForAction(null);
|
||||
}, []);
|
||||
|
||||
if (loadingSeasons) {
|
||||
return (
|
||||
<View style={styles.centeredContainer}>
|
||||
|
|
@ -543,7 +753,11 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
|
||||
|
||||
|
||||
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
|
||||
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => {
|
||||
if (a === 0) return 1;
|
||||
if (b === 0) return -1;
|
||||
return a - b;
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
|
|
@ -660,7 +874,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
{ color: currentTheme.colors.highEmphasis }
|
||||
]
|
||||
]} numberOfLines={1}>
|
||||
Season {season}
|
||||
{season === 0 ? 'Specials' : `Season ${season}`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -723,7 +937,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
]
|
||||
]}
|
||||
>
|
||||
Season {season}
|
||||
{season === 0 ? 'Specials' : `Season ${season}`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -822,6 +1036,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
}
|
||||
]}
|
||||
onPress={() => onSelectEpisode(episode)}
|
||||
onLongPress={() => handleEpisodeLongPress(episode)}
|
||||
delayLongPress={400}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[
|
||||
|
|
@ -1103,6 +1319,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
}
|
||||
]}
|
||||
onPress={() => onSelectEpisode(episode)}
|
||||
onLongPress={() => handleEpisodeLongPress(episode)}
|
||||
delayLongPress={400}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{/* Solid outline replaces gradient border */}
|
||||
|
|
@ -1434,6 +1652,205 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
)
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Episode Action Menu Modal */}
|
||||
<Modal
|
||||
visible={episodeActionMenuVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={closeEpisodeActionMenu}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)', // Darker overlay
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
}}
|
||||
onPress={closeEpisodeActionMenu}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: '#1E1E1E', // Solid opaque dark background
|
||||
borderRadius: isTV ? 20 : 16,
|
||||
padding: isTV ? 24 : 20,
|
||||
width: isTV ? 400 : isLargeTablet ? 360 : isTablet ? 320 : '100%',
|
||||
maxWidth: 400,
|
||||
alignSelf: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)', // Subtle border
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 10,
|
||||
},
|
||||
shadowOpacity: 0.51,
|
||||
shadowRadius: 13.16,
|
||||
elevation: 20,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={{ marginBottom: isTV ? 20 : 16 }}>
|
||||
<Text style={{
|
||||
color: '#FFFFFF', // High contrast text
|
||||
fontSize: isTV ? 20 : 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
{selectedEpisodeForAction ? `S${selectedEpisodeForAction.season_number}E${selectedEpisodeForAction.episode_number}` : ''}
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: '#AAAAAA', // Medium emphasis text
|
||||
fontSize: isTV ? 16 : 14,
|
||||
}} numberOfLines={1} ellipsizeMode="tail">
|
||||
{selectedEpisodeForAction?.name || ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Action buttons */}
|
||||
<View style={{ gap: isTV ? 12 : 10 }}>
|
||||
{/* Mark as Watched / Unwatched */}
|
||||
{selectedEpisodeForAction && (
|
||||
isEpisodeWatched(selectedEpisodeForAction) ? (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)', // Defined background
|
||||
padding: isTV ? 16 : 14,
|
||||
borderRadius: isTV ? 12 : 10,
|
||||
opacity: markingAsWatched ? 0.5 : 1,
|
||||
}}
|
||||
onPress={handleMarkAsUnwatched}
|
||||
disabled={markingAsWatched}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="visibility-off"
|
||||
size={isTV ? 24 : 22}
|
||||
color="#FFFFFF"
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: isTV ? 16 : 15,
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
{markingAsWatched ? 'Removing...' : 'Mark as Unwatched'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
padding: isTV ? 16 : 14,
|
||||
borderRadius: isTV ? 12 : 10,
|
||||
opacity: markingAsWatched ? 0.5 : 1,
|
||||
}}
|
||||
onPress={handleMarkAsWatched}
|
||||
disabled={markingAsWatched}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={isTV ? 24 : 22}
|
||||
color="#FFFFFF"
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: isTV ? 16 : 15,
|
||||
fontWeight: '600',
|
||||
}}>
|
||||
{markingAsWatched ? 'Marking...' : 'Mark as Watched'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Mark Season as Watched / Unwatched */}
|
||||
{isSeasonWatched() ? (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
padding: isTV ? 16 : 14,
|
||||
borderRadius: isTV ? 12 : 10,
|
||||
opacity: markingAsWatched ? 0.5 : 1,
|
||||
}}
|
||||
onPress={handleMarkSeasonAsUnwatched}
|
||||
disabled={markingAsWatched}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="playlist-remove"
|
||||
size={isTV ? 24 : 22}
|
||||
color="#FFFFFF"
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: isTV ? 16 : 15,
|
||||
fontWeight: '500',
|
||||
flex: 1, // Allow text to take up space
|
||||
}} numberOfLines={1}>
|
||||
{markingAsWatched ? 'Removing...' : `Unmark Season ${selectedSeason}`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
padding: isTV ? 16 : 14,
|
||||
borderRadius: isTV ? 12 : 10,
|
||||
opacity: markingAsWatched ? 0.5 : 1,
|
||||
}}
|
||||
onPress={handleMarkSeasonAsWatched}
|
||||
disabled={markingAsWatched}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="playlist-add-check"
|
||||
size={isTV ? 24 : 22}
|
||||
color="#FFFFFF"
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: isTV ? 16 : 15,
|
||||
fontWeight: '500',
|
||||
flex: 1,
|
||||
}} numberOfLines={1}>
|
||||
{markingAsWatched ? 'Marking...' : `Mark Season ${selectedSeason}`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Cancel */}
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
padding: isTV ? 14 : 12,
|
||||
marginTop: isTV ? 8 : 4,
|
||||
}}
|
||||
onPress={closeEpisodeActionMenu}
|
||||
>
|
||||
<Text style={{
|
||||
color: '#999999',
|
||||
fontSize: isTV ? 15 : 14,
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -133,6 +133,20 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
} as any;
|
||||
};
|
||||
|
||||
// Helper to get dynamic volume icon
|
||||
const getVolumeIcon = (value: number) => {
|
||||
if (value === 0) return 'volume-off';
|
||||
if (value < 0.3) return 'volume-mute';
|
||||
if (value < 0.6) return 'volume-down';
|
||||
return 'volume-up';
|
||||
};
|
||||
|
||||
// Helper to get dynamic brightness icon
|
||||
const getBrightnessIcon = (value: number) => {
|
||||
if (value < 0.3) return 'brightness-low';
|
||||
if (value < 0.7) return 'brightness-medium';
|
||||
return 'brightness-high';
|
||||
};
|
||||
|
||||
// Get appropriate headers based on stream type
|
||||
const getStreamHeaders = () => {
|
||||
|
|
@ -861,18 +875,21 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// Re-apply immersive mode on layout changes to keep system bars hidden
|
||||
enableImmersiveMode();
|
||||
});
|
||||
const initializePlayer = async () => {
|
||||
StatusBar.setHidden(true, 'none');
|
||||
enableImmersiveMode();
|
||||
startOpeningAnimation();
|
||||
|
||||
// Initialize current volume and brightness levels
|
||||
// Volume starts at 1.0 (full volume) - React Native Video handles this natively
|
||||
setVolume(1.0);
|
||||
if (DEBUG_MODE) {
|
||||
logger.log(`[AndroidVideoPlayer] Initial volume: 1.0 (native)`);
|
||||
}
|
||||
// Immediate player setup - UI critical
|
||||
StatusBar.setHidden(true, 'none');
|
||||
enableImmersiveMode();
|
||||
startOpeningAnimation();
|
||||
|
||||
// Initialize volume immediately (no async)
|
||||
setVolume(1.0);
|
||||
if (DEBUG_MODE) {
|
||||
logger.log(`[AndroidVideoPlayer] Initial volume: 1.0 (native)`);
|
||||
}
|
||||
|
||||
// Defer brightness initialization until after navigation animation completes
|
||||
// This prevents sluggish player entry
|
||||
const brightnessTask = InteractionManager.runAfterInteractions(async () => {
|
||||
try {
|
||||
// Capture Android system brightness and mode to restore later
|
||||
if (Platform.OS === 'android') {
|
||||
|
|
@ -900,10 +917,11 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// Fallback to 1.0 if brightness API fails
|
||||
setBrightness(1.0);
|
||||
}
|
||||
};
|
||||
initializePlayer();
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription?.remove();
|
||||
brightnessTask.cancel();
|
||||
disableImmersiveMode();
|
||||
};
|
||||
}, []);
|
||||
|
|
@ -1758,11 +1776,20 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
if (Platform.OS !== 'android') return;
|
||||
try {
|
||||
// Restore mode first (if available), then brightness value
|
||||
if (originalSystemBrightnessModeRef.current !== null && typeof (Brightness as any).setSystemBrightnessModeAsync === 'function') {
|
||||
await (Brightness as any).setSystemBrightnessModeAsync(originalSystemBrightnessModeRef.current);
|
||||
}
|
||||
if (originalSystemBrightnessRef.current !== null && typeof (Brightness as any).setSystemBrightnessAsync === 'function') {
|
||||
await (Brightness as any).setSystemBrightnessAsync(originalSystemBrightnessRef.current);
|
||||
// Restore mode first (if available), then brightness value
|
||||
if (typeof (Brightness as any).restoreSystemBrightnessAsync === 'function') {
|
||||
await (Brightness as any).restoreSystemBrightnessAsync();
|
||||
} else {
|
||||
// Fallback: verify we have permission before attempting to write to system settings
|
||||
const { status } = await (Brightness as any).getPermissionsAsync();
|
||||
if (status === 'granted') {
|
||||
if (originalSystemBrightnessModeRef.current !== null && typeof (Brightness as any).setSystemBrightnessModeAsync === 'function') {
|
||||
await (Brightness as any).setSystemBrightnessModeAsync(originalSystemBrightnessModeRef.current);
|
||||
}
|
||||
if (originalSystemBrightnessRef.current !== null && typeof (Brightness as any).setSystemBrightnessAsync === 'function') {
|
||||
await (Brightness as any).setSystemBrightnessAsync(originalSystemBrightnessRef.current);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (DEBUG_MODE) {
|
||||
logger.log('[AndroidVideoPlayer] Restored Android system brightness and mode');
|
||||
|
|
@ -1772,55 +1799,52 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
await restoreSystemBrightness();
|
||||
// Don't await brightness restoration - do it in background
|
||||
restoreSystemBrightness();
|
||||
|
||||
// Navigate immediately without delay
|
||||
ScreenOrientation.unlockAsync().then(() => {
|
||||
// On tablets keep rotation unlocked; on phones, return to portrait
|
||||
const { width: dw, height: dh } = Dimensions.get('window');
|
||||
const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true);
|
||||
if (!isTablet) {
|
||||
setTimeout(() => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
|
||||
}, 50);
|
||||
} else {
|
||||
ScreenOrientation.unlockAsync().catch(() => { });
|
||||
}
|
||||
disableImmersiveMode();
|
||||
// Disable immersive mode immediately (synchronous)
|
||||
disableImmersiveMode();
|
||||
|
||||
// Simple back navigation (StreamsScreen should be below Player)
|
||||
if ((navigation as any).canGoBack && (navigation as any).canGoBack()) {
|
||||
(navigation as any).goBack();
|
||||
} else {
|
||||
// Fallback to Streams if stack isn't present
|
||||
(navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true });
|
||||
}
|
||||
}).catch(() => {
|
||||
// Fallback: still try to restore portrait on phones then navigate
|
||||
const { width: dw, height: dh } = Dimensions.get('window');
|
||||
const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true);
|
||||
if (!isTablet) {
|
||||
setTimeout(() => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
|
||||
}, 50);
|
||||
} else {
|
||||
ScreenOrientation.unlockAsync().catch(() => { });
|
||||
}
|
||||
disableImmersiveMode();
|
||||
// Navigate IMMEDIATELY - don't wait for orientation changes
|
||||
if ((navigation as any).canGoBack && (navigation as any).canGoBack()) {
|
||||
(navigation as any).goBack();
|
||||
} else {
|
||||
// Fallback to Streams if stack isn't present
|
||||
(navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true });
|
||||
}
|
||||
|
||||
// Simple back navigation fallback path
|
||||
if ((navigation as any).canGoBack && (navigation as any).canGoBack()) {
|
||||
(navigation as any).goBack();
|
||||
} else {
|
||||
(navigation as any).navigate('Streams', { id, type, episodeId, fromPlayer: true });
|
||||
}
|
||||
});
|
||||
// Fire orientation changes in background - don't await
|
||||
ScreenOrientation.unlockAsync()
|
||||
.then(() => {
|
||||
// On tablets keep rotation unlocked; on phones, return to portrait
|
||||
const { width: dw, height: dh } = Dimensions.get('window');
|
||||
const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true);
|
||||
if (!isTablet) {
|
||||
setTimeout(() => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
|
||||
}, 50);
|
||||
} else {
|
||||
ScreenOrientation.unlockAsync().catch(() => { });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback: still try to restore portrait on phones
|
||||
const { width: dw, height: dh } = Dimensions.get('window');
|
||||
const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true);
|
||||
if (!isTablet) {
|
||||
setTimeout(() => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
|
||||
}, 50);
|
||||
} else {
|
||||
ScreenOrientation.unlockAsync().catch(() => { });
|
||||
}
|
||||
});
|
||||
|
||||
// Send Trakt sync in background (don't await)
|
||||
const backgroundSync = async () => {
|
||||
try {
|
||||
logger.log('[AndroidVideoPlayer] Starting background Trakt sync');
|
||||
// IMMEDIATE: Force immediate progress update (scrobble/pause) with the exact time
|
||||
// IMMEDIATE: Force immediate progress update (uses scrobble/stop which handles pause/scrobble)
|
||||
await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true);
|
||||
|
||||
// IMMEDIATE: Use user_close reason to trigger immediate scrobble stop
|
||||
|
|
@ -2558,7 +2582,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
logger.log('[AndroidVideoPlayer] Fetching streams for next episode:', nextEpisodeId);
|
||||
|
||||
// Import stremio service
|
||||
// Import stremio service
|
||||
const stremioService = require('../../services/stremioService').default;
|
||||
|
||||
let bestStream: any = null;
|
||||
|
|
@ -2803,11 +2827,13 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// Best-effort restore of Android system brightness state on unmount
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
if (originalSystemBrightnessModeRef.current !== null && typeof (Brightness as any).setSystemBrightnessModeAsync === 'function') {
|
||||
(Brightness as any).setSystemBrightnessModeAsync(originalSystemBrightnessModeRef.current);
|
||||
}
|
||||
if (originalSystemBrightnessRef.current !== null && typeof (Brightness as any).setSystemBrightnessAsync === 'function') {
|
||||
(Brightness as any).setSystemBrightnessAsync(originalSystemBrightnessRef.current);
|
||||
// Use restoreSystemBrightnessAsync if available to reset window override
|
||||
if (typeof (Brightness as any).restoreSystemBrightnessAsync === 'function') {
|
||||
(Brightness as any).restoreSystemBrightnessAsync();
|
||||
} else {
|
||||
// Fallback for older versions or if restore is not available
|
||||
// Only attempt to write system settings if strictly necessary and likely to succeed
|
||||
// We skip the permission check here for sync cleanup, but catch the error if it fails
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('[AndroidVideoPlayer] Failed to restore system brightness on unmount:', e);
|
||||
|
|
@ -3124,7 +3150,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
]}
|
||||
>
|
||||
{/* Combined gesture handler for left side - brightness + tap + long press */}
|
||||
{/* Left side gesture handler - brightness + tap + long press (Android and iOS) */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
|
|
@ -3148,11 +3174,11 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15, // Back to original margin
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: 0,
|
||||
width: screenDimensions.width * 0.4, // Back to larger area (40% of screen)
|
||||
height: screenDimensions.height * 0.7, // Back to larger middle portion (70% of screen)
|
||||
zIndex: 10, // Higher z-index to capture gestures
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
|
|
@ -3406,8 +3432,57 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
buffered={buffered}
|
||||
formatTime={formatTime}
|
||||
playerBackend={useVLC ? 'VLC' : 'ExoPlayer'}
|
||||
nextLoadingTitle={nextLoadingTitle}
|
||||
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 120 : 100}
|
||||
/>
|
||||
|
||||
{/* Combined Volume & Brightness Gesture Indicator - NEW PILL STYLE (No Bar) */}
|
||||
{(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
{/* Dynamic Icon */}
|
||||
<View
|
||||
style={[
|
||||
localStyles.iconWrapper,
|
||||
{
|
||||
// Conditional Background Color Logic
|
||||
backgroundColor: gestureControls.showVolumeOverlay && volume === 0
|
||||
? 'rgba(242, 184, 181)'
|
||||
: 'rgba(59, 59, 59)'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={
|
||||
gestureControls.showVolumeOverlay
|
||||
? getVolumeIcon(volume)
|
||||
: getBrightnessIcon(brightness)
|
||||
}
|
||||
size={24} // Reduced size to fit inside a 32-40px circle better
|
||||
color={
|
||||
gestureControls.showVolumeOverlay && volume === 0
|
||||
? 'rgba(96, 20, 16)' // Bright RED for MUTE icon itself
|
||||
: 'rgba(255, 255, 255)' // White for all other states
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Text Label: Shows "Muted" or percentage */}
|
||||
<Text
|
||||
style={[
|
||||
localStyles.gestureText,
|
||||
// Conditional Text Color Logic
|
||||
gestureControls.showVolumeOverlay && volume === 0 && { color: 'rgba(242, 184, 181)' } // Light RED for "Muted"
|
||||
]}
|
||||
>
|
||||
{/* Conditional Text Content Logic */}
|
||||
{gestureControls.showVolumeOverlay && volume === 0
|
||||
? "Muted" // Display "Muted" when volume is 0
|
||||
: `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%` // Display percentage otherwise
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showPauseOverlay && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
|
|
@ -3750,214 +3825,20 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 120 : 100}
|
||||
/>
|
||||
|
||||
{/* Volume Overlay */}
|
||||
{gestureControls.showVolumeOverlay && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: screenDimensions.width / 2 - 60,
|
||||
top: screenDimensions.height / 2 - 60,
|
||||
opacity: gestureControls.volumeOverlayOpacity,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
width: 120,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}>
|
||||
<MaterialIcons
|
||||
name={volume === 0 ? "volume-off" : volume < 0.3 ? "volume-mute" : volume < 0.7 ? "volume-down" : "volume-up"}
|
||||
size={24}
|
||||
color={volume === 0 ? "#FF6B6B" : "#FFFFFF"}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
|
||||
{/* Horizontal Dotted Progress Bar */}
|
||||
<View style={{
|
||||
width: 80,
|
||||
height: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{/* Dotted background */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 1,
|
||||
}}>
|
||||
{Array.from({ length: 16 }, (_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
width: 1.5,
|
||||
height: 1.5,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 0.75,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Progress fill */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: `${volume * 100}%`,
|
||||
height: 6,
|
||||
backgroundColor: volume === 0 ? '#FF6B6B' : '#E50914',
|
||||
borderRadius: 3,
|
||||
shadowColor: volume === 0 ? '#FF6B6B' : '#E50914',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 2,
|
||||
}} />
|
||||
</View>
|
||||
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
{Math.round(volume * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Brightness Overlay */}
|
||||
{gestureControls.showBrightnessOverlay && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: screenDimensions.width / 2 - 60,
|
||||
top: screenDimensions.height / 2 - 60,
|
||||
opacity: gestureControls.brightnessOverlayOpacity,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
width: 120,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}>
|
||||
<MaterialIcons
|
||||
name={brightness < 0.2 ? "brightness-low" : brightness < 0.5 ? "brightness-medium" : brightness < 0.8 ? "brightness-high" : "brightness-auto"}
|
||||
size={24}
|
||||
color={brightness < 0.2 ? "#FFD700" : "#FFFFFF"}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
|
||||
{/* Horizontal Dotted Progress Bar */}
|
||||
<View style={{
|
||||
width: 80,
|
||||
height: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{/* Dotted background */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 1,
|
||||
}}>
|
||||
{Array.from({ length: 16 }, (_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
width: 1.5,
|
||||
height: 1.5,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 0.75,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Progress fill */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: `${brightness * 100}%`,
|
||||
height: 6,
|
||||
backgroundColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
|
||||
borderRadius: 3,
|
||||
shadowColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 2,
|
||||
}} />
|
||||
</View>
|
||||
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
{Math.round(brightness * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Speed Activated Overlay */}
|
||||
{showSpeedActivatedOverlay && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.1,
|
||||
top: screenDimensions.height * 0.06,
|
||||
left: screenDimensions.width / 2 - 40,
|
||||
opacity: speedActivatedOverlayOpacity,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(25, 25, 25, 0.6)',
|
||||
borderRadius: 35,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
alignItems: 'center',
|
||||
|
|
@ -3974,7 +3855,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
{holdToSpeedValue}x Speed Activated
|
||||
{holdToSpeedValue}x Speed
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
|
@ -4183,4 +4064,36 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default AndroidVideoPlayer;
|
||||
// New styles for the gesture indicator
|
||||
const localStyles = StyleSheet.create({
|
||||
gestureIndicatorContainer: {
|
||||
position: 'absolute',
|
||||
top: '4%', // Adjust this for vertical position
|
||||
alignSelf: 'center', // Adjust this for horizontal position
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(25, 25, 25)', // Dark pill background
|
||||
borderRadius: 70,
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 15,
|
||||
zIndex: 2000, // Very high z-index to ensure visibility
|
||||
minWidth: 120, // Adjusted min width since bar is removed
|
||||
},
|
||||
iconWrapper: {
|
||||
borderRadius: 50, // Makes it a perfect circle (set to a high number)
|
||||
width: 40, // Define the diameter of the circle
|
||||
height: 40, // Define the diameter of the circle
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12, // Margin to separate icon circle from percentage text
|
||||
},
|
||||
gestureText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: 'normal',
|
||||
minWidth: 35,
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
|
||||
export default AndroidVideoPlayer;
|
||||
|
|
|
|||
|
|
@ -130,7 +130,9 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
|
|||
getTracks: async () => {
|
||||
if (nativeRef.current) {
|
||||
const node = findNodeHandle(nativeRef.current);
|
||||
return await KSPlayerModule.getTracks(node);
|
||||
if (node) {
|
||||
return await KSPlayerModule.getTracks(node);
|
||||
}
|
||||
}
|
||||
return { audioTracks: [], textTracks: [] };
|
||||
},
|
||||
|
|
@ -153,15 +155,21 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
|
|||
getAirPlayState: async () => {
|
||||
if (nativeRef.current) {
|
||||
const node = findNodeHandle(nativeRef.current);
|
||||
return await KSPlayerModule.getAirPlayState(node);
|
||||
if (node) {
|
||||
return await KSPlayerModule.getAirPlayState(node);
|
||||
}
|
||||
}
|
||||
return { allowsExternalPlayback: false, usesExternalPlaybackWhileExternalScreenIsActive: false, isExternalPlaybackActive: false };
|
||||
},
|
||||
showAirPlayPicker: () => {
|
||||
if (nativeRef.current) {
|
||||
const node = findNodeHandle(nativeRef.current);
|
||||
console.log('[KSPlayerComponent] Calling showAirPlayPicker with node:', node);
|
||||
KSPlayerModule.showAirPlayPicker(node);
|
||||
if (node) {
|
||||
console.log('[KSPlayerComponent] Calling showAirPlayPicker with node:', node);
|
||||
KSPlayerModule.showAirPlayPicker(node);
|
||||
} else {
|
||||
console.warn('[KSPlayerComponent] Cannot call showAirPlayPicker: node is null');
|
||||
}
|
||||
} else {
|
||||
console.log('[KSPlayerComponent] nativeRef.current is null');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -83,215 +83,225 @@ export function useFeaturedContent() {
|
|||
const signal = abortControllerRef.current.signal;
|
||||
|
||||
try {
|
||||
let formattedContent: StreamingContent[] = [];
|
||||
// Load list of catalogs to fetch
|
||||
const configs = await catalogService.resolveHomeCatalogsToFetch(selectedCatalogs);
|
||||
|
||||
{
|
||||
// Load from installed catalogs with optimization
|
||||
const tCats = Date.now();
|
||||
// Pass selected catalogs to service for optimized fetching
|
||||
const catalogs = await catalogService.getHomeCatalogs(selectedCatalogs);
|
||||
if (signal.aborted) return;
|
||||
|
||||
if (signal.aborted) return;
|
||||
// Prepare for incremental loading
|
||||
const seenIds = new Set<string>();
|
||||
let accumulatedContent: StreamingContent[] = [];
|
||||
const TARGET_COUNT = 10;
|
||||
let hasSetInitialContent = false;
|
||||
|
||||
// If no catalogs are installed, stop loading and return.
|
||||
if (catalogs.length === 0) {
|
||||
formattedContent = [];
|
||||
} else {
|
||||
// Use catalogs directly (filtering is now done in service)
|
||||
const filteredCatalogs = catalogs;
|
||||
// Helper function to enrich items
|
||||
const enrichItems = async (items: any[]): Promise<StreamingContent[]> => {
|
||||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||||
|
||||
// Flatten all catalog items into a single array, filter out items without posters
|
||||
const tFlat = Date.now();
|
||||
const allItems = filteredCatalogs.flatMap(catalog => catalog.items)
|
||||
.filter(item => item.poster)
|
||||
.filter((item, index, self) =>
|
||||
// Remove duplicates based on ID
|
||||
index === self.findIndex(t => t.id === item.id)
|
||||
);
|
||||
|
||||
// Sort by popular, newest, etc. (possibly enhanced later) and take first 10
|
||||
const topItems = allItems.sort(() => Math.random() - 0.5).slice(0, 10);
|
||||
|
||||
// Optionally enrich with logos (TMDB only) for tmdb/imdb sourced IDs
|
||||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||||
|
||||
const enrichLogo = async (item: any): Promise<StreamingContent> => {
|
||||
const base: StreamingContent = {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
poster: item.poster,
|
||||
banner: (item as any).banner,
|
||||
logo: (item as any).logo,
|
||||
description: (item as any).description,
|
||||
year: (item as any).year,
|
||||
genres: (item as any).genres,
|
||||
inLibrary: Boolean((item as any).inLibrary),
|
||||
};
|
||||
|
||||
try {
|
||||
if (!settings.enrichMetadataWithTMDB) {
|
||||
// When enrichment is OFF, keep addon logo or undefined
|
||||
return { ...base, logo: base.logo || undefined };
|
||||
}
|
||||
|
||||
// When enrichment is ON, fetch from TMDB with language preference
|
||||
const rawId = String(item.id);
|
||||
const isTmdb = rawId.startsWith('tmdb:');
|
||||
const isImdb = rawId.startsWith('tt');
|
||||
let tmdbId: string | null = null;
|
||||
let imdbId: string | null = null;
|
||||
|
||||
if (isTmdb) tmdbId = rawId.split(':')[1];
|
||||
if (isImdb) imdbId = rawId.split(':')[0];
|
||||
if (!tmdbId && imdbId) {
|
||||
const found = await tmdbService.findTMDBIdByIMDB(imdbId);
|
||||
tmdbId = found ? String(found) : null;
|
||||
}
|
||||
|
||||
if (tmdbId) {
|
||||
const logoUrl = await tmdbService.getContentLogo(item.type === 'series' ? 'tv' : 'movie', tmdbId as string, preferredLanguage);
|
||||
return { ...base, logo: logoUrl || undefined }; // TMDB logo or undefined (no addon fallback)
|
||||
}
|
||||
|
||||
return { ...base, logo: undefined }; // No TMDB ID means no logo
|
||||
} catch (error) {
|
||||
return { ...base, logo: undefined }; // Error means no logo
|
||||
}
|
||||
const enrichLogo = async (item: any): Promise<StreamingContent> => {
|
||||
const base: StreamingContent = {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
poster: item.poster,
|
||||
banner: (item as any).banner,
|
||||
logo: (item as any).logo,
|
||||
description: (item as any).description,
|
||||
year: (item as any).year,
|
||||
genres: (item as any).genres,
|
||||
inLibrary: Boolean((item as any).inLibrary),
|
||||
};
|
||||
|
||||
// Only enrich with logos if enrichment is enabled
|
||||
if (settings.enrichMetadataWithTMDB) {
|
||||
formattedContent = await Promise.all(topItems.map(enrichLogo));
|
||||
try {
|
||||
const details = formattedContent.slice(0, 20).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
hasLogo: Boolean(c.logo),
|
||||
logoSource: c.logo ? (isTmdbUrl(String(c.logo)) ? 'tmdb' : 'addon') : 'none',
|
||||
logo: c.logo || undefined,
|
||||
}));
|
||||
} catch { }
|
||||
} else {
|
||||
// When enrichment is disabled, prefer addon-provided logos; if missing, fetch basic meta to pull logo (like HeroSection)
|
||||
const baseItems = topItems.map((item: any) => {
|
||||
const base: StreamingContent = {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
poster: item.poster,
|
||||
banner: (item as any).banner,
|
||||
logo: (item as any).logo || undefined,
|
||||
description: (item as any).description,
|
||||
year: (item as any).year,
|
||||
genres: (item as any).genres,
|
||||
inLibrary: Boolean((item as any).inLibrary),
|
||||
};
|
||||
return base;
|
||||
});
|
||||
try {
|
||||
if (!settings.enrichMetadataWithTMDB) {
|
||||
return { ...base, logo: base.logo || undefined };
|
||||
}
|
||||
|
||||
// Attempt to fill missing logos from addon meta details for a limited subset
|
||||
const candidates = baseItems.filter(i => !i.logo).slice(0, 10);
|
||||
const rawId = String(item.id);
|
||||
const isTmdb = rawId.startsWith('tmdb:');
|
||||
const isImdb = rawId.startsWith('tt');
|
||||
let tmdbId: string | null = null;
|
||||
let imdbId: string | null = null;
|
||||
|
||||
if (isTmdb) tmdbId = rawId.split(':')[1];
|
||||
if (isImdb) imdbId = rawId.split(':')[0];
|
||||
if (!tmdbId && imdbId) {
|
||||
const found = await tmdbService.findTMDBIdByIMDB(imdbId);
|
||||
tmdbId = found ? String(found) : null;
|
||||
}
|
||||
|
||||
if (tmdbId) {
|
||||
const logoUrl = await tmdbService.getContentLogo(item.type === 'series' ? 'tv' : 'movie', tmdbId as string, preferredLanguage);
|
||||
return { ...base, logo: logoUrl || undefined };
|
||||
}
|
||||
|
||||
return { ...base, logo: undefined };
|
||||
} catch (error) {
|
||||
return { ...base, logo: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
if (settings.enrichMetadataWithTMDB) {
|
||||
return Promise.all(items.map(enrichLogo));
|
||||
} else {
|
||||
// Fallback logic for when enrichment is disabled
|
||||
const baseItems = items.map((item: any) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
poster: item.poster,
|
||||
banner: (item as any).banner,
|
||||
logo: (item as any).logo || undefined,
|
||||
description: (item as any).description,
|
||||
year: (item as any).year,
|
||||
genres: (item as any).genres,
|
||||
inLibrary: Boolean((item as any).inLibrary),
|
||||
}));
|
||||
|
||||
// Try to get logos for items missing them
|
||||
const missingLogoCandidates = baseItems.filter((i: any) => !i.logo);
|
||||
if (missingLogoCandidates.length > 0) {
|
||||
try {
|
||||
const filled = await Promise.allSettled(candidates.map(async (item) => {
|
||||
const filled = await Promise.allSettled(missingLogoCandidates.map(async (item: any) => {
|
||||
try {
|
||||
const meta = await catalogService.getBasicContentDetails(item.type, item.id);
|
||||
if (meta?.logo) {
|
||||
return { id: item.id, logo: meta.logo } as { id: string; logo: string };
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
return { id: item.id, logo: undefined as any };
|
||||
if (meta?.logo) return { id: item.id, logo: meta.logo };
|
||||
} catch { }
|
||||
return { id: item.id, logo: undefined };
|
||||
}));
|
||||
|
||||
const idToLogo = new Map<string, string>();
|
||||
filled.forEach(res => {
|
||||
if (res.status === 'fulfilled' && res.value && res.value.logo) {
|
||||
const idToLogo = new Map();
|
||||
filled.forEach((res: any) => {
|
||||
if (res.status === 'fulfilled' && res.value?.logo) {
|
||||
idToLogo.set(res.value.id, res.value.logo);
|
||||
}
|
||||
});
|
||||
|
||||
formattedContent = baseItems.map(i => (
|
||||
idToLogo.has(i.id) ? { ...i, logo: idToLogo.get(i.id)! } : i
|
||||
));
|
||||
return baseItems.map((i: any) => idToLogo.has(i.id) ? { ...i, logo: idToLogo.get(i.id) } : i);
|
||||
} catch {
|
||||
formattedContent = baseItems;
|
||||
return baseItems;
|
||||
}
|
||||
|
||||
try {
|
||||
const details = formattedContent.slice(0, 20).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
hasLogo: Boolean(c.logo),
|
||||
logoSource: c.logo ? (isTmdbUrl(String(c.logo)) ? 'tmdb' : 'addon') : 'none',
|
||||
logo: c.logo || undefined,
|
||||
}));
|
||||
} catch { }
|
||||
}
|
||||
return baseItems;
|
||||
}
|
||||
};
|
||||
|
||||
// Process each catalog independently
|
||||
const processCatalog = async (config: { addon: any, catalog: any }) => {
|
||||
if (signal.aborted) return;
|
||||
// Optimization: Stop fetching if we have enough items
|
||||
// Note: We check length here but parallel requests might race. This is acceptable.
|
||||
if (accumulatedContent.length >= TARGET_COUNT) return;
|
||||
|
||||
try {
|
||||
const cat = await catalogService.fetchHomeCatalog(config.addon, config.catalog);
|
||||
if (signal.aborted) return;
|
||||
if (!cat || !cat.items || cat.items.length === 0) return;
|
||||
|
||||
// Deduplicate
|
||||
const newItems = cat.items.filter(item => {
|
||||
if (!item.poster) return false;
|
||||
if (seenIds.has(item.id)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (newItems.length === 0) return;
|
||||
|
||||
// Take only what we need (or a small batch)
|
||||
const needed = TARGET_COUNT - accumulatedContent.length;
|
||||
// Shuffle this batch locally just to mix it up a bit if the catalog returns strict order
|
||||
const shuffledBatch = newItems.sort(() => Math.random() - 0.5).slice(0, needed);
|
||||
|
||||
if (shuffledBatch.length === 0) return;
|
||||
|
||||
shuffledBatch.forEach(item => seenIds.add(item.id));
|
||||
|
||||
// Enrich this batch
|
||||
const enrichedBatch = await enrichItems(shuffledBatch);
|
||||
if (signal.aborted) return;
|
||||
|
||||
// Update accumulated content
|
||||
accumulatedContent = [...accumulatedContent, ...enrichedBatch];
|
||||
|
||||
// Update State
|
||||
// Always update allFeaturedContent to show progress
|
||||
setAllFeaturedContent([...accumulatedContent]);
|
||||
|
||||
// If this is the first batch, set initial state and UNBLOCK LOADING
|
||||
if (!hasSetInitialContent && accumulatedContent.length > 0) {
|
||||
hasSetInitialContent = true;
|
||||
setFeaturedContent(accumulatedContent[0]);
|
||||
persistentStore.featuredContent = accumulatedContent[0];
|
||||
persistentStore.allFeaturedContent = accumulatedContent;
|
||||
currentIndexRef.current = 0;
|
||||
setLoading(false); // <--- Key improvement: Display content immediately
|
||||
} else {
|
||||
// Just update store for subsequent batches
|
||||
persistentStore.allFeaturedContent = accumulatedContent;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
logger.error('Error processing catalog in parallel', e);
|
||||
}
|
||||
};
|
||||
|
||||
// If no catalogs to fetch, fallback immediately
|
||||
if (configs.length === 0) {
|
||||
// Fallback logic
|
||||
} else {
|
||||
// Run fetches in parallel
|
||||
await Promise.all(configs.map(processCatalog));
|
||||
}
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
// Safety guard: if nothing came back within a reasonable time, stop loading
|
||||
if (!formattedContent || formattedContent.length === 0) {
|
||||
// Handle case where we finished all fetches but found NOTHING
|
||||
if (accumulatedContent.length === 0) {
|
||||
// Fall back to any cached featured item so UI can render something
|
||||
const cachedJson = await mmkvStorage.getItem(STORAGE_KEY).catch(() => null);
|
||||
if (cachedJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(cachedJson);
|
||||
if (parsed?.featuredContent) {
|
||||
formattedContent = Array.isArray(parsed.allFeaturedContent) && parsed.allFeaturedContent.length > 0
|
||||
const fallback = Array.isArray(parsed.allFeaturedContent) && parsed.allFeaturedContent.length > 0
|
||||
? parsed.allFeaturedContent
|
||||
: [parsed.featuredContent];
|
||||
|
||||
setAllFeaturedContent(fallback);
|
||||
setFeaturedContent(fallback[0]);
|
||||
setLoading(false);
|
||||
return; // Done
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Update persistent store with the new data (no lastFetchTime when cache disabled)
|
||||
persistentStore.allFeaturedContent = formattedContent;
|
||||
if (!DISABLE_CACHE) {
|
||||
persistentStore.lastFetchTime = now;
|
||||
}
|
||||
persistentStore.isFirstLoad = false;
|
||||
|
||||
setAllFeaturedContent(formattedContent);
|
||||
|
||||
if (formattedContent.length > 0) {
|
||||
persistentStore.featuredContent = formattedContent[0];
|
||||
setFeaturedContent(formattedContent[0]);
|
||||
currentIndexRef.current = 0;
|
||||
// Persist cache for fast startup (skipped when cache disabled)
|
||||
if (!DISABLE_CACHE) {
|
||||
try {
|
||||
await mmkvStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
ts: now,
|
||||
featuredContent: formattedContent[0],
|
||||
allFeaturedContent: formattedContent,
|
||||
})
|
||||
);
|
||||
} catch { }
|
||||
}
|
||||
} else {
|
||||
persistentStore.featuredContent = null;
|
||||
// If still nothing
|
||||
setFeaturedContent(null);
|
||||
// Clear persisted cache on empty (skipped when cache disabled)
|
||||
if (!DISABLE_CACHE) {
|
||||
try { await mmkvStorage.removeItem(STORAGE_KEY); } catch { }
|
||||
}
|
||||
setAllFeaturedContent([]);
|
||||
setLoading(false); // Ensure we don't hang in loading state
|
||||
}
|
||||
|
||||
// Final persistence
|
||||
persistentStore.allFeaturedContent = accumulatedContent;
|
||||
if (!DISABLE_CACHE && accumulatedContent.length > 0) {
|
||||
persistentStore.lastFetchTime = now;
|
||||
try {
|
||||
await mmkvStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
ts: now,
|
||||
featuredContent: accumulatedContent[0],
|
||||
allFeaturedContent: accumulatedContent,
|
||||
})
|
||||
);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
} else {
|
||||
}
|
||||
setFeaturedContent(null);
|
||||
setAllFeaturedContent([]);
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
// Even on error, ensure we stop loading
|
||||
setFeaturedContent(null);
|
||||
setAllFeaturedContent([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { mmkvStorage } from '../services/mmkvStorage';
|
||||
import * as Updates from 'expo-updates';
|
||||
import { getDisplayedAppVersion } from '../utils/version';
|
||||
|
|
@ -23,7 +24,14 @@ export function useGithubMajorUpdate(): MajorUpdateData {
|
|||
const [releaseUrl, setReleaseUrl] = useState<string | undefined>();
|
||||
|
||||
const check = useCallback(async () => {
|
||||
if (Platform.OS === 'ios') return;
|
||||
try {
|
||||
// Check if major update alerts are disabled
|
||||
const majorAlertsEnabled = await mmkvStorage.getItem('@major_updates_alerts_enabled');
|
||||
if (majorAlertsEnabled === 'false') {
|
||||
return; // Major update alerts are disabled by user
|
||||
}
|
||||
|
||||
// Always compare with Settings screen version
|
||||
const current = getDisplayedAppVersion() || Updates.runtimeVersion || '0.0.0';
|
||||
const info = await fetchLatestGithubRelease();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
stopWatching,
|
||||
stopWatchingImmediate
|
||||
} = useTraktIntegration();
|
||||
|
||||
|
||||
const { settings: autosyncSettings } = useTraktAutosyncSettings();
|
||||
|
||||
|
||||
const hasStartedWatching = useRef(false);
|
||||
const hasStopped = useRef(false); // New: Track if we've already stopped for this session
|
||||
const isSessionComplete = useRef(false); // New: Track if session is completely finished (scrobbled)
|
||||
|
|
@ -41,66 +41,106 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
const sessionKey = useRef<string | null>(null);
|
||||
const unmountCount = useRef(0);
|
||||
const lastStopCall = useRef(0); // New: Track last stop call timestamp
|
||||
|
||||
|
||||
// Generate a unique session key for this content instance
|
||||
useEffect(() => {
|
||||
const contentKey = options.type === 'movie'
|
||||
? `movie:${options.imdbId}`
|
||||
: `episode:${options.showImdbId || options.imdbId}:${options.season}:${options.episode}`;
|
||||
sessionKey.current = `${contentKey}:${Date.now()}`;
|
||||
|
||||
|
||||
// Reset all session state for new content
|
||||
hasStartedWatching.current = false;
|
||||
hasStopped.current = false;
|
||||
isSessionComplete.current = false;
|
||||
isUnmounted.current = false; // Reset unmount flag for new mount
|
||||
lastStopCall.current = 0;
|
||||
|
||||
|
||||
logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`);
|
||||
|
||||
|
||||
return () => {
|
||||
unmountCount.current++;
|
||||
isUnmounted.current = true; // Mark as unmounted to prevent post-unmount operations
|
||||
logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`);
|
||||
};
|
||||
}, [options.imdbId, options.season, options.episode, options.type]);
|
||||
|
||||
|
||||
// Build Trakt content data from options
|
||||
const buildContentData = useCallback((): TraktContentData => {
|
||||
// Ensure year is a number and valid
|
||||
const parseYear = (year: number | string | undefined): number => {
|
||||
if (!year) return 0;
|
||||
if (typeof year === 'number') return year;
|
||||
// Returns null if required fields are missing or invalid
|
||||
const buildContentData = useCallback((): TraktContentData | null => {
|
||||
// Parse and validate year - returns undefined for invalid/missing years
|
||||
const parseYear = (year: number | string | undefined): number | undefined => {
|
||||
if (year === undefined || year === null || year === '') return undefined;
|
||||
if (typeof year === 'number') {
|
||||
// Year must be a reasonable value (between 1800 and current year + 10)
|
||||
const currentYear = new Date().getFullYear();
|
||||
if (year <= 0 || year < 1800 || year > currentYear + 10) {
|
||||
logger.warn(`[TraktAutosync] Invalid year value: ${year}`);
|
||||
return undefined;
|
||||
}
|
||||
return year;
|
||||
}
|
||||
const parsed = parseInt(year.toString(), 10);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
if (isNaN(parsed) || parsed <= 0) {
|
||||
logger.warn(`[TraktAutosync] Failed to parse year: ${year}`);
|
||||
return undefined;
|
||||
}
|
||||
// Validate parsed year range
|
||||
const currentYear = new Date().getFullYear();
|
||||
if (parsed < 1800 || parsed > currentYear + 10) {
|
||||
logger.warn(`[TraktAutosync] Year out of valid range: ${parsed}`);
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
|
||||
// Validate required fields early
|
||||
if (!options.title || options.title.trim() === '') {
|
||||
logger.error('[TraktAutosync] Cannot build content data: missing or empty title');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!options.imdbId || options.imdbId.trim() === '') {
|
||||
logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId');
|
||||
return null;
|
||||
}
|
||||
|
||||
const numericYear = parseYear(options.year);
|
||||
const numericShowYear = parseYear(options.showYear);
|
||||
|
||||
// Validate required fields
|
||||
if (!options.title || !options.imdbId) {
|
||||
logger.warn('[TraktAutosync] Missing required fields:', { title: options.title, imdbId: options.imdbId });
|
||||
|
||||
// Log warning if year is missing (but don't fail - Trakt can sometimes work with IMDb ID alone)
|
||||
if (numericYear === undefined) {
|
||||
logger.warn('[TraktAutosync] Year is missing or invalid, proceeding without year');
|
||||
}
|
||||
|
||||
|
||||
if (options.type === 'movie') {
|
||||
return {
|
||||
type: 'movie',
|
||||
imdbId: options.imdbId,
|
||||
title: options.title,
|
||||
year: numericYear
|
||||
imdbId: options.imdbId.trim(),
|
||||
title: options.title.trim(),
|
||||
year: numericYear // Can be undefined now
|
||||
};
|
||||
} else {
|
||||
// For episodes, also validate season and episode numbers
|
||||
if (options.season === undefined || options.season === null || options.season < 0) {
|
||||
logger.error('[TraktAutosync] Cannot build episode content data: invalid season');
|
||||
return null;
|
||||
}
|
||||
if (options.episode === undefined || options.episode === null || options.episode < 0) {
|
||||
logger.error('[TraktAutosync] Cannot build episode content data: invalid episode');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'episode',
|
||||
imdbId: options.imdbId,
|
||||
title: options.title,
|
||||
imdbId: options.imdbId.trim(),
|
||||
title: options.title.trim(),
|
||||
year: numericYear,
|
||||
season: options.season,
|
||||
episode: options.episode,
|
||||
showTitle: options.showTitle || options.title,
|
||||
showTitle: (options.showTitle || options.title).trim(),
|
||||
showYear: numericShowYear || numericYear,
|
||||
showImdbId: options.showImdbId || options.imdbId
|
||||
showImdbId: (options.showImdbId || options.imdbId).trim()
|
||||
};
|
||||
}
|
||||
}, [options]);
|
||||
|
|
@ -143,7 +183,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
const rawProgress = (currentTime / duration) * 100;
|
||||
const progressPercent = Math.min(100, Math.max(0, rawProgress));
|
||||
const contentData = buildContentData();
|
||||
|
||||
|
||||
// Skip if content data is invalid
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping start: invalid content data');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await startWatching(contentData, progressPercent);
|
||||
if (success) {
|
||||
hasStartedWatching.current = true;
|
||||
|
|
@ -184,6 +230,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
if (force) {
|
||||
// IMMEDIATE: User action (pause/unpause) - bypass queue
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping progress update: invalid content data');
|
||||
return;
|
||||
}
|
||||
success = await updateProgressImmediate(contentData, progressPercent);
|
||||
|
||||
if (success) {
|
||||
|
|
@ -212,6 +262,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
}
|
||||
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping progress update: invalid content data');
|
||||
return;
|
||||
}
|
||||
success = await updateProgress(contentData, progressPercent, force);
|
||||
|
||||
if (success) {
|
||||
|
|
@ -335,9 +389,11 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
// If we have valid progress but no started session, force start one first
|
||||
if (!hasStartedWatching.current && progressPercent > 1) {
|
||||
const contentData = buildContentData();
|
||||
const success = await startWatching(contentData, progressPercent);
|
||||
if (success) {
|
||||
hasStartedWatching.current = true;
|
||||
if (contentData) {
|
||||
const success = await startWatching(contentData, progressPercent);
|
||||
if (success) {
|
||||
hasStartedWatching.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -356,6 +412,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
|
||||
const contentData = buildContentData();
|
||||
|
||||
// Skip if content data is invalid
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping stop: invalid content data');
|
||||
hasStopped.current = false; // Allow retry with valid data
|
||||
return;
|
||||
}
|
||||
|
||||
// IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends
|
||||
const success = useImmediate
|
||||
? await stopWatchingImmediate(contentData, progressPercent)
|
||||
|
|
@ -394,7 +457,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
{ forceNotify: true }
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
logger.log(`[TraktAutosync] ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import {
|
||||
traktService,
|
||||
TraktUser,
|
||||
TraktWatchedItem,
|
||||
import {
|
||||
traktService,
|
||||
TraktUser,
|
||||
TraktWatchedItem,
|
||||
TraktWatchlistItem,
|
||||
TraktCollectionItem,
|
||||
TraktRatingItem,
|
||||
TraktContentData,
|
||||
TraktPlaybackItem
|
||||
TraktContentData,
|
||||
TraktPlaybackItem
|
||||
} from '../services/traktService';
|
||||
import { storageService } from '../services/storageService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
|
@ -25,8 +25,7 @@ export function useTraktIntegration() {
|
|||
const [collectionShows, setCollectionShows] = useState<TraktCollectionItem[]>([]);
|
||||
const [continueWatching, setContinueWatching] = useState<TraktPlaybackItem[]>([]);
|
||||
const [ratedContent, setRatedContent] = useState<TraktRatingItem[]>([]);
|
||||
const [lastAuthCheck, setLastAuthCheck] = useState<number>(Date.now());
|
||||
|
||||
|
||||
// State for real-time status tracking
|
||||
const [watchlistItems, setWatchlistItems] = useState<Set<string>>(new Set());
|
||||
const [collectionItems, setCollectionItems] = useState<Set<string>>(new Set());
|
||||
|
|
@ -39,7 +38,7 @@ export function useTraktIntegration() {
|
|||
const authenticated = await traktService.isAuthenticated();
|
||||
logger.log(`[useTraktIntegration] Authentication check result: ${authenticated}`);
|
||||
setIsAuthenticated(authenticated);
|
||||
|
||||
|
||||
if (authenticated) {
|
||||
logger.log('[useTraktIntegration] User is authenticated, fetching profile...');
|
||||
const profile = await traktService.getUserProfile();
|
||||
|
|
@ -49,9 +48,8 @@ export function useTraktIntegration() {
|
|||
logger.log('[useTraktIntegration] User is not authenticated');
|
||||
setUserProfile(null);
|
||||
}
|
||||
|
||||
// Update the last auth check timestamp to trigger dependent components to update
|
||||
setLastAuthCheck(Date.now());
|
||||
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[useTraktIntegration] Error checking auth status:', error);
|
||||
} finally {
|
||||
|
|
@ -68,7 +66,7 @@ export function useTraktIntegration() {
|
|||
// Load watched items
|
||||
const loadWatchedItems = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [movies, shows] = await Promise.all([
|
||||
|
|
@ -87,7 +85,7 @@ export function useTraktIntegration() {
|
|||
// Load all collections (watchlist, collection, continue watching, ratings)
|
||||
const loadAllCollections = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [
|
||||
|
|
@ -105,44 +103,44 @@ export function useTraktIntegration() {
|
|||
traktService.getPlaybackProgressWithImages(),
|
||||
traktService.getRatingsWithImages()
|
||||
]);
|
||||
|
||||
|
||||
setWatchlistMovies(watchlistMovies);
|
||||
setWatchlistShows(watchlistShows);
|
||||
setCollectionMovies(collectionMovies);
|
||||
setCollectionShows(collectionShows);
|
||||
setContinueWatching(continueWatching);
|
||||
setRatedContent(ratings);
|
||||
|
||||
|
||||
// Populate watchlist and collection sets for quick lookups
|
||||
const newWatchlistItems = new Set<string>();
|
||||
const newCollectionItems = new Set<string>();
|
||||
|
||||
|
||||
// Add movies to sets
|
||||
watchlistMovies.forEach(item => {
|
||||
if (item.movie?.ids?.imdb) {
|
||||
newWatchlistItems.add(`movie:${item.movie.ids.imdb}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
collectionMovies.forEach(item => {
|
||||
if (item.movie?.ids?.imdb) {
|
||||
newCollectionItems.add(`movie:${item.movie.ids.imdb}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Add shows to sets
|
||||
watchlistShows.forEach(item => {
|
||||
if (item.show?.ids?.imdb) {
|
||||
newWatchlistItems.add(`show:${item.show.ids.imdb}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
collectionShows.forEach(item => {
|
||||
if (item.show?.ids?.imdb) {
|
||||
newCollectionItems.add(`show:${item.show.ids.imdb}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
setWatchlistItems(newWatchlistItems);
|
||||
setCollectionItems(newCollectionItems);
|
||||
} catch (error) {
|
||||
|
|
@ -155,7 +153,7 @@ export function useTraktIntegration() {
|
|||
// Check if a movie is watched
|
||||
const isMovieWatched = useCallback(async (imdbId: string): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
|
||||
try {
|
||||
return await traktService.isMovieWatched(imdbId);
|
||||
} catch (error) {
|
||||
|
|
@ -166,12 +164,12 @@ export function useTraktIntegration() {
|
|||
|
||||
// Check if an episode is watched
|
||||
const isEpisodeWatched = useCallback(async (
|
||||
imdbId: string,
|
||||
season: number,
|
||||
imdbId: string,
|
||||
season: number,
|
||||
episode: number
|
||||
): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
|
||||
try {
|
||||
return await traktService.isEpisodeWatched(imdbId, season, episode);
|
||||
} catch (error) {
|
||||
|
|
@ -182,11 +180,11 @@ export function useTraktIntegration() {
|
|||
|
||||
// Mark a movie as watched
|
||||
const markMovieAsWatched = useCallback(async (
|
||||
imdbId: string,
|
||||
imdbId: string,
|
||||
watchedAt: Date = new Date()
|
||||
): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
|
||||
try {
|
||||
const result = await traktService.addToWatchedMovies(imdbId, watchedAt);
|
||||
if (result) {
|
||||
|
|
@ -203,7 +201,7 @@ export function useTraktIntegration() {
|
|||
// Add content to Trakt watchlist
|
||||
const addToWatchlist = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
|
||||
try {
|
||||
const success = await traktService.addToWatchlist(imdbId, type);
|
||||
if (success) {
|
||||
|
|
@ -223,7 +221,7 @@ export function useTraktIntegration() {
|
|||
// Remove content from Trakt watchlist
|
||||
const removeFromWatchlist = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
|
||||
try {
|
||||
const success = await traktService.removeFromWatchlist(imdbId, type);
|
||||
if (success) {
|
||||
|
|
@ -246,7 +244,7 @@ export function useTraktIntegration() {
|
|||
// Add content to Trakt collection
|
||||
const addToCollection = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
|
||||
try {
|
||||
const success = await traktService.addToCollection(imdbId, type);
|
||||
if (success) {
|
||||
|
|
@ -265,7 +263,7 @@ export function useTraktIntegration() {
|
|||
// Remove content from Trakt collection
|
||||
const removeFromCollection = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
|
||||
try {
|
||||
const success = await traktService.removeFromCollection(imdbId, type);
|
||||
if (success) {
|
||||
|
|
@ -301,13 +299,13 @@ export function useTraktIntegration() {
|
|||
|
||||
// Mark an episode as watched
|
||||
const markEpisodeAsWatched = useCallback(async (
|
||||
imdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
imdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
watchedAt: Date = new Date()
|
||||
): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
|
||||
try {
|
||||
const result = await traktService.addToWatchedEpisodes(imdbId, season, episode, watchedAt);
|
||||
if (result) {
|
||||
|
|
@ -324,7 +322,7 @@ export function useTraktIntegration() {
|
|||
// Start watching content (scrobble start)
|
||||
const startWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
|
||||
try {
|
||||
return await traktService.scrobbleStart(contentData, progress);
|
||||
} catch (error) {
|
||||
|
|
@ -392,12 +390,12 @@ export function useTraktIntegration() {
|
|||
|
||||
// Sync progress to Trakt (legacy method)
|
||||
const syncProgress = useCallback(async (
|
||||
contentData: TraktContentData,
|
||||
progress: number,
|
||||
contentData: TraktContentData,
|
||||
progress: number,
|
||||
force: boolean = false
|
||||
): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
|
||||
try {
|
||||
return await traktService.syncProgressToTrakt(contentData, progress, force);
|
||||
} catch (error) {
|
||||
|
|
@ -409,12 +407,12 @@ export function useTraktIntegration() {
|
|||
// Get playback progress from Trakt
|
||||
const getTraktPlaybackProgress = useCallback(async (type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> => {
|
||||
// getTraktPlaybackProgress call logging removed
|
||||
|
||||
|
||||
if (!isAuthenticated) {
|
||||
logger.log('[useTraktIntegration] getTraktPlaybackProgress: Not authenticated');
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// traktService.getPlaybackProgress call logging removed
|
||||
const result = await traktService.getPlaybackProgress(type);
|
||||
|
|
@ -429,37 +427,65 @@ export function useTraktIntegration() {
|
|||
// Sync all local progress to Trakt
|
||||
const syncAllProgress = useCallback(async (): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
|
||||
try {
|
||||
const unsyncedProgress = await storageService.getUnsyncedProgress();
|
||||
logger.log(`[useTraktIntegration] Found ${unsyncedProgress.length} unsynced progress entries`);
|
||||
|
||||
|
||||
let syncedCount = 0;
|
||||
const batchSize = 5; // Process in smaller batches
|
||||
const delayBetweenBatches = 2000; // 2 seconds between batches
|
||||
|
||||
|
||||
// Process items in batches to avoid overwhelming the API
|
||||
for (let i = 0; i < unsyncedProgress.length; i += batchSize) {
|
||||
const batch = unsyncedProgress.slice(i, i + batchSize);
|
||||
|
||||
|
||||
// Process batch items with individual error handling
|
||||
const batchPromises = batch.map(async (item) => {
|
||||
try {
|
||||
const season = item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined;
|
||||
const episode = item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined;
|
||||
|
||||
// Build content data from stored progress
|
||||
const contentData: TraktContentData = {
|
||||
type: item.type as 'movie' | 'episode',
|
||||
imdbId: item.id,
|
||||
title: 'Unknown', // We don't store title in progress, this would need metadata lookup
|
||||
year: 0,
|
||||
season: item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined,
|
||||
episode: item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined
|
||||
season: season,
|
||||
episode: episode
|
||||
};
|
||||
|
||||
|
||||
const progressPercent = (item.progress.currentTime / item.progress.duration) * 100;
|
||||
|
||||
const success = await traktService.syncProgressToTrakt(contentData, progressPercent, true);
|
||||
const isCompleted = progressPercent >= traktService.completionThreshold;
|
||||
|
||||
let success = false;
|
||||
|
||||
if (isCompleted) {
|
||||
// Item is completed - add to history with original watched date
|
||||
const watchedAt = new Date(item.progress.lastUpdated);
|
||||
logger.log(`[useTraktIntegration] Syncing completed item to history with date ${watchedAt.toISOString()}: ${item.type}:${item.id}`);
|
||||
|
||||
if (item.type === 'movie') {
|
||||
success = await traktService.addToWatchedMovies(item.id, watchedAt);
|
||||
} else if (item.type === 'series' || item.type === 'episode') { // Handle both type strings for safety
|
||||
if (season !== undefined && episode !== undefined) {
|
||||
success = await traktService.addToWatchedEpisodes(item.id, season, episode, watchedAt);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Item is in progress - sync as paused (scrobble)
|
||||
success = await traktService.syncProgressToTrakt(contentData, progressPercent, true);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
await storageService.updateTraktSyncStatus(item.id, item.type, true, progressPercent, item.episodeId);
|
||||
await storageService.updateTraktSyncStatus(
|
||||
item.id,
|
||||
item.type,
|
||||
true,
|
||||
isCompleted ? 100 : progressPercent,
|
||||
item.episodeId
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -468,17 +494,17 @@ export function useTraktIntegration() {
|
|||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Wait for batch to complete
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
syncedCount += batchResults.filter(result => result).length;
|
||||
|
||||
|
||||
// Delay between batches to avoid rate limiting
|
||||
if (i + batchSize < unsyncedProgress.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
logger.log(`[useTraktIntegration] Synced ${syncedCount}/${unsyncedProgress.length} progress entries`);
|
||||
return syncedCount > 0;
|
||||
} catch (error) {
|
||||
|
|
@ -492,26 +518,26 @@ export function useTraktIntegration() {
|
|||
if (!isAuthenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Fetch both playback progress and recently watched movies
|
||||
const [traktProgress, watchedMovies] = await Promise.all([
|
||||
getTraktPlaybackProgress(),
|
||||
traktService.getWatchedMovies()
|
||||
]);
|
||||
|
||||
|
||||
// Progress retrieval logging removed
|
||||
|
||||
|
||||
// Batch process all updates to reduce storage notifications
|
||||
const updatePromises: Promise<void>[] = [];
|
||||
|
||||
|
||||
// Process playback progress (in-progress items)
|
||||
for (const item of traktProgress) {
|
||||
try {
|
||||
let id: string;
|
||||
let type: string;
|
||||
let episodeId: string | undefined;
|
||||
|
||||
|
||||
if (item.type === 'movie' && item.movie) {
|
||||
id = item.movie.ids.imdb;
|
||||
type = 'movie';
|
||||
|
|
@ -522,7 +548,7 @@ export function useTraktIntegration() {
|
|||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Try to calculate exact time if we have stored duration
|
||||
const exactTime = await (async () => {
|
||||
const storedDuration = await storageService.getContentDuration(id, type, episodeId);
|
||||
|
|
@ -531,13 +557,13 @@ export function useTraktIntegration() {
|
|||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
|
||||
updatePromises.push(
|
||||
storageService.mergeWithTraktProgress(
|
||||
id,
|
||||
type,
|
||||
item.progress,
|
||||
item.paused_at,
|
||||
id,
|
||||
type,
|
||||
item.progress,
|
||||
item.paused_at,
|
||||
episodeId,
|
||||
exactTime
|
||||
)
|
||||
|
|
@ -546,20 +572,20 @@ export function useTraktIntegration() {
|
|||
logger.error('[useTraktIntegration] Error preparing Trakt progress update:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Process watched movies (100% completed)
|
||||
for (const movie of watchedMovies) {
|
||||
try {
|
||||
if (movie.movie?.ids?.imdb) {
|
||||
const id = movie.movie.ids.imdb;
|
||||
const watchedAt = movie.last_watched_at;
|
||||
|
||||
|
||||
updatePromises.push(
|
||||
storageService.mergeWithTraktProgress(
|
||||
id,
|
||||
'movie',
|
||||
100, // 100% progress for watched items
|
||||
watchedAt
|
||||
id,
|
||||
'movie',
|
||||
100, // 100% progress for watched items
|
||||
watchedAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -567,10 +593,10 @@ export function useTraktIntegration() {
|
|||
logger.error('[useTraktIntegration] Error preparing watched movie update:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Execute all updates in parallel
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
|
||||
// Trakt merge logging removed
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -614,18 +640,15 @@ export function useTraktIntegration() {
|
|||
};
|
||||
|
||||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||
|
||||
|
||||
return () => {
|
||||
subscription?.remove();
|
||||
};
|
||||
}, [isAuthenticated, fetchAndMergeTraktProgress]);
|
||||
|
||||
// Trigger sync when auth status is manually refreshed (for login scenarios)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchAndMergeTraktProgress();
|
||||
}
|
||||
}, [lastAuthCheck, isAuthenticated, fetchAndMergeTraktProgress]);
|
||||
// Note: Auth check sync removed - fetchAndMergeTraktProgress is already called
|
||||
// by the isAuthenticated useEffect (lines 595-602) and app focus sync (lines 605-621)
|
||||
// Having another useEffect on lastAuthCheck caused infinite update depth errors
|
||||
|
||||
// Manual force sync function for testing/troubleshooting
|
||||
const forceSyncTraktProgress = useCallback(async (): Promise<boolean> => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useTraktContext } from '../contexts/TraktContext';
|
||||
import { logger } from '../utils/logger';
|
||||
|
|
@ -14,26 +14,31 @@ interface WatchProgressData {
|
|||
}
|
||||
|
||||
export const useWatchProgress = (
|
||||
id: string,
|
||||
type: 'movie' | 'series',
|
||||
id: string,
|
||||
type: 'movie' | 'series',
|
||||
episodeId?: string,
|
||||
episodes: any[] = []
|
||||
) => {
|
||||
const [watchProgress, setWatchProgress] = useState<WatchProgressData | null>(null);
|
||||
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
|
||||
|
||||
|
||||
// Use ref for episodes to avoid infinite loops - episodes array changes on every render
|
||||
const episodesRef = useRef(episodes);
|
||||
episodesRef.current = episodes;
|
||||
|
||||
// Function to get episode details from episodeId
|
||||
const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => {
|
||||
const currentEpisodes = episodesRef.current;
|
||||
// Try to parse from format "seriesId:season:episode"
|
||||
const parts = episodeId.split(':');
|
||||
if (parts.length === 3) {
|
||||
const [, seasonNum, episodeNum] = parts;
|
||||
// Find episode in our local episodes array
|
||||
const episode = episodes.find(
|
||||
ep => ep.season_number === parseInt(seasonNum) &&
|
||||
ep.episode_number === parseInt(episodeNum)
|
||||
const episode = currentEpisodes.find(
|
||||
ep => ep.season_number === parseInt(seasonNum) &&
|
||||
ep.episode_number === parseInt(episodeNum)
|
||||
);
|
||||
|
||||
|
||||
if (episode) {
|
||||
return {
|
||||
seasonNumber: seasonNum,
|
||||
|
|
@ -44,7 +49,7 @@ export const useWatchProgress = (
|
|||
}
|
||||
|
||||
// If not found by season/episode, try stremioId
|
||||
const episodeByStremioId = episodes.find(ep => ep.stremioId === episodeId);
|
||||
const episodeByStremioId = currentEpisodes.find(ep => ep.stremioId === episodeId);
|
||||
if (episodeByStremioId) {
|
||||
return {
|
||||
seasonNumber: episodeByStremioId.season_number.toString(),
|
||||
|
|
@ -54,15 +59,15 @@ export const useWatchProgress = (
|
|||
}
|
||||
|
||||
return null;
|
||||
}, [episodes]);
|
||||
|
||||
}, []); // Removed episodes dependency - using ref instead
|
||||
|
||||
// Enhanced load watch progress with Trakt integration
|
||||
const loadWatchProgress = useCallback(async () => {
|
||||
try {
|
||||
if (id && type) {
|
||||
if (type === 'series') {
|
||||
const allProgress = await storageService.getAllWatchProgress();
|
||||
|
||||
|
||||
// Function to get episode number from episodeId
|
||||
const getEpisodeNumber = (epId: string) => {
|
||||
const parts = epId.split(':');
|
||||
|
|
@ -93,8 +98,8 @@ export const useWatchProgress = (
|
|||
if (progress) {
|
||||
// Always show the current episode progress when viewing it specifically
|
||||
// This allows HeroSection to properly display watched state
|
||||
setWatchProgress({
|
||||
...progress,
|
||||
setWatchProgress({
|
||||
...progress,
|
||||
episodeId,
|
||||
traktSynced: progress.traktSynced,
|
||||
traktProgress: progress.traktProgress
|
||||
|
|
@ -105,17 +110,17 @@ export const useWatchProgress = (
|
|||
} else {
|
||||
// FIXED: Find the most recently watched episode instead of first unfinished
|
||||
// Sort by lastUpdated timestamp (most recent first)
|
||||
const sortedProgresses = seriesProgresses.sort((a, b) =>
|
||||
const sortedProgresses = seriesProgresses.sort((a, b) =>
|
||||
b.progress.lastUpdated - a.progress.lastUpdated
|
||||
);
|
||||
|
||||
|
||||
if (sortedProgresses.length > 0) {
|
||||
// Use the most recently watched episode
|
||||
const mostRecentProgress = sortedProgresses[0];
|
||||
const progress = mostRecentProgress.progress;
|
||||
|
||||
|
||||
// Removed excessive logging for most recent progress
|
||||
|
||||
|
||||
setWatchProgress({
|
||||
...progress,
|
||||
episodeId: mostRecentProgress.episodeId,
|
||||
|
|
@ -133,8 +138,8 @@ export const useWatchProgress = (
|
|||
if (progress && progress.currentTime > 0) {
|
||||
// Always show progress data, even if watched (≥95%)
|
||||
// The HeroSection will handle the "watched" state display
|
||||
setWatchProgress({
|
||||
...progress,
|
||||
setWatchProgress({
|
||||
...progress,
|
||||
episodeId,
|
||||
traktSynced: progress.traktSynced,
|
||||
traktProgress: progress.traktProgress
|
||||
|
|
@ -148,7 +153,7 @@ export const useWatchProgress = (
|
|||
logger.error('[useWatchProgress] Error loading watch progress:', error);
|
||||
setWatchProgress(null);
|
||||
}
|
||||
}, [id, type, episodeId, episodes]);
|
||||
}, [id, type, episodeId]); // Removed episodes dependency - using ref instead
|
||||
|
||||
// Enhanced function to get play button text with Trakt awareness
|
||||
const getPlayButtonText = useCallback(() => {
|
||||
|
|
@ -167,19 +172,33 @@ export const useWatchProgress = (
|
|||
return 'Resume';
|
||||
}, [watchProgress]);
|
||||
|
||||
// Subscribe to storage changes for real-time updates
|
||||
// Subscribe to storage changes for real-time updates (with debounce to prevent loops)
|
||||
useEffect(() => {
|
||||
let debounceTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const unsubscribe = storageService.subscribeToWatchProgressUpdates(() => {
|
||||
loadWatchProgress();
|
||||
// Debounce rapid updates to prevent infinite loops
|
||||
if (debounceTimeout) {
|
||||
clearTimeout(debounceTimeout);
|
||||
}
|
||||
debounceTimeout = setTimeout(() => {
|
||||
loadWatchProgress();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
|
||||
return () => {
|
||||
if (debounceTimeout) {
|
||||
clearTimeout(debounceTimeout);
|
||||
}
|
||||
unsubscribe();
|
||||
};
|
||||
}, [loadWatchProgress]);
|
||||
|
||||
// Initial load
|
||||
// Initial load - only once on mount
|
||||
useEffect(() => {
|
||||
loadWatchProgress();
|
||||
}, [loadWatchProgress]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, type, episodeId]); // Only re-run when core IDs change, not when loadWatchProgress ref changes
|
||||
|
||||
// Refresh when screen comes into focus
|
||||
useFocusEffect(
|
||||
|
|
@ -188,15 +207,16 @@ export const useWatchProgress = (
|
|||
}, [loadWatchProgress])
|
||||
);
|
||||
|
||||
// Re-load when Trakt authentication status changes
|
||||
// Re-load when Trakt authentication status changes (with guard)
|
||||
useEffect(() => {
|
||||
if (isTraktAuthenticated !== undefined) {
|
||||
// Small delay to ensure Trakt context is fully initialized
|
||||
setTimeout(() => {
|
||||
loadWatchProgress();
|
||||
}, 100);
|
||||
}
|
||||
}, [isTraktAuthenticated, loadWatchProgress]);
|
||||
// Skip on initial mount, only run when isTraktAuthenticated actually changes
|
||||
const timeoutId = setTimeout(() => {
|
||||
loadWatchProgress();
|
||||
}, 200); // Slightly longer delay to avoid race conditions
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isTraktAuthenticated]); // Intentionally exclude loadWatchProgress to prevent loops
|
||||
|
||||
return {
|
||||
watchProgress,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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[]);
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ import {
|
|||
RefreshControl,
|
||||
Dimensions,
|
||||
Platform,
|
||||
InteractionManager
|
||||
InteractionManager,
|
||||
ScrollView
|
||||
} from 'react-native';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { RouteProp } from '@react-navigation/native';
|
||||
import { StackNavigationProp } from '@react-navigation/stack';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { Meta, stremioService } from '../services/stremioService';
|
||||
import { Meta, stremioService, CatalogExtra } from '../services/stremioService';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { BlurView } from 'expo-blur';
|
||||
|
|
@ -65,11 +66,11 @@ const calculateCatalogLayout = (screenWidth: number) => {
|
|||
// Increase padding and spacing on larger screens for proper breathing room
|
||||
const HORIZONTAL_PADDING = screenWidth >= 1600 ? SPACING.xl * 4 : screenWidth >= 1200 ? SPACING.xl * 3 : screenWidth >= 1000 ? SPACING.xl * 2 : SPACING.lg * 2;
|
||||
const ITEM_SPACING = screenWidth >= 1600 ? SPACING.xl : screenWidth >= 1200 ? SPACING.lg : screenWidth >= 1000 ? SPACING.md : SPACING.sm;
|
||||
|
||||
|
||||
// Calculate how many columns can fit
|
||||
const availableWidth = screenWidth - HORIZONTAL_PADDING;
|
||||
const maxColumns = Math.floor(availableWidth / (MIN_ITEM_WIDTH + ITEM_SPACING));
|
||||
|
||||
|
||||
// More flexible column limits for different screen sizes
|
||||
let numColumns;
|
||||
if (screenWidth < 600) {
|
||||
|
|
@ -88,14 +89,14 @@ const calculateCatalogLayout = (screenWidth: number) => {
|
|||
// Ultra-wide: 6-10 columns
|
||||
numColumns = Math.min(Math.max(maxColumns, 6), 10);
|
||||
}
|
||||
|
||||
|
||||
// Calculate actual item width with proper spacing
|
||||
const totalSpacing = ITEM_SPACING * (numColumns - 1);
|
||||
const itemWidth = (availableWidth - totalSpacing) / numColumns;
|
||||
|
||||
|
||||
// Ensure item width doesn't exceed maximum
|
||||
const finalItemWidth = Math.floor(Math.min(itemWidth, MAX_ITEM_WIDTH));
|
||||
|
||||
|
||||
return {
|
||||
numColumns,
|
||||
itemWidth: finalItemWidth,
|
||||
|
|
@ -154,7 +155,7 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
},
|
||||
poster: {
|
||||
width: '100%',
|
||||
aspectRatio: 2/3,
|
||||
aspectRatio: 2 / 3,
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
backgroundColor: colors.elevation3,
|
||||
|
|
@ -230,7 +231,38 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
}
|
||||
},
|
||||
// Filter chip bar styles
|
||||
filterContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
filterScrollContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
filterChip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.elevation3,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.elevation3,
|
||||
},
|
||||
filterChipActive: {
|
||||
backgroundColor: colors.primary + '30',
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
filterChipText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.mediumGray,
|
||||
},
|
||||
filterChipTextActive: {
|
||||
color: colors.primary,
|
||||
},
|
||||
});
|
||||
|
||||
const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||
|
|
@ -253,6 +285,11 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
});
|
||||
const [mobileColumnsPref, setMobileColumnsPref] = useState<'auto' | 2 | 3>('auto');
|
||||
const [nowPlayingMovies, setNowPlayingMovies] = useState<Set<string>>(new Set());
|
||||
// Filter state for catalog extra properties per protocol
|
||||
const [catalogExtras, setCatalogExtras] = useState<CatalogExtra[]>([]);
|
||||
const [selectedFilters, setSelectedFilters] = useState<Record<string, string>>({});
|
||||
const [activeGenreFilter, setActiveGenreFilter] = useState<string | undefined>(genreFilter);
|
||||
const [showTitles, setShowTitles] = useState(true); // Default to showing titles
|
||||
const { currentTheme } = useTheme();
|
||||
const colors = currentTheme.colors;
|
||||
const styles = createStyles(colors);
|
||||
|
|
@ -266,7 +303,11 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
if (pref === '2') setMobileColumnsPref(2);
|
||||
else if (pref === '3') setMobileColumnsPref(3);
|
||||
else setMobileColumnsPref('auto');
|
||||
} catch {}
|
||||
|
||||
// Load show titles preference (default: true)
|
||||
const titlesPref = await mmkvStorage.getItem('catalog_show_titles');
|
||||
setShowTitles(titlesPref !== 'false'); // Default to true if not set
|
||||
} catch { }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
|
|
@ -284,52 +325,63 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
}, []);
|
||||
|
||||
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
|
||||
|
||||
|
||||
// Create display name with proper type suffix
|
||||
const createDisplayName = (catalogName: string) => {
|
||||
if (!catalogName) return '';
|
||||
|
||||
|
||||
// Check if the name already includes content type indicators
|
||||
const lowerName = catalogName.toLowerCase();
|
||||
const contentType = type === 'movie' ? 'Movies' : type === 'series' ? 'TV Shows' : `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
|
||||
|
||||
|
||||
// If the name already contains type information, return as is
|
||||
if (lowerName.includes('movie') || lowerName.includes('tv') || lowerName.includes('show') || lowerName.includes('series')) {
|
||||
return catalogName;
|
||||
}
|
||||
|
||||
|
||||
// Otherwise append the content type
|
||||
return `${catalogName} ${contentType}`;
|
||||
};
|
||||
|
||||
// Use actual catalog name if available, otherwise fallback to custom name or original name
|
||||
const displayName = actualCatalogName
|
||||
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
|
||||
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
|
||||
(genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` :
|
||||
`${type.charAt(0).toUpperCase() + type.slice(1)}s`);
|
||||
|
||||
// Add effect to get the actual catalog name from addon manifest
|
||||
// Use actual catalog name if available, otherwise fallback to custom name or original name
|
||||
const displayName = actualCatalogName
|
||||
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
|
||||
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
|
||||
(genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` :
|
||||
`${type.charAt(0).toUpperCase() + type.slice(1)}s`);
|
||||
|
||||
// Add effect to get the actual catalog name and filter extras from addon manifest
|
||||
useEffect(() => {
|
||||
const getActualCatalogName = async () => {
|
||||
const getCatalogDetails = async () => {
|
||||
if (addonId && type && id) {
|
||||
try {
|
||||
const manifests = await stremioService.getInstalledAddonsAsync();
|
||||
const addon = manifests.find(a => a.id === addonId);
|
||||
|
||||
|
||||
if (addon && addon.catalogs) {
|
||||
const catalog = addon.catalogs.find(c => c.type === type && c.id === id);
|
||||
if (catalog && catalog.name) {
|
||||
setActualCatalogName(catalog.name);
|
||||
if (catalog) {
|
||||
if (catalog.name) {
|
||||
setActualCatalogName(catalog.name);
|
||||
}
|
||||
// Extract filter extras per protocol (genre, etc.)
|
||||
if (catalog.extra && Array.isArray(catalog.extra)) {
|
||||
// Only show filterable extras with options (not search/skip)
|
||||
const filterableExtras = catalog.extra.filter(
|
||||
extra => extra.options && extra.options.length > 0 && extra.name !== 'skip'
|
||||
);
|
||||
setCatalogExtras(filterableExtras);
|
||||
logger.log('[CatalogScreen] Loaded catalog extras:', filterableExtras.map(e => e.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get actual catalog name:', error);
|
||||
logger.error('Failed to get catalog details:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getActualCatalogName();
|
||||
|
||||
getCatalogDetails();
|
||||
}, [addonId, type, id]);
|
||||
|
||||
// Add effect to get data source preference when component mounts
|
||||
|
|
@ -372,7 +424,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
type,
|
||||
id,
|
||||
dataSource,
|
||||
genreFilter
|
||||
activeGenreFilter
|
||||
});
|
||||
try {
|
||||
if (shouldRefresh) {
|
||||
|
|
@ -383,9 +435,9 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
|
||||
// Process the genre filter - ignore "All" and clean up the value
|
||||
let effectiveGenreFilter = genreFilter;
|
||||
let effectiveGenreFilter = activeGenreFilter;
|
||||
if (effectiveGenreFilter === 'All') {
|
||||
effectiveGenreFilter = undefined;
|
||||
logger.log('Genre "All" detected, removing genre filter');
|
||||
|
|
@ -394,7 +446,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
effectiveGenreFilter = effectiveGenreFilter.trim();
|
||||
logger.log(`Using cleaned genre filter: "${effectiveGenreFilter}"`);
|
||||
}
|
||||
|
||||
|
||||
// Check if using TMDB as data source and not requesting a specific addon
|
||||
if (dataSource === DataSource.TMDB && !addonId) {
|
||||
logger.log('Using TMDB data source for CatalogScreen');
|
||||
|
|
@ -406,7 +458,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
catalogs.forEach(catalog => {
|
||||
allItems.push(...catalog.items);
|
||||
});
|
||||
|
||||
|
||||
// Convert StreamingContent to Meta format
|
||||
const metaItems: Meta[] = allItems.map(item => ({
|
||||
id: item.id,
|
||||
|
|
@ -423,12 +475,12 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
runtime: item.runtime,
|
||||
certification: item.certification,
|
||||
}));
|
||||
|
||||
|
||||
// Remove duplicates
|
||||
const uniqueItems = metaItems.filter((item, index, self) =>
|
||||
index === self.findIndex((t) => t.id === item.id)
|
||||
);
|
||||
|
||||
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
setItems(uniqueItems);
|
||||
setHasMore(false); // TMDB already returns a full set
|
||||
|
|
@ -465,22 +517,22 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Use this flag to track if we found and processed any items
|
||||
let foundItems = false;
|
||||
let allItems: Meta[] = [];
|
||||
|
||||
|
||||
// Get all installed addon manifests directly
|
||||
const manifests = await stremioService.getInstalledAddonsAsync();
|
||||
|
||||
|
||||
if (addonId) {
|
||||
// If addon ID is provided, find the specific addon
|
||||
const addon = manifests.find(a => a.id === addonId);
|
||||
|
||||
|
||||
if (!addon) {
|
||||
throw new Error(`Addon ${addonId} not found`);
|
||||
}
|
||||
|
||||
|
||||
// Create filters array for genre filtering if provided
|
||||
const filters = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : [];
|
||||
|
||||
|
|
@ -509,11 +561,14 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
let nextHasMore = false;
|
||||
try {
|
||||
const svcHasMore = addonId ? stremioService.getCatalogHasMore(addonId, type, id) : undefined;
|
||||
// If service explicitly provides hasMore, use it; otherwise assume there's more if we got any items
|
||||
// This handles addons with different page sizes (not just 50 items per page)
|
||||
nextHasMore = typeof svcHasMore === 'boolean' ? svcHasMore : (catalogItems.length > 0);
|
||||
// If service explicitly provides hasMore, use it
|
||||
// Otherwise, only assume there's more if we got a reasonable number of items (>= 5)
|
||||
// This prevents infinite loops when addons return just 1-2 items per page
|
||||
const MIN_ITEMS_FOR_MORE = 5;
|
||||
nextHasMore = typeof svcHasMore === 'boolean' ? svcHasMore : (catalogItems.length >= MIN_ITEMS_FOR_MORE);
|
||||
} catch {
|
||||
nextHasMore = catalogItems.length > 0;
|
||||
// Fallback: only assume more if we got at least 5 items
|
||||
nextHasMore = catalogItems.length >= 5;
|
||||
}
|
||||
setHasMore(nextHasMore);
|
||||
logger.log('[CatalogScreen] Updated items and hasMore', {
|
||||
|
|
@ -525,60 +580,60 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
}
|
||||
} else if (effectiveGenreFilter) {
|
||||
// Get all addons that have catalogs of the specified type
|
||||
const typeManifests = manifests.filter(manifest =>
|
||||
const typeManifests = manifests.filter(manifest =>
|
||||
manifest.catalogs && manifest.catalogs.some(catalog => catalog.type === type)
|
||||
);
|
||||
|
||||
|
||||
// Add debug logging for genre filter
|
||||
logger.log(`Using genre filter: "${effectiveGenreFilter}" for type: ${type}`);
|
||||
|
||||
|
||||
// For each addon, try to get content with the genre filter
|
||||
for (const manifest of typeManifests) {
|
||||
try {
|
||||
// Find catalogs of this type
|
||||
const typeCatalogs = manifest.catalogs?.filter(catalog => catalog.type === type) || [];
|
||||
|
||||
|
||||
// For each catalog, try to get content
|
||||
for (const catalog of typeCatalogs) {
|
||||
try {
|
||||
const filters = [{ title: 'genre', value: effectiveGenreFilter }];
|
||||
|
||||
|
||||
// Debug logging for each catalog request
|
||||
logger.log(`Requesting from ${manifest.name}, catalog ${catalog.id} with genre "${effectiveGenreFilter}"`);
|
||||
|
||||
|
||||
const catalogItems = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
|
||||
|
||||
|
||||
if (catalogItems && catalogItems.length > 0) {
|
||||
// Log first few items' genres to debug
|
||||
const sampleItems = catalogItems.slice(0, 3);
|
||||
sampleItems.forEach(item => {
|
||||
logger.log(`Item "${item.name}" has genres: ${JSON.stringify(item.genres)}`);
|
||||
});
|
||||
|
||||
|
||||
// Filter items client-side to ensure they contain the requested genre
|
||||
// Some addons might not properly filter by genre on the server
|
||||
let filteredItems = catalogItems;
|
||||
if (effectiveGenreFilter) {
|
||||
const normalizedGenreFilter = effectiveGenreFilter.toLowerCase().trim();
|
||||
|
||||
|
||||
filteredItems = catalogItems.filter(item => {
|
||||
// Skip items without genres
|
||||
if (!item.genres || !Array.isArray(item.genres)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check for genre match (exact or substring)
|
||||
return item.genres.some(genre => {
|
||||
const normalizedGenre = genre.toLowerCase().trim();
|
||||
return normalizedGenre === normalizedGenreFilter ||
|
||||
normalizedGenre.includes(normalizedGenreFilter) ||
|
||||
normalizedGenreFilter.includes(normalizedGenre);
|
||||
return normalizedGenre === normalizedGenreFilter ||
|
||||
normalizedGenre.includes(normalizedGenreFilter) ||
|
||||
normalizedGenreFilter.includes(normalizedGenre);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
logger.log(`Filtered ${catalogItems.length} items to ${filteredItems.length} matching genre "${effectiveGenreFilter}"`);
|
||||
}
|
||||
|
||||
|
||||
allItems = [...allItems, ...filteredItems];
|
||||
foundItems = filteredItems.length > 0;
|
||||
}
|
||||
|
|
@ -592,7 +647,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
// Continue with other addons
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Remove duplicates by ID
|
||||
const uniqueItems = allItems.filter((item, index, self) =>
|
||||
index === self.findIndex((t) => t.id === item.id)
|
||||
|
|
@ -607,7 +662,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!foundItems) {
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
setError("No content found for the selected filters");
|
||||
|
|
@ -630,7 +685,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
});
|
||||
});
|
||||
}
|
||||
}, [addonId, type, id, genreFilter, dataSource]);
|
||||
}, [addonId, type, id, activeGenreFilter, dataSource]);
|
||||
|
||||
useEffect(() => {
|
||||
loadItems(true, 1);
|
||||
|
|
@ -641,6 +696,28 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
loadItems(true);
|
||||
}, [loadItems]);
|
||||
|
||||
// Handle filter chip selection
|
||||
const handleFilterChange = useCallback((filterName: string, value: string | undefined) => {
|
||||
logger.log('[CatalogScreen] Filter changed:', filterName, value);
|
||||
|
||||
if (filterName === 'genre') {
|
||||
setActiveGenreFilter(value);
|
||||
} else {
|
||||
setSelectedFilters(prev => {
|
||||
if (value === undefined) {
|
||||
const { [filterName]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return { ...prev, [filterName]: value };
|
||||
});
|
||||
}
|
||||
|
||||
// Reset pagination - don't clear items to avoid flash of empty state
|
||||
// loadItems will replace items when new data arrives
|
||||
setPage(1);
|
||||
setLoading(true);
|
||||
}, []);
|
||||
|
||||
|
||||
const effectiveNumColumns = React.useMemo(() => {
|
||||
const isPhone = screenData.width < 600; // basic breakpoint; tablets generally above this
|
||||
|
|
@ -665,12 +742,12 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
if (!poster || poster.includes('placeholder')) {
|
||||
return 'https://via.placeholder.com/300x450/333333/666666?text=No+Image';
|
||||
}
|
||||
|
||||
|
||||
// For TMDB images, use smaller sizes for better performance
|
||||
if (poster.includes('image.tmdb.org')) {
|
||||
return poster.replace(/\/w\d+\//, '/w300/');
|
||||
}
|
||||
|
||||
|
||||
return poster;
|
||||
}, []);
|
||||
|
||||
|
|
@ -679,12 +756,16 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
const isLastInRow = (index + 1) % effectiveNumColumns === 0;
|
||||
// For proper spacing
|
||||
const rightMargin = isLastInRow ? 0 : ((screenData as any).itemSpacing ?? SPACING.sm);
|
||||
|
||||
|
||||
// Calculate aspect ratio based on posterShape
|
||||
const shape = item.posterShape || 'poster';
|
||||
const aspectRatio = shape === 'landscape' ? 16 / 9 : (shape === 'square' ? 1 : 2 / 3);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.item,
|
||||
{
|
||||
{
|
||||
marginRight: rightMargin,
|
||||
width: effectiveItemWidth
|
||||
}
|
||||
|
|
@ -694,7 +775,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
>
|
||||
<FastImage
|
||||
source={{ uri: optimizePosterUrl(item.poster) }}
|
||||
style={styles.poster}
|
||||
style={[styles.poster, { aspectRatio }]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
|
||||
|
|
@ -739,9 +820,26 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
</View>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Poster Title */}
|
||||
{showTitles && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: colors.mediumGray,
|
||||
marginTop: 6,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 4,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}, [navigation, styles, effectiveNumColumns, effectiveItemWidth, type, nowPlayingMovies, colors.white, optimizePosterUrl]);
|
||||
}, [navigation, styles, effectiveNumColumns, effectiveItemWidth, screenData, type, nowPlayingMovies, colors.white, colors.mediumGray, optimizePosterUrl, addonId, isDarkMode, showTitles]);
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.centered}>
|
||||
|
|
@ -787,7 +885,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
|
|
@ -806,7 +904,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
|
|
@ -824,7 +922,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
|
|
@ -833,7 +931,54 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
|
||||
|
||||
|
||||
{/* Filter chip bar - shows when catalog has filterable extras */}
|
||||
{catalogExtras.length > 0 && (
|
||||
<View style={styles.filterContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filterScrollContent}
|
||||
>
|
||||
{catalogExtras.map(extra => (
|
||||
<React.Fragment key={extra.name}>
|
||||
{/* All option - clears filter */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.filterChip,
|
||||
(extra.name === 'genre' ? !activeGenreFilter : !selectedFilters[extra.name]) && styles.filterChipActive
|
||||
]}
|
||||
onPress={() => handleFilterChange(extra.name, undefined)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.filterChipText,
|
||||
(extra.name === 'genre' ? !activeGenreFilter : !selectedFilters[extra.name]) && styles.filterChipTextActive
|
||||
]}>All</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Filter options from catalog extra */}
|
||||
{extra.options?.map(option => {
|
||||
const isActive = extra.name === 'genre'
|
||||
? activeGenreFilter === option
|
||||
: selectedFilters[extra.name] === option;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option}
|
||||
style={[styles.filterChip, isActive && styles.filterChipActive]}
|
||||
onPress={() => handleFilterChange(extra.name, option)}
|
||||
>
|
||||
<Text style={[styles.filterChipText, isActive && styles.filterChipTextActive]}>
|
||||
{option}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<FlashList
|
||||
data={items}
|
||||
|
|
|
|||
|
|
@ -177,17 +177,17 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
optionChipTextSelected: {
|
||||
color: colors.white,
|
||||
},
|
||||
hintRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
hintText: {
|
||||
fontSize: 12,
|
||||
color: colors.mediumGray,
|
||||
},
|
||||
hintRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
hintText: {
|
||||
fontSize: 12,
|
||||
color: colors.mediumGray,
|
||||
},
|
||||
enabledCount: {
|
||||
fontSize: 15,
|
||||
color: colors.mediumGray,
|
||||
|
|
@ -268,6 +268,7 @@ const CatalogSettingsScreen = () => {
|
|||
const [settings, setSettings] = useState<CatalogSetting[]>([]);
|
||||
const [groupedSettings, setGroupedSettings] = useState<GroupedCatalogs>({});
|
||||
const [mobileColumns, setMobileColumns] = useState<'auto' | 2 | 3>('auto');
|
||||
const [showTitles, setShowTitles] = useState(true); // Default to showing titles
|
||||
const navigation = useNavigation();
|
||||
const { refreshCatalogs } = useCatalogContext();
|
||||
const { currentTheme } = useTheme();
|
||||
|
|
@ -288,11 +289,11 @@ const CatalogSettingsScreen = () => {
|
|||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
// Get installed addons and their catalogs
|
||||
const addons = await stremioService.getInstalledAddonsAsync();
|
||||
const availableCatalogs: CatalogSetting[] = [];
|
||||
|
||||
|
||||
// Get saved enable/disable settings
|
||||
const savedSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
|
||||
const savedEnabledSettings: { [key: string]: boolean } = savedSettingsJson ? JSON.parse(savedSettingsJson) : {};
|
||||
|
|
@ -300,12 +301,12 @@ const CatalogSettingsScreen = () => {
|
|||
// Get saved custom names
|
||||
const savedCustomNamesJson = await mmkvStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
|
||||
const savedCustomNames: { [key: string]: string } = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
|
||||
|
||||
|
||||
// Process each addon's catalogs
|
||||
addons.forEach(addon => {
|
||||
if (addon.catalogs && addon.catalogs.length > 0) {
|
||||
const uniqueCatalogs = new Map<string, CatalogSetting>();
|
||||
|
||||
|
||||
addon.catalogs.forEach(catalog => {
|
||||
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
|
||||
let displayName = catalog.name || catalog.id;
|
||||
|
|
@ -330,7 +331,7 @@ const CatalogSettingsScreen = () => {
|
|||
if (!displayName.toLowerCase().includes(catalogType.toLowerCase())) {
|
||||
displayName = `${displayName} ${catalogType}`.trim();
|
||||
}
|
||||
|
||||
|
||||
uniqueCatalogs.set(settingKey, {
|
||||
addonId: addon.id,
|
||||
catalogId: catalog.id,
|
||||
|
|
@ -340,32 +341,32 @@ const CatalogSettingsScreen = () => {
|
|||
customName: savedCustomNames[settingKey]
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
availableCatalogs.push(...uniqueCatalogs.values());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Group settings by addon name
|
||||
const grouped: GroupedCatalogs = {};
|
||||
availableCatalogs.forEach(setting => {
|
||||
const addon = addons.find(a => a.id === setting.addonId);
|
||||
if (!addon) return;
|
||||
|
||||
|
||||
if (!grouped[setting.addonId]) {
|
||||
grouped[setting.addonId] = {
|
||||
name: addon.name,
|
||||
catalogs: [],
|
||||
expanded: true,
|
||||
expanded: true,
|
||||
enabledCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
grouped[setting.addonId].catalogs.push(setting);
|
||||
if (setting.enabled) {
|
||||
grouped[setting.addonId].enabledCount++;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
setSettings(availableCatalogs);
|
||||
setGroupedSettings(grouped);
|
||||
|
||||
|
|
@ -375,6 +376,10 @@ const CatalogSettingsScreen = () => {
|
|||
if (pref === '2') setMobileColumns(2);
|
||||
else if (pref === '3') setMobileColumns(3);
|
||||
else setMobileColumns('auto');
|
||||
|
||||
// Load show titles preference (default: true)
|
||||
const titlesPref = await mmkvStorage.getItem('catalog_show_titles');
|
||||
setShowTitles(titlesPref !== 'false'); // Default to true if not set
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
|
@ -396,7 +401,7 @@ const CatalogSettingsScreen = () => {
|
|||
settingsObj[key] = setting.enabled;
|
||||
});
|
||||
await mmkvStorage.setItem(CATALOG_SETTINGS_KEY, JSON.stringify(settingsObj));
|
||||
|
||||
|
||||
// Small delay to ensure AsyncStorage has fully persisted before triggering refresh
|
||||
setTimeout(() => {
|
||||
refreshCatalogs(); // Trigger catalog refresh after saving settings
|
||||
|
|
@ -411,26 +416,26 @@ const CatalogSettingsScreen = () => {
|
|||
const newSettings = [...settings];
|
||||
const catalogsForAddon = groupedSettings[addonId].catalogs;
|
||||
const setting = catalogsForAddon[index];
|
||||
|
||||
|
||||
const updatedSetting = {
|
||||
...setting,
|
||||
enabled: !setting.enabled
|
||||
};
|
||||
|
||||
const flatIndex = newSettings.findIndex(s =>
|
||||
s.addonId === setting.addonId &&
|
||||
s.type === setting.type &&
|
||||
|
||||
const flatIndex = newSettings.findIndex(s =>
|
||||
s.addonId === setting.addonId &&
|
||||
s.type === setting.type &&
|
||||
s.catalogId === setting.catalogId
|
||||
);
|
||||
|
||||
|
||||
if (flatIndex !== -1) {
|
||||
newSettings[flatIndex] = updatedSetting;
|
||||
}
|
||||
|
||||
|
||||
const newGroupedSettings = { ...groupedSettings };
|
||||
newGroupedSettings[addonId].catalogs[index] = updatedSetting;
|
||||
newGroupedSettings[addonId].enabledCount += updatedSetting.enabled ? 1 : -1;
|
||||
|
||||
|
||||
setSettings(newSettings);
|
||||
setGroupedSettings(newGroupedSettings);
|
||||
saveEnabledSettings(newSettings); // Use specific save function
|
||||
|
|
@ -459,11 +464,11 @@ const CatalogSettingsScreen = () => {
|
|||
if (!catalogToRename || !currentRenameValue) return;
|
||||
|
||||
const settingKey = `${catalogToRename.addonId}:${catalogToRename.type}:${catalogToRename.catalogId}`;
|
||||
|
||||
|
||||
try {
|
||||
const savedCustomNamesJson = await mmkvStorage.getItem(CATALOG_CUSTOM_NAMES_KEY);
|
||||
const customNames: { [key: string]: string } = savedCustomNamesJson ? JSON.parse(savedCustomNamesJson) : {};
|
||||
|
||||
|
||||
const trimmedNewName = currentRenameValue.trim();
|
||||
|
||||
if (trimmedNewName === catalogToRename.name || trimmedNewName === '') {
|
||||
|
|
@ -471,22 +476,22 @@ const CatalogSettingsScreen = () => {
|
|||
} else {
|
||||
customNames[settingKey] = trimmedNewName;
|
||||
}
|
||||
|
||||
|
||||
await mmkvStorage.setItem(CATALOG_CUSTOM_NAMES_KEY, JSON.stringify(customNames));
|
||||
// Clear in-memory cache so new name is used immediately
|
||||
try { clearCustomNameCache(); } catch {}
|
||||
try { clearCustomNameCache(); } catch { }
|
||||
|
||||
// --- Reload settings to reflect the change ---
|
||||
await loadSettings();
|
||||
await loadSettings();
|
||||
// Also trigger home/catalog consumers to refresh
|
||||
try { refreshCatalogs(); } catch {}
|
||||
try { refreshCatalogs(); } catch { }
|
||||
// --- No need to manually update local state anymore ---
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to save custom catalog name:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Could not save the custom name.');
|
||||
setAlertActions([{ label: 'OK', onPress: () => {} }]);
|
||||
setAlertActions([{ label: 'OK', onPress: () => { } }]);
|
||||
setAlertVisible(true);
|
||||
} finally {
|
||||
setIsRenameModalVisible(false);
|
||||
|
|
@ -533,7 +538,7 @@ const CatalogSettingsScreen = () => {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.headerTitle}>Catalogs</Text>
|
||||
|
||||
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
|
||||
{/* Layout (Mobile only) */}
|
||||
{Platform.OS && (
|
||||
|
|
@ -552,7 +557,7 @@ const CatalogSettingsScreen = () => {
|
|||
try {
|
||||
await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, 'auto');
|
||||
setMobileColumns('auto');
|
||||
} catch {}
|
||||
} catch { }
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
|
|
@ -564,7 +569,7 @@ const CatalogSettingsScreen = () => {
|
|||
try {
|
||||
await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, '2');
|
||||
setMobileColumns(2);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
|
|
@ -576,7 +581,7 @@ const CatalogSettingsScreen = () => {
|
|||
try {
|
||||
await mmkvStorage.setItem(CATALOG_MOBILE_COLUMNS_KEY, '3');
|
||||
setMobileColumns(3);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
|
|
@ -587,6 +592,26 @@ const CatalogSettingsScreen = () => {
|
|||
<MaterialIcons name="info-outline" size={14} color={colors.mediumGray} />
|
||||
<Text style={styles.hintText}>Applies to phones only. Tablets keep adaptive layout.</Text>
|
||||
</View>
|
||||
|
||||
{/* Show Titles Toggle */}
|
||||
<View style={[styles.catalogItem, { borderBottomWidth: 0 }]}>
|
||||
<View style={styles.catalogInfo}>
|
||||
<Text style={styles.catalogName}>Show Poster Titles</Text>
|
||||
<Text style={styles.catalogType}>Display title text below each poster</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={showTitles}
|
||||
onValueChange={async (value) => {
|
||||
try {
|
||||
await mmkvStorage.setItem('catalog_show_titles', value ? 'true' : 'false');
|
||||
setShowTitles(value);
|
||||
} catch { }
|
||||
}}
|
||||
trackColor={{ false: '#505050', true: colors.primary }}
|
||||
thumbColor={Platform.OS === 'android' ? colors.white : undefined}
|
||||
ios_backgroundColor="#505050"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -596,9 +621,9 @@ const CatalogSettingsScreen = () => {
|
|||
<Text style={styles.addonTitle}>
|
||||
{group.name.toUpperCase()}
|
||||
</Text>
|
||||
|
||||
|
||||
<View style={styles.card}>
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.groupHeader}
|
||||
onPress={() => toggleExpansion(addonId)}
|
||||
activeOpacity={0.7}
|
||||
|
|
@ -608,14 +633,14 @@ const CatalogSettingsScreen = () => {
|
|||
<Text style={styles.enabledCount}>
|
||||
{group.enabledCount} of {group.catalogs.length} enabled
|
||||
</Text>
|
||||
<MaterialIcons
|
||||
name={group.expanded ? "keyboard-arrow-down" : "keyboard-arrow-right"}
|
||||
size={24}
|
||||
color={colors.mediumGray}
|
||||
<MaterialIcons
|
||||
name={group.expanded ? "keyboard-arrow-down" : "keyboard-arrow-right"}
|
||||
size={24}
|
||||
color={colors.mediumGray}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
||||
{group.expanded && (
|
||||
<>
|
||||
<View style={styles.hintRow}>
|
||||
|
|
@ -623,30 +648,30 @@ const CatalogSettingsScreen = () => {
|
|||
<Text style={styles.hintText}>Long-press a catalog to rename</Text>
|
||||
</View>
|
||||
{group.catalogs.map((setting, index) => (
|
||||
<Pressable
|
||||
key={`${setting.addonId}:${setting.type}:${setting.catalogId}`}
|
||||
onLongPress={() => handleLongPress(setting)} // Added long press handler
|
||||
style={({ pressed }) => [
|
||||
styles.catalogItem,
|
||||
pressed && styles.catalogItemPressed, // Optional pressed style
|
||||
]}
|
||||
>
|
||||
<View style={styles.catalogInfo}>
|
||||
<Text style={styles.catalogName}>
|
||||
{setting.customName || setting.name} {/* Display custom or default name */}
|
||||
</Text>
|
||||
<Text style={styles.catalogType}>
|
||||
{setting.type.charAt(0).toUpperCase() + setting.type.slice(1)}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={setting.enabled}
|
||||
onValueChange={() => toggleCatalog(addonId, index)}
|
||||
trackColor={{ false: '#505050', true: colors.primary }}
|
||||
thumbColor={Platform.OS === 'android' ? colors.white : undefined}
|
||||
ios_backgroundColor="#505050"
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
key={`${setting.addonId}:${setting.type}:${setting.catalogId}`}
|
||||
onLongPress={() => handleLongPress(setting)} // Added long press handler
|
||||
style={({ pressed }) => [
|
||||
styles.catalogItem,
|
||||
pressed && styles.catalogItemPressed, // Optional pressed style
|
||||
]}
|
||||
>
|
||||
<View style={styles.catalogInfo}>
|
||||
<Text style={styles.catalogName}>
|
||||
{setting.customName || setting.name} {/* Display custom or default name */}
|
||||
</Text>
|
||||
<Text style={styles.catalogType}>
|
||||
{setting.type.charAt(0).toUpperCase() + setting.type.slice(1)}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={setting.enabled}
|
||||
onValueChange={() => toggleCatalog(addonId, index)}
|
||||
trackColor={{ false: '#505050', true: colors.primary }}
|
||||
thumbColor={Platform.OS === 'android' ? colors.white : undefined}
|
||||
ios_backgroundColor="#505050"
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
|
@ -706,8 +731,8 @@ const CatalogSettingsScreen = () => {
|
|||
)}
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable style={styles.modalOverlay} onPress={() => setIsRenameModalVisible(false)}>
|
||||
<Pressable style={styles.modalContent} onPress={(e) => e.stopPropagation()}>
|
||||
<Pressable style={styles.modalOverlay} onPress={() => setIsRenameModalVisible(false)}>
|
||||
<Pressable style={styles.modalContent} onPress={(e) => e.stopPropagation()}>
|
||||
<Text style={styles.modalTitle}>Rename Catalog</Text>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
|
|
|
|||
|
|
@ -772,7 +772,7 @@ const DebridIntegrationScreen = () => {
|
|||
<Text style={styles.poweredBy}>Powered by</Text>
|
||||
<View style={styles.logoRow}>
|
||||
<Image
|
||||
source={{ uri: 'https://torbox.app/assets/logo-57adbf99.svg' }}
|
||||
source={{ uri: 'https://torbox.app/assets/logo-bb7a9579.svg' }}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ const HomeScreen = () => {
|
|||
const [hasAddons, setHasAddons] = useState<boolean | null>(null);
|
||||
const [hintVisible, setHintVisible] = useState(false);
|
||||
const totalCatalogsRef = useRef(0);
|
||||
const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory
|
||||
const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Stabilize insets to prevent iOS layout shifts
|
||||
|
|
@ -147,7 +147,7 @@ const HomeScreen = () => {
|
|||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [insets.top]);
|
||||
|
||||
|
||||
const {
|
||||
featuredContent,
|
||||
allFeaturedContent,
|
||||
|
|
@ -158,43 +158,49 @@ const HomeScreen = () => {
|
|||
refreshFeatured
|
||||
} = useFeaturedContent();
|
||||
|
||||
// Guard to prevent overlapping fetch calls
|
||||
const isFetchingRef = useRef(false);
|
||||
|
||||
// Progressive catalog loading function with performance optimizations
|
||||
const loadCatalogsProgressively = useCallback(async () => {
|
||||
if (isFetchingRef.current) return;
|
||||
isFetchingRef.current = true;
|
||||
|
||||
setCatalogsLoading(true);
|
||||
setCatalogs([]);
|
||||
setLoadedCatalogCount(0);
|
||||
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
let catalogSettings: Record<string, boolean> = {};
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
if (cachedCatalogSettings && (now - catalogSettingsCacheTimestamp) < CATALOG_SETTINGS_CACHE_TTL) {
|
||||
catalogSettings = cachedCatalogSettings;
|
||||
} else {
|
||||
// Load from storage
|
||||
const catalogSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
|
||||
catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
|
||||
|
||||
|
||||
// Update cache
|
||||
cachedCatalogSettings = catalogSettings;
|
||||
catalogSettingsCacheTimestamp = now;
|
||||
}
|
||||
|
||||
|
||||
const [addons, addonManifests] = await Promise.all([
|
||||
catalogService.getAllAddons(),
|
||||
stremioService.getInstalledAddonsAsync()
|
||||
]);
|
||||
|
||||
|
||||
// Set hasAddons state based on whether we have any addons - ensure on main thread
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
setHasAddons(addons.length > 0);
|
||||
});
|
||||
|
||||
|
||||
// Create placeholder array with proper order and track indices
|
||||
let catalogIndex = 0;
|
||||
const catalogQueue: (() => Promise<void>)[] = [];
|
||||
|
||||
|
||||
// Launch all catalog loaders in parallel
|
||||
const launchAllCatalogs = () => {
|
||||
while (catalogQueue.length > 0) {
|
||||
|
|
@ -204,18 +210,18 @@ const HomeScreen = () => {
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
for (const addon of addons) {
|
||||
if (addon.catalogs) {
|
||||
for (const catalog of addon.catalogs) {
|
||||
// Check if this catalog is enabled (default to true if no setting exists)
|
||||
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
|
||||
const isEnabled = catalogSettings[settingKey] ?? true;
|
||||
|
||||
|
||||
// Only load enabled catalogs
|
||||
if (isEnabled) {
|
||||
const currentIndex = catalogIndex;
|
||||
|
||||
|
||||
const catalogLoader = async () => {
|
||||
try {
|
||||
const manifest = addonManifests.find((a: any) => a.id === addon.id);
|
||||
|
|
@ -226,7 +232,7 @@ const HomeScreen = () => {
|
|||
// Aggressively limit items per catalog on Android to reduce memory usage
|
||||
const limit = Platform.OS === 'android' ? 18 : 30;
|
||||
const limitedMetas = metas.slice(0, limit);
|
||||
|
||||
|
||||
const items = limitedMetas.map((meta: any) => ({
|
||||
id: meta.id,
|
||||
type: meta.type,
|
||||
|
|
@ -267,7 +273,7 @@ const HomeScreen = () => {
|
|||
displayName = `${displayName} ${contentType}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const catalogContent = {
|
||||
addon: addon.id,
|
||||
type: catalog.type,
|
||||
|
|
@ -275,7 +281,7 @@ const HomeScreen = () => {
|
|||
name: displayName,
|
||||
items
|
||||
};
|
||||
|
||||
|
||||
// Update the catalog at its specific position - ensure on main thread
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
setCatalogs(prevCatalogs => {
|
||||
|
|
@ -296,26 +302,37 @@ const HomeScreen = () => {
|
|||
if (prev === 0) {
|
||||
setCatalogsLoading(false);
|
||||
}
|
||||
// ** Crucial: If all catalogs processed, release the fetch guard **
|
||||
if (next >= totalCatalogsRef.current) {
|
||||
isFetchingRef.current = false;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
catalogQueue.push(catalogLoader);
|
||||
catalogIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
totalCatalogsRef.current = catalogIndex;
|
||||
|
||||
|
||||
// If no catalogs to load, release locks immediately
|
||||
if (catalogIndex === 0) {
|
||||
setCatalogsLoading(false);
|
||||
isFetchingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize catalogs array with proper length - ensure on main thread
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
setCatalogs(new Array(catalogIndex).fill(null));
|
||||
});
|
||||
|
||||
|
||||
// Start all catalog requests in parallel
|
||||
launchAllCatalogs();
|
||||
} catch (error) {
|
||||
|
|
@ -323,6 +340,7 @@ const HomeScreen = () => {
|
|||
InteractionManager.runAfterInteractions(() => {
|
||||
setCatalogsLoading(false);
|
||||
});
|
||||
isFetchingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -355,7 +373,22 @@ const HomeScreen = () => {
|
|||
|
||||
// Listen for catalog changes (addon additions/removals) and reload catalogs
|
||||
useEffect(() => {
|
||||
loadCatalogsProgressively();
|
||||
// Skip initial mount (handled by the loadCatalogsProgressively effect)
|
||||
if (lastUpdate === 0) return;
|
||||
|
||||
// Force reset the fetch guard to ensure refresh happens
|
||||
isFetchingRef.current = false;
|
||||
|
||||
// Invalidate catalog settings cache so fresh settings are loaded
|
||||
cachedCatalogSettings = null;
|
||||
catalogSettingsCacheTimestamp = 0;
|
||||
|
||||
// Small delay to ensure previous fetch is fully stopped
|
||||
const timer = setTimeout(() => {
|
||||
loadCatalogsProgressively();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [lastUpdate, loadCatalogsProgressively]);
|
||||
|
||||
// One-time hint after skipping login in onboarding
|
||||
|
|
@ -371,7 +404,7 @@ const HomeScreen = () => {
|
|||
// Also show a global toast for consistency across screens
|
||||
// showInfo('Sign In Available', 'You can sign in anytime from Settings → Account');
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
})();
|
||||
return () => {
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
|
|
@ -389,10 +422,10 @@ const HomeScreen = () => {
|
|||
setShowHeroSection(settings.showHeroSection);
|
||||
setFeaturedContentSource(settings.featuredContentSource);
|
||||
};
|
||||
|
||||
|
||||
// Subscribe to settings changes
|
||||
const unsubscribe = settingsEmitter.addListener(handleSettingsChange);
|
||||
|
||||
|
||||
return unsubscribe;
|
||||
}, [settings.showHeroSection, settings.featuredContentSource]);
|
||||
|
||||
|
|
@ -409,12 +442,12 @@ const HomeScreen = () => {
|
|||
StatusBar.setHidden(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
statusBarConfig();
|
||||
|
||||
|
||||
// Unlock orientation to allow free rotation
|
||||
ScreenOrientation.unlockAsync().catch(() => {});
|
||||
|
||||
ScreenOrientation.unlockAsync().catch(() => { });
|
||||
|
||||
return () => {
|
||||
// Stop trailer when screen loses focus (navigating to other screens)
|
||||
setTrailerPlaying(false);
|
||||
|
|
@ -450,12 +483,12 @@ const HomeScreen = () => {
|
|||
StatusBar.setTranslucent(false);
|
||||
StatusBar.setBackgroundColor(currentTheme.colors.darkBackground);
|
||||
}
|
||||
|
||||
|
||||
// Clean up any lingering timeouts
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
}
|
||||
|
||||
|
||||
// Don't clear FastImage cache on unmount - it causes broken images on remount
|
||||
// FastImage's native libraries (SDWebImage/Glide) handle memory automatically
|
||||
// Cache clearing only happens on app background (see AppState handler above)
|
||||
|
|
@ -468,11 +501,11 @@ const HomeScreen = () => {
|
|||
// Balanced preload images function using FastImage
|
||||
const preloadImages = useCallback(async (content: StreamingContent[]) => {
|
||||
if (!content.length) return;
|
||||
|
||||
|
||||
try {
|
||||
// Moderate prefetching for better performance balance
|
||||
const MAX_IMAGES = 10; // Preload 10 most important images
|
||||
|
||||
|
||||
// Only preload poster images (skip banner and logo entirely)
|
||||
const posterImages = content.slice(0, MAX_IMAGES)
|
||||
.map(item => item.poster)
|
||||
|
|
@ -499,26 +532,27 @@ const HomeScreen = () => {
|
|||
|
||||
const handlePlayStream = useCallback(async (stream: Stream) => {
|
||||
if (!featuredContent) return;
|
||||
|
||||
|
||||
try {
|
||||
// Don't clear cache before player - causes broken images on return
|
||||
// FastImage's native libraries handle memory efficiently
|
||||
|
||||
|
||||
// Lock orientation to landscape before navigation to prevent glitches
|
||||
try {
|
||||
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
|
||||
|
||||
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
|
||||
|
||||
// Longer delay to ensure orientation is fully set before navigation
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
} catch (orientationError) {
|
||||
// If orientation lock fails, continue anyway but log it
|
||||
logger.warn('[HomeScreen] Orientation lock failed:', orientationError);
|
||||
// Still add a small delay
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
|
||||
// @ts-ignore
|
||||
navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', {
|
||||
uri: stream.url,
|
||||
uri: stream.url as any,
|
||||
title: featuredContent.name,
|
||||
year: featuredContent.year,
|
||||
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
|
||||
|
|
@ -528,10 +562,12 @@ const HomeScreen = () => {
|
|||
});
|
||||
} catch (error) {
|
||||
logger.error('[HomeScreen] Error in handlePlayStream:', error);
|
||||
|
||||
|
||||
// Fallback: navigate anyway
|
||||
// Fallback: navigate anyway
|
||||
// @ts-ignore
|
||||
navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', {
|
||||
uri: stream.url,
|
||||
uri: stream.url as any,
|
||||
title: featuredContent.name,
|
||||
year: featuredContent.year,
|
||||
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
|
||||
|
|
@ -545,9 +581,9 @@ const HomeScreen = () => {
|
|||
const refreshContinueWatching = useCallback(async () => {
|
||||
if (continueWatchingRef.current) {
|
||||
try {
|
||||
const hasContent = await continueWatchingRef.current.refresh();
|
||||
setHasContinueWatching(hasContent);
|
||||
|
||||
const hasContent = await continueWatchingRef.current.refresh();
|
||||
setHasContinueWatching(hasContent);
|
||||
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('[HomeScreen] Error refreshing continue watching:', error);
|
||||
setHasContinueWatching(false);
|
||||
|
|
@ -555,19 +591,31 @@ const HomeScreen = () => {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Use refs to track state for event listeners without triggering re-effects
|
||||
const catalogsLengthRef = useRef(catalogs.length);
|
||||
const catalogsLoadingRef = useRef(catalogsLoading);
|
||||
|
||||
useEffect(() => {
|
||||
catalogsLengthRef.current = catalogs.length;
|
||||
}, [catalogs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
catalogsLoadingRef.current = catalogsLoading;
|
||||
}, [catalogsLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = navigation.addListener('focus', () => {
|
||||
// Only refresh continue watching section on focus
|
||||
refreshContinueWatching();
|
||||
// Don't reload catalogs unless they haven't been loaded yet
|
||||
// Catalogs will be refreshed through context updates when addons change
|
||||
if (catalogs.length === 0 && !catalogsLoading) {
|
||||
// Uses refs to avoid re-binding the listener on every state change
|
||||
if (catalogsLengthRef.current === 0 && !catalogsLoadingRef.current) {
|
||||
loadCatalogsProgressively();
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [navigation, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]);
|
||||
}, [navigation, refreshContinueWatching, loadCatalogsProgressively]);
|
||||
|
||||
// Memoize the loading screen to prevent unnecessary re-renders
|
||||
const renderLoadingScreen = useMemo(() => {
|
||||
|
|
@ -603,7 +651,7 @@ const HomeScreen = () => {
|
|||
|
||||
// Only show a limited number of catalogs initially for performance
|
||||
const catalogsToShow = catalogs.slice(0, visibleCatalogCount);
|
||||
|
||||
|
||||
catalogsToShow.forEach((catalog, index) => {
|
||||
if (catalog) {
|
||||
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
|
||||
|
|
@ -637,7 +685,7 @@ const HomeScreen = () => {
|
|||
// Memoize individual section components to prevent re-renders
|
||||
const memoizedFeaturedContent = useMemo(() => {
|
||||
const heroStyleToUse = settings.heroStyle;
|
||||
|
||||
|
||||
// AppleTVHero is only available on mobile devices (not tablets)
|
||||
if (heroStyleToUse === 'appletv' && !isTablet) {
|
||||
return (
|
||||
|
|
@ -694,7 +742,7 @@ const HomeScreen = () => {
|
|||
const lastToggleRef = useRef(0);
|
||||
const scrollAnimationFrameRef = useRef<number | null>(null);
|
||||
const isScrollingRef = useRef(false);
|
||||
|
||||
|
||||
const toggleHeader = useCallback((hide: boolean) => {
|
||||
const now = Date.now();
|
||||
if (now - lastToggleRef.current < 120) return; // debounce
|
||||
|
|
@ -735,7 +783,7 @@ const HomeScreen = () => {
|
|||
);
|
||||
case 'loadMore':
|
||||
return (
|
||||
<Animated.View entering={FadeIn.duration(300)}>
|
||||
<View>
|
||||
<View style={styles.loadMoreContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.loadMoreButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
|
|
@ -747,7 +795,7 @@ const HomeScreen = () => {
|
|||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
case 'welcome':
|
||||
return <FirstTimeWelcome />;
|
||||
|
|
@ -783,26 +831,26 @@ const HomeScreen = () => {
|
|||
const handleScroll = useCallback((event: any) => {
|
||||
// Persist the event before using requestAnimationFrame to prevent event pooling issues
|
||||
event.persist();
|
||||
|
||||
|
||||
// Cancel any pending animation frame
|
||||
if (scrollAnimationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(scrollAnimationFrameRef.current);
|
||||
}
|
||||
|
||||
|
||||
// Capture scroll values immediately before async operation
|
||||
const scrollYValue = event.nativeEvent.contentOffset.y;
|
||||
|
||||
|
||||
// Update shared value for parallax (on UI thread)
|
||||
scrollY.value = scrollYValue;
|
||||
|
||||
|
||||
// Use requestAnimationFrame to throttle scroll handling
|
||||
scrollAnimationFrameRef.current = requestAnimationFrame(() => {
|
||||
const y = scrollYValue;
|
||||
const dy = y - lastScrollYRef.current;
|
||||
lastScrollYRef.current = y;
|
||||
|
||||
|
||||
isScrollingRef.current = Math.abs(dy) > 0;
|
||||
|
||||
|
||||
if (y <= 10) {
|
||||
toggleHeader(false);
|
||||
return;
|
||||
|
|
@ -813,7 +861,7 @@ const HomeScreen = () => {
|
|||
} else if (dy < -6) {
|
||||
toggleHeader(false); // scrolling up
|
||||
}
|
||||
|
||||
|
||||
scrollAnimationFrameRef.current = null;
|
||||
});
|
||||
}, [toggleHeader]);
|
||||
|
|
@ -823,9 +871,9 @@ const HomeScreen = () => {
|
|||
const contentContainerStyle = useMemo(() => {
|
||||
const heroStyleToUse = settings.heroStyle;
|
||||
const isUsingAppleTVHero = heroStyleToUse === 'appletv' && !isTablet && showHeroSection;
|
||||
|
||||
|
||||
return StyleSheet.flatten([
|
||||
styles.scrollContent,
|
||||
styles.scrollContent,
|
||||
{ paddingTop: isUsingAppleTVHero ? 0 : stableInsetsTop }
|
||||
]);
|
||||
}, [stableInsetsTop, settings.heroStyle, isTablet, showHeroSection]);
|
||||
|
|
@ -833,9 +881,9 @@ const HomeScreen = () => {
|
|||
// Memoize the main content section
|
||||
const renderMainContent = useMemo(() => {
|
||||
if (isLoading) return null;
|
||||
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<StatusBar
|
||||
barStyle="light-content"
|
||||
backgroundColor="transparent"
|
||||
|
|
@ -882,13 +930,13 @@ const calculatePosterLayout = (screenWidth: number) => {
|
|||
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
|
||||
const LEFT_PADDING = 16; // Left padding
|
||||
const SPACING = 8; // Space between posters
|
||||
|
||||
|
||||
// Calculate available width for posters (reserve space for left padding)
|
||||
const availableWidth = screenWidth - LEFT_PADDING;
|
||||
|
||||
|
||||
// Try different numbers of full posters to find the best fit
|
||||
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
|
||||
|
||||
|
||||
for (let n = 3; n <= 6; n++) {
|
||||
// Calculate poster width needed for N full posters + 0.25 partial poster
|
||||
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
|
||||
|
|
@ -896,12 +944,12 @@ const calculatePosterLayout = (screenWidth: number) => {
|
|||
// We'll use minimal right padding (8px) to maximize space
|
||||
const usableWidth = availableWidth - 8;
|
||||
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
|
||||
|
||||
|
||||
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
|
||||
bestLayout = { numFullPosters: n, posterWidth };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
numFullPosters: bestLayout.numFullPosters,
|
||||
posterWidth: bestLayout.posterWidth,
|
||||
|
|
@ -966,7 +1014,7 @@ const styles = StyleSheet.create<any>({
|
|||
},
|
||||
placeholderPoster: {
|
||||
width: POSTER_WIDTH,
|
||||
aspectRatio: 2/3,
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 12,
|
||||
marginRight: 2,
|
||||
},
|
||||
|
|
@ -1203,7 +1251,7 @@ const styles = StyleSheet.create<any>({
|
|||
},
|
||||
contentItem: {
|
||||
width: POSTER_WIDTH,
|
||||
aspectRatio: 2/3,
|
||||
aspectRatio: 2 / 3,
|
||||
margin: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
|
|
|
|||
|
|
@ -385,47 +385,49 @@ const LibraryScreen = () => {
|
|||
return folders.filter(folder => folder.itemCount > 0);
|
||||
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
|
||||
|
||||
const renderItem = ({ item }: { item: LibraryItem }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.itemContainer, { width: itemWidth }]}
|
||||
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
|
||||
onLongPress={() => {
|
||||
setSelectedItem(item);
|
||||
setMenuVisible(true);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View>
|
||||
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
|
||||
<FastImage
|
||||
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
||||
style={styles.poster}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
{item.watched && (
|
||||
<View style={styles.watchedIndicator}>
|
||||
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success || '#4CAF50'} />
|
||||
</View>
|
||||
)}
|
||||
{item.progress !== undefined && item.progress < 1 && (
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{settings.showPosterTitles && (
|
||||
const renderItem = ({ item }: { item: LibraryItem }) => {
|
||||
const aspectRatio = item.posterShape === 'landscape' ? 16 / 9 : (item.posterShape === 'square' ? 1 : 2 / 3);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.itemContainer, { width: itemWidth }]}
|
||||
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
|
||||
onLongPress={() => {
|
||||
setSelectedItem(item);
|
||||
setMenuVisible(true);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View>
|
||||
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black, aspectRatio }]}>
|
||||
<FastImage
|
||||
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
||||
style={styles.poster}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
{item.watched && (
|
||||
<View style={styles.watchedIndicator}>
|
||||
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success || '#4CAF50'} />
|
||||
</View>
|
||||
)}
|
||||
{item.progress !== undefined && item.progress < 1 && (
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{item.name}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => (
|
||||
<TouchableOpacity
|
||||
|
|
|
|||
|
|
@ -41,7 +41,11 @@ import Animated, {
|
|||
Easing,
|
||||
interpolateColor,
|
||||
withSpring,
|
||||
createAnimatedComponent,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
// Create animated version of SafeAreaView for use with Reanimated styles
|
||||
const AnimatedSafeAreaView = createAnimatedComponent(SafeAreaView);
|
||||
import { RouteProp } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
|
|
@ -911,7 +915,7 @@ const MetadataScreen: React.FC = () => {
|
|||
|
||||
return (
|
||||
<Animated.View style={[animatedBackgroundStyle, { flex: 1 }]}>
|
||||
<SafeAreaView
|
||||
<AnimatedSafeAreaView
|
||||
style={[containerStyle, styles.container]}
|
||||
edges={[]}
|
||||
>
|
||||
|
|
@ -1270,6 +1274,7 @@ const MetadataScreen: React.FC = () => {
|
|||
onSelectEpisode={handleEpisodeSelect}
|
||||
groupedEpisodes={groupedEpisodes}
|
||||
metadata={metadata || undefined}
|
||||
imdbId={imdbId || undefined}
|
||||
/>
|
||||
) : (
|
||||
metadata && <MemoizedMovieContent metadata={metadata} />
|
||||
|
|
@ -1417,7 +1422,7 @@ const MetadataScreen: React.FC = () => {
|
|||
isSpoilerRevealed={selectedComment ? revealedSpoilers.has(selectedComment.id.toString()) : false}
|
||||
onSpoilerPress={() => selectedComment && handleSpoilerPress(selectedComment)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</AnimatedSafeAreaView>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -78,17 +78,17 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
marginBottom: 8,
|
||||
},
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
sectionDescription: {
|
||||
fontSize: 14,
|
||||
color: colors.mediumGray,
|
||||
|
|
@ -283,59 +283,59 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
marginTop: 8,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: colors.mediumEmphasis,
|
||||
lineHeight: 20,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 32,
|
||||
},
|
||||
emptyStateTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyStateDescription: {
|
||||
fontSize: 14,
|
||||
color: colors.mediumGray,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
scrapersList: {
|
||||
gap: 12,
|
||||
},
|
||||
scrapersContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
lastSection: {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
disabledSection: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
disabledText: {
|
||||
color: colors.elevation3,
|
||||
},
|
||||
disabledContainer: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
disabledInput: {
|
||||
backgroundColor: colors.elevation1,
|
||||
opacity: 0.5,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
disabledImage: {
|
||||
fontSize: 14,
|
||||
color: colors.mediumEmphasis,
|
||||
lineHeight: 20,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 32,
|
||||
},
|
||||
emptyStateTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyStateDescription: {
|
||||
fontSize: 14,
|
||||
color: colors.mediumGray,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
scrapersList: {
|
||||
gap: 12,
|
||||
},
|
||||
scrapersContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
lastSection: {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
disabledSection: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
disabledText: {
|
||||
color: colors.elevation3,
|
||||
},
|
||||
disabledContainer: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
disabledInput: {
|
||||
backgroundColor: colors.elevation1,
|
||||
opacity: 0.5,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
disabledImage: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
availableIndicator: {
|
||||
|
|
@ -484,46 +484,60 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: colors.darkBackground,
|
||||
backgroundColor: '#1E1E1E', // Match CustomAlert
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
margin: 20,
|
||||
maxHeight: '70%',
|
||||
width: screenWidth - 40,
|
||||
padding: 24,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.elevation3,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
alignSelf: 'center',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.51,
|
||||
shadowRadius: 13.16,
|
||||
},
|
||||
android: {
|
||||
elevation: 20,
|
||||
},
|
||||
}),
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
modalText: {
|
||||
fontSize: 16,
|
||||
color: colors.mediumGray,
|
||||
lineHeight: 24,
|
||||
fontSize: 15,
|
||||
color: '#AAAAAA',
|
||||
lineHeight: 22,
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
modalButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 16,
|
||||
minHeight: 48,
|
||||
},
|
||||
modalButtonText: {
|
||||
color: colors.white,
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
fontWeight: '600',
|
||||
},
|
||||
// Compact modal styles
|
||||
modalHeader: {
|
||||
|
|
@ -761,10 +775,10 @@ const CollapsibleSection: React.FC<{
|
|||
<View style={styles.collapsibleSection}>
|
||||
<TouchableOpacity style={styles.collapsibleHeader} onPress={onToggle}>
|
||||
<Text style={styles.collapsibleTitle}>{title}</Text>
|
||||
<Ionicons
|
||||
name={isExpanded ? "chevron-up" : "chevron-down"}
|
||||
size={20}
|
||||
color={colors.mediumGray}
|
||||
<Ionicons
|
||||
name={isExpanded ? "chevron-up" : "chevron-down"}
|
||||
size={20}
|
||||
color={colors.mediumGray}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{isExpanded && <View style={styles.collapsibleContent}>{children}</View>}
|
||||
|
|
@ -803,7 +817,7 @@ const StatusBadge: React.FC<{
|
|||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
|
||||
|
||||
return (
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
|
|
@ -828,7 +842,7 @@ const PluginsScreen: React.FC = () => {
|
|||
const { currentTheme } = useTheme();
|
||||
const colors = currentTheme.colors;
|
||||
const styles = createStyles(colors);
|
||||
|
||||
|
||||
// CustomAlert state
|
||||
const [alertVisible, setAlertVisible] = useState(false);
|
||||
const [alertTitle, setAlertTitle] = useState('');
|
||||
|
|
@ -842,7 +856,7 @@ const PluginsScreen: React.FC = () => {
|
|||
) => {
|
||||
setAlertTitle(title);
|
||||
setAlertMessage(message);
|
||||
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]);
|
||||
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]);
|
||||
setAlertVisible(true);
|
||||
};
|
||||
|
||||
|
|
@ -856,14 +870,14 @@ const PluginsScreen: React.FC = () => {
|
|||
const [showboxSavedToken, setShowboxSavedToken] = useState<string>('');
|
||||
const [showboxScraperId, setShowboxScraperId] = useState<string | null>(null);
|
||||
const [showboxTokenVisible, setShowboxTokenVisible] = useState<boolean>(false);
|
||||
|
||||
|
||||
// Multiple repositories state
|
||||
const [repositories, setRepositories] = useState<RepositoryInfo[]>([]);
|
||||
const [currentRepositoryId, setCurrentRepositoryId] = useState<string>('');
|
||||
const [showAddRepositoryModal, setShowAddRepositoryModal] = useState(false);
|
||||
const [newRepositoryUrl, setNewRepositoryUrl] = useState('');
|
||||
const [switchingRepository, setSwitchingRepository] = useState<string | null>(null);
|
||||
|
||||
|
||||
// New UX state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'movie' | 'tv'>('all');
|
||||
|
|
@ -897,7 +911,7 @@ const PluginsScreen: React.FC = () => {
|
|||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(scraper =>
|
||||
filtered = filtered.filter(scraper =>
|
||||
scraper.name.toLowerCase().includes(query) ||
|
||||
scraper.description.toLowerCase().includes(query) ||
|
||||
scraper.id.toLowerCase().includes(query)
|
||||
|
|
@ -906,7 +920,7 @@ const PluginsScreen: React.FC = () => {
|
|||
|
||||
// Filter by type
|
||||
if (selectedFilter !== 'all') {
|
||||
filtered = filtered.filter(scraper =>
|
||||
filtered = filtered.filter(scraper =>
|
||||
scraper.supportedTypes?.includes(selectedFilter as 'movie' | 'tv')
|
||||
);
|
||||
}
|
||||
|
|
@ -933,7 +947,7 @@ const PluginsScreen: React.FC = () => {
|
|||
const handleBulkToggle = async (enabled: boolean) => {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const promises = filteredScrapers.map(scraper =>
|
||||
const promises = filteredScrapers.map(scraper =>
|
||||
pluginService.setScraperEnabled(scraper.id, enabled)
|
||||
);
|
||||
await Promise.all(promises);
|
||||
|
|
@ -994,14 +1008,14 @@ const PluginsScreen: React.FC = () => {
|
|||
description: '',
|
||||
enabled: true
|
||||
});
|
||||
|
||||
|
||||
await loadRepositories();
|
||||
|
||||
|
||||
// Switch to the new repository and refresh it
|
||||
await pluginService.setCurrentRepository(repoId);
|
||||
await loadRepositories();
|
||||
await loadScrapers();
|
||||
|
||||
|
||||
setNewRepositoryUrl('');
|
||||
setShowAddRepositoryModal(false);
|
||||
openAlert('Success', 'Repository added and refreshed successfully');
|
||||
|
|
@ -1034,9 +1048,9 @@ const PluginsScreen: React.FC = () => {
|
|||
|
||||
// Special handling for the last repository
|
||||
const isLastRepository = repositories.length === 1;
|
||||
|
||||
|
||||
const alertTitle = isLastRepository ? 'Remove Last Repository' : 'Remove Repository';
|
||||
const alertMessage = isLastRepository
|
||||
const alertMessage = isLastRepository
|
||||
? `Are you sure you want to remove "${repo.name}"? This is your only repository, so you'll have no scrapers available until you add a new repository.`
|
||||
: `Are you sure you want to remove "${repo.name}"? This will also remove all scrapers from this repository.`;
|
||||
|
||||
|
|
@ -1044,7 +1058,7 @@ const PluginsScreen: React.FC = () => {
|
|||
alertTitle,
|
||||
alertMessage,
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{ label: 'Cancel', onPress: () => { } },
|
||||
{
|
||||
label: 'Remove',
|
||||
onPress: async () => {
|
||||
|
|
@ -1052,7 +1066,7 @@ const PluginsScreen: React.FC = () => {
|
|||
await pluginService.removeRepository(repoId);
|
||||
await loadRepositories();
|
||||
await loadScrapers();
|
||||
const successMessage = isLastRepository
|
||||
const successMessage = isLastRepository
|
||||
? 'Repository removed successfully. You can add a new repository using the "Add Repository" button.'
|
||||
: 'Repository removed successfully';
|
||||
openAlert('Success', successMessage);
|
||||
|
|
@ -1105,14 +1119,14 @@ const PluginsScreen: React.FC = () => {
|
|||
try {
|
||||
// First refresh repository names from manifests for existing repositories
|
||||
await pluginService.refreshRepositoryNamesFromManifests();
|
||||
|
||||
|
||||
const repos = await pluginService.getRepositories();
|
||||
setRepositories(repos);
|
||||
setHasRepository(repos.length > 0);
|
||||
|
||||
|
||||
const currentRepoId = pluginService.getCurrentRepositoryId();
|
||||
setCurrentRepositoryId(currentRepoId);
|
||||
|
||||
|
||||
const currentRepo = repos.find(r => r.id === currentRepoId);
|
||||
if (currentRepo) {
|
||||
setRepositoryUrl(currentRepo.url);
|
||||
|
|
@ -1144,7 +1158,7 @@ const PluginsScreen: React.FC = () => {
|
|||
const url = repositoryUrl.trim();
|
||||
if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) {
|
||||
openAlert(
|
||||
'Invalid URL Format',
|
||||
'Invalid URL Format',
|
||||
'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/master'
|
||||
);
|
||||
return;
|
||||
|
|
@ -1199,7 +1213,7 @@ const PluginsScreen: React.FC = () => {
|
|||
// If enabling a scraper, ensure it's installed first
|
||||
const installedScrapers = await pluginService.getInstalledScrapers();
|
||||
const isInstalled = installedScrapers.some(scraper => scraper.id === scraperId);
|
||||
|
||||
|
||||
if (!isInstalled) {
|
||||
// Need to install the scraper first
|
||||
setIsRefreshing(true);
|
||||
|
|
@ -1207,7 +1221,7 @@ const PluginsScreen: React.FC = () => {
|
|||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await pluginService.setScraperEnabled(scraperId, enabled);
|
||||
await loadScrapers();
|
||||
} catch (error) {
|
||||
|
|
@ -1222,7 +1236,7 @@ const PluginsScreen: React.FC = () => {
|
|||
'Clear All Scrapers',
|
||||
'Are you sure you want to remove all installed scrapers? This action cannot be undone.',
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{ label: 'Cancel', onPress: () => { } },
|
||||
{
|
||||
label: 'Clear',
|
||||
onPress: async () => {
|
||||
|
|
@ -1245,7 +1259,7 @@ const PluginsScreen: React.FC = () => {
|
|||
'Clear Repository Cache',
|
||||
'This will remove the saved repository URL and clear all cached scraper data. You will need to re-enter your repository URL.',
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{ label: 'Cancel', onPress: () => { } },
|
||||
{
|
||||
label: 'Clear Cache',
|
||||
onPress: async () => {
|
||||
|
|
@ -1274,19 +1288,19 @@ const PluginsScreen: React.FC = () => {
|
|||
|
||||
const handleToggleLocalScrapers = async (enabled: boolean) => {
|
||||
await updateSetting('enableLocalScrapers', enabled);
|
||||
|
||||
|
||||
// If enabling plugins, refresh repository and reload plugins
|
||||
if (enabled) {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
logger.log('[PluginsScreen] Enabling plugins - refreshing repository...');
|
||||
|
||||
|
||||
// Refresh repository to ensure plugins are available
|
||||
await pluginService.refreshRepository();
|
||||
|
||||
|
||||
// Reload plugins to get the latest state
|
||||
await loadScrapers();
|
||||
|
||||
|
||||
logger.log('[PluginsScreen] Plugins enabled and repository refreshed');
|
||||
} catch (error) {
|
||||
logger.error('[PluginsScreen] Failed to refresh repository when enabling plugins:', error);
|
||||
|
|
@ -1304,7 +1318,7 @@ const PluginsScreen: React.FC = () => {
|
|||
const handleToggleQualityExclusion = async (quality: string) => {
|
||||
const currentExcluded = settings.excludedQualities || [];
|
||||
const isExcluded = currentExcluded.includes(quality);
|
||||
|
||||
|
||||
let newExcluded: string[];
|
||||
if (isExcluded) {
|
||||
// Remove from excluded list
|
||||
|
|
@ -1313,14 +1327,14 @@ const PluginsScreen: React.FC = () => {
|
|||
// Add to excluded list
|
||||
newExcluded = [...currentExcluded, quality];
|
||||
}
|
||||
|
||||
|
||||
await updateSetting('excludedQualities', newExcluded);
|
||||
};
|
||||
|
||||
const handleToggleLanguageExclusion = async (language: string) => {
|
||||
const currentExcluded = settings.excludedLanguages || [];
|
||||
const isExcluded = currentExcluded.includes(language);
|
||||
|
||||
|
||||
let newExcluded: string[];
|
||||
if (isExcluded) {
|
||||
// Remove from excluded list
|
||||
|
|
@ -1329,13 +1343,13 @@ const PluginsScreen: React.FC = () => {
|
|||
// Add to excluded list
|
||||
newExcluded = [...currentExcluded, language];
|
||||
}
|
||||
|
||||
|
||||
await updateSetting('excludedLanguages', newExcluded);
|
||||
};
|
||||
|
||||
// Define available quality options
|
||||
const qualityOptions = ['Auto', 'Adaptive', '2160p', '4K', '1080p', '720p', '360p', 'DV', 'HDR', 'REMUX', '480p', 'CAM', 'TS'];
|
||||
|
||||
|
||||
// Define available language options
|
||||
const languageOptions = ['Original', 'English', 'Spanish', 'Latin', 'French', 'German', 'Italian', 'Portuguese', 'Russian', 'Japanese', 'Korean', 'Chinese', 'Arabic', 'Hindi', 'Turkish', 'Dutch', 'Polish'];
|
||||
|
||||
|
|
@ -1344,7 +1358,7 @@ const PluginsScreen: React.FC = () => {
|
|||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
|
|
@ -1354,7 +1368,7 @@ const PluginsScreen: React.FC = () => {
|
|||
<Ionicons name="arrow-back" size={24} color={colors.primary} />
|
||||
<Text style={styles.backText}>Settings</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
||||
<View style={styles.headerActions}>
|
||||
{/* Help Button */}
|
||||
<TouchableOpacity
|
||||
|
|
@ -1365,7 +1379,7 @@ const PluginsScreen: React.FC = () => {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
<Text style={styles.headerTitle}>Plugins</Text>
|
||||
|
||||
<ScrollView
|
||||
|
|
@ -1429,7 +1443,7 @@ const PluginsScreen: React.FC = () => {
|
|||
<Text style={styles.sectionDescription}>
|
||||
Manage multiple scraper repositories. Switch between repositories to access different sets of scrapers.
|
||||
</Text>
|
||||
|
||||
|
||||
{/* Current Repository */}
|
||||
{currentRepositoryId && (
|
||||
<View style={styles.currentRepoContainer}>
|
||||
|
|
@ -1438,7 +1452,7 @@ const PluginsScreen: React.FC = () => {
|
|||
<Text style={[styles.currentRepoUrl, { fontSize: 12, opacity: 0.7, marginTop: 4 }]}>{repositoryUrl}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
{/* Repository List */}
|
||||
{repositories.length > 0 && (
|
||||
<View style={styles.repositoriesList}>
|
||||
|
|
@ -1467,8 +1481,8 @@ const PluginsScreen: React.FC = () => {
|
|||
<Text style={styles.repositoryUrl}>{repo.url}</Text>
|
||||
<Text style={styles.repositoryMeta}>
|
||||
{repo.scraperCount || 0} scrapers • Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'}
|
||||
</Text>
|
||||
</View>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.repositoryActions}>
|
||||
{repo.id !== currentRepositoryId && (
|
||||
<TouchableOpacity
|
||||
|
|
@ -1502,7 +1516,7 @@ const PluginsScreen: React.FC = () => {
|
|||
<Text style={styles.repositoryActionButtonText}>Remove</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -1541,9 +1555,9 @@ const PluginsScreen: React.FC = () => {
|
|||
{searchQuery.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setSearchQuery('')}>
|
||||
<Ionicons name="close-circle" size={20} color={colors.mediumGray} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Filter Chips */}
|
||||
<View style={styles.filterContainer}>
|
||||
|
|
@ -1561,7 +1575,7 @@ const PluginsScreen: React.FC = () => {
|
|||
selectedFilter === filter && styles.filterChipTextSelected
|
||||
]}>
|
||||
{filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'}
|
||||
</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
|
@ -1590,17 +1604,17 @@ const PluginsScreen: React.FC = () => {
|
|||
|
||||
{filteredScrapers.length === 0 ? (
|
||||
<View style={styles.emptyStateContainer}>
|
||||
<Ionicons
|
||||
name={searchQuery ? "search" : "download-outline"}
|
||||
size={48}
|
||||
<Ionicons
|
||||
name={searchQuery ? "search" : "download-outline"}
|
||||
size={48}
|
||||
color={colors.mediumGray}
|
||||
style={styles.emptyStateIcon}
|
||||
/>
|
||||
<Text style={styles.emptyStateTitle}>
|
||||
{searchQuery ? 'No Scrapers Found' : 'No Scrapers Available'}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={styles.emptyStateDescription}>
|
||||
{searchQuery
|
||||
{searchQuery
|
||||
? `No scrapers match "${searchQuery}". Try a different search term.`
|
||||
: 'Configure a repository above to view available scrapers.'
|
||||
}
|
||||
|
|
@ -1613,45 +1627,45 @@ const PluginsScreen: React.FC = () => {
|
|||
<Text style={styles.secondaryButtonText}>Clear Search</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.scrapersContainer}>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.scrapersContainer}>
|
||||
{filteredScrapers.map((scraper) => (
|
||||
<View key={scraper.id} style={styles.scraperCard}>
|
||||
<View style={styles.scraperCardHeader}>
|
||||
{scraper.logo ? (
|
||||
(scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? (
|
||||
<Image
|
||||
source={{ uri: scraper.logo }}
|
||||
style={styles.scraperLogo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
<FastImage
|
||||
source={{ uri: scraper.logo }}
|
||||
style={styles.scraperLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
{scraper.logo ? (
|
||||
(scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? (
|
||||
<Image
|
||||
source={{ uri: scraper.logo }}
|
||||
style={styles.scraperLogo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
<FastImage
|
||||
source={{ uri: scraper.logo }}
|
||||
style={styles.scraperLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<View style={styles.scraperLogo} />
|
||||
)}
|
||||
<View style={styles.scraperCardInfo}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
|
||||
<Text style={styles.scraperName}>{scraper.name}</Text>
|
||||
<StatusBadge status={getScraperStatus(scraper)} colors={colors} />
|
||||
</View>
|
||||
<Text style={styles.scraperDescription}>{scraper.description}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={scraper.enabled && settings.enableLocalScrapers}
|
||||
onValueChange={(enabled) => handleToggleScraper(scraper.id, enabled)}
|
||||
trackColor={{ false: colors.elevation3, true: colors.primary }}
|
||||
thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
|
||||
disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={styles.scraperDescription}>{scraper.description}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={scraper.enabled && settings.enableLocalScrapers}
|
||||
onValueChange={(enabled) => handleToggleScraper(scraper.id, enabled)}
|
||||
trackColor={{ false: colors.elevation3, true: colors.primary }}
|
||||
thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
|
||||
disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.scraperCardMeta}>
|
||||
<View style={styles.scraperCardMetaItem}>
|
||||
<Ionicons name="information-circle" size={12} color={colors.mediumGray} />
|
||||
|
|
@ -1682,62 +1696,62 @@ const PluginsScreen: React.FC = () => {
|
|||
</View>
|
||||
|
||||
{/* ShowBox Settings - only visible when ShowBox scraper is available */}
|
||||
{showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && (
|
||||
{showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && (
|
||||
<View style={{ marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: colors.elevation3 }}>
|
||||
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox UI Token</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}>
|
||||
<TextInput
|
||||
style={[styles.textInput, { flex: 1, marginBottom: 0 }]}
|
||||
value={showboxUiToken}
|
||||
onChangeText={setShowboxUiToken}
|
||||
placeholder="Paste your ShowBox UI token"
|
||||
placeholderTextColor={colors.mediumGray}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry={showboxSavedToken.length > 0 && !showboxTokenVisible}
|
||||
multiline={false}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
{showboxSavedToken.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}>
|
||||
<Ionicons name={showboxTokenVisible ? 'eye-off' : 'eye'} size={18} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.buttonRow}>
|
||||
{showboxUiToken !== showboxSavedToken && (
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.primaryButton]}
|
||||
onPress={async () => {
|
||||
if (showboxScraperId) {
|
||||
await pluginService.setScraperSettings(showboxScraperId, { uiToken: showboxUiToken });
|
||||
}
|
||||
setShowboxSavedToken(showboxUiToken);
|
||||
openAlert('Saved', 'ShowBox settings updated');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.buttonText}>Save</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={async () => {
|
||||
setShowboxUiToken('');
|
||||
setShowboxSavedToken('');
|
||||
if (showboxScraperId) {
|
||||
await pluginService.setScraperSettings(showboxScraperId, {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Clear</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox UI Token</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}>
|
||||
<TextInput
|
||||
style={[styles.textInput, { flex: 1, marginBottom: 0 }]}
|
||||
value={showboxUiToken}
|
||||
onChangeText={setShowboxUiToken}
|
||||
placeholder="Paste your ShowBox UI token"
|
||||
placeholderTextColor={colors.mediumGray}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry={showboxSavedToken.length > 0 && !showboxTokenVisible}
|
||||
multiline={false}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
{showboxSavedToken.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}>
|
||||
<Ionicons name={showboxTokenVisible ? 'eye-off' : 'eye'} size={18} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.buttonRow}>
|
||||
{showboxUiToken !== showboxSavedToken && (
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.primaryButton]}
|
||||
onPress={async () => {
|
||||
if (showboxScraperId) {
|
||||
await pluginService.setScraperSettings(showboxScraperId, { uiToken: showboxUiToken });
|
||||
}
|
||||
setShowboxSavedToken(showboxUiToken);
|
||||
openAlert('Saved', 'ShowBox settings updated');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.buttonText}>Save</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={async () => {
|
||||
setShowboxUiToken('');
|
||||
setShowboxSavedToken('');
|
||||
if (showboxScraperId) {
|
||||
await pluginService.setScraperSettings(showboxScraperId, {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Clear</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Additional Settings */}
|
||||
|
|
@ -1763,7 +1777,7 @@ const PluginsScreen: React.FC = () => {
|
|||
disabled={!settings.enableLocalScrapers}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingInfo}>
|
||||
<Text style={styles.settingTitle}>Group Plugin Streams</Text>
|
||||
|
|
@ -1772,20 +1786,20 @@ const PluginsScreen: React.FC = () => {
|
|||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.streamDisplayMode === 'grouped'}
|
||||
onValueChange={(value) => {
|
||||
updateSetting('streamDisplayMode', value ? 'grouped' : 'separate');
|
||||
// Auto-disable quality sorting when grouping is disabled
|
||||
if (!value && settings.streamSortMode === 'quality-then-scraper') {
|
||||
updateSetting('streamSortMode', 'scraper-then-quality');
|
||||
}
|
||||
}}
|
||||
trackColor={{ false: colors.elevation3, true: colors.primary }}
|
||||
thumbColor={settings.streamDisplayMode === 'grouped' ? colors.white : '#f4f3f4'}
|
||||
disabled={!settings.enableLocalScrapers}
|
||||
/>
|
||||
value={settings.streamDisplayMode === 'grouped'}
|
||||
onValueChange={(value) => {
|
||||
updateSetting('streamDisplayMode', value ? 'grouped' : 'separate');
|
||||
// Auto-disable quality sorting when grouping is disabled
|
||||
if (!value && settings.streamSortMode === 'quality-then-scraper') {
|
||||
updateSetting('streamSortMode', 'scraper-then-quality');
|
||||
}
|
||||
}}
|
||||
trackColor={{ false: colors.elevation3, true: colors.primary }}
|
||||
thumbColor={settings.streamDisplayMode === 'grouped' ? colors.white : '#f4f3f4'}
|
||||
disabled={!settings.enableLocalScrapers}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingInfo}>
|
||||
<Text style={styles.settingTitle}>Sort by Quality First</Text>
|
||||
|
|
@ -1794,14 +1808,14 @@ const PluginsScreen: React.FC = () => {
|
|||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.streamSortMode === 'quality-then-scraper'}
|
||||
onValueChange={(value) => updateSetting('streamSortMode', value ? 'quality-then-scraper' : 'scraper-then-quality')}
|
||||
trackColor={{ false: colors.elevation3, true: colors.primary }}
|
||||
thumbColor={settings.streamSortMode === 'quality-then-scraper' ? colors.white : '#f4f3f4'}
|
||||
disabled={!settings.enableLocalScrapers || settings.streamDisplayMode !== 'grouped'}
|
||||
/>
|
||||
value={settings.streamSortMode === 'quality-then-scraper'}
|
||||
onValueChange={(value) => updateSetting('streamSortMode', value ? 'quality-then-scraper' : 'scraper-then-quality')}
|
||||
trackColor={{ false: colors.elevation3, true: colors.primary }}
|
||||
thumbColor={settings.streamSortMode === 'quality-then-scraper' ? colors.white : '#f4f3f4'}
|
||||
disabled={!settings.enableLocalScrapers || settings.streamDisplayMode !== 'grouped'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingInfo}>
|
||||
<Text style={styles.settingTitle}>Show Scraper Logos</Text>
|
||||
|
|
@ -1810,12 +1824,12 @@ const PluginsScreen: React.FC = () => {
|
|||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.showScraperLogos && settings.enableLocalScrapers}
|
||||
onValueChange={(value) => updateSetting('showScraperLogos', value)}
|
||||
trackColor={{ false: colors.elevation3, true: colors.primary }}
|
||||
thumbColor={settings.showScraperLogos && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
|
||||
disabled={!settings.enableLocalScrapers}
|
||||
/>
|
||||
value={settings.showScraperLogos && settings.enableLocalScrapers}
|
||||
onValueChange={(value) => updateSetting('showScraperLogos', value)}
|
||||
trackColor={{ false: colors.elevation3, true: colors.primary }}
|
||||
thumbColor={settings.showScraperLogos && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
|
||||
disabled={!settings.enableLocalScrapers}
|
||||
/>
|
||||
</View>
|
||||
</CollapsibleSection>
|
||||
|
||||
|
|
@ -1830,7 +1844,7 @@ const PluginsScreen: React.FC = () => {
|
|||
<Text style={styles.sectionDescription}>
|
||||
Exclude specific video qualities from search results. Tap on a quality to exclude it from plugin results.
|
||||
</Text>
|
||||
|
||||
|
||||
<View style={styles.qualityChipsContainer}>
|
||||
{qualityOptions.map((quality) => {
|
||||
const isExcluded = (settings.excludedQualities || []).includes(quality);
|
||||
|
|
@ -1856,7 +1870,7 @@ const PluginsScreen: React.FC = () => {
|
|||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
|
||||
{(settings.excludedQualities || []).length > 0 && (
|
||||
<Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}>
|
||||
Excluded qualities: {(settings.excludedQualities || []).join(', ')}
|
||||
|
|
@ -1875,11 +1889,11 @@ const PluginsScreen: React.FC = () => {
|
|||
<Text style={styles.sectionDescription}>
|
||||
Exclude specific languages from search results. Tap on a language to exclude it from plugin results.
|
||||
</Text>
|
||||
|
||||
|
||||
<Text style={[styles.infoText, { marginTop: 8, fontSize: 13, color: colors.mediumEmphasis }]}>
|
||||
<Text style={{ fontWeight: '600' }}>Note:</Text> This filter only applies to providers that include language information in their stream names. It does not affect other providers.
|
||||
</Text>
|
||||
|
||||
|
||||
<View style={styles.qualityChipsContainer}>
|
||||
{languageOptions.map((language) => {
|
||||
const isExcluded = (settings.excludedLanguages || []).includes(language);
|
||||
|
|
@ -1905,7 +1919,7 @@ const PluginsScreen: React.FC = () => {
|
|||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
|
||||
{(settings.excludedLanguages || []).length > 0 && (
|
||||
<Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}>
|
||||
Excluded languages: {(settings.excludedLanguages || []).join(', ')}
|
||||
|
|
@ -1988,36 +2002,36 @@ const PluginsScreen: React.FC = () => {
|
|||
/>
|
||||
|
||||
|
||||
{/* Format Hint */}
|
||||
<Text style={styles.formatHint}>
|
||||
Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch
|
||||
</Text>
|
||||
{/* Format Hint */}
|
||||
<Text style={styles.formatHint}>
|
||||
Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch
|
||||
</Text>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.compactActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.compactButton, styles.cancelButton]}
|
||||
onPress={() => {
|
||||
setShowAddRepositoryModal(false);
|
||||
setNewRepositoryUrl('');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.compactActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.compactButton, styles.cancelButton]}
|
||||
onPress={() => {
|
||||
setShowAddRepositoryModal(false);
|
||||
setNewRepositoryUrl('');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.compactButton, styles.addButton, (!newRepositoryUrl.trim() || isLoading) && styles.disabledButton]}
|
||||
onPress={handleAddRepository}
|
||||
disabled={!newRepositoryUrl.trim() || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={colors.white} />
|
||||
) : (
|
||||
<Text style={styles.addButtonText}>Add</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
<TouchableOpacity
|
||||
style={[styles.compactButton, styles.addButton, (!newRepositoryUrl.trim() || isLoading) && styles.disabledButton]}
|
||||
onPress={handleAddRepository}
|
||||
disabled={!newRepositoryUrl.trim() || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={colors.white} />
|
||||
) : (
|
||||
<Text style={styles.addButtonText}>Add</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
Platform,
|
||||
Easing,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useNavigation, useRoute, useFocusEffect } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { MaterialIcons, Feather } from '@expo/vector-icons';
|
||||
import { catalogService, StreamingContent, GroupedSearchResults, AddonSearchResults } from '../services/catalogService';
|
||||
|
|
@ -235,6 +235,15 @@ const SearchScreen = () => {
|
|||
const addonOrderRankRef = useRef<Record<string, number>>({});
|
||||
// Track if this is the initial mount to prevent unnecessary operations
|
||||
const isInitialMount = useRef(true);
|
||||
// Track mount status for async operations
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
// DropUpMenu state
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<StreamingContent | null>(null);
|
||||
|
|
@ -380,45 +389,87 @@ const SearchScreen = () => {
|
|||
// Create a stable debounced search function using useMemo
|
||||
const debouncedSearch = useMemo(() => {
|
||||
return debounce(async (searchQuery: string) => {
|
||||
if (!searchQuery.trim()) {
|
||||
// Cancel any in-flight live search
|
||||
liveSearchHandle.current?.cancel();
|
||||
liveSearchHandle.current = null;
|
||||
setResults({ byAddon: [], allResults: [] });
|
||||
setSearching(false);
|
||||
return;
|
||||
// Cancel any in-flight live search
|
||||
liveSearchHandle.current?.cancel();
|
||||
liveSearchHandle.current = null;
|
||||
performLiveSearch(searchQuery);
|
||||
}, 800);
|
||||
}, []); // Empty dependency array - create once and never recreate
|
||||
|
||||
// Track focus state to strictly prevent updates when blurred (fixes Telemetry crash)
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
// Cancel any active searches immediately on blur
|
||||
if (liveSearchHandle.current) {
|
||||
liveSearchHandle.current.cancel();
|
||||
liveSearchHandle.current = null;
|
||||
}
|
||||
debouncedSearch.cancel();
|
||||
};
|
||||
}, [debouncedSearch])
|
||||
);
|
||||
|
||||
// Live search implementation
|
||||
const performLiveSearch = async (searchQuery: string) => {
|
||||
// strict guard: don't search if unmounted or blurred
|
||||
if (!isMounted.current) return;
|
||||
|
||||
if (!searchQuery || searchQuery.trim().length === 0) {
|
||||
setResults({ byAddon: [], allResults: [] });
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
setResults({ byAddon: [], allResults: [] });
|
||||
// Reset order rank for new search
|
||||
addonOrderRankRef.current = {};
|
||||
|
||||
try {
|
||||
if (liveSearchHandle.current) {
|
||||
liveSearchHandle.current.cancel();
|
||||
}
|
||||
|
||||
// Cancel prior live search
|
||||
liveSearchHandle.current?.cancel();
|
||||
setResults({ byAddon: [], allResults: [] });
|
||||
setSearching(true);
|
||||
// Pre-fetch addon list to establish a stable order rank
|
||||
const addons = await catalogService.getAllAddons();
|
||||
// ... (rank logic) ...
|
||||
const rank: Record<string, number> = {};
|
||||
let rankCounter = 0;
|
||||
|
||||
logger.info('Starting live search for:', searchQuery);
|
||||
// Preload addon order to keep sections sorted by installation order
|
||||
try {
|
||||
const addons = await catalogService.getAllAddons();
|
||||
const rank: Record<string, number> = {};
|
||||
addons.forEach((a, idx) => { rank[a.id] = idx; });
|
||||
addonOrderRankRef.current = rank;
|
||||
} catch { }
|
||||
// Cinemeta first
|
||||
rank['com.linvo.cinemeta'] = rankCounter++;
|
||||
|
||||
// Then others
|
||||
addons.forEach(addon => {
|
||||
if (addon.id !== 'com.linvo.cinemeta') {
|
||||
rank[addon.id] = rankCounter++;
|
||||
}
|
||||
});
|
||||
addonOrderRankRef.current = rank;
|
||||
|
||||
const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => {
|
||||
// Append/update this addon section immediately with minimal changes
|
||||
setResults(prev => {
|
||||
const rank = addonOrderRankRef.current;
|
||||
const getRank = (id: string) => rank[id] ?? Number.MAX_SAFE_INTEGER;
|
||||
// Prevent updates if component is unmounted or blurred
|
||||
if (!isMounted.current) return;
|
||||
|
||||
// Append/update this addon section...
|
||||
setResults(prev => {
|
||||
// ... (existing update logic) ...
|
||||
if (!isMounted.current) return prev; // Extra guard inside setter
|
||||
|
||||
const getRank = (id: string) => addonOrderRankRef.current[id] ?? Number.MAX_SAFE_INTEGER;
|
||||
// ... (same logic as before) ...
|
||||
const existingIndex = prev.byAddon.findIndex(s => s.addonId === section.addonId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing section in-place (preserve order and other sections)
|
||||
const copy = prev.byAddon.slice();
|
||||
copy[existingIndex] = section;
|
||||
return { byAddon: copy, allResults: prev.allResults };
|
||||
}
|
||||
|
||||
// Insert new section at correct position based on rank
|
||||
// Insert new section
|
||||
const insertRank = getRank(section.addonId);
|
||||
let insertAt = prev.byAddon.length;
|
||||
for (let i = 0; i < prev.byAddon.length; i++) {
|
||||
|
|
@ -442,15 +493,24 @@ const SearchScreen = () => {
|
|||
return { byAddon: nextByAddon, allResults: prev.allResults };
|
||||
});
|
||||
|
||||
// Save to recents after first result batch
|
||||
try {
|
||||
await saveRecentSearch(searchQuery);
|
||||
} catch { }
|
||||
});
|
||||
liveSearchHandle.current = handle;
|
||||
}, 800);
|
||||
}, []); // Empty dependency array - create once and never recreate
|
||||
|
||||
liveSearchHandle.current = handle;
|
||||
await handle.done;
|
||||
|
||||
if (isMounted.current) {
|
||||
setSearching(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted.current) {
|
||||
console.error('Live search error:', error);
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
// Skip initial mount to prevent unnecessary operations
|
||||
if (isInitialMount.current) {
|
||||
|
|
@ -503,22 +563,20 @@ const SearchScreen = () => {
|
|||
if (!showRecent || recentSearches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<View
|
||||
style={styles.recentSearchesContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
|
||||
Recent Searches
|
||||
</Text>
|
||||
{recentSearches.map((search, index) => (
|
||||
<AnimatedTouchable
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={styles.recentSearchItem}
|
||||
onPress={() => {
|
||||
setQuery(search);
|
||||
Keyboard.dismiss();
|
||||
}}
|
||||
entering={FadeIn.duration(300).delay(index * 50)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="history"
|
||||
|
|
@ -541,9 +599,9 @@ const SearchScreen = () => {
|
|||
>
|
||||
<MaterialIcons name="close" size={16} color={currentTheme.colors.lightGray} />
|
||||
</TouchableOpacity>
|
||||
</AnimatedTouchable>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -557,6 +615,25 @@ const SearchScreen = () => {
|
|||
}) => {
|
||||
const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary);
|
||||
const [watched, setWatched] = React.useState(false);
|
||||
|
||||
// Calculate dimensions based on poster shape
|
||||
const { itemWidth, aspectRatio } = useMemo(() => {
|
||||
const shape = item.posterShape || 'poster';
|
||||
const baseHeight = HORIZONTAL_POSTER_HEIGHT;
|
||||
|
||||
let w = HORIZONTAL_ITEM_WIDTH;
|
||||
let r = 2 / 3;
|
||||
|
||||
if (shape === 'landscape') {
|
||||
r = 16 / 9;
|
||||
w = baseHeight * r;
|
||||
} else if (shape === 'square') {
|
||||
r = 1;
|
||||
w = baseHeight;
|
||||
}
|
||||
return { itemWidth: w, aspectRatio: r };
|
||||
}, [item.posterShape]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const updateWatched = () => {
|
||||
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
|
||||
|
|
@ -572,9 +649,10 @@ const SearchScreen = () => {
|
|||
});
|
||||
return () => unsubscribe();
|
||||
}, [item.id, item.type]);
|
||||
|
||||
return (
|
||||
<AnimatedTouchable
|
||||
style={styles.horizontalItem}
|
||||
<TouchableOpacity
|
||||
style={[styles.horizontalItem, { width: itemWidth }]}
|
||||
onPress={() => {
|
||||
navigation.navigate('Metadata', { id: item.id, type: item.type });
|
||||
}}
|
||||
|
|
@ -584,10 +662,14 @@ const SearchScreen = () => {
|
|||
// Do NOT toggle refreshFlag here
|
||||
}}
|
||||
delayLongPress={300}
|
||||
entering={FadeIn.duration(300).delay(index * 50)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.horizontalItemPosterContainer, {
|
||||
width: itemWidth,
|
||||
height: undefined, // Let aspect ratio control height or keep fixed height with width?
|
||||
// Actually, since we derived width from fixed height, we can keep height fixed or use aspect.
|
||||
// Using aspect ratio is safer if baseHeight changes.
|
||||
aspectRatio: aspectRatio,
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
borderColor: 'rgba(255,255,255,0.05)'
|
||||
}]}>
|
||||
|
|
@ -634,7 +716,7 @@ const SearchScreen = () => {
|
|||
{item.year}
|
||||
</Text>
|
||||
)}
|
||||
</AnimatedTouchable>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -664,7 +746,7 @@ const SearchScreen = () => {
|
|||
);
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeIn.duration(300).delay(addonIndex * 50)}>
|
||||
<View>
|
||||
{/* Addon Header */}
|
||||
<View style={styles.addonHeaderContainer}>
|
||||
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
|
||||
|
|
@ -679,7 +761,7 @@ const SearchScreen = () => {
|
|||
|
||||
{/* Movies */}
|
||||
{movieResults.length > 0 && (
|
||||
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
|
||||
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
|
||||
<Text style={[
|
||||
styles.carouselSubtitle,
|
||||
{
|
||||
|
|
@ -708,12 +790,12 @@ const SearchScreen = () => {
|
|||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* TV Shows */}
|
||||
{seriesResults.length > 0 && (
|
||||
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
|
||||
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
|
||||
<Text style={[
|
||||
styles.carouselSubtitle,
|
||||
{
|
||||
|
|
@ -742,12 +824,12 @@ const SearchScreen = () => {
|
|||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Other types */}
|
||||
{otherResults.length > 0 && (
|
||||
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
|
||||
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
|
||||
<Text style={[
|
||||
styles.carouselSubtitle,
|
||||
{
|
||||
|
|
@ -776,9 +858,9 @@ const SearchScreen = () => {
|
|||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
// Only re-render if this section's reference changed
|
||||
|
|
@ -804,13 +886,8 @@ const SearchScreen = () => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<View
|
||||
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
|
||||
entering={Platform.OS === 'android' ? undefined : FadeIn.duration(350)}
|
||||
exiting={Platform.OS === 'android' ?
|
||||
FadeOut.duration(200).withInitialValues({ opacity: 1 }) :
|
||||
FadeOut.duration(250)
|
||||
}
|
||||
>
|
||||
<StatusBar
|
||||
barStyle="light-content"
|
||||
|
|
@ -884,9 +961,8 @@ const SearchScreen = () => {
|
|||
/>
|
||||
</View>
|
||||
) : query.trim().length === 1 ? (
|
||||
<Animated.View
|
||||
<View
|
||||
style={styles.emptyContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="search"
|
||||
|
|
@ -899,11 +975,10 @@ const SearchScreen = () => {
|
|||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
||||
Type at least 2 characters to search
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
) : searched && !hasResultsToShow ? (
|
||||
<Animated.View
|
||||
<View
|
||||
style={styles.emptyContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="search-off"
|
||||
|
|
@ -916,14 +991,13 @@ const SearchScreen = () => {
|
|||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
||||
Try different keywords or check your spelling
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
) : (
|
||||
<Animated.ScrollView
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollViewContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScrollBeginDrag={Keyboard.dismiss}
|
||||
entering={FadeIn.duration(300)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{!query.trim() && renderRecentSearches()}
|
||||
|
|
@ -935,7 +1009,7 @@ const SearchScreen = () => {
|
|||
addonIndex={addonIndex}
|
||||
/>
|
||||
))}
|
||||
</Animated.ScrollView>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
{/* DropUpMenu integration for search results */}
|
||||
|
|
@ -981,7 +1055,7 @@ const SearchScreen = () => {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -410,9 +410,9 @@ export const StreamsScreen = () => {
|
|||
isLoadingStreamsRef.current = true;
|
||||
|
||||
try {
|
||||
// Check for Stremio addons
|
||||
const hasStremioProviders = await stremioService.hasStreamProviders();
|
||||
if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders);
|
||||
// Check for Stremio addons that support this content type (including embedded streams)
|
||||
const hasStremioProviders = await stremioService.hasStreamProviders(type);
|
||||
if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders, 'for type:', type);
|
||||
|
||||
// Check for local scrapers (only if enabled in settings)
|
||||
const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers();
|
||||
|
|
@ -802,19 +802,25 @@ export const StreamsScreen = () => {
|
|||
}, [type, id, currentEpisode?.season_number, currentEpisode?.episode_number]);
|
||||
|
||||
const navigateToPlayer = useCallback(async (stream: Stream, options?: { forceVlc?: boolean; headers?: Record<string, string> }) => {
|
||||
// Filter headers for Vidrock - only send essential headers
|
||||
// Filter headers for Vidrock - only send essential headers
|
||||
// Filter headers for Vidrock - only send essential headers
|
||||
const filterHeadersForVidrock = (headers: Record<string, string> | undefined): Record<string, string> | undefined => {
|
||||
if (!headers) return undefined;
|
||||
|
||||
// Only keep essential headers for Vidrock
|
||||
const essentialHeaders: Record<string, string> = {};
|
||||
// @ts-ignore
|
||||
if (headers['User-Agent']) essentialHeaders['User-Agent'] = headers['User-Agent'];
|
||||
// @ts-ignore
|
||||
if (headers['Referer']) essentialHeaders['Referer'] = headers['Referer'];
|
||||
// @ts-ignore
|
||||
if (headers['Origin']) essentialHeaders['Origin'] = headers['Origin'];
|
||||
|
||||
return Object.keys(essentialHeaders).length > 0 ? essentialHeaders : undefined;
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const finalHeaders = filterHeadersForVidrock(options?.headers || stream.headers);
|
||||
|
||||
// Add logging here
|
||||
|
|
@ -883,8 +889,9 @@ export const StreamsScreen = () => {
|
|||
// Simple platform check - iOS uses KSPlayerCore, Android uses AndroidVideoPlayer
|
||||
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
|
||||
|
||||
// @ts-ignore
|
||||
navigation.navigate(playerRoute as any, {
|
||||
uri: stream.url,
|
||||
uri: stream.url as any,
|
||||
title: metadata?.name || '',
|
||||
episodeTitle: (type === 'series' || type === 'other') ? currentEpisode?.name : undefined,
|
||||
season: (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined,
|
||||
|
|
@ -1040,6 +1047,9 @@ export const StreamsScreen = () => {
|
|||
if (index >= externalPlayerUrls.length) {
|
||||
if (__DEV__) console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`);
|
||||
// Try direct URL as last resort
|
||||
if (__DEV__) console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`);
|
||||
// Try direct URL as last resort
|
||||
// @ts-ignore
|
||||
Linking.openURL(stream.url)
|
||||
.then(() => { if (__DEV__) console.log('Opened with direct URL'); })
|
||||
.catch(() => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import {
|
|||
StatusBar,
|
||||
Platform,
|
||||
Dimensions,
|
||||
Linking
|
||||
Linking,
|
||||
Switch
|
||||
} from 'react-native';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
|
|
@ -37,9 +38,9 @@ interface SettingsCardProps {
|
|||
|
||||
const SettingsCard: React.FC<SettingsCardProps> = ({ children, title, isTablet = false }) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
|
||||
return (
|
||||
<View
|
||||
<View
|
||||
style={[
|
||||
styles.cardContainer,
|
||||
isTablet && styles.tabletCardContainer
|
||||
|
|
@ -111,19 +112,84 @@ const UpdateScreen: React.FC = () => {
|
|||
const [updateProgress, setUpdateProgress] = useState<number>(0);
|
||||
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'downloading' | 'installing' | 'success' | 'error'>('idle');
|
||||
|
||||
// Update notification settings
|
||||
const [otaAlertsEnabled, setOtaAlertsEnabled] = useState(true);
|
||||
const [majorAlertsEnabled, setMajorAlertsEnabled] = useState(true);
|
||||
|
||||
// Load notification settings on mount
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const otaSetting = await mmkvStorage.getItem('@ota_updates_alerts_enabled');
|
||||
const majorSetting = await mmkvStorage.getItem('@major_updates_alerts_enabled');
|
||||
// Default to true if not set
|
||||
setOtaAlertsEnabled(otaSetting !== 'false');
|
||||
setMajorAlertsEnabled(majorSetting !== 'false');
|
||||
} catch { }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Handle toggling OTA alerts with warning
|
||||
const handleOtaAlertsToggle = async (value: boolean) => {
|
||||
if (!value) {
|
||||
openAlert(
|
||||
'Disable OTA Update Alerts?',
|
||||
'You will no longer receive automatic notifications for OTA updates.\n\n⚠️ Warning: Staying on the latest version is important for:\n• Bug fixes and stability improvements\n• New features and enhancements\n• Providing accurate feedback and crash reports\n\nYou can still manually check for updates in this screen.',
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => setAlertVisible(false) },
|
||||
{
|
||||
label: 'Disable',
|
||||
onPress: async () => {
|
||||
await mmkvStorage.setItem('@ota_updates_alerts_enabled', 'false');
|
||||
setOtaAlertsEnabled(false);
|
||||
setAlertVisible(false);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
} else {
|
||||
await mmkvStorage.setItem('@ota_updates_alerts_enabled', 'true');
|
||||
setOtaAlertsEnabled(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle toggling Major update alerts with warning
|
||||
const handleMajorAlertsToggle = async (value: boolean) => {
|
||||
if (!value) {
|
||||
openAlert(
|
||||
'Disable Major Update Alerts?',
|
||||
'You will no longer receive notifications for major app updates that require reinstallation.\n\n⚠️ Warning: Major updates often include:\n• Critical security patches\n• Breaking changes that require app reinstall\n• Important compatibility fixes\n\nYou can still check for updates manually.',
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => setAlertVisible(false) },
|
||||
{
|
||||
label: 'Disable',
|
||||
onPress: async () => {
|
||||
await mmkvStorage.setItem('@major_updates_alerts_enabled', 'false');
|
||||
setMajorAlertsEnabled(false);
|
||||
setAlertVisible(false);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
} else {
|
||||
await mmkvStorage.setItem('@major_updates_alerts_enabled', 'true');
|
||||
setMajorAlertsEnabled(true);
|
||||
}
|
||||
};
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
setIsChecking(true);
|
||||
setUpdateStatus('checking');
|
||||
setUpdateProgress(0);
|
||||
setLastOperation('Checking for updates...');
|
||||
|
||||
|
||||
const info = await UpdateService.checkForUpdates();
|
||||
setUpdateInfo(info);
|
||||
setLastChecked(new Date());
|
||||
|
||||
|
||||
// Logs disabled
|
||||
|
||||
|
||||
if (info.isAvailable) {
|
||||
setUpdateStatus('available');
|
||||
setLastOperation(`Update available: ${info.manifest?.id || 'unknown'}`);
|
||||
|
|
@ -135,7 +201,7 @@ const UpdateScreen: React.FC = () => {
|
|||
if (__DEV__) console.error('Error checking for updates:', error);
|
||||
setUpdateStatus('error');
|
||||
setLastOperation(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
openAlert('Error', 'Failed to check for updates');
|
||||
openAlert('Error', 'Failed to check for updates');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
|
|
@ -146,12 +212,12 @@ const UpdateScreen: React.FC = () => {
|
|||
if (Platform.OS === 'android') {
|
||||
// ensure badge clears when entering this screen
|
||||
(async () => {
|
||||
try { await mmkvStorage.removeItem('@update_badge_pending'); } catch {}
|
||||
try { await mmkvStorage.removeItem('@update_badge_pending'); } catch { }
|
||||
})();
|
||||
}
|
||||
checkForUpdates();
|
||||
// Also refresh GitHub section on mount (works in dev and prod)
|
||||
try { github.refresh(); } catch {}
|
||||
try { github.refresh(); } catch { }
|
||||
if (Platform.OS === 'android') {
|
||||
showInfo('Checking for Updates', 'Checking for updates…');
|
||||
}
|
||||
|
|
@ -163,7 +229,7 @@ const UpdateScreen: React.FC = () => {
|
|||
setUpdateStatus('downloading');
|
||||
setUpdateProgress(0);
|
||||
setLastOperation('Downloading update...');
|
||||
|
||||
|
||||
// Simulate progress updates
|
||||
const progressInterval = setInterval(() => {
|
||||
setUpdateProgress(prev => {
|
||||
|
|
@ -171,30 +237,30 @@ const UpdateScreen: React.FC = () => {
|
|||
return prev + Math.random() * 10;
|
||||
});
|
||||
}, 500);
|
||||
|
||||
|
||||
const success = await UpdateService.downloadAndInstallUpdate();
|
||||
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUpdateProgress(100);
|
||||
setUpdateStatus('installing');
|
||||
setLastOperation('Installing update...');
|
||||
|
||||
|
||||
// Logs disabled
|
||||
|
||||
|
||||
if (success) {
|
||||
setUpdateStatus('success');
|
||||
setLastOperation('Update installed successfully');
|
||||
openAlert('Success', 'Update will be applied on next app restart');
|
||||
openAlert('Success', 'Update will be applied on next app restart');
|
||||
} else {
|
||||
setUpdateStatus('error');
|
||||
setLastOperation('No update available to install');
|
||||
openAlert('No Update', 'No update available to install');
|
||||
openAlert('No Update', 'No update available to install');
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error installing update:', error);
|
||||
setUpdateStatus('error');
|
||||
setLastOperation(`Installation error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
openAlert('Error', 'Failed to install update');
|
||||
openAlert('Error', 'Failed to install update');
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
|
|
@ -236,7 +302,7 @@ const UpdateScreen: React.FC = () => {
|
|||
try {
|
||||
setLastOperation('Testing connectivity...');
|
||||
const isReachable = await UpdateService.testUpdateConnectivity();
|
||||
|
||||
|
||||
if (isReachable) {
|
||||
setLastOperation('Update server is reachable');
|
||||
} else {
|
||||
|
|
@ -334,7 +400,7 @@ const UpdateScreen: React.FC = () => {
|
|||
{ backgroundColor: currentTheme.colors.darkBackground }
|
||||
]}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
|
|
@ -346,316 +412,367 @@ const UpdateScreen: React.FC = () => {
|
|||
Settings
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
||||
<View style={styles.headerActions}>
|
||||
{/* Empty for now, but ready for future actions */}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
|
||||
App Updates
|
||||
</Text>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<SettingsCard title="APP UPDATES" isTablet={isTablet}>
|
||||
{/* Main Update Card */}
|
||||
<View style={styles.updateMainCard}>
|
||||
{/* Status Section */}
|
||||
<View style={styles.updateStatusSection}>
|
||||
<View style={[styles.statusIndicator, { backgroundColor: `${getStatusColor()}20` }]}>
|
||||
{getStatusIcon()}
|
||||
</View>
|
||||
<View style={styles.statusContent}>
|
||||
<Text style={[styles.statusMainText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{getStatusText()}
|
||||
</Text>
|
||||
<Text style={[styles.statusDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{lastOperation || 'Ready to check for updates'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.contentContainer}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<SettingsCard title="APP UPDATES" isTablet={isTablet}>
|
||||
{/* Main Update Card */}
|
||||
<View style={styles.updateMainCard}>
|
||||
{/* Status Section */}
|
||||
<View style={styles.updateStatusSection}>
|
||||
<View style={[styles.statusIndicator, { backgroundColor: `${getStatusColor()}20` }]}>
|
||||
{getStatusIcon()}
|
||||
</View>
|
||||
|
||||
{/* Progress Section */}
|
||||
{(updateStatus === 'downloading' || updateStatus === 'installing') && (
|
||||
<View style={styles.progressSection}>
|
||||
<View style={styles.progressHeader}>
|
||||
<Text style={[styles.progressLabel, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{updateStatus === 'downloading' ? 'Downloading' : 'Installing'}
|
||||
</Text>
|
||||
<Text style={[styles.progressPercentage, { color: currentTheme.colors.primary }]}>
|
||||
{Math.round(updateProgress)}%
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.modernProgressBar, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.modernProgressFill,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
width: `${updateProgress}%`
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Action Section */}
|
||||
<View style={styles.actionSection}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modernButton,
|
||||
styles.primaryAction,
|
||||
{ backgroundColor: currentTheme.colors.primary },
|
||||
(isChecking || isInstalling) && styles.disabledAction
|
||||
]}
|
||||
onPress={checkForUpdates}
|
||||
disabled={isChecking || isInstalling}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isChecking ? (
|
||||
<MaterialIcons name="refresh" size={18} color="white" />
|
||||
) : (
|
||||
<MaterialIcons name="system-update" size={18} color="white" />
|
||||
)}
|
||||
<Text style={styles.modernButtonText}>
|
||||
{isChecking ? 'Checking...' : 'Check for Updates'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{updateInfo?.isAvailable && updateStatus !== 'success' && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modernButton,
|
||||
styles.installAction,
|
||||
{ backgroundColor: currentTheme.colors.success || '#34C759' },
|
||||
(isInstalling) && styles.disabledAction
|
||||
]}
|
||||
onPress={installUpdate}
|
||||
disabled={isInstalling}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<MaterialIcons name="install-mobile" size={18} color="white" />
|
||||
) : (
|
||||
<MaterialIcons name="download" size={18} color="white" />
|
||||
)}
|
||||
<Text style={styles.modernButtonText}>
|
||||
{isInstalling ? 'Installing...' : 'Install Update'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<View style={styles.statusContent}>
|
||||
<Text style={[styles.statusMainText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{getStatusText()}
|
||||
</Text>
|
||||
<Text style={[styles.statusDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{lastOperation || 'Ready to check for updates'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Release Notes */}
|
||||
{updateInfo?.isAvailable && !!getReleaseNotes() && (
|
||||
<View style={styles.infoSection}>
|
||||
<View style={styles.infoItem}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Release notes:</Text>
|
||||
{/* Progress Section */}
|
||||
{(updateStatus === 'downloading' || updateStatus === 'installing') && (
|
||||
<View style={styles.progressSection}>
|
||||
<View style={styles.progressHeader}>
|
||||
<Text style={[styles.progressLabel, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{updateStatus === 'downloading' ? 'Downloading' : 'Installing'}
|
||||
</Text>
|
||||
<Text style={[styles.progressPercentage, { color: currentTheme.colors.primary }]}>
|
||||
{Math.round(updateProgress)}%
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.modernProgressBar, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.modernProgressFill,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
width: `${updateProgress}%`
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>{getReleaseNotes()}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Info Section */}
|
||||
<View style={styles.infoSection}>
|
||||
<View style={styles.infoItem}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="info-outline" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Version:</Text>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{updateInfo?.manifest?.id ? `${updateInfo.manifest.id.substring(0, 8)}...` : 'Unknown'}
|
||||
{/* Action Section */}
|
||||
<View style={styles.actionSection}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modernButton,
|
||||
styles.primaryAction,
|
||||
{ backgroundColor: currentTheme.colors.primary },
|
||||
(isChecking || isInstalling) && styles.disabledAction
|
||||
]}
|
||||
onPress={checkForUpdates}
|
||||
disabled={isChecking || isInstalling}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isChecking ? (
|
||||
<MaterialIcons name="refresh" size={18} color="white" />
|
||||
) : (
|
||||
<MaterialIcons name="system-update" size={18} color="white" />
|
||||
)}
|
||||
<Text style={styles.modernButtonText}>
|
||||
{isChecking ? 'Checking...' : 'Check for Updates'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{lastChecked && (
|
||||
<View style={styles.infoItem}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="schedule" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Last checked:</Text>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{formatDate(lastChecked)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Current Version Section */}
|
||||
<View style={styles.infoSection}>
|
||||
<View style={styles.infoItem}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="verified" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current version:</Text>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}
|
||||
selectable>
|
||||
{currentInfo?.manifest?.id || (currentInfo?.isEmbeddedLaunch === false ? 'Unknown' : 'Embedded')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!!getCurrentReleaseNotes() && (
|
||||
<View style={{ marginTop: 8 }}>
|
||||
<View style={[styles.infoItem, { alignItems: 'flex-start' }]}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current release notes:</Text>
|
||||
</View>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{getCurrentReleaseNotes()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Developer Logs removed */}
|
||||
</SettingsCard>
|
||||
|
||||
{/* GitHub Release (compact) – only show when update is available */}
|
||||
{github.latestTag && isAnyUpgrade(getDisplayedAppVersion(), github.latestTag) ? (
|
||||
<SettingsCard title="GITHUB RELEASE" isTablet={isTablet}>
|
||||
<View style={styles.infoSection}>
|
||||
<View style={styles.infoItem}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="new-releases" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current:</Text>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{getDisplayedAppVersion()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoItem}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="tag" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Latest:</Text>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{github.latestTag}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{github.releaseNotes ? (
|
||||
<View style={{ marginTop: 4 }}>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Notes:</Text>
|
||||
<Text
|
||||
numberOfLines={3}
|
||||
style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}
|
||||
>
|
||||
{github.releaseNotes}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={[styles.actionSection, { marginTop: 8 }]}>
|
||||
<View style={{ flexDirection: 'row', gap: 10 }}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modernButton, { backgroundColor: currentTheme.colors.primary, flex: 1 }]}
|
||||
onPress={() => github.releaseUrl ? Linking.openURL(github.releaseUrl as string) : null}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialIcons name="open-in-new" size={18} color="white" />
|
||||
<Text style={styles.modernButtonText}>View Release</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
|
||||
{false && (
|
||||
<SettingsCard title="UPDATE LOGS" isTablet={isTablet}>
|
||||
<View style={styles.logsContainer}>
|
||||
<View style={styles.logsHeader}>
|
||||
<Text style={[styles.logsHeaderText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Update Service Logs
|
||||
</Text>
|
||||
<View style={styles.logsActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.logActionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={testConnectivity}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons name="wifi" size={16} color={currentTheme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.logActionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={testAssetUrls}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons name="link" size={16} color={currentTheme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
{/* Test log removed */}
|
||||
{/* Copy all logs removed */}
|
||||
{/* Refresh logs removed */}
|
||||
{/* Clear logs removed */}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={[styles.logsScrollView, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
showsVerticalScrollIndicator={true}
|
||||
nestedScrollEnabled={true}
|
||||
{updateInfo?.isAvailable && updateStatus !== 'success' && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modernButton,
|
||||
styles.installAction,
|
||||
{ backgroundColor: currentTheme.colors.success || '#34C759' },
|
||||
(isInstalling) && styles.disabledAction
|
||||
]}
|
||||
onPress={installUpdate}
|
||||
disabled={isInstalling}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{false ? (
|
||||
<Text style={[styles.noLogsText, { color: currentTheme.colors.mediumEmphasis }]}>No logs available</Text>
|
||||
{isInstalling ? (
|
||||
<MaterialIcons name="install-mobile" size={18} color="white" />
|
||||
) : (
|
||||
([] as string[]).map((log, index) => {
|
||||
const isError = log.indexOf('[ERROR]') !== -1;
|
||||
const isWarning = log.indexOf('[WARN]') !== -1;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.logEntry,
|
||||
{ backgroundColor: 'rgba(255,255,255,0.05)' }
|
||||
]}
|
||||
onPress={() => {}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.logEntryContent}>
|
||||
<Text style={[
|
||||
styles.logText,
|
||||
{
|
||||
color: isError
|
||||
? (currentTheme.colors.error || '#ff4444')
|
||||
: isWarning
|
||||
? (currentTheme.colors.warning || '#ffaa00')
|
||||
: currentTheme.colors.mediumEmphasis
|
||||
}
|
||||
]}>
|
||||
{log}
|
||||
</Text>
|
||||
<MaterialIcons
|
||||
name="content-copy"
|
||||
size={14}
|
||||
color={currentTheme.colors.mediumEmphasis}
|
||||
style={styles.logCopyIcon}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})
|
||||
<MaterialIcons name="download" size={18} color="white" />
|
||||
)}
|
||||
</ScrollView>
|
||||
<Text style={styles.modernButtonText}>
|
||||
{isInstalling ? 'Installing...' : 'Install Update'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Release Notes */}
|
||||
{updateInfo?.isAvailable && !!getReleaseNotes() && (
|
||||
<View style={styles.infoSection}>
|
||||
<View style={styles.infoItem}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Release notes:</Text>
|
||||
</View>
|
||||
</SettingsCard>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>{getReleaseNotes()}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Info Section */}
|
||||
<View style={styles.infoSection}>
|
||||
<View style={styles.infoItem}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="info-outline" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Version:</Text>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{updateInfo?.manifest?.id ? `${updateInfo.manifest.id.substring(0, 8)}...` : 'Unknown'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{lastChecked && (
|
||||
<View style={styles.infoItem}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="schedule" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Last checked:</Text>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{formatDate(lastChecked)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Current Version Section */}
|
||||
<View style={styles.infoSection}>
|
||||
<View style={styles.infoItem}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="verified" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current version:</Text>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}
|
||||
selectable>
|
||||
{currentInfo?.manifest?.id || (currentInfo?.isEmbeddedLaunch === false ? 'Unknown' : 'Embedded')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!!getCurrentReleaseNotes() && (
|
||||
<View style={{ marginTop: 8 }}>
|
||||
<View style={[styles.infoItem, { alignItems: 'flex-start' }]}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current release notes:</Text>
|
||||
</View>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{getCurrentReleaseNotes()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Developer Logs removed */}
|
||||
</SettingsCard>
|
||||
|
||||
{/* GitHub Release (compact) – only show when update is available */}
|
||||
{github.latestTag && isAnyUpgrade(getDisplayedAppVersion(), github.latestTag) ? (
|
||||
<SettingsCard title="GITHUB RELEASE" isTablet={isTablet}>
|
||||
<View style={styles.infoSection}>
|
||||
<View style={styles.infoItem}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="new-releases" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current:</Text>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{getDisplayedAppVersion()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoItem}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
|
||||
<MaterialIcons name="tag" size={14} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Latest:</Text>
|
||||
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{github.latestTag}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{github.releaseNotes ? (
|
||||
<View style={{ marginTop: 4 }}>
|
||||
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Notes:</Text>
|
||||
<Text
|
||||
numberOfLines={3}
|
||||
style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}
|
||||
>
|
||||
{github.releaseNotes}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={[styles.actionSection, { marginTop: 8 }]}>
|
||||
<View style={{ flexDirection: 'row', gap: 10 }}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modernButton, { backgroundColor: currentTheme.colors.primary, flex: 1 }]}
|
||||
onPress={() => github.releaseUrl ? Linking.openURL(github.releaseUrl as string) : null}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialIcons name="open-in-new" size={18} color="white" />
|
||||
<Text style={styles.modernButtonText}>View Release</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
|
||||
{/* Update Notification Settings */}
|
||||
<SettingsCard title="NOTIFICATION SETTINGS" isTablet={isTablet}>
|
||||
{/* OTA Updates Toggle */}
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingInfo}>
|
||||
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
|
||||
OTA Update Alerts
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Show notifications for over-the-air updates
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={otaAlertsEnabled}
|
||||
onValueChange={handleOtaAlertsToggle}
|
||||
trackColor={{ false: '#505050', true: currentTheme.colors.primary }}
|
||||
thumbColor={Platform.OS === 'android' ? '#fff' : undefined}
|
||||
ios_backgroundColor="#505050"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Major Updates Toggle */}
|
||||
<View style={[styles.settingRow, { borderBottomWidth: 0 }]}>
|
||||
<View style={styles.settingInfo}>
|
||||
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Major Update Alerts
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Show notifications for new app versions on GitHub
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={majorAlertsEnabled}
|
||||
onValueChange={handleMajorAlertsToggle}
|
||||
trackColor={{ false: '#505050', true: currentTheme.colors.primary }}
|
||||
thumbColor={Platform.OS === 'android' ? '#fff' : undefined}
|
||||
ios_backgroundColor="#505050"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Warning note */}
|
||||
<View style={[styles.infoItem, { paddingHorizontal: 16, paddingBottom: 12 }]}>
|
||||
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.warning || '#FFA500'}20` }]}>
|
||||
<MaterialIcons name="info-outline" size={14} color={currentTheme.colors.warning || '#FFA500'} />
|
||||
</View>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis, flex: 1 }]}>
|
||||
Keeping alerts enabled ensures you receive bug fixes and can provide accurate crash reports.
|
||||
</Text>
|
||||
</View>
|
||||
</SettingsCard>
|
||||
|
||||
{false && (
|
||||
<SettingsCard title="UPDATE LOGS" isTablet={isTablet}>
|
||||
<View style={styles.logsContainer}>
|
||||
<View style={styles.logsHeader}>
|
||||
<Text style={[styles.logsHeaderText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Update Service Logs
|
||||
</Text>
|
||||
<View style={styles.logsActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.logActionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={testConnectivity}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons name="wifi" size={16} color={currentTheme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.logActionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={testAssetUrls}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons name="link" size={16} color={currentTheme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
{/* Test log removed */}
|
||||
{/* Copy all logs removed */}
|
||||
{/* Refresh logs removed */}
|
||||
{/* Clear logs removed */}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={[styles.logsScrollView, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
showsVerticalScrollIndicator={true}
|
||||
nestedScrollEnabled={true}
|
||||
>
|
||||
{false ? (
|
||||
<Text style={[styles.noLogsText, { color: currentTheme.colors.mediumEmphasis }]}>No logs available</Text>
|
||||
) : (
|
||||
([] as string[]).map((log, index) => {
|
||||
const isError = log.indexOf('[ERROR]') !== -1;
|
||||
const isWarning = log.indexOf('[WARN]') !== -1;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.logEntry,
|
||||
{ backgroundColor: 'rgba(255,255,255,0.05)' }
|
||||
]}
|
||||
onPress={() => { }}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.logEntryContent}>
|
||||
<Text style={[
|
||||
styles.logText,
|
||||
{
|
||||
color: isError
|
||||
? (currentTheme.colors.error || '#ff4444')
|
||||
: isWarning
|
||||
? (currentTheme.colors.warning || '#ffaa00')
|
||||
: currentTheme.colors.mediumEmphasis
|
||||
}
|
||||
]}>
|
||||
{log}
|
||||
</Text>
|
||||
<MaterialIcons
|
||||
name="content-copy"
|
||||
size={14}
|
||||
color={currentTheme.colors.mediumEmphasis}
|
||||
style={styles.logCopyIcon}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
|
|
@ -715,7 +832,7 @@ const styles = StyleSheet.create({
|
|||
width: '100%',
|
||||
paddingBottom: 90,
|
||||
},
|
||||
|
||||
|
||||
// Common card styles
|
||||
cardContainer: {
|
||||
width: '100%',
|
||||
|
|
@ -754,7 +871,7 @@ const styles = StyleSheet.create({
|
|||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
|
||||
|
||||
// Update UI Styles
|
||||
updateMainCard: {
|
||||
padding: 20,
|
||||
|
|
@ -810,9 +927,9 @@ const styles = StyleSheet.create({
|
|||
overflow: 'hidden',
|
||||
},
|
||||
modernProgressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
},
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
},
|
||||
actionSection: {
|
||||
gap: 12,
|
||||
},
|
||||
|
|
@ -905,7 +1022,7 @@ const styles = StyleSheet.create({
|
|||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
|
||||
// Logs styles
|
||||
logsContainer: {
|
||||
padding: 20,
|
||||
|
|
@ -962,6 +1079,30 @@ const styles = StyleSheet.create({
|
|||
textAlign: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
|
||||
// Settings toggle styles
|
||||
settingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
settingInfo: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
settingLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
||||
export default UpdateScreen;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export interface StreamingContent {
|
|||
name: string;
|
||||
tmdbId?: number;
|
||||
poster: string;
|
||||
posterShape?: string;
|
||||
posterShape?: 'poster' | 'square' | 'landscape';
|
||||
banner?: string;
|
||||
logo?: string;
|
||||
imdbRating?: string;
|
||||
|
|
@ -323,7 +323,7 @@ class CatalogService {
|
|||
};
|
||||
}
|
||||
|
||||
async getHomeCatalogs(limitIds?: string[]): Promise<CatalogContent[]> {
|
||||
async resolveHomeCatalogsToFetch(limitIds?: string[]): Promise<{ addon: StreamingAddon; catalog: any }[]> {
|
||||
const addons = await this.getAllAddons();
|
||||
|
||||
// Load enabled/disabled settings
|
||||
|
|
@ -360,59 +360,70 @@ class CatalogService {
|
|||
catalogsToFetch = potentialCatalogs.sort(() => 0.5 - Math.random()).slice(0, 5);
|
||||
}
|
||||
|
||||
// Create promises for the selected catalogs
|
||||
const catalogPromises = catalogsToFetch.map(async ({ addon, catalog }) => {
|
||||
try {
|
||||
// Hoist manifest list retrieval and find once
|
||||
const addonManifests = await stremioService.getInstalledAddonsAsync();
|
||||
const manifest = addonManifests.find(a => a.id === addon.id);
|
||||
if (!manifest) return null;
|
||||
return catalogsToFetch;
|
||||
}
|
||||
|
||||
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
|
||||
if (metas && metas.length > 0) {
|
||||
// Cap items per catalog to reduce memory and rendering load
|
||||
const limited = metas.slice(0, 12);
|
||||
const items = limited.map(meta => this.convertMetaToStreamingContent(meta));
|
||||
async fetchHomeCatalog(addon: StreamingAddon, catalog: any): Promise<CatalogContent | null> {
|
||||
try {
|
||||
// Hoist manifest list retrieval and find once
|
||||
const addonManifests = await stremioService.getInstalledAddonsAsync();
|
||||
const manifest = addonManifests.find(a => a.id === addon.id);
|
||||
if (!manifest) return null;
|
||||
|
||||
// Get potentially custom display name; if customized, respect it as-is
|
||||
const originalName = catalog.name || catalog.id;
|
||||
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName);
|
||||
const isCustom = displayName !== originalName;
|
||||
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
|
||||
if (metas && metas.length > 0) {
|
||||
// Cap items per catalog to reduce memory and rendering load
|
||||
const limited = metas.slice(0, 12);
|
||||
const items = limited.map(meta => this.convertMetaToStreamingContent(meta));
|
||||
|
||||
if (!isCustom) {
|
||||
// Remove duplicate words and clean up the name (case-insensitive)
|
||||
const words = displayName.split(' ');
|
||||
const uniqueWords: string[] = [];
|
||||
const seenWords = new Set<string>();
|
||||
for (const word of words) {
|
||||
const lowerWord = word.toLowerCase();
|
||||
if (!seenWords.has(lowerWord)) {
|
||||
uniqueWords.push(word);
|
||||
seenWords.add(lowerWord);
|
||||
}
|
||||
}
|
||||
displayName = uniqueWords.join(' ');
|
||||
// Get potentially custom display name; if customized, respect it as-is
|
||||
const originalName = catalog.name || catalog.id;
|
||||
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName);
|
||||
const isCustom = displayName !== originalName;
|
||||
|
||||
// Add content type if not present
|
||||
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
|
||||
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
|
||||
displayName = `${displayName} ${contentType}`;
|
||||
if (!isCustom) {
|
||||
// Remove duplicate words and clean up the name (case-insensitive)
|
||||
const words = displayName.split(' ');
|
||||
const uniqueWords: string[] = [];
|
||||
const seenWords = new Set<string>();
|
||||
for (const word of words) {
|
||||
const lowerWord = word.toLowerCase();
|
||||
if (!seenWords.has(lowerWord)) {
|
||||
uniqueWords.push(word);
|
||||
seenWords.add(lowerWord);
|
||||
}
|
||||
}
|
||||
displayName = uniqueWords.join(' ');
|
||||
|
||||
return {
|
||||
addon: addon.id,
|
||||
type: catalog.type,
|
||||
id: catalog.id,
|
||||
name: displayName,
|
||||
items
|
||||
};
|
||||
// Add content type if not present
|
||||
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
|
||||
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
|
||||
displayName = `${displayName} ${contentType}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error);
|
||||
return null;
|
||||
|
||||
return {
|
||||
addon: addon.id,
|
||||
type: catalog.type,
|
||||
id: catalog.id,
|
||||
name: displayName,
|
||||
items
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getHomeCatalogs(limitIds?: string[]): Promise<CatalogContent[]> {
|
||||
// Determine which catalogs to actually fetch
|
||||
const catalogsToFetch = await this.resolveHomeCatalogsToFetch(limitIds);
|
||||
|
||||
// Create promises for the selected catalogs
|
||||
const catalogPromises = catalogsToFetch.map(async ({ addon, catalog }) => {
|
||||
return this.fetchHomeCatalog(addon, catalog);
|
||||
});
|
||||
|
||||
// Wait for all selected catalog fetch promises to resolve in parallel
|
||||
|
|
@ -824,7 +835,7 @@ class CatalogService {
|
|||
type: meta.type,
|
||||
name: meta.name,
|
||||
poster: posterUrl,
|
||||
posterShape: 'poster',
|
||||
posterShape: meta.posterShape || 'poster', // Use addon's shape or default to poster type
|
||||
banner: meta.background,
|
||||
logo: logoUrl,
|
||||
imdbRating: meta.imdbRating,
|
||||
|
|
@ -846,7 +857,7 @@ class CatalogService {
|
|||
type: meta.type,
|
||||
name: meta.name,
|
||||
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
|
||||
posterShape: 'poster',
|
||||
posterShape: meta.posterShape || 'poster',
|
||||
banner: meta.background,
|
||||
// Use addon's logo if available, otherwise undefined
|
||||
logo: (meta as any).logo || undefined,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -52,6 +52,8 @@ export interface TraktWatchedItem {
|
|||
};
|
||||
plays: number;
|
||||
last_watched_at: string;
|
||||
last_updated_at?: string; // Timestamp for syncing - only re-process if newer
|
||||
reset_at?: string | null; // When user started re-watching - ignore episodes watched before this
|
||||
seasons?: {
|
||||
number: number;
|
||||
episodes: {
|
||||
|
|
@ -251,11 +253,25 @@ export interface TraktScrobbleResponse {
|
|||
alreadyScrobbled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content data for Trakt scrobbling.
|
||||
*
|
||||
* Required fields:
|
||||
* - type: 'movie' or 'episode'
|
||||
* - imdbId: A valid IMDb ID (with or without 'tt' prefix)
|
||||
* - title: Non-empty content title
|
||||
*
|
||||
* Optional fields:
|
||||
* - year: Release year (must be valid if provided, e.g., 1800-current year+10)
|
||||
* - season/episode: Required for episode type
|
||||
* - showTitle/showYear/showImdbId: Show metadata for episodes
|
||||
*/
|
||||
export interface TraktContentData {
|
||||
type: 'movie' | 'episode';
|
||||
imdbId: string;
|
||||
title: string;
|
||||
year: number;
|
||||
/** Release year - optional as Trakt can often resolve content via IMDb ID alone */
|
||||
year?: number;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
showTitle?: string;
|
||||
|
|
@ -623,14 +639,14 @@ export class TraktService {
|
|||
/**
|
||||
* Get the current completion threshold (user-configured or default)
|
||||
*/
|
||||
private get completionThreshold(): number {
|
||||
public get completionThreshold(): number {
|
||||
return this._completionThreshold || this.DEFAULT_COMPLETION_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the completion threshold
|
||||
*/
|
||||
private set completionThreshold(value: number) {
|
||||
public set completionThreshold(value: number) {
|
||||
this._completionThreshold = value;
|
||||
}
|
||||
|
||||
|
|
@ -1312,11 +1328,15 @@ export class TraktService {
|
|||
try {
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||
if (!traktId) {
|
||||
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.log(`[TraktService] Marking S${season}E${episode} as watched for show ${imdbId} (trakt: ${traktId})`);
|
||||
|
||||
// Use shows array with seasons/episodes structure per Trakt API docs
|
||||
await this.apiRequest('/sync/history', 'POST', {
|
||||
episodes: [
|
||||
shows: [
|
||||
{
|
||||
ids: {
|
||||
trakt: traktId
|
||||
|
|
@ -1335,6 +1355,7 @@ export class TraktService {
|
|||
}
|
||||
]
|
||||
});
|
||||
logger.log(`[TraktService] Successfully marked S${season}E${episode} as watched`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to mark episode as watched:', error);
|
||||
|
|
@ -1342,6 +1363,194 @@ export class TraktService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an entire season as watched on Trakt
|
||||
* @param imdbId - The IMDb ID of the show
|
||||
* @param season - The season number to mark as watched
|
||||
* @param watchedAt - Optional date when watched (defaults to now)
|
||||
*/
|
||||
public async markSeasonAsWatched(
|
||||
imdbId: string,
|
||||
season: number,
|
||||
watchedAt: Date = new Date()
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||
if (!traktId) {
|
||||
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.log(`[TraktService] Marking entire season ${season} as watched for show ${imdbId} (trakt: ${traktId})`);
|
||||
|
||||
// Mark entire season - Trakt will mark all episodes in the season
|
||||
await this.apiRequest('/sync/history', 'POST', {
|
||||
shows: [
|
||||
{
|
||||
ids: {
|
||||
trakt: traktId
|
||||
},
|
||||
seasons: [
|
||||
{
|
||||
number: season,
|
||||
watched_at: watchedAt.toISOString()
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
logger.log(`[TraktService] Successfully marked season ${season} as watched`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to mark season as watched:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark multiple episodes as watched on Trakt (batch operation)
|
||||
* @param imdbId - The IMDb ID of the show
|
||||
* @param episodes - Array of episodes to mark as watched
|
||||
* @param watchedAt - Optional date when watched (defaults to now)
|
||||
*/
|
||||
public async markEpisodesAsWatched(
|
||||
imdbId: string,
|
||||
episodes: Array<{ season: number; episode: number }>,
|
||||
watchedAt: Date = new Date()
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (episodes.length === 0) {
|
||||
logger.warn('[TraktService] No episodes provided to mark as watched');
|
||||
return false;
|
||||
}
|
||||
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||
if (!traktId) {
|
||||
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.log(`[TraktService] Marking ${episodes.length} episodes as watched for show ${imdbId}`);
|
||||
|
||||
// Group episodes by season for the API call
|
||||
const seasonMap = new Map<number, Array<{ number: number; watched_at: string }>>();
|
||||
for (const ep of episodes) {
|
||||
if (!seasonMap.has(ep.season)) {
|
||||
seasonMap.set(ep.season, []);
|
||||
}
|
||||
seasonMap.get(ep.season)!.push({
|
||||
number: ep.episode,
|
||||
watched_at: watchedAt.toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
const seasons = Array.from(seasonMap.entries()).map(([seasonNum, eps]) => ({
|
||||
number: seasonNum,
|
||||
episodes: eps
|
||||
}));
|
||||
|
||||
await this.apiRequest('/sync/history', 'POST', {
|
||||
shows: [
|
||||
{
|
||||
ids: {
|
||||
trakt: traktId
|
||||
},
|
||||
seasons
|
||||
}
|
||||
]
|
||||
});
|
||||
logger.log(`[TraktService] Successfully marked ${episodes.length} episodes as watched`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to mark episodes as watched:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark entire show as watched on Trakt (all seasons and episodes)
|
||||
* @param imdbId - The IMDb ID of the show
|
||||
* @param watchedAt - Optional date when watched (defaults to now)
|
||||
*/
|
||||
public async markShowAsWatched(
|
||||
imdbId: string,
|
||||
watchedAt: Date = new Date()
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||
if (!traktId) {
|
||||
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.log(`[TraktService] Marking entire show as watched: ${imdbId} (trakt: ${traktId})`);
|
||||
|
||||
// Mark entire show - Trakt will mark all episodes
|
||||
await this.apiRequest('/sync/history', 'POST', {
|
||||
shows: [
|
||||
{
|
||||
ids: {
|
||||
trakt: traktId
|
||||
},
|
||||
watched_at: watchedAt.toISOString()
|
||||
}
|
||||
]
|
||||
});
|
||||
logger.log(`[TraktService] Successfully marked entire show as watched`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to mark show as watched:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an entire season from watched history on Trakt
|
||||
* @param imdbId - The IMDb ID of the show
|
||||
* @param season - The season number to remove from history
|
||||
*/
|
||||
public async removeSeasonFromHistory(
|
||||
imdbId: string,
|
||||
season: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
logger.log(`[TraktService] Removing season ${season} from history for show: ${imdbId}`);
|
||||
|
||||
const fullImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
|
||||
|
||||
const payload: TraktHistoryRemovePayload = {
|
||||
shows: [
|
||||
{
|
||||
ids: {
|
||||
imdb: fullImdbId
|
||||
},
|
||||
seasons: [
|
||||
{
|
||||
number: season
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
logger.log(`[TraktService] Sending removeSeasonFromHistory payload:`, JSON.stringify(payload, null, 2));
|
||||
|
||||
const result = await this.removeFromHistory(payload);
|
||||
|
||||
if (result) {
|
||||
const success = result.deleted.episodes > 0;
|
||||
logger.log(`[TraktService] Season removal success: ${success} (${result.deleted.episodes} episodes deleted)`);
|
||||
return success;
|
||||
}
|
||||
|
||||
logger.log(`[TraktService] No result from removeSeasonFromHistory`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to remove season from history:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a movie is in user's watched history
|
||||
*/
|
||||
|
|
@ -1430,7 +1639,14 @@ export class TraktService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Pause watching content (scrobble pause)
|
||||
* Pause watching content - saves playback progress
|
||||
*
|
||||
* NOTE: Trakt API does NOT have a /scrobble/pause endpoint.
|
||||
* Instead, /scrobble/stop handles both cases:
|
||||
* - Progress 1-79%: Treated as "pause", saves playback progress to /sync/playback
|
||||
* - Progress ≥80%: Treated as "scrobble", marks as watched
|
||||
*
|
||||
* This method uses /scrobble/stop which automatically handles the pause/scrobble logic.
|
||||
*/
|
||||
public async pauseWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
|
||||
try {
|
||||
|
|
@ -1446,7 +1662,8 @@ export class TraktService {
|
|||
return null;
|
||||
}
|
||||
|
||||
return this.apiRequest<TraktScrobbleResponse>('/scrobble/pause', 'POST', payload);
|
||||
// Use /scrobble/stop - Trakt automatically treats <80% as pause, ≥80% as scrobble
|
||||
return this.apiRequest<TraktScrobbleResponse>('/scrobble/stop', 'POST', payload);
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to pause watching:', error);
|
||||
return null;
|
||||
|
|
@ -1527,12 +1744,27 @@ export class TraktService {
|
|||
|
||||
/**
|
||||
* Build scrobble payload for API requests
|
||||
* Returns null if required data is missing or invalid
|
||||
*/
|
||||
private async buildScrobblePayload(contentData: TraktContentData, progress: number): Promise<any | null> {
|
||||
try {
|
||||
// Clamp progress between 0 and 100 and round to 2 decimals for API
|
||||
const clampedProgress = Math.min(100, Math.max(0, Math.round(progress * 100) / 100));
|
||||
|
||||
// Helper function to validate year
|
||||
const isValidYear = (year: number | undefined): year is number => {
|
||||
if (year === undefined || year === null) return false;
|
||||
if (typeof year !== 'number' || isNaN(year)) return false;
|
||||
// Year must be between 1800 and current year + 10
|
||||
const currentYear = new Date().getFullYear();
|
||||
return year > 0 && year >= 1800 && year <= currentYear + 10;
|
||||
};
|
||||
|
||||
// Helper function to validate title
|
||||
const isValidTitle = (title: string | undefined): title is string => {
|
||||
return typeof title === 'string' && title.trim().length > 0;
|
||||
};
|
||||
|
||||
// Enhanced debug logging for payload building
|
||||
logger.log('[TraktService] Building scrobble payload:', {
|
||||
type: contentData.type,
|
||||
|
|
@ -1548,9 +1780,14 @@ export class TraktService {
|
|||
});
|
||||
|
||||
if (contentData.type === 'movie') {
|
||||
if (!contentData.imdbId || !contentData.title) {
|
||||
logger.error('[TraktService] Missing movie data for scrobbling:', {
|
||||
imdbId: contentData.imdbId,
|
||||
// Validate required movie fields
|
||||
if (!contentData.imdbId || contentData.imdbId.trim() === '') {
|
||||
logger.error('[TraktService] Missing movie imdbId for scrobbling');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isValidTitle(contentData.title)) {
|
||||
logger.error('[TraktService] Missing or empty movie title for scrobbling:', {
|
||||
title: contentData.title
|
||||
});
|
||||
return null;
|
||||
|
|
@ -1561,36 +1798,70 @@ export class TraktService {
|
|||
? contentData.imdbId
|
||||
: `tt${contentData.imdbId}`;
|
||||
|
||||
// Build movie payload - only include year if valid
|
||||
const movieData: { title: string; year?: number; ids: { imdb: string } } = {
|
||||
title: contentData.title.trim(),
|
||||
ids: {
|
||||
imdb: imdbIdWithPrefix
|
||||
}
|
||||
};
|
||||
|
||||
// Only add year if it's valid (prevents year: 0 or invalid years)
|
||||
if (isValidYear(contentData.year)) {
|
||||
movieData.year = contentData.year;
|
||||
} else {
|
||||
logger.warn('[TraktService] Movie year is missing or invalid, omitting from payload:', {
|
||||
year: contentData.year
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
movie: {
|
||||
title: contentData.title,
|
||||
year: contentData.year,
|
||||
ids: {
|
||||
imdb: imdbIdWithPrefix
|
||||
}
|
||||
},
|
||||
movie: movieData,
|
||||
progress: clampedProgress
|
||||
};
|
||||
|
||||
logger.log('[TraktService] Movie payload built:', payload);
|
||||
return payload;
|
||||
} else if (contentData.type === 'episode') {
|
||||
if (!contentData.season || !contentData.episode || !contentData.showTitle || !contentData.showYear) {
|
||||
logger.error('[TraktService] Missing episode data for scrobbling:', {
|
||||
season: contentData.season,
|
||||
episode: contentData.episode,
|
||||
showTitle: contentData.showTitle,
|
||||
showYear: contentData.showYear
|
||||
// Validate season and episode numbers
|
||||
if (contentData.season === undefined || contentData.season === null || contentData.season < 0) {
|
||||
logger.error('[TraktService] Invalid season for episode scrobbling:', {
|
||||
season: contentData.season
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contentData.episode === undefined || contentData.episode === null || contentData.episode <= 0) {
|
||||
logger.error('[TraktService] Invalid episode number for scrobbling:', {
|
||||
episode: contentData.episode
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isValidTitle(contentData.showTitle)) {
|
||||
logger.error('[TraktService] Missing or empty show title for episode scrobbling:', {
|
||||
showTitle: contentData.showTitle
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build show data - only include year if valid
|
||||
const showData: { title: string; year?: number; ids: { imdb?: string } } = {
|
||||
title: contentData.showTitle.trim(),
|
||||
ids: {}
|
||||
};
|
||||
|
||||
// Only add year if it's valid
|
||||
if (isValidYear(contentData.showYear)) {
|
||||
showData.year = contentData.showYear;
|
||||
} else {
|
||||
logger.warn('[TraktService] Show year is missing or invalid, omitting from payload:', {
|
||||
showYear: contentData.showYear
|
||||
});
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
show: {
|
||||
title: contentData.showTitle,
|
||||
year: contentData.showYear,
|
||||
ids: {}
|
||||
},
|
||||
show: showData,
|
||||
episode: {
|
||||
season: contentData.season,
|
||||
number: contentData.episode
|
||||
|
|
@ -1599,7 +1870,7 @@ export class TraktService {
|
|||
};
|
||||
|
||||
// Add show IMDB ID if available
|
||||
if (contentData.showImdbId) {
|
||||
if (contentData.showImdbId && contentData.showImdbId.trim() !== '') {
|
||||
const showImdbWithPrefix = contentData.showImdbId.startsWith('tt')
|
||||
? contentData.showImdbId
|
||||
: `tt${contentData.showImdbId}`;
|
||||
|
|
@ -1607,7 +1878,7 @@ export class TraktService {
|
|||
}
|
||||
|
||||
// Add episode IMDB ID if available (for specific episode IDs)
|
||||
if (contentData.imdbId && contentData.imdbId !== contentData.showImdbId) {
|
||||
if (contentData.imdbId && contentData.imdbId.trim() !== '' && contentData.imdbId !== contentData.showImdbId) {
|
||||
const episodeImdbWithPrefix = contentData.imdbId.startsWith('tt')
|
||||
? contentData.imdbId
|
||||
: `tt${contentData.imdbId}`;
|
||||
|
|
|
|||
392
src/services/watchedService.ts
Normal file
392
src/services/watchedService.ts
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
import { TraktService } from './traktService';
|
||||
import { storageService } from './storageService';
|
||||
import { mmkvStorage } from './mmkvStorage';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* WatchedService - Manages "watched" status for movies, episodes, and seasons.
|
||||
* Handles both local storage and Trakt sync transparently.
|
||||
*
|
||||
* When Trakt is authenticated, it syncs to Trakt.
|
||||
* When not authenticated, it stores locally.
|
||||
*/
|
||||
class WatchedService {
|
||||
private static instance: WatchedService;
|
||||
private traktService: TraktService;
|
||||
|
||||
private constructor() {
|
||||
this.traktService = TraktService.getInstance();
|
||||
}
|
||||
|
||||
public static getInstance(): WatchedService {
|
||||
if (!WatchedService.instance) {
|
||||
WatchedService.instance = new WatchedService();
|
||||
}
|
||||
return WatchedService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a movie as watched
|
||||
* @param imdbId - The IMDb ID of the movie
|
||||
* @param watchedAt - Optional date when watched
|
||||
*/
|
||||
public async markMovieAsWatched(
|
||||
imdbId: string,
|
||||
watchedAt: Date = new Date()
|
||||
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||
try {
|
||||
logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`);
|
||||
|
||||
// Check if Trakt is authenticated
|
||||
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||
let syncedToTrakt = false;
|
||||
|
||||
if (isTraktAuth) {
|
||||
// Sync to Trakt
|
||||
syncedToTrakt = await this.traktService.addToWatchedMovies(imdbId, watchedAt);
|
||||
logger.log(`[WatchedService] Trakt sync result for movie: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Also store locally as "completed" (100% progress)
|
||||
await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt);
|
||||
|
||||
return { success: true, syncedToTrakt };
|
||||
} catch (error) {
|
||||
logger.error('[WatchedService] Failed to mark movie as watched:', error);
|
||||
return { success: false, syncedToTrakt: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single episode as watched
|
||||
* @param showImdbId - The IMDb ID of the show
|
||||
* @param showId - The Stremio ID of the show (for local storage)
|
||||
* @param season - Season number
|
||||
* @param episode - Episode number
|
||||
* @param watchedAt - Optional date when watched
|
||||
*/
|
||||
public async markEpisodeAsWatched(
|
||||
showImdbId: string,
|
||||
showId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
watchedAt: Date = new Date()
|
||||
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||
try {
|
||||
logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`);
|
||||
|
||||
// Check if Trakt is authenticated
|
||||
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||
let syncedToTrakt = false;
|
||||
|
||||
if (isTraktAuth) {
|
||||
// Sync to Trakt
|
||||
syncedToTrakt = await this.traktService.addToWatchedEpisodes(
|
||||
showImdbId,
|
||||
season,
|
||||
episode,
|
||||
watchedAt
|
||||
);
|
||||
logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Store locally as "completed"
|
||||
const episodeId = `${showId}:${season}:${episode}`;
|
||||
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
|
||||
|
||||
return { success: true, syncedToTrakt };
|
||||
} catch (error) {
|
||||
logger.error('[WatchedService] Failed to mark episode as watched:', error);
|
||||
return { success: false, syncedToTrakt: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark multiple episodes as watched (batch operation)
|
||||
* @param showImdbId - The IMDb ID of the show
|
||||
* @param showId - The Stremio ID of the show (for local storage)
|
||||
* @param episodes - Array of { season, episode } objects
|
||||
* @param watchedAt - Optional date when watched
|
||||
*/
|
||||
public async markEpisodesAsWatched(
|
||||
showImdbId: string,
|
||||
showId: string,
|
||||
episodes: Array<{ season: number; episode: number }>,
|
||||
watchedAt: Date = new Date()
|
||||
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
||||
try {
|
||||
if (episodes.length === 0) {
|
||||
return { success: true, syncedToTrakt: false, count: 0 };
|
||||
}
|
||||
|
||||
logger.log(`[WatchedService] Marking ${episodes.length} episodes as watched for ${showImdbId}`);
|
||||
|
||||
// Check if Trakt is authenticated
|
||||
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||
let syncedToTrakt = false;
|
||||
|
||||
if (isTraktAuth) {
|
||||
// Sync to Trakt (batch operation)
|
||||
syncedToTrakt = await this.traktService.markEpisodesAsWatched(
|
||||
showImdbId,
|
||||
episodes,
|
||||
watchedAt
|
||||
);
|
||||
logger.log(`[WatchedService] Trakt batch sync result: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Store locally as "completed" for each episode
|
||||
for (const ep of episodes) {
|
||||
const episodeId = `${showId}:${ep.season}:${ep.episode}`;
|
||||
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
|
||||
}
|
||||
|
||||
return { success: true, syncedToTrakt, count: episodes.length };
|
||||
} catch (error) {
|
||||
logger.error('[WatchedService] Failed to mark episodes as watched:', error);
|
||||
return { success: false, syncedToTrakt: false, count: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an entire season as watched
|
||||
* @param showImdbId - The IMDb ID of the show
|
||||
* @param showId - The Stremio ID of the show (for local storage)
|
||||
* @param season - Season number
|
||||
* @param episodeNumbers - Array of episode numbers in the season
|
||||
* @param watchedAt - Optional date when watched
|
||||
*/
|
||||
public async markSeasonAsWatched(
|
||||
showImdbId: string,
|
||||
showId: string,
|
||||
season: number,
|
||||
episodeNumbers: number[],
|
||||
watchedAt: Date = new Date()
|
||||
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
||||
try {
|
||||
logger.log(`[WatchedService] Marking season ${season} as watched for ${showImdbId}`);
|
||||
|
||||
// Check if Trakt is authenticated
|
||||
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||
let syncedToTrakt = false;
|
||||
|
||||
if (isTraktAuth) {
|
||||
// Sync entire season to Trakt
|
||||
syncedToTrakt = await this.traktService.markSeasonAsWatched(
|
||||
showImdbId,
|
||||
season,
|
||||
watchedAt
|
||||
);
|
||||
logger.log(`[WatchedService] Trakt season sync result: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Store locally as "completed" for each episode in the season
|
||||
for (const epNum of episodeNumbers) {
|
||||
const episodeId = `${showId}:${season}:${epNum}`;
|
||||
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
|
||||
}
|
||||
|
||||
return { success: true, syncedToTrakt, count: episodeNumbers.length };
|
||||
} catch (error) {
|
||||
logger.error('[WatchedService] Failed to mark season as watched:', error);
|
||||
return { success: false, syncedToTrakt: false, count: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmark a movie as watched (remove from history)
|
||||
*/
|
||||
public async unmarkMovieAsWatched(
|
||||
imdbId: string
|
||||
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||
try {
|
||||
logger.log(`[WatchedService] Unmarking movie as watched: ${imdbId}`);
|
||||
|
||||
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||
let syncedToTrakt = false;
|
||||
|
||||
if (isTraktAuth) {
|
||||
syncedToTrakt = await this.traktService.removeMovieFromHistory(imdbId);
|
||||
logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Remove local progress
|
||||
await storageService.removeWatchProgress(imdbId, 'movie');
|
||||
await mmkvStorage.removeItem(`watched:movie:${imdbId}`);
|
||||
|
||||
return { success: true, syncedToTrakt };
|
||||
} catch (error) {
|
||||
logger.error('[WatchedService] Failed to unmark movie as watched:', error);
|
||||
return { success: false, syncedToTrakt: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmark an episode as watched (remove from history)
|
||||
*/
|
||||
public async unmarkEpisodeAsWatched(
|
||||
showImdbId: string,
|
||||
showId: string,
|
||||
season: number,
|
||||
episode: number
|
||||
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||
try {
|
||||
logger.log(`[WatchedService] Unmarking episode as watched: ${showImdbId} S${season}E${episode}`);
|
||||
|
||||
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||
let syncedToTrakt = false;
|
||||
|
||||
if (isTraktAuth) {
|
||||
syncedToTrakt = await this.traktService.removeEpisodeFromHistory(
|
||||
showImdbId,
|
||||
season,
|
||||
episode
|
||||
);
|
||||
logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Remove local progress
|
||||
const episodeId = `${showId}:${season}:${episode}`;
|
||||
await storageService.removeWatchProgress(showId, 'series', episodeId);
|
||||
|
||||
return { success: true, syncedToTrakt };
|
||||
} catch (error) {
|
||||
logger.error('[WatchedService] Failed to unmark episode as watched:', error);
|
||||
return { success: false, syncedToTrakt: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmark an entire season as watched (remove from history)
|
||||
* @param showImdbId - The IMDb ID of the show
|
||||
* @param showId - The Stremio ID of the show (for local storage)
|
||||
* @param season - Season number
|
||||
* @param episodeNumbers - Array of episode numbers in the season
|
||||
*/
|
||||
public async unmarkSeasonAsWatched(
|
||||
showImdbId: string,
|
||||
showId: string,
|
||||
season: number,
|
||||
episodeNumbers: number[]
|
||||
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
||||
try {
|
||||
logger.log(`[WatchedService] Unmarking season ${season} as watched for ${showImdbId}`);
|
||||
|
||||
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||
let syncedToTrakt = false;
|
||||
|
||||
if (isTraktAuth) {
|
||||
// Remove entire season from Trakt
|
||||
syncedToTrakt = await this.traktService.removeSeasonFromHistory(
|
||||
showImdbId,
|
||||
season
|
||||
);
|
||||
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
|
||||
}
|
||||
|
||||
// Remove local progress for each episode in the season
|
||||
for (const epNum of episodeNumbers) {
|
||||
const episodeId = `${showId}:${season}:${epNum}`;
|
||||
await storageService.removeWatchProgress(showId, 'series', episodeId);
|
||||
}
|
||||
|
||||
return { success: true, syncedToTrakt, count: episodeNumbers.length };
|
||||
} catch (error) {
|
||||
logger.error('[WatchedService] Failed to unmark season as watched:', error);
|
||||
return { success: false, syncedToTrakt: false, count: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a movie is marked as watched (locally)
|
||||
*/
|
||||
public async isMovieWatched(imdbId: string): Promise<boolean> {
|
||||
try {
|
||||
// First check local watched flag
|
||||
const localWatched = await mmkvStorage.getItem(`watched:movie:${imdbId}`);
|
||||
if (localWatched === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check local progress
|
||||
const progress = await storageService.getWatchProgress(imdbId, 'movie');
|
||||
if (progress) {
|
||||
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||
if (progressPercent >= 85) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('[WatchedService] Error checking movie watched status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an episode is marked as watched (locally)
|
||||
*/
|
||||
public async isEpisodeWatched(showId: string, season: number, episode: number): Promise<boolean> {
|
||||
try {
|
||||
const episodeId = `${showId}:${season}:${episode}`;
|
||||
|
||||
// Check local progress
|
||||
const progress = await storageService.getWatchProgress(showId, 'series', episodeId);
|
||||
if (progress) {
|
||||
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||
if (progressPercent >= 85) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('[WatchedService] Error checking episode watched status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set local watched status by creating a "completed" progress entry
|
||||
*/
|
||||
private async setLocalWatchedStatus(
|
||||
id: string,
|
||||
type: 'movie' | 'series',
|
||||
watched: boolean,
|
||||
episodeId?: string,
|
||||
watchedAt: Date = new Date()
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (watched) {
|
||||
// Create a "completed" progress entry (100% watched)
|
||||
const progress = {
|
||||
currentTime: 1, // Minimal values to indicate completion
|
||||
duration: 1,
|
||||
lastUpdated: watchedAt.getTime(),
|
||||
traktSynced: false, // Will be set to true if Trakt sync succeeded
|
||||
traktProgress: 100,
|
||||
};
|
||||
await storageService.setWatchProgress(id, type, progress, episodeId, {
|
||||
forceWrite: true,
|
||||
forceNotify: true
|
||||
});
|
||||
|
||||
// Also set the legacy watched flag for movies
|
||||
if (type === 'movie') {
|
||||
await mmkvStorage.setItem(`watched:${type}:${id}`, 'true');
|
||||
}
|
||||
} else {
|
||||
// Remove progress
|
||||
await storageService.removeWatchProgress(id, type, episodeId);
|
||||
if (type === 'movie') {
|
||||
await mmkvStorage.removeItem(`watched:${type}:${id}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[WatchedService] Error setting local watched status:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const watchedService = WatchedService.getInstance();
|
||||
|
|
@ -11,42 +11,74 @@ export type RouteParams = {
|
|||
episodeId?: string;
|
||||
};
|
||||
|
||||
// Stream related types
|
||||
// Stream related types - aligned with Stremio protocol
|
||||
export interface Subtitle {
|
||||
id: string; // Required per protocol
|
||||
url: string;
|
||||
lang: string;
|
||||
fps?: number;
|
||||
addon?: string;
|
||||
addonName?: string;
|
||||
format?: 'srt' | 'vtt' | 'ass' | 'ssa';
|
||||
}
|
||||
|
||||
export interface Stream {
|
||||
// Primary stream source - one of these must be provided per protocol
|
||||
url?: string; // Direct HTTP URL (now optional)
|
||||
ytId?: string; // YouTube video ID
|
||||
infoHash?: string; // BitTorrent info hash
|
||||
externalUrl?: string; // External URL to open in browser
|
||||
|
||||
// Display information
|
||||
name?: string;
|
||||
title?: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
|
||||
// Addon identification
|
||||
addon?: string;
|
||||
addonId?: string;
|
||||
addonName?: string;
|
||||
behaviorHints?: {
|
||||
cached?: boolean;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
// Stream properties
|
||||
size?: number;
|
||||
isFree?: boolean;
|
||||
isDebrid?: boolean;
|
||||
quality?: string;
|
||||
type?: string;
|
||||
lang?: string;
|
||||
fileIdx?: number;
|
||||
|
||||
headers?: {
|
||||
Referer?: string;
|
||||
'User-Agent'?: string;
|
||||
Origin?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
|
||||
files?: {
|
||||
file: string;
|
||||
type: string;
|
||||
quality: string;
|
||||
lang: string;
|
||||
}[];
|
||||
subtitles?: {
|
||||
url: string;
|
||||
lang: string;
|
||||
}[];
|
||||
addon?: string;
|
||||
description?: string;
|
||||
infoHash?: string;
|
||||
fileIdx?: number;
|
||||
size?: number;
|
||||
isFree?: boolean;
|
||||
isDebrid?: boolean;
|
||||
|
||||
subtitles?: Subtitle[];
|
||||
sources?: string[];
|
||||
|
||||
behaviorHints?: {
|
||||
bingeGroup?: string;
|
||||
notWebReady?: boolean;
|
||||
countryWhitelist?: string[];
|
||||
cached?: boolean;
|
||||
proxyHeaders?: {
|
||||
request?: Record<string, string>;
|
||||
response?: Record<string, string>;
|
||||
};
|
||||
videoHash?: string;
|
||||
videoSize?: number;
|
||||
filename?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GroupedStreams {
|
||||
|
|
|
|||
|
|
@ -1,34 +1,85 @@
|
|||
export interface Stream {
|
||||
name?: string;
|
||||
title?: string;
|
||||
// Source object for archive streams per protocol
|
||||
export interface SourceObject {
|
||||
url: string;
|
||||
bytes?: number;
|
||||
}
|
||||
|
||||
export interface Subtitle {
|
||||
id: string; // Required per protocol
|
||||
url: string;
|
||||
lang: string;
|
||||
fps?: number;
|
||||
addon?: string;
|
||||
addonName?: string;
|
||||
format?: 'srt' | 'vtt' | 'ass' | 'ssa';
|
||||
}
|
||||
|
||||
export interface Stream {
|
||||
// Primary stream source - one of these must be provided
|
||||
url?: string; // Direct HTTP(S)/FTP(S)/RTMP URL
|
||||
ytId?: string; // YouTube video ID
|
||||
infoHash?: string; // BitTorrent info hash
|
||||
externalUrl?: string; // External URL to open in browser
|
||||
nzbUrl?: string; // Usenet NZB file URL
|
||||
rarUrls?: SourceObject[]; // RAR archive files
|
||||
zipUrls?: SourceObject[]; // ZIP archive files
|
||||
'7zipUrls'?: SourceObject[]; // 7z archive files
|
||||
tgzUrls?: SourceObject[]; // TGZ archive files
|
||||
tarUrls?: SourceObject[]; // TAR archive files
|
||||
|
||||
// Stream selection within archives/torrents
|
||||
fileIdx?: number; // File index in archive/torrent
|
||||
fileMustInclude?: string; // Regex for file matching in archives
|
||||
servers?: string[]; // NNTP servers for nzbUrl
|
||||
|
||||
// Display information
|
||||
name?: string; // Stream name (usually quality)
|
||||
title?: string; // Stream title/description (deprecated for description)
|
||||
description?: string; // Stream description
|
||||
|
||||
// Addon identification
|
||||
addon?: string;
|
||||
addonId?: string;
|
||||
addonName?: string;
|
||||
behaviorHints?: {
|
||||
cached?: boolean;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
// Stream properties
|
||||
size?: number;
|
||||
isFree?: boolean;
|
||||
isDebrid?: boolean;
|
||||
quality?: string;
|
||||
type?: string;
|
||||
lang?: string;
|
||||
headers?: { [key: string]: string };
|
||||
headers?: Record<string, string>;
|
||||
|
||||
// Legacy files array (for compatibility)
|
||||
files?: {
|
||||
file: string;
|
||||
type: string;
|
||||
quality: string;
|
||||
lang: string;
|
||||
}[];
|
||||
subtitles?: {
|
||||
url: string;
|
||||
lang: string;
|
||||
}[];
|
||||
addon?: string;
|
||||
description?: string;
|
||||
infoHash?: string;
|
||||
fileIdx?: number;
|
||||
size?: number;
|
||||
isFree?: boolean;
|
||||
isDebrid?: boolean;
|
||||
|
||||
// Embedded subtitles per protocol
|
||||
subtitles?: Subtitle[];
|
||||
|
||||
// Additional tracker/DHT sources
|
||||
sources?: string[];
|
||||
|
||||
// Complete behavior hints per protocol
|
||||
behaviorHints?: {
|
||||
bingeGroup?: string; // Group for binge watching
|
||||
notWebReady?: boolean; // True if not HTTPS MP4
|
||||
countryWhitelist?: string[]; // ISO 3166-1 alpha-3 codes (lowercase)
|
||||
cached?: boolean; // Debrid cached status
|
||||
proxyHeaders?: { // Custom headers for stream
|
||||
request?: Record<string, string>;
|
||||
response?: Record<string, string>;
|
||||
};
|
||||
videoHash?: string; // OpenSubtitles hash
|
||||
videoSize?: number; // Video file size in bytes
|
||||
filename?: string; // Video filename
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GroupedStreams {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
205
trakt-docs/scrape-trakt-docs.js
Normal file
205
trakt-docs/scrape-trakt-docs.js
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const API_BLUEPRINT_URL = 'https://jsapi.apiary.io/apis/trakt.apib';
|
||||
|
||||
// Category mapping based on group names
|
||||
const CATEGORIES = {
|
||||
'introduction': { file: '01-introduction.md', title: 'Introduction' },
|
||||
'authentication-oauth': { file: '02-authentication-oauth.md', title: 'Authentication - OAuth' },
|
||||
'authentication-devices': { file: '03-authentication-devices.md', title: 'Authentication - Devices' },
|
||||
'calendars': { file: '04-calendars.md', title: 'Calendars' },
|
||||
'checkin': { file: '05-checkin.md', title: 'Checkin' },
|
||||
'certifications': { file: '06-certifications.md', title: 'Certifications' },
|
||||
'comments': { file: '07-comments.md', title: 'Comments' },
|
||||
'countries': { file: '08-countries.md', title: 'Countries' },
|
||||
'genres': { file: '09-genres.md', title: 'Genres' },
|
||||
'languages': { file: '10-languages.md', title: 'Languages' },
|
||||
'lists': { file: '11-lists.md', title: 'Lists' },
|
||||
'movies': { file: '12-movies.md', title: 'Movies' },
|
||||
'networks': { file: '13-networks.md', title: 'Networks' },
|
||||
'notes': { file: '14-notes.md', title: 'Notes' },
|
||||
'people': { file: '15-people.md', title: 'People' },
|
||||
'recommendations': { file: '16-recommendations.md', title: 'Recommendations' },
|
||||
'scrobble': { file: '17-scrobble.md', title: 'Scrobble' },
|
||||
'search': { file: '18-search.md', title: 'Search' },
|
||||
'shows': { file: '19-shows.md', title: 'Shows' },
|
||||
'seasons': { file: '20-seasons.md', title: 'Seasons' },
|
||||
'episodes': { file: '21-episodes.md', title: 'Episodes' },
|
||||
'sync': { file: '22-sync.md', title: 'Sync' },
|
||||
'users': { file: '23-users.md', title: 'Users' },
|
||||
};
|
||||
|
||||
function fetchUrl(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(data));
|
||||
res.on('error', reject);
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function parseApiBlueprint(content) {
|
||||
const sections = {};
|
||||
let currentGroup = 'introduction';
|
||||
let currentContent = [];
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// Detect group headers like "# Group Authentication - OAuth"
|
||||
const groupMatch = line.match(/^#\s+Group\s+(.+)$/i);
|
||||
if (groupMatch) {
|
||||
// Save previous group
|
||||
if (currentContent.length > 0) {
|
||||
if (!sections[currentGroup]) sections[currentGroup] = [];
|
||||
sections[currentGroup].push(...currentContent);
|
||||
}
|
||||
|
||||
// Start new group
|
||||
const groupName = groupMatch[1].toLowerCase().replace(/\s+/g, '-');
|
||||
currentGroup = groupName;
|
||||
currentContent = [`# ${groupMatch[1]}\n`];
|
||||
continue;
|
||||
}
|
||||
|
||||
currentContent.push(line);
|
||||
}
|
||||
|
||||
// Save last group
|
||||
if (currentContent.length > 0) {
|
||||
if (!sections[currentGroup]) sections[currentGroup] = [];
|
||||
sections[currentGroup].push(...currentContent);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function convertApiBlueprintToMarkdown(content) {
|
||||
let md = content;
|
||||
|
||||
// Convert API Blueprint specific syntax to markdown
|
||||
// Parameters section
|
||||
md = md.replace(/\+ Parameters/g, '### Parameters');
|
||||
|
||||
// Request/Response sections
|
||||
md = md.replace(/\+ Request \(([^)]+)\)/g, '### Request ($1)');
|
||||
md = md.replace(/\+ Response (\d+)(?: \(([^)]+)\))?/g, (match, code, type) => {
|
||||
return type ? `### Response ${code} (${type})` : `### Response ${code}`;
|
||||
});
|
||||
|
||||
// Body sections
|
||||
md = md.replace(/\+ Body/g, '**Body:**');
|
||||
|
||||
// Headers
|
||||
md = md.replace(/\+ Headers/g, '**Headers:**');
|
||||
|
||||
// Attributes
|
||||
md = md.replace(/\+ Attributes/g, '### Attributes');
|
||||
|
||||
// Clean up indentation for code blocks
|
||||
md = md.replace(/^ /gm, ' ');
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🔄 Fetching Trakt API Blueprint...');
|
||||
|
||||
try {
|
||||
const content = await fetchUrl(API_BLUEPRINT_URL);
|
||||
console.log(`✅ Fetched ${content.length} bytes`);
|
||||
|
||||
// Save raw blueprint
|
||||
fs.writeFileSync(path.join(__dirname, 'raw-api-blueprint.apib'), content);
|
||||
console.log('📝 Saved raw API Blueprint');
|
||||
|
||||
// Parse and organize by groups
|
||||
const sections = parseApiBlueprint(content);
|
||||
console.log(`📂 Found ${Object.keys(sections).length} sections`);
|
||||
|
||||
// Create markdown files for each category
|
||||
for (const [groupKey, lines] of Object.entries(sections)) {
|
||||
const category = CATEGORIES[groupKey];
|
||||
const fileName = category ? category.file : `${groupKey}.md`;
|
||||
const title = category ? category.title : groupKey;
|
||||
|
||||
let mdContent = lines.join('\n');
|
||||
mdContent = convertApiBlueprintToMarkdown(mdContent);
|
||||
|
||||
// Add header if not present
|
||||
if (!mdContent.startsWith('# ')) {
|
||||
mdContent = `# ${title}\n\n${mdContent}`;
|
||||
}
|
||||
|
||||
const filePath = path.join(__dirname, fileName);
|
||||
fs.writeFileSync(filePath, mdContent);
|
||||
console.log(`✅ Created ${fileName}`);
|
||||
}
|
||||
|
||||
// Create README
|
||||
const readme = generateReadme(Object.keys(sections));
|
||||
fs.writeFileSync(path.join(__dirname, 'README.md'), readme);
|
||||
console.log('✅ Created README.md');
|
||||
|
||||
console.log('\n🎉 Done! All documentation files created.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function generateReadme(groups) {
|
||||
let md = `# Trakt API Documentation
|
||||
|
||||
This folder contains the complete Trakt API documentation, scraped from [trakt.docs.apiary.io](https://trakt.docs.apiary.io/).
|
||||
|
||||
## API Base URL
|
||||
|
||||
\`\`\`
|
||||
https://api.trakt.tv
|
||||
\`\`\`
|
||||
|
||||
## Documentation Files
|
||||
|
||||
`;
|
||||
|
||||
for (const groupKey of groups) {
|
||||
const category = CATEGORIES[groupKey];
|
||||
if (category) {
|
||||
md += `- [${category.title}](./${category.file})\n`;
|
||||
} else {
|
||||
md += `- [${groupKey}](./${groupKey}.md)\n`;
|
||||
}
|
||||
}
|
||||
|
||||
md += `
|
||||
## Quick Reference
|
||||
|
||||
### Required Headers
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| \`Content-Type\` | \`application/json\` |
|
||||
| \`trakt-api-key\` | Your \`client_id\` |
|
||||
| \`trakt-api-version\` | \`2\` |
|
||||
| \`Authorization\` | \`Bearer [access_token]\` (for authenticated endpoints) |
|
||||
|
||||
### Useful Links
|
||||
|
||||
- [Create API App](https://trakt.tv/oauth/applications/new)
|
||||
- [GitHub Developer Forum](https://github.com/trakt/api-help/issues)
|
||||
- [API Blog](https://apiblog.trakt.tv)
|
||||
|
||||
---
|
||||
*Generated on ${new Date().toISOString()}*
|
||||
`;
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Reference in a new issue