mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
Merge branch 'main' into android-nav-bar-fix
This commit is contained in:
commit
14980f2bfd
32 changed files with 3630 additions and 2167 deletions
74
App.tsx
74
App.tsx
|
|
@ -18,7 +18,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Provider as PaperProvider } from 'react-native-paper';
|
||||
import { enableScreens, enableFreeze } from 'react-native-screens';
|
||||
import AppNavigator, {
|
||||
import AppNavigator, {
|
||||
CustomNavigationDarkTheme,
|
||||
CustomDarkTheme
|
||||
} from './src/navigation/AppNavigator';
|
||||
|
|
@ -41,6 +41,7 @@ import { aiService } from './src/services/aiService';
|
|||
import { AccountProvider, useAccount } from './src/contexts/AccountContext';
|
||||
import { ToastProvider } from './src/contexts/ToastContext';
|
||||
import { mmkvStorage } from './src/services/mmkvStorage';
|
||||
import AnnouncementOverlay from './src/components/AnnouncementOverlay';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
|
||||
|
|
@ -82,12 +83,13 @@ const ThemedApp = () => {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const engine = (global as any).HermesInternal ? 'Hermes' : 'JSC';
|
||||
console.log('JS Engine:', engine);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}, []);
|
||||
const { currentTheme } = useTheme();
|
||||
const [isAppReady, setIsAppReady] = useState(false);
|
||||
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState<boolean | null>(null);
|
||||
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
|
||||
// Update popup functionality
|
||||
const {
|
||||
showUpdatePopup,
|
||||
|
|
@ -100,7 +102,17 @@ const ThemedApp = () => {
|
|||
|
||||
// GitHub major/minor release overlay
|
||||
const githubUpdate = useGithubMajorUpdate();
|
||||
|
||||
|
||||
// Announcement data
|
||||
const announcements = [
|
||||
{
|
||||
icon: 'zap',
|
||||
title: 'Debrid Integration',
|
||||
description: 'Unlock 4K high-quality streams with lightning-fast speeds. Connect your TorBox account to access cached premium content with zero buffering.',
|
||||
tag: 'NEW',
|
||||
},
|
||||
];
|
||||
|
||||
// Check onboarding status and initialize services
|
||||
useEffect(() => {
|
||||
const initializeApp = async () => {
|
||||
|
|
@ -108,28 +120,37 @@ const ThemedApp = () => {
|
|||
// Check onboarding status
|
||||
const onboardingCompleted = await mmkvStorage.getItem('hasCompletedOnboarding');
|
||||
setHasCompletedOnboarding(onboardingCompleted === 'true');
|
||||
|
||||
|
||||
// Initialize update service
|
||||
await UpdateService.initialize();
|
||||
|
||||
|
||||
// Initialize memory monitoring service to prevent OutOfMemoryError
|
||||
memoryMonitorService; // Just accessing it starts the monitoring
|
||||
console.log('Memory monitoring service initialized');
|
||||
|
||||
|
||||
// Initialize AI service
|
||||
await aiService.initialize();
|
||||
console.log('AI service initialized');
|
||||
|
||||
|
||||
// Check if announcement should be shown (version 1.0.0)
|
||||
const announcementShown = await mmkvStorage.getItem('announcement_v1.0.0_shown');
|
||||
if (!announcementShown && onboardingCompleted === 'true') {
|
||||
// Show announcement only after app is ready
|
||||
setTimeout(() => {
|
||||
setShowAnnouncement(true);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing app:', error);
|
||||
// Default to showing onboarding if we can't check
|
||||
setHasCompletedOnboarding(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
initializeApp();
|
||||
}, []);
|
||||
|
||||
|
||||
// Create custom themes based on current theme
|
||||
const customDarkTheme = {
|
||||
...CustomDarkTheme,
|
||||
|
|
@ -138,7 +159,7 @@ const ThemedApp = () => {
|
|||
primary: currentTheme.colors.primary,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const customNavigationTheme = {
|
||||
...CustomNavigationDarkTheme,
|
||||
colors: {
|
||||
|
|
@ -153,15 +174,33 @@ const ThemedApp = () => {
|
|||
const handleSplashComplete = () => {
|
||||
setIsAppReady(true);
|
||||
};
|
||||
|
||||
|
||||
// Navigation reference
|
||||
const navigationRef = React.useRef<any>(null);
|
||||
|
||||
// Handler for navigating to debrid integration
|
||||
const handleNavigateToDebrid = () => {
|
||||
if (navigationRef.current) {
|
||||
navigationRef.current.navigate('DebridIntegration');
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for announcement close
|
||||
const handleAnnouncementClose = async () => {
|
||||
setShowAnnouncement(false);
|
||||
// Mark announcement as shown
|
||||
await mmkvStorage.setItem('announcement_v1.0.0_shown', 'true');
|
||||
};
|
||||
|
||||
// Don't render anything until we know the onboarding status
|
||||
const shouldShowApp = isAppReady && hasCompletedOnboarding !== null;
|
||||
const initialRouteName = hasCompletedOnboarding ? 'MainTabs' : 'Onboarding';
|
||||
|
||||
|
||||
return (
|
||||
<AccountProvider>
|
||||
<PaperProvider theme={customDarkTheme}>
|
||||
<NavigationContainer
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
theme={customNavigationTheme}
|
||||
linking={undefined}
|
||||
>
|
||||
|
|
@ -186,6 +225,13 @@ const ThemedApp = () => {
|
|||
onDismiss={githubUpdate.onDismiss}
|
||||
onLater={githubUpdate.onLater}
|
||||
/>
|
||||
<AnnouncementOverlay
|
||||
visible={showAnnouncement}
|
||||
announcements={announcements}
|
||||
onClose={handleAnnouncementClose}
|
||||
onActionPress={handleNavigateToDebrid}
|
||||
actionButtonText="Connect Now"
|
||||
/>
|
||||
</View>
|
||||
</DownloadsProvider>
|
||||
</NavigationContainer>
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ android {
|
|||
applicationId 'com.nuvio.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 24
|
||||
versionName "1.2.9"
|
||||
versionCode 25
|
||||
versionName "1.2.10"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ android {
|
|||
def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
def baseVersionCode = 24 // Current versionCode 24 from defaultConfig
|
||||
def baseVersionCode = 25 // Current versionCode 25 from defaultConfig
|
||||
def abiName = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
|
||||
def versionCode = baseVersionCode * 100 // Base multiplier
|
||||
|
|
@ -209,6 +209,10 @@ sentry {
|
|||
}
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
exclude group: 'com.caverock', module: 'androidsvg'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// @generated begin react-native-google-cast-dependencies - expo prebuild (DO NOT MODIFY) sync-3822a3c86222e7aca74039b551612aab7e75365d
|
||||
implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}"
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@
|
|||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
|
||||
<string name="expo_runtime_version">1.2.9</string>
|
||||
<string name="expo_runtime_version">1.2.10</string>
|
||||
</resources>
|
||||
8
app.json
8
app.json
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Nuvio",
|
||||
"slug": "nuvio",
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.10",
|
||||
"orientation": "default",
|
||||
"backgroundColor": "#020404",
|
||||
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
"supportsTablet": true,
|
||||
"requireFullScreen": true,
|
||||
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
||||
"buildNumber": "24",
|
||||
"buildNumber": "25",
|
||||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
"WRITE_SETTINGS"
|
||||
],
|
||||
"package": "com.nuvio.app",
|
||||
"versionCode": 24,
|
||||
"versionCode": 25,
|
||||
"architectures": [
|
||||
"arm64-v8a",
|
||||
"armeabi-v7a",
|
||||
|
|
@ -105,6 +105,6 @@
|
|||
"fallbackToCacheTimeout": 30000,
|
||||
"url": "https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest"
|
||||
},
|
||||
"runtimeVersion": "1.2.9"
|
||||
"runtimeVersion": "1.2.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -385,9 +385,11 @@ class KSPlayerView: UIView {
|
|||
options.asynchronousDecompression = true
|
||||
#endif
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Native HDR processing
|
||||
// Set destination dynamic range based on device capabilities to eliminate unnecessary color conversions
|
||||
options.destinationDynamicRange = getOptimalDynamicRange()
|
||||
// HDR handling: Let KSPlayer automatically detect content's native dynamic range
|
||||
// Setting destinationDynamicRange to nil allows KSPlayer to use the content's actual HDR/SDR mode
|
||||
// This prevents forcing HDR tone mapping on SDR content (which causes oversaturation)
|
||||
// KSPlayer will automatically detect HDR10/Dolby Vision/HLG from the video format description
|
||||
options.destinationDynamicRange = nil
|
||||
|
||||
// Configure audio for proper dialogue mixing using FFmpeg's pan filter
|
||||
// This approach uses standard audio engineering practices for multi-channel downmixing
|
||||
|
|
@ -455,9 +457,24 @@ class KSPlayerView: UIView {
|
|||
return
|
||||
}
|
||||
|
||||
playerView.seek(time: time) { success in
|
||||
// Capture the current paused state before seeking
|
||||
let wasPaused = isPaused
|
||||
print("KSPlayerView: Seeking to \(time), paused state before seek: \(wasPaused)")
|
||||
|
||||
playerView.seek(time: time) { [weak self] success in
|
||||
guard let self = self else { return }
|
||||
|
||||
if success {
|
||||
print("KSPlayerView: Seek successful to \(time)")
|
||||
|
||||
// Restore the paused state after seeking
|
||||
// KSPlayer's seek may resume playback, so we need to re-apply the paused state
|
||||
if wasPaused {
|
||||
DispatchQueue.main.async {
|
||||
self.playerView.pause()
|
||||
print("KSPlayerView: Restored paused state after seek")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("KSPlayerView: Seek failed to \(time)")
|
||||
}
|
||||
|
|
@ -804,40 +821,6 @@ class KSPlayerView: UIView {
|
|||
}
|
||||
|
||||
// MARK: - Performance Optimization Helpers
|
||||
|
||||
/// Detects device HDR capabilities and returns optimal dynamic range setting
|
||||
/// This prevents unnecessary color space conversion overhead
|
||||
private func getOptimalDynamicRange() -> DynamicRange? {
|
||||
#if canImport(UIKit)
|
||||
let availableHDRModes = AVPlayer.availableHDRModes
|
||||
|
||||
// If no HDR modes available, use SDR (nil will use content's native range)
|
||||
if availableHDRModes == AVPlayer.HDRMode(rawValue: 0) {
|
||||
return .sdr
|
||||
}
|
||||
|
||||
// Prefer HDR10 if supported (most common HDR format)
|
||||
if availableHDRModes.contains(.hdr10) {
|
||||
return .hdr10
|
||||
}
|
||||
|
||||
// Fallback to Dolby Vision if available
|
||||
if availableHDRModes.contains(.dolbyVision) {
|
||||
return .dolbyVision
|
||||
}
|
||||
|
||||
// Fallback to HLG if available
|
||||
if availableHDRModes.contains(.hlg) {
|
||||
return .hlg
|
||||
}
|
||||
|
||||
// Default to SDR if no HDR support
|
||||
return .sdr
|
||||
#else
|
||||
// macOS: Check screen capabilities
|
||||
return .sdr
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - High Performance KSOptions Subclass
|
||||
|
|
|
|||
|
|
@ -477,7 +477,7 @@
|
|||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
@ -508,8 +508,8 @@
|
|||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.9</string>
|
||||
<string>1.2.10</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>24</string>
|
||||
<string>25</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<key>EXUpdatesLaunchWaitMs</key>
|
||||
<integer>30000</integer>
|
||||
<key>EXUpdatesRuntimeVersion</key>
|
||||
<string>1.2.9</string>
|
||||
<string>1.2.10</string>
|
||||
<key>EXUpdatesURL</key>
|
||||
<string>https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest</string>
|
||||
</dict>
|
||||
|
|
|
|||
|
|
@ -3178,13 +3178,13 @@ EXTERNAL SOURCES:
|
|||
|
||||
CHECKOUT OPTIONS:
|
||||
DisplayCriteria:
|
||||
:commit: 83ba8419ca365e9397c0b45c4147755da522324e
|
||||
:commit: cbc74996afb55e096bf1ff240f07d1d206ac86df
|
||||
:git: https://github.com/kingslay/KSPlayer.git
|
||||
FFmpegKit:
|
||||
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
|
||||
:git: https://github.com/kingslay/FFmpegKit.git
|
||||
KSPlayer:
|
||||
:commit: 83ba8419ca365e9397c0b45c4147755da522324e
|
||||
:commit: cbc74996afb55e096bf1ff240f07d1d206ac86df
|
||||
:git: https://github.com/kingslay/KSPlayer.git
|
||||
Libass:
|
||||
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@
|
|||
"https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true"
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.10",
|
||||
"buildVersion": "25",
|
||||
"date": "2025-11-25",
|
||||
"localizedDescription": "# Nuvio Media Hub – v1.2.10 \n\n## Update Notes\n- **Dependency updates** for stability and performance \n- **Trakt optimizations** for smoother syncing \n- **Subtitle RTL detection** improvements for better language handling \n- **KSPlayer** pause behavior improvements \n- Fixed incorrect **HDR detection logic** in KSPlayer \n- Simplified **This Week’s section** card UI for a cleaner look \n\n## 📦 Changelog & Download\n[View on GitHub](https://github.com/tapframe/NuvioStreaming/releases/tag/1.2.10)\n\n🌐 **Official Website:** [tapframe.github.io/NuvioStreaming](https://tapframe.github.io/NuvioStreaming)\n\nIf you like **Nuvio Media Hub**, please consider **⭐ starring it on GitHub**. It really helps the project grow \n[⭐ Star on GitHub](https://github.com/tapframe/NuvioStreaming)",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.2.10/Stable_1-2-10.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.2.9",
|
||||
"buildVersion": "24",
|
||||
|
|
|
|||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -12,7 +12,7 @@
|
|||
"@adrianso/react-native-device-brightness": "^1.2.7",
|
||||
"@backpackapp-io/react-native-toast": "^0.15.1",
|
||||
"@bottom-tabs/react-navigation": "^1.0.2",
|
||||
"@d11/react-native-fast-image": "^8.8.0",
|
||||
"@d11/react-native-fast-image": "^8.13.0",
|
||||
"@expo/env": "^2.0.7",
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"@adrianso/react-native-device-brightness": "^1.2.7",
|
||||
"@backpackapp-io/react-native-toast": "^0.15.1",
|
||||
"@bottom-tabs/react-navigation": "^1.0.2",
|
||||
"@d11/react-native-fast-image": "^8.8.0",
|
||||
"@d11/react-native-fast-image": "^8.13.0",
|
||||
"@expo/env": "^2.0.7",
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
|
|
|
|||
308
src/components/AnnouncementOverlay.tsx
Normal file
308
src/components/AnnouncementOverlay.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
interface Announcement {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
interface AnnouncementOverlayProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onActionPress?: () => void;
|
||||
title?: string;
|
||||
announcements: Announcement[];
|
||||
actionButtonText?: string;
|
||||
}
|
||||
|
||||
const AnnouncementOverlay: React.FC<AnnouncementOverlayProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onActionPress,
|
||||
title = "What's New",
|
||||
announcements,
|
||||
actionButtonText = "Got it!",
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const colors = currentTheme.colors;
|
||||
|
||||
const scaleAnim = useRef(new Animated.Value(0.8)).current;
|
||||
const opacityAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
tension: 50,
|
||||
friction: 7,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
scaleAnim.setValue(0.8);
|
||||
opacityAnim.setValue(0);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleClose = () => {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 0.8,
|
||||
tension: 50,
|
||||
friction: 7,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
onClose();
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
if (onActionPress) {
|
||||
handleClose();
|
||||
// Delay navigation slightly to allow animation to complete
|
||||
setTimeout(() => {
|
||||
onActionPress();
|
||||
}, 300);
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
opacity: opacityAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
{/* Close Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={handleClose}
|
||||
>
|
||||
<Feather name="x" size={20} color={colors.white} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: colors.primary + '20' }]}>
|
||||
<Feather name="zap" size={32} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.title, { color: colors.white }]}>{title}</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.mediumEmphasis }]}>
|
||||
Exciting updates in this release
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Announcements */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{announcements.map((announcement, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={styles.announcementItem}
|
||||
>
|
||||
<View style={[styles.announcementIcon, { backgroundColor: colors.primary + '15' }]}>
|
||||
<Feather name={announcement.icon as any} size={24} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.announcementContent}>
|
||||
<View style={styles.announcementHeader}>
|
||||
<Text style={[styles.announcementTitle, { color: colors.white }]}>
|
||||
{announcement.title}
|
||||
</Text>
|
||||
{announcement.tag && (
|
||||
<View style={[styles.tag, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.tagText}>{announcement.tag}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.announcementDescription, { color: colors.mediumEmphasis }]}>
|
||||
{announcement.description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Action Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||
onPress={handleAction}
|
||||
>
|
||||
<Text style={styles.buttonText}>{actionButtonText}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
},
|
||||
container: {
|
||||
width: width * 0.9,
|
||||
maxWidth: 500,
|
||||
maxHeight: height * 0.8,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: 24,
|
||||
padding: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#2a2a2a',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.9,
|
||||
},
|
||||
scrollView: {
|
||||
maxHeight: height * 0.45,
|
||||
marginBottom: 20,
|
||||
},
|
||||
announcementItem: {
|
||||
backgroundColor: '#252525',
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
announcementIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
announcementContent: {
|
||||
flex: 1,
|
||||
},
|
||||
announcementHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
announcementTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.3,
|
||||
flex: 1,
|
||||
},
|
||||
tag: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
marginLeft: 8,
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
announcementDescription: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
opacity: 0.9,
|
||||
},
|
||||
button: {
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export default AnnouncementOverlay;
|
||||
|
|
@ -57,8 +57,8 @@ const STATUS_BAR_HEIGHT = StatusBar.currentHeight || 0;
|
|||
const HERO_HEIGHT = height * 0.85;
|
||||
|
||||
// Animated Pagination Dot Component
|
||||
const PaginationDot: React.FC<{
|
||||
isActive: boolean;
|
||||
const PaginationDot: React.FC<{
|
||||
isActive: boolean;
|
||||
isNext: boolean;
|
||||
dragProgress: SharedValue<number>;
|
||||
onPress: () => void;
|
||||
|
|
@ -70,11 +70,11 @@ const PaginationDot: React.FC<{
|
|||
const inactiveWidth = 8;
|
||||
const activeOpacity = 0.9;
|
||||
const inactiveOpacity = 0.3;
|
||||
|
||||
|
||||
// Calculate target width and opacity based on state
|
||||
let targetWidth = isActive ? activeWidth : inactiveWidth;
|
||||
let targetOpacity = isActive ? activeOpacity : inactiveOpacity;
|
||||
|
||||
|
||||
// If this is the next dot during drag, interpolate between inactive and active
|
||||
if (isNext && dragProgress.value > 0) {
|
||||
targetWidth = interpolate(
|
||||
|
|
@ -90,7 +90,7 @@ const PaginationDot: React.FC<{
|
|||
Extrapolation.CLAMP
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// If this is the current active dot during drag, interpolate from active to inactive
|
||||
if (isActive && dragProgress.value > 0) {
|
||||
targetWidth = interpolate(
|
||||
|
|
@ -106,7 +106,7 @@ const PaginationDot: React.FC<{
|
|||
Extrapolation.CLAMP
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
width: withTiming(targetWidth, {
|
||||
duration: 300,
|
||||
|
|
@ -144,11 +144,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const insets = useSafeAreaInsets();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer();
|
||||
|
||||
|
||||
// Create internal scrollY if not provided externally
|
||||
const internalScrollY = useSharedValue(0);
|
||||
const scrollY = externalScrollY || internalScrollY;
|
||||
|
||||
|
||||
// Determine items to display
|
||||
const items = useMemo(() => {
|
||||
if (allFeaturedContent && allFeaturedContent.length > 0) {
|
||||
|
|
@ -174,10 +174,10 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const [trailerPreloaded, setTrailerPreloaded] = useState(false);
|
||||
const [trailerShouldBePaused, setTrailerShouldBePaused] = useState(false);
|
||||
const trailerVideoRef = useRef<any>(null);
|
||||
|
||||
|
||||
// Use ref to avoid re-fetching trailer when trailerMuted changes
|
||||
const showTrailersEnabled = useRef(settings?.showTrailers ?? false);
|
||||
|
||||
|
||||
// Update ref when showTrailers setting changes
|
||||
useEffect(() => {
|
||||
showTrailersEnabled.current = settings?.showTrailers ?? false;
|
||||
|
|
@ -188,6 +188,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
// Animation values
|
||||
const dragProgress = useSharedValue(0);
|
||||
const dragDirection = useSharedValue(0); // -1 for left, 1 for right
|
||||
const isDragging = useSharedValue(0); // 1 when dragging, 0 when not
|
||||
const logoOpacity = useSharedValue(1);
|
||||
const [nextIndex, setNextIndex] = useState(currentIndex);
|
||||
const thumbnailOpacity = useSharedValue(1);
|
||||
|
|
@ -197,14 +198,14 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
|
||||
// Animated style for trailer container - 60% height with zoom
|
||||
const trailerContainerStyle = useAnimatedStyle(() => {
|
||||
// Fade out trailer during drag with smooth curve (inverse of next image fade)
|
||||
// Faster fade out during drag - complete fade by 0.3 progress instead of 1.0
|
||||
const dragFade = interpolate(
|
||||
dragProgress.value,
|
||||
[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.85, 1],
|
||||
[1, 0.95, 0.88, 0.78, 0.65, 0.5, 0.35, 0.22, 0.08, 0],
|
||||
[0, 0.05, 0.1, 0.15, 0.2, 0.3],
|
||||
[1, 0.85, 0.65, 0.4, 0.15, 0],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
|
@ -225,26 +226,36 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
};
|
||||
});
|
||||
|
||||
// Parallax style for background images
|
||||
// Parallax style for background images - disabled during drag
|
||||
const backgroundParallaxStyle = useAnimatedStyle(() => {
|
||||
'worklet';
|
||||
const scrollYValue = scrollY.value;
|
||||
|
||||
|
||||
// Disable parallax during drag to avoid transform conflicts
|
||||
if (isDragging.value > 0) {
|
||||
return {
|
||||
transform: [
|
||||
{ scale: 1.0 },
|
||||
{ translateY: 0 }
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-calculated constants - start at 1.0 for normal size
|
||||
const DEFAULT_ZOOM = 1.0;
|
||||
const SCROLL_UP_MULTIPLIER = 0.002;
|
||||
const SCROLL_DOWN_MULTIPLIER = 0.0001;
|
||||
const MAX_SCALE = 1.3;
|
||||
const PARALLAX_FACTOR = 0.3;
|
||||
|
||||
|
||||
// Optimized scale calculation with minimal branching
|
||||
const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER;
|
||||
const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER;
|
||||
const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE);
|
||||
|
||||
|
||||
// Single parallax calculation
|
||||
const parallaxOffset = scrollYValue * PARALLAX_FACTOR;
|
||||
|
||||
|
||||
return {
|
||||
transform: [
|
||||
{ scale },
|
||||
|
|
@ -253,26 +264,36 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
};
|
||||
});
|
||||
|
||||
// Parallax style for trailer
|
||||
// Parallax style for trailer - disabled during drag
|
||||
const trailerParallaxStyle = useAnimatedStyle(() => {
|
||||
'worklet';
|
||||
const scrollYValue = scrollY.value;
|
||||
|
||||
|
||||
// Disable parallax during drag to avoid transform conflicts
|
||||
if (isDragging.value > 0) {
|
||||
return {
|
||||
transform: [
|
||||
{ scale: 1.0 },
|
||||
{ translateY: 0 }
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-calculated constants - start at 1.0 for normal size
|
||||
const DEFAULT_ZOOM = 1.0;
|
||||
const SCROLL_UP_MULTIPLIER = 0.0015;
|
||||
const SCROLL_DOWN_MULTIPLIER = 0.0001;
|
||||
const MAX_SCALE = 1.2;
|
||||
const PARALLAX_FACTOR = 0.2; // Slower than background for depth
|
||||
|
||||
|
||||
// Optimized scale calculation with minimal branching
|
||||
const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER;
|
||||
const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER;
|
||||
const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE);
|
||||
|
||||
|
||||
// Single parallax calculation
|
||||
const parallaxOffset = scrollYValue * PARALLAX_FACTOR;
|
||||
|
||||
|
||||
return {
|
||||
transform: [
|
||||
{ scale },
|
||||
|
|
@ -316,16 +337,16 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
// Pause this screen's trailer
|
||||
setTrailerShouldBePaused(true);
|
||||
setTrailerPlaying(false);
|
||||
|
||||
|
||||
// Fade out trailer
|
||||
trailerOpacity.value = withTiming(0, { duration: 300 });
|
||||
thumbnailOpacity.value = withTiming(1, { duration: 300 });
|
||||
|
||||
|
||||
logger.info('[AppleTVHero] Screen lost focus - pausing trailer');
|
||||
} else {
|
||||
// Screen gained focus - allow trailer to resume if it was ready
|
||||
setTrailerShouldBePaused(false);
|
||||
|
||||
|
||||
// If trailer was ready and loaded, restore the video opacity
|
||||
if (trailerReady && trailerUrl) {
|
||||
logger.info('[AppleTVHero] Screen gained focus - restoring trailer');
|
||||
|
|
@ -370,20 +391,20 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
setTrailerReady(false);
|
||||
setTrailerPreloaded(false);
|
||||
setTrailerPlaying(false);
|
||||
|
||||
|
||||
// Fade out any existing trailer
|
||||
trailerOpacity.value = withTiming(0, { duration: 300 });
|
||||
thumbnailOpacity.value = withTiming(1, { duration: 300 });
|
||||
|
||||
try {
|
||||
// Extract year from metadata
|
||||
const year = currentItem.releaseInfo
|
||||
? parseInt(currentItem.releaseInfo.split('-')[0], 10)
|
||||
const year = currentItem.releaseInfo
|
||||
? parseInt(currentItem.releaseInfo.split('-')[0], 10)
|
||||
: new Date().getFullYear();
|
||||
|
||||
// Extract TMDB ID if available
|
||||
const tmdbId = currentItem.id?.startsWith('tmdb:')
|
||||
? currentItem.id.replace('tmdb:', '')
|
||||
const tmdbId = currentItem.id?.startsWith('tmdb:')
|
||||
? currentItem.id.replace('tmdb:', '')
|
||||
: undefined;
|
||||
|
||||
const contentType = currentItem.type === 'series' ? 'tv' : 'movie';
|
||||
|
|
@ -391,9 +412,9 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
logger.info('[AppleTVHero] Fetching trailer for:', currentItem.name, year, tmdbId);
|
||||
|
||||
const url = await TrailerService.getTrailerUrl(
|
||||
currentItem.name,
|
||||
year,
|
||||
tmdbId,
|
||||
currentItem.name,
|
||||
year,
|
||||
tmdbId,
|
||||
contentType
|
||||
);
|
||||
|
||||
|
|
@ -435,13 +456,13 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
// Handle trailer ready to play
|
||||
const handleTrailerReady = useCallback(() => {
|
||||
setTrailerReady(true);
|
||||
|
||||
|
||||
// Smooth crossfade: thumbnail out, trailer in
|
||||
thumbnailOpacity.value = withTiming(0, { duration: 800 });
|
||||
trailerOpacity.value = withTiming(1, { duration: 800 });
|
||||
|
||||
|
||||
logger.info('[AppleTVHero] Trailer ready - starting playback');
|
||||
|
||||
|
||||
// Auto-start trailer
|
||||
setTrailerPlaying(true);
|
||||
}, [thumbnailOpacity, trailerOpacity, setTrailerPlaying]);
|
||||
|
|
@ -451,11 +472,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
setTrailerError(true);
|
||||
setTrailerReady(false);
|
||||
setTrailerPlaying(false);
|
||||
|
||||
|
||||
// Fade back to thumbnail
|
||||
trailerOpacity.value = withTiming(0, { duration: 300 });
|
||||
thumbnailOpacity.value = withTiming(1, { duration: 300 });
|
||||
|
||||
|
||||
logger.error('[AppleTVHero] Trailer playback error');
|
||||
}, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]);
|
||||
|
||||
|
|
@ -463,11 +484,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const handleTrailerEnd = useCallback(() => {
|
||||
logger.info('[AppleTVHero] Trailer ended');
|
||||
setTrailerPlaying(false);
|
||||
|
||||
|
||||
// Reset trailer state
|
||||
setTrailerReady(false);
|
||||
setTrailerPreloaded(false);
|
||||
|
||||
|
||||
// Smooth fade back to thumbnail
|
||||
trailerOpacity.value = withTiming(0, { duration: 500 });
|
||||
thumbnailOpacity.value = withTiming(1, { duration: 500 });
|
||||
|
|
@ -531,12 +552,12 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
// Instant reset - no extra fade animation
|
||||
dragProgress.value = 0;
|
||||
setNextIndex(currentIndex);
|
||||
|
||||
|
||||
// Immediately hide trailer and show thumbnail when index changes
|
||||
trailerOpacity.value = 0;
|
||||
thumbnailOpacity.value = 1;
|
||||
setTrailerPlaying(false);
|
||||
|
||||
|
||||
// Faster logo fade
|
||||
logoOpacity.value = 0;
|
||||
logoOpacity.value = withDelay(
|
||||
|
|
@ -580,6 +601,9 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
.activeOffsetX([-5, 5]) // Smaller activation area - more sensitive
|
||||
.failOffsetY([-15, 15]) // Fail if vertical movement is detected
|
||||
.onStart(() => {
|
||||
// Mark as dragging to disable parallax
|
||||
isDragging.value = 1;
|
||||
|
||||
// Determine which direction and set preview
|
||||
runOnJS(updateInteractionTime)();
|
||||
// Immediately stop trailer playback when drag starts
|
||||
|
|
@ -589,10 +613,10 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const translationX = event.translationX;
|
||||
// Use larger width multiplier for smoother visual feedback on small swipes
|
||||
const progress = Math.abs(translationX) / (width * 1.2);
|
||||
|
||||
|
||||
// Update drag progress (0 to 1) with eased curve
|
||||
dragProgress.value = Math.min(progress, 1);
|
||||
|
||||
|
||||
// Track drag direction: positive = right (previous), negative = left (next)
|
||||
if (translationX > 0) {
|
||||
dragDirection.value = 1; // Swiping right - show previous
|
||||
|
|
@ -626,6 +650,9 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
},
|
||||
(finished) => {
|
||||
if (finished) {
|
||||
// Re-enable parallax after navigation completes
|
||||
isDragging.value = withTiming(0, { duration: 200 });
|
||||
|
||||
if (translationX > 0) {
|
||||
runOnJS(goToPrevious)();
|
||||
} else {
|
||||
|
|
@ -640,6 +667,9 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
duration: 450,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1), // Custom ease-out for buttery smooth return
|
||||
});
|
||||
|
||||
// Re-enable parallax immediately on cancel
|
||||
isDragging.value = withTiming(0, { duration: 200 });
|
||||
}
|
||||
}),
|
||||
[goToPrevious, goToNext, updateInteractionTime, setPreviewIndex, hideTrailerOnDrag, currentIndex, items.length]
|
||||
|
|
@ -654,15 +684,15 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
[0, 0.05, 0.12, 0.22, 0.35, 0.5, 0.65, 0.78, 0.92, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
|
||||
|
||||
// Ultra-subtle slide effect with smooth ease-out curve
|
||||
const slideDistance = 6; // Even more subtle 6px movement
|
||||
const slideProgress = interpolate(
|
||||
dragProgress.value,
|
||||
[0, 0.2, 0.4, 0.6, 0.8, 1], // 6-point for ultra-smooth acceleration
|
||||
[
|
||||
-slideDistance * dragDirection.value,
|
||||
-slideDistance * 0.8 * dragDirection.value,
|
||||
-slideDistance * dragDirection.value,
|
||||
-slideDistance * 0.8 * dragDirection.value,
|
||||
-slideDistance * 0.6 * dragDirection.value,
|
||||
-slideDistance * 0.35 * dragDirection.value,
|
||||
-slideDistance * 0.12 * dragDirection.value,
|
||||
|
|
@ -670,7 +700,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
|
||||
|
||||
return {
|
||||
opacity,
|
||||
transform: [{ translateX: slideProgress }],
|
||||
|
|
@ -685,7 +715,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
[1, 0.5, 0],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
|
||||
|
||||
return {
|
||||
opacity: dragFade * logoOpacity.value,
|
||||
};
|
||||
|
|
@ -915,10 +945,10 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
style={logoAnimatedStyle}
|
||||
>
|
||||
{currentItem.logo && !logoError[currentIndex] ? (
|
||||
<View
|
||||
<View
|
||||
style={[
|
||||
styles.logoContainer,
|
||||
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
|
||||
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
|
||||
? { marginBottom: 4 } // Minimal spacing for small logos
|
||||
: { marginBottom: 8 } // Small spacing for normal logos
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,9 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
// Subscribe to library updates and update local state if this item's status changes
|
||||
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
|
||||
const found = items.find((libItem) => libItem.id === item.id && libItem.type === item.type);
|
||||
setInLibrary(!!found);
|
||||
const newInLibrary = !!found;
|
||||
// Only update state if the value actually changed to prevent unnecessary re-renders
|
||||
setInLibrary(prev => prev !== newInLibrary ? newInLibrary : prev);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [item.id, item.type]);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Dimensions,
|
||||
AppState,
|
||||
AppStateStatus,
|
||||
|
|
@ -55,17 +55,17 @@ const calculatePosterLayout = (screenWidth: number) => {
|
|||
const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items
|
||||
const MAX_POSTER_WIDTH = 160; // Maximum poster width for this section
|
||||
const HORIZONTAL_PADDING = 40; // Total horizontal padding/margins
|
||||
|
||||
|
||||
// Calculate how many posters can fit (fewer items for continue watching)
|
||||
const availableWidth = screenWidth - HORIZONTAL_PADDING;
|
||||
const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
|
||||
|
||||
|
||||
// Limit to reasonable number of columns (2-5 for continue watching)
|
||||
const numColumns = Math.min(Math.max(maxColumns, 2), 5);
|
||||
|
||||
|
||||
// Calculate actual poster width
|
||||
const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
|
||||
|
||||
|
||||
return {
|
||||
numColumns,
|
||||
posterWidth,
|
||||
|
|
@ -85,7 +85,7 @@ const isSupportedId = (id: string): boolean => {
|
|||
// Function to check if an episode has been released
|
||||
const isEpisodeReleased = (video: any): boolean => {
|
||||
if (!video.released) return false;
|
||||
|
||||
|
||||
try {
|
||||
const releaseDate = new Date(video.released);
|
||||
const now = new Date();
|
||||
|
|
@ -112,16 +112,16 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const [dimensions, setDimensions] = useState(Dimensions.get('window'));
|
||||
const deviceWidth = dimensions.width;
|
||||
const deviceHeight = dimensions.height;
|
||||
|
||||
|
||||
// Listen for dimension changes (orientation changes)
|
||||
useEffect(() => {
|
||||
const subscription = Dimensions.addEventListener('change', ({ window }) => {
|
||||
setDimensions(window);
|
||||
});
|
||||
|
||||
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
|
||||
|
||||
// Determine device type based on width
|
||||
const getDeviceType = useCallback(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
|
|
@ -129,13 +129,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
|
||||
|
||||
const deviceType = getDeviceType();
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
const isLargeScreen = isTablet || isLargeTablet || isTV;
|
||||
|
||||
|
||||
// Enhanced responsive sizing for continue watching items
|
||||
const computedItemWidth = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -149,7 +149,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
return 280; // Original phone size
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
const computedItemHeight = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
|
|
@ -162,7 +162,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
return 120; // Original phone height
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
// Enhanced spacing and padding
|
||||
const horizontalPadding = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -176,7 +176,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
return 16; // phone
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
const itemSpacing = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
|
|
@ -198,11 +198,11 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
|
||||
// Use a ref to track if a background refresh is in progress to avoid state updates
|
||||
const isRefreshingRef = useRef(false);
|
||||
|
||||
|
||||
// Track recently removed items to prevent immediate re-addition
|
||||
const recentlyRemovedRef = useRef<Set<string>>(new Set());
|
||||
const REMOVAL_IGNORE_DURATION = 10000; // 10 seconds
|
||||
|
||||
|
||||
// Track last Trakt sync to prevent excessive API calls
|
||||
const lastTraktSyncRef = useRef<number>(0);
|
||||
const TRAKT_SYNC_COOLDOWN = 5 * 60 * 1000; // 5 minutes between Trakt syncs
|
||||
|
|
@ -216,18 +216,18 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const cacheKey = `${type}:${id}`;
|
||||
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] = await Promise.all([
|
||||
shouldFetchMeta ? stremioService.getMetaDetails(type, id) : Promise.resolve(null),
|
||||
catalogService.getBasicContentDetails(type, id)
|
||||
]);
|
||||
|
||||
|
||||
if (basicContent) {
|
||||
const result = { metadata, basicContent, timestamp: now };
|
||||
metadataCache.current[cacheKey] = result;
|
||||
|
|
@ -334,13 +334,67 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
if (!isAuthed) return new Set<string>();
|
||||
if (typeof (traktService as any).getWatchedMovies === 'function') {
|
||||
const watched = await (traktService as any).getWatchedMovies();
|
||||
const watchedSet = new Set<string>();
|
||||
|
||||
if (Array.isArray(watched)) {
|
||||
const ids = watched
|
||||
.map((w: any) => w?.movie?.ids?.imdb)
|
||||
.filter(Boolean)
|
||||
.map((imdb: string) => (imdb.startsWith('tt') ? imdb : `tt${imdb}`));
|
||||
return new Set<string>(ids);
|
||||
watched.forEach((w: any) => {
|
||||
const ids = w?.movie?.ids;
|
||||
if (!ids) return;
|
||||
|
||||
if (ids.imdb) {
|
||||
const imdb = ids.imdb;
|
||||
watchedSet.add(imdb.startsWith('tt') ? imdb : `tt${imdb}`);
|
||||
}
|
||||
if (ids.tmdb) {
|
||||
watchedSet.add(ids.tmdb.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
return watchedSet;
|
||||
}
|
||||
return new Set<string>();
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
})();
|
||||
|
||||
// Fetch Trakt watched shows once and reuse
|
||||
const traktShowsSetPromise = (async () => {
|
||||
try {
|
||||
const traktService = TraktService.getInstance();
|
||||
const isAuthed = await traktService.isAuthenticated();
|
||||
if (!isAuthed) return new Set<string>();
|
||||
|
||||
if (typeof (traktService as any).getWatchedShows === 'function') {
|
||||
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 (show.seasons && Array.isArray(show.seasons)) {
|
||||
show.seasons.forEach((season: any) => {
|
||||
if (season.episodes && Array.isArray(season.episodes)) {
|
||||
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;
|
||||
}
|
||||
return new Set<string>();
|
||||
} catch {
|
||||
|
|
@ -365,7 +419,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
traktSynced: true,
|
||||
traktProgress: 100,
|
||||
} as any);
|
||||
} catch (_e) {}
|
||||
} catch (_e) { }
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -422,6 +476,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
let season: number | undefined;
|
||||
let episodeNumber: number | undefined;
|
||||
let episodeTitle: string | undefined;
|
||||
let isWatchedOnTrakt = false;
|
||||
|
||||
if (episodeId && group.type === 'series') {
|
||||
let match = episodeId.match(/s(\d+)e(\d+)/i);
|
||||
if (match) {
|
||||
|
|
@ -442,6 +498,61 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this specific episode is watched on Trakt
|
||||
if (season !== undefined && episodeNumber !== undefined) {
|
||||
const watchedEpisodesSet = await traktShowsSetPromise;
|
||||
// Try with both raw ID and tt-prefixed ID, and TMDB ID (which is just the ID string)
|
||||
const rawId = group.id.replace(/^tt/, '');
|
||||
const ttId = `tt${rawId}`;
|
||||
|
||||
if (watchedEpisodesSet.has(`${ttId}:${season}:${episodeNumber}`) ||
|
||||
watchedEpisodesSet.has(`${rawId}:${season}:${episodeNumber}`) ||
|
||||
watchedEpisodesSet.has(`${group.id}:${season}:${episodeNumber}`)) {
|
||||
isWatchedOnTrakt = true;
|
||||
|
||||
// Update local storage to reflect watched status
|
||||
try {
|
||||
await storageService.setWatchProgress(
|
||||
group.id,
|
||||
'series',
|
||||
{
|
||||
currentTime: 1,
|
||||
duration: 1,
|
||||
lastUpdated: Date.now(),
|
||||
traktSynced: true,
|
||||
traktProgress: 100,
|
||||
} as any,
|
||||
episodeId
|
||||
);
|
||||
} catch (_e) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If watched on Trakt, treat it as completed (try to find next episode)
|
||||
if (isWatchedOnTrakt) {
|
||||
let nextSeason = season;
|
||||
let nextEpisode = (episodeNumber || 0) + 1;
|
||||
|
||||
if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) {
|
||||
const nextEpisodeVideo = metadata.videos.find((video: any) =>
|
||||
video.season === nextSeason && video.episode === nextEpisode
|
||||
);
|
||||
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
|
||||
batch.push({
|
||||
...basicContent,
|
||||
id: group.id,
|
||||
type: group.type,
|
||||
progress: 0,
|
||||
lastUpdated: progress.lastUpdated,
|
||||
season: nextSeason,
|
||||
episode: nextEpisode,
|
||||
episodeTitle: `Episode ${nextEpisode}`,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.push({
|
||||
|
|
@ -466,14 +577,14 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const traktService = TraktService.getInstance();
|
||||
const isAuthed = await traktService.isAuthenticated();
|
||||
if (!isAuthed) return;
|
||||
|
||||
|
||||
// Check Trakt sync cooldown to prevent excessive API calls
|
||||
const now = Date.now();
|
||||
if (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 historyItems = await traktService.getWatchedEpisodesHistory(1, 200);
|
||||
const latestWatchedByShow: Record<string, { season: number; episode: number; watchedAt: number }> = {};
|
||||
|
|
@ -650,7 +761,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadContinueWatching(true);
|
||||
return () => {};
|
||||
return () => { };
|
||||
}, [loadContinueWatching])
|
||||
);
|
||||
|
||||
|
|
@ -667,62 +778,62 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const handleContentPress = useCallback(async (item: ContinueWatchingItem) => {
|
||||
try {
|
||||
logger.log(`🎬 [ContinueWatching] User clicked on: ${item.name} (${item.type}:${item.id})`);
|
||||
|
||||
|
||||
// Check if cached streams are enabled in settings
|
||||
if (!settings.useCachedStreams) {
|
||||
logger.log(`📺 [ContinueWatching] Cached streams disabled, navigating to ${settings.openMetadataScreenWhenCacheDisabled ? 'MetadataScreen' : 'StreamsScreen'} for ${item.name}`);
|
||||
|
||||
|
||||
// Navigate based on the second setting
|
||||
if (settings.openMetadataScreenWhenCacheDisabled) {
|
||||
// Navigate to MetadataScreen
|
||||
if (item.type === 'series' && item.season && item.episode) {
|
||||
const episodeId = `${item.id}:${item.season}:${item.episode}`;
|
||||
navigation.navigate('Metadata', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId: episodeId
|
||||
navigation.navigate('Metadata', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId: episodeId
|
||||
});
|
||||
} else {
|
||||
navigation.navigate('Metadata', {
|
||||
id: item.id,
|
||||
type: item.type
|
||||
navigation.navigate('Metadata', {
|
||||
id: item.id,
|
||||
type: item.type
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Navigate to StreamsScreen
|
||||
if (item.type === 'series' && item.season && item.episode) {
|
||||
const episodeId = `${item.id}:${item.season}:${item.episode}`;
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId: episodeId
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId: episodeId
|
||||
});
|
||||
} else {
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if we have a cached stream for this content
|
||||
const episodeId = item.type === 'series' && item.season && item.episode
|
||||
? `${item.id}:${item.season}:${item.episode}`
|
||||
const episodeId = item.type === 'series' && item.season && item.episode
|
||||
? `${item.id}:${item.season}:${item.episode}`
|
||||
: undefined;
|
||||
|
||||
|
||||
logger.log(`🔍 [ContinueWatching] Looking for cached stream with episodeId: ${episodeId || 'none'}`);
|
||||
|
||||
|
||||
const cachedStream = await streamCacheService.getCachedStream(item.id, item.type, episodeId);
|
||||
|
||||
|
||||
if (cachedStream) {
|
||||
// We have a valid cached stream, navigate directly to player
|
||||
logger.log(`🚀 [ContinueWatching] Using cached stream for ${item.name}`);
|
||||
|
||||
|
||||
// Determine the player route based on platform
|
||||
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
|
||||
|
||||
|
||||
// Navigate directly to player with cached stream data
|
||||
navigation.navigate(playerRoute as any, {
|
||||
uri: cachedStream.stream.url,
|
||||
|
|
@ -743,25 +854,25 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
backdrop: cachedStream.metadata?.backdrop || item.banner,
|
||||
videoType: undefined, // Let player auto-detect
|
||||
} as any);
|
||||
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// No cached stream or cache failed, navigate to StreamsScreen
|
||||
logger.log(`📺 [ContinueWatching] No cached stream, navigating to StreamsScreen for ${item.name}`);
|
||||
|
||||
|
||||
if (item.type === 'series' && item.season && item.episode) {
|
||||
// For series, navigate to the specific episode
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId: episodeId
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId: episodeId
|
||||
});
|
||||
} else {
|
||||
// For movies or series without specific episode, navigate to main content
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -769,15 +880,15 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
// Fallback to StreamsScreen on any error
|
||||
if (item.type === 'series' && item.season && item.episode) {
|
||||
const episodeId = `${item.id}:${item.season}:${item.episode}`;
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId: episodeId
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId: episodeId
|
||||
});
|
||||
} else {
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type
|
||||
navigation.navigate('Streams', {
|
||||
id: item.id,
|
||||
type: item.type
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -798,7 +909,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
{
|
||||
label: 'Cancel',
|
||||
style: { color: '#888' },
|
||||
onPress: () => {},
|
||||
onPress: () => { },
|
||||
},
|
||||
{
|
||||
label: 'Remove',
|
||||
|
|
@ -842,7 +953,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.wideContentItem,
|
||||
styles.wideContentItem,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
borderColor: currentTheme.colors.border,
|
||||
|
|
@ -864,7 +975,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
]}>
|
||||
<FastImage
|
||||
source={{
|
||||
source={{
|
||||
uri: item.poster || 'https://via.placeholder.com/300x450',
|
||||
priority: FastImage.priority.high,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
|
|
@ -872,7 +983,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
style={styles.continueWatchingPoster}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
|
||||
|
||||
{/* Delete Indicator Overlay */}
|
||||
{deletingItemId === item.id && (
|
||||
<View style={styles.deletingOverlay}>
|
||||
|
|
@ -893,10 +1004,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const isUpNext = item.type === 'series' && item.progress === 0;
|
||||
return (
|
||||
<View style={styles.titleRow}>
|
||||
<Text
|
||||
<Text
|
||||
style={[
|
||||
styles.contentTitle,
|
||||
{
|
||||
styles.contentTitle,
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16
|
||||
}
|
||||
|
|
@ -906,19 +1017,19 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
{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
|
||||
}
|
||||
]}>
|
||||
<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 }
|
||||
]}>Up Next</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
|
@ -931,8 +1042,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
return (
|
||||
<View style={styles.episodeRow}>
|
||||
<Text style={[
|
||||
styles.episodeText,
|
||||
{
|
||||
styles.episodeText,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
|
||||
}
|
||||
|
|
@ -940,10 +1051,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
Season {item.season}
|
||||
</Text>
|
||||
{item.episodeTitle && (
|
||||
<Text
|
||||
<Text
|
||||
style={[
|
||||
styles.episodeTitle,
|
||||
{
|
||||
styles.episodeTitle,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 13 : 12
|
||||
}
|
||||
|
|
@ -958,8 +1069,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
} else {
|
||||
return (
|
||||
<Text style={[
|
||||
styles.yearText,
|
||||
{
|
||||
styles.yearText,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
|
||||
}
|
||||
|
|
@ -979,19 +1090,19 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
height: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
|
||||
}
|
||||
]}>
|
||||
<View
|
||||
<View
|
||||
style={[
|
||||
styles.wideProgressBar,
|
||||
{
|
||||
width: `${item.progress}%`,
|
||||
backgroundColor: currentTheme.colors.primary
|
||||
styles.wideProgressBar,
|
||||
{
|
||||
width: `${item.progress}%`,
|
||||
backgroundColor: currentTheme.colors.primary
|
||||
}
|
||||
]}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.progressLabel,
|
||||
{
|
||||
styles.progressLabel,
|
||||
{
|
||||
color: currentTheme.colors.textMuted,
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 11
|
||||
}
|
||||
|
|
@ -1023,15 +1134,15 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
<View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[
|
||||
styles.title,
|
||||
{
|
||||
styles.title,
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
|
||||
}
|
||||
]}>Continue Watching</Text>
|
||||
<View style={[
|
||||
styles.titleUnderline,
|
||||
{
|
||||
styles.titleUnderline,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
width: isTV ? 50 : isLargeTablet ? 45 : isTablet ? 40 : 40,
|
||||
height: isTV ? 4 : isLargeTablet ? 3.5 : isTablet ? 3 : 3
|
||||
|
|
@ -1039,7 +1150,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
]} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
<FlashList
|
||||
data={continueWatchingItems}
|
||||
renderItem={renderContinueWatchingItem}
|
||||
|
|
@ -1048,14 +1159,14 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[
|
||||
styles.wideList,
|
||||
{
|
||||
paddingLeft: horizontalPadding,
|
||||
paddingRight: horizontalPadding
|
||||
{
|
||||
paddingLeft: horizontalPadding,
|
||||
paddingRight: horizontalPadding
|
||||
}
|
||||
]}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
onEndReachedThreshold={0.7}
|
||||
onEndReached={() => {}}
|
||||
onEndReached={() => { }}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
|
||||
|
|
@ -1209,7 +1320,7 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
contentItem: {
|
||||
width: POSTER_WIDTH,
|
||||
aspectRatio: 2/3,
|
||||
aspectRatio: 2 / 3,
|
||||
margin: 0,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ interface ThisWeekEpisode {
|
|||
vote_average: number;
|
||||
still_path: string | null;
|
||||
season_poster_path: string | null;
|
||||
// Grouping fields
|
||||
isGroup?: boolean;
|
||||
episodeCount?: number;
|
||||
episodeRange?: string;
|
||||
}
|
||||
|
||||
export const ThisWeekSection = React.memo(() => {
|
||||
|
|
@ -60,7 +64,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
// Enhanced responsive sizing for tablets and TV screens
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const deviceHeight = Dimensions.get('window').height;
|
||||
|
||||
|
||||
// Determine device type based on width
|
||||
const getDeviceType = useCallback(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
|
|
@ -68,13 +72,13 @@ export const ThisWeekSection = React.memo(() => {
|
|||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
|
||||
|
||||
const deviceType = getDeviceType();
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
const isLargeScreen = isTablet || isLargeTablet || isTV;
|
||||
|
||||
|
||||
// Enhanced responsive sizing
|
||||
const computedItemWidth = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -88,7 +92,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
return ITEM_WIDTH; // phone
|
||||
}
|
||||
}, [deviceType, deviceWidth]);
|
||||
|
||||
|
||||
const computedItemHeight = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
|
|
@ -101,7 +105,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
return ITEM_HEIGHT; // phone
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
// Enhanced spacing and padding
|
||||
const horizontalPadding = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -115,7 +119,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
return 16; // phone
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
const itemSpacing = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
|
|
@ -134,16 +138,72 @@ export const ThisWeekSection = React.memo(() => {
|
|||
const thisWeekSection = calendarData.find(section => section.title === 'This Week');
|
||||
if (!thisWeekSection) return [];
|
||||
|
||||
// Limit episodes to prevent memory issues and add release status
|
||||
const episodes = memoryManager.limitArraySize(thisWeekSection.data, 20); // Limit to 20 for home screen
|
||||
|
||||
return episodes.map(episode => ({
|
||||
...episode,
|
||||
isReleased: episode.releaseDate ? isBefore(parseISO(episode.releaseDate), new Date()) : false,
|
||||
}));
|
||||
// Get raw episodes (limit to 60 to be safe for performance but allow grouping)
|
||||
const rawEpisodes = memoryManager.limitArraySize(thisWeekSection.data, 60);
|
||||
|
||||
// Group by series and date
|
||||
const groups: Record<string, typeof rawEpisodes> = {};
|
||||
|
||||
rawEpisodes.forEach(ep => {
|
||||
// Create a unique key for series + date
|
||||
const dateKey = ep.releaseDate || 'unknown';
|
||||
const key = `${ep.seriesId}_${dateKey}`;
|
||||
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(ep);
|
||||
});
|
||||
|
||||
const processedItems: ThisWeekEpisode[] = [];
|
||||
|
||||
Object.values(groups).forEach(group => {
|
||||
// Sort episodes in the group by episode number
|
||||
group.sort((a, b) => a.episode - b.episode);
|
||||
|
||||
const firstEp = group[0];
|
||||
const isReleased = firstEp.releaseDate ? isBefore(parseISO(firstEp.releaseDate), new Date()) : false;
|
||||
|
||||
if (group.length === 1) {
|
||||
processedItems.push({
|
||||
...firstEp,
|
||||
isReleased
|
||||
});
|
||||
} else {
|
||||
// Create group item
|
||||
const lastEp = group[group.length - 1];
|
||||
processedItems.push({
|
||||
...firstEp,
|
||||
id: `group_${firstEp.seriesId}_${firstEp.releaseDate}`, // Unique ID for the group
|
||||
title: `${group.length} New Episodes`,
|
||||
isReleased,
|
||||
isGroup: true,
|
||||
episodeCount: group.length,
|
||||
episodeRange: `E${firstEp.episode}-${lastEp.episode}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by release date
|
||||
processedItems.sort((a, b) => {
|
||||
if (!a.releaseDate) return 1;
|
||||
if (!b.releaseDate) return -1;
|
||||
return a.releaseDate.localeCompare(b.releaseDate);
|
||||
});
|
||||
|
||||
return memoryManager.limitArraySize(processedItems, 20);
|
||||
}, [calendarData]);
|
||||
|
||||
|
||||
const handleEpisodePress = (episode: ThisWeekEpisode) => {
|
||||
// For grouped episodes, always go to series details
|
||||
if (episode.isGroup) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: episode.seriesId,
|
||||
type: 'series'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For upcoming episodes, go to the metadata screen
|
||||
if (!episode.isReleased) {
|
||||
const episodeId = `${episode.seriesId}:${episode.season}:${episode.episode}`;
|
||||
|
|
@ -154,7 +214,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// For released episodes, go to the streams screen
|
||||
const episodeId = `${episode.seriesId}:${episode.season}:${episode.episode}`;
|
||||
navigation.navigate('Streams', {
|
||||
|
|
@ -163,136 +223,123 @@ export const ThisWeekSection = React.memo(() => {
|
|||
episodeId
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleViewAll = () => {
|
||||
navigation.navigate('Calendar' as any);
|
||||
};
|
||||
|
||||
|
||||
if (thisWeekEpisodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => {
|
||||
// Handle episodes without release dates gracefully
|
||||
const releaseDate = item.releaseDate ? parseISO(item.releaseDate) : null;
|
||||
const formattedDate = releaseDate ? format(releaseDate, 'E, MMM d') : 'TBA';
|
||||
const formattedDate = releaseDate ? format(releaseDate, 'MMM d') : 'TBA';
|
||||
const isReleased = item.isReleased;
|
||||
|
||||
|
||||
// Use episode still image if available, fallback to series poster
|
||||
const imageUrl = item.still_path ?
|
||||
tmdbService.getImageUrl(item.still_path) :
|
||||
(item.season_poster_path ?
|
||||
tmdbService.getImageUrl(item.season_poster_path) :
|
||||
const imageUrl = item.still_path ?
|
||||
tmdbService.getImageUrl(item.still_path) :
|
||||
(item.season_poster_path ?
|
||||
tmdbService.getImageUrl(item.season_poster_path) :
|
||||
item.poster);
|
||||
|
||||
|
||||
return (
|
||||
<View style={[styles.episodeItemContainer, { width: computedItemWidth, height: computedItemHeight }]}>
|
||||
{item.isGroup && (
|
||||
<View style={[
|
||||
styles.cardStackEffect,
|
||||
{
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
}
|
||||
]} />
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.episodeItem,
|
||||
{
|
||||
shadowColor: currentTheme.colors.black,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.background,
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
borderWidth: 1,
|
||||
}
|
||||
]}
|
||||
onPress={() => handleEpisodePress(item)}
|
||||
activeOpacity={0.8}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.imageContainer}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: imageUrl || undefined,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={styles.poster}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
|
||||
{/* Enhanced gradient overlay */}
|
||||
<LinearGradient
|
||||
<FastImage
|
||||
source={{
|
||||
uri: imageUrl || undefined,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={styles.poster}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'transparent',
|
||||
'transparent',
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.8)',
|
||||
'rgba(0,0,0,0.95)'
|
||||
'rgba(0,0,0,0.0)',
|
||||
'rgba(0,0,0,0.5)',
|
||||
'rgba(0,0,0,0.9)'
|
||||
]}
|
||||
style={[
|
||||
styles.gradient,
|
||||
{
|
||||
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
|
||||
}
|
||||
]}
|
||||
locations={[0, 0.4, 0.6, 0.8, 1]}
|
||||
style={styles.gradient}
|
||||
locations={[0, 0.4, 0.7, 1]}
|
||||
>
|
||||
{/* Content area */}
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: isReleased ? currentTheme.colors.primary : 'rgba(0,0,0,0.6)' }
|
||||
]}>
|
||||
<Text style={styles.statusText}>
|
||||
{isReleased ? (item.isGroup ? 'Released' : 'New') : formattedDate}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.contentArea}>
|
||||
<Text style={[
|
||||
styles.seriesName,
|
||||
{
|
||||
styles.seriesName,
|
||||
{
|
||||
color: currentTheme.colors.white,
|
||||
fontSize: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16
|
||||
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16
|
||||
}
|
||||
]} numberOfLines={1}>
|
||||
{item.seriesName}
|
||||
</Text>
|
||||
|
||||
<Text style={[
|
||||
styles.episodeTitle,
|
||||
{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14
|
||||
}
|
||||
]} numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
|
||||
{item.overview && (
|
||||
|
||||
<View style={styles.metaContainer}>
|
||||
<Text style={[
|
||||
styles.overview,
|
||||
{
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 13 : 12
|
||||
}
|
||||
]} numberOfLines={isLargeScreen ? 3 : 2}>
|
||||
{item.overview}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.dateContainer}>
|
||||
<Text style={[
|
||||
styles.episodeInfo,
|
||||
{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 13 : 12
|
||||
}
|
||||
]}>
|
||||
S{item.season}:E{item.episode} •
|
||||
</Text>
|
||||
<MaterialIcons
|
||||
name="event"
|
||||
size={isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.releaseDate,
|
||||
{
|
||||
styles.seasonBadge,
|
||||
{
|
||||
color: currentTheme.colors.primary,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 12
|
||||
}
|
||||
]}>
|
||||
{formattedDate}
|
||||
S{item.season} {item.isGroup ? item.episodeRange : `E${item.episode}`}
|
||||
</Text>
|
||||
<Text style={styles.dotSeparator}>•</Text>
|
||||
<Text style={[
|
||||
styles.episodeTitle,
|
||||
{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 12
|
||||
}
|
||||
]} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={styles.container}
|
||||
|
|
@ -300,16 +347,16 @@ export const ThisWeekSection = React.memo(() => {
|
|||
>
|
||||
<View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[
|
||||
styles.title,
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
|
||||
}
|
||||
]}>This Week</Text>
|
||||
<Text style={[
|
||||
styles.title,
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
|
||||
}
|
||||
]}>This Week</Text>
|
||||
<View style={[
|
||||
styles.titleUnderline,
|
||||
{
|
||||
styles.titleUnderline,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
width: isTV ? 50 : isLargeTablet ? 45 : isTablet ? 40 : 40,
|
||||
height: isTV ? 4 : isLargeTablet ? 3.5 : isTablet ? 3 : 3
|
||||
|
|
@ -324,20 +371,20 @@ export const ThisWeekSection = React.memo(() => {
|
|||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.viewAllText,
|
||||
{
|
||||
styles.viewAllText,
|
||||
{
|
||||
color: currentTheme.colors.textMuted,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 16 : isTablet ? 15 : 14
|
||||
}
|
||||
]}>View All</Text>
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}
|
||||
color={currentTheme.colors.textMuted}
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}
|
||||
color={currentTheme.colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
||||
<FlatList
|
||||
data={thisWeekEpisodes}
|
||||
keyExtractor={(item) => item.id}
|
||||
|
|
@ -345,10 +392,10 @@ export const ThisWeekSection = React.memo(() => {
|
|||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
{
|
||||
paddingLeft: horizontalPadding,
|
||||
paddingRight: horizontalPadding
|
||||
styles.listContent,
|
||||
{
|
||||
paddingLeft: horizontalPadding,
|
||||
paddingRight: horizontalPadding
|
||||
}
|
||||
]}
|
||||
snapToInterval={computedItemWidth + itemSpacing}
|
||||
|
|
@ -371,7 +418,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginVertical: 20,
|
||||
marginVertical: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -400,14 +447,15 @@ const styles = StyleSheet.create({
|
|||
viewAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
marginRight: -10,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
viewAllText: {
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginRight: 4,
|
||||
},
|
||||
|
|
@ -432,10 +480,11 @@ const styles = StyleSheet.create({
|
|||
height: '100%',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 12,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
|
|
@ -453,44 +502,64 @@ const styles = StyleSheet.create({
|
|||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
padding: 12,
|
||||
borderRadius: 16,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
width: '100%',
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
statusText: {
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
contentArea: {
|
||||
width: '100%',
|
||||
},
|
||||
seriesName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 6,
|
||||
},
|
||||
episodeTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
lineHeight: 18,
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
},
|
||||
overview: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
marginBottom: 6,
|
||||
opacity: 0.9,
|
||||
},
|
||||
dateContainer: {
|
||||
metaContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
episodeInfo: {
|
||||
seasonBadge: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginRight: 4,
|
||||
fontWeight: '700',
|
||||
},
|
||||
releaseDate: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
letterSpacing: 0.3,
|
||||
dotSeparator: {
|
||||
marginHorizontal: 6,
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
},
|
||||
episodeTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
flex: 1,
|
||||
},
|
||||
cardStackEffect: {
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
width: '92%',
|
||||
height: '100%',
|
||||
left: '4%',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
zIndex: -1,
|
||||
},
|
||||
});
|
||||
|
|
@ -1041,45 +1041,49 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
// Grace delay before showing text fallback to avoid flashing when logo arrives late
|
||||
const [shouldShowTextFallback, setShouldShowTextFallback] = useState<boolean>(!metadata?.logo);
|
||||
const logoWaitTimerRef = useRef<any>(null);
|
||||
// Ref to track the last synced logo to break circular dependency with error handling
|
||||
const lastSyncedLogoRef = useRef<string | undefined>(metadata?.logo);
|
||||
|
||||
// Update stable logo URI when metadata logo changes
|
||||
useEffect(() => {
|
||||
// Reset text fallback and timers on logo updates
|
||||
if (logoWaitTimerRef.current) {
|
||||
try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {}
|
||||
logoWaitTimerRef.current = null;
|
||||
}
|
||||
// Check if metadata logo has actually changed from what we last processed
|
||||
const currentMetadataLogo = metadata?.logo;
|
||||
|
||||
if (currentMetadataLogo !== lastSyncedLogoRef.current) {
|
||||
lastSyncedLogoRef.current = currentMetadataLogo;
|
||||
|
||||
if (metadata?.logo && metadata.logo !== stableLogoUri) {
|
||||
setStableLogoUri(metadata.logo);
|
||||
onStableLogoUriChange?.(metadata.logo);
|
||||
setLogoHasLoadedSuccessfully(false); // Reset for new logo
|
||||
logoLoadOpacity.value = 0; // reset fade for new logo
|
||||
setShouldShowTextFallback(false);
|
||||
} else if (!metadata?.logo && stableLogoUri) {
|
||||
// Clear logo if metadata no longer has one
|
||||
setStableLogoUri(null);
|
||||
onStableLogoUriChange?.(null);
|
||||
setLogoHasLoadedSuccessfully(false);
|
||||
// Start a short grace period before showing text fallback
|
||||
setShouldShowTextFallback(false);
|
||||
logoWaitTimerRef.current = setTimeout(() => {
|
||||
setShouldShowTextFallback(true);
|
||||
}, 600);
|
||||
} else if (!metadata?.logo && !stableLogoUri) {
|
||||
// No logo currently; wait briefly before showing text to avoid flash
|
||||
setShouldShowTextFallback(false);
|
||||
logoWaitTimerRef.current = setTimeout(() => {
|
||||
setShouldShowTextFallback(true);
|
||||
}, 600);
|
||||
// Reset text fallback and timers on logo updates
|
||||
if (logoWaitTimerRef.current) {
|
||||
try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {}
|
||||
logoWaitTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (currentMetadataLogo) {
|
||||
setStableLogoUri(currentMetadataLogo);
|
||||
onStableLogoUriChange?.(currentMetadataLogo);
|
||||
setLogoHasLoadedSuccessfully(false); // Reset for new logo
|
||||
logoLoadOpacity.value = 0; // reset fade for new logo
|
||||
setShouldShowTextFallback(false);
|
||||
} else {
|
||||
// Clear logo if metadata no longer has one
|
||||
setStableLogoUri(null);
|
||||
onStableLogoUriChange?.(null);
|
||||
setLogoHasLoadedSuccessfully(false);
|
||||
// Start a short grace period before showing text fallback
|
||||
setShouldShowTextFallback(false);
|
||||
logoWaitTimerRef.current = setTimeout(() => {
|
||||
setShouldShowTextFallback(true);
|
||||
}, 600);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (logoWaitTimerRef.current) {
|
||||
try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {}
|
||||
logoWaitTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [metadata?.logo, stableLogoUri]);
|
||||
}, [metadata?.logo]); // Removed stableLogoUri from dependencies to prevent circular updates on error
|
||||
|
||||
// Handle logo load success - once loaded successfully, keep it stable
|
||||
const handleLogoLoad = useCallback(() => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -54,7 +54,7 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
|||
formattedSegments,
|
||||
}) => {
|
||||
if (!useCustomSubtitles || !currentSubtitle) return null;
|
||||
|
||||
|
||||
const inverseScale = 1 / zoomScale;
|
||||
const bgColor = subtitleBackground ? `rgba(0, 0, 0, ${Math.min(Math.max(backgroundOpacity, 0), 1)})` : 'transparent';
|
||||
let effectiveBottom = bottomOffset;
|
||||
|
|
@ -65,23 +65,26 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
|||
}
|
||||
effectiveBottom = Math.max(0, effectiveBottom);
|
||||
|
||||
// Prepare content lines
|
||||
const lines = String(currentSubtitle).split(/\r?\n/);
|
||||
|
||||
// Detect RTL for each line
|
||||
const lineRTLStatus = lines.map(line => detectRTL(line));
|
||||
const hasRTL = lineRTLStatus.some(status => status);
|
||||
|
||||
// When using crisp outline, prefer SVG text with real stroke instead of blur shadow
|
||||
const useCrispSvgOutline = outline === true;
|
||||
// However, SVG text does not support complex text shaping (required for Arabic/RTL),
|
||||
// so we must fallback to standard Text component for RTL languages.
|
||||
const useCrispSvgOutline = outline === true && !hasRTL;
|
||||
|
||||
const shadowStyle = (textShadow && !useCrispSvgOutline)
|
||||
? {
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.9)',
|
||||
textShadowOffset: { width: 2, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
}
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.9)',
|
||||
textShadowOffset: { width: 2, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
}
|
||||
: {};
|
||||
|
||||
// Prepare content lines
|
||||
const lines = String(currentSubtitle).split(/\r?\n/);
|
||||
|
||||
// Detect RTL for each line
|
||||
const lineRTLStatus = lines.map(line => detectRTL(line));
|
||||
|
||||
const displayFontSize = subtitleSize * inverseScale;
|
||||
const displayLineHeight = subtitleSize * lineHeightMultiplier * inverseScale;
|
||||
const svgHeight = lines.length * displayLineHeight;
|
||||
|
|
@ -89,14 +92,14 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
|||
// Helper to render formatted segments
|
||||
const renderFormattedText = (segments: SubtitleSegment[], lineIndex: number, keyPrefix: string, isRTL?: boolean, customLetterSpacing?: number) => {
|
||||
if (!segments || segments.length === 0) return null;
|
||||
|
||||
|
||||
// For RTL, use a very small negative letter spacing to stretch words slightly
|
||||
// This helps with proper diacritic spacing while maintaining ligatures
|
||||
const effectiveLetterSpacing = isRTL ? (displayFontSize * -0.02) : (customLetterSpacing ?? letterSpacing);
|
||||
|
||||
// For RTL, adjust text alignment
|
||||
const effectiveAlign = isRTL && align === 'left' ? 'right' : (isRTL && align === 'right' ? 'left' : align);
|
||||
|
||||
|
||||
return (
|
||||
<Text key={`${keyPrefix}-line-${lineIndex}`} style={{
|
||||
color: textColor,
|
||||
|
|
@ -156,7 +159,7 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
|||
const isRTL = lineRTLStatus.every(status => status);
|
||||
let anchor: 'start' | 'middle' | 'end';
|
||||
let x: number;
|
||||
|
||||
|
||||
if (isRTL) {
|
||||
// For RTL, always use 'end' anchor to position from right edge
|
||||
anchor = 'end';
|
||||
|
|
@ -165,20 +168,20 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
|||
anchor = align === 'center' ? 'middle' : align === 'left' ? 'start' : 'end';
|
||||
x = align === 'center' ? 500 : (align === 'left' ? 0 : 1000);
|
||||
}
|
||||
|
||||
|
||||
const baseFontSize = displayFontSize;
|
||||
const lineHeightPx = displayLineHeight;
|
||||
const strokeWidth = Math.max(0.5, outlineWidth);
|
||||
// For RTL, use a very small negative letter spacing to stretch words slightly
|
||||
// This helps with proper diacritic spacing while maintaining ligatures
|
||||
const effectiveLetterSpacing = isRTL ? (baseFontSize * -0.02) : letterSpacing;
|
||||
|
||||
|
||||
// Position text from bottom up - last line should be at svgHeight - small margin
|
||||
// Add descender buffer so letters like y/g/p/q/j aren't clipped
|
||||
const descenderBuffer = baseFontSize * 0.35 + (strokeWidth * 0.5);
|
||||
const lastLineBaselineY = svgHeight - descenderBuffer;
|
||||
const startY = lastLineBaselineY - (lines.length - 1) * lineHeightPx;
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Stroke layer */}
|
||||
|
|
@ -239,7 +242,7 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
|||
const effectiveLetterSpacing = isRTL ? (subtitleSize * inverseScale * -0.02) : letterSpacing;
|
||||
// For RTL, adjust text alignment
|
||||
const effectiveAlign = isRTL && align === 'left' ? 'right' : (isRTL && align === 'right' ? 'left' : align);
|
||||
|
||||
|
||||
return (
|
||||
<Text style={[
|
||||
styles.customSubtitleText,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const safeDebugLog = (message: string, data?: any) => {
|
|||
};
|
||||
|
||||
// Add language code to name mapping
|
||||
export const languageMap: {[key: string]: string} = {
|
||||
export const languageMap: { [key: string]: string } = {
|
||||
'en': 'English',
|
||||
'eng': 'English',
|
||||
'es': 'Spanish',
|
||||
|
|
@ -81,10 +81,10 @@ export const formatLanguage = (code?: string): string => {
|
|||
if (!code) return 'Unknown';
|
||||
const normalized = code.toLowerCase();
|
||||
const languageName = languageMap[normalized] || code.toUpperCase();
|
||||
|
||||
|
||||
// If the result is still the uppercased code, it means we couldn't find it in our map.
|
||||
if (languageName === code.toUpperCase()) {
|
||||
return `Unknown (${code})`;
|
||||
return `Unknown (${code})`;
|
||||
}
|
||||
|
||||
return languageName;
|
||||
|
|
@ -103,8 +103,8 @@ export const getTrackDisplayName = (track: { name?: string, id: number, language
|
|||
}
|
||||
|
||||
// If the track name contains detailed information (like codec, bitrate, etc.), use it as-is
|
||||
if (track.name && (track.name.includes('DDP') || track.name.includes('DTS') || track.name.includes('AAC') ||
|
||||
track.name.includes('Kbps') || track.name.includes('Atmos') || track.name.includes('~'))) {
|
||||
if (track.name && (track.name.includes('DDP') || track.name.includes('DTS') || track.name.includes('AAC') ||
|
||||
track.name.includes('Kbps') || track.name.includes('Atmos') || track.name.includes('~'))) {
|
||||
return track.name;
|
||||
}
|
||||
|
||||
|
|
@ -159,7 +159,7 @@ export const formatTime = (seconds: number) => {
|
|||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${mins < 10 ? '0' : ''}${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
||||
} else {
|
||||
|
|
@ -189,14 +189,14 @@ export const detectRTL = (text: string): boolean => {
|
|||
// Arabic Presentation Forms-B: U+FE70–U+FEFF
|
||||
// Hebrew: U+0590–U+05FF
|
||||
// Persian/Urdu use Arabic script (no separate range)
|
||||
const rtlRegex = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/;
|
||||
|
||||
const rtlRegex = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/g;
|
||||
|
||||
// Remove whitespace and count characters
|
||||
const nonWhitespace = text.replace(/\s/g, '');
|
||||
if (nonWhitespace.length === 0) return false;
|
||||
|
||||
const rtlCount = (nonWhitespace.match(rtlRegex) || []).length;
|
||||
|
||||
|
||||
// Consider RTL if at least 30% of non-whitespace characters are RTL
|
||||
// This handles mixed-language subtitles (e.g., Arabic with English numbers)
|
||||
return rtlCount / nonWhitespace.length >= 0.3;
|
||||
|
|
|
|||
|
|
@ -10,24 +10,24 @@ import { parseISO, isBefore, isAfter, startOfToday, addWeeks, isThisWeek } from
|
|||
import { StreamingContent } from '../services/catalogService';
|
||||
|
||||
interface CalendarEpisode {
|
||||
id: string;
|
||||
seriesId: string;
|
||||
title: string;
|
||||
seriesName: string;
|
||||
poster: string;
|
||||
releaseDate: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
overview: string;
|
||||
vote_average: number;
|
||||
still_path: string | null;
|
||||
season_poster_path: string | null;
|
||||
}
|
||||
|
||||
interface CalendarSection {
|
||||
title: string;
|
||||
data: CalendarEpisode[];
|
||||
}
|
||||
id: string;
|
||||
seriesId: string;
|
||||
title: string;
|
||||
seriesName: string;
|
||||
poster: string;
|
||||
releaseDate: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
overview: string;
|
||||
vote_average: number;
|
||||
still_path: string | null;
|
||||
season_poster_path: string | null;
|
||||
}
|
||||
|
||||
interface CalendarSection {
|
||||
title: string;
|
||||
data: CalendarEpisode[];
|
||||
}
|
||||
|
||||
interface UseCalendarDataReturn {
|
||||
calendarData: CalendarSection[];
|
||||
|
|
@ -36,399 +36,416 @@ interface UseCalendarDataReturn {
|
|||
}
|
||||
|
||||
export const useCalendarData = (): UseCalendarDataReturn => {
|
||||
const [calendarData, setCalendarData] = useState<CalendarSection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [calendarData, setCalendarData] = useState<CalendarSection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { libraryItems, loading: libraryLoading } = useLibrary();
|
||||
const {
|
||||
isAuthenticated: traktAuthenticated,
|
||||
isLoading: traktLoading,
|
||||
watchedShows,
|
||||
watchlistShows,
|
||||
continueWatching,
|
||||
loadAllCollections,
|
||||
} = useTraktContext();
|
||||
const { libraryItems, loading: libraryLoading } = useLibrary();
|
||||
const {
|
||||
isAuthenticated: traktAuthenticated,
|
||||
isLoading: traktLoading,
|
||||
watchedShows,
|
||||
watchlistShows,
|
||||
continueWatching,
|
||||
loadAllCollections,
|
||||
} = useTraktContext();
|
||||
|
||||
const fetchCalendarData = useCallback(async (forceRefresh = false) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Check memory pressure and cleanup if needed
|
||||
memoryManager.checkMemoryPressure();
|
||||
const fetchCalendarData = useCallback(async (forceRefresh = false) => {
|
||||
setLoading(true);
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cachedData = await robustCalendarCache.getCachedCalendarData(
|
||||
libraryItems,
|
||||
{
|
||||
watchlist: watchlistShows,
|
||||
continueWatching: continueWatching,
|
||||
watched: watchedShows,
|
||||
}
|
||||
);
|
||||
try {
|
||||
// Check memory pressure and cleanup if needed
|
||||
memoryManager.checkMemoryPressure();
|
||||
|
||||
if (cachedData) {
|
||||
setCalendarData(cachedData);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!forceRefresh) {
|
||||
const cachedData = await robustCalendarCache.getCachedCalendarData(
|
||||
libraryItems,
|
||||
{
|
||||
watchlist: watchlistShows,
|
||||
continueWatching: continueWatching,
|
||||
watched: watchedShows,
|
||||
}
|
||||
|
||||
|
||||
const librarySeries = libraryItems.filter(item => item.type === 'series');
|
||||
let allSeries: StreamingContent[] = [...librarySeries];
|
||||
|
||||
if (traktAuthenticated) {
|
||||
const traktSeriesIds = new Set();
|
||||
|
||||
if (watchlistShows) {
|
||||
for (const item of watchlistShows) {
|
||||
if (item.show && item.show.ids.imdb) {
|
||||
const imdbId = item.show.ids.imdb;
|
||||
if (!librarySeries.some(s => s.id === imdbId)) {
|
||||
traktSeriesIds.add(imdbId);
|
||||
allSeries.push({
|
||||
id: imdbId,
|
||||
name: item.show.title,
|
||||
type: 'series',
|
||||
poster: '',
|
||||
year: item.show.year,
|
||||
traktSource: 'watchlist'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (continueWatching) {
|
||||
for (const item of continueWatching) {
|
||||
if (item.type === 'episode' && item.show && item.show.ids.imdb) {
|
||||
const imdbId = item.show.ids.imdb;
|
||||
if (!librarySeries.some(s => s.id === imdbId) && !traktSeriesIds.has(imdbId)) {
|
||||
traktSeriesIds.add(imdbId);
|
||||
allSeries.push({
|
||||
id: imdbId,
|
||||
name: item.show.title,
|
||||
type: 'series',
|
||||
poster: '',
|
||||
year: item.show.year,
|
||||
traktSource: 'continue-watching'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (watchedShows) {
|
||||
const recentWatched = watchedShows.slice(0, 20);
|
||||
for (const item of recentWatched) {
|
||||
if (item.show && item.show.ids.imdb) {
|
||||
const imdbId = item.show.ids.imdb;
|
||||
if (!librarySeries.some(s => s.id === imdbId) && !traktSeriesIds.has(imdbId)) {
|
||||
traktSeriesIds.add(imdbId);
|
||||
allSeries.push({
|
||||
id: imdbId,
|
||||
name: item.show.title,
|
||||
type: 'series',
|
||||
poster: '',
|
||||
year: item.show.year,
|
||||
traktSource: 'watched'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the number of series to prevent memory overflow
|
||||
const maxSeries = 100; // Reasonable limit to prevent OOM
|
||||
if (allSeries.length > maxSeries) {
|
||||
logger.warn(`[CalendarData] Too many series (${allSeries.length}), limiting to ${maxSeries} to prevent memory issues`);
|
||||
allSeries = allSeries.slice(0, maxSeries);
|
||||
}
|
||||
|
||||
logger.log(`[CalendarData] Total series to check: ${allSeries.length} (Library: ${librarySeries.length}, Trakt: ${allSeries.length - librarySeries.length})`);
|
||||
|
||||
let allEpisodes: CalendarEpisode[] = [];
|
||||
let seriesWithoutEpisodes: CalendarEpisode[] = [];
|
||||
|
||||
// Process series in memory-efficient batches to prevent OOM
|
||||
const processedSeries = await memoryManager.processArrayInBatches(
|
||||
allSeries,
|
||||
async (series: StreamingContent, index: number) => {
|
||||
try {
|
||||
// Use the new memory-efficient method to fetch upcoming and recent episodes
|
||||
const episodeData = await stremioService.getUpcomingEpisodes(series.type, series.id, {
|
||||
daysBack: 90, // 3 months back for recently released episodes
|
||||
daysAhead: 60, // 2 months ahead for upcoming episodes
|
||||
maxEpisodes: 50, // Increased limit to get more episodes per series
|
||||
});
|
||||
|
||||
if (episodeData && episodeData.episodes.length > 0) {
|
||||
const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id);
|
||||
let tmdbEpisodes: { [key: string]: any } = {};
|
||||
|
||||
// Only fetch TMDB data if we need it and limit it
|
||||
if (tmdbId && episodeData.episodes.length > 0) {
|
||||
try {
|
||||
// Get only current and next season to limit memory usage
|
||||
const seasons = [...new Set(episodeData.episodes.map(ep => ep.season || 1))];
|
||||
const limitedSeasons = seasons.slice(0, 3); // Limit to 3 seasons max
|
||||
|
||||
for (const seasonNum of limitedSeasons) {
|
||||
const seasonEpisodes = await tmdbService.getSeasonDetails(tmdbId, seasonNum);
|
||||
if (seasonEpisodes?.episodes) {
|
||||
seasonEpisodes.episodes.forEach((episode: any) => {
|
||||
const key = `${episode.season_number}:${episode.episode_number}`;
|
||||
tmdbEpisodes[key] = episode;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (tmdbError) {
|
||||
logger.warn(`[CalendarData] TMDB fetch failed for ${series.name}, continuing without additional metadata`);
|
||||
}
|
||||
}
|
||||
|
||||
// Transform episodes with memory-efficient processing
|
||||
const transformedEpisodes = episodeData.episodes.map(video => {
|
||||
const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {};
|
||||
const episode = {
|
||||
id: video.id,
|
||||
seriesId: series.id,
|
||||
title: tmdbEpisode.name || video.title || `Episode ${video.episode}`,
|
||||
seriesName: series.name || episodeData.seriesName,
|
||||
poster: series.poster || episodeData.poster || '',
|
||||
releaseDate: video.released,
|
||||
season: video.season || 0,
|
||||
episode: video.episode || 0,
|
||||
overview: tmdbEpisode.overview || '',
|
||||
vote_average: tmdbEpisode.vote_average || 0,
|
||||
still_path: tmdbEpisode.still_path || null,
|
||||
season_poster_path: tmdbEpisode.season_poster_path || null
|
||||
};
|
||||
);
|
||||
|
||||
|
||||
return episode;
|
||||
});
|
||||
|
||||
// Clear references to help garbage collection
|
||||
memoryManager.clearObjects(tmdbEpisodes);
|
||||
|
||||
return { type: 'episodes', data: transformedEpisodes };
|
||||
} else {
|
||||
return {
|
||||
type: 'no-episodes',
|
||||
data: {
|
||||
id: series.id,
|
||||
seriesId: series.id,
|
||||
title: 'No upcoming episodes',
|
||||
seriesName: series.name || episodeData?.seriesName || '',
|
||||
poster: series.poster || episodeData?.poster || '',
|
||||
releaseDate: '',
|
||||
season: 0,
|
||||
episode: 0,
|
||||
overview: '',
|
||||
vote_average: 0,
|
||||
still_path: null,
|
||||
season_poster_path: null
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[CalendarData] Error fetching episodes for ${series.name}:`, error);
|
||||
return {
|
||||
type: 'no-episodes',
|
||||
data: {
|
||||
id: series.id,
|
||||
seriesId: series.id,
|
||||
title: 'No upcoming episodes',
|
||||
seriesName: series.name || '',
|
||||
poster: series.poster || '',
|
||||
releaseDate: '',
|
||||
season: 0,
|
||||
episode: 0,
|
||||
overview: '',
|
||||
vote_average: 0,
|
||||
still_path: null,
|
||||
season_poster_path: null
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
5, // Small batch size to prevent memory spikes
|
||||
100 // Small delay between batches
|
||||
);
|
||||
|
||||
// Process results and separate episodes from no-episode series
|
||||
for (const result of processedSeries) {
|
||||
if (!result) {
|
||||
logger.error(`[CalendarData] Null/undefined result in processedSeries`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.type === 'episodes' && Array.isArray(result.data)) {
|
||||
allEpisodes.push(...result.data);
|
||||
} else if (result.type === 'no-episodes' && result.data) {
|
||||
seriesWithoutEpisodes.push(result.data as CalendarEpisode);
|
||||
} else {
|
||||
logger.warn(`[CalendarData] Unexpected result type or missing data:`, result);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear processed series to free memory
|
||||
memoryManager.clearObjects(processedSeries);
|
||||
|
||||
// Limit total episodes to prevent memory overflow
|
||||
allEpisodes = memoryManager.limitArraySize(allEpisodes, 500);
|
||||
seriesWithoutEpisodes = memoryManager.limitArraySize(seriesWithoutEpisodes, 100);
|
||||
|
||||
// Sort episodes by release date with error handling
|
||||
allEpisodes.sort((a, b) => {
|
||||
try {
|
||||
const dateA = new Date(a.releaseDate).getTime();
|
||||
const dateB = new Date(b.releaseDate).getTime();
|
||||
return dateA - dateB;
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error sorting episodes: ${a.releaseDate} vs ${b.releaseDate}`, error);
|
||||
return 0; // Keep original order if sorting fails
|
||||
}
|
||||
});
|
||||
|
||||
logger.log(`[CalendarData] Total episodes fetched: ${allEpisodes.length}`);
|
||||
|
||||
// Use memory-efficient filtering with error handling
|
||||
const thisWeekEpisodes = await memoryManager.filterLargeArray(
|
||||
allEpisodes,
|
||||
ep => {
|
||||
try {
|
||||
if (!ep.releaseDate) return false;
|
||||
const parsed = parseISO(ep.releaseDate);
|
||||
return isThisWeek(parsed) && isAfter(parsed, new Date());
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error parsing date for this week filtering: ${ep.releaseDate}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const upcomingEpisodes = await memoryManager.filterLargeArray(
|
||||
allEpisodes,
|
||||
ep => {
|
||||
try {
|
||||
if (!ep.releaseDate) return false;
|
||||
const parsed = parseISO(ep.releaseDate);
|
||||
return isAfter(parsed, new Date()) && !isThisWeek(parsed);
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error parsing date for upcoming filtering: ${ep.releaseDate}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const recentEpisodes = await memoryManager.filterLargeArray(
|
||||
allEpisodes,
|
||||
ep => {
|
||||
try {
|
||||
if (!ep.releaseDate) return false;
|
||||
const parsed = parseISO(ep.releaseDate);
|
||||
return isBefore(parsed, new Date());
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error parsing date for recent filtering: ${ep.releaseDate}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.log(`[CalendarData] Episode categorization: This Week: ${thisWeekEpisodes.length}, Upcoming: ${upcomingEpisodes.length}, Recently Released: ${recentEpisodes.length}, Series without episodes: ${seriesWithoutEpisodes.length}`);
|
||||
|
||||
// Debug: Show some example episodes from each category
|
||||
if (thisWeekEpisodes && thisWeekEpisodes.length > 0) {
|
||||
logger.log(`[CalendarData] This Week examples:`, thisWeekEpisodes.slice(0, 3).map(ep => ({
|
||||
title: ep.title,
|
||||
date: ep.releaseDate,
|
||||
series: ep.seriesName
|
||||
})));
|
||||
}
|
||||
if (recentEpisodes && recentEpisodes.length > 0) {
|
||||
logger.log(`[CalendarData] Recently Released examples:`, recentEpisodes.slice(0, 3).map(ep => ({
|
||||
title: ep.title,
|
||||
date: ep.releaseDate,
|
||||
series: ep.seriesName
|
||||
})));
|
||||
}
|
||||
|
||||
const sections: CalendarSection[] = [];
|
||||
if (thisWeekEpisodes.length > 0) {
|
||||
sections.push({ title: 'This Week', data: thisWeekEpisodes });
|
||||
logger.log(`[CalendarData] Added 'This Week' section with ${thisWeekEpisodes.length} episodes`);
|
||||
}
|
||||
if (upcomingEpisodes.length > 0) {
|
||||
sections.push({ title: 'Upcoming', data: upcomingEpisodes });
|
||||
logger.log(`[CalendarData] Added 'Upcoming' section with ${upcomingEpisodes.length} episodes`);
|
||||
}
|
||||
if (recentEpisodes.length > 0) {
|
||||
sections.push({ title: 'Recently Released', data: recentEpisodes });
|
||||
logger.log(`[CalendarData] Added 'Recently Released' section with ${recentEpisodes.length} episodes`);
|
||||
}
|
||||
if (seriesWithoutEpisodes.length > 0) {
|
||||
sections.push({ title: 'Series with No Scheduled Episodes', data: seriesWithoutEpisodes });
|
||||
logger.log(`[CalendarData] Added 'Series with No Scheduled Episodes' section with ${seriesWithoutEpisodes.length} episodes`);
|
||||
}
|
||||
|
||||
// Log section details before setting
|
||||
logger.log(`[CalendarData] About to set calendarData with ${sections.length} sections:`);
|
||||
sections.forEach((section, index) => {
|
||||
logger.log(` Section ${index}: "${section.title}" with ${section.data?.length || 0} episodes`);
|
||||
});
|
||||
|
||||
setCalendarData(sections);
|
||||
|
||||
// Clear large arrays to help garbage collection
|
||||
// Note: Don't clear the arrays that are referenced in sections (thisWeekEpisodes, upcomingEpisodes, recentEpisodes, seriesWithoutEpisodes)
|
||||
// as they would empty the section data
|
||||
memoryManager.clearObjects(allEpisodes);
|
||||
|
||||
await robustCalendarCache.setCachedCalendarData(
|
||||
sections,
|
||||
libraryItems,
|
||||
{ watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[CalendarData] Error fetching calendar data:', error);
|
||||
await robustCalendarCache.setCachedCalendarData(
|
||||
[],
|
||||
libraryItems,
|
||||
{ watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows },
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
// Force garbage collection after processing
|
||||
memoryManager.forceGarbageCollection();
|
||||
if (cachedData) {
|
||||
setCalendarData(cachedData);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}, [libraryItems, traktAuthenticated, watchlistShows, continueWatching, watchedShows]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryLoading && !traktLoading) {
|
||||
if (traktAuthenticated && (!watchlistShows || !continueWatching || !watchedShows)) {
|
||||
loadAllCollections();
|
||||
} else {
|
||||
fetchCalendarData();
|
||||
const librarySeries = libraryItems.filter(item => item.type === 'series');
|
||||
|
||||
// Prioritize series sources: Continue Watching > Watchlist > Library > Watched
|
||||
// This ensures that shows the user is actively watching or interested in are checked first
|
||||
// before hitting the series limit.
|
||||
let allSeries: StreamingContent[] = [];
|
||||
const addedIds = new Set<string>();
|
||||
|
||||
// Helper to add series if not already added
|
||||
const addSeries = (id: string, name: string, year: number, poster: string, source: 'watchlist' | 'continue-watching' | 'watched' | 'library') => {
|
||||
if (!addedIds.has(id)) {
|
||||
addedIds.add(id);
|
||||
allSeries.push({
|
||||
id,
|
||||
name,
|
||||
type: 'series',
|
||||
poster,
|
||||
year,
|
||||
traktSource: source as any // Cast to any to avoid strict type issues with 'library' which might not be in the interface
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (traktAuthenticated) {
|
||||
// 1. Continue Watching (Highest Priority)
|
||||
if (continueWatching) {
|
||||
for (const item of continueWatching) {
|
||||
if (item.type === 'episode' && item.show && item.show.ids.imdb) {
|
||||
addSeries(
|
||||
item.show.ids.imdb,
|
||||
item.show.title,
|
||||
item.show.year,
|
||||
'', // Poster will be fetched if missing
|
||||
'continue-watching'
|
||||
);
|
||||
}
|
||||
} else if (!libraryLoading && !traktAuthenticated) {
|
||||
fetchCalendarData();
|
||||
}
|
||||
}
|
||||
}, [libraryItems, libraryLoading, traktLoading, traktAuthenticated, watchlistShows, continueWatching, watchedShows, fetchCalendarData, loadAllCollections]);
|
||||
|
||||
const refresh = useCallback((force = false) => {
|
||||
fetchCalendarData(force);
|
||||
}, [fetchCalendarData]);
|
||||
// 2. Watchlist
|
||||
if (watchlistShows) {
|
||||
for (const item of watchlistShows) {
|
||||
if (item.show && item.show.ids.imdb) {
|
||||
addSeries(
|
||||
item.show.ids.imdb,
|
||||
item.show.title,
|
||||
item.show.year,
|
||||
'',
|
||||
'watchlist'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. Library
|
||||
for (const item of librarySeries) {
|
||||
addSeries(
|
||||
item.id,
|
||||
item.name,
|
||||
item.year || 0,
|
||||
item.poster,
|
||||
'library'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
calendarData,
|
||||
loading,
|
||||
refresh,
|
||||
};
|
||||
// 4. Watched (Lowest Priority)
|
||||
if (traktAuthenticated && watchedShows) {
|
||||
const recentWatched = watchedShows.slice(0, 20);
|
||||
for (const item of recentWatched) {
|
||||
if (item.show && item.show.ids.imdb) {
|
||||
addSeries(
|
||||
item.show.ids.imdb,
|
||||
item.show.title,
|
||||
item.show.year,
|
||||
'',
|
||||
'watched'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the number of series to prevent memory overflow
|
||||
const maxSeries = 300; // Increased from 100 to 300 to accommodate larger libraries
|
||||
if (allSeries.length > maxSeries) {
|
||||
logger.warn(`[CalendarData] Too many series (${allSeries.length}), limiting to ${maxSeries} to prevent memory issues`);
|
||||
allSeries = allSeries.slice(0, maxSeries);
|
||||
}
|
||||
|
||||
logger.log(`[CalendarData] Total series to check: ${allSeries.length}`);
|
||||
|
||||
let allEpisodes: CalendarEpisode[] = [];
|
||||
let seriesWithoutEpisodes: CalendarEpisode[] = [];
|
||||
|
||||
// Process series in memory-efficient batches to prevent OOM
|
||||
const processedSeries = await memoryManager.processArrayInBatches(
|
||||
allSeries,
|
||||
async (series: StreamingContent, index: number) => {
|
||||
try {
|
||||
// Use the new memory-efficient method to fetch upcoming and recent episodes
|
||||
const episodeData = await stremioService.getUpcomingEpisodes(series.type, series.id, {
|
||||
daysBack: 90, // 3 months back for recently released episodes
|
||||
daysAhead: 60, // 2 months ahead for upcoming episodes
|
||||
maxEpisodes: 50, // Increased limit to get more episodes per series
|
||||
});
|
||||
|
||||
if (episodeData && episodeData.episodes.length > 0) {
|
||||
const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id);
|
||||
let tmdbEpisodes: { [key: string]: any } = {};
|
||||
|
||||
// Only fetch TMDB data if we need it and limit it
|
||||
if (tmdbId && episodeData.episodes.length > 0) {
|
||||
try {
|
||||
// Get only current and next season to limit memory usage
|
||||
const seasons = [...new Set(episodeData.episodes.map(ep => ep.season || 1))];
|
||||
const limitedSeasons = seasons.slice(0, 3); // Limit to 3 seasons max
|
||||
|
||||
for (const seasonNum of limitedSeasons) {
|
||||
const seasonEpisodes = await tmdbService.getSeasonDetails(tmdbId, seasonNum);
|
||||
if (seasonEpisodes?.episodes) {
|
||||
seasonEpisodes.episodes.forEach((episode: any) => {
|
||||
const key = `${episode.season_number}:${episode.episode_number}`;
|
||||
tmdbEpisodes[key] = episode;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (tmdbError) {
|
||||
logger.warn(`[CalendarData] TMDB fetch failed for ${series.name}, continuing without additional metadata`);
|
||||
}
|
||||
}
|
||||
|
||||
// Transform episodes with memory-efficient processing
|
||||
const transformedEpisodes = episodeData.episodes.map(video => {
|
||||
const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {};
|
||||
const episode = {
|
||||
id: video.id,
|
||||
seriesId: series.id,
|
||||
title: tmdbEpisode.name || video.title || `Episode ${video.episode}`,
|
||||
seriesName: series.name || episodeData.seriesName,
|
||||
poster: series.poster || episodeData.poster || '',
|
||||
releaseDate: video.released,
|
||||
season: video.season || 0,
|
||||
episode: video.episode || 0,
|
||||
overview: tmdbEpisode.overview || '',
|
||||
vote_average: tmdbEpisode.vote_average || 0,
|
||||
still_path: tmdbEpisode.still_path || null,
|
||||
season_poster_path: tmdbEpisode.season_poster_path || null
|
||||
};
|
||||
|
||||
|
||||
return episode;
|
||||
});
|
||||
|
||||
// Clear references to help garbage collection
|
||||
memoryManager.clearObjects(tmdbEpisodes);
|
||||
|
||||
return { type: 'episodes', data: transformedEpisodes };
|
||||
} else {
|
||||
return {
|
||||
type: 'no-episodes',
|
||||
data: {
|
||||
id: series.id,
|
||||
seriesId: series.id,
|
||||
title: 'No upcoming episodes',
|
||||
seriesName: series.name || episodeData?.seriesName || '',
|
||||
poster: series.poster || episodeData?.poster || '',
|
||||
releaseDate: '',
|
||||
season: 0,
|
||||
episode: 0,
|
||||
overview: '',
|
||||
vote_average: 0,
|
||||
still_path: null,
|
||||
season_poster_path: null
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[CalendarData] Error fetching episodes for ${series.name}:`, error);
|
||||
return {
|
||||
type: 'no-episodes',
|
||||
data: {
|
||||
id: series.id,
|
||||
seriesId: series.id,
|
||||
title: 'No upcoming episodes',
|
||||
seriesName: series.name || '',
|
||||
poster: series.poster || '',
|
||||
releaseDate: '',
|
||||
season: 0,
|
||||
episode: 0,
|
||||
overview: '',
|
||||
vote_average: 0,
|
||||
still_path: null,
|
||||
season_poster_path: null
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
5, // Small batch size to prevent memory spikes
|
||||
100 // Small delay between batches
|
||||
);
|
||||
|
||||
// Process results and separate episodes from no-episode series
|
||||
for (const result of processedSeries) {
|
||||
if (!result) {
|
||||
logger.error(`[CalendarData] Null/undefined result in processedSeries`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.type === 'episodes' && Array.isArray(result.data)) {
|
||||
allEpisodes.push(...result.data);
|
||||
} else if (result.type === 'no-episodes' && result.data) {
|
||||
seriesWithoutEpisodes.push(result.data as CalendarEpisode);
|
||||
} else {
|
||||
logger.warn(`[CalendarData] Unexpected result type or missing data:`, result);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear processed series to free memory
|
||||
memoryManager.clearObjects(processedSeries);
|
||||
|
||||
// Limit total episodes to prevent memory overflow
|
||||
allEpisodes = memoryManager.limitArraySize(allEpisodes, 500);
|
||||
seriesWithoutEpisodes = memoryManager.limitArraySize(seriesWithoutEpisodes, 100);
|
||||
|
||||
// Sort episodes by release date with error handling
|
||||
allEpisodes.sort((a, b) => {
|
||||
try {
|
||||
const dateA = new Date(a.releaseDate).getTime();
|
||||
const dateB = new Date(b.releaseDate).getTime();
|
||||
return dateA - dateB;
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error sorting episodes: ${a.releaseDate} vs ${b.releaseDate}`, error);
|
||||
return 0; // Keep original order if sorting fails
|
||||
}
|
||||
});
|
||||
|
||||
logger.log(`[CalendarData] Total episodes fetched: ${allEpisodes.length}`);
|
||||
|
||||
// Use memory-efficient filtering with error handling
|
||||
const thisWeekEpisodes = await memoryManager.filterLargeArray(
|
||||
allEpisodes,
|
||||
ep => {
|
||||
try {
|
||||
if (!ep.releaseDate) return false;
|
||||
const parsed = parseISO(ep.releaseDate);
|
||||
// Show all episodes for this week, including released ones
|
||||
return isThisWeek(parsed);
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error parsing date for this week filtering: ${ep.releaseDate}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const upcomingEpisodes = await memoryManager.filterLargeArray(
|
||||
allEpisodes,
|
||||
ep => {
|
||||
try {
|
||||
if (!ep.releaseDate) return false;
|
||||
const parsed = parseISO(ep.releaseDate);
|
||||
// Show upcoming episodes that are NOT this week
|
||||
return isAfter(parsed, new Date()) && !isThisWeek(parsed);
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error parsing date for upcoming filtering: ${ep.releaseDate}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const recentEpisodes = await memoryManager.filterLargeArray(
|
||||
allEpisodes,
|
||||
ep => {
|
||||
try {
|
||||
if (!ep.releaseDate) return false;
|
||||
const parsed = parseISO(ep.releaseDate);
|
||||
// Show past episodes that are NOT this week
|
||||
return isBefore(parsed, new Date()) && !isThisWeek(parsed);
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error parsing date for recent filtering: ${ep.releaseDate}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.log(`[CalendarData] Episode categorization: This Week: ${thisWeekEpisodes.length}, Upcoming: ${upcomingEpisodes.length}, Recently Released: ${recentEpisodes.length}, Series without episodes: ${seriesWithoutEpisodes.length}`);
|
||||
|
||||
// Debug: Show some example episodes from each category
|
||||
if (thisWeekEpisodes && thisWeekEpisodes.length > 0) {
|
||||
logger.log(`[CalendarData] This Week examples:`, thisWeekEpisodes.slice(0, 3).map(ep => ({
|
||||
title: ep.title,
|
||||
date: ep.releaseDate,
|
||||
series: ep.seriesName
|
||||
})));
|
||||
}
|
||||
if (recentEpisodes && recentEpisodes.length > 0) {
|
||||
logger.log(`[CalendarData] Recently Released examples:`, recentEpisodes.slice(0, 3).map(ep => ({
|
||||
title: ep.title,
|
||||
date: ep.releaseDate,
|
||||
series: ep.seriesName
|
||||
})));
|
||||
}
|
||||
|
||||
const sections: CalendarSection[] = [];
|
||||
if (thisWeekEpisodes.length > 0) {
|
||||
sections.push({ title: 'This Week', data: thisWeekEpisodes });
|
||||
logger.log(`[CalendarData] Added 'This Week' section with ${thisWeekEpisodes.length} episodes`);
|
||||
}
|
||||
if (upcomingEpisodes.length > 0) {
|
||||
sections.push({ title: 'Upcoming', data: upcomingEpisodes });
|
||||
logger.log(`[CalendarData] Added 'Upcoming' section with ${upcomingEpisodes.length} episodes`);
|
||||
}
|
||||
if (recentEpisodes.length > 0) {
|
||||
sections.push({ title: 'Recently Released', data: recentEpisodes });
|
||||
logger.log(`[CalendarData] Added 'Recently Released' section with ${recentEpisodes.length} episodes`);
|
||||
}
|
||||
if (seriesWithoutEpisodes.length > 0) {
|
||||
sections.push({ title: 'Series with No Scheduled Episodes', data: seriesWithoutEpisodes });
|
||||
logger.log(`[CalendarData] Added 'Series with No Scheduled Episodes' section with ${seriesWithoutEpisodes.length} episodes`);
|
||||
}
|
||||
|
||||
// Log section details before setting
|
||||
logger.log(`[CalendarData] About to set calendarData with ${sections.length} sections:`);
|
||||
sections.forEach((section, index) => {
|
||||
logger.log(` Section ${index}: "${section.title}" with ${section.data?.length || 0} episodes`);
|
||||
});
|
||||
|
||||
setCalendarData(sections);
|
||||
|
||||
// Clear large arrays to help garbage collection
|
||||
// Note: Don't clear the arrays that are referenced in sections (thisWeekEpisodes, upcomingEpisodes, recentEpisodes, seriesWithoutEpisodes)
|
||||
// as they would empty the section data
|
||||
memoryManager.clearObjects(allEpisodes);
|
||||
|
||||
await robustCalendarCache.setCachedCalendarData(
|
||||
sections,
|
||||
libraryItems,
|
||||
{ watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[CalendarData] Error fetching calendar data:', error);
|
||||
await robustCalendarCache.setCachedCalendarData(
|
||||
[],
|
||||
libraryItems,
|
||||
{ watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows },
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
// Force garbage collection after processing
|
||||
memoryManager.forceGarbageCollection();
|
||||
setLoading(false);
|
||||
}
|
||||
}, [libraryItems, traktAuthenticated, watchlistShows, continueWatching, watchedShows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryLoading && !traktLoading) {
|
||||
if (traktAuthenticated && (!watchlistShows || !continueWatching || !watchedShows)) {
|
||||
loadAllCollections();
|
||||
} else {
|
||||
fetchCalendarData();
|
||||
}
|
||||
} else if (!libraryLoading && !traktAuthenticated) {
|
||||
fetchCalendarData();
|
||||
}
|
||||
}, [libraryItems, libraryLoading, traktLoading, traktAuthenticated, watchlistShows, continueWatching, watchedShows, fetchCalendarData, loadAllCollections]);
|
||||
|
||||
const refresh = useCallback((force = false) => {
|
||||
fetchCalendarData(force);
|
||||
}, [fetchCalendarData]);
|
||||
|
||||
return {
|
||||
calendarData,
|
||||
loading,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -2168,7 +2168,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
useEffect(() => {
|
||||
const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
|
||||
const isInLib = libraryItems.some(item => item.id === id);
|
||||
setInLibrary(isInLib);
|
||||
// Only update state if the value actually changed to prevent unnecessary re-renders
|
||||
setInLibrary(prev => prev !== isInLib ? isInLib : prev);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import BackdropGalleryScreen from '../screens/BackdropGalleryScreen';
|
|||
import BackupScreen from '../screens/BackupScreen';
|
||||
import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen';
|
||||
import ContributorsScreen from '../screens/ContributorsScreen';
|
||||
import DebridIntegrationScreen from '../screens/DebridIntegrationScreen';
|
||||
|
||||
// Stack navigator types
|
||||
export type RootStackParamList = {
|
||||
|
|
@ -81,27 +82,27 @@ export type RootStackParamList = {
|
|||
Update: undefined;
|
||||
Search: undefined;
|
||||
Calendar: undefined;
|
||||
Metadata: {
|
||||
id: string;
|
||||
Metadata: {
|
||||
id: string;
|
||||
type: string;
|
||||
episodeId?: string;
|
||||
addonId?: string;
|
||||
};
|
||||
Streams: {
|
||||
id: string;
|
||||
Streams: {
|
||||
id: string;
|
||||
type: string;
|
||||
episodeId?: string;
|
||||
episodeThumbnail?: string;
|
||||
fromPlayer?: boolean;
|
||||
};
|
||||
PlayerIOS: {
|
||||
uri: string;
|
||||
title?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
episodeTitle?: string;
|
||||
quality?: string;
|
||||
year?: number;
|
||||
PlayerIOS: {
|
||||
uri: string;
|
||||
title?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
episodeTitle?: string;
|
||||
quality?: string;
|
||||
year?: number;
|
||||
streamProvider?: string;
|
||||
streamName?: string;
|
||||
headers?: { [key: string]: string };
|
||||
|
|
@ -115,14 +116,14 @@ export type RootStackParamList = {
|
|||
videoType?: string;
|
||||
groupedEpisodes?: { [seasonNumber: number]: any[] };
|
||||
};
|
||||
PlayerAndroid: {
|
||||
uri: string;
|
||||
title?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
episodeTitle?: string;
|
||||
quality?: string;
|
||||
year?: number;
|
||||
PlayerAndroid: {
|
||||
uri: string;
|
||||
title?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
episodeTitle?: string;
|
||||
quality?: string;
|
||||
year?: number;
|
||||
streamProvider?: string;
|
||||
streamName?: string;
|
||||
headers?: { [key: string]: string };
|
||||
|
|
@ -179,6 +180,7 @@ export type RootStackParamList = {
|
|||
};
|
||||
ContinueWatchingSettings: undefined;
|
||||
Contributors: undefined;
|
||||
DebridIntegration: undefined;
|
||||
};
|
||||
|
||||
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
|
@ -375,9 +377,9 @@ export const CustomNavigationDarkTheme: Theme = {
|
|||
type IconNameType = string;
|
||||
|
||||
// Add TabIcon component
|
||||
const TabIcon = React.memo(({ focused, color, iconName, iconLibrary = 'material' }: {
|
||||
focused: boolean;
|
||||
color: string;
|
||||
const TabIcon = React.memo(({ focused, color, iconName, iconLibrary = 'material' }: {
|
||||
focused: boolean;
|
||||
color: string;
|
||||
iconName: IconNameType;
|
||||
iconLibrary?: 'material' | 'feather' | 'ionicons';
|
||||
}) => {
|
||||
|
|
@ -402,28 +404,28 @@ const TabIcon = React.memo(({ focused, color, iconName, iconLibrary = 'material'
|
|||
})();
|
||||
|
||||
return (
|
||||
<Animated.View style={{
|
||||
alignItems: 'center',
|
||||
<Animated.View style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transform: [{ scale: scaleAnim }]
|
||||
}}>
|
||||
{iconLibrary === 'feather' ? (
|
||||
<Feather
|
||||
<Feather
|
||||
name={finalIconName as any}
|
||||
size={24}
|
||||
color={color}
|
||||
size={24}
|
||||
color={color}
|
||||
/>
|
||||
) : iconLibrary === 'ionicons' ? (
|
||||
<Ionicons
|
||||
<Ionicons
|
||||
name={finalIconName as any}
|
||||
size={24}
|
||||
color={color}
|
||||
/>
|
||||
) : (
|
||||
<MaterialCommunityIcons
|
||||
<MaterialCommunityIcons
|
||||
name={finalIconName as any}
|
||||
size={24}
|
||||
color={color}
|
||||
size={24}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
|
@ -431,17 +433,17 @@ const TabIcon = React.memo(({ focused, color, iconName, iconLibrary = 'material'
|
|||
});
|
||||
|
||||
// Update the TabScreenWrapper component with fixed layout dimensions
|
||||
const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) => {
|
||||
const TabScreenWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [dimensions, setDimensions] = useState(Dimensions.get('window'));
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = Dimensions.addEventListener('change', ({ window }) => {
|
||||
setDimensions(window);
|
||||
});
|
||||
|
||||
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
|
||||
|
||||
const isTablet = useMemo(() => {
|
||||
const { width, height } = dimensions;
|
||||
const smallestDimension = Math.min(width, height);
|
||||
|
|
@ -455,35 +457,35 @@ const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) =
|
|||
StatusBar.setTranslucent(true);
|
||||
StatusBar.setBackgroundColor('transparent');
|
||||
};
|
||||
|
||||
|
||||
applyStatusBarConfig();
|
||||
|
||||
|
||||
// Apply status bar config on every focus
|
||||
const subscription = Platform.OS === 'android'
|
||||
const subscription = Platform.OS === 'android'
|
||||
? AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
applyStatusBarConfig();
|
||||
}
|
||||
})
|
||||
: { remove: () => {} };
|
||||
|
||||
if (state === 'active') {
|
||||
applyStatusBarConfig();
|
||||
}
|
||||
})
|
||||
: { remove: () => { } };
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{
|
||||
flex: 1,
|
||||
<View style={{
|
||||
flex: 1,
|
||||
backgroundColor: colors.darkBackground,
|
||||
// Lock the layout to prevent shifts
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Reserve consistent space for the header area on all screens */}
|
||||
<View style={{
|
||||
height: isTablet ? (insets.top + 64) : (Platform.OS === 'android' ? 80 : 60),
|
||||
width: '100%',
|
||||
<View style={{
|
||||
height: isTablet ? (insets.top + 64) : (Platform.OS === 'android' ? 80 : 60),
|
||||
width: '100%',
|
||||
backgroundColor: colors.darkBackground,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
|
@ -497,7 +499,7 @@ const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) =
|
|||
};
|
||||
|
||||
// Add this component to wrap each screen in the tab navigator
|
||||
const WrappedScreen: React.FC<{Screen: React.ComponentType<any>}> = ({ Screen }) => {
|
||||
const WrappedScreen: React.FC<{ Screen: React.ComponentType<any> }> = ({ Screen }) => {
|
||||
return (
|
||||
<TabScreenWrapper>
|
||||
<Screen />
|
||||
|
|
@ -513,12 +515,12 @@ const MainTabs = () => {
|
|||
const { settings: appSettings } = useSettingsHook();
|
||||
const [hasUpdateBadge, setHasUpdateBadge] = React.useState(false);
|
||||
const [dimensions, setDimensions] = useState(Dimensions.get('window'));
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = Dimensions.addEventListener('change', ({ window }) => {
|
||||
setDimensions(window);
|
||||
});
|
||||
|
||||
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
|
|
@ -528,7 +530,7 @@ const MainTabs = () => {
|
|||
try {
|
||||
const flag = await mmkvStorage.getItem('@update_badge_pending');
|
||||
if (mounted) setHasUpdateBadge(flag === 'true');
|
||||
} catch {}
|
||||
} catch { }
|
||||
};
|
||||
load();
|
||||
// Fast poll initially for quick badge appearance, then slow down
|
||||
|
|
@ -574,7 +576,7 @@ const MainTabs = () => {
|
|||
}, [hidden, headerAnim]);
|
||||
const translateY = headerAnim.interpolate({ inputRange: [0, 1], outputRange: [0, -70] });
|
||||
const fade = headerAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0] });
|
||||
|
||||
|
||||
const renderTabBar = (props: BottomTabBarProps) => {
|
||||
// Hide tab bar when home is loading
|
||||
if (isHomeLoading) {
|
||||
|
|
@ -589,18 +591,18 @@ const MainTabs = () => {
|
|||
// Top floating, text-only pill nav for tablets
|
||||
return (
|
||||
<Animated.View
|
||||
style={[{
|
||||
position: 'absolute',
|
||||
top: insets.top + 12,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 100,
|
||||
}, shouldKeepFixed ? {} : {
|
||||
transform: [{ translateY }],
|
||||
opacity: fade,
|
||||
}]}>
|
||||
style={[{
|
||||
position: 'absolute',
|
||||
top: insets.top + 12,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 100,
|
||||
}, shouldKeepFixed ? {} : {
|
||||
transform: [{ translateY }],
|
||||
opacity: fade,
|
||||
}]}>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
|
@ -644,8 +646,8 @@ const MainTabs = () => {
|
|||
options.tabBarLabel !== undefined
|
||||
? options.tabBarLabel
|
||||
: options.title !== undefined
|
||||
? options.title
|
||||
: route.name;
|
||||
? options.title
|
||||
: route.name;
|
||||
|
||||
const isFocused = props.state.index === index;
|
||||
|
||||
|
|
@ -691,10 +693,10 @@ const MainTabs = () => {
|
|||
|
||||
// Default bottom tab for phones
|
||||
return (
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: Platform.OS === 'android' ? 70 + insets.bottom : 85 + insets.bottom,
|
||||
backgroundColor: 'transparent',
|
||||
|
|
@ -758,8 +760,8 @@ const MainTabs = () => {
|
|||
options.tabBarLabel !== undefined
|
||||
? options.tabBarLabel
|
||||
: options.title !== undefined
|
||||
? options.title
|
||||
: route.name;
|
||||
? options.title
|
||||
: route.name;
|
||||
|
||||
const isFocused = props.state.index === index;
|
||||
|
||||
|
|
@ -812,9 +814,9 @@ const MainTabs = () => {
|
|||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<TabIcon
|
||||
focused={isFocused}
|
||||
color={isFocused ? currentTheme.colors.primary : currentTheme.colors.white}
|
||||
<TabIcon
|
||||
focused={isFocused}
|
||||
color={isFocused ? currentTheme.colors.primary : currentTheme.colors.white}
|
||||
iconName={iconName}
|
||||
iconLibrary={iconLibrary}
|
||||
/>
|
||||
|
|
@ -837,7 +839,7 @@ const MainTabs = () => {
|
|||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// iOS: Use native bottom tabs (@bottom-tabs/react-navigation)
|
||||
if (Platform.OS === 'ios') {
|
||||
// Dynamically require to avoid impacting Android bundle
|
||||
|
|
@ -922,7 +924,7 @@ const MainTabs = () => {
|
|||
barStyle="light-content"
|
||||
backgroundColor="transparent"
|
||||
/>
|
||||
|
||||
|
||||
<Tab.Navigator
|
||||
tabBar={renderTabBar}
|
||||
screenOptions={({ route, navigation, theme }) => ({
|
||||
|
|
@ -1058,16 +1060,24 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
const { currentTheme } = useTheme();
|
||||
const { user, loading } = useAccount();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
|
||||
// Handle Android-specific optimizations
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'android') {
|
||||
// Hide system navigation bar
|
||||
try {
|
||||
RNImmersiveMode.setBarMode('Bottom');
|
||||
RNImmersiveMode.fullLayout(true);
|
||||
} catch (error) {
|
||||
console.log('Immersive mode error:', error);
|
||||
}
|
||||
|
||||
// Ensure consistent background color for Android
|
||||
StatusBar.setBackgroundColor('transparent', true);
|
||||
StatusBar.setTranslucent(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar
|
||||
|
|
@ -1076,8 +1086,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
barStyle="light-content"
|
||||
/>
|
||||
<PaperProvider theme={CustomDarkTheme}>
|
||||
<View style={{
|
||||
flex: 1,
|
||||
<View style={{
|
||||
flex: 1,
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
...(Platform.OS === 'android' && {
|
||||
// Prevent white flashes on Android
|
||||
|
|
@ -1126,8 +1136,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
contentStyle: { backgroundColor: currentTheme.colors.darkBackground },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Onboarding"
|
||||
<Stack.Screen
|
||||
name="Onboarding"
|
||||
component={OnboardingScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
|
|
@ -1138,9 +1148,9 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MainTabs"
|
||||
component={MainTabs as any}
|
||||
<Stack.Screen
|
||||
name="MainTabs"
|
||||
component={MainTabs as any}
|
||||
options={{
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
|
|
@ -1159,11 +1169,11 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Metadata"
|
||||
<Stack.Screen
|
||||
name="Metadata"
|
||||
component={MetadataScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
options={{
|
||||
headerShown: false,
|
||||
animation: Platform.OS === 'android' ? 'none' : 'fade',
|
||||
animationDuration: Platform.OS === 'android' ? 0 : 300,
|
||||
...(Platform.OS === 'ios' && {
|
||||
|
|
@ -1177,9 +1187,9 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Streams"
|
||||
component={StreamsScreen as any}
|
||||
<Stack.Screen
|
||||
name="Streams"
|
||||
component={StreamsScreen as any}
|
||||
options={{
|
||||
headerShown: false,
|
||||
animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'none',
|
||||
|
|
@ -1194,10 +1204,10 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
freezeOnBlur: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PlayerIOS"
|
||||
component={KSPlayerCore as any}
|
||||
options={{
|
||||
<Stack.Screen
|
||||
name="PlayerIOS"
|
||||
component={KSPlayerCore as any}
|
||||
options={{
|
||||
animation: 'default',
|
||||
animationDuration: 0,
|
||||
// Force fullscreen presentation on iPad
|
||||
|
|
@ -1216,10 +1226,10 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
freezeOnBlur: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PlayerAndroid"
|
||||
component={AndroidVideoPlayer as any}
|
||||
options={{
|
||||
<Stack.Screen
|
||||
name="PlayerAndroid"
|
||||
component={AndroidVideoPlayer as any}
|
||||
options={{
|
||||
animation: 'none',
|
||||
animationDuration: 0,
|
||||
presentation: 'card',
|
||||
|
|
@ -1234,10 +1244,10 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
freezeOnBlur: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Catalog"
|
||||
component={CatalogScreen as any}
|
||||
options={{
|
||||
<Stack.Screen
|
||||
name="Catalog"
|
||||
component={CatalogScreen as any}
|
||||
options={{
|
||||
animation: 'slide_from_right',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
contentStyle: {
|
||||
|
|
@ -1245,10 +1255,10 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Addons"
|
||||
component={AddonsScreen as any}
|
||||
options={{
|
||||
<Stack.Screen
|
||||
name="Addons"
|
||||
component={AddonsScreen as any}
|
||||
options={{
|
||||
animation: 'slide_from_right',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
contentStyle: {
|
||||
|
|
@ -1256,10 +1266,10 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Search"
|
||||
component={SearchScreen as any}
|
||||
options={{
|
||||
<Stack.Screen
|
||||
name="Search"
|
||||
component={SearchScreen as any}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'none' : 'fade',
|
||||
animationDuration: Platform.OS === 'android' ? 0 : 350,
|
||||
gestureEnabled: true,
|
||||
|
|
@ -1269,10 +1279,10 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CatalogSettings"
|
||||
component={CatalogSettingsScreen as any}
|
||||
options={{
|
||||
<Stack.Screen
|
||||
name="CatalogSettings"
|
||||
component={CatalogSettingsScreen as any}
|
||||
options={{
|
||||
animation: 'slide_from_right',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
contentStyle: {
|
||||
|
|
@ -1280,8 +1290,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="HomeScreenSettings"
|
||||
<Stack.Screen
|
||||
name="HomeScreenSettings"
|
||||
component={HomeScreenSettings}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'default',
|
||||
|
|
@ -1295,8 +1305,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ContinueWatchingSettings"
|
||||
<Stack.Screen
|
||||
name="ContinueWatchingSettings"
|
||||
component={ContinueWatchingSettingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'default',
|
||||
|
|
@ -1310,8 +1320,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Contributors"
|
||||
<Stack.Screen
|
||||
name="Contributors"
|
||||
component={ContributorsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'default',
|
||||
|
|
@ -1325,8 +1335,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="HeroCatalogs"
|
||||
<Stack.Screen
|
||||
name="HeroCatalogs"
|
||||
component={HeroCatalogsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'default',
|
||||
|
|
@ -1340,8 +1350,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ShowRatings"
|
||||
<Stack.Screen
|
||||
name="ShowRatings"
|
||||
component={ShowRatingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'fade_from_bottom' : 'fade',
|
||||
|
|
@ -1355,10 +1365,10 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Calendar"
|
||||
component={CalendarScreen as any}
|
||||
options={{
|
||||
<Stack.Screen
|
||||
name="Calendar"
|
||||
component={CalendarScreen as any}
|
||||
options={{
|
||||
animation: 'slide_from_right',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
contentStyle: {
|
||||
|
|
@ -1366,10 +1376,10 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="NotificationSettings"
|
||||
component={NotificationSettingsScreen as any}
|
||||
options={{
|
||||
<Stack.Screen
|
||||
name="NotificationSettings"
|
||||
component={NotificationSettingsScreen as any}
|
||||
options={{
|
||||
animation: 'slide_from_right',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
contentStyle: {
|
||||
|
|
@ -1377,8 +1387,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MDBListSettings"
|
||||
<Stack.Screen
|
||||
name="MDBListSettings"
|
||||
component={MDBListSettingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||
|
|
@ -1392,8 +1402,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="TMDBSettings"
|
||||
<Stack.Screen
|
||||
name="TMDBSettings"
|
||||
component={TMDBSettingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||
|
|
@ -1407,8 +1417,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="TraktSettings"
|
||||
<Stack.Screen
|
||||
name="TraktSettings"
|
||||
component={TraktSettingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||
|
|
@ -1422,8 +1432,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PlayerSettings"
|
||||
<Stack.Screen
|
||||
name="PlayerSettings"
|
||||
component={PlayerSettingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||
|
|
@ -1437,8 +1447,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ThemeSettings"
|
||||
<Stack.Screen
|
||||
name="ThemeSettings"
|
||||
component={ThemeScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||
|
|
@ -1452,8 +1462,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ScraperSettings"
|
||||
<Stack.Screen
|
||||
name="ScraperSettings"
|
||||
component={PluginsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||
|
|
@ -1467,8 +1477,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CastMovies"
|
||||
<Stack.Screen
|
||||
name="CastMovies"
|
||||
component={CastMoviesScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||
|
|
@ -1482,8 +1492,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Update"
|
||||
<Stack.Screen
|
||||
name="Update"
|
||||
component={UpdateScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
|
||||
|
|
@ -1554,6 +1564,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="DebridIntegration"
|
||||
component={DebridIntegrationScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</View>
|
||||
</PaperProvider>
|
||||
|
|
@ -1562,8 +1587,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
};
|
||||
|
||||
const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStackParamList }) => (
|
||||
<PostHogProvider
|
||||
apiKey="phc_sk6THCtV3thEAn6cTaA9kL2cHuKDBnlYiSL40ywdS6C"
|
||||
<PostHogProvider
|
||||
apiKey="phc_sk6THCtV3thEAn6cTaA9kL2cHuKDBnlYiSL40ywdS6C"
|
||||
options={{
|
||||
host: "https://us.i.posthog.com",
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -524,8 +524,8 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
opacity: 0.8,
|
||||
},
|
||||
communityAddonVersion: {
|
||||
fontSize: 12,
|
||||
color: colors.lightGray,
|
||||
fontSize: 12,
|
||||
color: colors.lightGray,
|
||||
},
|
||||
communityAddonDot: {
|
||||
fontSize: 12,
|
||||
|
|
@ -533,18 +533,18 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
marginHorizontal: 5,
|
||||
},
|
||||
communityAddonCategory: {
|
||||
fontSize: 12,
|
||||
color: colors.lightGray,
|
||||
flexShrink: 1,
|
||||
fontSize: 12,
|
||||
color: colors.lightGray,
|
||||
flexShrink: 1,
|
||||
},
|
||||
separator: {
|
||||
height: 10,
|
||||
},
|
||||
sectionSeparator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: 20,
|
||||
marginVertical: 20,
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: 20,
|
||||
marginVertical: 20,
|
||||
},
|
||||
emptyMessage: {
|
||||
textAlign: 'center',
|
||||
|
|
@ -660,16 +660,26 @@ const AddonsScreen = () => {
|
|||
setLoading(true);
|
||||
// Use the regular method without disabled state
|
||||
const installedAddons = await stremioService.getInstalledAddonsAsync();
|
||||
setAddons(installedAddons as ExtendedManifest[]);
|
||||
|
||||
|
||||
// Filter out Torbox addons (managed via DebridIntegrationScreen)
|
||||
const filteredAddons = installedAddons.filter(addon => {
|
||||
const isTorboxAddon =
|
||||
addon.id?.includes('torbox') ||
|
||||
addon.url?.includes('torbox') ||
|
||||
(addon as any).transport?.includes('torbox');
|
||||
return !isTorboxAddon;
|
||||
});
|
||||
|
||||
setAddons(filteredAddons as ExtendedManifest[]);
|
||||
|
||||
// Count catalogs
|
||||
let totalCatalogs = 0;
|
||||
installedAddons.forEach(addon => {
|
||||
filteredAddons.forEach(addon => {
|
||||
if (addon.catalogs && addon.catalogs.length > 0) {
|
||||
totalCatalogs += addon.catalogs.length;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Get catalog settings to determine enabled count
|
||||
const catalogSettingsJson = await mmkvStorage.getItem('catalog_settings');
|
||||
if (catalogSettingsJson) {
|
||||
|
|
@ -682,11 +692,11 @@ const AddonsScreen = () => {
|
|||
setCatalogCount(totalCatalogs);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load addons:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Failed to load addons');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
logger.error('Failed to load addons:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Failed to load addons');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -706,9 +716,9 @@ const AddonsScreen = () => {
|
|||
|
||||
setCommunityAddons(validAddons);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load community addons:', error);
|
||||
setCommunityError('Failed to load community addons. Please try again later.');
|
||||
setCommunityAddons([]);
|
||||
logger.error('Failed to load community addons:', error);
|
||||
setCommunityError('Failed to load community addons. Please try again later.');
|
||||
setCommunityAddons([]);
|
||||
} finally {
|
||||
setCommunityLoading(false);
|
||||
}
|
||||
|
|
@ -756,16 +766,16 @@ const AddonsScreen = () => {
|
|||
setShowConfirmModal(false);
|
||||
setAddonDetails(null);
|
||||
loadAddons();
|
||||
setAlertTitle('Success');
|
||||
setAlertMessage('Addon installed successfully');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
setAlertTitle('Success');
|
||||
setAlertMessage('Addon installed successfully');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
} catch (error) {
|
||||
logger.error('Failed to install addon:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Failed to install addon');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
logger.error('Failed to install addon:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Failed to install addon');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
|
|
@ -813,13 +823,13 @@ const AddonsScreen = () => {
|
|||
const handleConfigureAddon = (addon: ExtendedManifest, transportUrl?: string) => {
|
||||
// Try different ways to get the configuration URL
|
||||
let configUrl = '';
|
||||
|
||||
|
||||
// Debug log the addon data to help troubleshoot
|
||||
logger.info(`Configure addon: ${addon.name}, ID: ${addon.id}`);
|
||||
if (transportUrl) {
|
||||
logger.info(`TransportUrl provided: ${transportUrl}`);
|
||||
}
|
||||
|
||||
|
||||
// First check if the addon has a configurationURL directly
|
||||
if (addon.behaviorHints?.configurationURL) {
|
||||
configUrl = addon.behaviorHints.configurationURL;
|
||||
|
|
@ -861,7 +871,7 @@ const AddonsScreen = () => {
|
|||
const baseUrl = addon.id.replace(/\/[^\/]+\.json$/, '/');
|
||||
configUrl = `${baseUrl}configure`;
|
||||
logger.info(`Using addon.id as HTTP URL: ${configUrl}`);
|
||||
}
|
||||
}
|
||||
// If the ID uses stremio:// protocol but contains http URL (common format)
|
||||
else if (addon.id && (addon.id.includes('https://') || addon.id.includes('http://'))) {
|
||||
// Extract the HTTP URL using a more flexible regex
|
||||
|
|
@ -874,7 +884,7 @@ const AddonsScreen = () => {
|
|||
logger.info(`Extracted HTTP URL from stremio:// format: ${configUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Special case for common addon format like stremio://addon.stremio.com/...
|
||||
if (!configUrl && addon.id && addon.id.startsWith('stremio://')) {
|
||||
// Try to convert stremio://domain.com/... to https://domain.com/...
|
||||
|
|
@ -886,21 +896,21 @@ const AddonsScreen = () => {
|
|||
logger.info(`Converted stremio:// protocol to https:// for config URL: ${configUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Use transport property if available (some addons include this)
|
||||
if (!configUrl && addon.transport && typeof addon.transport === 'string' && addon.transport.includes('http')) {
|
||||
const baseUrl = addon.transport.replace(/\/[^\/]+\.json$/, '/');
|
||||
configUrl = `${baseUrl}configure`;
|
||||
logger.info(`Using addon.transport for config URL: ${configUrl}`);
|
||||
}
|
||||
|
||||
|
||||
// Get the URL from manifest's originalUrl if available
|
||||
if (!configUrl && (addon as any).originalUrl) {
|
||||
const baseUrl = (addon as any).originalUrl.replace(/\/[^\/]+\.json$/, '/');
|
||||
configUrl = `${baseUrl}configure`;
|
||||
logger.info(`Using originalUrl property: ${configUrl}`);
|
||||
}
|
||||
|
||||
|
||||
// If we couldn't determine a config URL, show an error
|
||||
if (!configUrl) {
|
||||
logger.error(`Failed to determine config URL for addon: ${addon.name}, ID: ${addon.id}`);
|
||||
|
|
@ -910,10 +920,10 @@ const AddonsScreen = () => {
|
|||
setAlertVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Log the URL being opened
|
||||
logger.info(`Opening configuration for addon: ${addon.name} at URL: ${configUrl}`);
|
||||
|
||||
|
||||
// Check if the URL can be opened
|
||||
Linking.canOpenURL(configUrl).then(supported => {
|
||||
if (supported) {
|
||||
|
|
@ -927,10 +937,10 @@ const AddonsScreen = () => {
|
|||
}
|
||||
}).catch(err => {
|
||||
logger.error(`Error checking if URL can be opened: ${configUrl}`, err);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Could not open configuration page.');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Could not open configuration page.');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -947,12 +957,12 @@ const AddonsScreen = () => {
|
|||
const isConfigurable = item.behaviorHints?.configurable === true;
|
||||
// Check if addon is pre-installed
|
||||
const isPreInstalled = stremioService.isPreInstalledAddon(item.id);
|
||||
|
||||
|
||||
// Format the types into a simple category text
|
||||
const categoryText = types.length > 0
|
||||
? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
|
||||
const categoryText = types.length > 0
|
||||
? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
|
||||
: 'No categories';
|
||||
|
||||
|
||||
const isFirstItem = index === 0;
|
||||
const isLastItem = index === addons.length - 1;
|
||||
|
||||
|
|
@ -960,35 +970,35 @@ const AddonsScreen = () => {
|
|||
<View style={styles.addonItem}>
|
||||
{reorderMode && (
|
||||
<View style={styles.reorderButtons}>
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={[styles.reorderButton, isFirstItem && styles.disabledButton]}
|
||||
onPress={() => moveAddonUp(item)}
|
||||
disabled={isFirstItem}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="arrow-upward"
|
||||
size={20}
|
||||
color={isFirstItem ? colors.mediumGray : colors.white}
|
||||
<MaterialIcons
|
||||
name="arrow-upward"
|
||||
size={20}
|
||||
color={isFirstItem ? colors.mediumGray : colors.white}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={[styles.reorderButton, isLastItem && styles.disabledButton]}
|
||||
onPress={() => moveAddonDown(item)}
|
||||
disabled={isLastItem}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="arrow-downward"
|
||||
size={20}
|
||||
<MaterialIcons
|
||||
name="arrow-downward"
|
||||
size={20}
|
||||
color={isLastItem ? colors.mediumGray : colors.white}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
<View style={styles.addonHeader}>
|
||||
{logo ? (
|
||||
<FastImage
|
||||
source={{ uri: logo }}
|
||||
<FastImage
|
||||
source={{ uri: logo }}
|
||||
style={styles.addonIcon}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
|
|
@ -1016,7 +1026,7 @@ const AddonsScreen = () => {
|
|||
{!reorderMode ? (
|
||||
<>
|
||||
{isConfigurable && (
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.configButton}
|
||||
onPress={() => handleConfigureAddon(item, item.transport)}
|
||||
>
|
||||
|
|
@ -1024,7 +1034,7 @@ const AddonsScreen = () => {
|
|||
</TouchableOpacity>
|
||||
)}
|
||||
{!stremioService.isPreInstalledAddon(item.id) && (
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={() => handleRemoveAddon(item)}
|
||||
>
|
||||
|
|
@ -1039,7 +1049,7 @@ const AddonsScreen = () => {
|
|||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
<Text style={styles.addonDescription}>
|
||||
{description.length > 100 ? description.substring(0, 100) + '...' : description}
|
||||
</Text>
|
||||
|
|
@ -1077,9 +1087,9 @@ const AddonsScreen = () => {
|
|||
<Text style={styles.communityAddonName}>{manifest.name}</Text>
|
||||
<Text style={styles.communityAddonDesc} numberOfLines={2}>{description}</Text>
|
||||
<View style={styles.communityAddonMetaContainer}>
|
||||
<Text style={styles.communityAddonVersion}>v{manifest.version || 'N/A'}</Text>
|
||||
<Text style={styles.communityAddonDot}>•</Text>
|
||||
<Text style={styles.communityAddonCategory}>{categoryText}</Text>
|
||||
<Text style={styles.communityAddonVersion}>v{manifest.version || 'N/A'}</Text>
|
||||
<Text style={styles.communityAddonDot}>•</Text>
|
||||
<Text style={styles.communityAddonCategory}>{categoryText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.addonActionButtons}>
|
||||
|
|
@ -1117,50 +1127,50 @@ const AddonsScreen = () => {
|
|||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
|
||||
<Text style={styles.backText}>Settings</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
||||
<View style={styles.headerActions}>
|
||||
{/* Reorder Mode Toggle Button */}
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={[styles.headerButton, reorderMode && styles.activeHeaderButton]}
|
||||
onPress={toggleReorderMode}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="swap-vert"
|
||||
size={24}
|
||||
color={reorderMode ? colors.primary : colors.white}
|
||||
<MaterialIcons
|
||||
name="swap-vert"
|
||||
size={24}
|
||||
color={reorderMode ? colors.primary : colors.white}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
||||
{/* Refresh Button */}
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.headerButton}
|
||||
onPress={refreshAddons}
|
||||
disabled={loading}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="refresh"
|
||||
size={24}
|
||||
color={loading ? colors.mediumGray : colors.white}
|
||||
<MaterialIcons
|
||||
name="refresh"
|
||||
size={24}
|
||||
color={loading ? colors.mediumGray : colors.white}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
<Text style={styles.headerTitle}>
|
||||
Addons
|
||||
{reorderMode && <Text style={styles.reorderModeText}> (Reorder Mode)</Text>}
|
||||
</Text>
|
||||
|
||||
|
||||
{reorderMode && (
|
||||
<View style={styles.reorderInfoBanner}>
|
||||
<MaterialIcons name="info-outline" size={18} color={colors.primary} />
|
||||
|
|
@ -1169,18 +1179,18 @@ const AddonsScreen = () => {
|
|||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
|
||||
|
||||
{/* Overview Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>OVERVIEW</Text>
|
||||
|
|
@ -1192,7 +1202,7 @@ const AddonsScreen = () => {
|
|||
<StatsCard value={catalogCount} label="Catalogs" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
{/* Hide Add Addon Section in reorder mode */}
|
||||
{!reorderMode && (
|
||||
<View style={styles.section}>
|
||||
|
|
@ -1207,8 +1217,8 @@ const AddonsScreen = () => {
|
|||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, {opacity: installing || !addonUrl ? 0.6 : 1}]}
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, { opacity: installing || !addonUrl ? 0.6 : 1 }]}
|
||||
onPress={() => handleAddAddon()}
|
||||
disabled={installing || !addonUrl}
|
||||
>
|
||||
|
|
@ -1219,7 +1229,7 @@ const AddonsScreen = () => {
|
|||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
{/* Installed Addons Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
|
|
@ -1233,8 +1243,8 @@ const AddonsScreen = () => {
|
|||
</View>
|
||||
) : (
|
||||
addons.map((addon, index) => (
|
||||
<View
|
||||
key={addon.id}
|
||||
<View
|
||||
key={addon.id}
|
||||
style={{ marginBottom: index === addons.length - 1 ? 32 : 0 }}
|
||||
>
|
||||
{renderAddonItem({ item: addon, index })}
|
||||
|
|
@ -1245,68 +1255,68 @@ const AddonsScreen = () => {
|
|||
</View>
|
||||
|
||||
{/* Separator */}
|
||||
<View style={styles.sectionSeparator} />
|
||||
<View style={styles.sectionSeparator} />
|
||||
|
||||
{/* Promotional Addon Section (hidden if installed) */}
|
||||
{!isPromoInstalled && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>OFFICIAL ADDON</Text>
|
||||
<View style={styles.addonList}>
|
||||
<View style={styles.addonItem}>
|
||||
<View style={styles.addonHeader}>
|
||||
{promoAddon.logo ? (
|
||||
<FastImage
|
||||
source={{ uri: promoAddon.logo }}
|
||||
style={styles.addonIcon}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.addonIconPlaceholder}>
|
||||
<MaterialIcons name="extension" size={22} color={colors.mediumGray} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.addonTitleContainer}>
|
||||
<Text style={styles.addonName}>{promoAddon.name}</Text>
|
||||
<View style={styles.addonMetaContainer}>
|
||||
<Text style={styles.addonVersion}>v{promoAddon.version}</Text>
|
||||
<Text style={styles.addonDot}>•</Text>
|
||||
<Text style={styles.addonCategory}>{promoAddon.types?.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.addonActions}>
|
||||
{promoAddon.behaviorHints?.configurable && (
|
||||
<TouchableOpacity
|
||||
style={styles.configButton}
|
||||
onPress={() => handleConfigureAddon(promoAddon, PROMO_ADDON_URL)}
|
||||
>
|
||||
<MaterialIcons name="settings" size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.installButton}
|
||||
onPress={() => handleAddAddon(PROMO_ADDON_URL)}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing ? (
|
||||
<ActivityIndicator size="small" color={colors.white} />
|
||||
) : (
|
||||
<MaterialIcons name="add" size={20} color={colors.white} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.addonDescription}>
|
||||
{promoAddon.description}
|
||||
</Text>
|
||||
<Text style={[styles.addonDescription, { marginTop: 4, opacity: 0.9 }]}>
|
||||
Configure and install for full functionality.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{/* Promotional Addon Section (hidden if installed) */}
|
||||
{!isPromoInstalled && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>OFFICIAL ADDON</Text>
|
||||
<View style={styles.addonList}>
|
||||
<View style={styles.addonItem}>
|
||||
<View style={styles.addonHeader}>
|
||||
{promoAddon.logo ? (
|
||||
<FastImage
|
||||
source={{ uri: promoAddon.logo }}
|
||||
style={styles.addonIcon}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.addonIconPlaceholder}>
|
||||
<MaterialIcons name="extension" size={22} color={colors.mediumGray} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.addonTitleContainer}>
|
||||
<Text style={styles.addonName}>{promoAddon.name}</Text>
|
||||
<View style={styles.addonMetaContainer}>
|
||||
<Text style={styles.addonVersion}>v{promoAddon.version}</Text>
|
||||
<Text style={styles.addonDot}>•</Text>
|
||||
<Text style={styles.addonCategory}>{promoAddon.types?.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.addonActions}>
|
||||
{promoAddon.behaviorHints?.configurable && (
|
||||
<TouchableOpacity
|
||||
style={styles.configButton}
|
||||
onPress={() => handleConfigureAddon(promoAddon, PROMO_ADDON_URL)}
|
||||
>
|
||||
<MaterialIcons name="settings" size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.installButton}
|
||||
onPress={() => handleAddAddon(PROMO_ADDON_URL)}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing ? (
|
||||
<ActivityIndicator size="small" color={colors.white} />
|
||||
) : (
|
||||
<MaterialIcons name="add" size={20} color={colors.white} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.addonDescription}>
|
||||
{promoAddon.description}
|
||||
</Text>
|
||||
<Text style={[styles.addonDescription, { marginTop: 4, opacity: 0.9 }]}>
|
||||
Configure and install for full functionality.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Community Addons Section */}
|
||||
{/* Community Addons Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>COMMUNITY ADDONS</Text>
|
||||
<View style={styles.addonList}>
|
||||
|
|
@ -1326,15 +1336,15 @@ const AddonsScreen = () => {
|
|||
</View>
|
||||
) : (
|
||||
communityAddons.map((item, index) => (
|
||||
<View
|
||||
key={item.transportUrl}
|
||||
<View
|
||||
key={item.transportUrl}
|
||||
style={{ marginBottom: index === communityAddons.length - 1 ? 32 : 16 }}
|
||||
>
|
||||
<View style={styles.addonItem}>
|
||||
<View style={styles.addonHeader}>
|
||||
{item.manifest.logo ? (
|
||||
<FastImage
|
||||
source={{ uri: item.manifest.logo }}
|
||||
<FastImage
|
||||
source={{ uri: item.manifest.logo }}
|
||||
style={styles.addonIcon}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
|
|
@ -1357,14 +1367,14 @@ const AddonsScreen = () => {
|
|||
</View>
|
||||
<View style={styles.addonActions}>
|
||||
{item.manifest.behaviorHints?.configurable && (
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.configButton}
|
||||
onPress={() => handleConfigureAddon(item.manifest, item.transportUrl)}
|
||||
>
|
||||
<MaterialIcons name="settings" size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={[styles.installButton, installing && { opacity: 0.6 }]}
|
||||
onPress={() => handleAddAddon(item.transportUrl)}
|
||||
disabled={installing}
|
||||
|
|
@ -1377,12 +1387,12 @@ const AddonsScreen = () => {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
<Text style={styles.addonDescription}>
|
||||
{item.manifest.description
|
||||
? (item.manifest.description.length > 100
|
||||
? item.manifest.description.substring(0, 100) + '...'
|
||||
: item.manifest.description)
|
||||
{item.manifest.description
|
||||
? (item.manifest.description.length > 100
|
||||
? item.manifest.description.substring(0, 100) + '...'
|
||||
: item.manifest.description)
|
||||
: 'No description provided.'}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -1429,8 +1439,8 @@ const AddonsScreen = () => {
|
|||
<MaterialIcons name="close" size={24} color={colors.white} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
|
||||
<ScrollView
|
||||
style={styles.modalScrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces={true}
|
||||
|
|
@ -1451,14 +1461,14 @@ const AddonsScreen = () => {
|
|||
<Text style={styles.addonDetailName}>{addonDetails.name}</Text>
|
||||
<Text style={styles.addonDetailVersion}>v{addonDetails.version || '1.0.0'}</Text>
|
||||
</View>
|
||||
|
||||
|
||||
<View style={styles.addonDetailSection}>
|
||||
<Text style={styles.addonDetailSectionTitle}>Description</Text>
|
||||
<Text style={styles.addonDetailDescription}>
|
||||
{addonDetails.description || 'No description available'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
{addonDetails.types && addonDetails.types.length > 0 && (
|
||||
<View style={styles.addonDetailSection}>
|
||||
<Text style={styles.addonDetailSectionTitle}>Supported Types</Text>
|
||||
|
|
@ -1471,7 +1481,7 @@ const AddonsScreen = () => {
|
|||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
{addonDetails.catalogs && addonDetails.catalogs.length > 0 && (
|
||||
<View style={styles.addonDetailSection}>
|
||||
<Text style={styles.addonDetailSectionTitle}>Catalogs</Text>
|
||||
|
|
@ -1487,7 +1497,7 @@ const AddonsScreen = () => {
|
|||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, styles.cancelButton]}
|
||||
|
|
@ -1515,15 +1525,15 @@ const AddonsScreen = () => {
|
|||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
{/* Custom Alert Modal */}
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
actions={alertActions}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
{/* Custom Alert Modal */}
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
actions={alertActions}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
797
src/screens/DebridIntegrationScreen.tsx
Normal file
797
src/screens/DebridIntegrationScreen.tsx
Normal file
|
|
@ -0,0 +1,797 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
StatusBar,
|
||||
Platform,
|
||||
Linking,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Image,
|
||||
Switch,
|
||||
ActivityIndicator,
|
||||
RefreshControl
|
||||
} from 'react-native';
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { stremioService } from '../services/stremioService';
|
||||
import { logger } from '../utils/logger';
|
||||
import CustomAlert from '../components/CustomAlert';
|
||||
import { mmkvStorage } from '../services/mmkvStorage';
|
||||
import axios from 'axios';
|
||||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
const TORBOX_STORAGE_KEY = 'torbox_debrid_config';
|
||||
const TORBOX_API_BASE = 'https://api.torbox.app/v1';
|
||||
|
||||
interface TorboxConfig {
|
||||
apiKey: string;
|
||||
isConnected: boolean;
|
||||
isEnabled: boolean;
|
||||
addonId?: string;
|
||||
}
|
||||
|
||||
interface TorboxUserData {
|
||||
id: number;
|
||||
email: string;
|
||||
plan: number;
|
||||
total_downloaded: number;
|
||||
is_subscribed: boolean;
|
||||
premium_expires_at: string | null;
|
||||
base_email: string;
|
||||
}
|
||||
|
||||
const getPlanName = (plan: number): string => {
|
||||
switch (plan) {
|
||||
case 0: return 'Free';
|
||||
case 1: return 'Essential ($3/mo)';
|
||||
case 2: return 'Pro ($10/mo)';
|
||||
case 3: return 'Standard ($5/mo)';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
};
|
||||
const createStyles = (colors: any) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
marginRight: 4,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: colors.mediumEmphasis,
|
||||
marginBottom: 16,
|
||||
lineHeight: 20,
|
||||
opacity: 0.9,
|
||||
},
|
||||
statusCard: {
|
||||
backgroundColor: colors.elevation2,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: colors.mediumEmphasis,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
statusValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
},
|
||||
statusConnected: {
|
||||
color: colors.success || '#4CAF50',
|
||||
},
|
||||
statusDisconnected: {
|
||||
color: colors.error || '#F44336',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.elevation3,
|
||||
marginVertical: 10,
|
||||
},
|
||||
actionButton: {
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
dangerButton: {
|
||||
backgroundColor: colors.error || '#F44336',
|
||||
},
|
||||
buttonText: {
|
||||
color: colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.elevation2,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
color: colors.white,
|
||||
fontSize: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.elevation3,
|
||||
},
|
||||
connectButton: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
connectButtonText: {
|
||||
color: colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
section: {
|
||||
marginTop: 16,
|
||||
backgroundColor: colors.elevation1,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
marginBottom: 6,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
sectionText: {
|
||||
fontSize: 13,
|
||||
color: colors.mediumEmphasis,
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
lineHeight: 18,
|
||||
opacity: 0.9,
|
||||
},
|
||||
subscribeButton: {
|
||||
backgroundColor: colors.elevation3,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
subscribeButtonText: {
|
||||
color: colors.primary,
|
||||
fontWeight: '700',
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginTop: 'auto',
|
||||
paddingBottom: 16,
|
||||
paddingTop: 16,
|
||||
},
|
||||
poweredBy: {
|
||||
fontSize: 10,
|
||||
color: colors.mediumGray,
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
opacity: 0.6,
|
||||
},
|
||||
logo: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
marginBottom: 4,
|
||||
},
|
||||
logoRow: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
userDataCard: {
|
||||
backgroundColor: colors.elevation2,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
userDataRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 6,
|
||||
},
|
||||
userDataLabel: {
|
||||
fontSize: 13,
|
||||
color: colors.mediumEmphasis,
|
||||
flex: 1,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
userDataValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
planBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
planBadgeFree: {
|
||||
backgroundColor: colors.elevation3,
|
||||
},
|
||||
planBadgePaid: {
|
||||
backgroundColor: colors.primary + '20',
|
||||
},
|
||||
planBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
planBadgeTextFree: {
|
||||
color: colors.mediumEmphasis,
|
||||
},
|
||||
planBadgeTextPaid: {
|
||||
color: colors.primary,
|
||||
},
|
||||
userDataHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.elevation3,
|
||||
},
|
||||
userDataTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
guideLink: {
|
||||
marginBottom: 16,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
guideLinkText: {
|
||||
color: colors.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
disclaimer: {
|
||||
fontSize: 10,
|
||||
color: colors.mediumGray,
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
opacity: 0.6,
|
||||
}
|
||||
});
|
||||
|
||||
const DebridIntegrationScreen = () => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const colors = currentTheme.colors;
|
||||
const styles = createStyles(colors);
|
||||
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [config, setConfig] = useState<TorboxConfig | null>(null);
|
||||
const [userData, setUserData] = useState<TorboxUserData | null>(null);
|
||||
const [userDataLoading, setUserDataLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Alert state
|
||||
const [alertVisible, setAlertVisible] = useState(false);
|
||||
const [alertTitle, setAlertTitle] = useState('');
|
||||
const [alertMessage, setAlertMessage] = useState('');
|
||||
const [alertActions, setAlertActions] = useState<any[]>([]);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const storedConfig = await mmkvStorage.getItem(TORBOX_STORAGE_KEY);
|
||||
if (storedConfig) {
|
||||
const parsedConfig = JSON.parse(storedConfig);
|
||||
setConfig(parsedConfig);
|
||||
|
||||
// Check if addon is actually installed
|
||||
const addons = await stremioService.getInstalledAddonsAsync();
|
||||
const torboxAddon = addons.find(addon =>
|
||||
addon.id?.includes('torbox') ||
|
||||
addon.url?.includes('torbox') ||
|
||||
(addon as any).transport?.includes('torbox')
|
||||
);
|
||||
|
||||
if (torboxAddon && !parsedConfig.isConnected) {
|
||||
// Update config if addon exists but config says not connected
|
||||
const updatedConfig = { ...parsedConfig, isConnected: true, addonId: torboxAddon.id };
|
||||
setConfig(updatedConfig);
|
||||
await mmkvStorage.setItem(TORBOX_STORAGE_KEY, JSON.stringify(updatedConfig));
|
||||
} else if (!torboxAddon && parsedConfig.isConnected) {
|
||||
// Update config if addon doesn't exist but config says connected
|
||||
const updatedConfig = { ...parsedConfig, isConnected: false, addonId: undefined };
|
||||
setConfig(updatedConfig);
|
||||
await mmkvStorage.setItem(TORBOX_STORAGE_KEY, JSON.stringify(updatedConfig));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load Torbox config:', error);
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUserData = useCallback(async () => {
|
||||
if (!config?.apiKey || !config?.isConnected) return;
|
||||
|
||||
setUserDataLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`${TORBOX_API_BASE}/api/user/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`
|
||||
},
|
||||
params: {
|
||||
settings: false
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
setUserData(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch Torbox user data:', error);
|
||||
// Don't show error to user, just log it
|
||||
} finally {
|
||||
setUserDataLoading(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.isConnected) {
|
||||
fetchUserData();
|
||||
}
|
||||
}, [config?.isConnected, fetchUserData]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await Promise.all([loadConfig(), fetchUserData()]);
|
||||
setRefreshing(false);
|
||||
}, [loadConfig, fetchUserData]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Please enter a valid API Key');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const manifestUrl = `https://stremio.torbox.app/${apiKey.trim()}/manifest.json`;
|
||||
|
||||
// Install the addon using stremioService
|
||||
await stremioService.installAddon(manifestUrl);
|
||||
|
||||
// Get the installed addon ID
|
||||
const addons = await stremioService.getInstalledAddonsAsync();
|
||||
const torboxAddon = addons.find(addon =>
|
||||
addon.id?.includes('torbox') ||
|
||||
addon.url?.includes('torbox') ||
|
||||
(addon as any).transport?.includes('torbox')
|
||||
);
|
||||
|
||||
// Save config
|
||||
const newConfig: TorboxConfig = {
|
||||
apiKey: apiKey.trim(),
|
||||
isConnected: true,
|
||||
isEnabled: true,
|
||||
addonId: torboxAddon?.id
|
||||
};
|
||||
await mmkvStorage.setItem(TORBOX_STORAGE_KEY, JSON.stringify(newConfig));
|
||||
setConfig(newConfig);
|
||||
setApiKey('');
|
||||
|
||||
setAlertTitle('Success');
|
||||
setAlertMessage('Torbox addon connected successfully!');
|
||||
setAlertActions([{
|
||||
label: 'OK',
|
||||
onPress: () => setAlertVisible(false)
|
||||
}]);
|
||||
setAlertVisible(true);
|
||||
} catch (error) {
|
||||
logger.error('Failed to install Torbox addon:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Failed to connect addon. Please check your API Key and try again.');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (enabled: boolean) => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
const updatedConfig = { ...config, isEnabled: enabled };
|
||||
await mmkvStorage.setItem(TORBOX_STORAGE_KEY, JSON.stringify(updatedConfig));
|
||||
setConfig(updatedConfig);
|
||||
|
||||
// Note: Since we can't disable/enable addons in the current stremioService,
|
||||
// we'll just track the state. The addon filtering will happen in AddonsScreen
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle Torbox addon:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setAlertTitle('Disconnect Torbox');
|
||||
setAlertMessage('Are you sure you want to disconnect Torbox? This will remove the addon and clear your saved API key.');
|
||||
setAlertActions([
|
||||
{ label: 'Cancel', onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } },
|
||||
{
|
||||
label: 'Disconnect',
|
||||
onPress: async () => {
|
||||
setAlertVisible(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
// Find and remove the torbox addon
|
||||
const addons = await stremioService.getInstalledAddonsAsync();
|
||||
const torboxAddon = addons.find(addon =>
|
||||
addon.id?.includes('torbox') ||
|
||||
addon.url?.includes('torbox') ||
|
||||
(addon as any).transport?.includes('torbox')
|
||||
);
|
||||
|
||||
if (torboxAddon) {
|
||||
await stremioService.removeAddon(torboxAddon.id);
|
||||
}
|
||||
|
||||
// Clear config
|
||||
await mmkvStorage.removeItem(TORBOX_STORAGE_KEY);
|
||||
setConfig(null);
|
||||
|
||||
setAlertTitle('Success');
|
||||
setAlertMessage('Torbox disconnected successfully');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
} catch (error) {
|
||||
logger.error('Failed to disconnect Torbox:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Failed to disconnect Torbox');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
style: { color: colors.error || '#F44336' }
|
||||
}
|
||||
]);
|
||||
setAlertVisible(true);
|
||||
};
|
||||
|
||||
const openSubscription = () => {
|
||||
Linking.openURL('https://torbox.app/subscription?referral=493192f2-6403-440f-b414-768f72222ec7');
|
||||
};
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} />
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Feather name="arrow-left" size={24} color={colors.white} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Debrid Integration</Text>
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colors.primary}
|
||||
colors={[colors.primary]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{config?.isConnected ? (
|
||||
// Connected state
|
||||
<>
|
||||
<View style={styles.statusCard}>
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusLabel}>Status</Text>
|
||||
<Text style={[styles.statusValue, styles.statusConnected]}>Connected</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusLabel}>Enable Addon</Text>
|
||||
<Switch
|
||||
value={config.isEnabled}
|
||||
onValueChange={handleToggleEnabled}
|
||||
trackColor={{ false: colors.elevation2, true: colors.primary }}
|
||||
thumbColor={config.isEnabled ? colors.white : colors.mediumEmphasis}
|
||||
ios_backgroundColor={colors.elevation2}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.dangerButton, loading && styles.disabledButton]}
|
||||
onPress={handleDisconnect}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'Disconnecting...' : 'Disconnect & Remove'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* User Data Card */}
|
||||
{userData && (
|
||||
<View style={styles.userDataCard}>
|
||||
<View style={styles.userDataHeader}>
|
||||
<Text style={styles.userDataTitle}>Account Information</Text>
|
||||
{userDataLoading && (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.userDataRow}>
|
||||
<Text style={styles.userDataLabel}>Email</Text>
|
||||
<Text style={styles.userDataValue} numberOfLines={1}>
|
||||
{userData.base_email || userData.email}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.userDataRow}>
|
||||
<Text style={styles.userDataLabel}>Plan</Text>
|
||||
<View style={[
|
||||
styles.planBadge,
|
||||
userData.plan === 0 ? styles.planBadgeFree : styles.planBadgePaid
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.planBadgeText,
|
||||
userData.plan === 0 ? styles.planBadgeTextFree : styles.planBadgeTextPaid
|
||||
]}>
|
||||
{getPlanName(userData.plan)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.userDataRow}>
|
||||
<Text style={styles.userDataLabel}>Status</Text>
|
||||
<Text style={[
|
||||
styles.userDataValue,
|
||||
{ color: userData.is_subscribed ? (colors.success || '#4CAF50') : colors.mediumEmphasis }
|
||||
]}>
|
||||
{userData.is_subscribed ? 'Active' : 'Free'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{userData.premium_expires_at && (
|
||||
<View style={styles.userDataRow}>
|
||||
<Text style={styles.userDataLabel}>Expires</Text>
|
||||
<Text style={styles.userDataValue}>
|
||||
{new Date(userData.premium_expires_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.userDataRow}>
|
||||
<Text style={styles.userDataLabel}>Downloaded</Text>
|
||||
<Text style={styles.userDataValue}>
|
||||
{(userData.total_downloaded / (1024 * 1024 * 1024)).toFixed(2)} GB
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>✓ Connected to TorBox</Text>
|
||||
<Text style={styles.sectionText}>
|
||||
Your TorBox addon is active and providing premium streams.{config.isEnabled ? '' : ' (Currently disabled)'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Configure Addon</Text>
|
||||
<Text style={styles.sectionText}>
|
||||
Customize your streaming experience. Sort by quality, filter file sizes, and manage other integration settings.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.subscribeButton}
|
||||
onPress={() => Linking.openURL('https://torbox.app/settings?section=integration-settings')}
|
||||
>
|
||||
<Text style={styles.subscribeButtonText}>Open Settings</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
// Not connected state
|
||||
<>
|
||||
<Text style={styles.description}>
|
||||
Unlock 4K high-quality streams and lightning-fast speeds by integrating Torbox. Enter your API Key below to instantly upgrade your streaming experience.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://guides.viren070.me/stremio/technical-details#debrid-services')} style={styles.guideLink}>
|
||||
<Text style={styles.guideLinkText}>What is a Debrid Service?</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Torbox API Key</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter your API Key"
|
||||
placeholderTextColor={colors.mediumGray}
|
||||
value={apiKey}
|
||||
onChangeText={setApiKey}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.connectButton, loading && styles.disabledButton]}
|
||||
onPress={handleConnect}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.connectButtonText}>
|
||||
{loading ? 'Connecting...' : 'Connect & Install'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Unlock Premium Speeds</Text>
|
||||
<Text style={styles.sectionText}>
|
||||
Get a Torbox subscription to access cached high-quality streams with zero buffering.
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.subscribeButton} onPress={openSubscription}>
|
||||
<Text style={styles.subscribeButtonText}>Get Subscription</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={[styles.logoContainer, { marginTop: 60 }]}>
|
||||
<Text style={styles.poweredBy}>Powered by</Text>
|
||||
<View style={styles.logoRow}>
|
||||
<Image
|
||||
source={{ uri: 'https://torbox.app/assets/logo-57adbf99.svg' }}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.logoText}>TorBox</Text>
|
||||
</View>
|
||||
<Text style={styles.disclaimer}>Nuvio is not affiliated with Torbox in any way.</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
actions={alertActions}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebridIntegrationScreen;
|
||||
|
|
@ -67,9 +67,9 @@ interface SettingsCardProps {
|
|||
|
||||
const SettingsCard: React.FC<SettingsCardProps> = ({ children, title, isTablet = false }) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
|
||||
return (
|
||||
<View
|
||||
<View
|
||||
style={[
|
||||
styles.cardContainer,
|
||||
isTablet && styles.tabletCardContainer
|
||||
|
|
@ -119,13 +119,13 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
isTablet = false
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.6}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.settingItem,
|
||||
styles.settingItem,
|
||||
!isLast && styles.settingItemBorder,
|
||||
{ borderBottomColor: currentTheme.colors.elevation2 },
|
||||
isTablet && styles.tabletSettingItem
|
||||
|
|
@ -133,7 +133,7 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
>
|
||||
<View style={[
|
||||
styles.settingIconContainer,
|
||||
{
|
||||
{
|
||||
backgroundColor: currentTheme.colors.darkGray,
|
||||
borderWidth: 1,
|
||||
borderColor: currentTheme.colors.primary + '20'
|
||||
|
|
@ -143,17 +143,17 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
{customIcon ? (
|
||||
customIcon
|
||||
) : (
|
||||
<Feather
|
||||
name={icon! as any}
|
||||
size={isTablet ? 24 : 20}
|
||||
color={currentTheme.colors.primary}
|
||||
<Feather
|
||||
name={icon! as any}
|
||||
size={isTablet ? 24 : 20}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[
|
||||
styles.settingTitle,
|
||||
styles.settingTitle,
|
||||
{ color: currentTheme.colors.highEmphasis },
|
||||
isTablet && styles.tabletSettingTitle
|
||||
]}>
|
||||
|
|
@ -161,7 +161,7 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
</Text>
|
||||
{description && (
|
||||
<Text style={[
|
||||
styles.settingDescription,
|
||||
styles.settingDescription,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
isTablet && styles.tabletSettingDescription
|
||||
]} numberOfLines={1}>
|
||||
|
|
@ -224,16 +224,16 @@ const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, c
|
|||
name={category.icon as any}
|
||||
size={22}
|
||||
color={
|
||||
selectedCategory === category.id
|
||||
? currentTheme.colors.primary
|
||||
selectedCategory === category.id
|
||||
? currentTheme.colors.primary
|
||||
: currentTheme.colors.mediumEmphasis
|
||||
}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.sidebarItemText,
|
||||
{
|
||||
color: selectedCategory === category.id
|
||||
? currentTheme.colors.primary
|
||||
color: selectedCategory === category.id
|
||||
? currentTheme.colors.primary
|
||||
: currentTheme.colors.mediumEmphasis
|
||||
}
|
||||
]}>
|
||||
|
|
@ -263,7 +263,7 @@ const SettingsScreen: React.FC = () => {
|
|||
) => {
|
||||
setAlertTitle(title);
|
||||
setAlertMessage(message);
|
||||
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]);
|
||||
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]);
|
||||
setAlertVisible(true);
|
||||
};
|
||||
|
||||
|
|
@ -274,7 +274,7 @@ const SettingsScreen: React.FC = () => {
|
|||
try {
|
||||
const flag = await mmkvStorage.getItem('@update_badge_pending');
|
||||
if (mounted) setHasUpdateBadge(flag === 'true');
|
||||
} catch {}
|
||||
} catch { }
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
|
@ -283,7 +283,7 @@ const SettingsScreen: React.FC = () => {
|
|||
const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext();
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
|
||||
// Tablet-specific state
|
||||
const [selectedCategory, setSelectedCategory] = useState('account');
|
||||
|
||||
|
|
@ -310,7 +310,7 @@ const SettingsScreen: React.FC = () => {
|
|||
}
|
||||
refreshAuthStatus();
|
||||
});
|
||||
|
||||
|
||||
return unsubscribe;
|
||||
}, [navigation, isAuthenticated, userProfile, refreshAuthStatus]);
|
||||
|
||||
|
|
@ -320,7 +320,7 @@ const SettingsScreen: React.FC = () => {
|
|||
const addons = await stremioService.getInstalledAddonsAsync();
|
||||
setAddonCount(addons.length);
|
||||
setInitialLoadComplete(true);
|
||||
|
||||
|
||||
// Count total available catalogs
|
||||
let totalCatalogs = 0;
|
||||
addons.forEach(addon => {
|
||||
|
|
@ -328,7 +328,7 @@ const SettingsScreen: React.FC = () => {
|
|||
totalCatalogs += addon.catalogs.length;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Load saved catalog settings
|
||||
const catalogSettingsJson = await mmkvStorage.getItem('catalog_settings');
|
||||
if (catalogSettingsJson) {
|
||||
|
|
@ -358,7 +358,7 @@ const SettingsScreen: React.FC = () => {
|
|||
setTotalDownloads(downloads);
|
||||
setDisplayDownloads(downloads);
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error loading settings data:', error);
|
||||
}
|
||||
|
|
@ -382,7 +382,7 @@ const SettingsScreen: React.FC = () => {
|
|||
useEffect(() => {
|
||||
// Only poll when viewing the About section (where downloads counter is shown)
|
||||
const shouldPoll = isTablet ? selectedCategory === 'about' : true;
|
||||
|
||||
|
||||
if (!shouldPoll) return;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
|
|
@ -414,11 +414,11 @@ const SettingsScreen: React.FC = () => {
|
|||
const now = Date.now();
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
|
||||
// Ease out quad for smooth deceleration
|
||||
const easeProgress = 1 - Math.pow(1 - progress, 2);
|
||||
const current = Math.floor(start + (end - start) * easeProgress);
|
||||
|
||||
|
||||
setDisplayDownloads(current);
|
||||
|
||||
if (progress < 1) {
|
||||
|
|
@ -437,7 +437,7 @@ const SettingsScreen: React.FC = () => {
|
|||
'Reset Settings',
|
||||
'Are you sure you want to reset all settings to default values?',
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{ label: 'Cancel', onPress: () => { } },
|
||||
{
|
||||
label: 'Reset',
|
||||
onPress: () => {
|
||||
|
|
@ -455,7 +455,7 @@ const SettingsScreen: React.FC = () => {
|
|||
'Clear MDBList Cache',
|
||||
'Are you sure you want to clear all cached MDBList data? This cannot be undone.',
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{ label: 'Cancel', onPress: () => { } },
|
||||
{
|
||||
label: 'Clear',
|
||||
onPress: async () => {
|
||||
|
|
@ -483,9 +483,9 @@ const SettingsScreen: React.FC = () => {
|
|||
);
|
||||
|
||||
const ChevronRight = () => (
|
||||
<Feather
|
||||
name="chevron-right"
|
||||
size={isTablet ? 24 : 20}
|
||||
<Feather
|
||||
name="chevron-right"
|
||||
size={isTablet ? 24 : 20}
|
||||
color={currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
);
|
||||
|
|
@ -527,6 +527,14 @@ const SettingsScreen: React.FC = () => {
|
|||
onPress={() => navigation.navigate('Addons')}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Debrid Integration"
|
||||
description="Connect Torbox for premium streams"
|
||||
icon="link"
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('DebridIntegration')}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Plugins"
|
||||
description="Manage plugins and repositories"
|
||||
|
|
@ -648,7 +656,7 @@ const SettingsScreen: React.FC = () => {
|
|||
<SettingsCard title="PLAYBACK" isTablet={isTablet}>
|
||||
<SettingItem
|
||||
title="Video Player"
|
||||
description={Platform.OS === 'ios'
|
||||
description={Platform.OS === 'ios'
|
||||
? (settings?.preferredPlayer === 'internal' ? 'Built-in' : settings?.preferredPlayer?.toUpperCase() || 'Built-in')
|
||||
: (settings?.useExternalPlayer ? 'External' : 'Built-in')
|
||||
}
|
||||
|
|
@ -756,6 +764,21 @@ const SettingsScreen: React.FC = () => {
|
|||
renderControl={ChevronRight}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Test Announcement"
|
||||
icon="bell"
|
||||
description="Show what's new overlay"
|
||||
onPress={async () => {
|
||||
try {
|
||||
await mmkvStorage.removeItem('announcement_v1.0.0_shown');
|
||||
openAlert('Success', 'Announcement reset. Restart the app to see the announcement overlay.');
|
||||
} catch (error) {
|
||||
openAlert('Error', 'Failed to reset announcement.');
|
||||
}
|
||||
}}
|
||||
renderControl={ChevronRight}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Clear All Data"
|
||||
icon="trash-2"
|
||||
|
|
@ -764,7 +787,7 @@ const SettingsScreen: React.FC = () => {
|
|||
'Clear All Data',
|
||||
'This will reset all settings and clear all cached data. Are you sure?',
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{ label: 'Cancel', onPress: () => { } },
|
||||
{
|
||||
label: 'Clear',
|
||||
onPress: async () => {
|
||||
|
|
@ -824,7 +847,7 @@ const SettingsScreen: React.FC = () => {
|
|||
badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined}
|
||||
onPress={async () => {
|
||||
if (Platform.OS === 'android') {
|
||||
try { await mmkvStorage.removeItem('@update_badge_pending'); } catch {}
|
||||
try { await mmkvStorage.removeItem('@update_badge_pending'); } catch { }
|
||||
setHasUpdateBadge(false);
|
||||
}
|
||||
navigation.navigate('Update');
|
||||
|
|
@ -861,20 +884,20 @@ const SettingsScreen: React.FC = () => {
|
|||
categories={visibleCategories}
|
||||
extraTopPadding={tabletNavOffset}
|
||||
/>
|
||||
|
||||
|
||||
<View style={[
|
||||
styles.tabletContent,
|
||||
{
|
||||
paddingTop: (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48) + tabletNavOffset,
|
||||
}
|
||||
]}>
|
||||
<ScrollView
|
||||
<ScrollView
|
||||
style={styles.tabletScrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.tabletScrollContent}
|
||||
>
|
||||
{renderCategoryContent(selectedCategory)}
|
||||
|
||||
|
||||
{selectedCategory === 'about' && (
|
||||
<>
|
||||
{displayDownloads !== null && (
|
||||
|
|
@ -887,9 +910,9 @@ const SettingsScreen: React.FC = () => {
|
|||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Made with ❤️ by Tapframe and Friends
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -906,7 +929,7 @@ const SettingsScreen: React.FC = () => {
|
|||
style={styles.discordLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
<Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Join Discord
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -958,7 +981,7 @@ const SettingsScreen: React.FC = () => {
|
|||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<ScrollView
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
|
|
@ -1006,7 +1029,7 @@ const SettingsScreen: React.FC = () => {
|
|||
style={styles.discordLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
<Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Join Discord
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -1074,7 +1097,7 @@ const styles = StyleSheet.create({
|
|||
width: '100%',
|
||||
paddingBottom: 90,
|
||||
},
|
||||
|
||||
|
||||
// Tablet-specific styles
|
||||
tabletContainer: {
|
||||
flex: 1,
|
||||
|
|
@ -1128,7 +1151,7 @@ const styles = StyleSheet.create({
|
|||
tabletScrollContent: {
|
||||
paddingBottom: 32,
|
||||
},
|
||||
|
||||
|
||||
// Common card styles
|
||||
cardContainer: {
|
||||
width: '100%',
|
||||
|
|
|
|||
|
|
@ -928,8 +928,16 @@ class CatalogService {
|
|||
|
||||
public subscribeToLibraryUpdates(callback: (items: StreamingContent[]) => void): () => void {
|
||||
this.librarySubscribers.push(callback);
|
||||
// Initial callback with current items
|
||||
this.getLibraryItems().then(items => callback(items));
|
||||
// Defer initial callback to next tick to avoid synchronous state updates during render
|
||||
// This prevents infinite loops when the callback triggers setState in useEffect
|
||||
Promise.resolve().then(() => {
|
||||
this.getLibraryItems().then(items => {
|
||||
// Only call if still subscribed (callback might have been unsubscribed)
|
||||
if (this.librarySubscribers.includes(callback)) {
|
||||
callback(items);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@ class NotificationService {
|
|||
private appStateSubscription: any = null;
|
||||
private lastSyncTime: number = 0;
|
||||
private readonly MIN_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes minimum between syncs
|
||||
|
||||
// Download notification tracking - stores progress value (50) when notified
|
||||
private lastDownloadNotificationTime: Map<string, number> = new Map();
|
||||
|
||||
private constructor() {
|
||||
// Initialize notifications
|
||||
this.configureNotifications();
|
||||
|
|
@ -88,7 +90,7 @@ class NotificationService {
|
|||
|
||||
// Request permissions if needed
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||
|
||||
|
||||
if (existingStatus !== 'granted') {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
|
|
@ -102,7 +104,7 @@ class NotificationService {
|
|||
private async loadSettings(): Promise<void> {
|
||||
try {
|
||||
const storedSettings = await mmkvStorage.getItem(NOTIFICATION_SETTINGS_KEY);
|
||||
|
||||
|
||||
if (storedSettings) {
|
||||
this.settings = { ...DEFAULT_NOTIFICATION_SETTINGS, ...JSON.parse(storedSettings) };
|
||||
}
|
||||
|
|
@ -122,7 +124,7 @@ class NotificationService {
|
|||
private async loadScheduledNotifications(): Promise<void> {
|
||||
try {
|
||||
const storedNotifications = await mmkvStorage.getItem(NOTIFICATION_STORAGE_KEY);
|
||||
|
||||
|
||||
if (storedNotifications) {
|
||||
this.scheduledNotifications = JSON.parse(storedNotifications);
|
||||
}
|
||||
|
|
@ -156,9 +158,9 @@ class NotificationService {
|
|||
|
||||
// Check if notification already exists for this episode
|
||||
const existingNotification = this.scheduledNotifications.find(
|
||||
notification => notification.seriesId === item.seriesId &&
|
||||
notification.season === item.season &&
|
||||
notification.episode === item.episode
|
||||
notification => notification.seriesId === item.seriesId &&
|
||||
notification.season === item.season &&
|
||||
notification.episode === item.episode
|
||||
);
|
||||
if (existingNotification) {
|
||||
return null; // Don't schedule duplicate notifications
|
||||
|
|
@ -166,22 +168,22 @@ class NotificationService {
|
|||
|
||||
const releaseDate = parseISO(item.releaseDate);
|
||||
const now = new Date();
|
||||
|
||||
|
||||
// If release date has already passed, don't schedule
|
||||
if (releaseDate < now) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Calculate notification time (default to 24h before air time)
|
||||
const notificationTime = new Date(releaseDate);
|
||||
notificationTime.setHours(notificationTime.getHours() - this.settings.timeBeforeAiring);
|
||||
|
||||
|
||||
// If notification time has already passed, don't schedule the notification
|
||||
if (notificationTime < now) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Schedule the notification
|
||||
const notificationId = await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
|
|
@ -197,16 +199,16 @@ class NotificationService {
|
|||
type: SchedulableTriggerInputTypes.DATE,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Add to scheduled notifications
|
||||
this.scheduledNotifications.push({
|
||||
...item,
|
||||
notified: false,
|
||||
});
|
||||
|
||||
|
||||
// Save to storage
|
||||
await this.saveScheduledNotifications();
|
||||
|
||||
|
||||
return notificationId;
|
||||
} catch (error) {
|
||||
logger.error('Error scheduling notification:', error);
|
||||
|
|
@ -218,16 +220,16 @@ class NotificationService {
|
|||
if (!this.settings.enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
let scheduledCount = 0;
|
||||
|
||||
|
||||
for (const item of items) {
|
||||
const notificationId = await this.scheduleEpisodeNotification(item);
|
||||
if (notificationId) {
|
||||
scheduledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return scheduledCount;
|
||||
}
|
||||
|
||||
|
|
@ -235,12 +237,12 @@ class NotificationService {
|
|||
try {
|
||||
// Cancel with Expo
|
||||
await Notifications.cancelScheduledNotificationAsync(id);
|
||||
|
||||
|
||||
// Remove from our tracked notifications
|
||||
this.scheduledNotifications = this.scheduledNotifications.filter(
|
||||
notification => notification.id !== id
|
||||
);
|
||||
|
||||
|
||||
// Save updated list
|
||||
await this.saveScheduledNotifications();
|
||||
} catch (error) {
|
||||
|
|
@ -268,10 +270,10 @@ class NotificationService {
|
|||
// Subscribe to library updates from catalog service
|
||||
this.librarySubscription = catalogService.subscribeToLibraryUpdates(async (libraryItems) => {
|
||||
if (!this.settings.enabled) return;
|
||||
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastSync = now - this.lastSyncTime;
|
||||
|
||||
|
||||
// Only sync if enough time has passed since last sync
|
||||
if (timeSinceLastSync >= this.MIN_SYNC_INTERVAL) {
|
||||
// Reduced logging verbosity
|
||||
|
|
@ -309,7 +311,7 @@ class NotificationService {
|
|||
if (nextAppState === 'active' && this.settings.enabled) {
|
||||
const now = Date.now();
|
||||
const timeSinceLastSync = now - this.lastSyncTime;
|
||||
|
||||
|
||||
// Only sync if enough time has passed since last sync
|
||||
if (timeSinceLastSync >= this.MIN_SYNC_INTERVAL) {
|
||||
// App came to foreground, sync notifications
|
||||
|
|
@ -327,6 +329,21 @@ class NotificationService {
|
|||
try {
|
||||
if (!this.settings.enabled) return;
|
||||
if (AppState.currentState === 'active') return;
|
||||
|
||||
// Only notify at 50% progress
|
||||
if (progress < 50) {
|
||||
return; // Skip notifications before 50%
|
||||
}
|
||||
|
||||
// Check if we've already notified at 50% for this download
|
||||
const lastNotifiedProgress = this.lastDownloadNotificationTime.get(title) || 0;
|
||||
if (lastNotifiedProgress >= 50) {
|
||||
return; // Already notified at 50%, don't notify again
|
||||
}
|
||||
|
||||
// Mark that we've notified at 50%
|
||||
this.lastDownloadNotificationTime.set(title, 50);
|
||||
|
||||
const downloadedMb = Math.floor((downloadedBytes || 0) / (1024 * 1024));
|
||||
const totalMb = totalBytes ? Math.floor(totalBytes / (1024 * 1024)) : undefined;
|
||||
const body = `${progress}%` + (totalMb !== undefined ? ` • ${downloadedMb}MB / ${totalMb}MB` : '');
|
||||
|
|
@ -348,6 +365,7 @@ class NotificationService {
|
|||
try {
|
||||
if (!this.settings.enabled) return;
|
||||
if (AppState.currentState === 'active') return;
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Download complete',
|
||||
|
|
@ -356,6 +374,9 @@ class NotificationService {
|
|||
},
|
||||
trigger: null,
|
||||
});
|
||||
|
||||
// Clean up tracking entry after completion to prevent memory leaks
|
||||
this.lastDownloadNotificationTime.delete(title);
|
||||
} catch (error) {
|
||||
logger.error('[NotificationService] notifyDownloadComplete error:', error);
|
||||
}
|
||||
|
|
@ -365,14 +386,14 @@ class NotificationService {
|
|||
private async syncNotificationsForLibrary(libraryItems: any[]): Promise<void> {
|
||||
try {
|
||||
const seriesItems = libraryItems.filter(item => item.type === 'series');
|
||||
|
||||
|
||||
// Limit series to prevent memory overflow during notifications sync
|
||||
const limitedSeries = memoryManager.limitArraySize(seriesItems, 50);
|
||||
|
||||
|
||||
if (limitedSeries.length < seriesItems.length) {
|
||||
logger.warn(`[NotificationService] Limited series sync from ${seriesItems.length} to ${limitedSeries.length} to prevent memory issues`);
|
||||
}
|
||||
|
||||
|
||||
// Process in small batches with memory management
|
||||
await memoryManager.processArrayInBatches(
|
||||
limitedSeries,
|
||||
|
|
@ -386,10 +407,10 @@ class NotificationService {
|
|||
3, // Very small batch size to prevent memory spikes
|
||||
800 // Longer delay to prevent API overwhelming and reduce heating
|
||||
);
|
||||
|
||||
|
||||
// Force cleanup after processing
|
||||
memoryManager.forceGarbageCollection();
|
||||
|
||||
|
||||
// Reduced logging verbosity
|
||||
// logger.log(`[NotificationService] Synced notifications for ${limitedSeries.length} series from library`);
|
||||
} catch (error) {
|
||||
|
|
@ -402,20 +423,20 @@ class NotificationService {
|
|||
try {
|
||||
// Update last sync time at the start
|
||||
this.lastSyncTime = Date.now();
|
||||
|
||||
|
||||
// Reduced logging verbosity
|
||||
// logger.log('[NotificationService] Starting comprehensive background sync');
|
||||
|
||||
|
||||
// Get library items
|
||||
const libraryItems = await catalogService.getLibraryItems();
|
||||
await this.syncNotificationsForLibrary(libraryItems);
|
||||
|
||||
|
||||
// Sync Trakt items if authenticated
|
||||
await this.syncTraktNotifications();
|
||||
|
||||
|
||||
// Clean up old notifications
|
||||
await this.cleanupOldNotifications();
|
||||
|
||||
|
||||
// Reduced logging verbosity
|
||||
// logger.log('[NotificationService] Background sync completed');
|
||||
} catch (error) {
|
||||
|
|
@ -435,7 +456,7 @@ class NotificationService {
|
|||
|
||||
// Reduced logging verbosity
|
||||
// logger.log('[NotificationService] Syncing comprehensive Trakt notifications');
|
||||
|
||||
|
||||
// Get all Trakt data sources (same as calendar screen uses)
|
||||
const [watchlistShows, continueWatching, watchedShows, collectionShows] = await Promise.all([
|
||||
traktService.getWatchlistShows(),
|
||||
|
|
@ -446,7 +467,7 @@ class NotificationService {
|
|||
|
||||
// Combine and deduplicate shows using the same logic as calendar screen
|
||||
const allTraktShows = new Map();
|
||||
|
||||
|
||||
// Add watchlist shows
|
||||
if (watchlistShows) {
|
||||
watchlistShows.forEach((item: any) => {
|
||||
|
|
@ -523,11 +544,11 @@ class NotificationService {
|
|||
// Sync notifications for each Trakt show using memory-efficient batching
|
||||
const traktShows = Array.from(allTraktShows.values());
|
||||
const limitedTraktShows = memoryManager.limitArraySize(traktShows, 30); // Limit Trakt shows
|
||||
|
||||
|
||||
if (limitedTraktShows.length < traktShows.length) {
|
||||
logger.warn(`[NotificationService] Limited Trakt shows sync from ${traktShows.length} to ${limitedTraktShows.length} to prevent memory issues`);
|
||||
}
|
||||
|
||||
|
||||
let syncedCount = 0;
|
||||
await memoryManager.processArrayInBatches(
|
||||
limitedTraktShows,
|
||||
|
|
@ -542,7 +563,7 @@ class NotificationService {
|
|||
2, // Even smaller batch size for Trakt shows
|
||||
1000 // Longer delay to prevent API rate limiting
|
||||
);
|
||||
|
||||
|
||||
// Clear Trakt shows array to free memory
|
||||
memoryManager.clearObjects(traktShows, limitedTraktShows);
|
||||
|
||||
|
|
@ -558,23 +579,23 @@ class NotificationService {
|
|||
try {
|
||||
// Check memory pressure before processing
|
||||
memoryManager.checkMemoryPressure();
|
||||
|
||||
|
||||
// Use the new memory-efficient method to fetch only upcoming episodes
|
||||
const episodeData = await stremioService.getUpcomingEpisodes('series', seriesId, {
|
||||
daysBack: 7, // 1 week back for notifications
|
||||
daysAhead: 28, // 4 weeks ahead for notifications
|
||||
maxEpisodes: 10, // Limit to 10 episodes per series for notifications
|
||||
});
|
||||
|
||||
|
||||
let upcomingEpisodes: any[] = [];
|
||||
let metadata: any = null;
|
||||
|
||||
|
||||
if (episodeData && episodeData.episodes.length > 0) {
|
||||
metadata = {
|
||||
name: episodeData.seriesName,
|
||||
poster: episodeData.poster,
|
||||
};
|
||||
|
||||
|
||||
upcomingEpisodes = episodeData.episodes
|
||||
.filter(video => {
|
||||
if (!video.released) return false;
|
||||
|
|
@ -612,7 +633,7 @@ class NotificationService {
|
|||
// Get upcoming episodes from TMDB
|
||||
const now = new Date();
|
||||
const fourWeeksLater = addDays(now, 28);
|
||||
|
||||
|
||||
// Check current and next seasons for upcoming episodes
|
||||
for (let seasonNum = tmdbDetails.number_of_seasons; seasonNum >= Math.max(1, tmdbDetails.number_of_seasons - 2); seasonNum--) {
|
||||
try {
|
||||
|
|
@ -641,11 +662,11 @@ class NotificationService {
|
|||
logger.warn(`[NotificationService] TMDB fallback failed for ${seriesId}:`, tmdbError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Cancel existing notifications for this series
|
||||
const existingNotifications = await Notifications.getAllScheduledNotificationsAsync();
|
||||
for (const notification of existingNotifications) {
|
||||
|
|
@ -653,17 +674,17 @@ class NotificationService {
|
|||
await Notifications.cancelScheduledNotificationAsync(notification.identifier);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Remove from our tracked notifications
|
||||
this.scheduledNotifications = this.scheduledNotifications.filter(
|
||||
notification => notification.seriesId !== seriesId
|
||||
);
|
||||
|
||||
|
||||
// Schedule new notifications for upcoming episodes with memory limits
|
||||
if (upcomingEpisodes.length > 0 && metadata) {
|
||||
// Limit notifications per series to prevent memory overflow
|
||||
const limitedEpisodes = memoryManager.limitArraySize(upcomingEpisodes, 5);
|
||||
|
||||
|
||||
const notificationItems: NotificationItem[] = limitedEpisodes.map(episode => ({
|
||||
id: episode.id,
|
||||
seriesId,
|
||||
|
|
@ -675,23 +696,23 @@ class NotificationService {
|
|||
notified: false,
|
||||
poster: metadata.poster,
|
||||
}));
|
||||
|
||||
|
||||
const scheduledCount = await this.scheduleMultipleEpisodeNotifications(notificationItems);
|
||||
|
||||
|
||||
// Clear notification items array to free memory
|
||||
memoryManager.clearObjects(notificationItems, upcomingEpisodes);
|
||||
|
||||
|
||||
// Reduced logging verbosity
|
||||
// logger.log(`[NotificationService] Scheduled ${scheduledCount} notifications for ${metadata.name}`);
|
||||
} else {
|
||||
// logger.log(`[NotificationService] No upcoming episodes found for ${metadata?.name || seriesId}`);
|
||||
}
|
||||
|
||||
|
||||
// Clear episode data to free memory
|
||||
if (episodeData) {
|
||||
memoryManager.clearObjects(episodeData.episodes);
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`[NotificationService] Error updating notifications for series ${seriesId}:`, error);
|
||||
} finally {
|
||||
|
|
@ -705,18 +726,18 @@ class NotificationService {
|
|||
try {
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
|
||||
// Remove notifications for episodes that have already aired
|
||||
const validNotifications = this.scheduledNotifications.filter(notification => {
|
||||
const releaseDate = parseISO(notification.releaseDate);
|
||||
return releaseDate > oneDayAgo;
|
||||
});
|
||||
|
||||
|
||||
if (validNotifications.length !== this.scheduledNotifications.length) {
|
||||
this.scheduledNotifications = validNotifications;
|
||||
await this.saveScheduledNotifications();
|
||||
// Reduced logging verbosity
|
||||
// logger.log(`[NotificationService] Cleaned up ${this.scheduledNotifications.length - validNotifications.length} old notifications`);
|
||||
// logger.log(`[NotificationService] Cleaned up ${this.scheduledNotifications.length - validNotifications.length} old notifications`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[NotificationService] Error cleaning up notifications:', error);
|
||||
|
|
@ -734,17 +755,17 @@ class NotificationService {
|
|||
public getNotificationStats(): { total: number; upcoming: number; thisWeek: number } {
|
||||
const now = new Date();
|
||||
const oneWeekLater = addDays(now, 7);
|
||||
|
||||
|
||||
const upcoming = this.scheduledNotifications.filter(notification => {
|
||||
const releaseDate = parseISO(notification.releaseDate);
|
||||
return releaseDate > now;
|
||||
});
|
||||
|
||||
|
||||
const thisWeek = upcoming.filter(notification => {
|
||||
const releaseDate = parseISO(notification.releaseDate);
|
||||
return releaseDate < oneWeekLater;
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
total: this.scheduledNotifications.length,
|
||||
upcoming: upcoming.length,
|
||||
|
|
@ -758,12 +779,12 @@ class NotificationService {
|
|||
clearInterval(this.backgroundSyncInterval);
|
||||
this.backgroundSyncInterval = null;
|
||||
}
|
||||
|
||||
|
||||
if (this.librarySubscription) {
|
||||
this.librarySubscription();
|
||||
this.librarySubscription = null;
|
||||
}
|
||||
|
||||
|
||||
if (this.appStateSubscription) {
|
||||
this.appStateSubscription.remove();
|
||||
this.appStateSubscription = null;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ export interface TraktWatchedItem {
|
|||
};
|
||||
plays: number;
|
||||
last_watched_at: string;
|
||||
seasons?: {
|
||||
number: number;
|
||||
episodes: {
|
||||
number: number;
|
||||
plays: number;
|
||||
last_watched_at: string;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TraktWatchlistItem {
|
||||
|
|
@ -559,7 +567,7 @@ export class TraktService {
|
|||
private refreshToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
private isInitialized: boolean = false;
|
||||
|
||||
|
||||
// Rate limiting - Optimized for real-time scrobbling
|
||||
private lastApiCall: number = 0;
|
||||
private readonly MIN_API_INTERVAL = 500; // Reduced to 500ms for faster updates
|
||||
|
|
@ -575,21 +583,21 @@ export class TraktService {
|
|||
private currentlyWatching: Set<string> = new Set();
|
||||
private lastSyncTimes: Map<string, number> = new Map();
|
||||
private readonly SYNC_DEBOUNCE_MS = 5000; // Reduced from 20000ms to 5000ms for real-time updates
|
||||
|
||||
|
||||
// Debounce for stop calls - Optimized for responsiveness
|
||||
private lastStopCalls: Map<string, number> = new Map();
|
||||
private readonly STOP_DEBOUNCE_MS = 1000; // Reduced from 3000ms to 1000ms for better responsiveness
|
||||
|
||||
|
||||
// Default completion threshold (overridden by user settings)
|
||||
private readonly DEFAULT_COMPLETION_THRESHOLD = 80; // 80%
|
||||
|
||||
private constructor() {
|
||||
// Increased cleanup interval from 5 minutes to 15 minutes to reduce heating
|
||||
setInterval(() => this.cleanupOldStopCalls(), 15 * 60 * 1000); // Clean up every 15 minutes
|
||||
|
||||
|
||||
// Add AppState cleanup to reduce memory pressure
|
||||
AppState.addEventListener('change', this.handleAppStateChange);
|
||||
|
||||
|
||||
// Load user settings
|
||||
this.loadCompletionThreshold();
|
||||
}
|
||||
|
|
@ -611,21 +619,21 @@ export class TraktService {
|
|||
logger.error('[TraktService] Error loading completion threshold:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the current completion threshold (user-configured or default)
|
||||
*/
|
||||
private get completionThreshold(): number {
|
||||
return this._completionThreshold || this.DEFAULT_COMPLETION_THRESHOLD;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the completion threshold
|
||||
*/
|
||||
private set completionThreshold(value: number) {
|
||||
this._completionThreshold = value;
|
||||
}
|
||||
|
||||
|
||||
// Backing field for completion threshold
|
||||
private _completionThreshold: number | null = null;
|
||||
|
||||
|
|
@ -635,7 +643,7 @@ export class TraktService {
|
|||
private cleanupOldStopCalls(): void {
|
||||
const now = Date.now();
|
||||
let cleanupCount = 0;
|
||||
|
||||
|
||||
// Remove stop calls older than the debounce window
|
||||
for (const [key, timestamp] of this.lastStopCalls.entries()) {
|
||||
if (now - timestamp > this.STOP_DEBOUNCE_MS) {
|
||||
|
|
@ -643,7 +651,7 @@ export class TraktService {
|
|||
cleanupCount++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Also clean up old scrobbled timestamps
|
||||
for (const [key, timestamp] of this.scrobbledTimestamps.entries()) {
|
||||
if (now - timestamp > this.SCROBBLE_EXPIRY_MS) {
|
||||
|
|
@ -652,7 +660,7 @@ export class TraktService {
|
|||
cleanupCount++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Clean up old sync times that haven't been updated in a while
|
||||
for (const [key, timestamp] of this.lastSyncTimes.entries()) {
|
||||
if (now - timestamp > 24 * 60 * 60 * 1000) { // 24 hours
|
||||
|
|
@ -660,7 +668,7 @@ export class TraktService {
|
|||
cleanupCount++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Skip verbose cleanup logging to reduce CPU load
|
||||
}
|
||||
|
||||
|
|
@ -703,7 +711,7 @@ export class TraktService {
|
|||
*/
|
||||
public async isAuthenticated(): Promise<boolean> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
|
||||
if (!this.accessToken) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -908,12 +916,12 @@ export class TraktService {
|
|||
const maxRetries = 3;
|
||||
if (retryCount < maxRetries) {
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
const delay = retryAfter
|
||||
? parseInt(retryAfter) * 1000
|
||||
const delay = retryAfter
|
||||
? parseInt(retryAfter) * 1000
|
||||
: Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s
|
||||
|
||||
|
||||
logger.log(`[TraktService] Rate limited (429), retrying in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.apiRequest<T>(endpoint, method, body, retryCount + 1);
|
||||
} else {
|
||||
|
|
@ -926,13 +934,13 @@ export class TraktService {
|
|||
if (response.status === 409) {
|
||||
const errorText = await response.text();
|
||||
logger.log(`[TraktService] Content already scrobbled (409) for ${endpoint}:`, errorText);
|
||||
|
||||
|
||||
// Parse the error response to get expiry info
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
if (errorData.watched_at && errorData.expires_at) {
|
||||
logger.log(`[TraktService] Item was already watched at ${errorData.watched_at}, expires at ${errorData.expires_at}`);
|
||||
|
||||
|
||||
// If this is a scrobble endpoint, mark the item as already scrobbled
|
||||
if (endpoint.includes('/scrobble/') && body) {
|
||||
const contentKey = this.getContentKeyFromPayload(body);
|
||||
|
|
@ -942,7 +950,7 @@ export class TraktService {
|
|||
logger.log(`[TraktService] Marked content as already scrobbled: ${contentKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Return a success-like response for 409 conflicts
|
||||
// This prevents the error from bubbling up and causing retry loops
|
||||
return {
|
||||
|
|
@ -955,7 +963,7 @@ export class TraktService {
|
|||
} catch (parseError) {
|
||||
logger.warn(`[TraktService] Could not parse 409 error response: ${parseError}`);
|
||||
}
|
||||
|
||||
|
||||
// Return a graceful response even if we can't parse the error
|
||||
return {
|
||||
id: 0,
|
||||
|
|
@ -967,7 +975,7 @@ export class TraktService {
|
|||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
|
||||
// Enhanced error logging for debugging
|
||||
logger.error(`[TraktService] API Error ${response.status} for ${endpoint}:`, {
|
||||
status: response.status,
|
||||
|
|
@ -976,14 +984,14 @@ export class TraktService {
|
|||
requestBody: body ? JSON.stringify(body, null, 2) : 'No body',
|
||||
headers: Object.fromEntries(response.headers.entries())
|
||||
});
|
||||
|
||||
|
||||
// Handle 404 errors more gracefully - they might indicate content not found in Trakt
|
||||
if (response.status === 404) {
|
||||
logger.warn(`[TraktService] Content not found in Trakt database (404) for ${endpoint}. This might indicate:`);
|
||||
logger.warn(`[TraktService] 1. Invalid IMDb ID: ${body?.movie?.ids?.imdb || body?.show?.ids?.imdb || 'N/A'}`);
|
||||
logger.warn(`[TraktService] 2. Content not in Trakt database: ${body?.movie?.title || body?.show?.title || 'N/A'}`);
|
||||
logger.warn(`[TraktService] 3. Authentication issues with token`);
|
||||
|
||||
|
||||
// Return a graceful response for 404s instead of throwing
|
||||
return {
|
||||
id: 0,
|
||||
|
|
@ -992,7 +1000,7 @@ export class TraktService {
|
|||
error: 'Content not found in Trakt database'
|
||||
} as any;
|
||||
}
|
||||
|
||||
|
||||
throw new Error(`API request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
|
|
@ -1016,7 +1024,7 @@ export class TraktService {
|
|||
if (endpoint.includes('/scrobble/')) {
|
||||
// API success logging removed
|
||||
}
|
||||
|
||||
|
||||
return responseData;
|
||||
}
|
||||
|
||||
|
|
@ -1041,7 +1049,7 @@ export class TraktService {
|
|||
*/
|
||||
private isRecentlyScrobbled(contentData: TraktContentData): boolean {
|
||||
const contentKey = this.getWatchingKey(contentData);
|
||||
|
||||
|
||||
// Clean up expired entries
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of this.scrobbledTimestamps.entries()) {
|
||||
|
|
@ -1050,7 +1058,7 @@ export class TraktService {
|
|||
this.scrobbledTimestamps.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return this.scrobbledItems.has(contentKey);
|
||||
}
|
||||
|
||||
|
|
@ -1181,7 +1189,7 @@ export class TraktService {
|
|||
if (!images || !images.poster || images.poster.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Get the first poster and add https prefix
|
||||
const posterPath = images.poster[0];
|
||||
return posterPath.startsWith('http') ? posterPath : `https://${posterPath}`;
|
||||
|
|
@ -1194,7 +1202,7 @@ export class TraktService {
|
|||
if (!images || !images.fanart || images.fanart.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Get the first fanart and add https prefix
|
||||
const fanartPath = images.fanart[0];
|
||||
return fanartPath.startsWith('http') ? fanartPath : `https://${fanartPath}`;
|
||||
|
|
@ -1291,9 +1299,9 @@ export class TraktService {
|
|||
* Add a show episode to user's watched history
|
||||
*/
|
||||
public async addToWatchedEpisodes(
|
||||
imdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
imdbId: string,
|
||||
season: number,
|
||||
episode: number,
|
||||
watchedAt: Date = new Date()
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
|
|
@ -1355,8 +1363,8 @@ export class TraktService {
|
|||
* Check if a show episode is in user's watched history
|
||||
*/
|
||||
public async isEpisodeWatched(
|
||||
imdbId: string,
|
||||
season: number,
|
||||
imdbId: string,
|
||||
season: number,
|
||||
episode: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
|
|
@ -1478,19 +1486,19 @@ export class TraktService {
|
|||
*/
|
||||
private validateContentData(contentData: TraktContentData): { isValid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
|
||||
if (!contentData.type || !['movie', 'episode'].includes(contentData.type)) {
|
||||
errors.push('Invalid content type');
|
||||
}
|
||||
|
||||
|
||||
if (!contentData.title || contentData.title.trim() === '') {
|
||||
errors.push('Missing or empty title');
|
||||
}
|
||||
|
||||
|
||||
if (!contentData.imdbId || contentData.imdbId.trim() === '') {
|
||||
errors.push('Missing or empty IMDb ID');
|
||||
}
|
||||
|
||||
|
||||
if (contentData.type === 'episode') {
|
||||
if (!contentData.season || contentData.season < 1) {
|
||||
errors.push('Invalid season number');
|
||||
|
|
@ -1505,7 +1513,7 @@ export class TraktService {
|
|||
errors.push('Invalid show year');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
|
|
@ -1547,7 +1555,7 @@ export class TraktService {
|
|||
const imdbIdWithPrefix = contentData.imdbId.startsWith('tt')
|
||||
? contentData.imdbId
|
||||
: `tt${contentData.imdbId}`;
|
||||
|
||||
|
||||
const payload = {
|
||||
movie: {
|
||||
title: contentData.title,
|
||||
|
|
@ -1558,7 +1566,7 @@ export class TraktService {
|
|||
},
|
||||
progress: clampedProgress
|
||||
};
|
||||
|
||||
|
||||
logger.log('[TraktService] Movie payload built:', payload);
|
||||
return payload;
|
||||
} else if (contentData.type === 'episode') {
|
||||
|
|
@ -1598,11 +1606,11 @@ export class TraktService {
|
|||
const episodeImdbWithPrefix = contentData.imdbId.startsWith('tt')
|
||||
? contentData.imdbId
|
||||
: `tt${contentData.imdbId}`;
|
||||
|
||||
|
||||
if (!payload.episode.ids) {
|
||||
payload.episode.ids = {};
|
||||
}
|
||||
|
||||
|
||||
payload.episode.ids.imdb = episodeImdbWithPrefix;
|
||||
}
|
||||
|
||||
|
|
@ -1635,7 +1643,7 @@ export class TraktService {
|
|||
} catch (error) {
|
||||
logger.error('[TraktService] Queue request failed:', error);
|
||||
}
|
||||
|
||||
|
||||
// Wait minimum interval before next request
|
||||
if (this.requestQueue.length > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, this.MIN_API_INTERVAL));
|
||||
|
|
@ -1659,7 +1667,7 @@ export class TraktService {
|
|||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Start processing if not already running
|
||||
this.processQueue();
|
||||
});
|
||||
|
|
@ -1702,7 +1710,7 @@ export class TraktService {
|
|||
}
|
||||
|
||||
// Debug log removed to reduce terminal noise
|
||||
|
||||
|
||||
// Only start if not already watching this content
|
||||
if (this.currentlyWatching.has(watchingKey)) {
|
||||
logger.log(`[TraktService] Already watching this content, skipping start: ${contentData.title}`);
|
||||
|
|
@ -1736,10 +1744,10 @@ export class TraktService {
|
|||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
const watchingKey = this.getWatchingKey(contentData);
|
||||
const lastSync = this.lastSyncTimes.get(watchingKey) || 0;
|
||||
|
||||
|
||||
// IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 100ms)
|
||||
if (!force && (now - lastSync) < 100) {
|
||||
return true; // Skip this sync, but return success
|
||||
|
|
@ -1763,7 +1771,7 @@ export class TraktService {
|
|||
logger.warn('[TraktService] Rate limited, will retry later');
|
||||
return true; // Return success to avoid error spam
|
||||
}
|
||||
|
||||
|
||||
logger.error('[TraktService] Failed to update progress:', error);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1794,7 +1802,7 @@ export class TraktService {
|
|||
// Use pause if below user threshold, stop only when ready to scrobble
|
||||
const useStop = progress >= this.completionThreshold;
|
||||
const result = await this.queueRequest(async () => {
|
||||
return useStop
|
||||
return useStop
|
||||
? await this.stopWatching(contentData, progress)
|
||||
: await this.pauseWatching(contentData, progress);
|
||||
});
|
||||
|
|
@ -1923,8 +1931,8 @@ export class TraktService {
|
|||
* @deprecated Use scrobbleStart, scrobblePause, scrobbleStop instead
|
||||
*/
|
||||
public async syncProgressToTrakt(
|
||||
contentData: TraktContentData,
|
||||
progress: number,
|
||||
contentData: TraktContentData,
|
||||
progress: number,
|
||||
force: boolean = false
|
||||
): Promise<boolean> {
|
||||
// For backward compatibility, treat as a pause update
|
||||
|
|
@ -1937,11 +1945,11 @@ export class TraktService {
|
|||
public async debugTraktConnection(): Promise<any> {
|
||||
try {
|
||||
logger.log('[TraktService] Testing Trakt API connection...');
|
||||
|
||||
|
||||
// Test basic API access
|
||||
const userResponse = await this.apiRequest('/users/me', 'GET');
|
||||
logger.log('[TraktService] User info:', userResponse);
|
||||
|
||||
|
||||
// Test a minimal scrobble start to verify API works
|
||||
const testPayload = {
|
||||
movie: {
|
||||
|
|
@ -1953,19 +1961,19 @@ export class TraktService {
|
|||
},
|
||||
progress: 1.0
|
||||
};
|
||||
|
||||
|
||||
logger.log('[TraktService] Testing scrobble/start endpoint with test payload...');
|
||||
const scrobbleResponse = await this.apiRequest('/scrobble/start', 'POST', testPayload);
|
||||
logger.log('[TraktService] Scrobble test response:', scrobbleResponse);
|
||||
|
||||
return {
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
user: userResponse,
|
||||
scrobbleTest: scrobbleResponse
|
||||
user: userResponse,
|
||||
scrobbleTest: scrobbleResponse
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Debug connection failed:', error);
|
||||
return {
|
||||
return {
|
||||
authenticated: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
|
|
@ -1984,7 +1992,7 @@ export class TraktService {
|
|||
|
||||
const progress = await this.getPlaybackProgress();
|
||||
// Progress logging removed
|
||||
|
||||
|
||||
progress.forEach((item, index) => {
|
||||
if (item.type === 'movie' && item.movie) {
|
||||
// Movie progress logging removed
|
||||
|
|
@ -1992,7 +2000,7 @@ export class TraktService {
|
|||
// Episode progress logging removed
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (progress.length === 0) {
|
||||
// No progress logging removed
|
||||
}
|
||||
|
|
@ -2022,16 +2030,16 @@ export class TraktService {
|
|||
public async deletePlaybackForContent(imdbId: string, type: 'movie' | 'series', season?: number, episode?: number): Promise<boolean> {
|
||||
try {
|
||||
logger.log(`🔍 [TraktService] deletePlaybackForContent called for ${type}:${imdbId} (season:${season}, episode:${episode})`);
|
||||
|
||||
|
||||
if (!this.accessToken) {
|
||||
logger.log(`❌ [TraktService] No access token - cannot delete playback`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
logger.log(`🔍 [TraktService] Fetching current playback progress...`);
|
||||
const progressItems = await this.getPlaybackProgress();
|
||||
logger.log(`📊 [TraktService] Found ${progressItems.length} playback items`);
|
||||
|
||||
|
||||
const target = progressItems.find(item => {
|
||||
if (type === 'movie' && item.type === 'movie' && item.movie?.ids.imdb === imdbId) {
|
||||
logger.log(`🎯 [TraktService] Found matching movie: ${item.movie?.title}`);
|
||||
|
|
@ -2050,7 +2058,7 @@ export class TraktService {
|
|||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
if (target) {
|
||||
logger.log(`🗑️ [TraktService] Deleting playback item with ID: ${target.id}`);
|
||||
const result = await this.deletePlaybackItem(target.id);
|
||||
|
|
@ -2475,7 +2483,7 @@ export class TraktService {
|
|||
// Ensure IMDb ID includes the 'tt' prefix
|
||||
const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
|
||||
|
||||
const payload = type === 'movie'
|
||||
const payload = type === 'movie'
|
||||
? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] }
|
||||
: { shows: [{ ids: { imdb: imdbIdWithPrefix } }] };
|
||||
|
||||
|
|
@ -2500,7 +2508,7 @@ export class TraktService {
|
|||
// Ensure IMDb ID includes the 'tt' prefix
|
||||
const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
|
||||
|
||||
const payload = type === 'movie'
|
||||
const payload = type === 'movie'
|
||||
? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] }
|
||||
: { shows: [{ ids: { imdb: imdbIdWithPrefix } }] };
|
||||
|
||||
|
|
@ -2525,7 +2533,7 @@ export class TraktService {
|
|||
// Ensure IMDb ID includes the 'tt' prefix
|
||||
const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
|
||||
|
||||
const payload = type === 'movie'
|
||||
const payload = type === 'movie'
|
||||
? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] }
|
||||
: { shows: [{ ids: { imdb: imdbIdWithPrefix } }] };
|
||||
|
||||
|
|
@ -2550,7 +2558,7 @@ export class TraktService {
|
|||
// Ensure IMDb ID includes the 'tt' prefix
|
||||
const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
|
||||
|
||||
const payload = type === 'movie'
|
||||
const payload = type === 'movie'
|
||||
? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] }
|
||||
: { shows: [{ ids: { imdb: imdbIdWithPrefix } }] };
|
||||
|
||||
|
|
@ -2575,13 +2583,13 @@ export class TraktService {
|
|||
// Ensure IMDb ID includes the 'tt' prefix
|
||||
const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
|
||||
|
||||
const watchlistItems = type === 'movie'
|
||||
const watchlistItems = type === 'movie'
|
||||
? await this.getWatchlistMovies()
|
||||
: await this.getWatchlistShows();
|
||||
|
||||
return watchlistItems.some(item => {
|
||||
const itemImdbId = type === 'movie'
|
||||
? item.movie?.ids?.imdb
|
||||
const itemImdbId = type === 'movie'
|
||||
? item.movie?.ids?.imdb
|
||||
: item.show?.ids?.imdb;
|
||||
return itemImdbId === imdbIdWithPrefix;
|
||||
});
|
||||
|
|
@ -2603,13 +2611,13 @@ export class TraktService {
|
|||
// Ensure IMDb ID includes the 'tt' prefix
|
||||
const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
|
||||
|
||||
const collectionItems = type === 'movie'
|
||||
const collectionItems = type === 'movie'
|
||||
? await this.getCollectionMovies()
|
||||
: await this.getCollectionShows();
|
||||
|
||||
return collectionItems.some(item => {
|
||||
const itemImdbId = type === 'movie'
|
||||
? item.movie?.ids?.imdb
|
||||
const itemImdbId = type === 'movie'
|
||||
? item.movie?.ids?.imdb
|
||||
: item.show?.ids?.imdb;
|
||||
return itemImdbId === imdbIdWithPrefix;
|
||||
});
|
||||
|
|
@ -2630,7 +2638,7 @@ export class TraktService {
|
|||
this.currentlyWatching.clear();
|
||||
this.lastSyncTimes.clear();
|
||||
this.lastStopCalls.clear();
|
||||
|
||||
|
||||
// Clear request queue to prevent background processing
|
||||
this.requestQueue = [];
|
||||
this.isProcessingQueue = false;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Single source of truth for the app version displayed in Settings
|
||||
// Update this when bumping app version
|
||||
|
||||
export const APP_VERSION = '1.2.9';
|
||||
export const APP_VERSION = '1.2.10';
|
||||
|
||||
export function getDisplayedAppVersion(): string {
|
||||
return APP_VERSION;
|
||||
|
|
|
|||
Loading…
Reference in a new issue