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

48
App.tsx
View file

@ -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,11 +83,12 @@ 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 {
@ -101,6 +103,16 @@ 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 () => {
@ -120,6 +132,15 @@ const ThemedApp = () => {
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
@ -154,6 +175,23 @@ const ThemedApp = () => {
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';
@ -162,6 +200,7 @@ const ThemedApp = () => {
<AccountProvider>
<PaperProvider theme={customDarkTheme}>
<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

@ -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,11 +198,11 @@ 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
);
@ -225,11 +226,21 @@ 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;
@ -253,11 +264,21 @@ 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;
@ -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
@ -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]

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

@ -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({
@ -650,7 +761,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
useFocusEffect(
useCallback(() => {
loadContinueWatching(true);
return () => {};
return () => { };
}, [loadContinueWatching])
);
@ -798,7 +909,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
{
label: 'Cancel',
style: { color: '#888' },
onPress: () => {},
onPress: () => { },
},
{
label: 'Remove',
@ -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>
);
@ -1055,7 +1166,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
]}
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(() => {
@ -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
// Get raw episodes (limit to 60 to be safe for performance but allow grouping)
const rawEpisodes = memoryManager.limitArraySize(thisWeekSection.data, 60);
return episodes.map(episode => ({
...episode,
isReleased: episode.releaseDate ? isBefore(parseISO(episode.releaseDate), new Date()) : false,
}));
// 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}`;
@ -175,7 +235,7 @@ export const ThisWeekSection = React.memo(() => {
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
@ -187,106 +247,93 @@ export const ThisWeekSection = React.memo(() => {
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}
/>
<FastImage
source={{
uri: imageUrl || undefined,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable
}}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
/>
{/* Enhanced gradient overlay */}
<LinearGradient
<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,
{
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>
@ -300,13 +347,13 @@ 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,
{
@ -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;
// 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);
}
}
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);
}
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(() => {

View file

@ -116,8 +116,8 @@ const AndroidVideoPlayer: React.FC = () => {
// Check if the stream is HLS (m3u8 playlist)
const isHlsStream = (url: string) => {
return url.includes('.m3u8') || url.includes('m3u8') ||
url.includes('hls') || url.includes('playlist') ||
(currentVideoType && currentVideoType.toLowerCase() === 'm3u8');
url.includes('hls') || url.includes('playlist') ||
(currentVideoType && currentVideoType.toLowerCase() === 'm3u8');
};
// HLS-specific headers for better ExoPlayer compatibility
@ -226,8 +226,8 @@ const AndroidVideoPlayer: React.FC = () => {
const [isBackdropLoaded, setIsBackdropLoaded] = useState(false);
const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current;
const [isBuffering, setIsBuffering] = useState(false);
const [rnVideoAudioTracks, setRnVideoAudioTracks] = useState<Array<{id: number, name: string, language?: string}>>([]);
const [rnVideoTextTracks, setRnVideoTextTracks] = useState<Array<{id: number, name: string, language?: string}>>([]);
const [rnVideoAudioTracks, setRnVideoAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
const [rnVideoTextTracks, setRnVideoTextTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
// Speed boost state for hold-to-speed-up feature
const [isSpeedBoosted, setIsSpeedBoosted] = useState(false);
@ -323,8 +323,8 @@ const AndroidVideoPlayer: React.FC = () => {
// VLC track state - will be managed by VlcVideoPlayer component
const [vlcAudioTracks, setVlcAudioTracks] = useState<Array<{id: number, name: string, language?: string}>>([]);
const [vlcSubtitleTracks, setVlcSubtitleTracks] = useState<Array<{id: number, name: string, language?: string}>>([]);
const [vlcAudioTracks, setVlcAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
const [vlcSubtitleTracks, setVlcSubtitleTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
const [vlcSelectedAudioTrack, setVlcSelectedAudioTrack] = useState<number | undefined>(undefined);
const [vlcSelectedSubtitleTrack, setVlcSelectedSubtitleTrack] = useState<number | undefined>(undefined);
const [vlcRestoreTime, setVlcRestoreTime] = useState<number | undefined>(undefined); // Time to restore after remount
@ -357,8 +357,8 @@ const AndroidVideoPlayer: React.FC = () => {
useVLC
? (vlcSelectedAudioTrack ?? null)
: (selectedAudioTrack?.type === SelectedTrackType.INDEX && selectedAudioTrack.value !== undefined
? Number(selectedAudioTrack.value)
: null),
? Number(selectedAudioTrack.value)
: null),
[useVLC, vlcSelectedAudioTrack, selectedAudioTrack]
);
@ -629,7 +629,7 @@ const AndroidVideoPlayer: React.FC = () => {
const shouldLoadMetadata = Boolean(id && type);
const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) });
const { settings: appSettings } = useSettings();
const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => {} };
const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => { } };
// Logo animation values
const logoScaleAnim = useRef(new Animated.Value(0.8)).current;
@ -823,7 +823,7 @@ const AndroidVideoPlayer: React.FC = () => {
return () => {
if (isSpeedBoosted) {
// best-effort restoration on unmount
try { setPlaybackSpeed(originalSpeed); } catch {}
try { setPlaybackSpeed(originalSpeed); } catch { }
}
};
}, [isSpeedBoosted, originalSpeed]);
@ -916,7 +916,7 @@ const AndroidVideoPlayer: React.FC = () => {
setVlcKey(`vlc-focus-${Date.now()}`);
}, 100);
}
return () => {};
return () => { };
}, [useVLC])
);
@ -1496,101 +1496,101 @@ const AndroidVideoPlayer: React.FC = () => {
}
}
// Handle text tracks
if (data.textTracks && data.textTracks.length > 0) {
if (DEBUG_MODE) {
logger.log(`[AndroidVideoPlayer] Raw text tracks data:`, data.textTracks);
data.textTracks.forEach((track: any, idx: number) => {
logger.log(`[AndroidVideoPlayer] Text Track ${idx} raw data:`, {
index: track.index,
title: track.title,
language: track.language,
type: track.type,
name: track.name,
label: track.label,
allKeys: Object.keys(track),
fullTrackObject: track
});
// Handle text tracks
if (data.textTracks && data.textTracks.length > 0) {
if (DEBUG_MODE) {
logger.log(`[AndroidVideoPlayer] Raw text tracks data:`, data.textTracks);
data.textTracks.forEach((track: any, idx: number) => {
logger.log(`[AndroidVideoPlayer] Text Track ${idx} raw data:`, {
index: track.index,
title: track.title,
language: track.language,
type: track.type,
name: track.name,
label: track.label,
allKeys: Object.keys(track),
fullTrackObject: track
});
}
const formattedTextTracks = data.textTracks.map((track: any, index: number) => {
const trackIndex = track.index !== undefined ? track.index : index;
// Build comprehensive track name from available fields
let trackName = '';
const parts = [];
// Add language if available (try multiple possible fields)
let language = track.language || track.lang || track.languageCode;
// If no language field, try to extract from track name (e.g., "[Russian]", "[English]")
if ((!language || language === 'Unknown' || language === 'und' || language === '') && track.title) {
const languageMatch = track.title.match(/\[([^\]]+)\]/);
if (languageMatch && languageMatch[1]) {
language = languageMatch[1].trim();
}
}
if (language && language !== 'Unknown' && language !== 'und' && language !== '') {
parts.push(language.toUpperCase());
}
// Add codec information if available (try multiple possible fields)
const codec = track.codec || track.format;
if (codec && codec !== 'Unknown' && codec !== 'und') {
parts.push(codec.toUpperCase());
}
// Add title if available and not generic
let title = track.title || track.name || track.label;
if (title && !title.match(/^(Subtitle|Track)\s*\d*$/i) && title !== 'Unknown') {
// Clean up title by removing language brackets and trailing punctuation
title = title.replace(/\s*\[[^\]]+\]\s*[-–—]*\s*$/, '').trim();
if (title && title !== 'Unknown') {
parts.push(title);
}
}
// Combine parts or fallback to generic name
if (parts.length > 0) {
trackName = parts.join(' • ');
} else {
// For simple track names like "Track 1", "Subtitle 1", etc., use them as-is
const simpleName = track.title || track.name || track.label;
if (simpleName && simpleName.match(/^(Track|Subtitle)\s*\d*$/i)) {
trackName = simpleName;
} else {
// Try to extract any meaningful info from the track object
const meaningfulFields: string[] = [];
Object.keys(track).forEach(key => {
const value = track[key];
if (value && typeof value === 'string' && value !== 'Unknown' && value !== 'und' && value.length > 1) {
meaningfulFields.push(`${key}: ${value}`);
}
});
if (meaningfulFields.length > 0) {
trackName = meaningfulFields.join(' • ');
} else {
trackName = `Subtitle ${index + 1}`;
}
}
}
return {
id: trackIndex, // Use the actual track index from react-native-video
name: trackName,
language: language,
};
});
setRnVideoTextTracks(formattedTextTracks);
if (DEBUG_MODE) {
logger.log(`[AndroidVideoPlayer] Formatted text tracks:`, formattedTextTracks);
}
}
const formattedTextTracks = data.textTracks.map((track: any, index: number) => {
const trackIndex = track.index !== undefined ? track.index : index;
// Build comprehensive track name from available fields
let trackName = '';
const parts = [];
// Add language if available (try multiple possible fields)
let language = track.language || track.lang || track.languageCode;
// If no language field, try to extract from track name (e.g., "[Russian]", "[English]")
if ((!language || language === 'Unknown' || language === 'und' || language === '') && track.title) {
const languageMatch = track.title.match(/\[([^\]]+)\]/);
if (languageMatch && languageMatch[1]) {
language = languageMatch[1].trim();
}
}
if (language && language !== 'Unknown' && language !== 'und' && language !== '') {
parts.push(language.toUpperCase());
}
// Add codec information if available (try multiple possible fields)
const codec = track.codec || track.format;
if (codec && codec !== 'Unknown' && codec !== 'und') {
parts.push(codec.toUpperCase());
}
// Add title if available and not generic
let title = track.title || track.name || track.label;
if (title && !title.match(/^(Subtitle|Track)\s*\d*$/i) && title !== 'Unknown') {
// Clean up title by removing language brackets and trailing punctuation
title = title.replace(/\s*\[[^\]]+\]\s*[-–—]*\s*$/, '').trim();
if (title && title !== 'Unknown') {
parts.push(title);
}
}
// Combine parts or fallback to generic name
if (parts.length > 0) {
trackName = parts.join(' • ');
} else {
// For simple track names like "Track 1", "Subtitle 1", etc., use them as-is
const simpleName = track.title || track.name || track.label;
if (simpleName && simpleName.match(/^(Track|Subtitle)\s*\d*$/i)) {
trackName = simpleName;
} else {
// Try to extract any meaningful info from the track object
const meaningfulFields: string[] = [];
Object.keys(track).forEach(key => {
const value = track[key];
if (value && typeof value === 'string' && value !== 'Unknown' && value !== 'und' && value.length > 1) {
meaningfulFields.push(`${key}: ${value}`);
}
});
if (meaningfulFields.length > 0) {
trackName = meaningfulFields.join(' • ');
} else {
trackName = `Subtitle ${index + 1}`;
}
}
}
return {
id: trackIndex, // Use the actual track index from react-native-video
name: trackName,
language: language,
};
});
setRnVideoTextTracks(formattedTextTracks);
if (DEBUG_MODE) {
logger.log(`[AndroidVideoPlayer] Formatted text tracks:`, formattedTextTracks);
}
}
setIsVideoLoaded(true);
setIsPlayerReady(true);
@ -1773,10 +1773,10 @@ const AndroidVideoPlayer: React.FC = () => {
const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true);
if (!isTablet) {
setTimeout(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
}, 50);
} else {
ScreenOrientation.unlockAsync().catch(() => {});
ScreenOrientation.unlockAsync().catch(() => { });
}
disableImmersiveMode();
@ -1793,10 +1793,10 @@ const AndroidVideoPlayer: React.FC = () => {
const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true);
if (!isTablet) {
setTimeout(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
}, 50);
} else {
ScreenOrientation.unlockAsync().catch(() => {});
ScreenOrientation.unlockAsync().catch(() => { });
}
disableImmersiveMode();
@ -1875,14 +1875,14 @@ const AndroidVideoPlayer: React.FC = () => {
// Check for codec errors that should trigger VLC fallback
const errorString = JSON.stringify(error || {});
const isCodecError = errorString.includes('MediaCodecVideoRenderer error') ||
errorString.includes('MediaCodecAudioRenderer error') ||
errorString.includes('NO_EXCEEDS_CAPABILITIES') ||
errorString.includes('NO_UNSUPPORTED_TYPE') ||
errorString.includes('Decoder failed') ||
errorString.includes('video/hevc') ||
errorString.includes('audio/eac3') ||
errorString.includes('ERROR_CODE_DECODING_FAILED') ||
errorString.includes('ERROR_CODE_DECODER_INIT_FAILED');
errorString.includes('MediaCodecAudioRenderer error') ||
errorString.includes('NO_EXCEEDS_CAPABILITIES') ||
errorString.includes('NO_UNSUPPORTED_TYPE') ||
errorString.includes('Decoder failed') ||
errorString.includes('video/hevc') ||
errorString.includes('audio/eac3') ||
errorString.includes('ERROR_CODE_DECODING_FAILED') ||
errorString.includes('ERROR_CODE_DECODER_INIT_FAILED');
// If it's a codec error and we're not already using VLC, silently switch to VLC
if (isCodecError && !useVLC && !vlcFallbackAttemptedRef.current) {
@ -1937,7 +1937,7 @@ const AndroidVideoPlayer: React.FC = () => {
// Check if this might be an HLS stream that needs different handling
const mightBeHls = currentStreamUrl.includes('.m3u8') || currentStreamUrl.includes('playlist') ||
currentStreamUrl.includes('hls') || currentStreamUrl.includes('stream');
currentStreamUrl.includes('hls') || currentStreamUrl.includes('stream');
if (mightBeHls) {
logger.warn(`[AndroidVideoPlayer] HLS stream format not recognized. Retrying with explicit HLS type and headers`);
@ -1982,9 +1982,9 @@ const AndroidVideoPlayer: React.FC = () => {
// Handle HLS manifest parsing errors (when content isn't actually M3U8)
const isManifestParseError = error?.error?.errorCode === '23002' ||
error?.errorCode === '23002' ||
(error?.error?.errorString &&
error.error.errorString.includes('ERROR_CODE_PARSING_MANIFEST_MALFORMED'));
error?.errorCode === '23002' ||
(error?.error?.errorString &&
error.error.errorString.includes('ERROR_CODE_PARSING_MANIFEST_MALFORMED'));
if (isManifestParseError && retryAttemptRef.current < 2) {
retryAttemptRef.current = 2;
@ -2010,9 +2010,9 @@ const AndroidVideoPlayer: React.FC = () => {
// Check for specific AVFoundation server configuration errors (iOS)
const isServerConfigError = error?.error?.code === -11850 ||
error?.code === -11850 ||
(error?.error?.localizedDescription &&
error.error.localizedDescription.includes('server is not correctly configured'));
error?.code === -11850 ||
(error?.error?.localizedDescription &&
error.error.localizedDescription.includes('server is not correctly configured'));
// Format error details for user display
let errorMessage = 'An unknown error occurred';
@ -2334,9 +2334,9 @@ const AndroidVideoPlayer: React.FC = () => {
try {
const merged = { ...(saved || {}), subtitleSize: migrated };
await storageService.saveSubtitleSettings(merged);
} catch {}
} catch { }
}
try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch {}
try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch { }
return;
}
// If no saved settings, use responsive default
@ -2518,7 +2518,7 @@ const AndroidVideoPlayer: React.FC = () => {
const textNow = cueNow ? cueNow.text : '';
setCurrentSubtitle(textNow);
logger.log('[AndroidVideoPlayer] currentSubtitle set immediately after apply (Android)');
} catch {}
} catch { }
}
} catch (error) {
logger.error('[AndroidVideoPlayer] Error loading Wyzie subtitle:', error);
@ -2834,33 +2834,28 @@ const AndroidVideoPlayer: React.FC = () => {
// Extract formatted segments from current cue
if (currentCue?.formattedSegments) {
// Split by newlines to get per-line segments
const lines = (currentCue.text || '').split(/\r?\n/);
const segmentsPerLine: SubtitleSegment[][] = [];
let segmentIndex = 0;
let currentLine: SubtitleSegment[] = [];
for (const line of lines) {
const lineSegments: SubtitleSegment[] = [];
const words = line.split(/(\s+)/);
for (const word of words) {
if (word.trim()) {
if (segmentIndex < currentCue.formattedSegments.length) {
lineSegments.push(currentCue.formattedSegments[segmentIndex]);
segmentIndex++;
} else {
// Fallback if segment count doesn't match
lineSegments.push({ text: word });
}
currentCue.formattedSegments.forEach(seg => {
const parts = seg.text.split(/\r?\n/);
parts.forEach((part, index) => {
if (index > 0) {
// New line found
segmentsPerLine.push(currentLine);
currentLine = [];
}
}
if (part.length > 0) {
currentLine.push({ ...seg, text: part });
}
});
});
if (lineSegments.length > 0) {
segmentsPerLine.push(lineSegments);
}
if (currentLine.length > 0) {
segmentsPerLine.push(currentLine);
}
setCurrentFormattedSegments(segmentsPerLine.length > 0 ? segmentsPerLine : []);
setCurrentFormattedSegments(segmentsPerLine);
} else {
setCurrentFormattedSegments([]);
}
@ -2914,8 +2909,8 @@ const AndroidVideoPlayer: React.FC = () => {
if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier);
if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec);
}
} catch {} finally {
try { setSubtitleSettingsLoaded(true); } catch {}
} catch { } finally {
try { setSubtitleSettingsLoaded(true); } catch { }
}
})();
}, []);
@ -3283,14 +3278,14 @@ const AndroidVideoPlayer: React.FC = () => {
/>
) : (
<Video
ref={videoRef}
style={[styles.video, customVideoStyles]}
source={{
uri: currentStreamUrl,
headers: headers || getStreamHeaders(),
type: isHlsStream(currentStreamUrl) ? 'm3u8' : (currentVideoType as any)
}}
paused={paused}
ref={videoRef}
style={[styles.video, customVideoStyles]}
source={{
uri: currentStreamUrl,
headers: headers || getStreamHeaders(),
type: isHlsStream(currentStreamUrl) ? 'm3u8' : (currentVideoType as any)
}}
paused={paused}
onLoadStart={() => {
logger.log('[AndroidVideoPlayer][RN Video] onLoadStart');
loadStartAtRef.current = Date.now();
@ -3305,54 +3300,54 @@ const AndroidVideoPlayer: React.FC = () => {
};
logger.log('[AndroidVideoPlayer][RN Video] Stream info:', streamInfo);
}}
onProgress={handleProgress}
onLoad={(e) => {
logger.log('[AndroidVideoPlayer][RN Video] Video loaded successfully');
logger.log('[AndroidVideoPlayer][RN Video] onLoad fired', { duration: e?.duration });
onLoad(e);
}}
onReadyForDisplay={() => {
firstFrameAtRef.current = Date.now();
const startedAt = loadStartAtRef.current;
if (startedAt) {
const deltaMs = firstFrameAtRef.current - startedAt;
logger.log(`[AndroidVideoPlayer] First frame ready after ${deltaMs} ms (${Platform.OS})`);
} else {
logger.log('[AndroidVideoPlayer] First frame ready (no start timestamp)');
}
}}
onSeek={onSeek}
onEnd={onEnd}
onError={(err) => {
logger.error('[AndroidVideoPlayer][RN Video] Encountered error:', err);
handleError(err);
}}
onBuffer={(buf) => {
logger.log('[AndroidVideoPlayer] onBuffer', buf);
onBuffer(buf);
}}
resizeMode={getVideoResizeMode(resizeMode)}
selectedAudioTrack={selectedAudioTrack || undefined}
selectedTextTrack={useCustomSubtitles ? { type: SelectedTrackType.DISABLED } : (selectedTextTrack >= 0 ? { type: SelectedTrackType.INDEX, value: selectedTextTrack } : undefined)}
rate={playbackSpeed}
volume={volume}
muted={false}
repeat={false}
playInBackground={false}
playWhenInactive={false}
ignoreSilentSwitch="ignore"
mixWithOthers="inherit"
progressUpdateInterval={500}
// Remove artificial bit rate cap to allow high-bitrate streams (e.g., Blu-ray remux) to play
// maxBitRate intentionally omitted
disableFocus={true}
// iOS AVPlayer optimization
allowsExternalPlayback={false as any}
preventsDisplaySleepDuringVideoPlayback={true as any}
// ExoPlayer HLS optimization - let the player use optimal defaults
// Use surfaceView on Android for improved compatibility
viewType={Platform.OS === 'android' ? ViewType.SURFACE : undefined}
/>
onProgress={handleProgress}
onLoad={(e) => {
logger.log('[AndroidVideoPlayer][RN Video] Video loaded successfully');
logger.log('[AndroidVideoPlayer][RN Video] onLoad fired', { duration: e?.duration });
onLoad(e);
}}
onReadyForDisplay={() => {
firstFrameAtRef.current = Date.now();
const startedAt = loadStartAtRef.current;
if (startedAt) {
const deltaMs = firstFrameAtRef.current - startedAt;
logger.log(`[AndroidVideoPlayer] First frame ready after ${deltaMs} ms (${Platform.OS})`);
} else {
logger.log('[AndroidVideoPlayer] First frame ready (no start timestamp)');
}
}}
onSeek={onSeek}
onEnd={onEnd}
onError={(err) => {
logger.error('[AndroidVideoPlayer][RN Video] Encountered error:', err);
handleError(err);
}}
onBuffer={(buf) => {
logger.log('[AndroidVideoPlayer] onBuffer', buf);
onBuffer(buf);
}}
resizeMode={getVideoResizeMode(resizeMode)}
selectedAudioTrack={selectedAudioTrack || undefined}
selectedTextTrack={useCustomSubtitles ? { type: SelectedTrackType.DISABLED } : (selectedTextTrack >= 0 ? { type: SelectedTrackType.INDEX, value: selectedTextTrack } : undefined)}
rate={playbackSpeed}
volume={volume}
muted={false}
repeat={false}
playInBackground={false}
playWhenInactive={false}
ignoreSilentSwitch="ignore"
mixWithOthers="inherit"
progressUpdateInterval={500}
// Remove artificial bit rate cap to allow high-bitrate streams (e.g., Blu-ray remux) to play
// maxBitRate intentionally omitted
disableFocus={true}
// iOS AVPlayer optimization
allowsExternalPlayback={false as any}
preventsDisplaySleepDuringVideoPlayback={true as any}
// ExoPlayer HLS optimization - let the player use optimal defaults
// Use surfaceView on Android for improved compatibility
viewType={Platform.OS === 'android' ? ViewType.SURFACE : undefined}
/>
)}
</TouchableOpacity>
</View>
@ -3402,7 +3397,7 @@ const AndroidVideoPlayer: React.FC = () => {
onSlidingComplete={handleSlidingComplete}
buffered={buffered}
formatTime={formatTime}
playerBackend={useVLC ? 'VLC' : 'ExoPlayer'}
playerBackend={useVLC ? 'VLC' : 'ExoPlayer'}
/>
{showPauseOverlay && (
@ -3433,7 +3428,7 @@ const AndroidVideoPlayer: React.FC = () => {
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={[ 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)' ]}
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
locations={[0, 1]}
style={StyleSheet.absoluteFill}
/>
@ -3656,38 +3651,38 @@ const AndroidVideoPlayer: React.FC = () => {
marginRight: 8,
marginBottom: 8,
}}
onPress={() => {
setSelectedCastMember(castMember);
// Animate metadata out, then cast details in
Animated.parallel([
Animated.timing(metadataOpacity, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(metadataScale, {
toValue: 0.95,
duration: 250,
useNativeDriver: true,
})
]).start(() => {
setShowCastDetails(true);
// Animate cast details in
onPress={() => {
setSelectedCastMember(castMember);
// Animate metadata out, then cast details in
Animated.parallel([
Animated.timing(castDetailsOpacity, {
toValue: 1,
duration: 400,
Animated.timing(metadataOpacity, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.spring(castDetailsScale, {
toValue: 1,
tension: 80,
friction: 8,
Animated.timing(metadataScale, {
toValue: 0.95,
duration: 250,
useNativeDriver: true,
})
]).start();
});
}}
]).start(() => {
setShowCastDetails(true);
// Animate cast details in
Animated.parallel([
Animated.timing(castDetailsOpacity, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
Animated.spring(castDetailsScale, {
toValue: 1,
tension: 80,
friction: 8,
useNativeDriver: true,
})
]).start();
});
}}
>
<Text style={{
color: '#FFFFFF',
@ -3984,12 +3979,12 @@ const AndroidVideoPlayer: React.FC = () => {
<>
<AudioTrackModal
showAudioModal={showAudioModal}
setShowAudioModal={setShowAudioModal}
ksAudioTracks={useVLC ? vlcAudioTracks : rnVideoAudioTracks}
selectedAudioTrack={useVLC ? (vlcSelectedAudioTrack ?? null) : (selectedAudioTrack?.type === SelectedTrackType.INDEX && selectedAudioTrack.value !== undefined ? Number(selectedAudioTrack.value) : null)}
selectAudioTrack={selectAudioTrackById}
/>
showAudioModal={showAudioModal}
setShowAudioModal={setShowAudioModal}
ksAudioTracks={useVLC ? vlcAudioTracks : rnVideoAudioTracks}
selectedAudioTrack={useVLC ? (vlcSelectedAudioTrack ?? null) : (selectedAudioTrack?.type === SelectedTrackType.INDEX && selectedAudioTrack.value !== undefined ? Number(selectedAudioTrack.value) : null)}
selectAudioTrack={selectAudioTrackById}
/>
</>
<SubtitleModals
showSubtitleModal={showSubtitleModal}
@ -4089,91 +4084,91 @@ const AndroidVideoPlayer: React.FC = () => {
supportedOrientations={['landscape', 'portrait']}
statusBarTranslucent={true}
>
<View style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.8)'
}}>
<View style={{
backgroundColor: '#1a1a1a',
borderRadius: 14,
width: '85%',
maxHeight: '70%',
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 5,
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.8)'
}}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16
backgroundColor: '#1a1a1a',
borderRadius: 14,
width: '85%',
maxHeight: '70%',
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 5,
}}>
<MaterialIcons name="error" size={24} color="#ff4444" style={{ marginRight: 8 }} />
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16
}}>
<MaterialIcons name="error" size={24} color="#ff4444" style={{ marginRight: 8 }} />
<Text style={{
fontSize: 18,
fontWeight: 'bold',
color: '#ffffff',
flex: 1
}}>Playback Error</Text>
<TouchableOpacity onPress={handleErrorExit}>
<MaterialIcons name="close" size={24} color="#ffffff" />
</TouchableOpacity>
</View>
<Text style={{
fontSize: 18,
fontWeight: 'bold',
color: '#ffffff',
flex: 1
}}>Playback Error</Text>
<TouchableOpacity onPress={handleErrorExit}>
<MaterialIcons name="close" size={24} color="#ffffff" />
</TouchableOpacity>
</View>
fontSize: 14,
color: '#cccccc',
marginBottom: 16,
lineHeight: 20
}}>The video player encountered an error and cannot continue playback:</Text>
<Text style={{
fontSize: 14,
color: '#cccccc',
marginBottom: 16,
lineHeight: 20
}}>The video player encountered an error and cannot continue playback:</Text>
<View style={{
backgroundColor: '#2a2a2a',
borderRadius: 8,
padding: 12,
marginBottom: 20,
maxHeight: 200
}}>
<Text style={{
fontSize: 12,
color: '#ff8888',
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace'
}}>{errorDetails}</Text>
</View>
<View style={{
flexDirection: 'row',
justifyContent: 'flex-end'
}}>
<TouchableOpacity
style={{
backgroundColor: '#ff4444',
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 20
}}
onPress={handleErrorExit}
>
<Text style={{
color: '#ffffff',
fontWeight: '600',
fontSize: 16
}}>Exit Player</Text>
</TouchableOpacity>
</View>
<View style={{
backgroundColor: '#2a2a2a',
borderRadius: 8,
padding: 12,
marginBottom: 20,
maxHeight: 200
}}>
<Text style={{
fontSize: 12,
color: '#ff8888',
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace'
}}>{errorDetails}</Text>
color: '#888888',
textAlign: 'center',
marginTop: 12
}}>This dialog will auto-close in 5 seconds</Text>
</View>
<View style={{
flexDirection: 'row',
justifyContent: 'flex-end'
}}>
<TouchableOpacity
style={{
backgroundColor: '#ff4444',
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 20
}}
onPress={handleErrorExit}
>
<Text style={{
color: '#ffffff',
fontWeight: '600',
fontSize: 16
}}>Exit Player</Text>
</TouchableOpacity>
</View>
<Text style={{
fontSize: 12,
color: '#888888',
textAlign: 'center',
marginTop: 12
}}>This dialog will auto-close in 5 seconds</Text>
</View>
</View>
</Modal>
)}
</View>

