update dependencies and patchfile

This commit is contained in:
tapframe 2026-03-13 07:10:40 +05:30
parent 1367972681
commit 11d2944246
31 changed files with 5078 additions and 5532 deletions

View file

@ -2,12 +2,12 @@
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv"/> <uses-sdk tools:overrideLibrary="dev.jdtech.mpv"/>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <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.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS"/> <uses-permission android:name="android.permission.WRITE_SETTINGS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.VIEW"/> <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.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_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_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"/> <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"> <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> <intent-filter>
@ -37,4 +37,4 @@
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
</manifest> </manifest>

View file

@ -4,7 +4,7 @@ buildscript {
ext { ext {
buildToolsVersion = "35.0.0" buildToolsVersion = "35.0.0"
minSdkVersion = 24 minSdkVersion = 24
compileSdkVersion = 35 compileSdkVersion = 36
targetSdkVersion = 35 targetSdkVersion = 35
castFrameworkVersion = "22.1.0" castFrameworkVersion = "22.1.0"
ndkVersion = "29.0.14206865" // Required for libmpv AAR built with NDK r29 ndkVersion = "29.0.14206865" // Required for libmpv AAR built with NDK r29

View file

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

View file

@ -7,7 +7,5 @@
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string> <string>com.apple.widgetkit-extension</string>
</dict> </dict>
<key>RCTNewArchEnabled</key>
<true/>
</dict> </dict>
</plist> </plist>

View file

@ -29,6 +29,7 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
F285A1620F5847BA863124AF /* LiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EF8716173E0148BD82B233B7 /* LiveActivity.appex */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -39,6 +40,13 @@
remoteGlobalIDString = 0EA489F2BF6143F1BA7B8485; remoteGlobalIDString = 0EA489F2BF6143F1BA7B8485;
remoteInfo = LiveActivity; remoteInfo = LiveActivity;
}; };
7A41A1F529994F0C8801F1A5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = EFB756D21A05453EA489278C;
remoteInfo = LiveActivity;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@ -48,6 +56,7 @@
dstPath = ""; dstPath = "";
dstSubfolderSpec = 13; dstSubfolderSpec = 13;
files = ( files = (
797799D4F9144A9E8D2AB90D /* LiveActivity.appex in Embed Foundation Extensions */,
); );
name = "Embed Foundation Extensions"; name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -93,6 +102,16 @@
name = "Embed Foundation Extensions"; name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
CC1B793274FE428D8531E950 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Embed Foundation Extensions";
dstPath = "";
dstSubfolderSpec = 13;
};
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference 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; }; 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>"; }; 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>"; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -146,6 +166,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A2537B59C29048BFB082D5F3 /* Embed Foundation Extensions */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@ -213,6 +240,7 @@
B9F3EB198DED443D980ADFB3 /* LiveActivity */, B9F3EB198DED443D980ADFB3 /* LiveActivity */,
C05E525650E143FB85ED7622 /* LiveActivity */, C05E525650E143FB85ED7622 /* LiveActivity */,
D05210A39FF14E649D77F8A8 /* LiveActivity */, D05210A39FF14E649D77F8A8 /* LiveActivity */,
2EAF711C6AB246A0A253E404 /* LiveActivity */,
); );
indentWidth = 2; indentWidth = 2;
sourceTree = "<group>"; sourceTree = "<group>";
@ -224,6 +252,7 @@
children = ( children = (
13B07F961A680F5B00A75B9A /* Nuvio.app */, 13B07F961A680F5B00A75B9A /* Nuvio.app */,
EF8716173E0148BD82B233B7 /* LiveActivity.appex */, EF8716173E0148BD82B233B7 /* LiveActivity.appex */,
49DDF70A2BBD4320BBD94B1B /* LiveActivity.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -295,6 +324,26 @@
name = Nuvio; name = Nuvio;
sourceTree = "<group>"; 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 */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -333,17 +382,36 @@
571AD3FB23F14FC7BE6A1E44 /* Embed Foundation Extensions */, 571AD3FB23F14FC7BE6A1E44 /* Embed Foundation Extensions */,
13CD9594FB5C4FE4A6794089 /* Embed Foundation Extensions */, 13CD9594FB5C4FE4A6794089 /* Embed Foundation Extensions */,
F1058FE7710A45FABC0689A7 /* Embed Foundation Extensions */, F1058FE7710A45FABC0689A7 /* Embed Foundation Extensions */,
CC1B793274FE428D8531E950 /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
8410CAE82E604DD1A187EDA2 /* PBXTargetDependency */, 8410CAE82E604DD1A187EDA2 /* PBXTargetDependency */,
523C5D7CB8E740A5A0BF4322 /* PBXTargetDependency */,
); );
name = Nuvio; name = Nuvio;
productName = Nuvio; productName = Nuvio;
productReference = 13B07F961A680F5B00A75B9A /* Nuvio.app */; productReference = 13B07F961A680F5B00A75B9A /* Nuvio.app */;
productType = "com.apple.product-type.application"; 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 */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@ -362,6 +430,9 @@
LastSwiftMigration = 1250; LastSwiftMigration = 1250;
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
}; };
EFB756D21A05453EA489278C = {
LastSwiftMigration = 1250;
};
}; };
}; };
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Nuvio" */; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Nuvio" */;
@ -379,6 +450,7 @@
targets = ( targets = (
13B07F861A680F5B00A75B9A /* Nuvio */, 13B07F861A680F5B00A75B9A /* Nuvio */,
0EA489F2BF6143F1BA7B8485 /* LiveActivity */, 0EA489F2BF6143F1BA7B8485 /* LiveActivity */,
EFB756D21A05453EA489278C /* LiveActivity */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@ -403,6 +475,14 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B06A5B524C284D9FAFC33F3C /* Embed Foundation Extensions */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
730F1CE72F24B27100EF7E51 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
@ -649,6 +729,22 @@
); );
runOnlyForDeploymentPostprocessing = 0; 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 */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
@ -657,6 +753,11 @@
target = 0EA489F2BF6143F1BA7B8485 /* LiveActivity */; target = 0EA489F2BF6143F1BA7B8485 /* LiveActivity */;
targetProxy = 55A0DD628D7F4F4F88B4A001 /* PBXContainerItemProxy */; targetProxy = 55A0DD628D7F4F4F88B4A001 /* PBXContainerItemProxy */;
}; };
523C5D7CB8E740A5A0BF4322 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EFB756D21A05453EA489278C /* LiveActivity */;
targetProxy = 7A41A1F529994F0C8801F1A5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@ -690,7 +791,7 @@
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
PRODUCT_NAME = Nuvio; PRODUCT_NAME = "Nuvio";
SUPPORTS_MACCATALYST = YES; SUPPORTS_MACCATALYST = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -725,7 +826,7 @@
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
PRODUCT_NAME = Nuvio; PRODUCT_NAME = "Nuvio";
SUPPORTS_MACCATALYST = YES; SUPPORTS_MACCATALYST = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -900,6 +1001,46 @@
}; };
name = Release; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@ -930,6 +1071,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
F11E3E24512A427FB847D2F6 /* Build configuration list for PBXNativeTarget "LiveActivity" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B062D46778AF40DE92953986 /* Debug */,
67617475B4F443CBBCE1A6FD /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;

