mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-14 06:56:03 +00:00
update dependencies and patchfile
This commit is contained in:
parent
1367972681
commit
11d2944246
31 changed files with 5078 additions and 5532 deletions
|
|
@ -2,12 +2,12 @@
|
|||
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ERROR_RECOVERY_ONLY"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="5000"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="30000"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://ota.nuvioapp.space/api/manifest"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode|locale|layoutDirection" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified" android:supportsPictureInPicture="true" android:resizeableActivity="true">
|
||||
<intent-filter>
|
||||
|
|
@ -37,4 +37,4 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
|
@ -4,7 +4,7 @@ buildscript {
|
|||
ext {
|
||||
buildToolsVersion = "35.0.0"
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 35
|
||||
castFrameworkVersion = "22.1.0"
|
||||
ndkVersion = "29.0.14206865" // Required for libmpv AAR built with NDK r29
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
defaults.url=https://sentry.io/
|
||||
defaults.org=tapframe
|
||||
defaults.project=react-native
|
||||
auth.token=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
|
||||
# Using SENTRY_AUTH_TOKEN environment variable
|
||||
|
|
@ -7,7 +7,5 @@
|
|||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||
F285A1620F5847BA863124AF /* LiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EF8716173E0148BD82B233B7 /* LiveActivity.appex */; };
|
||||
797799D4F9144A9E8D2AB90D /* LiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 49DDF70A2BBD4320BBD94B1B /* LiveActivity.appex */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
|
|
@ -39,6 +40,13 @@
|
|||
remoteGlobalIDString = 0EA489F2BF6143F1BA7B8485;
|
||||
remoteInfo = LiveActivity;
|
||||
};
|
||||
7A41A1F529994F0C8801F1A5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = EFB756D21A05453EA489278C;
|
||||
remoteInfo = LiveActivity;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
|
|
@ -48,6 +56,7 @@
|
|||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
797799D4F9144A9E8D2AB90D /* LiveActivity.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -93,6 +102,16 @@
|
|||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CC1B793274FE428D8531E950 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
name = "Embed Foundation Extensions";
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
|
|
@ -128,6 +147,7 @@
|
|||
EF8716173E0148BD82B233B7 /* LiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; fileEncoding = 9; includeInIndex = 0; path = LiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Nuvio/AppDelegate.swift; sourceTree = "<group>"; };
|
||||
F11748442D0722820044C1D9 /* Nuvio-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Nuvio-Bridging-Header.h"; path = "Nuvio/Nuvio-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
49DDF70A2BBD4320BBD94B1B /* LiveActivity.appex */ = {isa = PBXFileReference; name = "LiveActivity.appex"; path = "LiveActivity.appex"; sourceTree = BUILT_PRODUCTS_DIR; fileEncoding = undefined; lastKnownFileType = undefined; explicitFileType = wrapper.app-extension; includeInIndex = 0; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -146,6 +166,13 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A2537B59C29048BFB082D5F3 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
|
|
@ -213,6 +240,7 @@
|
|||
B9F3EB198DED443D980ADFB3 /* LiveActivity */,
|
||||
C05E525650E143FB85ED7622 /* LiveActivity */,
|
||||
D05210A39FF14E649D77F8A8 /* LiveActivity */,
|
||||
2EAF711C6AB246A0A253E404 /* LiveActivity */,
|
||||
);
|
||||
indentWidth = 2;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -224,6 +252,7 @@
|
|||
children = (
|
||||
13B07F961A680F5B00A75B9A /* Nuvio.app */,
|
||||
EF8716173E0148BD82B233B7 /* LiveActivity.appex */,
|
||||
49DDF70A2BBD4320BBD94B1B /* LiveActivity.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -295,6 +324,26 @@
|
|||
name = Nuvio;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2EAF711C6AB246A0A253E404 /* LiveActivity */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8034143A77A946B5A793F967 /* Color+hex.swift */,
|
||||
3A48D8A298DD48928E8D0A02 /* Date+toTimerInterval.swift */,
|
||||
26957CDD392E4E9390811D0D /* Image+dynamic.swift */,
|
||||
2F448294A36E433E924078C1 /* LiveActivityView.swift */,
|
||||
AD48662BB71E4C9C9E340289 /* LiveActivityWidget.swift */,
|
||||
A83D742B36224176A0AB3B25 /* LiveActivityWidgetBundle.swift */,
|
||||
324373F393774A9CA40DE22E /* View+applyIfPresent.swift */,
|
||||
373D1473F5A74CBC9DBD108B /* View+applyWidgetURL.swift */,
|
||||
3396D68881EF486E99FD480A /* ViewHelpers.swift */,
|
||||
0E13CE4BDE2F4555806AE753 /* Info.plist */,
|
||||
0F1D0037D1F24E60BDB57628 /* Assets.xcassets */,
|
||||
2DE29A8A87D24662BEFFF849 /* LiveActivity.entitlements */,
|
||||
);
|
||||
name = LiveActivity;
|
||||
path = LiveActivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
|
@ -333,17 +382,36 @@
|
|||
571AD3FB23F14FC7BE6A1E44 /* Embed Foundation Extensions */,
|
||||
13CD9594FB5C4FE4A6794089 /* Embed Foundation Extensions */,
|
||||
F1058FE7710A45FABC0689A7 /* Embed Foundation Extensions */,
|
||||
CC1B793274FE428D8531E950 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
8410CAE82E604DD1A187EDA2 /* PBXTargetDependency */,
|
||||
523C5D7CB8E740A5A0BF4322 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Nuvio;
|
||||
productName = Nuvio;
|
||||
productReference = 13B07F961A680F5B00A75B9A /* Nuvio.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
EFB756D21A05453EA489278C /* LiveActivity */ = {
|
||||
isa = PBXNativeTarget;
|
||||
name = LiveActivity;
|
||||
productName = LiveActivity;
|
||||
productReference = 49DDF70A2BBD4320BBD94B1B;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
buildConfigurationList = F11E3E24512A427FB847D2F6;
|
||||
buildPhases = (
|
||||
784E2472974841CD88391F31 /* Embed Foundation Extensions */,
|
||||
A2537B59C29048BFB082D5F3 /* Embed Foundation Extensions */,
|
||||
B06A5B524C284D9FAFC33F3C /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
|
|
@ -362,6 +430,9 @@
|
|||
LastSwiftMigration = 1250;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
EFB756D21A05453EA489278C = {
|
||||
LastSwiftMigration = 1250;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Nuvio" */;
|
||||
|
|
@ -379,6 +450,7 @@
|
|||
targets = (
|
||||
13B07F861A680F5B00A75B9A /* Nuvio */,
|
||||
0EA489F2BF6143F1BA7B8485 /* LiveActivity */,
|
||||
EFB756D21A05453EA489278C /* LiveActivity */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
|
@ -403,6 +475,14 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B06A5B524C284D9FAFC33F3C /* Embed Foundation Extensions */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
730F1CE72F24B27100EF7E51 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
|
|
@ -649,6 +729,22 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
784E2472974841CD88391F31 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
730F1CDE2F24B27100EF7E51 /* "Color+hex.swift" in Sources */,
|
||||
730F1CDF2F24B27100EF7E51 /* "Date+toTimerInterval.swift" in Sources */,
|
||||
730F1CE02F24B27100EF7E51 /* "Image+dynamic.swift" in Sources */,
|
||||
730F1CE12F24B27100EF7E51 /* LiveActivityView.swift in Sources */,
|
||||
730F1CE22F24B27100EF7E51 /* LiveActivityWidget.swift in Sources */,
|
||||
730F1CE32F24B27100EF7E51 /* LiveActivityWidgetBundle.swift in Sources */,
|
||||
730F1CE42F24B27100EF7E51 /* "View+applyIfPresent.swift" in Sources */,
|
||||
730F1CE52F24B27100EF7E51 /* "View+applyWidgetURL.swift" in Sources */,
|
||||
730F1CE62F24B27100EF7E51 /* ViewHelpers.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
|
|
@ -657,6 +753,11 @@
|
|||
target = 0EA489F2BF6143F1BA7B8485 /* LiveActivity */;
|
||||
targetProxy = 55A0DD628D7F4F4F88B4A001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
523C5D7CB8E740A5A0BF4322 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = EFB756D21A05453EA489278C /* LiveActivity */;
|
||||
targetProxy = 7A41A1F529994F0C8801F1A5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
|
|
@ -690,7 +791,7 @@
|
|||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
|
|
@ -725,7 +826,7 @@
|
|||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
@ -900,6 +1001,46 @@
|
|||
};
|
||||
name = Release;
|
||||
};
|
||||
B062D46778AF40DE92953986 /* Debug */ = {
|
||||
name = Debug;
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
INFOPLIST_FILE = LiveActivity/Info.plist;
|
||||
CURRENT_PROJECT_VERSION = "37";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = "16.2";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.hub.LiveActivity";
|
||||
GENERATE_INFOPLIST_FILE = "YES";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = LiveActivity;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
MARKETING_VERSION = "1.4.1";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
CODE_SIGN_ENTITLEMENTS = "LiveActivity/LiveActivity.entitlements";
|
||||
APPLICATION_EXTENSION_API_ONLY = "YES";
|
||||
};
|
||||
};
|
||||
67617475B4F443CBBCE1A6FD /* Release */ = {
|
||||
name = Release;
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
INFOPLIST_FILE = LiveActivity/Info.plist;
|
||||
CURRENT_PROJECT_VERSION = "37";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = "16.2";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.hub.LiveActivity";
|
||||
GENERATE_INFOPLIST_FILE = "YES";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = LiveActivity;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
MARKETING_VERSION = "1.4.1";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
CODE_SIGN_ENTITLEMENTS = "LiveActivity/LiveActivity.entitlements";
|
||||
APPLICATION_EXTENSION_API_ONLY = "YES";
|
||||
};
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
|
|
@ -930,6 +1071,15 @@
|
|||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
F11E3E24512A427FB847D2F6 /* Build configuration list for PBXNativeTarget "LiveActivity" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B062D46778AF40DE92953986 /* Debug */,
|
||||
67617475B4F443CBBCE1A6FD /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
|
||||
|
|
|
|||
|
|
@ -1,107 +1,107 @@
|
|||
<?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>37</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>Nuvio uses the local network to discover Cast-enabled devices on your WiFi network and to connect to local media servers.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>This app does not require microphone access.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||
<false/>
|
||||
<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>
|
||||
<false/>
|
||||
<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.4.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>nuvio</string>
|
||||
<string>com.nuvio.hub</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>exp+nuvio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>37</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>Nuvio uses the local network to discover Cast-enabled devices on your WiFi network and to connect to local media servers.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>This app does not require microphone access.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||
<false/>
|
||||
<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>
|
||||
<false/>
|
||||
<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>
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
<key>EXUpdatesLaunchWaitMs</key>
|
||||
<integer>30000</integer>
|
||||
<key>EXUpdatesRuntimeVersion</key>
|
||||
<string>1.3.6</string>
|
||||
<string>1.4.1</string>
|
||||
<key>EXUpdatesURL</key>
|
||||
<string>https://ota.nuvioapp.space/api/manifest</string>
|
||||
</dict>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
defaults.url=https://sentry.io/
|
||||
defaults.org=tapframe
|
||||
defaults.project=react-native
|
||||
auth.token=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
|
||||
# Using SENTRY_AUTH_TOKEN environment variable
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
package com.brentvatne.exoplayer
|
||||
|
||||
import android.os.Build
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.View.MeasureSpec
|
||||
import android.widget.FrameLayout
|
||||
|
|
@ -20,13 +23,21 @@ import androidx.media3.ui.PlayerView
|
|||
import androidx.media3.ui.SubtitleView
|
||||
import com.brentvatne.common.api.ResizeMode
|
||||
import com.brentvatne.common.api.SubtitleStyle
|
||||
import com.brentvatne.common.api.ViewType
|
||||
import com.brentvatne.react.R
|
||||
|
||||
@UnstableApi
|
||||
class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
|
||||
FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var localStyle = SubtitleStyle()
|
||||
private var currentViewType = ViewType.VIEW_TYPE_SURFACE
|
||||
private var pendingResizeMode: Int? = null
|
||||
private var player: ExoPlayer? = null
|
||||
private var showSubtitleButton = false
|
||||
private var shutterColor = Color.TRANSPARENT
|
||||
private var controllerVisibilityListener: PlayerView.ControllerVisibilityListener? = null
|
||||
private var fullscreenButtonClickListener: PlayerView.FullscreenButtonClickListener? = null
|
||||
private val liveBadge: TextView = TextView(context).apply {
|
||||
text = "LIVE"
|
||||
setTextColor(Color.WHITE)
|
||||
|
|
@ -39,21 +50,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
visibility = View.GONE
|
||||
}
|
||||
|
||||
private val playerView = PlayerView(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
setShutterBackgroundColor(Color.TRANSPARENT)
|
||||
useController = true
|
||||
controllerAutoShow = true
|
||||
controllerHideOnTouch = true
|
||||
controllerShowTimeoutMs = 5000
|
||||
// Don't show subtitle button by default - will be enabled when tracks are available
|
||||
setShowSubtitleButton(false)
|
||||
// Enable proper surface view handling to prevent rendering issues
|
||||
setUseArtwork(false)
|
||||
setDefaultArtwork(null)
|
||||
// Ensure proper video scaling - start with FIT mode
|
||||
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
private var playerView = createPlayerView(currentViewType)
|
||||
|
||||
/**
|
||||
* Subtitles rendered in a full-size overlay (NOT inside PlayerView's content frame).
|
||||
|
|
@ -110,6 +107,8 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
}
|
||||
|
||||
fun setPlayer(player: ExoPlayer?) {
|
||||
this.player?.removeListener(playerListener)
|
||||
this.player = player
|
||||
playerView.player = player
|
||||
player?.addListener(playerListener)
|
||||
}
|
||||
|
|
@ -120,7 +119,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
ResizeMode.RESIZE_MODE_FIXED_WIDTH -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||
ResizeMode.RESIZE_MODE_FILL -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||
ResizeMode.RESIZE_MODE_ZOOM -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
ResizeMode.RESIZE_MODE_CENTER_CROP -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
else -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
if (playerView.width > 0 && playerView.height > 0) {
|
||||
|
|
@ -136,7 +135,20 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
|
||||
fun getPlayerView(): PlayerView = playerView
|
||||
|
||||
fun isPlaying(): Boolean = playerView.player?.isPlaying == true
|
||||
|
||||
fun setControllerVisibilityListener(listener: PlayerView.ControllerVisibilityListener?) {
|
||||
controllerVisibilityListener = listener
|
||||
playerView.setControllerVisibilityListener(listener)
|
||||
}
|
||||
|
||||
fun setFullscreenButtonClickListener(listener: PlayerView.FullscreenButtonClickListener?) {
|
||||
fullscreenButtonClickListener = listener
|
||||
playerView.setFullscreenButtonClickListener(listener)
|
||||
}
|
||||
|
||||
fun setShowSubtitleButton(show: Boolean) {
|
||||
showSubtitleButton = show
|
||||
playerView.setShowSubtitleButton(show)
|
||||
}
|
||||
|
||||
|
|
@ -166,6 +178,47 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
playerView.showController()
|
||||
}
|
||||
|
||||
fun updateSurfaceView(@ViewType.ViewType viewType: Int) {
|
||||
if (currentViewType == viewType) {
|
||||
return
|
||||
}
|
||||
|
||||
currentViewType = viewType
|
||||
|
||||
val previousPlayerView = playerView
|
||||
val previousLayoutParams = previousPlayerView.layoutParams ?: LayoutParams(
|
||||
LayoutParams.MATCH_PARENT,
|
||||
LayoutParams.MATCH_PARENT
|
||||
)
|
||||
val previousResizeMode = previousPlayerView.resizeMode
|
||||
val previousUseController = previousPlayerView.useController
|
||||
val previousControllerAutoShow = previousPlayerView.controllerAutoShow
|
||||
val previousControllerHideOnTouch = previousPlayerView.controllerHideOnTouch
|
||||
val previousControllerShowTimeoutMs = previousPlayerView.controllerShowTimeoutMs
|
||||
|
||||
val replacementPlayerView = createPlayerView(viewType).apply {
|
||||
layoutParams = previousLayoutParams
|
||||
resizeMode = previousResizeMode
|
||||
useController = previousUseController
|
||||
controllerAutoShow = previousControllerAutoShow
|
||||
controllerHideOnTouch = previousControllerHideOnTouch
|
||||
controllerShowTimeoutMs = previousControllerShowTimeoutMs
|
||||
setShowSubtitleButton(showSubtitleButton)
|
||||
setControllerVisibilityListener(controllerVisibilityListener)
|
||||
setFullscreenButtonClickListener(fullscreenButtonClickListener)
|
||||
setShutterBackgroundColor(shutterColor)
|
||||
player = this@ExoPlayerView.player
|
||||
}
|
||||
|
||||
removeView(previousPlayerView)
|
||||
playerView = replacementPlayerView
|
||||
addView(playerView, 0, previousLayoutParams)
|
||||
|
||||
updateSubtitleRenderingMode()
|
||||
applySubtitleStyle(localStyle)
|
||||
playerView.requestLayout()
|
||||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
localStyle = style
|
||||
applySubtitleStyle(localStyle)
|
||||
|
|
@ -287,6 +340,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
}
|
||||
|
||||
fun setShutterColor(color: Int) {
|
||||
shutterColor = color
|
||||
playerView.setShutterBackgroundColor(color)
|
||||
}
|
||||
|
||||
|
|
@ -328,4 +382,28 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
applySubtitleStyle(localStyle)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPlayerView(@ViewType.ViewType viewType: Int): PlayerView {
|
||||
val layoutRes = when (viewType) {
|
||||
ViewType.VIEW_TYPE_TEXTURE -> R.layout.exo_player_view_texture
|
||||
else -> R.layout.exo_player_view_surface
|
||||
}
|
||||
|
||||
return (LayoutInflater.from(context).inflate(layoutRes, this, false) as PlayerView).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
setShutterBackgroundColor(shutterColor)
|
||||
useController = true
|
||||
controllerAutoShow = true
|
||||
controllerHideOnTouch = true
|
||||
controllerShowTimeoutMs = 5000
|
||||
setShowSubtitleButton(showSubtitleButton)
|
||||
setUseArtwork(false)
|
||||
setDefaultArtwork(null)
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
|
||||
if (viewType == ViewType.VIEW_TYPE_SURFACE_SECURE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
(videoSurfaceView as? SurfaceView)?.setSecure(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1567,6 +1567,11 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
Track audioTrack = exoplayerTrackToGenericTrack(format, groupIndex, selection, group);
|
||||
audioTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate);
|
||||
audioTrack.setSelected(isSelected);
|
||||
// Encode channel count into title so JS can read it e.g. "English|ch:6"
|
||||
if (format.channelCount != Format.NO_VALUE && format.channelCount > 0) {
|
||||
String existing = audioTrack.getTitle() != null ? audioTrack.getTitle() : "";
|
||||
audioTrack.setTitle(existing + "|ch:" + format.channelCount);
|
||||
}
|
||||
audioTracks.add(audioTrack);
|
||||
}
|
||||
|
||||
|
|
@ -1753,7 +1758,11 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
Track track = new Track();
|
||||
track.setIndex(groupIndex);
|
||||
track.setLanguage(format.language != null ? format.language : "unknown");
|
||||
track.setTitle(format.label != null ? format.label : "Track " + (groupIndex + 1));
|
||||
String baseTitle = format.label != null ? format.label : "";
|
||||
if (format.channelCount != Format.NO_VALUE && format.channelCount > 0) {
|
||||
baseTitle = baseTitle + "|ch:" + format.channelCount;
|
||||
}
|
||||
track.setTitle(baseTitle);
|
||||
track.setSelected(false); // Don't report selection status - let PlayerView handle it
|
||||
if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType);
|
||||
track.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate);
|
||||
|
|
@ -2127,7 +2136,8 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
}
|
||||
|
||||
private void selectTextTrackInternal(String type, String value) {
|
||||
if (player == null || trackSelector == null) return;
|
||||
if (player == null || trackSelector == null)
|
||||
return;
|
||||
|
||||
DebugLog.d(TAG, "selectTextTrackInternal: type=" + type + ", value=" + value);
|
||||
|
||||
|
|
@ -2146,6 +2156,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
if (textRendererIndex != C.INDEX_UNSET) {
|
||||
TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
|
||||
boolean trackFound = false;
|
||||
// react-native-video uses a flattened `textTracks` list on the JS side.
|
||||
// For HLS/DASH, each TrackGroup often contains a single track at index 0,
|
||||
// so comparing against `trackIndex` alone makes only the first subtitle selectable.
|
||||
int flattenedIndex = 0;
|
||||
|
||||
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
||||
TrackGroup group = groups.get(groupIndex);
|
||||
|
|
@ -2159,10 +2173,12 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||
isMatch = true;
|
||||
} else if ("index".equals(type)) {
|
||||
int targetIndex = ReactBridgeUtils.safeParseInt(value, -1);
|
||||
if (targetIndex == trackIndex) {
|
||||
if (targetIndex == flattenedIndex) {
|
||||
isMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
flattenedIndex++;
|
||||
|
||||
if (isMatch) {
|
||||
TrackSelectionOverride override = new TrackSelectionOverride(group,
|
||||
|
|
|
|||
6
node_modules/react-native-video/android/src/main/res/layout/exo_player_view_surface.xml
generated
vendored
Normal file
6
node_modules/react-native-video/android/src/main/res/layout/exo_player_view_surface.xml
generated
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.media3.ui.PlayerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:surface_type="surface_view" />
|
||||
6
node_modules/react-native-video/android/src/main/res/layout/exo_player_view_texture.xml
generated
vendored
Normal file
6
node_modules/react-native-video/android/src/main/res/layout/exo_player_view_texture.xml
generated
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.media3.ui.PlayerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:surface_type="texture_view" />
|
||||
2550
package-lock.json
generated
2550
package-lock.json
generated
File diff suppressed because it is too large
Load diff
78
package.json
78
package.json
|
|
@ -12,34 +12,34 @@
|
|||
"dependencies": {
|
||||
"@adrianso/react-native-device-brightness": "^1.2.7",
|
||||
"@backpackapp-io/react-native-toast": "^0.15.1",
|
||||
"@bottom-tabs/react-navigation": "^1.0.2",
|
||||
"@bottom-tabs/react-navigation": "^1.1.0",
|
||||
"@d11/react-native-fast-image": "^8.13.0",
|
||||
"@expo/env": "^2.0.7",
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@kesha-antonov/react-native-background-downloader": "^4.4.5",
|
||||
"@legendapp/list": "^2.0.13",
|
||||
"@lottiefiles/dotlottie-react": "^0.13.5",
|
||||
"@gorhom/bottom-sheet": "^5.2.8",
|
||||
"@kesha-antonov/react-native-background-downloader": "^4.5.3",
|
||||
"@legendapp/list": "^2.0.19",
|
||||
"@lottiefiles/dotlottie-react": "^0.18.5",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-community/slider": "^5.1.1",
|
||||
"@react-native-community/netinfo": "^12.0.1",
|
||||
"@react-native-community/slider": "^5.1.2",
|
||||
"@react-native-picker/picker": "^2.11.4",
|
||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"@react-navigation/native-stack": "^7.3.10",
|
||||
"@react-navigation/stack": "^7.2.10",
|
||||
"@sentry/react-native": "^7.6.0",
|
||||
"@shopify/flash-list": "^2.2.0",
|
||||
"@shopify/react-native-skia": "^2.4.14",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react-native-video": "^5.0.20",
|
||||
"axios": "^1.12.2",
|
||||
"axios-cookiejar-support": "^6.0.4",
|
||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
"@react-navigation/native-stack": "^7.14.5",
|
||||
"@react-navigation/stack": "^7.8.5",
|
||||
"@sentry/react-native": "^8.4.0",
|
||||
"@shopify/flash-list": "^2.3.0",
|
||||
"@shopify/react-native-skia": "^2.5.1",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/react-native-video": "^5.0.21",
|
||||
"axios": "^1.13.6",
|
||||
"axios-cookiejar-support": "^6.0.5",
|
||||
"cheerio-without-node-native": "^0.20.2",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"expo": "^54",
|
||||
"expo-application": "~7.0.7",
|
||||
"expo-auth-session": "~7.0.8",
|
||||
|
|
@ -67,47 +67,47 @@
|
|||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"i18next": "^25.7.3",
|
||||
"i18next": "^25.8.18",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lottie-react-native": "~7.3.1",
|
||||
"posthog-react-native": "^4.4.0",
|
||||
"lodash": "^4.17.23",
|
||||
"lottie-react-native": "~7.3.6",
|
||||
"posthog-react-native": "^4.37.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-i18next": "^16.5.8",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-boost": "^0.6.2",
|
||||
"react-native-bottom-tabs": "^1.0.2",
|
||||
"react-native-gesture-handler": "^2.29.1",
|
||||
"react-native-boost": "^1.0.0",
|
||||
"react-native-bottom-tabs": "^1.1.0",
|
||||
"react-native-gesture-handler": "^2.30.0",
|
||||
"react-native-get-random-values": "^2.0.0",
|
||||
"react-native-google-cast": "^4.9.1",
|
||||
"react-native-image-colors": "^2.5.0",
|
||||
"react-native-image-colors": "^2.6.0",
|
||||
"react-native-immersive-mode": "^2.0.2",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-mmkv": "^4.0.0",
|
||||
"react-native-nitro-modules": "^0.31.2",
|
||||
"react-native-paper": "^5.14.5",
|
||||
"react-native-reanimated": "^4.2.0",
|
||||
"react-native-mmkv": "^4.2.0",
|
||||
"react-native-nitro-modules": "^0.35.0",
|
||||
"react-native-paper": "^5.15.0",
|
||||
"react-native-reanimated": "^4.2.2",
|
||||
"react-native-reanimated-carousel": "^4.0.3",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "^4.18.0",
|
||||
"react-native-svg": "^15.12.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "^4.24.0",
|
||||
"react-native-svg": "^15.15.3",
|
||||
"react-native-url-polyfill": "^3.0.0",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-video": "^6.19.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-wheel-color-picker": "^1.3.1",
|
||||
"react-native-worklets": "^0.7.1"
|
||||
"react-native-worklets": "^0.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-native": "^0.72.8",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"patch-package": "^8.0.1",
|
||||
"react-native-svg-transformer": "^1.5.0",
|
||||
"react-native-svg-transformer": "^1.5.3",
|
||||
"typescript": "^5.9.3",
|
||||
"xcode": "^3.0.1"
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,191 @@
|
|||
import React from 'react';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from '@gorhom/bottom-sheet';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EdgeInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { Theme } from '../../../contexts/ThemeContext';
|
||||
|
||||
import { styles } from './styles';
|
||||
import { ContinueWatchingItem } from './types';
|
||||
|
||||
interface ContinueWatchingActionSheetProps {
|
||||
actionSheetRef: React.RefObject<BottomSheetModal | null>;
|
||||
currentTheme: Theme;
|
||||
insets: EdgeInsets;
|
||||
selectedItem: ContinueWatchingItem | null;
|
||||
onDismiss: () => void;
|
||||
onChange: (index: number) => void;
|
||||
onViewDetails: () => void;
|
||||
onRemoveItem: () => void;
|
||||
}
|
||||
|
||||
export function ContinueWatchingActionSheet({
|
||||
actionSheetRef,
|
||||
currentTheme,
|
||||
insets,
|
||||
selectedItem,
|
||||
onDismiss,
|
||||
onChange,
|
||||
onViewDetails,
|
||||
onRemoveItem,
|
||||
}: ContinueWatchingActionSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={actionSheetRef}
|
||||
index={0}
|
||||
snapPoints={['35%']}
|
||||
enablePanDownToClose={true}
|
||||
backdropComponent={(props) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.6}
|
||||
/>
|
||||
)}
|
||||
backgroundStyle={{
|
||||
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
}}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: currentTheme.colors.mediumGray,
|
||||
width: 40,
|
||||
}}
|
||||
onDismiss={onDismiss}
|
||||
onChange={onChange}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={[styles.actionSheetContent, { paddingBottom: insets.bottom + 16 }]}
|
||||
>
|
||||
{selectedItem ? (
|
||||
<>
|
||||
<View style={styles.actionSheetHeader}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: selectedItem.poster || 'https://via.placeholder.com/100x150',
|
||||
priority: FastImage.priority.high,
|
||||
}}
|
||||
style={styles.actionSheetPoster}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
|
||||
<View style={styles.actionSheetInfo}>
|
||||
<Text
|
||||
style={[styles.actionSheetTitle, { color: currentTheme.colors.text }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{selectedItem.name}
|
||||
</Text>
|
||||
|
||||
{selectedItem.type === 'series' && selectedItem.season && selectedItem.episode ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.actionSheetSubtitle,
|
||||
{ color: currentTheme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
{t('home.season', { season: selectedItem.season })} ·{' '}
|
||||
{t('home.episode', { episode: selectedItem.episode })}
|
||||
{selectedItem.episodeTitle &&
|
||||
selectedItem.episodeTitle !== `Episode ${selectedItem.episode}`
|
||||
? `\n${selectedItem.episodeTitle}`
|
||||
: ''}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.actionSheetSubtitle,
|
||||
{ color: currentTheme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
{selectedItem.year
|
||||
? `${selectedItem.type === 'movie' ? t('home.movie') : t('home.series')} · ${selectedItem.year}`
|
||||
: selectedItem.type === 'movie'
|
||||
? t('home.movie')
|
||||
: t('home.series')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{selectedItem.progress > 0 ? (
|
||||
<View style={styles.actionSheetProgressContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.actionSheetProgressTrack,
|
||||
{ backgroundColor: currentTheme.colors.elevation1 },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.actionSheetProgressBar,
|
||||
{
|
||||
width: `${selectedItem.progress}%`,
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.actionSheetProgressText,
|
||||
{ color: currentTheme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
{t('home.percent_watched', {
|
||||
percent: Math.round(selectedItem.progress),
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionSheetButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={onViewDetails}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="information-circle-outline" size={22} color="#fff" />
|
||||
<Text style={styles.actionButtonText}>{t('home.view_details')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.actionButtonSecondary,
|
||||
{ backgroundColor: currentTheme.colors.elevation1 },
|
||||
]}
|
||||
onPress={onRemoveItem}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons
|
||||
name="trash-outline"
|
||||
size={22}
|
||||
color={currentTheme.colors.error}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.actionButtonText,
|
||||
{ color: currentTheme.colors.error },
|
||||
]}
|
||||
>
|
||||
{t('home.remove')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import React from 'react';
|
||||
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { Theme } from '../../../contexts/ThemeContext';
|
||||
|
||||
import { styles } from './styles';
|
||||
import { ContinueWatchingItem } from './types';
|
||||
|
||||
interface ContinueWatchingPosterCardProps {
|
||||
item: ContinueWatchingItem;
|
||||
currentTheme: Theme;
|
||||
deletingItemId: string | null;
|
||||
computedPosterWidth: number;
|
||||
computedPosterHeight: number;
|
||||
isTV: boolean;
|
||||
isLargeTablet: boolean;
|
||||
posterBorderRadius: number;
|
||||
onPress: (item: ContinueWatchingItem) => void;
|
||||
onLongPress: (item: ContinueWatchingItem) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export const ContinueWatchingPosterCard = React.memo(({
|
||||
item,
|
||||
currentTheme,
|
||||
deletingItemId,
|
||||
computedPosterWidth,
|
||||
computedPosterHeight,
|
||||
isTV,
|
||||
isLargeTablet,
|
||||
posterBorderRadius,
|
||||
onPress,
|
||||
onLongPress,
|
||||
t,
|
||||
}: ContinueWatchingPosterCardProps) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.posterContentItem,
|
||||
{
|
||||
width: computedPosterWidth,
|
||||
},
|
||||
]}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => onPress(item)}
|
||||
onLongPress={() => onLongPress(item)}
|
||||
delayLongPress={800}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.posterImageContainer,
|
||||
{
|
||||
height: computedPosterHeight,
|
||||
borderRadius: posterBorderRadius,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.poster || 'https://via.placeholder.com/300x450',
|
||||
priority: FastImage.priority.high,
|
||||
cache: FastImage.cacheControl.immutable,
|
||||
}}
|
||||
style={[styles.posterImage, { borderRadius: posterBorderRadius }]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.8)']}
|
||||
style={[styles.posterGradient, { borderRadius: posterBorderRadius }]}
|
||||
/>
|
||||
|
||||
{item.type === 'series' && item.season && item.episode ? (
|
||||
<View style={styles.posterEpisodeOverlay}>
|
||||
<Text
|
||||
style={[
|
||||
styles.posterEpisodeText,
|
||||
{ fontSize: isTV ? 14 : isLargeTablet ? 13 : 12 },
|
||||
]}
|
||||
>
|
||||
S{item.season} E{item.episode}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{item.type === 'series' && item.progress === 0 ? (
|
||||
<View
|
||||
style={[
|
||||
styles.posterUpNextBadge,
|
||||
{ backgroundColor: currentTheme.colors.primary },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.posterUpNextText,
|
||||
{ fontSize: isTV ? 12 : 10 },
|
||||
]}
|
||||
>
|
||||
{t('home.up_next_caps')}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{item.progress > 0 ? (
|
||||
<View style={styles.posterProgressContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.posterProgressTrack,
|
||||
{ backgroundColor: 'rgba(255,255,255,0.3)' },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.posterProgressBar,
|
||||
{
|
||||
width: `${item.progress}%`,
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{deletingItemId === item.id ? (
|
||||
<View style={[styles.deletingOverlay, { borderRadius: posterBorderRadius }]}>
|
||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.posterTitleContainer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.posterTitle,
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : 14,
|
||||
},
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.progress > 0 ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.posterProgressLabel,
|
||||
{
|
||||
color: currentTheme.colors.textMuted,
|
||||
fontSize: isTV ? 13 : 11,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{Math.round(item.progress)}%
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
));
|
||||
|
||||
ContinueWatchingPosterCard.displayName = 'ContinueWatchingPosterCard';
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
import React from 'react';
|
||||
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { Theme } from '../../../contexts/ThemeContext';
|
||||
|
||||
import { styles } from './styles';
|
||||
import { ContinueWatchingItem } from './types';
|
||||
|
||||
interface ContinueWatchingWideCardProps {
|
||||
item: ContinueWatchingItem;
|
||||
currentTheme: Theme;
|
||||
deletingItemId: string | null;
|
||||
computedItemWidth: number;
|
||||
computedItemHeight: number;
|
||||
isTV: boolean;
|
||||
isLargeTablet: boolean;
|
||||
isTablet: boolean;
|
||||
posterBorderRadius: number;
|
||||
onPress: (item: ContinueWatchingItem) => void;
|
||||
onLongPress: (item: ContinueWatchingItem) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export const ContinueWatchingWideCard = React.memo(({
|
||||
item,
|
||||
currentTheme,
|
||||
deletingItemId,
|
||||
computedItemWidth,
|
||||
computedItemHeight,
|
||||
isTV,
|
||||
isLargeTablet,
|
||||
isTablet,
|
||||
posterBorderRadius,
|
||||
onPress,
|
||||
onLongPress,
|
||||
t,
|
||||
}: ContinueWatchingWideCardProps) => {
|
||||
const isUpNext = item.type === 'series' && item.progress === 0;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.wideContentItem,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
borderColor: currentTheme.colors.border,
|
||||
shadowColor: currentTheme.colors.black,
|
||||
width: computedItemWidth,
|
||||
height: computedItemHeight,
|
||||
borderRadius: posterBorderRadius,
|
||||
},
|
||||
]}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => onPress(item)}
|
||||
onLongPress={() => onLongPress(item)}
|
||||
delayLongPress={800}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.posterContainer,
|
||||
{
|
||||
width: isTV ? 100 : isLargeTablet ? 90 : isTablet ? 85 : 80,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.poster || 'https://via.placeholder.com/300x450',
|
||||
priority: FastImage.priority.high,
|
||||
cache: FastImage.cacheControl.immutable,
|
||||
}}
|
||||
style={[
|
||||
styles.continueWatchingPoster,
|
||||
{
|
||||
borderTopLeftRadius: posterBorderRadius,
|
||||
borderBottomLeftRadius: posterBorderRadius,
|
||||
},
|
||||
]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
|
||||
{deletingItemId === item.id ? (
|
||||
<View style={styles.deletingOverlay}>
|
||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.contentDetails,
|
||||
{
|
||||
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.titleRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.contentTitle,
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
|
||||
},
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
|
||||
{isUpNext ? (
|
||||
<View
|
||||
style={[
|
||||
styles.progressBadge,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
|
||||
paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.progressText,
|
||||
{ fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12 },
|
||||
]}
|
||||
>
|
||||
{t('home.up_next')}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{item.type === 'series' && item.season && item.episode ? (
|
||||
<View style={styles.episodeRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.episodeText,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{t('home.season', { season: item.season })}
|
||||
</Text>
|
||||
{item.episodeTitle ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.episodeTitle,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 13 : 12,
|
||||
},
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.episodeTitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.yearText,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{item.year} • {item.type === 'movie' ? t('home.movie') : t('home.series')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{item.progress > 0 ? (
|
||||
<View style={styles.wideProgressContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.wideProgressTrack,
|
||||
{
|
||||
height: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.wideProgressBar,
|
||||
{
|
||||
width: `${item.progress}%`,
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.progressLabel,
|
||||
{
|
||||
color: currentTheme.colors.textMuted,
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 11,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{t('home.percent_watched', { percent: Math.round(item.progress) })}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
ContinueWatchingWideCard.displayName = 'ContinueWatchingWideCard';
|
||||
12
src/components/home/continueWatching/constants.ts
Normal file
12
src/components/home/continueWatching/constants.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
} as const;
|
||||
|
||||
export const REMOVAL_IGNORE_DURATION = 10000;
|
||||
export const CACHE_DURATION = 5 * 60 * 1000;
|
||||
export const TRAKT_SYNC_COOLDOWN = 0;
|
||||
export const SIMKL_SYNC_COOLDOWN = 0;
|
||||
export const TRAKT_RECONCILE_COOLDOWN = 0;
|
||||
259
src/components/home/continueWatching/dataShared.ts
Normal file
259
src/components/home/continueWatching/dataShared.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { MutableRefObject } from 'react';
|
||||
|
||||
import { StreamingContent, catalogService } from '../../../services/catalogService';
|
||||
import { storageService } from '../../../services/storageService';
|
||||
import { stremioService } from '../../../services/stremioService';
|
||||
import { TraktContentData } from '../../../services/traktService';
|
||||
|
||||
import { CACHE_DURATION } from './constants';
|
||||
import {
|
||||
CachedMetadataEntry,
|
||||
GetCachedMetadata,
|
||||
LocalProgressEntry,
|
||||
} from './dataTypes';
|
||||
import { ContinueWatchingItem } from './types';
|
||||
import {
|
||||
compareContinueWatchingItems,
|
||||
getContinueWatchingItemKey,
|
||||
getContinueWatchingRemoveId,
|
||||
getIdVariants,
|
||||
isEpisodeReleased,
|
||||
shouldPreferContinueWatchingCandidate,
|
||||
toYearNumber,
|
||||
} from './utils';
|
||||
|
||||
export function createGetCachedMetadata(
|
||||
metadataCache: MutableRefObject<Record<string, CachedMetadataEntry>>
|
||||
): GetCachedMetadata {
|
||||
return async (type: string, id: string, addonId?: string) => {
|
||||
const cacheKey = `${type}:${id}:${addonId || 'default'}`;
|
||||
const cached = metadataCache.current[cacheKey];
|
||||
const now = Date.now();
|
||||
|
||||
if (cached && now - cached.timestamp < CACHE_DURATION) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const shouldFetchMeta = await stremioService.isValidContentId(type, id);
|
||||
const [metadata, basicContent, addonSpecificMeta] = await Promise.all([
|
||||
shouldFetchMeta ? stremioService.getMetaDetails(type, id) : Promise.resolve(null),
|
||||
catalogService.getBasicContentDetails(type, id),
|
||||
addonId
|
||||
? stremioService.getMetaDetails(type, id, addonId).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const preferredAddonMeta = addonSpecificMeta || metadata;
|
||||
|
||||
const finalContent = basicContent
|
||||
? {
|
||||
...basicContent,
|
||||
...(preferredAddonMeta?.name && { name: preferredAddonMeta.name }),
|
||||
...(preferredAddonMeta?.poster && { poster: preferredAddonMeta.poster }),
|
||||
...(preferredAddonMeta?.description && {
|
||||
description: preferredAddonMeta.description,
|
||||
}),
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!finalContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: CachedMetadataEntry = {
|
||||
metadata,
|
||||
basicContent: finalContent as StreamingContent,
|
||||
addonContent: preferredAddonMeta,
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
metadataCache.current[cacheKey] = result;
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function filterRemovedItems(
|
||||
items: ContinueWatchingItem[],
|
||||
recentlyRemoved: Set<string>
|
||||
): Promise<ContinueWatchingItem[]> {
|
||||
const filtered: ContinueWatchingItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (recentlyRemoved.has(getContinueWatchingItemKey(item))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isRemoved = await storageService.isContinueWatchingRemoved(
|
||||
getContinueWatchingRemoveId(item),
|
||||
item.type
|
||||
);
|
||||
|
||||
if (!isRemoved) {
|
||||
filtered.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function dedupeLocalItems(items: ContinueWatchingItem[]): ContinueWatchingItem[] {
|
||||
const map = new Map<string, ContinueWatchingItem>();
|
||||
|
||||
for (const item of items) {
|
||||
const key = `${item.type}:${item.id}`;
|
||||
const existing = map.get(key);
|
||||
|
||||
if (!existing) {
|
||||
map.set(key, item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldPreferContinueWatchingCandidate(item, existing)) {
|
||||
const mergedLastUpdated = Math.max(item.lastUpdated ?? 0, existing.lastUpdated ?? 0);
|
||||
map.set(
|
||||
key,
|
||||
mergedLastUpdated !== (item.lastUpdated ?? 0)
|
||||
? { ...item, lastUpdated: mergedLastUpdated }
|
||||
: item
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values()).sort(compareContinueWatchingItems);
|
||||
}
|
||||
|
||||
export function findNextEpisode(
|
||||
currentSeason: number,
|
||||
currentEpisode: number,
|
||||
videos: any[],
|
||||
watchedSet?: Set<string>,
|
||||
showId?: string,
|
||||
localWatchedMap?: Map<string, number>,
|
||||
baseTimestamp: number = 0
|
||||
): { video: any; lastWatched: number } | null {
|
||||
if (!videos || !Array.isArray(videos)) return null;
|
||||
|
||||
const sortedVideos = [...videos].sort((a, b) => {
|
||||
if (a.season !== b.season) return a.season - b.season;
|
||||
return a.episode - b.episode;
|
||||
});
|
||||
|
||||
let latestWatchedTimestamp = baseTimestamp;
|
||||
|
||||
if (localWatchedMap && showId) {
|
||||
const cleanShowId = showId.startsWith('tt') ? showId : `tt${showId}`;
|
||||
for (const video of sortedVideos) {
|
||||
const sig1 = `${cleanShowId}:${video.season}:${video.episode}`;
|
||||
const sig2 = `${showId}:${video.season}:${video.episode}`;
|
||||
latestWatchedTimestamp = Math.max(
|
||||
latestWatchedTimestamp,
|
||||
localWatchedMap.get(sig1) || 0,
|
||||
localWatchedMap.get(sig2) || 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isAlreadyWatched = (season: number, episode: number): boolean => {
|
||||
if (!showId) return false;
|
||||
|
||||
const cleanShowId = showId.startsWith('tt') ? showId : `tt${showId}`;
|
||||
const sig1 = `${cleanShowId}:${season}:${episode}`;
|
||||
const sig2 = `${showId}:${season}:${episode}`;
|
||||
|
||||
if (watchedSet && (watchedSet.has(sig1) || watchedSet.has(sig2))) return true;
|
||||
if (localWatchedMap && (localWatchedMap.has(sig1) || localWatchedMap.has(sig2))) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const video of sortedVideos) {
|
||||
if (video.season < currentSeason) continue;
|
||||
if (video.season === currentSeason && video.episode <= currentEpisode) continue;
|
||||
if (isAlreadyWatched(video.season, video.episode)) continue;
|
||||
|
||||
if (isEpisodeReleased(video)) {
|
||||
return { video, lastWatched: latestWatchedTimestamp };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildTraktContentData(
|
||||
item: ContinueWatchingItem
|
||||
): TraktContentData | null {
|
||||
if (item.type === 'movie') {
|
||||
return {
|
||||
type: 'movie',
|
||||
imdbId: item.id,
|
||||
title: item.name,
|
||||
year: toYearNumber((item as any).year),
|
||||
};
|
||||
}
|
||||
|
||||
if (item.type === 'series' && item.season && item.episode) {
|
||||
return {
|
||||
type: 'episode',
|
||||
imdbId: item.id,
|
||||
title: item.episodeTitle || `S${item.season}E${item.episode}`,
|
||||
season: item.season,
|
||||
episode: item.episode,
|
||||
showTitle: item.name,
|
||||
showYear: toYearNumber((item as any).year),
|
||||
showImdbId: item.id,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getLocalMatches(
|
||||
item: ContinueWatchingItem,
|
||||
localProgressIndex: Map<string, LocalProgressEntry[]> | null
|
||||
): LocalProgressEntry[] {
|
||||
if (!localProgressIndex) return [];
|
||||
|
||||
const matches: LocalProgressEntry[] = [];
|
||||
|
||||
for (const idVariant of getIdVariants(item.id)) {
|
||||
const entries = localProgressIndex.get(`${item.type}:${idVariant}`);
|
||||
if (!entries?.length) continue;
|
||||
|
||||
if (item.type === 'movie') {
|
||||
matches.push(...entries);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.season === undefined || item.episode === undefined) continue;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.season === item.season && entry.episode === item.episode) {
|
||||
matches.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function getMostRecentLocalMatch(
|
||||
matches: LocalProgressEntry[]
|
||||
): LocalProgressEntry | null {
|
||||
return matches.reduce<LocalProgressEntry | null>((acc, cur) => {
|
||||
if (!acc) return cur;
|
||||
return (cur.lastUpdated ?? 0) > (acc.lastUpdated ?? 0) ? cur : acc;
|
||||
}, null);
|
||||
}
|
||||
|
||||
export function getHighestLocalMatch(
|
||||
matches: LocalProgressEntry[]
|
||||
): LocalProgressEntry | null {
|
||||
return matches.reduce<LocalProgressEntry | null>((acc, cur) => {
|
||||
if (!acc) return cur;
|
||||
return (cur.progressPercent ?? 0) > (acc.progressPercent ?? 0) ? cur : acc;
|
||||
}, null);
|
||||
}
|
||||
31
src/components/home/continueWatching/dataTypes.ts
Normal file
31
src/components/home/continueWatching/dataTypes.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { StreamingContent } from '../../../services/catalogService';
|
||||
|
||||
import { ContinueWatchingItem } from './types';
|
||||
|
||||
export interface CachedMetadataEntry {
|
||||
metadata: any;
|
||||
basicContent: StreamingContent | null;
|
||||
addonContent?: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface LocalProgressEntry {
|
||||
episodeId?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
progressPercent: number;
|
||||
lastUpdated: number;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export type GetCachedMetadata = (
|
||||
type: string,
|
||||
id: string,
|
||||
addonId?: string
|
||||
) => Promise<CachedMetadataEntry | null>;
|
||||
|
||||
export interface LoadLocalContinueWatchingResult {
|
||||
items: ContinueWatchingItem[];
|
||||
shouldClearItems: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
import { storageService } from '../../../services/storageService';
|
||||
|
||||
import { GetCachedMetadata, LoadLocalContinueWatchingResult } from './dataTypes';
|
||||
import { ContinueWatchingItem } from './types';
|
||||
import { findNextEpisode } from './dataShared';
|
||||
import { isSupportedId, parseEpisodeId } from './utils';
|
||||
|
||||
interface LoadLocalContinueWatchingParams {
|
||||
getCachedMetadata: GetCachedMetadata;
|
||||
traktMoviesSetPromise: Promise<Set<string>>;
|
||||
traktShowsSetPromise: Promise<Set<string>>;
|
||||
localWatchedShowsMapPromise: Promise<Map<string, number>>;
|
||||
}
|
||||
|
||||
export async function loadLocalContinueWatching({
|
||||
getCachedMetadata,
|
||||
traktMoviesSetPromise,
|
||||
traktShowsSetPromise,
|
||||
localWatchedShowsMapPromise,
|
||||
}: LoadLocalContinueWatchingParams): Promise<LoadLocalContinueWatchingResult> {
|
||||
const allProgress = await storageService.getAllWatchProgress();
|
||||
if (Object.keys(allProgress).length === 0) {
|
||||
return { items: [], shouldClearItems: true };
|
||||
}
|
||||
|
||||
const sortedProgress = Object.entries(allProgress)
|
||||
.sort(([, a], [, b]) => b.lastUpdated - a.lastUpdated)
|
||||
.slice(0, 30);
|
||||
|
||||
const contentGroups: Record<
|
||||
string,
|
||||
{
|
||||
type: string;
|
||||
id: string;
|
||||
episodes: Array<{
|
||||
key: string;
|
||||
episodeId?: string;
|
||||
progress: any;
|
||||
progressPercent: number;
|
||||
}>;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const [key, progress] of sortedProgress) {
|
||||
const [type, id, ...episodeIdParts] = key.split(':');
|
||||
const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined;
|
||||
const progressPercent =
|
||||
progress.duration > 0 ? (progress.currentTime / progress.duration) * 100 : 0;
|
||||
|
||||
if (
|
||||
type === 'movie' &&
|
||||
(progressPercent >= 85 || !isFinite(progressPercent) || progressPercent <= 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentKey = `${type}:${id}`;
|
||||
if (!contentGroups[contentKey]) {
|
||||
contentGroups[contentKey] = { type, id, episodes: [] };
|
||||
}
|
||||
|
||||
contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent });
|
||||
}
|
||||
|
||||
const batches = await Promise.all(
|
||||
Object.values(contentGroups).map(async (group) => {
|
||||
try {
|
||||
if (!isSupportedId(group.id)) return [];
|
||||
|
||||
if (group.type === 'movie') {
|
||||
const watchedSet = await traktMoviesSetPromise;
|
||||
const imdbId = group.id.startsWith('tt') ? group.id : `tt${group.id}`;
|
||||
|
||||
if (watchedSet.has(imdbId)) {
|
||||
try {
|
||||
const existingMovieProgress = group.episodes[0]?.progress;
|
||||
await storageService.setWatchProgress(
|
||||
group.id,
|
||||
'movie',
|
||||
{
|
||||
currentTime: existingMovieProgress?.currentTime ?? 1,
|
||||
duration: existingMovieProgress?.duration ?? 1,
|
||||
lastUpdated: existingMovieProgress?.lastUpdated ?? Date.now(),
|
||||
traktSynced: true,
|
||||
traktProgress: 100,
|
||||
} as any,
|
||||
undefined,
|
||||
{ preserveTimestamp: true }
|
||||
);
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const cachedData = await getCachedMetadata(
|
||||
group.type,
|
||||
group.id,
|
||||
group.episodes[0]?.progress?.addonId
|
||||
);
|
||||
if (!cachedData?.basicContent) return [];
|
||||
|
||||
const { metadata, basicContent } = cachedData;
|
||||
const batch: ContinueWatchingItem[] = [];
|
||||
|
||||
for (const episode of group.episodes) {
|
||||
const { episodeId, progress, progressPercent } = episode;
|
||||
|
||||
if (group.type === 'series' && progressPercent >= 85) {
|
||||
const parsedEpisode = parseEpisodeId(episodeId);
|
||||
|
||||
if (
|
||||
parsedEpisode?.season !== undefined &&
|
||||
parsedEpisode?.episode !== undefined &&
|
||||
metadata?.videos
|
||||
) {
|
||||
const watchedEpisodesSet = await traktShowsSetPromise;
|
||||
const localWatchedMap = await localWatchedShowsMapPromise;
|
||||
const baseTimestamp =
|
||||
progress.currentTime === 1 && progress.duration === 1
|
||||
? 0
|
||||
: progress.lastUpdated;
|
||||
|
||||
const nextEpisodeResult = findNextEpisode(
|
||||
parsedEpisode.season,
|
||||
parsedEpisode.episode,
|
||||
metadata.videos,
|
||||
watchedEpisodesSet,
|
||||
group.id,
|
||||
localWatchedMap,
|
||||
baseTimestamp
|
||||
);
|
||||
|
||||
if (nextEpisodeResult) {
|
||||
const nextEpisode = nextEpisodeResult.video;
|
||||
batch.push({
|
||||
...basicContent,
|
||||
progress: 0,
|
||||
lastUpdated: nextEpisodeResult.lastWatched,
|
||||
season: nextEpisode.season,
|
||||
episode: nextEpisode.episode,
|
||||
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
|
||||
addonId: progress.addonId,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
let season: number | undefined;
|
||||
let episodeNumber: number | undefined;
|
||||
let episodeTitle: string | undefined;
|
||||
let isWatchedOnTrakt = false;
|
||||
|
||||
if (episodeId && group.type === 'series') {
|
||||
const parsedEpisode = parseEpisodeId(episodeId);
|
||||
season = parsedEpisode?.season;
|
||||
episodeNumber = parsedEpisode?.episode;
|
||||
|
||||
if (episodeNumber !== undefined) {
|
||||
episodeTitle = `Episode ${episodeNumber}`;
|
||||
}
|
||||
|
||||
if (season !== undefined && episodeNumber !== undefined) {
|
||||
const watchedEpisodesSet = await traktShowsSetPromise;
|
||||
const localWatchedMap = await localWatchedShowsMapPromise;
|
||||
const rawId = group.id.replace(/^tt/, '');
|
||||
const ttId = `tt${rawId}`;
|
||||
|
||||
const signatures = [
|
||||
`${ttId}:${season}:${episodeNumber}`,
|
||||
`${rawId}:${season}:${episodeNumber}`,
|
||||
`${group.id}:${season}:${episodeNumber}`,
|
||||
];
|
||||
|
||||
isWatchedOnTrakt = signatures.some(
|
||||
(signature) =>
|
||||
watchedEpisodesSet.has(signature) || localWatchedMap.has(signature)
|
||||
);
|
||||
|
||||
if (isWatchedOnTrakt) {
|
||||
try {
|
||||
await storageService.setWatchProgress(
|
||||
group.id,
|
||||
'series',
|
||||
{
|
||||
currentTime: progress.currentTime ?? 1,
|
||||
duration: progress.duration ?? 1,
|
||||
lastUpdated: progress.lastUpdated,
|
||||
traktSynced: true,
|
||||
traktProgress: 100,
|
||||
} as any,
|
||||
episodeId,
|
||||
{ preserveTimestamp: true }
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isWatchedOnTrakt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.push({
|
||||
...basicContent,
|
||||
progress: progressPercent,
|
||||
lastUpdated: progress.lastUpdated,
|
||||
season,
|
||||
episode: episodeNumber,
|
||||
episodeTitle,
|
||||
addonId: progress.addonId,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
|
||||
return batch;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
items: batches.flat(),
|
||||
shouldClearItems: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
|
||||
import { SimklService } from '../../../services/simklService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
import { SIMKL_SYNC_COOLDOWN } from './constants';
|
||||
import { GetCachedMetadata, LocalProgressEntry } from './dataTypes';
|
||||
import {
|
||||
filterRemovedItems,
|
||||
findNextEpisode,
|
||||
getLocalMatches,
|
||||
getMostRecentLocalMatch,
|
||||
} from './dataShared';
|
||||
import { ContinueWatchingItem } from './types';
|
||||
import { compareContinueWatchingItems } from './utils';
|
||||
|
||||
interface MergeSimklContinueWatchingParams {
|
||||
simklService: SimklService;
|
||||
getCachedMetadata: GetCachedMetadata;
|
||||
localProgressIndex: Map<string, LocalProgressEntry[]> | null;
|
||||
traktShowsSetPromise: Promise<Set<string>>;
|
||||
localWatchedShowsMapPromise: Promise<Map<string, number>>;
|
||||
recentlyRemoved: Set<string>;
|
||||
lastSimklSyncRef: MutableRefObject<number>;
|
||||
setContinueWatchingItems: Dispatch<SetStateAction<ContinueWatchingItem[]>>;
|
||||
}
|
||||
|
||||
export async function mergeSimklContinueWatching({
|
||||
simklService,
|
||||
getCachedMetadata,
|
||||
localProgressIndex,
|
||||
traktShowsSetPromise,
|
||||
localWatchedShowsMapPromise,
|
||||
recentlyRemoved,
|
||||
lastSimklSyncRef,
|
||||
setContinueWatchingItems,
|
||||
}: MergeSimklContinueWatchingParams): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
SIMKL_SYNC_COOLDOWN > 0 &&
|
||||
now - lastSimklSyncRef.current < SIMKL_SYNC_COOLDOWN
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastSimklSyncRef.current = now;
|
||||
|
||||
const playbackItems = await simklService.getPlaybackStatus();
|
||||
const simklBatch: ContinueWatchingItem[] = [];
|
||||
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const sortedPlaybackItems = [...playbackItems]
|
||||
.sort((a, b) => new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime())
|
||||
.slice(0, 30);
|
||||
|
||||
for (const item of sortedPlaybackItems) {
|
||||
try {
|
||||
if ((item.progress ?? 0) < 2) continue;
|
||||
|
||||
const pausedAt = new Date(item.paused_at).getTime();
|
||||
if (pausedAt < thirtyDaysAgo) continue;
|
||||
|
||||
if (item.type === 'movie' && item.movie?.ids?.imdb) {
|
||||
if (item.progress >= 85) continue;
|
||||
|
||||
const imdbId = item.movie.ids.imdb.startsWith('tt')
|
||||
? item.movie.ids.imdb
|
||||
: `tt${item.movie.ids.imdb}`;
|
||||
|
||||
if (recentlyRemoved.has(`movie:${imdbId}`)) continue;
|
||||
|
||||
const cachedData = await getCachedMetadata('movie', imdbId);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
|
||||
simklBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: imdbId,
|
||||
type: 'movie',
|
||||
progress: item.progress,
|
||||
lastUpdated: pausedAt,
|
||||
addonId: undefined,
|
||||
} as ContinueWatchingItem);
|
||||
} 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}`;
|
||||
const episodeNum = item.episode.episode ?? item.episode.number;
|
||||
|
||||
if (episodeNum === undefined || episodeNum === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recentlyRemoved.has(`series:${showImdb}`)) continue;
|
||||
|
||||
const cachedData = await getCachedMetadata('series', showImdb);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
|
||||
if (item.progress >= 85) {
|
||||
if (cachedData.metadata?.videos) {
|
||||
const watchedEpisodesSet = await traktShowsSetPromise;
|
||||
const localWatchedMap = await localWatchedShowsMapPromise;
|
||||
const nextEpisodeResult = findNextEpisode(
|
||||
item.episode.season,
|
||||
episodeNum,
|
||||
cachedData.metadata.videos,
|
||||
watchedEpisodesSet,
|
||||
showImdb,
|
||||
localWatchedMap,
|
||||
pausedAt
|
||||
);
|
||||
|
||||
if (nextEpisodeResult) {
|
||||
const nextEpisode = nextEpisodeResult.video;
|
||||
simklBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: showImdb,
|
||||
type: 'series',
|
||||
progress: 0,
|
||||
lastUpdated: nextEpisodeResult.lastWatched,
|
||||
season: nextEpisode.season,
|
||||
episode: nextEpisode.episode,
|
||||
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
|
||||
addonId: undefined,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
simklBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: showImdb,
|
||||
type: 'series',
|
||||
progress: item.progress,
|
||||
lastUpdated: pausedAt,
|
||||
season: item.episode.season,
|
||||
episode: episodeNum,
|
||||
episodeTitle: item.episode.title || `Episode ${episodeNum}`,
|
||||
addonId: undefined,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
} catch {
|
||||
// Keep processing remaining playback items.
|
||||
}
|
||||
}
|
||||
|
||||
if (simklBatch.length === 0) {
|
||||
setContinueWatchingItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const deduped = new Map<string, ContinueWatchingItem>();
|
||||
for (const item of simklBatch) {
|
||||
const key = `${item.type}:${item.id}`;
|
||||
const existing = deduped.get(key);
|
||||
if (!existing || (item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
|
||||
deduped.set(key, item);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = await filterRemovedItems(Array.from(deduped.values()), recentlyRemoved);
|
||||
|
||||
const adjustedItems = filteredItems.map((item) => {
|
||||
const matches = getLocalMatches(item, localProgressIndex);
|
||||
if (matches.length === 0) return item;
|
||||
|
||||
const mostRecentLocal = getMostRecentLocalMatch(matches);
|
||||
if (!mostRecentLocal) return item;
|
||||
|
||||
const localProgress = mostRecentLocal.progressPercent;
|
||||
const simklProgress = item.progress ?? 0;
|
||||
const localTs = mostRecentLocal.lastUpdated ?? 0;
|
||||
const simklTs = item.lastUpdated ?? 0;
|
||||
|
||||
const isAhead = isFinite(localProgress) && localProgress > simklProgress + 0.5;
|
||||
const isLocalNewer = localTs > simklTs + 5000;
|
||||
|
||||
if (isAhead || isLocalNewer) {
|
||||
return {
|
||||
...item,
|
||||
progress: localProgress,
|
||||
lastUpdated: localTs > 0 ? localTs : item.lastUpdated,
|
||||
} as ContinueWatchingItem;
|
||||
}
|
||||
|
||||
if (localTs > 0 && localTs > simklTs) {
|
||||
return {
|
||||
...item,
|
||||
lastUpdated: localTs,
|
||||
} as ContinueWatchingItem;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
adjustedItems.sort(compareContinueWatchingItems);
|
||||
setContinueWatchingItems(adjustedItems);
|
||||
}
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
import { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
|
||||
import { storageService } from '../../../services/storageService';
|
||||
import {
|
||||
TraktService,
|
||||
TraktWatchedItem,
|
||||
} from '../../../services/traktService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
import { TRAKT_RECONCILE_COOLDOWN, TRAKT_SYNC_COOLDOWN } from './constants';
|
||||
import { GetCachedMetadata, LocalProgressEntry } from './dataTypes';
|
||||
import {
|
||||
buildTraktContentData,
|
||||
filterRemovedItems,
|
||||
findNextEpisode,
|
||||
getHighestLocalMatch,
|
||||
getLocalMatches,
|
||||
getMostRecentLocalMatch,
|
||||
} from './dataShared';
|
||||
import { ContinueWatchingItem } from './types';
|
||||
import { compareContinueWatchingItems } from './utils';
|
||||
|
||||
interface MergeTraktContinueWatchingParams {
|
||||
traktService: TraktService;
|
||||
getCachedMetadata: GetCachedMetadata;
|
||||
localProgressIndex: Map<string, LocalProgressEntry[]> | null;
|
||||
localWatchedShowsMapPromise: Promise<Map<string, number>>;
|
||||
recentlyRemoved: Set<string>;
|
||||
lastTraktSyncRef: MutableRefObject<number>;
|
||||
lastTraktReconcileRef: MutableRefObject<Map<string, number>>;
|
||||
setContinueWatchingItems: Dispatch<SetStateAction<ContinueWatchingItem[]>>;
|
||||
}
|
||||
|
||||
export async function mergeTraktContinueWatching({
|
||||
traktService,
|
||||
getCachedMetadata,
|
||||
localProgressIndex,
|
||||
localWatchedShowsMapPromise,
|
||||
recentlyRemoved,
|
||||
lastTraktSyncRef,
|
||||
lastTraktReconcileRef,
|
||||
setContinueWatchingItems,
|
||||
}: MergeTraktContinueWatchingParams): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
TRAKT_SYNC_COOLDOWN > 0 &&
|
||||
now - lastTraktSyncRef.current < TRAKT_SYNC_COOLDOWN
|
||||
) {
|
||||
logger.log(
|
||||
`[TraktSync] Skipping Trakt sync - cooldown active (${Math.round((TRAKT_SYNC_COOLDOWN - (now - lastTraktSyncRef.current)) / 1000)}s remaining)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
lastTraktSyncRef.current = now;
|
||||
const playbackItems = await traktService.getPlaybackProgress();
|
||||
const traktBatch: ContinueWatchingItem[] = [];
|
||||
|
||||
let watchedShowsData: TraktWatchedItem[] = [];
|
||||
const watchedEpisodeSetByShow = new Map<string, Set<string>>();
|
||||
|
||||
try {
|
||||
watchedShowsData = await traktService.getWatchedShows();
|
||||
for (const watchedShow of watchedShowsData) {
|
||||
if (!watchedShow.show?.ids?.imdb) continue;
|
||||
|
||||
const imdb = watchedShow.show.ids.imdb.startsWith('tt')
|
||||
? watchedShow.show.ids.imdb
|
||||
: `tt${watchedShow.show.ids.imdb}`;
|
||||
const resetAt = watchedShow.reset_at ? new Date(watchedShow.reset_at).getTime() : 0;
|
||||
const episodeSet = new Set<string>();
|
||||
|
||||
if (watchedShow.seasons) {
|
||||
for (const season of watchedShow.seasons) {
|
||||
for (const episode of season.episodes) {
|
||||
if (resetAt > 0) {
|
||||
const watchedAt = new Date(episode.last_watched_at).getTime();
|
||||
if (watchedAt < resetAt) continue;
|
||||
}
|
||||
|
||||
episodeSet.add(`${imdb}:${season.number}:${episode.number}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watchedEpisodeSetByShow.set(imdb, episodeSet);
|
||||
}
|
||||
} catch {
|
||||
// Continue without watched-show acceleration.
|
||||
}
|
||||
|
||||
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
|
||||
const sortedPlaybackItems = [...playbackItems]
|
||||
.sort((a, b) => new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime())
|
||||
.slice(0, 30);
|
||||
|
||||
for (const item of sortedPlaybackItems) {
|
||||
try {
|
||||
if (item.progress < 2) continue;
|
||||
|
||||
const pausedAt = new Date(item.paused_at).getTime();
|
||||
if (pausedAt < thirtyDaysAgo) continue;
|
||||
|
||||
if (item.type === 'movie' && item.movie?.ids?.imdb) {
|
||||
if (item.progress >= 85) continue;
|
||||
|
||||
const imdbId = item.movie.ids.imdb.startsWith('tt')
|
||||
? item.movie.ids.imdb
|
||||
: `tt${item.movie.ids.imdb}`;
|
||||
|
||||
if (recentlyRemoved.has(`movie:${imdbId}`)) continue;
|
||||
|
||||
const cachedData = await getCachedMetadata('movie', imdbId);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
|
||||
traktBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: imdbId,
|
||||
type: 'movie',
|
||||
progress: item.progress,
|
||||
lastUpdated: pausedAt,
|
||||
addonId: undefined,
|
||||
traktPlaybackId: item.id,
|
||||
} as ContinueWatchingItem);
|
||||
} 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}`;
|
||||
|
||||
if (recentlyRemoved.has(`series:${showImdb}`)) continue;
|
||||
|
||||
const cachedData = await getCachedMetadata('series', showImdb);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
|
||||
if (item.progress >= 85) {
|
||||
if (cachedData.metadata?.videos) {
|
||||
const watchedSetForShow = watchedEpisodeSetByShow.get(showImdb);
|
||||
const localWatchedMap = await localWatchedShowsMapPromise;
|
||||
const nextEpisodeResult = findNextEpisode(
|
||||
item.episode.season,
|
||||
item.episode.number,
|
||||
cachedData.metadata.videos,
|
||||
watchedSetForShow,
|
||||
showImdb,
|
||||
localWatchedMap,
|
||||
pausedAt
|
||||
);
|
||||
|
||||
if (nextEpisodeResult) {
|
||||
const nextEpisode = nextEpisodeResult.video;
|
||||
traktBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: showImdb,
|
||||
type: 'series',
|
||||
progress: 0,
|
||||
lastUpdated: nextEpisodeResult.lastWatched,
|
||||
season: nextEpisode.season,
|
||||
episode: nextEpisode.episode,
|
||||
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
|
||||
addonId: undefined,
|
||||
traktPlaybackId: item.id,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
}
|
||||
|
||||
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}`,
|
||||
addonId: undefined,
|
||||
traktPlaybackId: item.id,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
} catch {
|
||||
// Continue with remaining playback items.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const watchedShow of watchedShowsData) {
|
||||
try {
|
||||
if (!watchedShow.show?.ids?.imdb) continue;
|
||||
|
||||
const lastWatchedAt = new Date(watchedShow.last_watched_at).getTime();
|
||||
if (lastWatchedAt < thirtyDaysAgoForShows) continue;
|
||||
|
||||
const showImdb = watchedShow.show.ids.imdb.startsWith('tt')
|
||||
? watchedShow.show.ids.imdb
|
||||
: `tt${watchedShow.show.ids.imdb}`;
|
||||
|
||||
if (recentlyRemoved.has(`series:${showImdb}`)) continue;
|
||||
|
||||
const resetAt = watchedShow.reset_at ? new Date(watchedShow.reset_at).getTime() : 0;
|
||||
let lastWatchedSeason = 0;
|
||||
let lastWatchedEpisode = 0;
|
||||
let latestEpisodeTimestamp = 0;
|
||||
|
||||
if (watchedShow.seasons) {
|
||||
for (const season of watchedShow.seasons) {
|
||||
for (const episode of season.episodes) {
|
||||
const episodeTimestamp = new Date(episode.last_watched_at).getTime();
|
||||
if (resetAt > 0 && episodeTimestamp < resetAt) continue;
|
||||
|
||||
if (episodeTimestamp > latestEpisodeTimestamp) {
|
||||
latestEpisodeTimestamp = episodeTimestamp;
|
||||
lastWatchedSeason = season.number;
|
||||
lastWatchedEpisode = episode.number;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastWatchedSeason === 0 && lastWatchedEpisode === 0) continue;
|
||||
|
||||
const cachedData = await getCachedMetadata('series', showImdb);
|
||||
if (!cachedData?.basicContent || !cachedData.metadata?.videos) continue;
|
||||
|
||||
const watchedEpisodeSet = watchedEpisodeSetByShow.get(showImdb) ?? new Set<string>();
|
||||
const localWatchedMap = await localWatchedShowsMapPromise;
|
||||
const nextEpisodeResult = findNextEpisode(
|
||||
lastWatchedSeason,
|
||||
lastWatchedEpisode,
|
||||
cachedData.metadata.videos,
|
||||
watchedEpisodeSet,
|
||||
showImdb,
|
||||
localWatchedMap,
|
||||
latestEpisodeTimestamp
|
||||
);
|
||||
|
||||
if (nextEpisodeResult) {
|
||||
const nextEpisode = nextEpisodeResult.video;
|
||||
traktBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: showImdb,
|
||||
type: 'series',
|
||||
progress: 0,
|
||||
lastUpdated: nextEpisodeResult.lastWatched,
|
||||
season: nextEpisode.season,
|
||||
episode: nextEpisode.episode,
|
||||
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
|
||||
addonId: undefined,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
} catch {
|
||||
// Continue with remaining watched shows.
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[TraktSync] Error fetching watched shows for Up Next:', err);
|
||||
}
|
||||
|
||||
if (traktBatch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deduped = new Map<string, ContinueWatchingItem>();
|
||||
for (const item of traktBatch) {
|
||||
const key = `${item.type}:${item.id}`;
|
||||
const existing = deduped.get(key);
|
||||
|
||||
if (!existing) {
|
||||
deduped.set(key, item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingHasProgress = (existing.progress ?? 0) > 0;
|
||||
const candidateHasProgress = (item.progress ?? 0) > 0;
|
||||
|
||||
if (candidateHasProgress && !existingHasProgress) {
|
||||
const mergedTs = Math.max(item.lastUpdated ?? 0, existing.lastUpdated ?? 0);
|
||||
deduped.set(
|
||||
key,
|
||||
mergedTs !== (item.lastUpdated ?? 0)
|
||||
? { ...item, lastUpdated: mergedTs }
|
||||
: item
|
||||
);
|
||||
} else if (!candidateHasProgress && existingHasProgress) {
|
||||
if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
|
||||
deduped.set(key, { ...existing, lastUpdated: item.lastUpdated });
|
||||
}
|
||||
} else if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
|
||||
deduped.set(key, item);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = await filterRemovedItems(Array.from(deduped.values()), recentlyRemoved);
|
||||
const reconcilePromises: Promise<any>[] = [];
|
||||
const reconcileLocalPromises: Promise<any>[] = [];
|
||||
|
||||
const adjustedItems = filteredItems
|
||||
.map((item) => {
|
||||
const matches = getLocalMatches(item, localProgressIndex);
|
||||
if (matches.length === 0) return item;
|
||||
|
||||
const mostRecentLocal = getMostRecentLocalMatch(matches);
|
||||
const highestLocal = getHighestLocalMatch(matches);
|
||||
|
||||
if (!mostRecentLocal || !highestLocal) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const mergedLastUpdated = Math.max(
|
||||
mostRecentLocal.lastUpdated ?? 0,
|
||||
item.lastUpdated ?? 0
|
||||
);
|
||||
const localProgress = mostRecentLocal.progressPercent;
|
||||
const traktProgress = item.progress ?? 0;
|
||||
const traktTs = item.lastUpdated ?? 0;
|
||||
const localTs = mostRecentLocal.lastUpdated ?? 0;
|
||||
|
||||
const isAhead = isFinite(localProgress) && localProgress > traktProgress + 0.5;
|
||||
const isLocalNewer = localTs > traktTs + 5000;
|
||||
const isLocalRecent = localTs > 0 && Date.now() - localTs < 5 * 60 * 1000;
|
||||
const isDifferent = Math.abs((localProgress || 0) - (traktProgress || 0)) > 0.5;
|
||||
const isTraktAhead = isFinite(traktProgress) && traktProgress > localProgress + 0.5;
|
||||
|
||||
if (isTraktAhead && !isLocalRecent && mostRecentLocal.duration > 0) {
|
||||
const reconcileKey = `local:${item.type}:${item.id}:${item.season ?? ''}:${item.episode ?? ''}`;
|
||||
const last = lastTraktReconcileRef.current.get(reconcileKey) ?? 0;
|
||||
const now = Date.now();
|
||||
|
||||
if (now - last >= TRAKT_RECONCILE_COOLDOWN) {
|
||||
lastTraktReconcileRef.current.set(reconcileKey, now);
|
||||
|
||||
const targetEpisodeId =
|
||||
item.type === 'series'
|
||||
? mostRecentLocal.episodeId ||
|
||||
(item.season && item.episode
|
||||
? `${item.id}:${item.season}:${item.episode}`
|
||||
: undefined)
|
||||
: undefined;
|
||||
|
||||
const newCurrentTime = (traktProgress / 100) * mostRecentLocal.duration;
|
||||
|
||||
reconcileLocalPromises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const existing = await storageService.getWatchProgress(
|
||||
item.id,
|
||||
item.type,
|
||||
targetEpisodeId
|
||||
);
|
||||
|
||||
if (!existing || !existing.duration || existing.duration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await storageService.setWatchProgress(
|
||||
item.id,
|
||||
item.type,
|
||||
{
|
||||
...existing,
|
||||
currentTime: Math.max(existing.currentTime ?? 0, newCurrentTime),
|
||||
duration: existing.duration,
|
||||
traktSynced: true,
|
||||
traktLastSynced: Date.now(),
|
||||
traktProgress: Math.max(existing.traktProgress ?? 0, traktProgress),
|
||||
lastUpdated: existing.lastUpdated,
|
||||
} as any,
|
||||
targetEpisodeId,
|
||||
{ preserveTimestamp: true, forceWrite: true }
|
||||
);
|
||||
} catch {
|
||||
// Ignore background sync failures.
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ((isAhead || ((isLocalNewer || isLocalRecent) && isDifferent)) && localProgress >= 2) {
|
||||
const reconcileKey = `${item.type}:${item.id}:${item.season ?? ''}:${item.episode ?? ''}`;
|
||||
const last = lastTraktReconcileRef.current.get(reconcileKey) ?? 0;
|
||||
const now = Date.now();
|
||||
|
||||
if (now - last >= TRAKT_RECONCILE_COOLDOWN) {
|
||||
lastTraktReconcileRef.current.set(reconcileKey, now);
|
||||
|
||||
const contentData = buildTraktContentData(item);
|
||||
if (contentData) {
|
||||
const progressToSend =
|
||||
localProgress >= 85
|
||||
? Math.min(localProgress, 100)
|
||||
: Math.min(localProgress, 79.9);
|
||||
|
||||
reconcilePromises.push(
|
||||
traktService.pauseWatching(contentData, progressToSend).catch(() => null)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (((isLocalNewer || isLocalRecent) && isDifferent) || isAhead) {
|
||||
return {
|
||||
...item,
|
||||
progress: localProgress,
|
||||
lastUpdated: mergedLastUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
lastUpdated: mergedLastUpdated,
|
||||
};
|
||||
})
|
||||
.filter((item) => (item.progress ?? 0) < 85);
|
||||
|
||||
adjustedItems.sort(compareContinueWatchingItems);
|
||||
setContinueWatchingItems(adjustedItems);
|
||||
|
||||
if (reconcilePromises.length > 0) {
|
||||
Promise.allSettled(reconcilePromises).catch(() => null);
|
||||
}
|
||||
|
||||
if (reconcileLocalPromises.length > 0) {
|
||||
Promise.allSettled(reconcileLocalPromises).catch(() => null);
|
||||
}
|
||||
}
|
||||
283
src/components/home/continueWatching/styles.ts
Normal file
283
src/components/home/continueWatching/styles.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { StyleSheet } from 'react-native';
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 28,
|
||||
paddingTop: 0,
|
||||
marginTop: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
titleUnderline: {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
left: 0,
|
||||
width: 40,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
opacity: 0.8,
|
||||
},
|
||||
wideList: {
|
||||
paddingBottom: 8,
|
||||
paddingTop: 4,
|
||||
},
|
||||
wideContentItem: {
|
||||
width: 280,
|
||||
height: 120,
|
||||
flexDirection: 'row',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
elevation: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 1,
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(255,255,255,0.15)',
|
||||
},
|
||||
posterContainer: {
|
||||
width: 80,
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
},
|
||||
continueWatchingPoster: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderTopLeftRadius: 12,
|
||||
borderBottomLeftRadius: 12,
|
||||
},
|
||||
deletingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderTopLeftRadius: 12,
|
||||
borderBottomLeftRadius: 12,
|
||||
},
|
||||
contentDetails: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 4,
|
||||
},
|
||||
contentTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
progressBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
minWidth: 44,
|
||||
alignItems: 'center',
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
episodeRow: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
episodeText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
},
|
||||
episodeTitle: {
|
||||
fontSize: 12,
|
||||
},
|
||||
yearText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
wideProgressContainer: {
|
||||
marginTop: 'auto',
|
||||
},
|
||||
wideProgressTrack: {
|
||||
height: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
borderRadius: 2,
|
||||
marginBottom: 4,
|
||||
},
|
||||
wideProgressBar: {
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
},
|
||||
progressLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
posterContentItem: {
|
||||
overflow: 'visible',
|
||||
},
|
||||
posterImageContainer: {
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(255,255,255,0.15)',
|
||||
elevation: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 1,
|
||||
},
|
||||
posterImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
posterGradient: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '50%',
|
||||
},
|
||||
posterEpisodeOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 4,
|
||||
},
|
||||
posterEpisodeText: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
posterUpNextBadge: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 4,
|
||||
},
|
||||
posterUpNextText: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
posterProgressContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
posterProgressTrack: {
|
||||
height: 4,
|
||||
},
|
||||
posterProgressBar: {
|
||||
height: '100%',
|
||||
},
|
||||
posterTitleContainer: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 8,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
posterTitle: {
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
lineHeight: 18,
|
||||
},
|
||||
posterProgressLabel: {
|
||||
fontWeight: '500',
|
||||
marginLeft: 6,
|
||||
},
|
||||
actionSheetContent: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 8,
|
||||
},
|
||||
actionSheetHeader: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 20,
|
||||
},
|
||||
actionSheetPoster: {
|
||||
width: 70,
|
||||
height: 105,
|
||||
borderRadius: 10,
|
||||
marginRight: 16,
|
||||
},
|
||||
actionSheetInfo: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
actionSheetTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 6,
|
||||
lineHeight: 22,
|
||||
},
|
||||
actionSheetSubtitle: {
|
||||
fontSize: 14,
|
||||
opacity: 0.8,
|
||||
lineHeight: 20,
|
||||
},
|
||||
actionSheetProgressContainer: {
|
||||
marginTop: 10,
|
||||
},
|
||||
actionSheetProgressTrack: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
actionSheetProgressBar: {
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
},
|
||||
actionSheetProgressText: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
actionSheetButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
gap: 8,
|
||||
},
|
||||
actionButtonSecondary: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
20
src/components/home/continueWatching/types.ts
Normal file
20
src/components/home/continueWatching/types.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { StreamingContent } from '../../../services/catalogService';
|
||||
|
||||
export interface ContinueWatchingItem extends StreamingContent {
|
||||
progress: number;
|
||||
lastUpdated: number;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
episodeTitle?: string;
|
||||
addonId?: string;
|
||||
addonPoster?: string;
|
||||
addonName?: string;
|
||||
addonDescription?: string;
|
||||
traktPlaybackId?: number;
|
||||
}
|
||||
|
||||
export interface ContinueWatchingRef {
|
||||
refresh: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export type ContinueWatchingDeviceType = 'phone' | 'tablet' | 'largeTablet' | 'tv';
|
||||
407
src/components/home/continueWatching/useContinueWatchingData.ts
Normal file
407
src/components/home/continueWatching/useContinueWatchingData.ts
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
|
||||
import { SimklService } from '../../../services/simklService';
|
||||
import { storageService } from '../../../services/storageService';
|
||||
import { TraktService } from '../../../services/traktService';
|
||||
import { watchedService } from '../../../services/watchedService';
|
||||
|
||||
import { REMOVAL_IGNORE_DURATION } from './constants';
|
||||
import {
|
||||
createGetCachedMetadata,
|
||||
dedupeLocalItems,
|
||||
filterRemovedItems,
|
||||
} from './dataShared';
|
||||
import { CachedMetadataEntry, LocalProgressEntry } from './dataTypes';
|
||||
import { loadLocalContinueWatching } from './loadLocalContinueWatching';
|
||||
import { mergeSimklContinueWatching } from './mergeSimklContinueWatching';
|
||||
import { mergeTraktContinueWatching } from './mergeTraktContinueWatching';
|
||||
import { ContinueWatchingItem } from './types';
|
||||
import { getContinueWatchingItemKey, getContinueWatchingRemoveId, getIdVariants, parseEpisodeId } from './utils';
|
||||
|
||||
async function getTraktMoviesSet(
|
||||
isTraktAuthed: boolean,
|
||||
traktService: TraktService
|
||||
): Promise<Set<string>> {
|
||||
try {
|
||||
if (!isTraktAuthed || typeof (traktService as any).getWatchedMovies !== 'function') {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const watched = await (traktService as any).getWatchedMovies();
|
||||
const watchedSet = new Set<string>();
|
||||
|
||||
if (Array.isArray(watched)) {
|
||||
watched.forEach((movie: any) => {
|
||||
const ids = movie?.movie?.ids;
|
||||
if (!ids) return;
|
||||
|
||||
if (ids.imdb) {
|
||||
watchedSet.add(ids.imdb.startsWith('tt') ? ids.imdb : `tt${ids.imdb}`);
|
||||
}
|
||||
if (ids.tmdb) {
|
||||
watchedSet.add(ids.tmdb.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return watchedSet;
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
async function getTraktShowsSet(
|
||||
isTraktAuthed: boolean,
|
||||
traktService: TraktService
|
||||
): Promise<Set<string>> {
|
||||
try {
|
||||
if (!isTraktAuthed || typeof (traktService as any).getWatchedShows !== 'function') {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const watched = await (traktService as any).getWatchedShows();
|
||||
const watchedSet = new Set<string>();
|
||||
|
||||
if (Array.isArray(watched)) {
|
||||
watched.forEach((show: any) => {
|
||||
const ids = show?.show?.ids;
|
||||
if (!ids) return;
|
||||
|
||||
const imdbId = ids.imdb;
|
||||
const tmdbId = ids.tmdb;
|
||||
|
||||
if (!Array.isArray(show.seasons)) return;
|
||||
|
||||
show.seasons.forEach((season: any) => {
|
||||
if (!Array.isArray(season.episodes)) return;
|
||||
|
||||
season.episodes.forEach((episode: any) => {
|
||||
if (imdbId) {
|
||||
const cleanImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
|
||||
watchedSet.add(`${cleanImdbId}:${season.number}:${episode.number}`);
|
||||
}
|
||||
if (tmdbId) {
|
||||
watchedSet.add(`${tmdbId}:${season.number}:${episode.number}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return watchedSet;
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
async function getLocalWatchedShowsMap(): Promise<Map<string, number>> {
|
||||
try {
|
||||
const watched = await watchedService.getAllWatchedItems();
|
||||
const watchedMap = new Map<string, number>();
|
||||
|
||||
watched.forEach((item) => {
|
||||
if (!item.content_id) return;
|
||||
|
||||
const cleanId = item.content_id.startsWith('tt')
|
||||
? item.content_id
|
||||
: `tt${item.content_id}`;
|
||||
|
||||
if (item.season != null && item.episode != null) {
|
||||
watchedMap.set(`${cleanId}:${item.season}:${item.episode}`, item.watched_at);
|
||||
watchedMap.set(`${item.content_id}:${item.season}:${item.episode}`, item.watched_at);
|
||||
} else {
|
||||
watchedMap.set(cleanId, item.watched_at);
|
||||
watchedMap.set(item.content_id, item.watched_at);
|
||||
}
|
||||
});
|
||||
|
||||
return watchedMap;
|
||||
} catch {
|
||||
return new Map<string, number>();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildLocalProgressIndex(
|
||||
shouldBuild: boolean
|
||||
): Promise<Map<string, LocalProgressEntry[]> | null> {
|
||||
if (!shouldBuild) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const allProgress = await storageService.getAllWatchProgress();
|
||||
const index = new Map<string, LocalProgressEntry[]>();
|
||||
|
||||
for (const [key, progress] of Object.entries(allProgress)) {
|
||||
const [type, id, ...episodeIdParts] = key.split(':');
|
||||
const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined;
|
||||
|
||||
const progressPercent =
|
||||
progress?.duration > 0 ? (progress.currentTime / progress.duration) * 100 : 0;
|
||||
|
||||
if (!isFinite(progressPercent) || progressPercent <= 0) continue;
|
||||
|
||||
const parsed = parseEpisodeId(episodeId);
|
||||
const entry: LocalProgressEntry = {
|
||||
episodeId,
|
||||
season: parsed?.season,
|
||||
episode: parsed?.episode,
|
||||
progressPercent,
|
||||
lastUpdated: progress?.lastUpdated ?? 0,
|
||||
currentTime: progress?.currentTime ?? 0,
|
||||
duration: progress?.duration ?? 0,
|
||||
};
|
||||
|
||||
for (const idVariant of getIdVariants(id)) {
|
||||
const idxKey = `${type}:${idVariant}`;
|
||||
const list = index.get(idxKey);
|
||||
if (list) {
|
||||
list.push(entry);
|
||||
} else {
|
||||
index.set(idxKey, [entry]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useContinueWatchingData() {
|
||||
const [continueWatchingItems, setContinueWatchingItems] = useState<ContinueWatchingItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingItemId, setDeletingItemId] = useState<string | null>(null);
|
||||
|
||||
const appState = useRef(AppState.currentState);
|
||||
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pendingRefreshRef = useRef(false);
|
||||
const isRefreshingRef = useRef(false);
|
||||
const recentlyRemovedRef = useRef<Set<string>>(new Set());
|
||||
const lastTraktSyncRef = useRef<number>(0);
|
||||
const lastSimklSyncRef = useRef<number>(0);
|
||||
const lastTraktReconcileRef = useRef<Map<string, number>>(new Map());
|
||||
const metadataCache = useRef<Record<string, CachedMetadataEntry>>({});
|
||||
|
||||
const getCachedMetadata = useMemo(
|
||||
() => createGetCachedMetadata(metadataCache),
|
||||
[]
|
||||
);
|
||||
|
||||
const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => {
|
||||
if (isRefreshingRef.current) {
|
||||
pendingRefreshRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isBackgroundRefresh) {
|
||||
setLoading(true);
|
||||
}
|
||||
isRefreshingRef.current = true;
|
||||
|
||||
try {
|
||||
const traktService = TraktService.getInstance();
|
||||
const isTraktAuthed = await traktService.isAuthenticated();
|
||||
|
||||
const simklService = SimklService.getInstance();
|
||||
const isSimklAuthed = !isTraktAuthed ? await simklService.isAuthenticated() : false;
|
||||
|
||||
const traktMoviesSetPromise = getTraktMoviesSet(isTraktAuthed, traktService);
|
||||
const traktShowsSetPromise = getTraktShowsSet(isTraktAuthed, traktService);
|
||||
const localWatchedShowsMapPromise = getLocalWatchedShowsMap();
|
||||
const localProgressIndex = await buildLocalProgressIndex(
|
||||
isTraktAuthed || isSimklAuthed
|
||||
);
|
||||
|
||||
if (!isTraktAuthed && !isSimklAuthed) {
|
||||
const { items, shouldClearItems } = await loadLocalContinueWatching({
|
||||
getCachedMetadata,
|
||||
traktMoviesSetPromise,
|
||||
traktShowsSetPromise,
|
||||
localWatchedShowsMapPromise,
|
||||
});
|
||||
|
||||
if (shouldClearItems) {
|
||||
setContinueWatchingItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = await filterRemovedItems(
|
||||
dedupeLocalItems(items),
|
||||
recentlyRemovedRef.current
|
||||
);
|
||||
setContinueWatchingItems(filtered);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.allSettled([
|
||||
isTraktAuthed
|
||||
? mergeTraktContinueWatching({
|
||||
traktService,
|
||||
getCachedMetadata,
|
||||
localProgressIndex,
|
||||
localWatchedShowsMapPromise,
|
||||
recentlyRemoved: recentlyRemovedRef.current,
|
||||
lastTraktSyncRef,
|
||||
lastTraktReconcileRef,
|
||||
setContinueWatchingItems,
|
||||
})
|
||||
: Promise.resolve(),
|
||||
isSimklAuthed && !isTraktAuthed
|
||||
? mergeSimklContinueWatching({
|
||||
simklService,
|
||||
getCachedMetadata,
|
||||
localProgressIndex,
|
||||
traktShowsSetPromise,
|
||||
localWatchedShowsMapPromise,
|
||||
recentlyRemoved: recentlyRemovedRef.current,
|
||||
lastSimklSyncRef,
|
||||
setContinueWatchingItems,
|
||||
})
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
} catch {
|
||||
// Keep UI usable even if sync fails.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isRefreshingRef.current = false;
|
||||
|
||||
if (pendingRefreshRef.current) {
|
||||
pendingRefreshRef.current = false;
|
||||
setTimeout(() => {
|
||||
loadContinueWatching(true);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}, [getCachedMetadata]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
metadataCache.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAppStateChange = useCallback((nextAppState: AppStateStatus) => {
|
||||
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
|
||||
lastTraktSyncRef.current = 0;
|
||||
loadContinueWatching(true);
|
||||
}
|
||||
|
||||
appState.current = nextAppState;
|
||||
}, [loadContinueWatching]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||
|
||||
const watchProgressUpdateHandler = () => {
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
loadContinueWatching(true);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
if (storageService.subscribeToWatchProgressUpdates) {
|
||||
const unsubscribe =
|
||||
storageService.subscribeToWatchProgressUpdates(watchProgressUpdateHandler);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
unsubscribe();
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => loadContinueWatching(true), 300000);
|
||||
return () => {
|
||||
subscription.remove();
|
||||
clearInterval(intervalId);
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [handleAppStateChange, loadContinueWatching]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContinueWatching();
|
||||
const trailingRefreshId = setTimeout(() => {
|
||||
loadContinueWatching(true);
|
||||
}, 4000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(trailingRefreshId);
|
||||
};
|
||||
}, [loadContinueWatching]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadContinueWatching(true);
|
||||
return () => {};
|
||||
}, [loadContinueWatching])
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
lastTraktSyncRef.current = 0;
|
||||
await loadContinueWatching(false);
|
||||
return true;
|
||||
}, [loadContinueWatching]);
|
||||
|
||||
const removeItem = useCallback(async (item: ContinueWatchingItem) => {
|
||||
setDeletingItemId(item.id);
|
||||
|
||||
try {
|
||||
const isEpisode = item.type === 'series' && item.season && item.episode;
|
||||
if (isEpisode) {
|
||||
await storageService.removeWatchProgress(
|
||||
item.id,
|
||||
item.type,
|
||||
`${item.id}:${item.season}:${item.episode}`
|
||||
);
|
||||
} else {
|
||||
await storageService.removeAllWatchProgressForContent(item.id, item.type, {
|
||||
addBaseTombstone: true,
|
||||
});
|
||||
}
|
||||
|
||||
const traktService = TraktService.getInstance();
|
||||
const isAuthed = await traktService.isAuthenticated();
|
||||
if (isAuthed && item.traktPlaybackId) {
|
||||
await traktService.removePlaybackItem(item.traktPlaybackId);
|
||||
}
|
||||
|
||||
const itemKey = getContinueWatchingItemKey(item);
|
||||
recentlyRemovedRef.current.add(itemKey);
|
||||
await storageService.addContinueWatchingRemoved(
|
||||
getContinueWatchingRemoveId(item),
|
||||
item.type
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
recentlyRemovedRef.current.delete(itemKey);
|
||||
}, REMOVAL_IGNORE_DURATION);
|
||||
|
||||
setContinueWatchingItems((prev) =>
|
||||
prev.filter((currentItem) => getContinueWatchingItemKey(currentItem) !== itemKey)
|
||||
);
|
||||
} catch {
|
||||
// Keep UI state stable even if provider removal fails.
|
||||
} finally {
|
||||
setDeletingItemId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
continueWatchingItems,
|
||||
loading,
|
||||
deletingItemId,
|
||||
refresh,
|
||||
removeItem,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
|
||||
import { getDeviceType } from './utils';
|
||||
|
||||
export function useContinueWatchingLayout() {
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
return useMemo(() => {
|
||||
const deviceType = getDeviceType(width);
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
|
||||
const computedItemWidth = (() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
return 400;
|
||||
case 'largeTablet':
|
||||
return 350;
|
||||
case 'tablet':
|
||||
return 320;
|
||||
default:
|
||||
return 280;
|
||||
}
|
||||
})();
|
||||
|
||||
const computedItemHeight = (() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
return 160;
|
||||
case 'largeTablet':
|
||||
return 140;
|
||||
case 'tablet':
|
||||
return 130;
|
||||
default:
|
||||
return 120;
|
||||
}
|
||||
})();
|
||||
|
||||
const horizontalPadding = (() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
return 32;
|
||||
case 'largeTablet':
|
||||
return 28;
|
||||
case 'tablet':
|
||||
return 24;
|
||||
default:
|
||||
return 16;
|
||||
}
|
||||
})();
|
||||
|
||||
const itemSpacing = (() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
return 20;
|
||||
case 'largeTablet':
|
||||
return 18;
|
||||
case 'tablet':
|
||||
return 16;
|
||||
default:
|
||||
return 16;
|
||||
}
|
||||
})();
|
||||
|
||||
const computedPosterWidth = (() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
return 180;
|
||||
case 'largeTablet':
|
||||
return 160;
|
||||
case 'tablet':
|
||||
return 140;
|
||||
default:
|
||||
return 120;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
deviceType,
|
||||
isTablet,
|
||||
isLargeTablet,
|
||||
isTV,
|
||||
horizontalPadding,
|
||||
itemSpacing,
|
||||
computedItemWidth,
|
||||
computedItemHeight,
|
||||
computedPosterWidth,
|
||||
computedPosterHeight: computedPosterWidth * 1.5,
|
||||
};
|
||||
}, [width]);
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { useCallback } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
|
||||
import { AppSettings } from '../../../hooks/useSettings';
|
||||
import { RootStackParamList } from '../../../navigation/AppNavigator';
|
||||
import { streamCacheService } from '../../../services/streamCacheService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
import { ContinueWatchingItem } from './types';
|
||||
import { buildEpisodeId } from './utils';
|
||||
|
||||
interface ContinueWatchingNavigationParams {
|
||||
navigation: NavigationProp<RootStackParamList>;
|
||||
settings: Pick<AppSettings, 'useCachedStreams' | 'openMetadataScreenWhenCacheDisabled'>;
|
||||
}
|
||||
|
||||
export function useContinueWatchingNavigation({
|
||||
navigation,
|
||||
settings,
|
||||
}: ContinueWatchingNavigationParams) {
|
||||
const navigateToMetadata = useCallback((item: ContinueWatchingItem) => {
|
||||
const episodeId = buildEpisodeId(item);
|
||||
|
||||
navigation.navigate('Metadata', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId,
|
||||
addonId: item.addonId,
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const navigateToStreams = useCallback((item: ContinueWatchingItem) => {
|
||||
const episodeId = buildEpisodeId(item);
|
||||
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId,
|
||||
addonId: item.addonId,
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const handleContentPress = useCallback(async (item: ContinueWatchingItem) => {
|
||||
try {
|
||||
logger.log(`🎬 [ContinueWatching] User clicked on: ${item.name} (${item.type}:${item.id})`);
|
||||
|
||||
if (!settings.useCachedStreams) {
|
||||
logger.log(
|
||||
`📺 [ContinueWatching] Cached streams disabled, navigating to ${settings.openMetadataScreenWhenCacheDisabled ? 'MetadataScreen' : 'StreamsScreen'} for ${item.name}`
|
||||
);
|
||||
|
||||
if (settings.openMetadataScreenWhenCacheDisabled) {
|
||||
navigateToMetadata(item);
|
||||
} else {
|
||||
navigateToStreams(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const episodeId = buildEpisodeId(item);
|
||||
logger.log(`🔍 [ContinueWatching] Looking for cached stream with episodeId: ${episodeId || 'none'}`);
|
||||
|
||||
const cachedStream = await streamCacheService.getCachedStream(item.id, item.type, episodeId);
|
||||
|
||||
if (!cachedStream) {
|
||||
logger.log(`📺 [ContinueWatching] No cached stream, navigating to StreamsScreen for ${item.name}`);
|
||||
navigateToStreams(item);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`🚀 [ContinueWatching] Using cached stream for ${item.name}`);
|
||||
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
|
||||
|
||||
navigation.navigate(playerRoute as any, {
|
||||
uri: cachedStream.stream.url,
|
||||
title: cachedStream.metadata?.name || item.name,
|
||||
episodeTitle:
|
||||
cachedStream.episodeTitle ||
|
||||
(item.type === 'series' ? `Episode ${item.episode}` : undefined),
|
||||
season: cachedStream.season || item.season,
|
||||
episode: cachedStream.episode || item.episode,
|
||||
quality: (cachedStream.stream.title?.match(/(\d+)p/) || [])[1] || undefined,
|
||||
year: cachedStream.metadata?.year || item.year,
|
||||
streamProvider:
|
||||
cachedStream.stream.addonId ||
|
||||
cachedStream.stream.addonName ||
|
||||
cachedStream.stream.name,
|
||||
streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream',
|
||||
headers: cachedStream.stream.headers || undefined,
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId,
|
||||
imdbId: cachedStream.imdbId || cachedStream.metadata?.imdbId || item.imdb_id,
|
||||
backdrop: cachedStream.metadata?.backdrop || item.banner,
|
||||
videoType: undefined,
|
||||
} as any);
|
||||
} catch (error) {
|
||||
logger.warn('[ContinueWatching] Error handling content press:', error);
|
||||
navigateToStreams(item);
|
||||
}
|
||||
}, [navigateToMetadata, navigateToStreams, navigation, settings.openMetadataScreenWhenCacheDisabled, settings.useCachedStreams]);
|
||||
|
||||
return {
|
||||
handleContentPress,
|
||||
navigateToMetadata,
|
||||
};
|
||||
}
|
||||
129
src/components/home/continueWatching/utils.ts
Normal file
129
src/components/home/continueWatching/utils.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { BREAKPOINTS } from './constants';
|
||||
import { ContinueWatchingDeviceType, ContinueWatchingItem } from './types';
|
||||
|
||||
export const isSupportedId = (id: string): boolean => typeof id === 'string' && id.length > 0;
|
||||
|
||||
export const isEpisodeReleased = (video: any): boolean => {
|
||||
if (!video?.released) return false;
|
||||
|
||||
try {
|
||||
return new Date(video.released) <= new Date();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDeviceType = (deviceWidth: number): ContinueWatchingDeviceType => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
|
||||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
};
|
||||
|
||||
export const buildEpisodeId = (
|
||||
item: Pick<ContinueWatchingItem, 'id' | 'type' | 'season' | 'episode'>
|
||||
): string | undefined => {
|
||||
if (item.type !== 'series' || !item.season || !item.episode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${item.id}:${item.season}:${item.episode}`;
|
||||
};
|
||||
|
||||
export const getContinueWatchingItemKey = (
|
||||
item: Pick<ContinueWatchingItem, 'id' | 'type' | 'season' | 'episode'>
|
||||
): string => {
|
||||
const episodeId = buildEpisodeId(item);
|
||||
return episodeId ? `${item.type}:${episodeId}` : `${item.type}:${item.id}`;
|
||||
};
|
||||
|
||||
export const getContinueWatchingRemoveId = (
|
||||
item: Pick<ContinueWatchingItem, 'id' | 'type' | 'season' | 'episode'>
|
||||
): string => buildEpisodeId(item) ?? item.id;
|
||||
|
||||
export const compareContinueWatchingItems = (
|
||||
a: ContinueWatchingItem,
|
||||
b: ContinueWatchingItem
|
||||
): number => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0);
|
||||
|
||||
export const shouldPreferContinueWatchingCandidate = (
|
||||
candidate: ContinueWatchingItem,
|
||||
existing: ContinueWatchingItem
|
||||
): boolean => {
|
||||
const candidateUpdated = candidate.lastUpdated ?? 0;
|
||||
const existingUpdated = existing.lastUpdated ?? 0;
|
||||
const candidateProgress = candidate.progress ?? 0;
|
||||
const existingProgress = existing.progress ?? 0;
|
||||
|
||||
const sameEpisode =
|
||||
candidate.type === 'movie' ||
|
||||
(
|
||||
candidate.type === 'series' &&
|
||||
existing.type === 'series' &&
|
||||
candidate.season !== undefined &&
|
||||
candidate.episode !== undefined &&
|
||||
existing.season !== undefined &&
|
||||
existing.episode !== undefined &&
|
||||
candidate.season === existing.season &&
|
||||
candidate.episode === existing.episode
|
||||
);
|
||||
|
||||
if (sameEpisode) {
|
||||
if (candidateProgress > existingProgress + 0.5) return true;
|
||||
if (existingProgress > candidateProgress + 0.5) return false;
|
||||
}
|
||||
|
||||
if (candidateUpdated !== existingUpdated) {
|
||||
return candidateUpdated > existingUpdated;
|
||||
}
|
||||
|
||||
return candidateProgress > existingProgress;
|
||||
};
|
||||
|
||||
export const getIdVariants = (id: string): string[] => {
|
||||
const variants = new Set<string>();
|
||||
if (typeof id !== 'string' || id.length === 0) return [];
|
||||
|
||||
variants.add(id);
|
||||
|
||||
if (id.startsWith('tt')) {
|
||||
variants.add(id.replace(/^tt/, ''));
|
||||
} else if (/^\d+$/.test(id)) {
|
||||
variants.add(`tt${id}`);
|
||||
}
|
||||
|
||||
return Array.from(variants);
|
||||
};
|
||||
|
||||
export const parseEpisodeId = (episodeId?: string): { season: number; episode: number } | null => {
|
||||
if (!episodeId) return null;
|
||||
|
||||
const match = episodeId.match(/s(\d+)e(\d+)/i);
|
||||
if (match) {
|
||||
const season = parseInt(match[1], 10);
|
||||
const episode = parseInt(match[2], 10);
|
||||
if (!isNaN(season) && !isNaN(episode)) {
|
||||
return { season, episode };
|
||||
}
|
||||
}
|
||||
|
||||
const parts = episodeId.split(':');
|
||||
if (parts.length >= 3) {
|
||||
const season = parseInt(parts[parts.length - 2], 10);
|
||||
const episode = parseInt(parts[parts.length - 1], 10);
|
||||
if (!isNaN(season) && !isNaN(episode)) {
|
||||
return { season, episode };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const toYearNumber = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number' && isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (isFinite(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
Loading…
Reference in a new issue