View file

@ -328,7 +328,7 @@ const KSPlayerCore: React.FC = () => {
id: id || 'placeholder',
type: type || 'movie'
});
const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => {} };
const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => { } };
const { settings } = useSettings();
// Logo animation values
@ -559,7 +559,7 @@ const KSPlayerCore: React.FC = () => {
useEffect(() => {
return () => {
if (isSpeedBoosted) {
try { setPlaybackSpeed(originalSpeed); } catch {}
try { setPlaybackSpeed(originalSpeed); } catch { }
}
};
}, [isSpeedBoosted, originalSpeed]);
@ -648,7 +648,7 @@ const KSPlayerCore: React.FC = () => {
if (isOpeningAnimationComplete) {
enableImmersiveMode();
}
return () => {};
return () => { };
}, [isOpeningAnimationComplete])
);
@ -849,6 +849,8 @@ const KSPlayerCore: React.FC = () => {
const onPaused = () => {
if (isMounted.current) {
setPaused(true);
// Reset the wasPlayingBeforeDrag ref so that seeking while paused doesn't resume playback
wasPlayingBeforeDragRef.current = false;
// IMMEDIATE: Send immediate pause update to Trakt when user pauses
if (duration > 0) {
@ -919,8 +921,9 @@ const KSPlayerCore: React.FC = () => {
if (duration > 0) {
const seekTime = Math.min(value, duration - END_EPSILON);
seekToTime(seekTime);
// If the video was playing before the drag, ensure we remain in playing state after the seek
if (wasPlayingBeforeDragRef.current) {
// Only resume playback if the video was playing before the drag AND is not currently paused
// This ensures that if the user paused during or before the drag, it stays paused
if (wasPlayingBeforeDragRef.current && !paused) {
setTimeout(() => {
if (isMounted.current) {
setPaused(false);
@ -988,14 +991,6 @@ const KSPlayerCore: React.FC = () => {
completeOpeningAnimation();
}
// If time is advancing right after seek and we previously intended to play,
// ensure paused state is false to keep UI in sync
if (wasPlayingBeforeDragRef.current && paused && !isDragging) {
setPaused(false);
// Reset the intent once corrected
wasPlayingBeforeDragRef.current = false;
}
// Periodic check for disabled audio track (every 3 seconds, max 3 attempts)
const now = Date.now();
if (now - lastAudioTrackCheck > 3000 && !paused && duration > 0 && audioTrackFallbackAttempts < 3) {
@ -1011,12 +1006,12 @@ const KSPlayerCore: React.FC = () => {
const trackLang = (track.language || '').toLowerCase();
// Prefer stereo, AAC, or standard audio formats, avoid heavy codecs
return !trackName.includes('truehd') &&
!trackName.includes('dts') &&
!trackName.includes('dolby') &&
!trackName.includes('atmos') &&
!trackName.includes('7.1') &&
!trackName.includes('5.1') &&
index !== selectedAudioTrack; // Don't select the same track
!trackName.includes('dts') &&
!trackName.includes('dolby') &&
!trackName.includes('atmos') &&
!trackName.includes('7.1') &&
!trackName.includes('5.1') &&
index !== selectedAudioTrack; // Don't select the same track
});
if (fallbackTrack) {
@ -1203,10 +1198,10 @@ const KSPlayerCore: React.FC = () => {
// Auto-select English audio track if available, otherwise first track
if (selectedAudioTrack === null && formattedAudioTracks.length > 0) {
// Look for English track first
const englishTrack = formattedAudioTracks.find((track: {id: number, name: string, language?: string}) => {
const englishTrack = formattedAudioTracks.find((track: { id: number, name: string, language?: string }) => {
const lang = (track.language || '').toLowerCase();
return lang === 'english' || lang === 'en' || lang === 'eng' ||
(track.name && track.name.toLowerCase().includes('english'));
(track.name && track.name.toLowerCase().includes('english'));
});
const selectedTrack = englishTrack || formattedAudioTracks[0];
@ -1248,7 +1243,7 @@ const KSPlayerCore: React.FC = () => {
const lang = (track.language || '').toLowerCase();
const name = (track.name || '').toLowerCase();
return lang === 'english' || lang === 'en' || lang === 'eng' ||
name.includes('english') || name.includes('en');
name.includes('english') || name.includes('en');
});
if (englishTrack) {
@ -1393,9 +1388,9 @@ const KSPlayerCore: React.FC = () => {
const isTablet = (Platform as any).isPad === true || Math.min(dw, dh) >= 768;
setTimeout(() => {
if (isTablet) {
ScreenOrientation.unlockAsync().catch(() => {});
ScreenOrientation.unlockAsync().catch(() => { });
} else {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
}
}, 50);
}
@ -1538,12 +1533,12 @@ const KSPlayerCore: React.FC = () => {
const trackLang = (track.language || '').toLowerCase();
// Prefer stereo, AAC, or standard audio formats, avoid heavy codecs
return !trackName.includes('truehd') &&
!trackName.includes('dts') &&
!trackName.includes('dolby') &&
!trackName.includes('atmos') &&
!trackName.includes('7.1') &&
!trackName.includes('5.1') &&
index !== selectedAudioTrack; // Don't select the same track
!trackName.includes('dts') &&
!trackName.includes('dolby') &&
!trackName.includes('atmos') &&
!trackName.includes('7.1') &&
!trackName.includes('5.1') &&
index !== selectedAudioTrack; // Don't select the same track
});
if (fallbackTrack) {
@ -1673,8 +1668,8 @@ const KSPlayerCore: React.FC = () => {
// Check if this is a multi-channel track that might need downmixing
const trackName = selectedTrack.name.toLowerCase();
const isMultiChannel = trackName.includes('5.1') || trackName.includes('7.1') ||
trackName.includes('truehd') || trackName.includes('dts') ||
trackName.includes('dolby') || trackName.includes('atmos');
trackName.includes('truehd') || trackName.includes('dts') ||
trackName.includes('dolby') || trackName.includes('atmos');
if (isMultiChannel) {
logger.log(`[VideoPlayer] Multi-channel audio track detected: ${selectedTrack.name}`);
@ -1757,9 +1752,9 @@ const KSPlayerCore: React.FC = () => {
try {
const merged = { ...(saved || {}), subtitleSize: migrated };
await storageService.saveSubtitleSettings(merged);
} catch {}
} catch { }
}
try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch {}
try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch { }
return;
}
// If no saved settings, use responsive default
@ -2196,33 +2191,28 @@ const KSPlayerCore: React.FC = () => {
// Extract formatted segments from current cue
if (currentCue?.formattedSegments) {
// Split by newlines to get per-line segments
const lines = (currentCue.text || '').split(/\r?\n/);
const segmentsPerLine: SubtitleSegment[][] = [];
let segmentIndex = 0;
let currentLine: SubtitleSegment[] = [];
for (const line of lines) {
const lineSegments: SubtitleSegment[] = [];
const words = line.split(/(\s+)/);
for (const word of words) {
if (word.trim()) {
if (segmentIndex < currentCue.formattedSegments.length) {
lineSegments.push(currentCue.formattedSegments[segmentIndex]);
segmentIndex++;
} else {
// Fallback if segment count doesn't match
lineSegments.push({ text: word });
}
currentCue.formattedSegments.forEach(seg => {
const parts = seg.text.split(/\r?\n/);
parts.forEach((part, index) => {
if (index > 0) {
// New line found
segmentsPerLine.push(currentLine);
currentLine = [];
}
}
if (part.length > 0) {
currentLine.push({ ...seg, text: part });
}
});
});
if (lineSegments.length > 0) {
segmentsPerLine.push(lineSegments);
}
if (currentLine.length > 0) {
segmentsPerLine.push(currentLine);
}
setCurrentFormattedSegments(segmentsPerLine.length > 0 ? segmentsPerLine : []);
setCurrentFormattedSegments(segmentsPerLine);
} else {
setCurrentFormattedSegments([]);
}
@ -2243,14 +2233,14 @@ const KSPlayerCore: React.FC = () => {
if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor);
if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth);
if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right');
if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset);
if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset);
if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing);
if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier);
if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec);
}
} catch {} finally {
} catch { } finally {
// Mark subtitle settings as loaded so we can safely persist subsequent changes
try { setSubtitleSettingsLoaded(true); } catch {}
try { setSubtitleSettingsLoaded(true); } catch { }
}
})();
}, []);
@ -2283,7 +2273,7 @@ const KSPlayerCore: React.FC = () => {
subtitleOutlineColor,
subtitleOutlineWidth,
subtitleAlign,
subtitleBottomOffset,
subtitleBottomOffset,
subtitleLetterSpacing,
subtitleLineHeightMultiplier,
subtitleOffsetSec,
@ -2690,11 +2680,11 @@ const KSPlayerCore: React.FC = () => {
buffered={buffered}
formatTime={formatTime}
playerBackend={playerBackend}
cyclePlaybackSpeed={cyclePlaybackSpeed}
currentPlaybackSpeed={playbackSpeed}
isAirPlayActive={isAirPlayActive}
allowsAirPlay={allowsAirPlay}
onAirPlayPress={handleAirPlayPress}
cyclePlaybackSpeed={cyclePlaybackSpeed}
currentPlaybackSpeed={playbackSpeed}
isAirPlayActive={isAirPlayActive}
allowsAirPlay={allowsAirPlay}
onAirPlayPress={handleAirPlayPress}
/>
{showPauseOverlay && (
@ -2725,7 +2715,7 @@ const KSPlayerCore: React.FC = () => {
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={[ 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)' ]}
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
locations={[0, 1]}
style={StyleSheet.absoluteFill}
/>
@ -2948,38 +2938,38 @@ const KSPlayerCore: React.FC = () => {
marginRight: 8,
marginBottom: 8,
}}
onPress={() => {
setSelectedCastMember(castMember);
// Animate metadata out, then cast details in
Animated.parallel([
Animated.timing(metadataOpacity, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(metadataScale, {
toValue: 0.95,
duration: 250,
useNativeDriver: true,
})
]).start(() => {
setShowCastDetails(true);
// Animate cast details in
onPress={() => {
setSelectedCastMember(castMember);
// Animate metadata out, then cast details in
Animated.parallel([
Animated.timing(castDetailsOpacity, {
toValue: 1,
duration: 400,
Animated.timing(metadataOpacity, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.spring(castDetailsScale, {
toValue: 1,
tension: 80,
friction: 8,
Animated.timing(metadataScale, {
toValue: 0.95,
duration: 250,
useNativeDriver: true,
})
]).start();
});
}}
]).start(() => {
setShowCastDetails(true);
// Animate cast details in
Animated.parallel([
Animated.timing(castDetailsOpacity, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
Animated.spring(castDetailsScale, {
toValue: 1,
tension: 80,
friction: 8,
useNativeDriver: true,
})
]).start();
});
}}
>
<Text style={{
color: '#FFFFFF',

View file

@ -65,22 +65,25 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
}
effectiveBottom = Math.max(0, effectiveBottom);
// When using crisp outline, prefer SVG text with real stroke instead of blur shadow
const useCrispSvgOutline = outline === true;
const shadowStyle = (textShadow && !useCrispSvgOutline)
? {
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 hasRTL = lineRTLStatus.some(status => status);
// When using crisp outline, prefer SVG text with real stroke instead of blur shadow
// 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,
}
: {};
const displayFontSize = subtitleSize * inverseScale;
const displayLineHeight = subtitleSize * lineHeightMultiplier * inverseScale;

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',
@ -84,7 +84,7 @@ export const formatLanguage = (code?: string): string => {
// 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;
@ -104,7 +104,7 @@ 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('~'))) {
track.name.includes('Kbps') || track.name.includes('Atmos') || track.name.includes('~'))) {
return track.name;
}
@ -189,7 +189,7 @@ 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, '');

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;
}
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 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);
const fetchCalendarData = useCallback(async (forceRefresh = false) => {
setLoading(true);
try {
// Check memory pressure and cleanup if needed
memoryManager.checkMemoryPressure();
try {
// Check memory pressure and cleanup if needed
memoryManager.checkMemoryPressure();
if (!forceRefresh) {
const cachedData = await robustCalendarCache.getCachedCalendarData(
libraryItems,
{
watchlist: watchlistShows,
continueWatching: continueWatching,
watched: watchedShows,
}
);
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 = {
@ -179,6 +180,7 @@ export type RootStackParamList = {
};
ContinueWatchingSettings: undefined;
Contributors: undefined;
DebridIntegration: undefined;
};
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
@ -431,7 +433,7 @@ 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(() => {
@ -461,11 +463,11 @@ const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) =
// Apply status bar config on every focus
const subscription = Platform.OS === 'android'
? AppState.addEventListener('change', (state) => {
if (state === 'active') {
applyStatusBarConfig();
}
})
: { remove: () => {} };
if (state === 'active') {
applyStatusBarConfig();
}
})
: { remove: () => { } };
return () => {
subscription.remove();
@ -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 />
@ -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
@ -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;
@ -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;
@ -1062,6 +1064,14 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
// 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);
@ -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>

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,11 +660,21 @@ 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;
}
@ -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);
}
@ -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);
});
};
@ -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}>
@ -1208,7 +1218,7 @@ const AddonsScreen = () => {
autoCorrect={false}
/>
<TouchableOpacity
style={[styles.addButton, {opacity: installing || !addonUrl ? 0.6 : 1}]}
style={[styles.addButton, { opacity: installing || !addonUrl ? 0.6 : 1 }]}
onPress={() => handleAddAddon()}
disabled={installing || !addonUrl}
>
@ -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}>
@ -1381,8 +1391,8 @@ const AddonsScreen = () => {
<Text style={styles.addonDescription}>
{item.manifest.description
? (item.manifest.description.length > 100
? item.manifest.description.substring(0, 100) + '...'
: item.manifest.description)
? item.manifest.description.substring(0, 100) + '...'
: item.manifest.description)
: 'No description provided.'}
</Text>
</View>
@ -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

@ -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; };
}, []);
@ -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 () => {
@ -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"
@ -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');

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,6 +56,8 @@ 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
@ -157,8 +159,8 @@ 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.season === item.season &&
notification.episode === item.episode
);
if (existingNotification) {
return null; // Don't schedule duplicate 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);
}
@ -716,7 +737,7 @@ class NotificationService {
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);

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 {

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;