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 { AccountProvider, useAccount } from './src/contexts/AccountContext';
import { ToastProvider } from './src/contexts/ToastContext'; import { ToastProvider } from './src/contexts/ToastContext';
import { mmkvStorage } from './src/services/mmkvStorage'; import { mmkvStorage } from './src/services/mmkvStorage';
import AnnouncementOverlay from './src/components/AnnouncementOverlay';
Sentry.init({ Sentry.init({
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992', dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
@ -82,11 +83,12 @@ const ThemedApp = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const engine = (global as any).HermesInternal ? 'Hermes' : 'JSC'; const engine = (global as any).HermesInternal ? 'Hermes' : 'JSC';
console.log('JS Engine:', engine); console.log('JS Engine:', engine);
} catch {} } catch { }
}, []); }, []);
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const [isAppReady, setIsAppReady] = useState(false); const [isAppReady, setIsAppReady] = useState(false);
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState<boolean | null>(null); const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState<boolean | null>(null);
const [showAnnouncement, setShowAnnouncement] = useState(false);
// Update popup functionality // Update popup functionality
const { const {
@ -101,6 +103,16 @@ const ThemedApp = () => {
// GitHub major/minor release overlay // GitHub major/minor release overlay
const githubUpdate = useGithubMajorUpdate(); 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 // Check onboarding status and initialize services
useEffect(() => { useEffect(() => {
const initializeApp = async () => { const initializeApp = async () => {
@ -120,6 +132,15 @@ const ThemedApp = () => {
await aiService.initialize(); await aiService.initialize();
console.log('AI service initialized'); 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) { } catch (error) {
console.error('Error initializing app:', error); console.error('Error initializing app:', error);
// Default to showing onboarding if we can't check // Default to showing onboarding if we can't check
@ -154,6 +175,23 @@ const ThemedApp = () => {
setIsAppReady(true); 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 // Don't render anything until we know the onboarding status
const shouldShowApp = isAppReady && hasCompletedOnboarding !== null; const shouldShowApp = isAppReady && hasCompletedOnboarding !== null;
const initialRouteName = hasCompletedOnboarding ? 'MainTabs' : 'Onboarding'; const initialRouteName = hasCompletedOnboarding ? 'MainTabs' : 'Onboarding';
@ -162,6 +200,7 @@ const ThemedApp = () => {
<AccountProvider> <AccountProvider>
<PaperProvider theme={customDarkTheme}> <PaperProvider theme={customDarkTheme}>
<NavigationContainer <NavigationContainer
ref={navigationRef}
theme={customNavigationTheme} theme={customNavigationTheme}
linking={undefined} linking={undefined}
> >
@ -186,6 +225,13 @@ const ThemedApp = () => {
onDismiss={githubUpdate.onDismiss} onDismiss={githubUpdate.onDismiss}
onLater={githubUpdate.onLater} onLater={githubUpdate.onLater}
/> />
<AnnouncementOverlay
visible={showAnnouncement}
announcements={announcements}
onClose={handleAnnouncementClose}
onActionPress={handleNavigateToDebrid}
actionButtonText="Connect Now"
/>
</View> </View>
</DownloadsProvider> </DownloadsProvider>
</NavigationContainer> </NavigationContainer>

View file

@ -95,8 +95,8 @@ android {
applicationId 'com.nuvio.app' applicationId 'com.nuvio.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 24 versionCode 25
versionName "1.2.9" versionName "1.2.10"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" 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] def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
applicationVariants.all { variant -> applicationVariants.all { variant ->
variant.outputs.each { output -> 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 abiName = output.getFilter(com.android.build.OutputFile.ABI)
def versionCode = baseVersionCode * 100 // Base multiplier def versionCode = baseVersionCode * 100 // Base multiplier
@ -209,6 +209,10 @@ sentry {
} }
} }
configurations.all {
exclude group: 'com.caverock', module: 'androidsvg'
}
dependencies { dependencies {
// @generated begin react-native-google-cast-dependencies - expo prebuild (DO NOT MODIFY) sync-3822a3c86222e7aca74039b551612aab7e75365d // @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', '+')}" 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_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</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_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> </resources>

View file

@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Nuvio", "name": "Nuvio",
"slug": "nuvio", "slug": "nuvio",
"version": "1.2.9", "version": "1.2.10",
"orientation": "default", "orientation": "default",
"backgroundColor": "#020404", "backgroundColor": "#020404",
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png", "icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
@ -18,7 +18,7 @@
"supportsTablet": true, "supportsTablet": true,
"requireFullScreen": true, "requireFullScreen": true,
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png", "icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
"buildNumber": "24", "buildNumber": "25",
"infoPlist": { "infoPlist": {
"NSAppTransportSecurity": { "NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true "NSAllowsArbitraryLoads": true
@ -52,7 +52,7 @@
"WRITE_SETTINGS" "WRITE_SETTINGS"
], ],
"package": "com.nuvio.app", "package": "com.nuvio.app",
"versionCode": 24, "versionCode": 25,
"architectures": [ "architectures": [
"arm64-v8a", "arm64-v8a",
"armeabi-v7a", "armeabi-v7a",
@ -105,6 +105,6 @@
"fallbackToCacheTimeout": 30000, "fallbackToCacheTimeout": 30000,
"url": "https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest" "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 options.asynchronousDecompression = true
#endif #endif
// PERFORMANCE OPTIMIZATION: Native HDR processing // HDR handling: Let KSPlayer automatically detect content's native dynamic range
// Set destination dynamic range based on device capabilities to eliminate unnecessary color conversions // Setting destinationDynamicRange to nil allows KSPlayer to use the content's actual HDR/SDR mode
options.destinationDynamicRange = getOptimalDynamicRange() // 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 // Configure audio for proper dialogue mixing using FFmpeg's pan filter
// This approach uses standard audio engineering practices for multi-channel downmixing // This approach uses standard audio engineering practices for multi-channel downmixing
@ -455,9 +457,24 @@ class KSPlayerView: UIView {
return 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 { if success {
print("KSPlayerView: Seek successful to \(time)") 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 { } else {
print("KSPlayerView: Seek failed to \(time)") print("KSPlayerView: Seek failed to \(time)")
} }
@ -804,40 +821,6 @@ class KSPlayerView: UIView {
} }
// MARK: - Performance Optimization Helpers // 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 // MARK: - High Performance KSOptions Subclass

View file

@ -477,7 +477,7 @@
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
PRODUCT_NAME = Nuvio; PRODUCT_NAME = "Nuvio";
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -508,8 +508,8 @@
"-lc++", "-lc++",
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
PRODUCT_NAME = Nuvio; PRODUCT_NAME = "Nuvio";
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";

View file

@ -19,7 +19,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.2.9</string> <string>1.2.10</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@ -39,7 +39,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>24</string> <string>25</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>12.0</string> <string>12.0</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>

View file

@ -9,7 +9,7 @@
<key>EXUpdatesLaunchWaitMs</key> <key>EXUpdatesLaunchWaitMs</key>
<integer>30000</integer> <integer>30000</integer>
<key>EXUpdatesRuntimeVersion</key> <key>EXUpdatesRuntimeVersion</key>
<string>1.2.9</string> <string>1.2.10</string>
<key>EXUpdatesURL</key> <key>EXUpdatesURL</key>
<string>https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest</string> <string>https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest</string>
</dict> </dict>

View file

@ -3178,13 +3178,13 @@ EXTERNAL SOURCES:
CHECKOUT OPTIONS: CHECKOUT OPTIONS:
DisplayCriteria: DisplayCriteria:
:commit: 83ba8419ca365e9397c0b45c4147755da522324e :commit: cbc74996afb55e096bf1ff240f07d1d206ac86df
:git: https://github.com/kingslay/KSPlayer.git :git: https://github.com/kingslay/KSPlayer.git
FFmpegKit: FFmpegKit:
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9 :commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
:git: https://github.com/kingslay/FFmpegKit.git :git: https://github.com/kingslay/FFmpegKit.git
KSPlayer: KSPlayer:
:commit: 83ba8419ca365e9397c0b45c4147755da522324e :commit: cbc74996afb55e096bf1ff240f07d1d206ac86df
:git: https://github.com/kingslay/KSPlayer.git :git: https://github.com/kingslay/KSPlayer.git
Libass: Libass:
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9 :commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9

View file

@ -30,6 +30,14 @@
"https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true" "https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true"
], ],
"versions": [ "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", "version": "1.2.9",
"buildVersion": "24", "buildVersion": "24",

2
package-lock.json generated
View file

@ -12,7 +12,7 @@
"@adrianso/react-native-device-brightness": "^1.2.7", "@adrianso/react-native-device-brightness": "^1.2.7",
"@backpackapp-io/react-native-toast": "^0.15.1", "@backpackapp-io/react-native-toast": "^0.15.1",
"@bottom-tabs/react-navigation": "^1.0.2", "@bottom-tabs/react-navigation": "^1.0.2",
"@d11/react-native-fast-image": "^8.8.0", "@d11/react-native-fast-image": "^8.13.0",
"@expo/env": "^2.0.7", "@expo/env": "^2.0.7",
"@expo/metro-runtime": "~6.1.2", "@expo/metro-runtime": "~6.1.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",

View file

@ -12,7 +12,7 @@
"@adrianso/react-native-device-brightness": "^1.2.7", "@adrianso/react-native-device-brightness": "^1.2.7",
"@backpackapp-io/react-native-toast": "^0.15.1", "@backpackapp-io/react-native-toast": "^0.15.1",
"@bottom-tabs/react-navigation": "^1.0.2", "@bottom-tabs/react-navigation": "^1.0.2",
"@d11/react-native-fast-image": "^8.8.0", "@d11/react-native-fast-image": "^8.13.0",
"@expo/env": "^2.0.7", "@expo/env": "^2.0.7",
"@expo/metro-runtime": "~6.1.2", "@expo/metro-runtime": "~6.1.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",

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 // Animation values
const dragProgress = useSharedValue(0); const dragProgress = useSharedValue(0);
const dragDirection = useSharedValue(0); // -1 for left, 1 for right 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 logoOpacity = useSharedValue(1);
const [nextIndex, setNextIndex] = useState(currentIndex); const [nextIndex, setNextIndex] = useState(currentIndex);
const thumbnailOpacity = useSharedValue(1); const thumbnailOpacity = useSharedValue(1);
@ -197,11 +198,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
// Animated style for trailer container - 60% height with zoom // Animated style for trailer container - 60% height with zoom
const trailerContainerStyle = useAnimatedStyle(() => { 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( const dragFade = interpolate(
dragProgress.value, dragProgress.value,
[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.85, 1], [0, 0.05, 0.1, 0.15, 0.2, 0.3],
[1, 0.95, 0.88, 0.78, 0.65, 0.5, 0.35, 0.22, 0.08, 0], [1, 0.85, 0.65, 0.4, 0.15, 0],
Extrapolation.CLAMP 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(() => { const backgroundParallaxStyle = useAnimatedStyle(() => {
'worklet'; 'worklet';
const scrollYValue = scrollY.value; 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 // Pre-calculated constants - start at 1.0 for normal size
const DEFAULT_ZOOM = 1.0; const DEFAULT_ZOOM = 1.0;
const SCROLL_UP_MULTIPLIER = 0.002; 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(() => { const trailerParallaxStyle = useAnimatedStyle(() => {
'worklet'; 'worklet';
const scrollYValue = scrollY.value; 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 // Pre-calculated constants - start at 1.0 for normal size
const DEFAULT_ZOOM = 1.0; const DEFAULT_ZOOM = 1.0;
const SCROLL_UP_MULTIPLIER = 0.0015; const SCROLL_UP_MULTIPLIER = 0.0015;
@ -580,6 +601,9 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
.activeOffsetX([-5, 5]) // Smaller activation area - more sensitive .activeOffsetX([-5, 5]) // Smaller activation area - more sensitive
.failOffsetY([-15, 15]) // Fail if vertical movement is detected .failOffsetY([-15, 15]) // Fail if vertical movement is detected
.onStart(() => { .onStart(() => {
// Mark as dragging to disable parallax
isDragging.value = 1;
// Determine which direction and set preview // Determine which direction and set preview
runOnJS(updateInteractionTime)(); runOnJS(updateInteractionTime)();
// Immediately stop trailer playback when drag starts // Immediately stop trailer playback when drag starts
@ -626,6 +650,9 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
}, },
(finished) => { (finished) => {
if (finished) { if (finished) {
// Re-enable parallax after navigation completes
isDragging.value = withTiming(0, { duration: 200 });
if (translationX > 0) { if (translationX > 0) {
runOnJS(goToPrevious)(); runOnJS(goToPrevious)();
} else { } else {
@ -640,6 +667,9 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
duration: 450, duration: 450,
easing: Easing.bezier(0.25, 0.1, 0.25, 1), // Custom ease-out for buttery smooth return 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] [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 // Subscribe to library updates and update local state if this item's status changes
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => { const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
const found = items.find((libItem) => libItem.id === item.id && libItem.type === item.type); 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(); return () => unsubscribe();
}, [item.id, item.type]); }, [item.id, item.type]);

View file

@ -334,13 +334,67 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
if (!isAuthed) return new Set<string>(); if (!isAuthed) return new Set<string>();
if (typeof (traktService as any).getWatchedMovies === 'function') { if (typeof (traktService as any).getWatchedMovies === 'function') {
const watched = await (traktService as any).getWatchedMovies(); const watched = await (traktService as any).getWatchedMovies();
const watchedSet = new Set<string>();
if (Array.isArray(watched)) { if (Array.isArray(watched)) {
const ids = watched watched.forEach((w: any) => {
.map((w: any) => w?.movie?.ids?.imdb) const ids = w?.movie?.ids;
.filter(Boolean) if (!ids) return;
.map((imdb: string) => (imdb.startsWith('tt') ? imdb : `tt${imdb}`));
return new Set<string>(ids); 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>(); return new Set<string>();
} catch { } catch {
@ -365,7 +419,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
traktSynced: true, traktSynced: true,
traktProgress: 100, traktProgress: 100,
} as any); } as any);
} catch (_e) {} } catch (_e) { }
return; return;
} }
} }
@ -422,6 +476,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
let season: number | undefined; let season: number | undefined;
let episodeNumber: number | undefined; let episodeNumber: number | undefined;
let episodeTitle: string | undefined; let episodeTitle: string | undefined;
let isWatchedOnTrakt = false;
if (episodeId && group.type === 'series') { if (episodeId && group.type === 'series') {
let match = episodeId.match(/s(\d+)e(\d+)/i); let match = episodeId.match(/s(\d+)e(\d+)/i);
if (match) { 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({ batch.push({
@ -650,7 +761,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
loadContinueWatching(true); loadContinueWatching(true);
return () => {}; return () => { };
}, [loadContinueWatching]) }, [loadContinueWatching])
); );
@ -798,7 +909,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
{ {
label: 'Cancel', label: 'Cancel',
style: { color: '#888' }, style: { color: '#888' },
onPress: () => {}, onPress: () => { },
}, },
{ {
label: 'Remove', label: 'Remove',
@ -906,19 +1017,19 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
{item.name} {item.name}
</Text> </Text>
{isUpNext && ( {isUpNext && (
<View style={[ <View style={[
styles.progressBadge, styles.progressBadge,
{ {
backgroundColor: currentTheme.colors.primary, backgroundColor: currentTheme.colors.primary,
paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8, paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3 paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3
} }
]}> ]}>
<Text style={[ <Text style={[
styles.progressText, styles.progressText,
{ fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12 } { fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12 }
]}>Up Next</Text> ]}>Up Next</Text>
</View> </View>
)} )}
</View> </View>
); );
@ -1055,7 +1166,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
]} ]}
ItemSeparatorComponent={ItemSeparator} ItemSeparatorComponent={ItemSeparator}
onEndReachedThreshold={0.7} onEndReachedThreshold={0.7}
onEndReached={() => {}} onEndReached={() => { }}
removeClippedSubviews={true} removeClippedSubviews={true}
/> />
@ -1209,7 +1320,7 @@ const styles = StyleSheet.create({
}, },
contentItem: { contentItem: {
width: POSTER_WIDTH, width: POSTER_WIDTH,
aspectRatio: 2/3, aspectRatio: 2 / 3,
margin: 0, margin: 0,
borderRadius: 8, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',

View file

@ -50,6 +50,10 @@ interface ThisWeekEpisode {
vote_average: number; vote_average: number;
still_path: string | null; still_path: string | null;
season_poster_path: string | null; season_poster_path: string | null;
// Grouping fields
isGroup?: boolean;
episodeCount?: number;
episodeRange?: string;
} }
export const ThisWeekSection = React.memo(() => { export const ThisWeekSection = React.memo(() => {
@ -134,16 +138,72 @@ export const ThisWeekSection = React.memo(() => {
const thisWeekSection = calendarData.find(section => section.title === 'This Week'); const thisWeekSection = calendarData.find(section => section.title === 'This Week');
if (!thisWeekSection) return []; if (!thisWeekSection) return [];
// Limit episodes to prevent memory issues and add release status // Get raw episodes (limit to 60 to be safe for performance but allow grouping)
const episodes = memoryManager.limitArraySize(thisWeekSection.data, 20); // Limit to 20 for home screen const rawEpisodes = memoryManager.limitArraySize(thisWeekSection.data, 60);
return episodes.map(episode => ({ // Group by series and date
...episode, const groups: Record<string, typeof rawEpisodes> = {};
isReleased: episode.releaseDate ? isBefore(parseISO(episode.releaseDate), new Date()) : false,
})); 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]); }, [calendarData]);
const handleEpisodePress = (episode: ThisWeekEpisode) => { 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 // For upcoming episodes, go to the metadata screen
if (!episode.isReleased) { if (!episode.isReleased) {
const episodeId = `${episode.seriesId}:${episode.season}:${episode.episode}`; 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 }) => { const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => {
// Handle episodes without release dates gracefully // Handle episodes without release dates gracefully
const releaseDate = item.releaseDate ? parseISO(item.releaseDate) : null; 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; const isReleased = item.isReleased;
// Use episode still image if available, fallback to series poster // Use episode still image if available, fallback to series poster
@ -187,106 +247,93 @@ export const ThisWeekSection = React.memo(() => {
return ( return (
<View style={[styles.episodeItemContainer, { width: computedItemWidth, height: computedItemHeight }]}> <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 <TouchableOpacity
style={[ style={[
styles.episodeItem, styles.episodeItem,
{ {
shadowColor: currentTheme.colors.black,
backgroundColor: currentTheme.colors.background, backgroundColor: currentTheme.colors.background,
borderColor: 'rgba(255,255,255,0.08)',
borderWidth: 1,
} }
]} ]}
onPress={() => handleEpisodePress(item)} onPress={() => handleEpisodePress(item)}
activeOpacity={0.8} activeOpacity={0.7}
> >
<View style={styles.imageContainer}> <View style={styles.imageContainer}>
<FastImage <FastImage
source={{ source={{
uri: imageUrl || undefined, uri: imageUrl || undefined,
priority: FastImage.priority.normal, priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable cache: FastImage.cacheControl.immutable
}} }}
style={styles.poster} style={styles.poster}
resizeMode={FastImage.resizeMode.cover} resizeMode={FastImage.resizeMode.cover}
/> />
{/* Enhanced gradient overlay */} <LinearGradient
<LinearGradient
colors={[ colors={[
'transparent', 'transparent',
'transparent', 'rgba(0,0,0,0.0)',
'rgba(0,0,0,0.4)', 'rgba(0,0,0,0.5)',
'rgba(0,0,0,0.8)', 'rgba(0,0,0,0.9)'
'rgba(0,0,0,0.95)'
]} ]}
style={[ style={styles.gradient}
styles.gradient, locations={[0, 0.4, 0.7, 1]}
{
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}
locations={[0, 0.4, 0.6, 0.8, 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}> <View style={styles.contentArea}>
<Text style={[ <Text style={[
styles.seriesName, styles.seriesName,
{ {
color: currentTheme.colors.white, color: currentTheme.colors.white,
fontSize: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16 fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16
} }
]} numberOfLines={1}> ]} numberOfLines={1}>
{item.seriesName} {item.seriesName}
</Text> </Text>
<Text style={[ <View style={styles.metaContainer}>
styles.episodeTitle,
{
color: 'rgba(255,255,255,0.9)',
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14
}
]} numberOfLines={2}>
{item.title}
</Text>
{item.overview && (
<Text style={[ <Text style={[
styles.overview, styles.seasonBadge,
{
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,
{ {
color: currentTheme.colors.primary, 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> </Text>
</View> </View>
</View> </View>
</LinearGradient> </LinearGradient>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -300,13 +347,13 @@ export const ThisWeekSection = React.memo(() => {
> >
<View style={[styles.header, { paddingHorizontal: horizontalPadding }]}> <View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={[ <Text style={[
styles.title, styles.title,
{ {
color: currentTheme.colors.text, color: currentTheme.colors.text,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24 fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
} }
]}>This Week</Text> ]}>This Week</Text>
<View style={[ <View style={[
styles.titleUnderline, styles.titleUnderline,
{ {
@ -371,7 +418,7 @@ export const ThisWeekSection = React.memo(() => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
marginVertical: 20, marginVertical: 24,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
@ -400,14 +447,15 @@ const styles = StyleSheet.create({
viewAllButton: { viewAllButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingVertical: 8, paddingVertical: 6,
paddingHorizontal: 10, paddingHorizontal: 12,
borderRadius: 20, borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.1)', backgroundColor: 'rgba(255,255,255,0.08)',
marginRight: -10, borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
}, },
viewAllText: { viewAllText: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
marginRight: 4, marginRight: 4,
}, },
@ -432,10 +480,11 @@ const styles = StyleSheet.create({
height: '100%', height: '100%',
borderRadius: 16, borderRadius: 16,
overflow: 'hidden', overflow: 'hidden',
shadowOffset: { width: 0, height: 8 }, shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 12, shadowRadius: 8,
elevation: 12, elevation: 8,
}, },
imageContainer: { imageContainer: {
width: '100%', width: '100%',
@ -453,44 +502,64 @@ const styles = StyleSheet.create({
right: 0, right: 0,
top: 0, top: 0,
bottom: 0, bottom: 0,
justifyContent: 'flex-end', justifyContent: 'space-between',
padding: 12, padding: 12,
borderRadius: 16, 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: { contentArea: {
width: '100%', width: '100%',
}, },
seriesName: { seriesName: {
fontSize: 16, fontSize: 16,
fontWeight: '700', fontWeight: '800',
marginBottom: 6,
},
episodeTitle: {
fontSize: 14,
fontWeight: '600',
marginBottom: 4, marginBottom: 4,
lineHeight: 18, textShadowColor: 'rgba(0, 0, 0, 0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
}, },
overview: { metaContainer: {
fontSize: 12,
lineHeight: 16,
marginBottom: 6,
opacity: 0.9,
},
dateContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginTop: 4,
}, },
episodeInfo: { seasonBadge: {
fontSize: 12, fontSize: 12,
fontWeight: '600', fontWeight: '700',
marginRight: 4,
}, },
releaseDate: { dotSeparator: {
fontSize: 13, marginHorizontal: 6,
fontWeight: '600', fontSize: 12,
marginLeft: 6, color: 'rgba(255,255,255,0.5)',
letterSpacing: 0.3, },
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 // Grace delay before showing text fallback to avoid flashing when logo arrives late
const [shouldShowTextFallback, setShouldShowTextFallback] = useState<boolean>(!metadata?.logo); const [shouldShowTextFallback, setShouldShowTextFallback] = useState<boolean>(!metadata?.logo);
const logoWaitTimerRef = useRef<any>(null); 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 // Update stable logo URI when metadata logo changes
useEffect(() => { useEffect(() => {
// Reset text fallback and timers on logo updates // Check if metadata logo has actually changed from what we last processed
if (logoWaitTimerRef.current) { const currentMetadataLogo = metadata?.logo;
try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {}
logoWaitTimerRef.current = null; 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 () => { return () => {
if (logoWaitTimerRef.current) { if (logoWaitTimerRef.current) {
try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {} try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {}
logoWaitTimerRef.current = null; 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 // Handle logo load success - once loaded successfully, keep it stable
const handleLogoLoad = useCallback(() => { const handleLogoLoad = useCallback(() => {

View file

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

View file

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

View file

@ -65,22 +65,25 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
} }
effectiveBottom = Math.max(0, effectiveBottom); 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 // Prepare content lines
const lines = String(currentSubtitle).split(/\r?\n/); const lines = String(currentSubtitle).split(/\r?\n/);
// Detect RTL for each line // Detect RTL for each line
const lineRTLStatus = lines.map(line => detectRTL(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 displayFontSize = subtitleSize * inverseScale;
const displayLineHeight = subtitleSize * lineHeightMultiplier * 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 // Add language code to name mapping
export const languageMap: {[key: string]: string} = { export const languageMap: { [key: string]: string } = {
'en': 'English', 'en': 'English',
'eng': 'English', 'eng': 'English',
'es': 'Spanish', '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 the result is still the uppercased code, it means we couldn't find it in our map.
if (languageName === code.toUpperCase()) { if (languageName === code.toUpperCase()) {
return `Unknown (${code})`; return `Unknown (${code})`;
} }
return languageName; 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 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') || 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; return track.name;
} }
@ -189,7 +189,7 @@ export const detectRTL = (text: string): boolean => {
// Arabic Presentation Forms-B: U+FE70U+FEFF // Arabic Presentation Forms-B: U+FE70U+FEFF
// Hebrew: U+0590U+05FF // Hebrew: U+0590U+05FF
// Persian/Urdu use Arabic script (no separate range) // 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 // Remove whitespace and count characters
const nonWhitespace = text.replace(/\s/g, ''); 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'; import { StreamingContent } from '../services/catalogService';
interface CalendarEpisode { interface CalendarEpisode {
id: string; id: string;
seriesId: string; seriesId: string;
title: string; title: string;
seriesName: string; seriesName: string;
poster: string; poster: string;
releaseDate: string; releaseDate: string;
season: number; season: number;
episode: number; episode: number;
overview: string; overview: string;
vote_average: number; vote_average: number;
still_path: string | null; still_path: string | null;
season_poster_path: string | null; season_poster_path: string | null;
} }
interface CalendarSection { interface CalendarSection {
title: string; title: string;
data: CalendarEpisode[]; data: CalendarEpisode[];
} }
interface UseCalendarDataReturn { interface UseCalendarDataReturn {
calendarData: CalendarSection[]; calendarData: CalendarSection[];
@ -36,399 +36,416 @@ interface UseCalendarDataReturn {
} }
export const useCalendarData = (): UseCalendarDataReturn => { export const useCalendarData = (): UseCalendarDataReturn => {
const [calendarData, setCalendarData] = useState<CalendarSection[]>([]); const [calendarData, setCalendarData] = useState<CalendarSection[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { libraryItems, loading: libraryLoading } = useLibrary(); const { libraryItems, loading: libraryLoading } = useLibrary();
const { const {
isAuthenticated: traktAuthenticated, isAuthenticated: traktAuthenticated,
isLoading: traktLoading, isLoading: traktLoading,
watchedShows, watchedShows,
watchlistShows, watchlistShows,
continueWatching, continueWatching,
loadAllCollections, loadAllCollections,
} = useTraktContext(); } = useTraktContext();
const fetchCalendarData = useCallback(async (forceRefresh = false) => { const fetchCalendarData = useCallback(async (forceRefresh = false) => {
setLoading(true); setLoading(true);
try { try {
// Check memory pressure and cleanup if needed // Check memory pressure and cleanup if needed
memoryManager.checkMemoryPressure(); memoryManager.checkMemoryPressure();
if (!forceRefresh) { if (!forceRefresh) {
const cachedData = await robustCalendarCache.getCachedCalendarData( const cachedData = await robustCalendarCache.getCachedCalendarData(
libraryItems, libraryItems,
{ {
watchlist: watchlistShows, watchlist: watchlistShows,
continueWatching: continueWatching, continueWatching: continueWatching,
watched: watchedShows, watched: watchedShows,
}
);
if (cachedData) {
setCalendarData(cachedData);
setLoading(false);
return;
}
} }
);
if (cachedData) {
const librarySeries = libraryItems.filter(item => item.type === 'series'); setCalendarData(cachedData);
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();
setLoading(false); setLoading(false);
return;
} }
}, [libraryItems, traktAuthenticated, watchlistShows, continueWatching, watchedShows]); }
useEffect(() => { const librarySeries = libraryItems.filter(item => item.type === 'series');
if (!libraryLoading && !traktLoading) {
if (traktAuthenticated && (!watchlistShows || !continueWatching || !watchedShows)) { // Prioritize series sources: Continue Watching > Watchlist > Library > Watched
loadAllCollections(); // This ensures that shows the user is actively watching or interested in are checked first
} else { // before hitting the series limit.
fetchCalendarData(); 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) => { // 2. Watchlist
fetchCalendarData(force); if (watchlistShows) {
}, [fetchCalendarData]); 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 { // 4. Watched (Lowest Priority)
calendarData, if (traktAuthenticated && watchedShows) {
loading, const recentWatched = watchedShows.slice(0, 20);
refresh, 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(() => { useEffect(() => {
const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => { const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
const isInLib = libraryItems.some(item => item.id === id); 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(); return () => unsubscribe();

View file

@ -69,6 +69,7 @@ import BackdropGalleryScreen from '../screens/BackdropGalleryScreen';
import BackupScreen from '../screens/BackupScreen'; import BackupScreen from '../screens/BackupScreen';
import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen'; import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen';
import ContributorsScreen from '../screens/ContributorsScreen'; import ContributorsScreen from '../screens/ContributorsScreen';
import DebridIntegrationScreen from '../screens/DebridIntegrationScreen';
// Stack navigator types // Stack navigator types
export type RootStackParamList = { export type RootStackParamList = {
@ -179,6 +180,7 @@ export type RootStackParamList = {
}; };
ContinueWatchingSettings: undefined; ContinueWatchingSettings: undefined;
Contributors: undefined; Contributors: undefined;
DebridIntegration: undefined;
}; };
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>; 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 // 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')); const [dimensions, setDimensions] = useState(Dimensions.get('window'));
useEffect(() => { useEffect(() => {
@ -461,11 +463,11 @@ const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) =
// Apply status bar config on every focus // Apply status bar config on every focus
const subscription = Platform.OS === 'android' const subscription = Platform.OS === 'android'
? AppState.addEventListener('change', (state) => { ? AppState.addEventListener('change', (state) => {
if (state === 'active') { if (state === 'active') {
applyStatusBarConfig(); applyStatusBarConfig();
} }
}) })
: { remove: () => {} }; : { remove: () => { } };
return () => { return () => {
subscription.remove(); 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 // 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 ( return (
<TabScreenWrapper> <TabScreenWrapper>
<Screen /> <Screen />
@ -528,7 +530,7 @@ const MainTabs = () => {
try { try {
const flag = await mmkvStorage.getItem('@update_badge_pending'); const flag = await mmkvStorage.getItem('@update_badge_pending');
if (mounted) setHasUpdateBadge(flag === 'true'); if (mounted) setHasUpdateBadge(flag === 'true');
} catch {} } catch { }
}; };
load(); load();
// Fast poll initially for quick badge appearance, then slow down // Fast poll initially for quick badge appearance, then slow down
@ -589,18 +591,18 @@ const MainTabs = () => {
// Top floating, text-only pill nav for tablets // Top floating, text-only pill nav for tablets
return ( return (
<Animated.View <Animated.View
style={[{ style={[{
position: 'absolute', position: 'absolute',
top: insets.top + 12, top: insets.top + 12,
left: 0, left: 0,
right: 0, right: 0,
alignItems: 'center', alignItems: 'center',
backgroundColor: 'transparent', backgroundColor: 'transparent',
zIndex: 100, zIndex: 100,
}, shouldKeepFixed ? {} : { }, shouldKeepFixed ? {} : {
transform: [{ translateY }], transform: [{ translateY }],
opacity: fade, opacity: fade,
}]}> }]}>
<View style={{ <View style={{
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -644,8 +646,8 @@ const MainTabs = () => {
options.tabBarLabel !== undefined options.tabBarLabel !== undefined
? options.tabBarLabel ? options.tabBarLabel
: options.title !== undefined : options.title !== undefined
? options.title ? options.title
: route.name; : route.name;
const isFocused = props.state.index === index; const isFocused = props.state.index === index;
@ -758,8 +760,8 @@ const MainTabs = () => {
options.tabBarLabel !== undefined options.tabBarLabel !== undefined
? options.tabBarLabel ? options.tabBarLabel
: options.title !== undefined : options.title !== undefined
? options.title ? options.title
: route.name; : route.name;
const isFocused = props.state.index === index; const isFocused = props.state.index === index;
@ -1062,6 +1064,14 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
// Handle Android-specific optimizations // Handle Android-specific optimizations
useEffect(() => { useEffect(() => {
if (Platform.OS === 'android') { 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 // Ensure consistent background color for Android
StatusBar.setBackgroundColor('transparent', true); StatusBar.setBackgroundColor('transparent', true);
StatusBar.setTranslucent(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> </Stack.Navigator>
</View> </View>
</PaperProvider> </PaperProvider>

View file

@ -524,8 +524,8 @@ const createStyles = (colors: any) => StyleSheet.create({
opacity: 0.8, opacity: 0.8,
}, },
communityAddonVersion: { communityAddonVersion: {
fontSize: 12, fontSize: 12,
color: colors.lightGray, color: colors.lightGray,
}, },
communityAddonDot: { communityAddonDot: {
fontSize: 12, fontSize: 12,
@ -533,18 +533,18 @@ const createStyles = (colors: any) => StyleSheet.create({
marginHorizontal: 5, marginHorizontal: 5,
}, },
communityAddonCategory: { communityAddonCategory: {
fontSize: 12, fontSize: 12,
color: colors.lightGray, color: colors.lightGray,
flexShrink: 1, flexShrink: 1,
}, },
separator: { separator: {
height: 10, height: 10,
}, },
sectionSeparator: { sectionSeparator: {
height: 1, height: 1,
backgroundColor: colors.border, backgroundColor: colors.border,
marginHorizontal: 20, marginHorizontal: 20,
marginVertical: 20, marginVertical: 20,
}, },
emptyMessage: { emptyMessage: {
textAlign: 'center', textAlign: 'center',
@ -660,11 +660,21 @@ const AddonsScreen = () => {
setLoading(true); setLoading(true);
// Use the regular method without disabled state // Use the regular method without disabled state
const installedAddons = await stremioService.getInstalledAddonsAsync(); 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 // Count catalogs
let totalCatalogs = 0; let totalCatalogs = 0;
installedAddons.forEach(addon => { filteredAddons.forEach(addon => {
if (addon.catalogs && addon.catalogs.length > 0) { if (addon.catalogs && addon.catalogs.length > 0) {
totalCatalogs += addon.catalogs.length; totalCatalogs += addon.catalogs.length;
} }
@ -682,11 +692,11 @@ const AddonsScreen = () => {
setCatalogCount(totalCatalogs); setCatalogCount(totalCatalogs);
} }
} catch (error) { } catch (error) {
logger.error('Failed to load addons:', error); logger.error('Failed to load addons:', error);
setAlertTitle('Error'); setAlertTitle('Error');
setAlertMessage('Failed to load addons'); setAlertMessage('Failed to load addons');
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -706,9 +716,9 @@ const AddonsScreen = () => {
setCommunityAddons(validAddons); setCommunityAddons(validAddons);
} catch (error) { } catch (error) {
logger.error('Failed to load community addons:', error); logger.error('Failed to load community addons:', error);
setCommunityError('Failed to load community addons. Please try again later.'); setCommunityError('Failed to load community addons. Please try again later.');
setCommunityAddons([]); setCommunityAddons([]);
} finally { } finally {
setCommunityLoading(false); setCommunityLoading(false);
} }
@ -756,16 +766,16 @@ const AddonsScreen = () => {
setShowConfirmModal(false); setShowConfirmModal(false);
setAddonDetails(null); setAddonDetails(null);
loadAddons(); loadAddons();
setAlertTitle('Success'); setAlertTitle('Success');
setAlertMessage('Addon installed successfully'); setAlertMessage('Addon installed successfully');
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} catch (error) { } catch (error) {
logger.error('Failed to install addon:', error); logger.error('Failed to install addon:', error);
setAlertTitle('Error'); setAlertTitle('Error');
setAlertMessage('Failed to install addon'); setAlertMessage('Failed to install addon');
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setInstalling(false); setInstalling(false);
} }
@ -927,10 +937,10 @@ const AddonsScreen = () => {
} }
}).catch(err => { }).catch(err => {
logger.error(`Error checking if URL can be opened: ${configUrl}`, err); logger.error(`Error checking if URL can be opened: ${configUrl}`, err);
setAlertTitle('Error'); setAlertTitle('Error');
setAlertMessage('Could not open configuration page.'); setAlertMessage('Could not open configuration page.');
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
}); });
}; };
@ -1077,9 +1087,9 @@ const AddonsScreen = () => {
<Text style={styles.communityAddonName}>{manifest.name}</Text> <Text style={styles.communityAddonName}>{manifest.name}</Text>
<Text style={styles.communityAddonDesc} numberOfLines={2}>{description}</Text> <Text style={styles.communityAddonDesc} numberOfLines={2}>{description}</Text>
<View style={styles.communityAddonMetaContainer}> <View style={styles.communityAddonMetaContainer}>
<Text style={styles.communityAddonVersion}>v{manifest.version || 'N/A'}</Text> <Text style={styles.communityAddonVersion}>v{manifest.version || 'N/A'}</Text>
<Text style={styles.communityAddonDot}></Text> <Text style={styles.communityAddonDot}></Text>
<Text style={styles.communityAddonCategory}>{categoryText}</Text> <Text style={styles.communityAddonCategory}>{categoryText}</Text>
</View> </View>
</View> </View>
<View style={styles.addonActionButtons}> <View style={styles.addonActionButtons}>
@ -1208,7 +1218,7 @@ const AddonsScreen = () => {
autoCorrect={false} autoCorrect={false}
/> />
<TouchableOpacity <TouchableOpacity
style={[styles.addButton, {opacity: installing || !addonUrl ? 0.6 : 1}]} style={[styles.addButton, { opacity: installing || !addonUrl ? 0.6 : 1 }]}
onPress={() => handleAddAddon()} onPress={() => handleAddAddon()}
disabled={installing || !addonUrl} disabled={installing || !addonUrl}
> >
@ -1245,68 +1255,68 @@ const AddonsScreen = () => {
</View> </View>
{/* Separator */} {/* Separator */}
<View style={styles.sectionSeparator} /> <View style={styles.sectionSeparator} />
{/* Promotional Addon Section (hidden if installed) */} {/* Promotional Addon Section (hidden if installed) */}
{!isPromoInstalled && ( {!isPromoInstalled && (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>OFFICIAL ADDON</Text> <Text style={styles.sectionTitle}>OFFICIAL ADDON</Text>
<View style={styles.addonList}> <View style={styles.addonList}>
<View style={styles.addonItem}> <View style={styles.addonItem}>
<View style={styles.addonHeader}> <View style={styles.addonHeader}>
{promoAddon.logo ? ( {promoAddon.logo ? (
<FastImage <FastImage
source={{ uri: promoAddon.logo }} source={{ uri: promoAddon.logo }}
style={styles.addonIcon} style={styles.addonIcon}
resizeMode={FastImage.resizeMode.contain} resizeMode={FastImage.resizeMode.contain}
/> />
) : ( ) : (
<View style={styles.addonIconPlaceholder}> <View style={styles.addonIconPlaceholder}>
<MaterialIcons name="extension" size={22} color={colors.mediumGray} /> <MaterialIcons name="extension" size={22} color={colors.mediumGray} />
</View> </View>
)} )}
<View style={styles.addonTitleContainer}> <View style={styles.addonTitleContainer}>
<Text style={styles.addonName}>{promoAddon.name}</Text> <Text style={styles.addonName}>{promoAddon.name}</Text>
<View style={styles.addonMetaContainer}> <View style={styles.addonMetaContainer}>
<Text style={styles.addonVersion}>v{promoAddon.version}</Text> <Text style={styles.addonVersion}>v{promoAddon.version}</Text>
<Text style={styles.addonDot}></Text> <Text style={styles.addonDot}></Text>
<Text style={styles.addonCategory}>{promoAddon.types?.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')}</Text> <Text style={styles.addonCategory}>{promoAddon.types?.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')}</Text>
</View> </View>
</View> </View>
<View style={styles.addonActions}> <View style={styles.addonActions}>
{promoAddon.behaviorHints?.configurable && ( {promoAddon.behaviorHints?.configurable && (
<TouchableOpacity <TouchableOpacity
style={styles.configButton} style={styles.configButton}
onPress={() => handleConfigureAddon(promoAddon, PROMO_ADDON_URL)} onPress={() => handleConfigureAddon(promoAddon, PROMO_ADDON_URL)}
> >
<MaterialIcons name="settings" size={20} color={colors.primary} /> <MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity> </TouchableOpacity>
)} )}
<TouchableOpacity <TouchableOpacity
style={styles.installButton} style={styles.installButton}
onPress={() => handleAddAddon(PROMO_ADDON_URL)} onPress={() => handleAddAddon(PROMO_ADDON_URL)}
disabled={installing} disabled={installing}
> >
{installing ? ( {installing ? (
<ActivityIndicator size="small" color={colors.white} /> <ActivityIndicator size="small" color={colors.white} />
) : ( ) : (
<MaterialIcons name="add" size={20} color={colors.white} /> <MaterialIcons name="add" size={20} color={colors.white} />
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
<Text style={styles.addonDescription}> <Text style={styles.addonDescription}>
{promoAddon.description} {promoAddon.description}
</Text> </Text>
<Text style={[styles.addonDescription, { marginTop: 4, opacity: 0.9 }]}> <Text style={[styles.addonDescription, { marginTop: 4, opacity: 0.9 }]}>
Configure and install for full functionality. Configure and install for full functionality.
</Text> </Text>
</View> </View>
</View> </View>
</View> </View>
)} )}
{/* Community Addons Section */} {/* Community Addons Section */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>COMMUNITY ADDONS</Text> <Text style={styles.sectionTitle}>COMMUNITY ADDONS</Text>
<View style={styles.addonList}> <View style={styles.addonList}>
@ -1381,8 +1391,8 @@ const AddonsScreen = () => {
<Text style={styles.addonDescription}> <Text style={styles.addonDescription}>
{item.manifest.description {item.manifest.description
? (item.manifest.description.length > 100 ? (item.manifest.description.length > 100
? item.manifest.description.substring(0, 100) + '...' ? item.manifest.description.substring(0, 100) + '...'
: item.manifest.description) : item.manifest.description)
: 'No description provided.'} : 'No description provided.'}
</Text> </Text>
</View> </View>
@ -1515,15 +1525,15 @@ const AddonsScreen = () => {
</View> </View>
</View> </View>
</Modal> </Modal>
{/* Custom Alert Modal */} {/* Custom Alert Modal */}
<CustomAlert <CustomAlert
visible={alertVisible} visible={alertVisible}
title={alertTitle} title={alertTitle}
message={alertMessage} message={alertMessage}
onClose={() => setAlertVisible(false)} onClose={() => setAlertVisible(false)}
actions={alertActions} actions={alertActions}
/> />
</SafeAreaView> </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); setAlertTitle(title);
setAlertMessage(message); setAlertMessage(message);
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true); setAlertVisible(true);
}; };
@ -274,7 +274,7 @@ const SettingsScreen: React.FC = () => {
try { try {
const flag = await mmkvStorage.getItem('@update_badge_pending'); const flag = await mmkvStorage.getItem('@update_badge_pending');
if (mounted) setHasUpdateBadge(flag === 'true'); if (mounted) setHasUpdateBadge(flag === 'true');
} catch {} } catch { }
})(); })();
return () => { mounted = false; }; return () => { mounted = false; };
}, []); }, []);
@ -437,7 +437,7 @@ const SettingsScreen: React.FC = () => {
'Reset Settings', 'Reset Settings',
'Are you sure you want to reset all settings to default values?', 'Are you sure you want to reset all settings to default values?',
[ [
{ label: 'Cancel', onPress: () => {} }, { label: 'Cancel', onPress: () => { } },
{ {
label: 'Reset', label: 'Reset',
onPress: () => { onPress: () => {
@ -455,7 +455,7 @@ const SettingsScreen: React.FC = () => {
'Clear MDBList Cache', 'Clear MDBList Cache',
'Are you sure you want to clear all cached MDBList data? This cannot be undone.', 'Are you sure you want to clear all cached MDBList data? This cannot be undone.',
[ [
{ label: 'Cancel', onPress: () => {} }, { label: 'Cancel', onPress: () => { } },
{ {
label: 'Clear', label: 'Clear',
onPress: async () => { onPress: async () => {
@ -527,6 +527,14 @@ const SettingsScreen: React.FC = () => {
onPress={() => navigation.navigate('Addons')} onPress={() => navigation.navigate('Addons')}
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem
title="Debrid Integration"
description="Connect Torbox for premium streams"
icon="link"
renderControl={ChevronRight}
onPress={() => navigation.navigate('DebridIntegration')}
isTablet={isTablet}
/>
<SettingItem <SettingItem
title="Plugins" title="Plugins"
description="Manage plugins and repositories" description="Manage plugins and repositories"
@ -756,6 +764,21 @@ const SettingsScreen: React.FC = () => {
renderControl={ChevronRight} renderControl={ChevronRight}
isTablet={isTablet} 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 <SettingItem
title="Clear All Data" title="Clear All Data"
icon="trash-2" icon="trash-2"
@ -764,7 +787,7 @@ const SettingsScreen: React.FC = () => {
'Clear All Data', 'Clear All Data',
'This will reset all settings and clear all cached data. Are you sure?', 'This will reset all settings and clear all cached data. Are you sure?',
[ [
{ label: 'Cancel', onPress: () => {} }, { label: 'Cancel', onPress: () => { } },
{ {
label: 'Clear', label: 'Clear',
onPress: async () => { onPress: async () => {
@ -824,7 +847,7 @@ const SettingsScreen: React.FC = () => {
badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined} badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined}
onPress={async () => { onPress={async () => {
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
try { await mmkvStorage.removeItem('@update_badge_pending'); } catch {} try { await mmkvStorage.removeItem('@update_badge_pending'); } catch { }
setHasUpdateBadge(false); setHasUpdateBadge(false);
} }
navigation.navigate('Update'); navigation.navigate('Update');

View file

@ -928,8 +928,16 @@ class CatalogService {
public subscribeToLibraryUpdates(callback: (items: StreamingContent[]) => void): () => void { public subscribeToLibraryUpdates(callback: (items: StreamingContent[]) => void): () => void {
this.librarySubscribers.push(callback); this.librarySubscribers.push(callback);
// Initial callback with current items // Defer initial callback to next tick to avoid synchronous state updates during render
this.getLibraryItems().then(items => callback(items)); // 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 unsubscribe function
return () => { return () => {

View file

@ -56,6 +56,8 @@ class NotificationService {
private appStateSubscription: any = null; private appStateSubscription: any = null;
private lastSyncTime: number = 0; private lastSyncTime: number = 0;
private readonly MIN_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes minimum between syncs 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() { private constructor() {
// Initialize notifications // Initialize notifications
@ -157,8 +159,8 @@ class NotificationService {
// Check if notification already exists for this episode // Check if notification already exists for this episode
const existingNotification = this.scheduledNotifications.find( const existingNotification = this.scheduledNotifications.find(
notification => notification.seriesId === item.seriesId && notification => notification.seriesId === item.seriesId &&
notification.season === item.season && notification.season === item.season &&
notification.episode === item.episode notification.episode === item.episode
); );
if (existingNotification) { if (existingNotification) {
return null; // Don't schedule duplicate notifications return null; // Don't schedule duplicate notifications
@ -327,6 +329,21 @@ class NotificationService {
try { try {
if (!this.settings.enabled) return; if (!this.settings.enabled) return;
if (AppState.currentState === 'active') 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 downloadedMb = Math.floor((downloadedBytes || 0) / (1024 * 1024));
const totalMb = totalBytes ? Math.floor(totalBytes / (1024 * 1024)) : undefined; const totalMb = totalBytes ? Math.floor(totalBytes / (1024 * 1024)) : undefined;
const body = `${progress}%` + (totalMb !== undefined ? `${downloadedMb}MB / ${totalMb}MB` : ''); const body = `${progress}%` + (totalMb !== undefined ? `${downloadedMb}MB / ${totalMb}MB` : '');
@ -348,6 +365,7 @@ class NotificationService {
try { try {
if (!this.settings.enabled) return; if (!this.settings.enabled) return;
if (AppState.currentState === 'active') return; if (AppState.currentState === 'active') return;
await Notifications.scheduleNotificationAsync({ await Notifications.scheduleNotificationAsync({
content: { content: {
title: 'Download complete', title: 'Download complete',
@ -356,6 +374,9 @@ class NotificationService {
}, },
trigger: null, trigger: null,
}); });
// Clean up tracking entry after completion to prevent memory leaks
this.lastDownloadNotificationTime.delete(title);
} catch (error) { } catch (error) {
logger.error('[NotificationService] notifyDownloadComplete error:', error); logger.error('[NotificationService] notifyDownloadComplete error:', error);
} }
@ -716,7 +737,7 @@ class NotificationService {
this.scheduledNotifications = validNotifications; this.scheduledNotifications = validNotifications;
await this.saveScheduledNotifications(); await this.saveScheduledNotifications();
// Reduced logging verbosity // 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) { } catch (error) {
logger.error('[NotificationService] Error cleaning up notifications:', error); logger.error('[NotificationService] Error cleaning up notifications:', error);

View file

@ -52,6 +52,14 @@ export interface TraktWatchedItem {
}; };
plays: number; plays: number;
last_watched_at: string; last_watched_at: string;
seasons?: {
number: number;
episodes: {
number: number;
plays: number;
last_watched_at: string;
}[];
}[];
} }
export interface TraktWatchlistItem { export interface TraktWatchlistItem {

View file

@ -1,7 +1,7 @@
// Single source of truth for the app version displayed in Settings // Single source of truth for the app version displayed in Settings
// Update this when bumping app version // Update this when bumping app version
export const APP_VERSION = '1.2.9'; export const APP_VERSION = '1.2.10';
export function getDisplayedAppVersion(): string { export function getDisplayedAppVersion(): string {
return APP_VERSION; return APP_VERSION;