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-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
@ -21,7 +21,7 @@
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ERROR_RECOVERY_ONLY"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="5000"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="30000"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://ota.nuvioapp.space/api/manifest"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode|locale|layoutDirection" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified" android:supportsPictureInPicture="true" android:resizeableActivity="true">
<intent-filter>
@ -37,4 +37,4 @@
</intent-filter>
</activity>
</application>
</manifest>
</manifest>

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
F285A1620F5847BA863124AF /* LiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EF8716173E0148BD82B233B7 /* LiveActivity.appex */; };
797799D4F9144A9E8D2AB90D /* LiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 49DDF70A2BBD4320BBD94B1B /* LiveActivity.appex */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -39,6 +40,13 @@
remoteGlobalIDString = 0EA489F2BF6143F1BA7B8485;
remoteInfo = LiveActivity;
};
7A41A1F529994F0C8801F1A5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = EFB756D21A05453EA489278C;
remoteInfo = LiveActivity;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@ -48,6 +56,7 @@
dstPath = "";
dstSubfolderSpec = 13;
files = (
797799D4F9144A9E8D2AB90D /* LiveActivity.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@ -93,6 +102,16 @@
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
CC1B793274FE428D8531E950 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Embed Foundation Extensions";
dstPath = "";
dstSubfolderSpec = 13;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@ -128,6 +147,7 @@
EF8716173E0148BD82B233B7 /* LiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; fileEncoding = 9; includeInIndex = 0; path = LiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Nuvio/AppDelegate.swift; sourceTree = "<group>"; };
F11748442D0722820044C1D9 /* Nuvio-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Nuvio-Bridging-Header.h"; path = "Nuvio/Nuvio-Bridging-Header.h"; sourceTree = "<group>"; };
49DDF70A2BBD4320BBD94B1B /* LiveActivity.appex */ = {isa = PBXFileReference; name = "LiveActivity.appex"; path = "LiveActivity.appex"; sourceTree = BUILT_PRODUCTS_DIR; fileEncoding = undefined; lastKnownFileType = undefined; explicitFileType = wrapper.app-extension; includeInIndex = 0; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -146,6 +166,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A2537B59C29048BFB082D5F3 /* Embed Foundation Extensions */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@ -213,6 +240,7 @@
B9F3EB198DED443D980ADFB3 /* LiveActivity */,
C05E525650E143FB85ED7622 /* LiveActivity */,
D05210A39FF14E649D77F8A8 /* LiveActivity */,
2EAF711C6AB246A0A253E404 /* LiveActivity */,
);
indentWidth = 2;
sourceTree = "<group>";
@ -224,6 +252,7 @@
children = (
13B07F961A680F5B00A75B9A /* Nuvio.app */,
EF8716173E0148BD82B233B7 /* LiveActivity.appex */,
49DDF70A2BBD4320BBD94B1B /* LiveActivity.appex */,
);
name = Products;
sourceTree = "<group>";
@ -295,6 +324,26 @@
name = Nuvio;
sourceTree = "<group>";
};
2EAF711C6AB246A0A253E404 /* LiveActivity */ = {
isa = PBXGroup;
children = (
8034143A77A946B5A793F967 /* Color+hex.swift */,
3A48D8A298DD48928E8D0A02 /* Date+toTimerInterval.swift */,
26957CDD392E4E9390811D0D /* Image+dynamic.swift */,
2F448294A36E433E924078C1 /* LiveActivityView.swift */,
AD48662BB71E4C9C9E340289 /* LiveActivityWidget.swift */,
A83D742B36224176A0AB3B25 /* LiveActivityWidgetBundle.swift */,
324373F393774A9CA40DE22E /* View+applyIfPresent.swift */,
373D1473F5A74CBC9DBD108B /* View+applyWidgetURL.swift */,
3396D68881EF486E99FD480A /* ViewHelpers.swift */,
0E13CE4BDE2F4555806AE753 /* Info.plist */,
0F1D0037D1F24E60BDB57628 /* Assets.xcassets */,
2DE29A8A87D24662BEFFF849 /* LiveActivity.entitlements */,
);
name = LiveActivity;
path = LiveActivity;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -333,17 +382,36 @@
571AD3FB23F14FC7BE6A1E44 /* Embed Foundation Extensions */,
13CD9594FB5C4FE4A6794089 /* Embed Foundation Extensions */,
F1058FE7710A45FABC0689A7 /* Embed Foundation Extensions */,
CC1B793274FE428D8531E950 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
8410CAE82E604DD1A187EDA2 /* PBXTargetDependency */,
523C5D7CB8E740A5A0BF4322 /* PBXTargetDependency */,
);
name = Nuvio;
productName = Nuvio;
productReference = 13B07F961A680F5B00A75B9A /* Nuvio.app */;
productType = "com.apple.product-type.application";
};
EFB756D21A05453EA489278C /* LiveActivity */ = {
isa = PBXNativeTarget;
name = LiveActivity;
productName = LiveActivity;
productReference = 49DDF70A2BBD4320BBD94B1B;
productType = "com.apple.product-type.app-extension";
buildConfigurationList = F11E3E24512A427FB847D2F6;
buildPhases = (
784E2472974841CD88391F31 /* Embed Foundation Extensions */,
A2537B59C29048BFB082D5F3 /* Embed Foundation Extensions */,
B06A5B524C284D9FAFC33F3C /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
);
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@ -362,6 +430,9 @@
LastSwiftMigration = 1250;
ProvisioningStyle = Automatic;
};
EFB756D21A05453EA489278C = {
LastSwiftMigration = 1250;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Nuvio" */;
@ -379,6 +450,7 @@
targets = (
13B07F861A680F5B00A75B9A /* Nuvio */,
0EA489F2BF6143F1BA7B8485 /* LiveActivity */,
EFB756D21A05453EA489278C /* LiveActivity */,
);
};
/* End PBXProject section */
@ -403,6 +475,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B06A5B524C284D9FAFC33F3C /* Embed Foundation Extensions */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
730F1CE72F24B27100EF7E51 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@ -649,6 +729,22 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
784E2472974841CD88391F31 /* Embed Foundation Extensions */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
730F1CDE2F24B27100EF7E51 /* "Color+hex.swift" in Sources */,
730F1CDF2F24B27100EF7E51 /* "Date+toTimerInterval.swift" in Sources */,
730F1CE02F24B27100EF7E51 /* "Image+dynamic.swift" in Sources */,
730F1CE12F24B27100EF7E51 /* LiveActivityView.swift in Sources */,
730F1CE22F24B27100EF7E51 /* LiveActivityWidget.swift in Sources */,
730F1CE32F24B27100EF7E51 /* LiveActivityWidgetBundle.swift in Sources */,
730F1CE42F24B27100EF7E51 /* "View+applyIfPresent.swift" in Sources */,
730F1CE52F24B27100EF7E51 /* "View+applyWidgetURL.swift" in Sources */,
730F1CE62F24B27100EF7E51 /* ViewHelpers.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@ -657,6 +753,11 @@
target = 0EA489F2BF6143F1BA7B8485 /* LiveActivity */;
targetProxy = 55A0DD628D7F4F4F88B4A001 /* PBXContainerItemProxy */;
};
523C5D7CB8E740A5A0BF4322 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EFB756D21A05453EA489278C /* LiveActivity */;
targetProxy = 7A41A1F529994F0C8801F1A5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@ -690,7 +791,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
PRODUCT_NAME = Nuvio;
PRODUCT_NAME = "Nuvio";
SUPPORTS_MACCATALYST = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -725,7 +826,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
PRODUCT_NAME = Nuvio;
PRODUCT_NAME = "Nuvio";
SUPPORTS_MACCATALYST = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@ -900,6 +1001,46 @@
};
name = Release;
};
B062D46778AF40DE92953986 /* Debug */ = {
name = Debug;
isa = XCBuildConfiguration;
buildSettings = {
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
INFOPLIST_FILE = LiveActivity/Info.plist;
CURRENT_PROJECT_VERSION = "37";
IPHONEOS_DEPLOYMENT_TARGET = "16.2";
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.hub.LiveActivity";
GENERATE_INFOPLIST_FILE = "YES";
INFOPLIST_KEY_CFBundleDisplayName = LiveActivity;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
MARKETING_VERSION = "1.4.1";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
CODE_SIGN_ENTITLEMENTS = "LiveActivity/LiveActivity.entitlements";
APPLICATION_EXTENSION_API_ONLY = "YES";
};
};
67617475B4F443CBBCE1A6FD /* Release */ = {
name = Release;
isa = XCBuildConfiguration;
buildSettings = {
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
INFOPLIST_FILE = LiveActivity/Info.plist;
CURRENT_PROJECT_VERSION = "37";
IPHONEOS_DEPLOYMENT_TARGET = "16.2";
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.hub.LiveActivity";
GENERATE_INFOPLIST_FILE = "YES";
INFOPLIST_KEY_CFBundleDisplayName = LiveActivity;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
MARKETING_VERSION = "1.4.1";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
CODE_SIGN_ENTITLEMENTS = "LiveActivity/LiveActivity.entitlements";
APPLICATION_EXTENSION_API_ONLY = "YES";
};
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@ -930,6 +1071,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
F11E3E24512A427FB847D2F6 /* Build configuration list for PBXNativeTarget "LiveActivity" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B062D46778AF40DE92953986 /* Debug */,
67617475B4F443CBBCE1A6FD /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;

View file

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

View file

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

View file

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

View file

@ -1,9 +1,12 @@
package com.brentvatne.exoplayer
import android.os.Build
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.SurfaceView
import android.view.View
import android.view.View.MeasureSpec
import android.widget.FrameLayout
@ -20,13 +23,21 @@ import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import com.brentvatne.common.api.ResizeMode
import com.brentvatne.common.api.SubtitleStyle
import com.brentvatne.common.api.ViewType
import com.brentvatne.react.R
@UnstableApi
class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
private var localStyle = SubtitleStyle()
private var currentViewType = ViewType.VIEW_TYPE_SURFACE
private var pendingResizeMode: Int? = null
private var player: ExoPlayer? = null
private var showSubtitleButton = false
private var shutterColor = Color.TRANSPARENT
private var controllerVisibilityListener: PlayerView.ControllerVisibilityListener? = null
private var fullscreenButtonClickListener: PlayerView.FullscreenButtonClickListener? = null
private val liveBadge: TextView = TextView(context).apply {
text = "LIVE"
setTextColor(Color.WHITE)
@ -39,21 +50,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
visibility = View.GONE
}
private val playerView = PlayerView(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
setShutterBackgroundColor(Color.TRANSPARENT)
useController = true
controllerAutoShow = true
controllerHideOnTouch = true
controllerShowTimeoutMs = 5000
// Don't show subtitle button by default - will be enabled when tracks are available
setShowSubtitleButton(false)
// Enable proper surface view handling to prevent rendering issues
setUseArtwork(false)
setDefaultArtwork(null)
// Ensure proper video scaling - start with FIT mode
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
private var playerView = createPlayerView(currentViewType)
/**
* Subtitles rendered in a full-size overlay (NOT inside PlayerView's content frame).
@ -110,6 +107,8 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
}
fun setPlayer(player: ExoPlayer?) {
this.player?.removeListener(playerListener)
this.player = player
playerView.player = player
player?.addListener(playerListener)
}
@ -120,7 +119,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
ResizeMode.RESIZE_MODE_FIXED_WIDTH -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
ResizeMode.RESIZE_MODE_FILL -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FILL
ResizeMode.RESIZE_MODE_ZOOM -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
ResizeMode.RESIZE_MODE_CENTER_CROP -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
else -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
if (playerView.width > 0 && playerView.height > 0) {
@ -136,7 +135,20 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
fun getPlayerView(): PlayerView = playerView
fun isPlaying(): Boolean = playerView.player?.isPlaying == true
fun setControllerVisibilityListener(listener: PlayerView.ControllerVisibilityListener?) {
controllerVisibilityListener = listener
playerView.setControllerVisibilityListener(listener)
}
fun setFullscreenButtonClickListener(listener: PlayerView.FullscreenButtonClickListener?) {
fullscreenButtonClickListener = listener
playerView.setFullscreenButtonClickListener(listener)
}
fun setShowSubtitleButton(show: Boolean) {
showSubtitleButton = show
playerView.setShowSubtitleButton(show)
}
@ -166,6 +178,47 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
playerView.showController()
}
fun updateSurfaceView(@ViewType.ViewType viewType: Int) {
if (currentViewType == viewType) {
return
}
currentViewType = viewType
val previousPlayerView = playerView
val previousLayoutParams = previousPlayerView.layoutParams ?: LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT
)
val previousResizeMode = previousPlayerView.resizeMode
val previousUseController = previousPlayerView.useController
val previousControllerAutoShow = previousPlayerView.controllerAutoShow
val previousControllerHideOnTouch = previousPlayerView.controllerHideOnTouch
val previousControllerShowTimeoutMs = previousPlayerView.controllerShowTimeoutMs
val replacementPlayerView = createPlayerView(viewType).apply {
layoutParams = previousLayoutParams
resizeMode = previousResizeMode
useController = previousUseController
controllerAutoShow = previousControllerAutoShow
controllerHideOnTouch = previousControllerHideOnTouch
controllerShowTimeoutMs = previousControllerShowTimeoutMs
setShowSubtitleButton(showSubtitleButton)
setControllerVisibilityListener(controllerVisibilityListener)
setFullscreenButtonClickListener(fullscreenButtonClickListener)
setShutterBackgroundColor(shutterColor)
player = this@ExoPlayerView.player
}
removeView(previousPlayerView)
playerView = replacementPlayerView
addView(playerView, 0, previousLayoutParams)
updateSubtitleRenderingMode()
applySubtitleStyle(localStyle)
playerView.requestLayout()
}
fun setSubtitleStyle(style: SubtitleStyle) {
localStyle = style
applySubtitleStyle(localStyle)
@ -287,6 +340,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
}
fun setShutterColor(color: Int) {
shutterColor = color
playerView.setShutterBackgroundColor(color)
}
@ -328,4 +382,28 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
applySubtitleStyle(localStyle)
}
}
private fun createPlayerView(@ViewType.ViewType viewType: Int): PlayerView {
val layoutRes = when (viewType) {
ViewType.VIEW_TYPE_TEXTURE -> R.layout.exo_player_view_texture
else -> R.layout.exo_player_view_surface
}
return (LayoutInflater.from(context).inflate(layoutRes, this, false) as PlayerView).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
setShutterBackgroundColor(shutterColor)
useController = true
controllerAutoShow = true
controllerHideOnTouch = true
controllerShowTimeoutMs = 5000
setShowSubtitleButton(showSubtitleButton)
setUseArtwork(false)
setDefaultArtwork(null)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
if (viewType == ViewType.VIEW_TYPE_SURFACE_SECURE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
(videoSurfaceView as? SurfaceView)?.setSecure(true)
}
}
}
}

View file

@ -1567,6 +1567,11 @@ public class ReactExoplayerView extends FrameLayout implements
Track audioTrack = exoplayerTrackToGenericTrack(format, groupIndex, selection, group);
audioTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate);
audioTrack.setSelected(isSelected);
// Encode channel count into title so JS can read it e.g. "English|ch:6"
if (format.channelCount != Format.NO_VALUE && format.channelCount > 0) {
String existing = audioTrack.getTitle() != null ? audioTrack.getTitle() : "";
audioTrack.setTitle(existing + "|ch:" + format.channelCount);
}
audioTracks.add(audioTrack);
}
@ -1753,7 +1758,11 @@ public class ReactExoplayerView extends FrameLayout implements
Track track = new Track();
track.setIndex(groupIndex);
track.setLanguage(format.language != null ? format.language : "unknown");
track.setTitle(format.label != null ? format.label : "Track " + (groupIndex + 1));
String baseTitle = format.label != null ? format.label : "";
if (format.channelCount != Format.NO_VALUE && format.channelCount > 0) {
baseTitle = baseTitle + "|ch:" + format.channelCount;
}
track.setTitle(baseTitle);
track.setSelected(false); // Don't report selection status - let PlayerView handle it
if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType);
track.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate);
@ -2127,7 +2136,8 @@ public class ReactExoplayerView extends FrameLayout implements
}
private void selectTextTrackInternal(String type, String value) {
if (player == null || trackSelector == null) return;
if (player == null || trackSelector == null)
return;
DebugLog.d(TAG, "selectTextTrackInternal: type=" + type + ", value=" + value);
@ -2146,6 +2156,10 @@ public class ReactExoplayerView extends FrameLayout implements
if (textRendererIndex != C.INDEX_UNSET) {
TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
boolean trackFound = false;
// react-native-video uses a flattened `textTracks` list on the JS side.
// For HLS/DASH, each TrackGroup often contains a single track at index 0,
// so comparing against `trackIndex` alone makes only the first subtitle selectable.
int flattenedIndex = 0;
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
TrackGroup group = groups.get(groupIndex);
@ -2159,10 +2173,12 @@ public class ReactExoplayerView extends FrameLayout implements
isMatch = true;
} else if ("index".equals(type)) {
int targetIndex = ReactBridgeUtils.safeParseInt(value, -1);
if (targetIndex == trackIndex) {
if (targetIndex == flattenedIndex) {
isMatch = true;
}
}
flattenedIndex++;
if (isMatch) {
TrackSelectionOverride override = new TrackSelectionOverride(group,

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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