Merge branch 'main' into android-nav-bar-fix

This commit is contained in:
Nayif 2025-11-26 23:26:15 +05:30 committed by GitHub
commit 14980f2bfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 3630 additions and 2167 deletions

74
App.tsx
View file

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

View file

@ -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', '+')}"

View file

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

View file

@ -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"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 Weeks 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
View file

@ -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",

View file

@ -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",

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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+FE70U+FEFF
// Hebrew: U+0590U+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;

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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%',

View file

@ -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 () => {

View file

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

View file

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

View file

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