View file

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

View file

@ -9,7 +9,7 @@
<key>EXUpdatesLaunchWaitMs</key> <key>EXUpdatesLaunchWaitMs</key>
<integer>30000</integer> <integer>30000</integer>
<key>EXUpdatesRuntimeVersion</key> <key>EXUpdatesRuntimeVersion</key>
<string>1.3.6</string> <string>1.4.1</string>
<key>EXUpdatesURL</key> <key>EXUpdatesURL</key>
<string>https://ota.nuvioapp.space/api/manifest</string> <string>https://ota.nuvioapp.space/api/manifest</string>
</dict> </dict>

View file

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

View file

@ -1,9 +1,12 @@
package com.brentvatne.exoplayer package com.brentvatne.exoplayer
import android.os.Build
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.SurfaceView
import android.view.View import android.view.View
import android.view.View.MeasureSpec import android.view.View.MeasureSpec
import android.widget.FrameLayout import android.widget.FrameLayout
@ -20,13 +23,21 @@ import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import com.brentvatne.common.api.ResizeMode import com.brentvatne.common.api.ResizeMode
import com.brentvatne.common.api.SubtitleStyle import com.brentvatne.common.api.SubtitleStyle
import com.brentvatne.common.api.ViewType
import com.brentvatne.react.R
@UnstableApi @UnstableApi
class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) { FrameLayout(context, attrs, defStyleAttr) {
private var localStyle = SubtitleStyle() private var localStyle = SubtitleStyle()
private var currentViewType = ViewType.VIEW_TYPE_SURFACE
private var pendingResizeMode: Int? = null 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 { private val liveBadge: TextView = TextView(context).apply {
text = "LIVE" text = "LIVE"
setTextColor(Color.WHITE) setTextColor(Color.WHITE)
@ -39,21 +50,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
visibility = View.GONE visibility = View.GONE
} }
private val playerView = PlayerView(context).apply { private var playerView = createPlayerView(currentViewType)
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
}
/** /**
* Subtitles rendered in a full-size overlay (NOT inside PlayerView's content frame). * 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?) { fun setPlayer(player: ExoPlayer?) {
this.player?.removeListener(playerListener)
this.player = player
playerView.player = player playerView.player = player
player?.addListener(playerListener) 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_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_FIXED_HEIGHT -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
ResizeMode.RESIZE_MODE_FILL -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FILL 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 else -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
} }
if (playerView.width > 0 && playerView.height > 0) { if (playerView.width > 0 && playerView.height > 0) {
@ -136,7 +135,20 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
fun getPlayerView(): PlayerView = playerView 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) { fun setShowSubtitleButton(show: Boolean) {
showSubtitleButton = show
playerView.setShowSubtitleButton(show) playerView.setShowSubtitleButton(show)
} }
@ -166,6 +178,47 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
playerView.showController() 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) { fun setSubtitleStyle(style: SubtitleStyle) {
localStyle = style localStyle = style
applySubtitleStyle(localStyle) applySubtitleStyle(localStyle)
@ -287,6 +340,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
} }
fun setShutterColor(color: Int) { fun setShutterColor(color: Int) {
shutterColor = color
playerView.setShutterBackgroundColor(color) playerView.setShutterBackgroundColor(color)
} }
@ -328,4 +382,28 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
applySubtitleStyle(localStyle) 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)
}
}
}
} }

View file

@ -1567,6 +1567,11 @@ public class ReactExoplayerView extends FrameLayout implements
Track audioTrack = exoplayerTrackToGenericTrack(format, groupIndex, selection, group); Track audioTrack = exoplayerTrackToGenericTrack(format, groupIndex, selection, group);
audioTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); audioTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate);
audioTrack.setSelected(isSelected); 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); audioTracks.add(audioTrack);
} }
@ -1753,7 +1758,11 @@ public class ReactExoplayerView extends FrameLayout implements
Track track = new Track(); Track track = new Track();
track.setIndex(groupIndex); track.setIndex(groupIndex);
track.setLanguage(format.language != null ? format.language : "unknown"); 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 track.setSelected(false); // Don't report selection status - let PlayerView handle it
if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType); if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType);
track.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); 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) { 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); DebugLog.d(TAG, "selectTextTrackInternal: type=" + type + ", value=" + value);
@ -2146,6 +2156,10 @@ public class ReactExoplayerView extends FrameLayout implements
if (textRendererIndex != C.INDEX_UNSET) { if (textRendererIndex != C.INDEX_UNSET) {
TrackGroupArray groups = info.getTrackGroups(textRendererIndex); TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
boolean trackFound = false; 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++) { for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
TrackGroup group = groups.get(groupIndex); TrackGroup group = groups.get(groupIndex);
@ -2159,10 +2173,12 @@ public class ReactExoplayerView extends FrameLayout implements
isMatch = true; isMatch = true;
} else if ("index".equals(type)) { } else if ("index".equals(type)) {
int targetIndex = ReactBridgeUtils.safeParseInt(value, -1); int targetIndex = ReactBridgeUtils.safeParseInt(value, -1);
if (targetIndex == trackIndex) { if (targetIndex == flattenedIndex) {
isMatch = true; isMatch = true;
} }
} }
flattenedIndex++;
if (isMatch) { if (isMatch) {
TrackSelectionOverride override = new TrackSelectionOverride(group, TrackSelectionOverride override = new TrackSelectionOverride(group,

View 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" />

View 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

File diff suppressed because it is too large Load diff

View file

@ -12,34 +12,34 @@
"dependencies": { "dependencies": {
"@adrianso/react-native-device-brightness": "^1.2.7", "@adrianso/react-native-device-brightness": "^1.2.7",
"@backpackapp-io/react-native-toast": "^0.15.1", "@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", "@d11/react-native-fast-image": "^8.13.0",
"@expo/env": "^2.0.7", "@expo/env": "^2.0.7",
"@expo/metro-runtime": "~6.1.2", "@expo/metro-runtime": "~6.1.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.8",
"@kesha-antonov/react-native-background-downloader": "^4.4.5", "@kesha-antonov/react-native-background-downloader": "^4.5.3",
"@legendapp/list": "^2.0.13", "@legendapp/list": "^2.0.19",
"@lottiefiles/dotlottie-react": "^0.13.5", "@lottiefiles/dotlottie-react": "^0.18.5",
"@react-native-community/blur": "^4.4.1", "@react-native-community/blur": "^4.4.1",
"@react-native-community/netinfo": "^11.4.1", "@react-native-community/netinfo": "^12.0.1",
"@react-native-community/slider": "^5.1.1", "@react-native-community/slider": "^5.1.2",
"@react-native-picker/picker": "^2.11.4", "@react-native-picker/picker": "^2.11.4",
"@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.33",
"@react-navigation/native-stack": "^7.3.10", "@react-navigation/native-stack": "^7.14.5",
"@react-navigation/stack": "^7.2.10", "@react-navigation/stack": "^7.8.5",
"@sentry/react-native": "^7.6.0", "@sentry/react-native": "^8.4.0",
"@shopify/flash-list": "^2.2.0", "@shopify/flash-list": "^2.3.0",
"@shopify/react-native-skia": "^2.4.14", "@shopify/react-native-skia": "^2.5.1",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.24",
"@types/react-native-video": "^5.0.20", "@types/react-native-video": "^5.0.21",
"axios": "^1.12.2", "axios": "^1.13.6",
"axios-cookiejar-support": "^6.0.4", "axios-cookiejar-support": "^6.0.5",
"cheerio-without-node-native": "^0.20.2", "cheerio-without-node-native": "^0.20.2",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.4",
"expo": "^54", "expo": "^54",
"expo-application": "~7.0.7", "expo-application": "~7.0.7",
"expo-auth-session": "~7.0.8", "expo-auth-session": "~7.0.8",
@ -67,47 +67,47 @@
"expo-system-ui": "~6.0.7", "expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12", "expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8", "expo-web-browser": "~15.0.8",
"i18next": "^25.7.3", "i18next": "^25.8.18",
"intl-pluralrules": "^2.0.1", "intl-pluralrules": "^2.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.23",
"lottie-react-native": "~7.3.1", "lottie-react-native": "~7.3.6",
"posthog-react-native": "^4.4.0", "posthog-react-native": "^4.37.2",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "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": "0.81.4",
"react-native-boost": "^0.6.2", "react-native-boost": "^1.0.0",
"react-native-bottom-tabs": "^1.0.2", "react-native-bottom-tabs": "^1.1.0",
"react-native-gesture-handler": "^2.29.1", "react-native-gesture-handler": "^2.30.0",
"react-native-get-random-values": "^2.0.0", "react-native-get-random-values": "^2.0.0",
"react-native-google-cast": "^4.9.1", "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-immersive-mode": "^2.0.2",
"react-native-markdown-display": "^7.0.2", "react-native-markdown-display": "^7.0.2",
"react-native-mmkv": "^4.0.0", "react-native-mmkv": "^4.2.0",
"react-native-nitro-modules": "^0.31.2", "react-native-nitro-modules": "^0.35.0",
"react-native-paper": "^5.14.5", "react-native-paper": "^5.15.0",
"react-native-reanimated": "^4.2.0", "react-native-reanimated": "^4.2.2",
"react-native-reanimated-carousel": "^4.0.3", "react-native-reanimated-carousel": "^4.0.3",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "^4.18.0", "react-native-screens": "^4.24.0",
"react-native-svg": "^15.12.1", "react-native-svg": "^15.15.3",
"react-native-url-polyfill": "^3.0.0", "react-native-url-polyfill": "^3.0.0",
"react-native-vector-icons": "^10.3.0", "react-native-vector-icons": "^10.3.0",
"react-native-video": "^6.19.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-wheel-color-picker": "^1.3.1",
"react-native-worklets": "^0.7.1" "react-native-worklets": "^0.7.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.29.0",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/react": "~18.3.12", "@types/react": "~18.3.12",
"@types/react-native": "^0.72.8", "@types/react-native": "^0.72.8",
"@types/react-native-vector-icons": "^6.4.18", "@types/react-native-vector-icons": "^6.4.18",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"react-native-svg-transformer": "^1.5.0", "react-native-svg-transformer": "^1.5.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"xcode": "^3.0.1" "xcode": "^3.0.1"
}, },

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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>
);
}

View file

@ -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';

View file

@ -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';

View 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;

View 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);
}

View 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;
}

View file

@ -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,
};
}

View file

@ -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);
}

View file

@ -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);
}
}

View 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',
},
});

View 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';

View 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,
};
}

View file

@ -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]);
}

View file

@ -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,
};
}

View 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;
};