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