mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-21 08:41:57 +00:00
Merge branch 'main' into android-nav-bar-fix
This commit is contained in:
commit
14980f2bfd
32 changed files with 3630 additions and 2167 deletions
48
App.tsx
48
App.tsx
|
|
@ -41,6 +41,7 @@ import { aiService } from './src/services/aiService';
|
|||
import { AccountProvider, useAccount } from './src/contexts/AccountContext';
|
||||
import { ToastProvider } from './src/contexts/ToastContext';
|
||||
import { mmkvStorage } from './src/services/mmkvStorage';
|
||||
import AnnouncementOverlay from './src/components/AnnouncementOverlay';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
|
||||
|
|
@ -82,11 +83,12 @@ const ThemedApp = () => {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const engine = (global as any).HermesInternal ? 'Hermes' : 'JSC';
|
||||
console.log('JS Engine:', engine);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}, []);
|
||||
const { currentTheme } = useTheme();
|
||||
const [isAppReady, setIsAppReady] = useState(false);
|
||||
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState<boolean | null>(null);
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
|
||||
// Update popup functionality
|
||||
const {
|
||||
|
|
@ -101,6 +103,16 @@ const ThemedApp = () => {
|
|||
// GitHub major/minor release overlay
|
||||
const githubUpdate = useGithubMajorUpdate();
|
||||
|
||||
// Announcement data
|
||||
const announcements = [
|
||||
{
|
||||
icon: 'zap',
|
||||
title: 'Debrid Integration',
|
||||
description: 'Unlock 4K high-quality streams with lightning-fast speeds. Connect your TorBox account to access cached premium content with zero buffering.',
|
||||
tag: 'NEW',
|
||||
},
|
||||
];
|
||||
|
||||
// Check onboarding status and initialize services
|
||||
useEffect(() => {
|
||||
const initializeApp = async () => {
|
||||
|
|
@ -120,6 +132,15 @@ const ThemedApp = () => {
|
|||
await aiService.initialize();
|
||||
console.log('AI service initialized');
|
||||
|
||||
// Check if announcement should be shown (version 1.0.0)
|
||||
const announcementShown = await mmkvStorage.getItem('announcement_v1.0.0_shown');
|
||||
if (!announcementShown && onboardingCompleted === 'true') {
|
||||
// Show announcement only after app is ready
|
||||
setTimeout(() => {
|
||||
setShowAnnouncement(true);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing app:', error);
|
||||
// Default to showing onboarding if we can't check
|
||||
|
|
@ -154,6 +175,23 @@ const ThemedApp = () => {
|
|||
setIsAppReady(true);
|
||||
};
|
||||
|
||||
// Navigation reference
|
||||
const navigationRef = React.useRef<any>(null);
|
||||
|
||||
// Handler for navigating to debrid integration
|
||||
const handleNavigateToDebrid = () => {
|
||||
if (navigationRef.current) {
|
||||
navigationRef.current.navigate('DebridIntegration');
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for announcement close
|
||||
const handleAnnouncementClose = async () => {
|
||||
setShowAnnouncement(false);
|
||||
// Mark announcement as shown
|
||||
await mmkvStorage.setItem('announcement_v1.0.0_shown', 'true');
|
||||
};
|
||||
|
||||
// Don't render anything until we know the onboarding status
|
||||
const shouldShowApp = isAppReady && hasCompletedOnboarding !== null;
|
||||
const initialRouteName = hasCompletedOnboarding ? 'MainTabs' : 'Onboarding';
|
||||
|
|
@ -162,6 +200,7 @@ const ThemedApp = () => {
|
|||
<AccountProvider>
|
||||
<PaperProvider theme={customDarkTheme}>
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
theme={customNavigationTheme}
|
||||
linking={undefined}
|
||||
>
|
||||
|
|
@ -186,6 +225,13 @@ const ThemedApp = () => {
|
|||
onDismiss={githubUpdate.onDismiss}
|
||||
onLater={githubUpdate.onLater}
|
||||
/>
|
||||
<AnnouncementOverlay
|
||||
visible={showAnnouncement}
|
||||
announcements={announcements}
|
||||
onClose={handleAnnouncementClose}
|
||||
onActionPress={handleNavigateToDebrid}
|
||||
actionButtonText="Connect Now"
|
||||
/>
|
||||
</View>
|
||||
</DownloadsProvider>
|
||||
</NavigationContainer>
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ android {
|
|||
applicationId 'com.nuvio.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 24
|
||||
versionName "1.2.9"
|
||||
versionCode 25
|
||||
versionName "1.2.10"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ android {
|
|||
def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
def baseVersionCode = 24 // Current versionCode 24 from defaultConfig
|
||||
def baseVersionCode = 25 // Current versionCode 25 from defaultConfig
|
||||
def abiName = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
|
||||
def versionCode = baseVersionCode * 100 // Base multiplier
|
||||
|
|
@ -209,6 +209,10 @@ sentry {
|
|||
}
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
exclude group: 'com.caverock', module: 'androidsvg'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// @generated begin react-native-google-cast-dependencies - expo prebuild (DO NOT MODIFY) sync-3822a3c86222e7aca74039b551612aab7e75365d
|
||||
implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}"
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@
|
|||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
|
||||
<string name="expo_runtime_version">1.2.9</string>
|
||||
<string name="expo_runtime_version">1.2.10</string>
|
||||
</resources>
|
||||
8
app.json
8
app.json
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Nuvio",
|
||||
"slug": "nuvio",
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.10",
|
||||
"orientation": "default",
|
||||
"backgroundColor": "#020404",
|
||||
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
"supportsTablet": true,
|
||||
"requireFullScreen": true,
|
||||
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
||||
"buildNumber": "24",
|
||||
"buildNumber": "25",
|
||||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
"WRITE_SETTINGS"
|
||||
],
|
||||
"package": "com.nuvio.app",
|
||||
"versionCode": 24,
|
||||
"versionCode": 25,
|
||||
"architectures": [
|
||||
"arm64-v8a",
|
||||
"armeabi-v7a",
|
||||
|
|
@ -105,6 +105,6 @@
|
|||
"fallbackToCacheTimeout": 30000,
|
||||
"url": "https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest"
|
||||
},
|
||||
"runtimeVersion": "1.2.9"
|
||||
"runtimeVersion": "1.2.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -385,9 +385,11 @@ class KSPlayerView: UIView {
|
|||
options.asynchronousDecompression = true
|
||||
#endif
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Native HDR processing
|
||||
// Set destination dynamic range based on device capabilities to eliminate unnecessary color conversions
|
||||
options.destinationDynamicRange = getOptimalDynamicRange()
|
||||
// HDR handling: Let KSPlayer automatically detect content's native dynamic range
|
||||
// Setting destinationDynamicRange to nil allows KSPlayer to use the content's actual HDR/SDR mode
|
||||
// This prevents forcing HDR tone mapping on SDR content (which causes oversaturation)
|
||||
// KSPlayer will automatically detect HDR10/Dolby Vision/HLG from the video format description
|
||||
options.destinationDynamicRange = nil
|
||||
|
||||
// Configure audio for proper dialogue mixing using FFmpeg's pan filter
|
||||
// This approach uses standard audio engineering practices for multi-channel downmixing
|
||||
|
|
@ -455,9 +457,24 @@ class KSPlayerView: UIView {
|
|||
return
|
||||
}
|
||||
|
||||
playerView.seek(time: time) { success in
|
||||
// Capture the current paused state before seeking
|
||||
let wasPaused = isPaused
|
||||
print("KSPlayerView: Seeking to \(time), paused state before seek: \(wasPaused)")
|
||||
|
||||
playerView.seek(time: time) { [weak self] success in
|
||||
guard let self = self else { return }
|
||||
|
||||
if success {
|
||||
print("KSPlayerView: Seek successful to \(time)")
|
||||
|
||||
// Restore the paused state after seeking
|
||||
// KSPlayer's seek may resume playback, so we need to re-apply the paused state
|
||||
if wasPaused {
|
||||
DispatchQueue.main.async {
|
||||
self.playerView.pause()
|
||||
print("KSPlayerView: Restored paused state after seek")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("KSPlayerView: Seek failed to \(time)")
|
||||
}
|
||||
|
|
@ -804,40 +821,6 @@ class KSPlayerView: UIView {
|
|||
}
|
||||
|
||||
// MARK: - Performance Optimization Helpers
|
||||
|
||||
/// Detects device HDR capabilities and returns optimal dynamic range setting
|
||||
/// This prevents unnecessary color space conversion overhead
|
||||
private func getOptimalDynamicRange() -> DynamicRange? {
|
||||
#if canImport(UIKit)
|
||||
let availableHDRModes = AVPlayer.availableHDRModes
|
||||
|
||||
// If no HDR modes available, use SDR (nil will use content's native range)
|
||||
if availableHDRModes == AVPlayer.HDRMode(rawValue: 0) {
|
||||
return .sdr
|
||||
}
|
||||
|
||||
// Prefer HDR10 if supported (most common HDR format)
|
||||
if availableHDRModes.contains(.hdr10) {
|
||||
return .hdr10
|
||||
}
|
||||
|
||||
// Fallback to Dolby Vision if available
|
||||
if availableHDRModes.contains(.dolbyVision) {
|
||||
return .dolbyVision
|
||||
}
|
||||
|
||||
// Fallback to HLG if available
|
||||
if availableHDRModes.contains(.hlg) {
|
||||
return .hlg
|
||||
}
|
||||
|
||||
// Default to SDR if no HDR support
|
||||
return .sdr
|
||||
#else
|
||||
// macOS: Check screen capabilities
|
||||
return .sdr
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - High Performance KSOptions Subclass
|
||||
|
|
|
|||
|
|
@ -477,7 +477,7 @@
|
|||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
@ -508,8 +508,8 @@
|
|||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.9</string>
|
||||
<string>1.2.10</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>24</string>
|
||||
<string>25</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<key>EXUpdatesLaunchWaitMs</key>
|
||||
<integer>30000</integer>
|
||||
<key>EXUpdatesRuntimeVersion</key>
|
||||
<string>1.2.9</string>
|
||||
<string>1.2.10</string>
|
||||
<key>EXUpdatesURL</key>
|
||||
<string>https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest</string>
|
||||
</dict>
|
||||
|
|
|
|||
|
|
@ -3178,13 +3178,13 @@ EXTERNAL SOURCES:
|
|||
|
||||
CHECKOUT OPTIONS:
|
||||
DisplayCriteria:
|
||||
:commit: 83ba8419ca365e9397c0b45c4147755da522324e
|
||||
:commit: cbc74996afb55e096bf1ff240f07d1d206ac86df
|
||||
:git: https://github.com/kingslay/KSPlayer.git
|
||||
FFmpegKit:
|
||||
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
|
||||
:git: https://github.com/kingslay/FFmpegKit.git
|
||||
KSPlayer:
|
||||
:commit: 83ba8419ca365e9397c0b45c4147755da522324e
|
||||
:commit: cbc74996afb55e096bf1ff240f07d1d206ac86df
|
||||
:git: https://github.com/kingslay/KSPlayer.git
|
||||
Libass:
|
||||
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@
|
|||
"https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true"
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.10",
|
||||
"buildVersion": "25",
|
||||
"date": "2025-11-25",
|
||||
"localizedDescription": "# Nuvio Media Hub – v1.2.10 \n\n## Update Notes\n- **Dependency updates** for stability and performance \n- **Trakt optimizations** for smoother syncing \n- **Subtitle RTL detection** improvements for better language handling \n- **KSPlayer** pause behavior improvements \n- Fixed incorrect **HDR detection logic** in KSPlayer \n- Simplified **This Week’s section** card UI for a cleaner look \n\n## 📦 Changelog & Download\n[View on GitHub](https://github.com/tapframe/NuvioStreaming/releases/tag/1.2.10)\n\n🌐 **Official Website:** [tapframe.github.io/NuvioStreaming](https://tapframe.github.io/NuvioStreaming)\n\nIf you like **Nuvio Media Hub**, please consider **⭐ starring it on GitHub**. It really helps the project grow \n[⭐ Star on GitHub](https://github.com/tapframe/NuvioStreaming)",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.2.10/Stable_1-2-10.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.2.9",
|
||||
"buildVersion": "24",
|
||||
|
|
|
|||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -12,7 +12,7 @@
|
|||
"@adrianso/react-native-device-brightness": "^1.2.7",
|
||||
"@backpackapp-io/react-native-toast": "^0.15.1",
|
||||
"@bottom-tabs/react-navigation": "^1.0.2",
|
||||
"@d11/react-native-fast-image": "^8.8.0",
|
||||
"@d11/react-native-fast-image": "^8.13.0",
|
||||
"@expo/env": "^2.0.7",
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"@adrianso/react-native-device-brightness": "^1.2.7",
|
||||
"@backpackapp-io/react-native-toast": "^0.15.1",
|
||||
"@bottom-tabs/react-navigation": "^1.0.2",
|
||||
"@d11/react-native-fast-image": "^8.8.0",
|
||||
"@d11/react-native-fast-image": "^8.13.0",
|
||||
"@expo/env": "^2.0.7",
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
|
|
|
|||
308
src/components/AnnouncementOverlay.tsx
Normal file
308
src/components/AnnouncementOverlay.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
interface Announcement {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
interface AnnouncementOverlayProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onActionPress?: () => void;
|
||||
title?: string;
|
||||
announcements: Announcement[];
|
||||
actionButtonText?: string;
|
||||
}
|
||||
|
||||
const AnnouncementOverlay: React.FC<AnnouncementOverlayProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onActionPress,
|
||||
title = "What's New",
|
||||
announcements,
|
||||
actionButtonText = "Got it!",
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const colors = currentTheme.colors;
|
||||
|
||||
const scaleAnim = useRef(new Animated.Value(0.8)).current;
|
||||
const opacityAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
tension: 50,
|
||||
friction: 7,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
scaleAnim.setValue(0.8);
|
||||
opacityAnim.setValue(0);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleClose = () => {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 0.8,
|
||||
tension: 50,
|
||||
friction: 7,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
onClose();
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
if (onActionPress) {
|
||||
handleClose();
|
||||
// Delay navigation slightly to allow animation to complete
|
||||
setTimeout(() => {
|
||||
onActionPress();
|
||||
}, 300);
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
opacity: opacityAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
{/* Close Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={handleClose}
|
||||
>
|
||||
<Feather name="x" size={20} color={colors.white} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: colors.primary + '20' }]}>
|
||||
<Feather name="zap" size={32} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.title, { color: colors.white }]}>{title}</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.mediumEmphasis }]}>
|
||||
Exciting updates in this release
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Announcements */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{announcements.map((announcement, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={styles.announcementItem}
|
||||
>
|
||||
<View style={[styles.announcementIcon, { backgroundColor: colors.primary + '15' }]}>
|
||||
<Feather name={announcement.icon as any} size={24} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.announcementContent}>
|
||||
<View style={styles.announcementHeader}>
|
||||
<Text style={[styles.announcementTitle, { color: colors.white }]}>
|
||||
{announcement.title}
|
||||
</Text>
|
||||
{announcement.tag && (
|
||||
<View style={[styles.tag, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.tagText}>{announcement.tag}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.announcementDescription, { color: colors.mediumEmphasis }]}>
|
||||
{announcement.description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Action Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||
onPress={handleAction}
|
||||
>
|
||||
<Text style={styles.buttonText}>{actionButtonText}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
},
|
||||
container: {
|
||||
width: width * 0.9,
|
||||
maxWidth: 500,
|
||||
maxHeight: height * 0.8,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: 24,
|
||||
padding: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#2a2a2a',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.9,
|
||||
},
|
||||
scrollView: {
|
||||
maxHeight: height * 0.45,
|
||||
marginBottom: 20,
|
||||
},
|
||||
announcementItem: {
|
||||
backgroundColor: '#252525',
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
announcementIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
announcementContent: {
|
||||
flex: 1,
|
||||
},
|
||||
announcementHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
announcementTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.3,
|
||||
flex: 1,
|
||||
},
|
||||
tag: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
marginLeft: 8,
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
announcementDescription: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
opacity: 0.9,
|
||||
},
|
||||
button: {
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export default AnnouncementOverlay;
|
||||
|
|
@ -188,6 +188,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
// Animation values
|
||||
const dragProgress = useSharedValue(0);
|
||||
const dragDirection = useSharedValue(0); // -1 for left, 1 for right
|
||||
const isDragging = useSharedValue(0); // 1 when dragging, 0 when not
|
||||
const logoOpacity = useSharedValue(1);
|
||||
const [nextIndex, setNextIndex] = useState(currentIndex);
|
||||
const thumbnailOpacity = useSharedValue(1);
|
||||
|
|
@ -197,11 +198,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
|
||||
// Animated style for trailer container - 60% height with zoom
|
||||
const trailerContainerStyle = useAnimatedStyle(() => {
|
||||
// Fade out trailer during drag with smooth curve (inverse of next image fade)
|
||||
// Faster fade out during drag - complete fade by 0.3 progress instead of 1.0
|
||||
const dragFade = interpolate(
|
||||
dragProgress.value,
|
||||
[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.85, 1],
|
||||
[1, 0.95, 0.88, 0.78, 0.65, 0.5, 0.35, 0.22, 0.08, 0],
|
||||
[0, 0.05, 0.1, 0.15, 0.2, 0.3],
|
||||
[1, 0.85, 0.65, 0.4, 0.15, 0],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
|
||||
|
|
@ -225,11 +226,21 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
};
|
||||
});
|
||||
|
||||
// Parallax style for background images
|
||||
// Parallax style for background images - disabled during drag
|
||||
const backgroundParallaxStyle = useAnimatedStyle(() => {
|
||||
'worklet';
|
||||
const scrollYValue = scrollY.value;
|
||||
|
||||
// Disable parallax during drag to avoid transform conflicts
|
||||
if (isDragging.value > 0) {
|
||||
return {
|
||||
transform: [
|
||||
{ scale: 1.0 },
|
||||
{ translateY: 0 }
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-calculated constants - start at 1.0 for normal size
|
||||
const DEFAULT_ZOOM = 1.0;
|
||||
const SCROLL_UP_MULTIPLIER = 0.002;
|
||||
|
|
@ -253,11 +264,21 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
};
|
||||
});
|
||||
|
||||
// Parallax style for trailer
|
||||
// Parallax style for trailer - disabled during drag
|
||||
const trailerParallaxStyle = useAnimatedStyle(() => {
|
||||
'worklet';
|
||||
const scrollYValue = scrollY.value;
|
||||
|
||||
// Disable parallax during drag to avoid transform conflicts
|
||||
if (isDragging.value > 0) {
|
||||
return {
|
||||
transform: [
|
||||
{ scale: 1.0 },
|
||||
{ translateY: 0 }
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-calculated constants - start at 1.0 for normal size
|
||||
const DEFAULT_ZOOM = 1.0;
|
||||
const SCROLL_UP_MULTIPLIER = 0.0015;
|
||||
|
|
@ -580,6 +601,9 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
.activeOffsetX([-5, 5]) // Smaller activation area - more sensitive
|
||||
.failOffsetY([-15, 15]) // Fail if vertical movement is detected
|
||||
.onStart(() => {
|
||||
// Mark as dragging to disable parallax
|
||||
isDragging.value = 1;
|
||||
|
||||
// Determine which direction and set preview
|
||||
runOnJS(updateInteractionTime)();
|
||||
// Immediately stop trailer playback when drag starts
|
||||
|
|
@ -626,6 +650,9 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
},
|
||||
(finished) => {
|
||||
if (finished) {
|
||||
// Re-enable parallax after navigation completes
|
||||
isDragging.value = withTiming(0, { duration: 200 });
|
||||
|
||||
if (translationX > 0) {
|
||||
runOnJS(goToPrevious)();
|
||||
} else {
|
||||
|
|
@ -640,6 +667,9 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
duration: 450,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1), // Custom ease-out for buttery smooth return
|
||||
});
|
||||
|
||||
// Re-enable parallax immediately on cancel
|
||||
isDragging.value = withTiming(0, { duration: 200 });
|
||||
}
|
||||
}),
|
||||
[goToPrevious, goToNext, updateInteractionTime, setPreviewIndex, hideTrailerOnDrag, currentIndex, items.length]
|
||||
|
|
|
|||
|
|
@ -89,7 +89,9 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
// Subscribe to library updates and update local state if this item's status changes
|
||||
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
|
||||
const found = items.find((libItem) => libItem.id === item.id && libItem.type === item.type);
|
||||
setInLibrary(!!found);
|
||||
const newInLibrary = !!found;
|
||||
// Only update state if the value actually changed to prevent unnecessary re-renders
|
||||
setInLibrary(prev => prev !== newInLibrary ? newInLibrary : prev);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [item.id, item.type]);
|
||||
|
|
|
|||
|
|
@ -334,13 +334,67 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
if (!isAuthed) return new Set<string>();
|
||||
if (typeof (traktService as any).getWatchedMovies === 'function') {
|
||||
const watched = await (traktService as any).getWatchedMovies();
|
||||
const watchedSet = new Set<string>();
|
||||
|
||||
if (Array.isArray(watched)) {
|
||||
const ids = watched
|
||||
.map((w: any) => w?.movie?.ids?.imdb)
|
||||
.filter(Boolean)
|
||||
.map((imdb: string) => (imdb.startsWith('tt') ? imdb : `tt${imdb}`));
|
||||
return new Set<string>(ids);
|
||||
watched.forEach((w: any) => {
|
||||
const ids = w?.movie?.ids;
|
||||
if (!ids) return;
|
||||
|
||||
if (ids.imdb) {
|
||||
const imdb = ids.imdb;
|
||||
watchedSet.add(imdb.startsWith('tt') ? imdb : `tt${imdb}`);
|
||||
}
|
||||
if (ids.tmdb) {
|
||||
watchedSet.add(ids.tmdb.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
return watchedSet;
|
||||
}
|
||||
return new Set<string>();
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
})();
|
||||
|
||||
// Fetch Trakt watched shows once and reuse
|
||||
const traktShowsSetPromise = (async () => {
|
||||
try {
|
||||
const traktService = TraktService.getInstance();
|
||||
const isAuthed = await traktService.isAuthenticated();
|
||||
if (!isAuthed) return new Set<string>();
|
||||
|
||||
if (typeof (traktService as any).getWatchedShows === 'function') {
|
||||
const watched = await (traktService as any).getWatchedShows();
|
||||
const watchedSet = new Set<string>();
|
||||
|
||||
if (Array.isArray(watched)) {
|
||||
watched.forEach((show: any) => {
|
||||
const ids = show?.show?.ids;
|
||||
if (!ids) return;
|
||||
|
||||
const imdbId = ids.imdb;
|
||||
const tmdbId = ids.tmdb;
|
||||
|
||||
if (show.seasons && Array.isArray(show.seasons)) {
|
||||
show.seasons.forEach((season: any) => {
|
||||
if (season.episodes && Array.isArray(season.episodes)) {
|
||||
season.episodes.forEach((episode: any) => {
|
||||
if (imdbId) {
|
||||
const cleanImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
|
||||
watchedSet.add(`${cleanImdbId}:${season.number}:${episode.number}`);
|
||||
}
|
||||
if (tmdbId) {
|
||||
watchedSet.add(`${tmdbId}:${season.number}:${episode.number}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return watchedSet;
|
||||
}
|
||||
return new Set<string>();
|
||||
} catch {
|
||||
|
|
@ -365,7 +419,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
traktSynced: true,
|
||||
traktProgress: 100,
|
||||
} as any);
|
||||
} catch (_e) {}
|
||||
} catch (_e) { }
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -422,6 +476,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
let season: number | undefined;
|
||||
let episodeNumber: number | undefined;
|
||||
let episodeTitle: string | undefined;
|
||||
let isWatchedOnTrakt = false;
|
||||
|
||||
if (episodeId && group.type === 'series') {
|
||||
let match = episodeId.match(/s(\d+)e(\d+)/i);
|
||||
if (match) {
|
||||
|
|
@ -442,6 +498,61 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this specific episode is watched on Trakt
|
||||
if (season !== undefined && episodeNumber !== undefined) {
|
||||
const watchedEpisodesSet = await traktShowsSetPromise;
|
||||
// Try with both raw ID and tt-prefixed ID, and TMDB ID (which is just the ID string)
|
||||
const rawId = group.id.replace(/^tt/, '');
|
||||
const ttId = `tt${rawId}`;
|
||||
|
||||
if (watchedEpisodesSet.has(`${ttId}:${season}:${episodeNumber}`) ||
|
||||
watchedEpisodesSet.has(`${rawId}:${season}:${episodeNumber}`) ||
|
||||
watchedEpisodesSet.has(`${group.id}:${season}:${episodeNumber}`)) {
|
||||
isWatchedOnTrakt = true;
|
||||
|
||||
// Update local storage to reflect watched status
|
||||
try {
|
||||
await storageService.setWatchProgress(
|
||||
group.id,
|
||||
'series',
|
||||
{
|
||||
currentTime: 1,
|
||||
duration: 1,
|
||||
lastUpdated: Date.now(),
|
||||
traktSynced: true,
|
||||
traktProgress: 100,
|
||||
} as any,
|
||||
episodeId
|
||||
);
|
||||
} catch (_e) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If watched on Trakt, treat it as completed (try to find next episode)
|
||||
if (isWatchedOnTrakt) {
|
||||
let nextSeason = season;
|
||||
let nextEpisode = (episodeNumber || 0) + 1;
|
||||
|
||||
if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) {
|
||||
const nextEpisodeVideo = metadata.videos.find((video: any) =>
|
||||
video.season === nextSeason && video.episode === nextEpisode
|
||||
);
|
||||
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
|
||||
batch.push({
|
||||
...basicContent,
|
||||
id: group.id,
|
||||
type: group.type,
|
||||
progress: 0,
|
||||
lastUpdated: progress.lastUpdated,
|
||||
season: nextSeason,
|
||||
episode: nextEpisode,
|
||||
episodeTitle: `Episode ${nextEpisode}`,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.push({
|
||||
|
|
@ -650,7 +761,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadContinueWatching(true);
|
||||
return () => {};
|
||||
return () => { };
|
||||
}, [loadContinueWatching])
|
||||
);
|
||||
|
||||
|
|
@ -798,7 +909,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
{
|
||||
label: 'Cancel',
|
||||
style: { color: '#888' },
|
||||
onPress: () => {},
|
||||
onPress: () => { },
|
||||
},
|
||||
{
|
||||
label: 'Remove',
|
||||
|
|
@ -906,19 +1017,19 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
{item.name}
|
||||
</Text>
|
||||
{isUpNext && (
|
||||
<View style={[
|
||||
styles.progressBadge,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
|
||||
paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3
|
||||
}
|
||||
]}>
|
||||
<View style={[
|
||||
styles.progressBadge,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
|
||||
paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.progressText,
|
||||
{ fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12 }
|
||||
]}>Up Next</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
|
@ -1055,7 +1166,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
]}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
onEndReachedThreshold={0.7}
|
||||
onEndReached={() => {}}
|
||||
onEndReached={() => { }}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
|
||||
|
|
@ -1209,7 +1320,7 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
contentItem: {
|
||||
width: POSTER_WIDTH,
|
||||
aspectRatio: 2/3,
|
||||
aspectRatio: 2 / 3,
|
||||
margin: 0,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ interface ThisWeekEpisode {
|
|||
vote_average: number;
|
||||
still_path: string | null;
|
||||
season_poster_path: string | null;
|
||||
// Grouping fields
|
||||
isGroup?: boolean;
|
||||
episodeCount?: number;
|
||||
episodeRange?: string;
|
||||
}
|
||||
|
||||
export const ThisWeekSection = React.memo(() => {
|
||||
|
|
@ -134,16 +138,72 @@ export const ThisWeekSection = React.memo(() => {
|
|||
const thisWeekSection = calendarData.find(section => section.title === 'This Week');
|
||||
if (!thisWeekSection) return [];
|
||||
|
||||
// Limit episodes to prevent memory issues and add release status
|
||||
const episodes = memoryManager.limitArraySize(thisWeekSection.data, 20); // Limit to 20 for home screen
|
||||
// Get raw episodes (limit to 60 to be safe for performance but allow grouping)
|
||||
const rawEpisodes = memoryManager.limitArraySize(thisWeekSection.data, 60);
|
||||
|
||||
return episodes.map(episode => ({
|
||||
...episode,
|
||||
isReleased: episode.releaseDate ? isBefore(parseISO(episode.releaseDate), new Date()) : false,
|
||||
}));
|
||||
// Group by series and date
|
||||
const groups: Record<string, typeof rawEpisodes> = {};
|
||||
|
||||
rawEpisodes.forEach(ep => {
|
||||
// Create a unique key for series + date
|
||||
const dateKey = ep.releaseDate || 'unknown';
|
||||
const key = `${ep.seriesId}_${dateKey}`;
|
||||
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(ep);
|
||||
});
|
||||
|
||||
const processedItems: ThisWeekEpisode[] = [];
|
||||
|
||||
Object.values(groups).forEach(group => {
|
||||
// Sort episodes in the group by episode number
|
||||
group.sort((a, b) => a.episode - b.episode);
|
||||
|
||||
const firstEp = group[0];
|
||||
const isReleased = firstEp.releaseDate ? isBefore(parseISO(firstEp.releaseDate), new Date()) : false;
|
||||
|
||||
if (group.length === 1) {
|
||||
processedItems.push({
|
||||
...firstEp,
|
||||
isReleased
|
||||
});
|
||||
} else {
|
||||
// Create group item
|
||||
const lastEp = group[group.length - 1];
|
||||
processedItems.push({
|
||||
...firstEp,
|
||||
id: `group_${firstEp.seriesId}_${firstEp.releaseDate}`, // Unique ID for the group
|
||||
title: `${group.length} New Episodes`,
|
||||
isReleased,
|
||||
isGroup: true,
|
||||
episodeCount: group.length,
|
||||
episodeRange: `E${firstEp.episode}-${lastEp.episode}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by release date
|
||||
processedItems.sort((a, b) => {
|
||||
if (!a.releaseDate) return 1;
|
||||
if (!b.releaseDate) return -1;
|
||||
return a.releaseDate.localeCompare(b.releaseDate);
|
||||
});
|
||||
|
||||
return memoryManager.limitArraySize(processedItems, 20);
|
||||
}, [calendarData]);
|
||||
|
||||
const handleEpisodePress = (episode: ThisWeekEpisode) => {
|
||||
// For grouped episodes, always go to series details
|
||||
if (episode.isGroup) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: episode.seriesId,
|
||||
type: 'series'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For upcoming episodes, go to the metadata screen
|
||||
if (!episode.isReleased) {
|
||||
const episodeId = `${episode.seriesId}:${episode.season}:${episode.episode}`;
|
||||
|
|
@ -175,7 +235,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => {
|
||||
// Handle episodes without release dates gracefully
|
||||
const releaseDate = item.releaseDate ? parseISO(item.releaseDate) : null;
|
||||
const formattedDate = releaseDate ? format(releaseDate, 'E, MMM d') : 'TBA';
|
||||
const formattedDate = releaseDate ? format(releaseDate, 'MMM d') : 'TBA';
|
||||
const isReleased = item.isReleased;
|
||||
|
||||
// Use episode still image if available, fallback to series poster
|
||||
|
|
@ -187,106 +247,93 @@ export const ThisWeekSection = React.memo(() => {
|
|||
|
||||
return (
|
||||
<View style={[styles.episodeItemContainer, { width: computedItemWidth, height: computedItemHeight }]}>
|
||||
{item.isGroup && (
|
||||
<View style={[
|
||||
styles.cardStackEffect,
|
||||
{
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
}
|
||||
]} />
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.episodeItem,
|
||||
{
|
||||
shadowColor: currentTheme.colors.black,
|
||||
backgroundColor: currentTheme.colors.background,
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
borderWidth: 1,
|
||||
}
|
||||
]}
|
||||
onPress={() => handleEpisodePress(item)}
|
||||
activeOpacity={0.8}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.imageContainer}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: imageUrl || undefined,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={styles.poster}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: imageUrl || undefined,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={styles.poster}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
|
||||
{/* Enhanced gradient overlay */}
|
||||
<LinearGradient
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'transparent',
|
||||
'transparent',
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.8)',
|
||||
'rgba(0,0,0,0.95)'
|
||||
'rgba(0,0,0,0.0)',
|
||||
'rgba(0,0,0,0.5)',
|
||||
'rgba(0,0,0,0.9)'
|
||||
]}
|
||||
style={[
|
||||
styles.gradient,
|
||||
{
|
||||
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
|
||||
}
|
||||
]}
|
||||
locations={[0, 0.4, 0.6, 0.8, 1]}
|
||||
style={styles.gradient}
|
||||
locations={[0, 0.4, 0.7, 1]}
|
||||
>
|
||||
{/* Content area */}
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: isReleased ? currentTheme.colors.primary : 'rgba(0,0,0,0.6)' }
|
||||
]}>
|
||||
<Text style={styles.statusText}>
|
||||
{isReleased ? (item.isGroup ? 'Released' : 'New') : formattedDate}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.contentArea}>
|
||||
<Text style={[
|
||||
styles.seriesName,
|
||||
{
|
||||
color: currentTheme.colors.white,
|
||||
fontSize: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16
|
||||
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16
|
||||
}
|
||||
]} numberOfLines={1}>
|
||||
{item.seriesName}
|
||||
</Text>
|
||||
|
||||
<Text style={[
|
||||
styles.episodeTitle,
|
||||
{
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14
|
||||
}
|
||||
]} numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
|
||||
{item.overview && (
|
||||
<View style={styles.metaContainer}>
|
||||
<Text style={[
|
||||
styles.overview,
|
||||
{
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 13 : 12
|
||||
}
|
||||
]} numberOfLines={isLargeScreen ? 3 : 2}>
|
||||
{item.overview}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.dateContainer}>
|
||||
<Text style={[
|
||||
styles.episodeInfo,
|
||||
{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 13 : 12
|
||||
}
|
||||
]}>
|
||||
S{item.season}:E{item.episode} •
|
||||
</Text>
|
||||
<MaterialIcons
|
||||
name="event"
|
||||
size={isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.releaseDate,
|
||||
styles.seasonBadge,
|
||||
{
|
||||
color: currentTheme.colors.primary,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 12
|
||||
}
|
||||
]}>
|
||||
{formattedDate}
|
||||
S{item.season} {item.isGroup ? item.episodeRange : `E${item.episode}`}
|
||||
</Text>
|
||||
<Text style={styles.dotSeparator}>•</Text>
|
||||
<Text style={[
|
||||
styles.episodeTitle,
|
||||
{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 12
|
||||
}
|
||||
]} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -300,13 +347,13 @@ export const ThisWeekSection = React.memo(() => {
|
|||
>
|
||||
<View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[
|
||||
styles.title,
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
|
||||
}
|
||||
]}>This Week</Text>
|
||||
<Text style={[
|
||||
styles.title,
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
|
||||
}
|
||||
]}>This Week</Text>
|
||||
<View style={[
|
||||
styles.titleUnderline,
|
||||
{
|
||||
|
|
@ -371,7 +418,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginVertical: 20,
|
||||
marginVertical: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -400,14 +447,15 @@ const styles = StyleSheet.create({
|
|||
viewAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
marginRight: -10,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
viewAllText: {
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginRight: 4,
|
||||
},
|
||||
|
|
@ -432,10 +480,11 @@ const styles = StyleSheet.create({
|
|||
height: '100%',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 12,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
|
|
@ -453,44 +502,64 @@ const styles = StyleSheet.create({
|
|||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
padding: 12,
|
||||
borderRadius: 16,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
width: '100%',
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
statusText: {
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
contentArea: {
|
||||
width: '100%',
|
||||
},
|
||||
seriesName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 6,
|
||||
},
|
||||
episodeTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
lineHeight: 18,
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
},
|
||||
overview: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
marginBottom: 6,
|
||||
opacity: 0.9,
|
||||
},
|
||||
dateContainer: {
|
||||
metaContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
episodeInfo: {
|
||||
seasonBadge: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginRight: 4,
|
||||
fontWeight: '700',
|
||||
},
|
||||
releaseDate: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
letterSpacing: 0.3,
|
||||
dotSeparator: {
|
||||
marginHorizontal: 6,
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
},
|
||||
episodeTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
flex: 1,
|
||||
},
|
||||
cardStackEffect: {
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
width: '92%',
|
||||
height: '100%',
|
||||
left: '4%',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
zIndex: -1,
|
||||
},
|
||||
});
|
||||
|
|
@ -1041,45 +1041,49 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
// Grace delay before showing text fallback to avoid flashing when logo arrives late
|
||||
const [shouldShowTextFallback, setShouldShowTextFallback] = useState<boolean>(!metadata?.logo);
|
||||
const logoWaitTimerRef = useRef<any>(null);
|
||||
// Ref to track the last synced logo to break circular dependency with error handling
|
||||
const lastSyncedLogoRef = useRef<string | undefined>(metadata?.logo);
|
||||
|
||||
// Update stable logo URI when metadata logo changes
|
||||
useEffect(() => {
|
||||
// Reset text fallback and timers on logo updates
|
||||
if (logoWaitTimerRef.current) {
|
||||
try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {}
|
||||
logoWaitTimerRef.current = null;
|
||||
// Check if metadata logo has actually changed from what we last processed
|
||||
const currentMetadataLogo = metadata?.logo;
|
||||
|
||||
if (currentMetadataLogo !== lastSyncedLogoRef.current) {
|
||||
lastSyncedLogoRef.current = currentMetadataLogo;
|
||||
|
||||
// Reset text fallback and timers on logo updates
|
||||
if (logoWaitTimerRef.current) {
|
||||
try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {}
|
||||
logoWaitTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (currentMetadataLogo) {
|
||||
setStableLogoUri(currentMetadataLogo);
|
||||
onStableLogoUriChange?.(currentMetadataLogo);
|
||||
setLogoHasLoadedSuccessfully(false); // Reset for new logo
|
||||
logoLoadOpacity.value = 0; // reset fade for new logo
|
||||
setShouldShowTextFallback(false);
|
||||
} else {
|
||||
// Clear logo if metadata no longer has one
|
||||
setStableLogoUri(null);
|
||||
onStableLogoUriChange?.(null);
|
||||
setLogoHasLoadedSuccessfully(false);
|
||||
// Start a short grace period before showing text fallback
|
||||
setShouldShowTextFallback(false);
|
||||
logoWaitTimerRef.current = setTimeout(() => {
|
||||
setShouldShowTextFallback(true);
|
||||
}, 600);
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata?.logo && metadata.logo !== stableLogoUri) {
|
||||
setStableLogoUri(metadata.logo);
|
||||
onStableLogoUriChange?.(metadata.logo);
|
||||
setLogoHasLoadedSuccessfully(false); // Reset for new logo
|
||||
logoLoadOpacity.value = 0; // reset fade for new logo
|
||||
setShouldShowTextFallback(false);
|
||||
} else if (!metadata?.logo && stableLogoUri) {
|
||||
// Clear logo if metadata no longer has one
|
||||
setStableLogoUri(null);
|
||||
onStableLogoUriChange?.(null);
|
||||
setLogoHasLoadedSuccessfully(false);
|
||||
// Start a short grace period before showing text fallback
|
||||
setShouldShowTextFallback(false);
|
||||
logoWaitTimerRef.current = setTimeout(() => {
|
||||
setShouldShowTextFallback(true);
|
||||
}, 600);
|
||||
} else if (!metadata?.logo && !stableLogoUri) {
|
||||
// No logo currently; wait briefly before showing text to avoid flash
|
||||
setShouldShowTextFallback(false);
|
||||
logoWaitTimerRef.current = setTimeout(() => {
|
||||
setShouldShowTextFallback(true);
|
||||
}, 600);
|
||||
}
|
||||
return () => {
|
||||
if (logoWaitTimerRef.current) {
|
||||
try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {}
|
||||
logoWaitTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [metadata?.logo, stableLogoUri]);
|
||||
}, [metadata?.logo]); // Removed stableLogoUri from dependencies to prevent circular updates on error
|
||||
|
||||
// Handle logo load success - once loaded successfully, keep it stable
|
||||
const handleLogoLoad = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -116,8 +116,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// Check if the stream is HLS (m3u8 playlist)
|
||||
const isHlsStream = (url: string) => {
|
||||
return url.includes('.m3u8') || url.includes('m3u8') ||
|
||||
url.includes('hls') || url.includes('playlist') ||
|
||||
(currentVideoType && currentVideoType.toLowerCase() === 'm3u8');
|
||||
url.includes('hls') || url.includes('playlist') ||
|
||||
(currentVideoType && currentVideoType.toLowerCase() === 'm3u8');
|
||||
};
|
||||
|
||||
// HLS-specific headers for better ExoPlayer compatibility
|
||||
|
|
@ -226,8 +226,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const [isBackdropLoaded, setIsBackdropLoaded] = useState(false);
|
||||
const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current;
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const [rnVideoAudioTracks, setRnVideoAudioTracks] = useState<Array<{id: number, name: string, language?: string}>>([]);
|
||||
const [rnVideoTextTracks, setRnVideoTextTracks] = useState<Array<{id: number, name: string, language?: string}>>([]);
|
||||
const [rnVideoAudioTracks, setRnVideoAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
|
||||
const [rnVideoTextTracks, setRnVideoTextTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
|
||||
|
||||
// Speed boost state for hold-to-speed-up feature
|
||||
const [isSpeedBoosted, setIsSpeedBoosted] = useState(false);
|
||||
|
|
@ -323,8 +323,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
|
||||
// VLC track state - will be managed by VlcVideoPlayer component
|
||||
const [vlcAudioTracks, setVlcAudioTracks] = useState<Array<{id: number, name: string, language?: string}>>([]);
|
||||
const [vlcSubtitleTracks, setVlcSubtitleTracks] = useState<Array<{id: number, name: string, language?: string}>>([]);
|
||||
const [vlcAudioTracks, setVlcAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
|
||||
const [vlcSubtitleTracks, setVlcSubtitleTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]);
|
||||
const [vlcSelectedAudioTrack, setVlcSelectedAudioTrack] = useState<number | undefined>(undefined);
|
||||
const [vlcSelectedSubtitleTrack, setVlcSelectedSubtitleTrack] = useState<number | undefined>(undefined);
|
||||
const [vlcRestoreTime, setVlcRestoreTime] = useState<number | undefined>(undefined); // Time to restore after remount
|
||||
|
|
@ -357,8 +357,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
useVLC
|
||||
? (vlcSelectedAudioTrack ?? null)
|
||||
: (selectedAudioTrack?.type === SelectedTrackType.INDEX && selectedAudioTrack.value !== undefined
|
||||
? Number(selectedAudioTrack.value)
|
||||
: null),
|
||||
? Number(selectedAudioTrack.value)
|
||||
: null),
|
||||
[useVLC, vlcSelectedAudioTrack, selectedAudioTrack]
|
||||
);
|
||||
|
||||
|
|
@ -629,7 +629,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const shouldLoadMetadata = Boolean(id && type);
|
||||
const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) });
|
||||
const { settings: appSettings } = useSettings();
|
||||
const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => {} };
|
||||
const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => { } };
|
||||
|
||||
// Logo animation values
|
||||
const logoScaleAnim = useRef(new Animated.Value(0.8)).current;
|
||||
|
|
@ -823,7 +823,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
return () => {
|
||||
if (isSpeedBoosted) {
|
||||
// best-effort restoration on unmount
|
||||
try { setPlaybackSpeed(originalSpeed); } catch {}
|
||||
try { setPlaybackSpeed(originalSpeed); } catch { }
|
||||
}
|
||||
};
|
||||
}, [isSpeedBoosted, originalSpeed]);
|
||||
|
|
@ -916,7 +916,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
setVlcKey(`vlc-focus-${Date.now()}`);
|
||||
}, 100);
|
||||
}
|
||||
return () => {};
|
||||
return () => { };
|
||||
}, [useVLC])
|
||||
);
|
||||
|
||||
|
|
@ -1496,101 +1496,101 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Handle text tracks
|
||||
if (data.textTracks && data.textTracks.length > 0) {
|
||||
if (DEBUG_MODE) {
|
||||
logger.log(`[AndroidVideoPlayer] Raw text tracks data:`, data.textTracks);
|
||||
data.textTracks.forEach((track: any, idx: number) => {
|
||||
logger.log(`[AndroidVideoPlayer] Text Track ${idx} raw data:`, {
|
||||
index: track.index,
|
||||
title: track.title,
|
||||
language: track.language,
|
||||
type: track.type,
|
||||
name: track.name,
|
||||
label: track.label,
|
||||
allKeys: Object.keys(track),
|
||||
fullTrackObject: track
|
||||
});
|
||||
// Handle text tracks
|
||||
if (data.textTracks && data.textTracks.length > 0) {
|
||||
if (DEBUG_MODE) {
|
||||
logger.log(`[AndroidVideoPlayer] Raw text tracks data:`, data.textTracks);
|
||||
data.textTracks.forEach((track: any, idx: number) => {
|
||||
logger.log(`[AndroidVideoPlayer] Text Track ${idx} raw data:`, {
|
||||
index: track.index,
|
||||
title: track.title,
|
||||
language: track.language,
|
||||
type: track.type,
|
||||
name: track.name,
|
||||
label: track.label,
|
||||
allKeys: Object.keys(track),
|
||||
fullTrackObject: track
|
||||
});
|
||||
}
|
||||
|
||||
const formattedTextTracks = data.textTracks.map((track: any, index: number) => {
|
||||
const trackIndex = track.index !== undefined ? track.index : index;
|
||||
|
||||
// Build comprehensive track name from available fields
|
||||
let trackName = '';
|
||||
const parts = [];
|
||||
|
||||
// Add language if available (try multiple possible fields)
|
||||
let language = track.language || track.lang || track.languageCode;
|
||||
|
||||
// If no language field, try to extract from track name (e.g., "[Russian]", "[English]")
|
||||
if ((!language || language === 'Unknown' || language === 'und' || language === '') && track.title) {
|
||||
const languageMatch = track.title.match(/\[([^\]]+)\]/);
|
||||
if (languageMatch && languageMatch[1]) {
|
||||
language = languageMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (language && language !== 'Unknown' && language !== 'und' && language !== '') {
|
||||
parts.push(language.toUpperCase());
|
||||
}
|
||||
|
||||
// Add codec information if available (try multiple possible fields)
|
||||
const codec = track.codec || track.format;
|
||||
if (codec && codec !== 'Unknown' && codec !== 'und') {
|
||||
parts.push(codec.toUpperCase());
|
||||
}
|
||||
|
||||
// Add title if available and not generic
|
||||
let title = track.title || track.name || track.label;
|
||||
if (title && !title.match(/^(Subtitle|Track)\s*\d*$/i) && title !== 'Unknown') {
|
||||
// Clean up title by removing language brackets and trailing punctuation
|
||||
title = title.replace(/\s*\[[^\]]+\]\s*[-–—]*\s*$/, '').trim();
|
||||
if (title && title !== 'Unknown') {
|
||||
parts.push(title);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine parts or fallback to generic name
|
||||
if (parts.length > 0) {
|
||||
trackName = parts.join(' • ');
|
||||
} else {
|
||||
// For simple track names like "Track 1", "Subtitle 1", etc., use them as-is
|
||||
const simpleName = track.title || track.name || track.label;
|
||||
if (simpleName && simpleName.match(/^(Track|Subtitle)\s*\d*$/i)) {
|
||||
trackName = simpleName;
|
||||
} else {
|
||||
// Try to extract any meaningful info from the track object
|
||||
const meaningfulFields: string[] = [];
|
||||
Object.keys(track).forEach(key => {
|
||||
const value = track[key];
|
||||
if (value && typeof value === 'string' && value !== 'Unknown' && value !== 'und' && value.length > 1) {
|
||||
meaningfulFields.push(`${key}: ${value}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (meaningfulFields.length > 0) {
|
||||
trackName = meaningfulFields.join(' • ');
|
||||
} else {
|
||||
trackName = `Subtitle ${index + 1}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: trackIndex, // Use the actual track index from react-native-video
|
||||
name: trackName,
|
||||
language: language,
|
||||
};
|
||||
});
|
||||
setRnVideoTextTracks(formattedTextTracks);
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
logger.log(`[AndroidVideoPlayer] Formatted text tracks:`, formattedTextTracks);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedTextTracks = data.textTracks.map((track: any, index: number) => {
|
||||
const trackIndex = track.index !== undefined ? track.index : index;
|
||||
|
||||
// Build comprehensive track name from available fields
|
||||
let trackName = '';
|
||||
const parts = [];
|
||||
|
||||
// Add language if available (try multiple possible fields)
|
||||
let language = track.language || track.lang || track.languageCode;
|
||||
|
||||
// If no language field, try to extract from track name (e.g., "[Russian]", "[English]")
|
||||
if ((!language || language === 'Unknown' || language === 'und' || language === '') && track.title) {
|
||||
const languageMatch = track.title.match(/\[([^\]]+)\]/);
|
||||
if (languageMatch && languageMatch[1]) {
|
||||
language = languageMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (language && language !== 'Unknown' && language !== 'und' && language !== '') {
|
||||
parts.push(language.toUpperCase());
|
||||
}
|
||||
|
||||
// Add codec information if available (try multiple possible fields)
|
||||
const codec = track.codec || track.format;
|
||||
if (codec && codec !== 'Unknown' && codec !== 'und') {
|
||||
parts.push(codec.toUpperCase());
|
||||
}
|
||||
|
||||
// Add title if available and not generic
|
||||
let title = track.title || track.name || track.label;
|
||||
if (title && !title.match(/^(Subtitle|Track)\s*\d*$/i) && title !== 'Unknown') {
|
||||
// Clean up title by removing language brackets and trailing punctuation
|
||||
title = title.replace(/\s*\[[^\]]+\]\s*[-–—]*\s*$/, '').trim();
|
||||
if (title && title !== 'Unknown') {
|
||||
parts.push(title);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine parts or fallback to generic name
|
||||
if (parts.length > 0) {
|
||||
trackName = parts.join(' • ');
|
||||
} else {
|
||||
// For simple track names like "Track 1", "Subtitle 1", etc., use them as-is
|
||||
const simpleName = track.title || track.name || track.label;
|
||||
if (simpleName && simpleName.match(/^(Track|Subtitle)\s*\d*$/i)) {
|
||||
trackName = simpleName;
|
||||
} else {
|
||||
// Try to extract any meaningful info from the track object
|
||||
const meaningfulFields: string[] = [];
|
||||
Object.keys(track).forEach(key => {
|
||||
const value = track[key];
|
||||
if (value && typeof value === 'string' && value !== 'Unknown' && value !== 'und' && value.length > 1) {
|
||||
meaningfulFields.push(`${key}: ${value}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (meaningfulFields.length > 0) {
|
||||
trackName = meaningfulFields.join(' • ');
|
||||
} else {
|
||||
trackName = `Subtitle ${index + 1}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: trackIndex, // Use the actual track index from react-native-video
|
||||
name: trackName,
|
||||
language: language,
|
||||
};
|
||||
});
|
||||
setRnVideoTextTracks(formattedTextTracks);
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
logger.log(`[AndroidVideoPlayer] Formatted text tracks:`, formattedTextTracks);
|
||||
}
|
||||
}
|
||||
|
||||
setIsVideoLoaded(true);
|
||||
setIsPlayerReady(true);
|
||||
|
||||
|
|
@ -1773,10 +1773,10 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true);
|
||||
if (!isTablet) {
|
||||
setTimeout(() => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
|
||||
}, 50);
|
||||
} else {
|
||||
ScreenOrientation.unlockAsync().catch(() => {});
|
||||
ScreenOrientation.unlockAsync().catch(() => { });
|
||||
}
|
||||
disableImmersiveMode();
|
||||
|
||||
|
|
@ -1793,10 +1793,10 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true);
|
||||
if (!isTablet) {
|
||||
setTimeout(() => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
|
||||
}, 50);
|
||||
} else {
|
||||
ScreenOrientation.unlockAsync().catch(() => {});
|
||||
ScreenOrientation.unlockAsync().catch(() => { });
|
||||
}
|
||||
disableImmersiveMode();
|
||||
|
||||
|
|
@ -1875,14 +1875,14 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// Check for codec errors that should trigger VLC fallback
|
||||
const errorString = JSON.stringify(error || {});
|
||||
const isCodecError = errorString.includes('MediaCodecVideoRenderer error') ||
|
||||
errorString.includes('MediaCodecAudioRenderer error') ||
|
||||
errorString.includes('NO_EXCEEDS_CAPABILITIES') ||
|
||||
errorString.includes('NO_UNSUPPORTED_TYPE') ||
|
||||
errorString.includes('Decoder failed') ||
|
||||
errorString.includes('video/hevc') ||
|
||||
errorString.includes('audio/eac3') ||
|
||||
errorString.includes('ERROR_CODE_DECODING_FAILED') ||
|
||||
errorString.includes('ERROR_CODE_DECODER_INIT_FAILED');
|
||||
errorString.includes('MediaCodecAudioRenderer error') ||
|
||||
errorString.includes('NO_EXCEEDS_CAPABILITIES') ||
|
||||
errorString.includes('NO_UNSUPPORTED_TYPE') ||
|
||||
errorString.includes('Decoder failed') ||
|
||||
errorString.includes('video/hevc') ||
|
||||
errorString.includes('audio/eac3') ||
|
||||
errorString.includes('ERROR_CODE_DECODING_FAILED') ||
|
||||
errorString.includes('ERROR_CODE_DECODER_INIT_FAILED');
|
||||
|
||||
// If it's a codec error and we're not already using VLC, silently switch to VLC
|
||||
if (isCodecError && !useVLC && !vlcFallbackAttemptedRef.current) {
|
||||
|
|
@ -1937,7 +1937,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
// Check if this might be an HLS stream that needs different handling
|
||||
const mightBeHls = currentStreamUrl.includes('.m3u8') || currentStreamUrl.includes('playlist') ||
|
||||
currentStreamUrl.includes('hls') || currentStreamUrl.includes('stream');
|
||||
currentStreamUrl.includes('hls') || currentStreamUrl.includes('stream');
|
||||
|
||||
if (mightBeHls) {
|
||||
logger.warn(`[AndroidVideoPlayer] HLS stream format not recognized. Retrying with explicit HLS type and headers`);
|
||||
|
|
@ -1982,9 +1982,9 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
// Handle HLS manifest parsing errors (when content isn't actually M3U8)
|
||||
const isManifestParseError = error?.error?.errorCode === '23002' ||
|
||||
error?.errorCode === '23002' ||
|
||||
(error?.error?.errorString &&
|
||||
error.error.errorString.includes('ERROR_CODE_PARSING_MANIFEST_MALFORMED'));
|
||||
error?.errorCode === '23002' ||
|
||||
(error?.error?.errorString &&
|
||||
error.error.errorString.includes('ERROR_CODE_PARSING_MANIFEST_MALFORMED'));
|
||||
|
||||
if (isManifestParseError && retryAttemptRef.current < 2) {
|
||||
retryAttemptRef.current = 2;
|
||||
|
|
@ -2010,9 +2010,9 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
// Check for specific AVFoundation server configuration errors (iOS)
|
||||
const isServerConfigError = error?.error?.code === -11850 ||
|
||||
error?.code === -11850 ||
|
||||
(error?.error?.localizedDescription &&
|
||||
error.error.localizedDescription.includes('server is not correctly configured'));
|
||||
error?.code === -11850 ||
|
||||
(error?.error?.localizedDescription &&
|
||||
error.error.localizedDescription.includes('server is not correctly configured'));
|
||||
|
||||
// Format error details for user display
|
||||
let errorMessage = 'An unknown error occurred';
|
||||
|
|
@ -2334,9 +2334,9 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
try {
|
||||
const merged = { ...(saved || {}), subtitleSize: migrated };
|
||||
await storageService.saveSubtitleSettings(merged);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch {}
|
||||
try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch { }
|
||||
return;
|
||||
}
|
||||
// If no saved settings, use responsive default
|
||||
|
|
@ -2518,7 +2518,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const textNow = cueNow ? cueNow.text : '';
|
||||
setCurrentSubtitle(textNow);
|
||||
logger.log('[AndroidVideoPlayer] currentSubtitle set immediately after apply (Android)');
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[AndroidVideoPlayer] Error loading Wyzie subtitle:', error);
|
||||
|
|
@ -2834,33 +2834,28 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
// Extract formatted segments from current cue
|
||||
if (currentCue?.formattedSegments) {
|
||||
// Split by newlines to get per-line segments
|
||||
const lines = (currentCue.text || '').split(/\r?\n/);
|
||||
const segmentsPerLine: SubtitleSegment[][] = [];
|
||||
let segmentIndex = 0;
|
||||
let currentLine: SubtitleSegment[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const lineSegments: SubtitleSegment[] = [];
|
||||
const words = line.split(/(\s+)/);
|
||||
|
||||
for (const word of words) {
|
||||
if (word.trim()) {
|
||||
if (segmentIndex < currentCue.formattedSegments.length) {
|
||||
lineSegments.push(currentCue.formattedSegments[segmentIndex]);
|
||||
segmentIndex++;
|
||||
} else {
|
||||
// Fallback if segment count doesn't match
|
||||
lineSegments.push({ text: word });
|
||||
}
|
||||
currentCue.formattedSegments.forEach(seg => {
|
||||
const parts = seg.text.split(/\r?\n/);
|
||||
parts.forEach((part, index) => {
|
||||
if (index > 0) {
|
||||
// New line found
|
||||
segmentsPerLine.push(currentLine);
|
||||
currentLine = [];
|
||||
}
|
||||
}
|
||||
if (part.length > 0) {
|
||||
currentLine.push({ ...seg, text: part });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (lineSegments.length > 0) {
|
||||
segmentsPerLine.push(lineSegments);
|
||||
}
|
||||
if (currentLine.length > 0) {
|
||||
segmentsPerLine.push(currentLine);
|
||||
}
|
||||
|
||||
setCurrentFormattedSegments(segmentsPerLine.length > 0 ? segmentsPerLine : []);
|
||||
setCurrentFormattedSegments(segmentsPerLine);
|
||||
} else {
|
||||
setCurrentFormattedSegments([]);
|
||||
}
|
||||
|
|
@ -2914,8 +2909,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier);
|
||||
if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec);
|
||||
}
|
||||
} catch {} finally {
|
||||
try { setSubtitleSettingsLoaded(true); } catch {}
|
||||
} catch { } finally {
|
||||
try { setSubtitleSettingsLoaded(true); } catch { }
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
|
@ -3283,14 +3278,14 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
/>
|
||||
) : (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
style={[styles.video, customVideoStyles]}
|
||||
source={{
|
||||
uri: currentStreamUrl,
|
||||
headers: headers || getStreamHeaders(),
|
||||
type: isHlsStream(currentStreamUrl) ? 'm3u8' : (currentVideoType as any)
|
||||
}}
|
||||
paused={paused}
|
||||
ref={videoRef}
|
||||
style={[styles.video, customVideoStyles]}
|
||||
source={{
|
||||
uri: currentStreamUrl,
|
||||
headers: headers || getStreamHeaders(),
|
||||
type: isHlsStream(currentStreamUrl) ? 'm3u8' : (currentVideoType as any)
|
||||
}}
|
||||
paused={paused}
|
||||
onLoadStart={() => {
|
||||
logger.log('[AndroidVideoPlayer][RN Video] onLoadStart');
|
||||
loadStartAtRef.current = Date.now();
|
||||
|
|
@ -3305,54 +3300,54 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
};
|
||||
logger.log('[AndroidVideoPlayer][RN Video] Stream info:', streamInfo);
|
||||
}}
|
||||
onProgress={handleProgress}
|
||||
onLoad={(e) => {
|
||||
logger.log('[AndroidVideoPlayer][RN Video] Video loaded successfully');
|
||||
logger.log('[AndroidVideoPlayer][RN Video] onLoad fired', { duration: e?.duration });
|
||||
onLoad(e);
|
||||
}}
|
||||
onReadyForDisplay={() => {
|
||||
firstFrameAtRef.current = Date.now();
|
||||
const startedAt = loadStartAtRef.current;
|
||||
if (startedAt) {
|
||||
const deltaMs = firstFrameAtRef.current - startedAt;
|
||||
logger.log(`[AndroidVideoPlayer] First frame ready after ${deltaMs} ms (${Platform.OS})`);
|
||||
} else {
|
||||
logger.log('[AndroidVideoPlayer] First frame ready (no start timestamp)');
|
||||
}
|
||||
}}
|
||||
onSeek={onSeek}
|
||||
onEnd={onEnd}
|
||||
onError={(err) => {
|
||||
logger.error('[AndroidVideoPlayer][RN Video] Encountered error:', err);
|
||||
handleError(err);
|
||||
}}
|
||||
onBuffer={(buf) => {
|
||||
logger.log('[AndroidVideoPlayer] onBuffer', buf);
|
||||
onBuffer(buf);
|
||||
}}
|
||||
resizeMode={getVideoResizeMode(resizeMode)}
|
||||
selectedAudioTrack={selectedAudioTrack || undefined}
|
||||
selectedTextTrack={useCustomSubtitles ? { type: SelectedTrackType.DISABLED } : (selectedTextTrack >= 0 ? { type: SelectedTrackType.INDEX, value: selectedTextTrack } : undefined)}
|
||||
rate={playbackSpeed}
|
||||
volume={volume}
|
||||
muted={false}
|
||||
repeat={false}
|
||||
playInBackground={false}
|
||||
playWhenInactive={false}
|
||||
ignoreSilentSwitch="ignore"
|
||||
mixWithOthers="inherit"
|
||||
progressUpdateInterval={500}
|
||||
// Remove artificial bit rate cap to allow high-bitrate streams (e.g., Blu-ray remux) to play
|
||||
// maxBitRate intentionally omitted
|
||||
disableFocus={true}
|
||||
// iOS AVPlayer optimization
|
||||
allowsExternalPlayback={false as any}
|
||||
preventsDisplaySleepDuringVideoPlayback={true as any}
|
||||
// ExoPlayer HLS optimization - let the player use optimal defaults
|
||||
// Use surfaceView on Android for improved compatibility
|
||||
viewType={Platform.OS === 'android' ? ViewType.SURFACE : undefined}
|
||||
/>
|
||||
onProgress={handleProgress}
|
||||
onLoad={(e) => {
|
||||
logger.log('[AndroidVideoPlayer][RN Video] Video loaded successfully');
|
||||
logger.log('[AndroidVideoPlayer][RN Video] onLoad fired', { duration: e?.duration });
|
||||
onLoad(e);
|
||||
}}
|
||||
onReadyForDisplay={() => {
|
||||
firstFrameAtRef.current = Date.now();
|
||||
const startedAt = loadStartAtRef.current;
|
||||
if (startedAt) {
|
||||
const deltaMs = firstFrameAtRef.current - startedAt;
|
||||
logger.log(`[AndroidVideoPlayer] First frame ready after ${deltaMs} ms (${Platform.OS})`);
|
||||
} else {
|
||||
logger.log('[AndroidVideoPlayer] First frame ready (no start timestamp)');
|
||||
}
|
||||
}}
|
||||
onSeek={onSeek}
|
||||
onEnd={onEnd}
|
||||
onError={(err) => {
|
||||
logger.error('[AndroidVideoPlayer][RN Video] Encountered error:', err);
|
||||
handleError(err);
|
||||
}}
|
||||
onBuffer={(buf) => {
|
||||
logger.log('[AndroidVideoPlayer] onBuffer', buf);
|
||||
onBuffer(buf);
|
||||
}}
|
||||
resizeMode={getVideoResizeMode(resizeMode)}
|
||||
selectedAudioTrack={selectedAudioTrack || undefined}
|
||||
selectedTextTrack={useCustomSubtitles ? { type: SelectedTrackType.DISABLED } : (selectedTextTrack >= 0 ? { type: SelectedTrackType.INDEX, value: selectedTextTrack } : undefined)}
|
||||
rate={playbackSpeed}
|
||||
volume={volume}
|
||||
muted={false}
|
||||
repeat={false}
|
||||
playInBackground={false}
|
||||
playWhenInactive={false}
|
||||
ignoreSilentSwitch="ignore"
|
||||
mixWithOthers="inherit"
|
||||
progressUpdateInterval={500}
|
||||
// Remove artificial bit rate cap to allow high-bitrate streams (e.g., Blu-ray remux) to play
|
||||
// maxBitRate intentionally omitted
|
||||
disableFocus={true}
|
||||
// iOS AVPlayer optimization
|
||||
allowsExternalPlayback={false as any}
|
||||
preventsDisplaySleepDuringVideoPlayback={true as any}
|
||||
// ExoPlayer HLS optimization - let the player use optimal defaults
|
||||
// Use surfaceView on Android for improved compatibility
|
||||
viewType={Platform.OS === 'android' ? ViewType.SURFACE : undefined}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -3402,7 +3397,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
onSlidingComplete={handleSlidingComplete}
|
||||
buffered={buffered}
|
||||
formatTime={formatTime}
|
||||
playerBackend={useVLC ? 'VLC' : 'ExoPlayer'}
|
||||
playerBackend={useVLC ? 'VLC' : 'ExoPlayer'}
|
||||
/>
|
||||
|
||||
{showPauseOverlay && (
|
||||
|
|
@ -3433,7 +3428,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
<LinearGradient
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
colors={[ 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)' ]}
|
||||
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
|
||||
locations={[0, 1]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
|
@ -3656,38 +3651,38 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
marginRight: 8,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
onPress={() => {
|
||||
setSelectedCastMember(castMember);
|
||||
// Animate metadata out, then cast details in
|
||||
Animated.parallel([
|
||||
Animated.timing(metadataOpacity, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(metadataScale, {
|
||||
toValue: 0.95,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start(() => {
|
||||
setShowCastDetails(true);
|
||||
// Animate cast details in
|
||||
onPress={() => {
|
||||
setSelectedCastMember(castMember);
|
||||
// Animate metadata out, then cast details in
|
||||
Animated.parallel([
|
||||
Animated.timing(castDetailsOpacity, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
Animated.timing(metadataOpacity, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(castDetailsScale, {
|
||||
toValue: 1,
|
||||
tension: 80,
|
||||
friction: 8,
|
||||
Animated.timing(metadataScale, {
|
||||
toValue: 0.95,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
});
|
||||
}}
|
||||
]).start(() => {
|
||||
setShowCastDetails(true);
|
||||
// Animate cast details in
|
||||
Animated.parallel([
|
||||
Animated.timing(castDetailsOpacity, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(castDetailsScale, {
|
||||
toValue: 1,
|
||||
tension: 80,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
|
|
@ -3984,12 +3979,12 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
<>
|
||||
<AudioTrackModal
|
||||
showAudioModal={showAudioModal}
|
||||
setShowAudioModal={setShowAudioModal}
|
||||
ksAudioTracks={useVLC ? vlcAudioTracks : rnVideoAudioTracks}
|
||||
selectedAudioTrack={useVLC ? (vlcSelectedAudioTrack ?? null) : (selectedAudioTrack?.type === SelectedTrackType.INDEX && selectedAudioTrack.value !== undefined ? Number(selectedAudioTrack.value) : null)}
|
||||
selectAudioTrack={selectAudioTrackById}
|
||||
/>
|
||||
showAudioModal={showAudioModal}
|
||||
setShowAudioModal={setShowAudioModal}
|
||||
ksAudioTracks={useVLC ? vlcAudioTracks : rnVideoAudioTracks}
|
||||
selectedAudioTrack={useVLC ? (vlcSelectedAudioTrack ?? null) : (selectedAudioTrack?.type === SelectedTrackType.INDEX && selectedAudioTrack.value !== undefined ? Number(selectedAudioTrack.value) : null)}
|
||||
selectAudioTrack={selectAudioTrackById}
|
||||
/>
|
||||
</>
|
||||
<SubtitleModals
|
||||
showSubtitleModal={showSubtitleModal}
|
||||
|
|
@ -4089,91 +4084,91 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
supportedOrientations={['landscape', 'portrait']}
|
||||
statusBarTranslucent={true}
|
||||
>
|
||||
<View style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.8)'
|
||||
}}>
|
||||
<View style={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: 14,
|
||||
width: '85%',
|
||||
maxHeight: '70%',
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.8)'
|
||||
}}>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: 14,
|
||||
width: '85%',
|
||||
maxHeight: '70%',
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
}}>
|
||||
<MaterialIcons name="error" size={24} color="#ff4444" style={{ marginRight: 8 }} />
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<MaterialIcons name="error" size={24} color="#ff4444" style={{ marginRight: 8 }} />
|
||||
<Text style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
flex: 1
|
||||
}}>Playback Error</Text>
|
||||
<TouchableOpacity onPress={handleErrorExit}>
|
||||
<MaterialIcons name="close" size={24} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
flex: 1
|
||||
}}>Playback Error</Text>
|
||||
<TouchableOpacity onPress={handleErrorExit}>
|
||||
<MaterialIcons name="close" size={24} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
fontSize: 14,
|
||||
color: '#cccccc',
|
||||
marginBottom: 16,
|
||||
lineHeight: 20
|
||||
}}>The video player encountered an error and cannot continue playback:</Text>
|
||||
|
||||
<Text style={{
|
||||
fontSize: 14,
|
||||
color: '#cccccc',
|
||||
marginBottom: 16,
|
||||
lineHeight: 20
|
||||
}}>The video player encountered an error and cannot continue playback:</Text>
|
||||
<View style={{
|
||||
backgroundColor: '#2a2a2a',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 20,
|
||||
maxHeight: 200
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: 12,
|
||||
color: '#ff8888',
|
||||
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace'
|
||||
}}>{errorDetails}</Text>
|
||||
</View>
|
||||
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end'
|
||||
}}>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
backgroundColor: '#ff4444',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20
|
||||
}}
|
||||
onPress={handleErrorExit}
|
||||
>
|
||||
<Text style={{
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
fontSize: 16
|
||||
}}>Exit Player</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={{
|
||||
backgroundColor: '#2a2a2a',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 20,
|
||||
maxHeight: 200
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: 12,
|
||||
color: '#ff8888',
|
||||
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace'
|
||||
}}>{errorDetails}</Text>
|
||||
color: '#888888',
|
||||
textAlign: 'center',
|
||||
marginTop: 12
|
||||
}}>This dialog will auto-close in 5 seconds</Text>
|
||||
</View>
|
||||
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end'
|
||||
}}>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
backgroundColor: '#ff4444',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20
|
||||
}}
|
||||
onPress={handleErrorExit}
|
||||
>
|
||||
<Text style={{
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
fontSize: 16
|
||||
}}>Exit Player</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={{
|
||||
fontSize: 12,
|
||||
color: '#888888',
|
||||
textAlign: 'center',
|
||||
marginTop: 12
|
||||
}}>This dialog will auto-close in 5 seconds</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)}
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
id: id || 'placeholder',
|
||||
type: type || 'movie'
|
||||
});
|
||||
const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => {} };
|
||||
const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => { } };
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Logo animation values
|
||||
|
|
@ -559,7 +559,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
useEffect(() => {
|
||||
return () => {
|
||||
if (isSpeedBoosted) {
|
||||
try { setPlaybackSpeed(originalSpeed); } catch {}
|
||||
try { setPlaybackSpeed(originalSpeed); } catch { }
|
||||
}
|
||||
};
|
||||
}, [isSpeedBoosted, originalSpeed]);
|
||||
|
|
@ -648,7 +648,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
if (isOpeningAnimationComplete) {
|
||||
enableImmersiveMode();
|
||||
}
|
||||
return () => {};
|
||||
return () => { };
|
||||
}, [isOpeningAnimationComplete])
|
||||
);
|
||||
|
||||
|
|
@ -849,6 +849,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
const onPaused = () => {
|
||||
if (isMounted.current) {
|
||||
setPaused(true);
|
||||
// Reset the wasPlayingBeforeDrag ref so that seeking while paused doesn't resume playback
|
||||
wasPlayingBeforeDragRef.current = false;
|
||||
|
||||
// IMMEDIATE: Send immediate pause update to Trakt when user pauses
|
||||
if (duration > 0) {
|
||||
|
|
@ -919,8 +921,9 @@ const KSPlayerCore: React.FC = () => {
|
|||
if (duration > 0) {
|
||||
const seekTime = Math.min(value, duration - END_EPSILON);
|
||||
seekToTime(seekTime);
|
||||
// If the video was playing before the drag, ensure we remain in playing state after the seek
|
||||
if (wasPlayingBeforeDragRef.current) {
|
||||
// Only resume playback if the video was playing before the drag AND is not currently paused
|
||||
// This ensures that if the user paused during or before the drag, it stays paused
|
||||
if (wasPlayingBeforeDragRef.current && !paused) {
|
||||
setTimeout(() => {
|
||||
if (isMounted.current) {
|
||||
setPaused(false);
|
||||
|
|
@ -988,14 +991,6 @@ const KSPlayerCore: React.FC = () => {
|
|||
completeOpeningAnimation();
|
||||
}
|
||||
|
||||
// If time is advancing right after seek and we previously intended to play,
|
||||
// ensure paused state is false to keep UI in sync
|
||||
if (wasPlayingBeforeDragRef.current && paused && !isDragging) {
|
||||
setPaused(false);
|
||||
// Reset the intent once corrected
|
||||
wasPlayingBeforeDragRef.current = false;
|
||||
}
|
||||
|
||||
// Periodic check for disabled audio track (every 3 seconds, max 3 attempts)
|
||||
const now = Date.now();
|
||||
if (now - lastAudioTrackCheck > 3000 && !paused && duration > 0 && audioTrackFallbackAttempts < 3) {
|
||||
|
|
@ -1011,12 +1006,12 @@ const KSPlayerCore: React.FC = () => {
|
|||
const trackLang = (track.language || '').toLowerCase();
|
||||
// Prefer stereo, AAC, or standard audio formats, avoid heavy codecs
|
||||
return !trackName.includes('truehd') &&
|
||||
!trackName.includes('dts') &&
|
||||
!trackName.includes('dolby') &&
|
||||
!trackName.includes('atmos') &&
|
||||
!trackName.includes('7.1') &&
|
||||
!trackName.includes('5.1') &&
|
||||
index !== selectedAudioTrack; // Don't select the same track
|
||||
!trackName.includes('dts') &&
|
||||
!trackName.includes('dolby') &&
|
||||
!trackName.includes('atmos') &&
|
||||
!trackName.includes('7.1') &&
|
||||
!trackName.includes('5.1') &&
|
||||
index !== selectedAudioTrack; // Don't select the same track
|
||||
});
|
||||
|
||||
if (fallbackTrack) {
|
||||
|
|
@ -1203,10 +1198,10 @@ const KSPlayerCore: React.FC = () => {
|
|||
// Auto-select English audio track if available, otherwise first track
|
||||
if (selectedAudioTrack === null && formattedAudioTracks.length > 0) {
|
||||
// Look for English track first
|
||||
const englishTrack = formattedAudioTracks.find((track: {id: number, name: string, language?: string}) => {
|
||||
const englishTrack = formattedAudioTracks.find((track: { id: number, name: string, language?: string }) => {
|
||||
const lang = (track.language || '').toLowerCase();
|
||||
return lang === 'english' || lang === 'en' || lang === 'eng' ||
|
||||
(track.name && track.name.toLowerCase().includes('english'));
|
||||
(track.name && track.name.toLowerCase().includes('english'));
|
||||
});
|
||||
|
||||
const selectedTrack = englishTrack || formattedAudioTracks[0];
|
||||
|
|
@ -1248,7 +1243,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
const lang = (track.language || '').toLowerCase();
|
||||
const name = (track.name || '').toLowerCase();
|
||||
return lang === 'english' || lang === 'en' || lang === 'eng' ||
|
||||
name.includes('english') || name.includes('en');
|
||||
name.includes('english') || name.includes('en');
|
||||
});
|
||||
|
||||
if (englishTrack) {
|
||||
|
|
@ -1393,9 +1388,9 @@ const KSPlayerCore: React.FC = () => {
|
|||
const isTablet = (Platform as any).isPad === true || Math.min(dw, dh) >= 768;
|
||||
setTimeout(() => {
|
||||
if (isTablet) {
|
||||
ScreenOrientation.unlockAsync().catch(() => {});
|
||||
ScreenOrientation.unlockAsync().catch(() => { });
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
|
@ -1538,12 +1533,12 @@ const KSPlayerCore: React.FC = () => {
|
|||
const trackLang = (track.language || '').toLowerCase();
|
||||
// Prefer stereo, AAC, or standard audio formats, avoid heavy codecs
|
||||
return !trackName.includes('truehd') &&
|
||||
!trackName.includes('dts') &&
|
||||
!trackName.includes('dolby') &&
|
||||
!trackName.includes('atmos') &&
|
||||
!trackName.includes('7.1') &&
|
||||
!trackName.includes('5.1') &&
|
||||
index !== selectedAudioTrack; // Don't select the same track
|
||||
!trackName.includes('dts') &&
|
||||
!trackName.includes('dolby') &&
|
||||
!trackName.includes('atmos') &&
|
||||
!trackName.includes('7.1') &&
|
||||
!trackName.includes('5.1') &&
|
||||
index !== selectedAudioTrack; // Don't select the same track
|
||||
});
|
||||
|
||||
if (fallbackTrack) {
|
||||
|
|
@ -1673,8 +1668,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
// Check if this is a multi-channel track that might need downmixing
|
||||
const trackName = selectedTrack.name.toLowerCase();
|
||||
const isMultiChannel = trackName.includes('5.1') || trackName.includes('7.1') ||
|
||||
trackName.includes('truehd') || trackName.includes('dts') ||
|
||||
trackName.includes('dolby') || trackName.includes('atmos');
|
||||
trackName.includes('truehd') || trackName.includes('dts') ||
|
||||
trackName.includes('dolby') || trackName.includes('atmos');
|
||||
|
||||
if (isMultiChannel) {
|
||||
logger.log(`[VideoPlayer] Multi-channel audio track detected: ${selectedTrack.name}`);
|
||||
|
|
@ -1757,9 +1752,9 @@ const KSPlayerCore: React.FC = () => {
|
|||
try {
|
||||
const merged = { ...(saved || {}), subtitleSize: migrated };
|
||||
await storageService.saveSubtitleSettings(merged);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch {}
|
||||
try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch { }
|
||||
return;
|
||||
}
|
||||
// If no saved settings, use responsive default
|
||||
|
|
@ -2196,33 +2191,28 @@ const KSPlayerCore: React.FC = () => {
|
|||
|
||||
// Extract formatted segments from current cue
|
||||
if (currentCue?.formattedSegments) {
|
||||
// Split by newlines to get per-line segments
|
||||
const lines = (currentCue.text || '').split(/\r?\n/);
|
||||
const segmentsPerLine: SubtitleSegment[][] = [];
|
||||
let segmentIndex = 0;
|
||||
let currentLine: SubtitleSegment[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const lineSegments: SubtitleSegment[] = [];
|
||||
const words = line.split(/(\s+)/);
|
||||
|
||||
for (const word of words) {
|
||||
if (word.trim()) {
|
||||
if (segmentIndex < currentCue.formattedSegments.length) {
|
||||
lineSegments.push(currentCue.formattedSegments[segmentIndex]);
|
||||
segmentIndex++;
|
||||
} else {
|
||||
// Fallback if segment count doesn't match
|
||||
lineSegments.push({ text: word });
|
||||
}
|
||||
currentCue.formattedSegments.forEach(seg => {
|
||||
const parts = seg.text.split(/\r?\n/);
|
||||
parts.forEach((part, index) => {
|
||||
if (index > 0) {
|
||||
// New line found
|
||||
segmentsPerLine.push(currentLine);
|
||||
currentLine = [];
|
||||
}
|
||||
}
|
||||
if (part.length > 0) {
|
||||
currentLine.push({ ...seg, text: part });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (lineSegments.length > 0) {
|
||||
segmentsPerLine.push(lineSegments);
|
||||
}
|
||||
if (currentLine.length > 0) {
|
||||
segmentsPerLine.push(currentLine);
|
||||
}
|
||||
|
||||
setCurrentFormattedSegments(segmentsPerLine.length > 0 ? segmentsPerLine : []);
|
||||
setCurrentFormattedSegments(segmentsPerLine);
|
||||
} else {
|
||||
setCurrentFormattedSegments([]);
|
||||
}
|
||||
|
|
@ -2243,14 +2233,14 @@ const KSPlayerCore: React.FC = () => {
|
|||
if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor);
|
||||
if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth);
|
||||
if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right');
|
||||
if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset);
|
||||
if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset);
|
||||
if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing);
|
||||
if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier);
|
||||
if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec);
|
||||
}
|
||||
} catch {} finally {
|
||||
} catch { } finally {
|
||||
// Mark subtitle settings as loaded so we can safely persist subsequent changes
|
||||
try { setSubtitleSettingsLoaded(true); } catch {}
|
||||
try { setSubtitleSettingsLoaded(true); } catch { }
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
|
@ -2283,7 +2273,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
subtitleOutlineColor,
|
||||
subtitleOutlineWidth,
|
||||
subtitleAlign,
|
||||
subtitleBottomOffset,
|
||||
subtitleBottomOffset,
|
||||
subtitleLetterSpacing,
|
||||
subtitleLineHeightMultiplier,
|
||||
subtitleOffsetSec,
|
||||
|
|
@ -2690,11 +2680,11 @@ const KSPlayerCore: React.FC = () => {
|
|||
buffered={buffered}
|
||||
formatTime={formatTime}
|
||||
playerBackend={playerBackend}
|
||||
cyclePlaybackSpeed={cyclePlaybackSpeed}
|
||||
currentPlaybackSpeed={playbackSpeed}
|
||||
isAirPlayActive={isAirPlayActive}
|
||||
allowsAirPlay={allowsAirPlay}
|
||||
onAirPlayPress={handleAirPlayPress}
|
||||
cyclePlaybackSpeed={cyclePlaybackSpeed}
|
||||
currentPlaybackSpeed={playbackSpeed}
|
||||
isAirPlayActive={isAirPlayActive}
|
||||
allowsAirPlay={allowsAirPlay}
|
||||
onAirPlayPress={handleAirPlayPress}
|
||||
/>
|
||||
|
||||
{showPauseOverlay && (
|
||||
|
|
@ -2725,7 +2715,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
<LinearGradient
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
colors={[ 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)' ]}
|
||||
colors={['rgba(0,0,0,0.85)', 'rgba(0,0,0,0.0)']}
|
||||
locations={[0, 1]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
|
@ -2948,38 +2938,38 @@ const KSPlayerCore: React.FC = () => {
|
|||
marginRight: 8,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
onPress={() => {
|
||||
setSelectedCastMember(castMember);
|
||||
// Animate metadata out, then cast details in
|
||||
Animated.parallel([
|
||||
Animated.timing(metadataOpacity, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(metadataScale, {
|
||||
toValue: 0.95,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start(() => {
|
||||
setShowCastDetails(true);
|
||||
// Animate cast details in
|
||||
onPress={() => {
|
||||
setSelectedCastMember(castMember);
|
||||
// Animate metadata out, then cast details in
|
||||
Animated.parallel([
|
||||
Animated.timing(castDetailsOpacity, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
Animated.timing(metadataOpacity, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(castDetailsScale, {
|
||||
toValue: 1,
|
||||
tension: 80,
|
||||
friction: 8,
|
||||
Animated.timing(metadataScale, {
|
||||
toValue: 0.95,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
});
|
||||
}}
|
||||
]).start(() => {
|
||||
setShowCastDetails(true);
|
||||
// Animate cast details in
|
||||
Animated.parallel([
|
||||
Animated.timing(castDetailsOpacity, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(castDetailsScale, {
|
||||
toValue: 1,
|
||||
tension: 80,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
|
|
|
|||
|
|
@ -65,22 +65,25 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
|||
}
|
||||
effectiveBottom = Math.max(0, effectiveBottom);
|
||||
|
||||
// When using crisp outline, prefer SVG text with real stroke instead of blur shadow
|
||||
const useCrispSvgOutline = outline === true;
|
||||
|
||||
const shadowStyle = (textShadow && !useCrispSvgOutline)
|
||||
? {
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.9)',
|
||||
textShadowOffset: { width: 2, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
}
|
||||
: {};
|
||||
|
||||
// Prepare content lines
|
||||
const lines = String(currentSubtitle).split(/\r?\n/);
|
||||
|
||||
// Detect RTL for each line
|
||||
const lineRTLStatus = lines.map(line => detectRTL(line));
|
||||
const hasRTL = lineRTLStatus.some(status => status);
|
||||
|
||||
// When using crisp outline, prefer SVG text with real stroke instead of blur shadow
|
||||
// However, SVG text does not support complex text shaping (required for Arabic/RTL),
|
||||
// so we must fallback to standard Text component for RTL languages.
|
||||
const useCrispSvgOutline = outline === true && !hasRTL;
|
||||
|
||||
const shadowStyle = (textShadow && !useCrispSvgOutline)
|
||||
? {
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.9)',
|
||||
textShadowOffset: { width: 2, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
}
|
||||
: {};
|
||||
|
||||
const displayFontSize = subtitleSize * inverseScale;
|
||||
const displayLineHeight = subtitleSize * lineHeightMultiplier * inverseScale;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const safeDebugLog = (message: string, data?: any) => {
|
|||
};
|
||||
|
||||
// Add language code to name mapping
|
||||
export const languageMap: {[key: string]: string} = {
|
||||
export const languageMap: { [key: string]: string } = {
|
||||
'en': 'English',
|
||||
'eng': 'English',
|
||||
'es': 'Spanish',
|
||||
|
|
@ -84,7 +84,7 @@ export const formatLanguage = (code?: string): string => {
|
|||
|
||||
// If the result is still the uppercased code, it means we couldn't find it in our map.
|
||||
if (languageName === code.toUpperCase()) {
|
||||
return `Unknown (${code})`;
|
||||
return `Unknown (${code})`;
|
||||
}
|
||||
|
||||
return languageName;
|
||||
|
|
@ -104,7 +104,7 @@ export const getTrackDisplayName = (track: { name?: string, id: number, language
|
|||
|
||||
// If the track name contains detailed information (like codec, bitrate, etc.), use it as-is
|
||||
if (track.name && (track.name.includes('DDP') || track.name.includes('DTS') || track.name.includes('AAC') ||
|
||||
track.name.includes('Kbps') || track.name.includes('Atmos') || track.name.includes('~'))) {
|
||||
track.name.includes('Kbps') || track.name.includes('Atmos') || track.name.includes('~'))) {
|
||||
return track.name;
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +189,7 @@ export const detectRTL = (text: string): boolean => {
|
|||
// Arabic Presentation Forms-B: U+FE70–U+FEFF
|
||||
// Hebrew: U+0590–U+05FF
|
||||
// Persian/Urdu use Arabic script (no separate range)
|
||||
const rtlRegex = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/;
|
||||
const rtlRegex = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/g;
|
||||
|
||||
// Remove whitespace and count characters
|
||||
const nonWhitespace = text.replace(/\s/g, '');
|
||||
|
|
|
|||
|
|
@ -10,24 +10,24 @@ import { parseISO, isBefore, isAfter, startOfToday, addWeeks, isThisWeek } from
|
|||
import { StreamingContent } from '../services/catalogService';
|
||||
|
||||
interface CalendarEpisode {
|
||||
id: string;
|
||||
seriesId: string;
|
||||
title: string;
|
||||
seriesName: string;
|
||||
poster: string;
|
||||
releaseDate: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
overview: string;
|
||||
vote_average: number;
|
||||
still_path: string | null;
|
||||
season_poster_path: string | null;
|
||||
}
|
||||
id: string;
|
||||
seriesId: string;
|
||||
title: string;
|
||||
seriesName: string;
|
||||
poster: string;
|
||||
releaseDate: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
overview: string;
|
||||
vote_average: number;
|
||||
still_path: string | null;
|
||||
season_poster_path: string | null;
|
||||
}
|
||||
|
||||
interface CalendarSection {
|
||||
title: string;
|
||||
data: CalendarEpisode[];
|
||||
}
|
||||
interface CalendarSection {
|
||||
title: string;
|
||||
data: CalendarEpisode[];
|
||||
}
|
||||
|
||||
interface UseCalendarDataReturn {
|
||||
calendarData: CalendarSection[];
|
||||
|
|
@ -36,399 +36,416 @@ interface UseCalendarDataReturn {
|
|||
}
|
||||
|
||||
export const useCalendarData = (): UseCalendarDataReturn => {
|
||||
const [calendarData, setCalendarData] = useState<CalendarSection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [calendarData, setCalendarData] = useState<CalendarSection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { libraryItems, loading: libraryLoading } = useLibrary();
|
||||
const {
|
||||
isAuthenticated: traktAuthenticated,
|
||||
isLoading: traktLoading,
|
||||
watchedShows,
|
||||
watchlistShows,
|
||||
continueWatching,
|
||||
loadAllCollections,
|
||||
} = useTraktContext();
|
||||
const { libraryItems, loading: libraryLoading } = useLibrary();
|
||||
const {
|
||||
isAuthenticated: traktAuthenticated,
|
||||
isLoading: traktLoading,
|
||||
watchedShows,
|
||||
watchlistShows,
|
||||
continueWatching,
|
||||
loadAllCollections,
|
||||
} = useTraktContext();
|
||||
|
||||
const fetchCalendarData = useCallback(async (forceRefresh = false) => {
|
||||
setLoading(true);
|
||||
const fetchCalendarData = useCallback(async (forceRefresh = false) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Check memory pressure and cleanup if needed
|
||||
memoryManager.checkMemoryPressure();
|
||||
try {
|
||||
// Check memory pressure and cleanup if needed
|
||||
memoryManager.checkMemoryPressure();
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cachedData = await robustCalendarCache.getCachedCalendarData(
|
||||
libraryItems,
|
||||
{
|
||||
watchlist: watchlistShows,
|
||||
continueWatching: continueWatching,
|
||||
watched: watchedShows,
|
||||
}
|
||||
);
|
||||
|
||||
if (cachedData) {
|
||||
setCalendarData(cachedData);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!forceRefresh) {
|
||||
const cachedData = await robustCalendarCache.getCachedCalendarData(
|
||||
libraryItems,
|
||||
{
|
||||
watchlist: watchlistShows,
|
||||
continueWatching: continueWatching,
|
||||
watched: watchedShows,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
const librarySeries = libraryItems.filter(item => item.type === 'series');
|
||||
let allSeries: StreamingContent[] = [...librarySeries];
|
||||
|
||||
if (traktAuthenticated) {
|
||||
const traktSeriesIds = new Set();
|
||||
|
||||
if (watchlistShows) {
|
||||
for (const item of watchlistShows) {
|
||||
if (item.show && item.show.ids.imdb) {
|
||||
const imdbId = item.show.ids.imdb;
|
||||
if (!librarySeries.some(s => s.id === imdbId)) {
|
||||
traktSeriesIds.add(imdbId);
|
||||
allSeries.push({
|
||||
id: imdbId,
|
||||
name: item.show.title,
|
||||
type: 'series',
|
||||
poster: '',
|
||||
year: item.show.year,
|
||||
traktSource: 'watchlist'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (continueWatching) {
|
||||
for (const item of continueWatching) {
|
||||
if (item.type === 'episode' && item.show && item.show.ids.imdb) {
|
||||
const imdbId = item.show.ids.imdb;
|
||||
if (!librarySeries.some(s => s.id === imdbId) && !traktSeriesIds.has(imdbId)) {
|
||||
traktSeriesIds.add(imdbId);
|
||||
allSeries.push({
|
||||
id: imdbId,
|
||||
name: item.show.title,
|
||||
type: 'series',
|
||||
poster: '',
|
||||
year: item.show.year,
|
||||
traktSource: 'continue-watching'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (watchedShows) {
|
||||
const recentWatched = watchedShows.slice(0, 20);
|
||||
for (const item of recentWatched) {
|
||||
if (item.show && item.show.ids.imdb) {
|
||||
const imdbId = item.show.ids.imdb;
|
||||
if (!librarySeries.some(s => s.id === imdbId) && !traktSeriesIds.has(imdbId)) {
|
||||
traktSeriesIds.add(imdbId);
|
||||
allSeries.push({
|
||||
id: imdbId,
|
||||
name: item.show.title,
|
||||
type: 'series',
|
||||
poster: '',
|
||||
year: item.show.year,
|
||||
traktSource: 'watched'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the number of series to prevent memory overflow
|
||||
const maxSeries = 100; // Reasonable limit to prevent OOM
|
||||
if (allSeries.length > maxSeries) {
|
||||
logger.warn(`[CalendarData] Too many series (${allSeries.length}), limiting to ${maxSeries} to prevent memory issues`);
|
||||
allSeries = allSeries.slice(0, maxSeries);
|
||||
}
|
||||
|
||||
logger.log(`[CalendarData] Total series to check: ${allSeries.length} (Library: ${librarySeries.length}, Trakt: ${allSeries.length - librarySeries.length})`);
|
||||
|
||||
let allEpisodes: CalendarEpisode[] = [];
|
||||
let seriesWithoutEpisodes: CalendarEpisode[] = [];
|
||||
|
||||
// Process series in memory-efficient batches to prevent OOM
|
||||
const processedSeries = await memoryManager.processArrayInBatches(
|
||||
allSeries,
|
||||
async (series: StreamingContent, index: number) => {
|
||||
try {
|
||||
// Use the new memory-efficient method to fetch upcoming and recent episodes
|
||||
const episodeData = await stremioService.getUpcomingEpisodes(series.type, series.id, {
|
||||
daysBack: 90, // 3 months back for recently released episodes
|
||||
daysAhead: 60, // 2 months ahead for upcoming episodes
|
||||
maxEpisodes: 50, // Increased limit to get more episodes per series
|
||||
});
|
||||
|
||||
if (episodeData && episodeData.episodes.length > 0) {
|
||||
const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id);
|
||||
let tmdbEpisodes: { [key: string]: any } = {};
|
||||
|
||||
// Only fetch TMDB data if we need it and limit it
|
||||
if (tmdbId && episodeData.episodes.length > 0) {
|
||||
try {
|
||||
// Get only current and next season to limit memory usage
|
||||
const seasons = [...new Set(episodeData.episodes.map(ep => ep.season || 1))];
|
||||
const limitedSeasons = seasons.slice(0, 3); // Limit to 3 seasons max
|
||||
|
||||
for (const seasonNum of limitedSeasons) {
|
||||
const seasonEpisodes = await tmdbService.getSeasonDetails(tmdbId, seasonNum);
|
||||
if (seasonEpisodes?.episodes) {
|
||||
seasonEpisodes.episodes.forEach((episode: any) => {
|
||||
const key = `${episode.season_number}:${episode.episode_number}`;
|
||||
tmdbEpisodes[key] = episode;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (tmdbError) {
|
||||
logger.warn(`[CalendarData] TMDB fetch failed for ${series.name}, continuing without additional metadata`);
|
||||
}
|
||||
}
|
||||
|
||||
// Transform episodes with memory-efficient processing
|
||||
const transformedEpisodes = episodeData.episodes.map(video => {
|
||||
const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {};
|
||||
const episode = {
|
||||
id: video.id,
|
||||
seriesId: series.id,
|
||||
title: tmdbEpisode.name || video.title || `Episode ${video.episode}`,
|
||||
seriesName: series.name || episodeData.seriesName,
|
||||
poster: series.poster || episodeData.poster || '',
|
||||
releaseDate: video.released,
|
||||
season: video.season || 0,
|
||||
episode: video.episode || 0,
|
||||
overview: tmdbEpisode.overview || '',
|
||||
vote_average: tmdbEpisode.vote_average || 0,
|
||||
still_path: tmdbEpisode.still_path || null,
|
||||
season_poster_path: tmdbEpisode.season_poster_path || null
|
||||
};
|
||||
|
||||
|
||||
return episode;
|
||||
});
|
||||
|
||||
// Clear references to help garbage collection
|
||||
memoryManager.clearObjects(tmdbEpisodes);
|
||||
|
||||
return { type: 'episodes', data: transformedEpisodes };
|
||||
} else {
|
||||
return {
|
||||
type: 'no-episodes',
|
||||
data: {
|
||||
id: series.id,
|
||||
seriesId: series.id,
|
||||
title: 'No upcoming episodes',
|
||||
seriesName: series.name || episodeData?.seriesName || '',
|
||||
poster: series.poster || episodeData?.poster || '',
|
||||
releaseDate: '',
|
||||
season: 0,
|
||||
episode: 0,
|
||||
overview: '',
|
||||
vote_average: 0,
|
||||
still_path: null,
|
||||
season_poster_path: null
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[CalendarData] Error fetching episodes for ${series.name}:`, error);
|
||||
return {
|
||||
type: 'no-episodes',
|
||||
data: {
|
||||
id: series.id,
|
||||
seriesId: series.id,
|
||||
title: 'No upcoming episodes',
|
||||
seriesName: series.name || '',
|
||||
poster: series.poster || '',
|
||||
releaseDate: '',
|
||||
season: 0,
|
||||
episode: 0,
|
||||
overview: '',
|
||||
vote_average: 0,
|
||||
still_path: null,
|
||||
season_poster_path: null
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
5, // Small batch size to prevent memory spikes
|
||||
100 // Small delay between batches
|
||||
);
|
||||
|
||||
// Process results and separate episodes from no-episode series
|
||||
for (const result of processedSeries) {
|
||||
if (!result) {
|
||||
logger.error(`[CalendarData] Null/undefined result in processedSeries`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.type === 'episodes' && Array.isArray(result.data)) {
|
||||
allEpisodes.push(...result.data);
|
||||
} else if (result.type === 'no-episodes' && result.data) {
|
||||
seriesWithoutEpisodes.push(result.data as CalendarEpisode);
|
||||
} else {
|
||||
logger.warn(`[CalendarData] Unexpected result type or missing data:`, result);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear processed series to free memory
|
||||
memoryManager.clearObjects(processedSeries);
|
||||
|
||||
// Limit total episodes to prevent memory overflow
|
||||
allEpisodes = memoryManager.limitArraySize(allEpisodes, 500);
|
||||
seriesWithoutEpisodes = memoryManager.limitArraySize(seriesWithoutEpisodes, 100);
|
||||
|
||||
// Sort episodes by release date with error handling
|
||||
allEpisodes.sort((a, b) => {
|
||||
try {
|
||||
const dateA = new Date(a.releaseDate).getTime();
|
||||
const dateB = new Date(b.releaseDate).getTime();
|
||||
return dateA - dateB;
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error sorting episodes: ${a.releaseDate} vs ${b.releaseDate}`, error);
|
||||
return 0; // Keep original order if sorting fails
|
||||
}
|
||||
});
|
||||
|
||||
logger.log(`[CalendarData] Total episodes fetched: ${allEpisodes.length}`);
|
||||
|
||||
// Use memory-efficient filtering with error handling
|
||||
const thisWeekEpisodes = await memoryManager.filterLargeArray(
|
||||
allEpisodes,
|
||||
ep => {
|
||||
try {
|
||||
if (!ep.releaseDate) return false;
|
||||
const parsed = parseISO(ep.releaseDate);
|
||||
return isThisWeek(parsed) && isAfter(parsed, new Date());
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error parsing date for this week filtering: ${ep.releaseDate}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const upcomingEpisodes = await memoryManager.filterLargeArray(
|
||||
allEpisodes,
|
||||
ep => {
|
||||
try {
|
||||
if (!ep.releaseDate) return false;
|
||||
const parsed = parseISO(ep.releaseDate);
|
||||
return isAfter(parsed, new Date()) && !isThisWeek(parsed);
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error parsing date for upcoming filtering: ${ep.releaseDate}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const recentEpisodes = await memoryManager.filterLargeArray(
|
||||
allEpisodes,
|
||||
ep => {
|
||||
try {
|
||||
if (!ep.releaseDate) return false;
|
||||
const parsed = parseISO(ep.releaseDate);
|
||||
return isBefore(parsed, new Date());
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error parsing date for recent filtering: ${ep.releaseDate}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.log(`[CalendarData] Episode categorization: This Week: ${thisWeekEpisodes.length}, Upcoming: ${upcomingEpisodes.length}, Recently Released: ${recentEpisodes.length}, Series without episodes: ${seriesWithoutEpisodes.length}`);
|
||||
|
||||
// Debug: Show some example episodes from each category
|
||||
if (thisWeekEpisodes && thisWeekEpisodes.length > 0) {
|
||||
logger.log(`[CalendarData] This Week examples:`, thisWeekEpisodes.slice(0, 3).map(ep => ({
|
||||
title: ep.title,
|
||||
date: ep.releaseDate,
|
||||
series: ep.seriesName
|
||||
})));
|
||||
}
|
||||
if (recentEpisodes && recentEpisodes.length > 0) {
|
||||
logger.log(`[CalendarData] Recently Released examples:`, recentEpisodes.slice(0, 3).map(ep => ({
|
||||
title: ep.title,
|
||||
date: ep.releaseDate,
|
||||
series: ep.seriesName
|
||||
})));
|
||||
}
|
||||
|
||||
const sections: CalendarSection[] = [];
|
||||
if (thisWeekEpisodes.length > 0) {
|
||||
sections.push({ title: 'This Week', data: thisWeekEpisodes });
|
||||
logger.log(`[CalendarData] Added 'This Week' section with ${thisWeekEpisodes.length} episodes`);
|
||||
}
|
||||
if (upcomingEpisodes.length > 0) {
|
||||
sections.push({ title: 'Upcoming', data: upcomingEpisodes });
|
||||
logger.log(`[CalendarData] Added 'Upcoming' section with ${upcomingEpisodes.length} episodes`);
|
||||
}
|
||||
if (recentEpisodes.length > 0) {
|
||||
sections.push({ title: 'Recently Released', data: recentEpisodes });
|
||||
logger.log(`[CalendarData] Added 'Recently Released' section with ${recentEpisodes.length} episodes`);
|
||||
}
|
||||
if (seriesWithoutEpisodes.length > 0) {
|
||||
sections.push({ title: 'Series with No Scheduled Episodes', data: seriesWithoutEpisodes });
|
||||
logger.log(`[CalendarData] Added 'Series with No Scheduled Episodes' section with ${seriesWithoutEpisodes.length} episodes`);
|
||||
}
|
||||
|
||||
// Log section details before setting
|
||||
logger.log(`[CalendarData] About to set calendarData with ${sections.length} sections:`);
|
||||
sections.forEach((section, index) => {
|
||||
logger.log(` Section ${index}: "${section.title}" with ${section.data?.length || 0} episodes`);
|
||||
});
|
||||
|
||||
setCalendarData(sections);
|
||||
|
||||
// Clear large arrays to help garbage collection
|
||||
// Note: Don't clear the arrays that are referenced in sections (thisWeekEpisodes, upcomingEpisodes, recentEpisodes, seriesWithoutEpisodes)
|
||||
// as they would empty the section data
|
||||
memoryManager.clearObjects(allEpisodes);
|
||||
|
||||
await robustCalendarCache.setCachedCalendarData(
|
||||
sections,
|
||||
libraryItems,
|
||||
{ watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[CalendarData] Error fetching calendar data:', error);
|
||||
await robustCalendarCache.setCachedCalendarData(
|
||||
[],
|
||||
libraryItems,
|
||||
{ watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows },
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
// Force garbage collection after processing
|
||||
memoryManager.forceGarbageCollection();
|
||||
if (cachedData) {
|
||||
setCalendarData(cachedData);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}, [libraryItems, traktAuthenticated, watchlistShows, continueWatching, watchedShows]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryLoading && !traktLoading) {
|
||||
if (traktAuthenticated && (!watchlistShows || !continueWatching || !watchedShows)) {
|
||||
loadAllCollections();
|
||||
} else {
|
||||
fetchCalendarData();
|
||||
const librarySeries = libraryItems.filter(item => item.type === 'series');
|
||||
|
||||
// Prioritize series sources: Continue Watching > Watchlist > Library > Watched
|
||||
// This ensures that shows the user is actively watching or interested in are checked first
|
||||
// before hitting the series limit.
|
||||
let allSeries: StreamingContent[] = [];
|
||||
const addedIds = new Set<string>();
|
||||
|
||||
// Helper to add series if not already added
|
||||
const addSeries = (id: string, name: string, year: number, poster: string, source: 'watchlist' | 'continue-watching' | 'watched' | 'library') => {
|
||||
if (!addedIds.has(id)) {
|
||||
addedIds.add(id);
|
||||
allSeries.push({
|
||||
id,
|
||||
name,
|
||||
type: 'series',
|
||||
poster,
|
||||
year,
|
||||
traktSource: source as any // Cast to any to avoid strict type issues with 'library' which might not be in the interface
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (traktAuthenticated) {
|
||||
// 1. Continue Watching (Highest Priority)
|
||||
if (continueWatching) {
|
||||
for (const item of continueWatching) {
|
||||
if (item.type === 'episode' && item.show && item.show.ids.imdb) {
|
||||
addSeries(
|
||||
item.show.ids.imdb,
|
||||
item.show.title,
|
||||
item.show.year,
|
||||
'', // Poster will be fetched if missing
|
||||
'continue-watching'
|
||||
);
|
||||
}
|
||||
} else if (!libraryLoading && !traktAuthenticated) {
|
||||
fetchCalendarData();
|
||||
}
|
||||
}
|
||||
}, [libraryItems, libraryLoading, traktLoading, traktAuthenticated, watchlistShows, continueWatching, watchedShows, fetchCalendarData, loadAllCollections]);
|
||||
|
||||
const refresh = useCallback((force = false) => {
|
||||
fetchCalendarData(force);
|
||||
}, [fetchCalendarData]);
|
||||
// 2. Watchlist
|
||||
if (watchlistShows) {
|
||||
for (const item of watchlistShows) {
|
||||
if (item.show && item.show.ids.imdb) {
|
||||
addSeries(
|
||||
item.show.ids.imdb,
|
||||
item.show.title,
|
||||
item.show.year,
|
||||
'',
|
||||
'watchlist'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. Library
|
||||
for (const item of librarySeries) {
|
||||
addSeries(
|
||||
item.id,
|
||||
item.name,
|
||||
item.year || 0,
|
||||
item.poster,
|
||||
'library'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
calendarData,
|
||||
loading,
|
||||
refresh,
|
||||
};
|
||||
// 4. Watched (Lowest Priority)
|
||||
if (traktAuthenticated && watchedShows) {
|
||||
const recentWatched = watchedShows.slice(0, 20);
|
||||
for (const item of recentWatched) {
|
||||
if (item.show && item.show.ids.imdb) {
|
||||
addSeries(
|
||||
item.show.ids.imdb,
|
||||
item.show.title,
|
||||
item.show.year,
|
||||
'',
|
||||
'watched'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the number of series to prevent memory overflow
|
||||
const maxSeries = 300; // Increased from 100 to 300 to accommodate larger libraries
|
||||
if (allSeries.length > maxSeries) {
|
||||
logger.warn(`[CalendarData] Too many series (${allSeries.length}), limiting to ${maxSeries} to prevent memory issues`);
|
||||
allSeries = allSeries.slice(0, maxSeries);
|
||||
}
|
||||
|
||||
logger.log(`[CalendarData] Total series to check: ${allSeries.length}`);
|
||||
|
||||
let allEpisodes: CalendarEpisode[] = [];
|
||||
let seriesWithoutEpisodes: CalendarEpisode[] = [];
|
||||
|
||||
// Process series in memory-efficient batches to prevent OOM
|
||||
const processedSeries = await memoryManager.processArrayInBatches(
|
||||
allSeries,
|
||||
async (series: StreamingContent, index: number) => {
|
||||
try {
|
||||
// Use the new memory-efficient method to fetch upcoming and recent episodes
|
||||
const episodeData = await stremioService.getUpcomingEpisodes(series.type, series.id, {
|
||||
daysBack: 90, // 3 months back for recently released episodes
|
||||
daysAhead: 60, // 2 months ahead for upcoming episodes
|
||||
maxEpisodes: 50, // Increased limit to get more episodes per series
|
||||
});
|
||||
|
||||
if (episodeData && episodeData.episodes.length > 0) {
|
||||
const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id);
|
||||
let tmdbEpisodes: { [key: string]: any } = {};
|
||||
|
||||
// Only fetch TMDB data if we need it and limit it
|
||||
if (tmdbId && episodeData.episodes.length > 0) {
|
||||
try {
|
||||
// Get only current and next season to limit memory usage
|
||||
const seasons = [...new Set(episodeData.episodes.map(ep => ep.season || 1))];
|
||||
const limitedSeasons = seasons.slice(0, 3); // Limit to 3 seasons max
|
||||
|
||||
for (const seasonNum of limitedSeasons) {
|
||||
const seasonEpisodes = await tmdbService.getSeasonDetails(tmdbId, seasonNum);
|
||||
if (seasonEpisodes?.episodes) {
|
||||
seasonEpisodes.episodes.forEach((episode: any) => {
|
||||
const key = `${episode.season_number}:${episode.episode_number}`;
|
||||
tmdbEpisodes[key] = episode;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (tmdbError) {
|
||||
logger.warn(`[CalendarData] TMDB fetch failed for ${series.name}, continuing without additional metadata`);
|
||||
}
|
||||
}
|
||||
|
||||
// Transform episodes with memory-efficient processing
|
||||
const transformedEpisodes = episodeData.episodes.map(video => {
|
||||
const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {};
|
||||
const episode = {
|
||||
id: video.id,
|
||||
seriesId: series.id,
|
||||
title: tmdbEpisode.name || video.title || `Episode ${video.episode}`,
|
||||
seriesName: series.name || episodeData.seriesName,
|
||||
poster: series.poster || episodeData.poster || '',
|
||||
releaseDate: video.released,
|
||||
season: video.season || 0,
|
||||
episode: video.episode || 0,
|
||||
overview: tmdbEpisode.overview || '',
|
||||
vote_average: tmdbEpisode.vote_average || 0,
|
||||
still_path: tmdbEpisode.still_path || null,
|
||||
season_poster_path: tmdbEpisode.season_poster_path || null
|
||||
};
|
||||
|
||||
|
||||
return episode;
|
||||
});
|
||||
|
||||
// Clear references to help garbage collection
|
||||
memoryManager.clearObjects(tmdbEpisodes);
|
||||
|
||||
return { type: 'episodes', data: transformedEpisodes };
|
||||
} else {
|
||||
return {
|
||||
type: 'no-episodes',
|
||||
data: {
|
||||
id: series.id,
|
||||
seriesId: series.id,
|
||||
title: 'No upcoming episodes',
|
||||
seriesName: series.name || episodeData?.seriesName || '',
|
||||
poster: series.poster || episodeData?.poster || '',
|
||||
releaseDate: '',
|
||||
season: 0,
|
||||
episode: 0,
|
||||
overview: '',
|
||||
vote_average: 0,
|
||||
still_path: null,
|
||||
season_poster_path: null
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[CalendarData] Error fetching episodes for ${series.name}:`, error);
|
||||
return {
|
||||
type: 'no-episodes',
|
||||
data: {
|
||||
id: series.id,
|
||||
seriesId: series.id,
|
||||
title: 'No upcoming episodes',
|
||||
seriesName: series.name || '',
|
||||
poster: series.poster || '',
|
||||
releaseDate: '',
|
||||
season: 0,
|
||||
episode: 0,
|
||||
overview: '',
|
||||
vote_average: 0,
|
||||
still_path: null,
|
||||
season_poster_path: null
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
5, // Small batch size to prevent memory spikes
|
||||
100 // Small delay between batches
|
||||
);
|
||||
|
||||
// Process results and separate episodes from no-episode series
|
||||
for (const result of processedSeries) {
|
||||
if (!result) {
|
||||
logger.error(`[CalendarData] Null/undefined result in processedSeries`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.type === 'episodes' && Array.isArray(result.data)) {
|
||||
allEpisodes.push(...result.data);
|
||||
} else if (result.type === 'no-episodes' && result.data) {
|
||||
seriesWithoutEpisodes.push(result.data as CalendarEpisode);
|
||||
} else {
|
||||
logger.warn(`[CalendarData] Unexpected result type or missing data:`, result);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear processed series to free memory
|
||||
memoryManager.clearObjects(processedSeries);
|
||||
|
||||
// Limit total episodes to prevent memory overflow
|
||||
allEpisodes = memoryManager.limitArraySize(allEpisodes, 500);
|
||||
seriesWithoutEpisodes = memoryManager.limitArraySize(seriesWithoutEpisodes, 100);
|
||||
|
||||
// Sort episodes by release date with error handling
|
||||
allEpisodes.sort((a, b) => {
|
||||
try {
|
||||
const dateA = new Date(a.releaseDate).getTime();
|
||||
const dateB = new Date(b.releaseDate).getTime();
|
||||
return dateA - dateB;
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error sorting episodes: ${a.releaseDate} vs ${b.releaseDate}`, error);
|
||||
return 0; // Keep original order if sorting fails
|
||||
}
|
||||
});
|
||||
|
||||
logger.log(`[CalendarData] Total episodes fetched: ${allEpisodes.length}`);
|
||||
|
||||
// Use memory-efficient filtering with error handling
|
||||
const thisWeekEpisodes = await memoryManager.filterLargeArray(
|
||||
allEpisodes,
|
||||
ep => {
|
||||
try {
|
||||
if (!ep.releaseDate) return false;
|
||||
const parsed = parseISO(ep.releaseDate);
|
||||
// Show all episodes for this week, including released ones
|
||||
return isThisWeek(parsed);
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error parsing date for this week filtering: ${ep.releaseDate}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const upcomingEpisodes = await memoryManager.filterLargeArray(
|
||||
allEpisodes,
|
||||
ep => {
|
||||
try {
|
||||
if (!ep.releaseDate) return false;
|
||||
const parsed = parseISO(ep.releaseDate);
|
||||
// Show upcoming episodes that are NOT this week
|
||||
return isAfter(parsed, new Date()) && !isThisWeek(parsed);
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error parsing date for upcoming filtering: ${ep.releaseDate}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const recentEpisodes = await memoryManager.filterLargeArray(
|
||||
allEpisodes,
|
||||
ep => {
|
||||
try {
|
||||
if (!ep.releaseDate) return false;
|
||||
const parsed = parseISO(ep.releaseDate);
|
||||
// Show past episodes that are NOT this week
|
||||
return isBefore(parsed, new Date()) && !isThisWeek(parsed);
|
||||
} catch (error) {
|
||||
logger.warn(`[CalendarData] Error parsing date for recent filtering: ${ep.releaseDate}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.log(`[CalendarData] Episode categorization: This Week: ${thisWeekEpisodes.length}, Upcoming: ${upcomingEpisodes.length}, Recently Released: ${recentEpisodes.length}, Series without episodes: ${seriesWithoutEpisodes.length}`);
|
||||
|
||||
// Debug: Show some example episodes from each category
|
||||
if (thisWeekEpisodes && thisWeekEpisodes.length > 0) {
|
||||
logger.log(`[CalendarData] This Week examples:`, thisWeekEpisodes.slice(0, 3).map(ep => ({
|
||||
title: ep.title,
|
||||
date: ep.releaseDate,
|
||||
series: ep.seriesName
|
||||
})));
|
||||
}
|
||||
if (recentEpisodes && recentEpisodes.length > 0) {
|
||||
logger.log(`[CalendarData] Recently Released examples:`, recentEpisodes.slice(0, 3).map(ep => ({
|
||||
title: ep.title,
|
||||
date: ep.releaseDate,
|
||||
series: ep.seriesName
|
||||
})));
|
||||
}
|
||||
|
||||
const sections: CalendarSection[] = [];
|
||||
if (thisWeekEpisodes.length > 0) {
|
||||
sections.push({ title: 'This Week', data: thisWeekEpisodes });
|
||||
logger.log(`[CalendarData] Added 'This Week' section with ${thisWeekEpisodes.length} episodes`);
|
||||
}
|
||||
if (upcomingEpisodes.length > 0) {
|
||||
sections.push({ title: 'Upcoming', data: upcomingEpisodes });
|
||||
logger.log(`[CalendarData] Added 'Upcoming' section with ${upcomingEpisodes.length} episodes`);
|
||||
}
|
||||
if (recentEpisodes.length > 0) {
|
||||
sections.push({ title: 'Recently Released', data: recentEpisodes });
|
||||
logger.log(`[CalendarData] Added 'Recently Released' section with ${recentEpisodes.length} episodes`);
|
||||
}
|
||||
if (seriesWithoutEpisodes.length > 0) {
|
||||
sections.push({ title: 'Series with No Scheduled Episodes', data: seriesWithoutEpisodes });
|
||||
logger.log(`[CalendarData] Added 'Series with No Scheduled Episodes' section with ${seriesWithoutEpisodes.length} episodes`);
|
||||
}
|
||||
|
||||
// Log section details before setting
|
||||
logger.log(`[CalendarData] About to set calendarData with ${sections.length} sections:`);
|
||||
sections.forEach((section, index) => {
|
||||
logger.log(` Section ${index}: "${section.title}" with ${section.data?.length || 0} episodes`);
|
||||
});
|
||||
|
||||
setCalendarData(sections);
|
||||
|
||||
// Clear large arrays to help garbage collection
|
||||
// Note: Don't clear the arrays that are referenced in sections (thisWeekEpisodes, upcomingEpisodes, recentEpisodes, seriesWithoutEpisodes)
|
||||
// as they would empty the section data
|
||||
memoryManager.clearObjects(allEpisodes);
|
||||
|
||||
await robustCalendarCache.setCachedCalendarData(
|
||||
sections,
|
||||
libraryItems,
|
||||
{ watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[CalendarData] Error fetching calendar data:', error);
|
||||
await robustCalendarCache.setCachedCalendarData(
|
||||
[],
|
||||
libraryItems,
|
||||
{ watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows },
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
// Force garbage collection after processing
|
||||
memoryManager.forceGarbageCollection();
|
||||
setLoading(false);
|
||||
}
|
||||
}, [libraryItems, traktAuthenticated, watchlistShows, continueWatching, watchedShows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryLoading && !traktLoading) {
|
||||
if (traktAuthenticated && (!watchlistShows || !continueWatching || !watchedShows)) {
|
||||
loadAllCollections();
|
||||
} else {
|
||||
fetchCalendarData();
|
||||
}
|
||||
} else if (!libraryLoading && !traktAuthenticated) {
|
||||
fetchCalendarData();
|
||||
}
|
||||
}, [libraryItems, libraryLoading, traktLoading, traktAuthenticated, watchlistShows, continueWatching, watchedShows, fetchCalendarData, loadAllCollections]);
|
||||
|
||||
const refresh = useCallback((force = false) => {
|
||||
fetchCalendarData(force);
|
||||
}, [fetchCalendarData]);
|
||||
|
||||
return {
|
||||
calendarData,
|
||||
loading,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -2168,7 +2168,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
useEffect(() => {
|
||||
const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
|
||||
const isInLib = libraryItems.some(item => item.id === id);
|
||||
setInLibrary(isInLib);
|
||||
// Only update state if the value actually changed to prevent unnecessary re-renders
|
||||
setInLibrary(prev => prev !== isInLib ? isInLib : prev);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import BackdropGalleryScreen from '../screens/BackdropGalleryScreen';
|
|||
import BackupScreen from '../screens/BackupScreen';
|
||||
import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen';
|
||||
import ContributorsScreen from '../screens/ContributorsScreen';
|
||||
import DebridIntegrationScreen from '../screens/DebridIntegrationScreen';
|
||||
|
||||
// Stack navigator types
|
||||
export type RootStackParamList = {
|
||||
|
|
@ -179,6 +180,7 @@ export type RootStackParamList = {
|
|||
};
|
||||
ContinueWatchingSettings: undefined;
|
||||
Contributors: undefined;
|
||||
DebridIntegration: undefined;
|
||||
};
|
||||
|
||||
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
|
@ -431,7 +433,7 @@ const TabIcon = React.memo(({ focused, color, iconName, iconLibrary = 'material'
|
|||
});
|
||||
|
||||
// Update the TabScreenWrapper component with fixed layout dimensions
|
||||
const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) => {
|
||||
const TabScreenWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [dimensions, setDimensions] = useState(Dimensions.get('window'));
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -461,11 +463,11 @@ const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) =
|
|||
// Apply status bar config on every focus
|
||||
const subscription = Platform.OS === 'android'
|
||||
? AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
applyStatusBarConfig();
|
||||
}
|
||||
})
|
||||
: { remove: () => {} };
|
||||
if (state === 'active') {
|
||||
applyStatusBarConfig();
|
||||
}
|
||||
})
|
||||
: { remove: () => { } };
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
|
|
@ -497,7 +499,7 @@ const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) =
|
|||
};
|
||||
|
||||
// Add this component to wrap each screen in the tab navigator
|
||||
const WrappedScreen: React.FC<{Screen: React.ComponentType<any>}> = ({ Screen }) => {
|
||||
const WrappedScreen: React.FC<{ Screen: React.ComponentType<any> }> = ({ Screen }) => {
|
||||
return (
|
||||
<TabScreenWrapper>
|
||||
<Screen />
|
||||
|
|
@ -528,7 +530,7 @@ const MainTabs = () => {
|
|||
try {
|
||||
const flag = await mmkvStorage.getItem('@update_badge_pending');
|
||||
if (mounted) setHasUpdateBadge(flag === 'true');
|
||||
} catch {}
|
||||
} catch { }
|
||||
};
|
||||
load();
|
||||
// Fast poll initially for quick badge appearance, then slow down
|
||||
|
|
@ -589,18 +591,18 @@ const MainTabs = () => {
|
|||
// Top floating, text-only pill nav for tablets
|
||||
return (
|
||||
<Animated.View
|
||||
style={[{
|
||||
position: 'absolute',
|
||||
top: insets.top + 12,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 100,
|
||||
}, shouldKeepFixed ? {} : {
|
||||
transform: [{ translateY }],
|
||||
opacity: fade,
|
||||
}]}>
|
||||
style={[{
|
||||
position: 'absolute',
|
||||
top: insets.top + 12,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 100,
|
||||
}, shouldKeepFixed ? {} : {
|
||||
transform: [{ translateY }],
|
||||
opacity: fade,
|
||||
}]}>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
|
@ -644,8 +646,8 @@ const MainTabs = () => {
|
|||
options.tabBarLabel !== undefined
|
||||
? options.tabBarLabel
|
||||
: options.title !== undefined
|
||||
? options.title
|
||||
: route.name;
|
||||
? options.title
|
||||
: route.name;
|
||||
|
||||
const isFocused = props.state.index === index;
|
||||
|
||||
|
|
@ -758,8 +760,8 @@ const MainTabs = () => {
|
|||
options.tabBarLabel !== undefined
|
||||
? options.tabBarLabel
|
||||
: options.title !== undefined
|
||||
? options.title
|
||||
: route.name;
|
||||
? options.title
|
||||
: route.name;
|
||||
|
||||
const isFocused = props.state.index === index;
|
||||
|
||||
|
|
@ -1062,6 +1064,14 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
// Handle Android-specific optimizations
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'android') {
|
||||
// Hide system navigation bar
|
||||
try {
|
||||
RNImmersiveMode.setBarMode('Bottom');
|
||||
RNImmersiveMode.fullLayout(true);
|
||||
} catch (error) {
|
||||
console.log('Immersive mode error:', error);
|
||||
}
|
||||
|
||||
// Ensure consistent background color for Android
|
||||
StatusBar.setBackgroundColor('transparent', true);
|
||||
StatusBar.setTranslucent(true);
|
||||
|
|
@ -1554,6 +1564,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="DebridIntegration"
|
||||
component={DebridIntegrationScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</View>
|
||||
</PaperProvider>
|
||||
|
|
|
|||
|
|
@ -524,8 +524,8 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
opacity: 0.8,
|
||||
},
|
||||
communityAddonVersion: {
|
||||
fontSize: 12,
|
||||
color: colors.lightGray,
|
||||
fontSize: 12,
|
||||
color: colors.lightGray,
|
||||
},
|
||||
communityAddonDot: {
|
||||
fontSize: 12,
|
||||
|
|
@ -533,18 +533,18 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
marginHorizontal: 5,
|
||||
},
|
||||
communityAddonCategory: {
|
||||
fontSize: 12,
|
||||
color: colors.lightGray,
|
||||
flexShrink: 1,
|
||||
fontSize: 12,
|
||||
color: colors.lightGray,
|
||||
flexShrink: 1,
|
||||
},
|
||||
separator: {
|
||||
height: 10,
|
||||
},
|
||||
sectionSeparator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: 20,
|
||||
marginVertical: 20,
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginHorizontal: 20,
|
||||
marginVertical: 20,
|
||||
},
|
||||
emptyMessage: {
|
||||
textAlign: 'center',
|
||||
|
|
@ -660,11 +660,21 @@ const AddonsScreen = () => {
|
|||
setLoading(true);
|
||||
// Use the regular method without disabled state
|
||||
const installedAddons = await stremioService.getInstalledAddonsAsync();
|
||||
setAddons(installedAddons as ExtendedManifest[]);
|
||||
|
||||
// Filter out Torbox addons (managed via DebridIntegrationScreen)
|
||||
const filteredAddons = installedAddons.filter(addon => {
|
||||
const isTorboxAddon =
|
||||
addon.id?.includes('torbox') ||
|
||||
addon.url?.includes('torbox') ||
|
||||
(addon as any).transport?.includes('torbox');
|
||||
return !isTorboxAddon;
|
||||
});
|
||||
|
||||
setAddons(filteredAddons as ExtendedManifest[]);
|
||||
|
||||
// Count catalogs
|
||||
let totalCatalogs = 0;
|
||||
installedAddons.forEach(addon => {
|
||||
filteredAddons.forEach(addon => {
|
||||
if (addon.catalogs && addon.catalogs.length > 0) {
|
||||
totalCatalogs += addon.catalogs.length;
|
||||
}
|
||||
|
|
@ -682,11 +692,11 @@ const AddonsScreen = () => {
|
|||
setCatalogCount(totalCatalogs);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load addons:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Failed to load addons');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
logger.error('Failed to load addons:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Failed to load addons');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -706,9 +716,9 @@ const AddonsScreen = () => {
|
|||
|
||||
setCommunityAddons(validAddons);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load community addons:', error);
|
||||
setCommunityError('Failed to load community addons. Please try again later.');
|
||||
setCommunityAddons([]);
|
||||
logger.error('Failed to load community addons:', error);
|
||||
setCommunityError('Failed to load community addons. Please try again later.');
|
||||
setCommunityAddons([]);
|
||||
} finally {
|
||||
setCommunityLoading(false);
|
||||
}
|
||||
|
|
@ -756,16 +766,16 @@ const AddonsScreen = () => {
|
|||
setShowConfirmModal(false);
|
||||
setAddonDetails(null);
|
||||
loadAddons();
|
||||
setAlertTitle('Success');
|
||||
setAlertMessage('Addon installed successfully');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
setAlertTitle('Success');
|
||||
setAlertMessage('Addon installed successfully');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
} catch (error) {
|
||||
logger.error('Failed to install addon:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Failed to install addon');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
logger.error('Failed to install addon:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Failed to install addon');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
|
|
@ -927,10 +937,10 @@ const AddonsScreen = () => {
|
|||
}
|
||||
}).catch(err => {
|
||||
logger.error(`Error checking if URL can be opened: ${configUrl}`, err);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Could not open configuration page.');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Could not open configuration page.');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -1077,9 +1087,9 @@ const AddonsScreen = () => {
|
|||
<Text style={styles.communityAddonName}>{manifest.name}</Text>
|
||||
<Text style={styles.communityAddonDesc} numberOfLines={2}>{description}</Text>
|
||||
<View style={styles.communityAddonMetaContainer}>
|
||||
<Text style={styles.communityAddonVersion}>v{manifest.version || 'N/A'}</Text>
|
||||
<Text style={styles.communityAddonDot}>•</Text>
|
||||
<Text style={styles.communityAddonCategory}>{categoryText}</Text>
|
||||
<Text style={styles.communityAddonVersion}>v{manifest.version || 'N/A'}</Text>
|
||||
<Text style={styles.communityAddonDot}>•</Text>
|
||||
<Text style={styles.communityAddonCategory}>{categoryText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.addonActionButtons}>
|
||||
|
|
@ -1208,7 +1218,7 @@ const AddonsScreen = () => {
|
|||
autoCorrect={false}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, {opacity: installing || !addonUrl ? 0.6 : 1}]}
|
||||
style={[styles.addButton, { opacity: installing || !addonUrl ? 0.6 : 1 }]}
|
||||
onPress={() => handleAddAddon()}
|
||||
disabled={installing || !addonUrl}
|
||||
>
|
||||
|
|
@ -1245,68 +1255,68 @@ const AddonsScreen = () => {
|
|||
</View>
|
||||
|
||||
{/* Separator */}
|
||||
<View style={styles.sectionSeparator} />
|
||||
<View style={styles.sectionSeparator} />
|
||||
|
||||
{/* Promotional Addon Section (hidden if installed) */}
|
||||
{!isPromoInstalled && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>OFFICIAL ADDON</Text>
|
||||
<View style={styles.addonList}>
|
||||
<View style={styles.addonItem}>
|
||||
<View style={styles.addonHeader}>
|
||||
{promoAddon.logo ? (
|
||||
<FastImage
|
||||
source={{ uri: promoAddon.logo }}
|
||||
style={styles.addonIcon}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.addonIconPlaceholder}>
|
||||
<MaterialIcons name="extension" size={22} color={colors.mediumGray} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.addonTitleContainer}>
|
||||
<Text style={styles.addonName}>{promoAddon.name}</Text>
|
||||
<View style={styles.addonMetaContainer}>
|
||||
<Text style={styles.addonVersion}>v{promoAddon.version}</Text>
|
||||
<Text style={styles.addonDot}>•</Text>
|
||||
<Text style={styles.addonCategory}>{promoAddon.types?.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.addonActions}>
|
||||
{promoAddon.behaviorHints?.configurable && (
|
||||
<TouchableOpacity
|
||||
style={styles.configButton}
|
||||
onPress={() => handleConfigureAddon(promoAddon, PROMO_ADDON_URL)}
|
||||
>
|
||||
<MaterialIcons name="settings" size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.installButton}
|
||||
onPress={() => handleAddAddon(PROMO_ADDON_URL)}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing ? (
|
||||
<ActivityIndicator size="small" color={colors.white} />
|
||||
) : (
|
||||
<MaterialIcons name="add" size={20} color={colors.white} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.addonDescription}>
|
||||
{promoAddon.description}
|
||||
</Text>
|
||||
<Text style={[styles.addonDescription, { marginTop: 4, opacity: 0.9 }]}>
|
||||
Configure and install for full functionality.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{/* Promotional Addon Section (hidden if installed) */}
|
||||
{!isPromoInstalled && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>OFFICIAL ADDON</Text>
|
||||
<View style={styles.addonList}>
|
||||
<View style={styles.addonItem}>
|
||||
<View style={styles.addonHeader}>
|
||||
{promoAddon.logo ? (
|
||||
<FastImage
|
||||
source={{ uri: promoAddon.logo }}
|
||||
style={styles.addonIcon}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.addonIconPlaceholder}>
|
||||
<MaterialIcons name="extension" size={22} color={colors.mediumGray} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.addonTitleContainer}>
|
||||
<Text style={styles.addonName}>{promoAddon.name}</Text>
|
||||
<View style={styles.addonMetaContainer}>
|
||||
<Text style={styles.addonVersion}>v{promoAddon.version}</Text>
|
||||
<Text style={styles.addonDot}>•</Text>
|
||||
<Text style={styles.addonCategory}>{promoAddon.types?.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.addonActions}>
|
||||
{promoAddon.behaviorHints?.configurable && (
|
||||
<TouchableOpacity
|
||||
style={styles.configButton}
|
||||
onPress={() => handleConfigureAddon(promoAddon, PROMO_ADDON_URL)}
|
||||
>
|
||||
<MaterialIcons name="settings" size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.installButton}
|
||||
onPress={() => handleAddAddon(PROMO_ADDON_URL)}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing ? (
|
||||
<ActivityIndicator size="small" color={colors.white} />
|
||||
) : (
|
||||
<MaterialIcons name="add" size={20} color={colors.white} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.addonDescription}>
|
||||
{promoAddon.description}
|
||||
</Text>
|
||||
<Text style={[styles.addonDescription, { marginTop: 4, opacity: 0.9 }]}>
|
||||
Configure and install for full functionality.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Community Addons Section */}
|
||||
{/* Community Addons Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>COMMUNITY ADDONS</Text>
|
||||
<View style={styles.addonList}>
|
||||
|
|
@ -1381,8 +1391,8 @@ const AddonsScreen = () => {
|
|||
<Text style={styles.addonDescription}>
|
||||
{item.manifest.description
|
||||
? (item.manifest.description.length > 100
|
||||
? item.manifest.description.substring(0, 100) + '...'
|
||||
: item.manifest.description)
|
||||
? item.manifest.description.substring(0, 100) + '...'
|
||||
: item.manifest.description)
|
||||
: 'No description provided.'}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -1515,15 +1525,15 @@ const AddonsScreen = () => {
|
|||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
{/* Custom Alert Modal */}
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
actions={alertActions}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
{/* Custom Alert Modal */}
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
actions={alertActions}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
797
src/screens/DebridIntegrationScreen.tsx
Normal file
797
src/screens/DebridIntegrationScreen.tsx
Normal file
|
|
@ -0,0 +1,797 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
StatusBar,
|
||||
Platform,
|
||||
Linking,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Image,
|
||||
Switch,
|
||||
ActivityIndicator,
|
||||
RefreshControl
|
||||
} from 'react-native';
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { stremioService } from '../services/stremioService';
|
||||
import { logger } from '../utils/logger';
|
||||
import CustomAlert from '../components/CustomAlert';
|
||||
import { mmkvStorage } from '../services/mmkvStorage';
|
||||
import axios from 'axios';
|
||||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
const TORBOX_STORAGE_KEY = 'torbox_debrid_config';
|
||||
const TORBOX_API_BASE = 'https://api.torbox.app/v1';
|
||||
|
||||
interface TorboxConfig {
|
||||
apiKey: string;
|
||||
isConnected: boolean;
|
||||
isEnabled: boolean;
|
||||
addonId?: string;
|
||||
}
|
||||
|
||||
interface TorboxUserData {
|
||||
id: number;
|
||||
email: string;
|
||||
plan: number;
|
||||
total_downloaded: number;
|
||||
is_subscribed: boolean;
|
||||
premium_expires_at: string | null;
|
||||
base_email: string;
|
||||
}
|
||||
|
||||
const getPlanName = (plan: number): string => {
|
||||
switch (plan) {
|
||||
case 0: return 'Free';
|
||||
case 1: return 'Essential ($3/mo)';
|
||||
case 2: return 'Pro ($10/mo)';
|
||||
case 3: return 'Standard ($5/mo)';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
};
|
||||
const createStyles = (colors: any) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
marginRight: 4,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: colors.mediumEmphasis,
|
||||
marginBottom: 16,
|
||||
lineHeight: 20,
|
||||
opacity: 0.9,
|
||||
},
|
||||
statusCard: {
|
||||
backgroundColor: colors.elevation2,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: colors.mediumEmphasis,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
statusValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
},
|
||||
statusConnected: {
|
||||
color: colors.success || '#4CAF50',
|
||||
},
|
||||
statusDisconnected: {
|
||||
color: colors.error || '#F44336',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.elevation3,
|
||||
marginVertical: 10,
|
||||
},
|
||||
actionButton: {
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
dangerButton: {
|
||||
backgroundColor: colors.error || '#F44336',
|
||||
},
|
||||
buttonText: {
|
||||
color: colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.elevation2,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
color: colors.white,
|
||||
fontSize: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.elevation3,
|
||||
},
|
||||
connectButton: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
connectButtonText: {
|
||||
color: colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
section: {
|
||||
marginTop: 16,
|
||||
backgroundColor: colors.elevation1,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
marginBottom: 6,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
sectionText: {
|
||||
fontSize: 13,
|
||||
color: colors.mediumEmphasis,
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
lineHeight: 18,
|
||||
opacity: 0.9,
|
||||
},
|
||||
subscribeButton: {
|
||||
backgroundColor: colors.elevation3,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
subscribeButtonText: {
|
||||
color: colors.primary,
|
||||
fontWeight: '700',
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginTop: 'auto',
|
||||
paddingBottom: 16,
|
||||
paddingTop: 16,
|
||||
},
|
||||
poweredBy: {
|
||||
fontSize: 10,
|
||||
color: colors.mediumGray,
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
opacity: 0.6,
|
||||
},
|
||||
logo: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
marginBottom: 4,
|
||||
},
|
||||
logoRow: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
userDataCard: {
|
||||
backgroundColor: colors.elevation2,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
userDataRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 6,
|
||||
},
|
||||
userDataLabel: {
|
||||
fontSize: 13,
|
||||
color: colors.mediumEmphasis,
|
||||
flex: 1,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
userDataValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: colors.white,
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
planBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
planBadgeFree: {
|
||||
backgroundColor: colors.elevation3,
|
||||
},
|
||||
planBadgePaid: {
|
||||
backgroundColor: colors.primary + '20',
|
||||
},
|
||||
planBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
planBadgeTextFree: {
|
||||
color: colors.mediumEmphasis,
|
||||
},
|
||||
planBadgeTextPaid: {
|
||||
color: colors.primary,
|
||||
},
|
||||
userDataHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.elevation3,
|
||||
},
|
||||
userDataTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
guideLink: {
|
||||
marginBottom: 16,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
guideLinkText: {
|
||||
color: colors.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
disclaimer: {
|
||||
fontSize: 10,
|
||||
color: colors.mediumGray,
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
opacity: 0.6,
|
||||
}
|
||||
});
|
||||
|
||||
const DebridIntegrationScreen = () => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const colors = currentTheme.colors;
|
||||
const styles = createStyles(colors);
|
||||
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [config, setConfig] = useState<TorboxConfig | null>(null);
|
||||
const [userData, setUserData] = useState<TorboxUserData | null>(null);
|
||||
const [userDataLoading, setUserDataLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Alert state
|
||||
const [alertVisible, setAlertVisible] = useState(false);
|
||||
const [alertTitle, setAlertTitle] = useState('');
|
||||
const [alertMessage, setAlertMessage] = useState('');
|
||||
const [alertActions, setAlertActions] = useState<any[]>([]);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const storedConfig = await mmkvStorage.getItem(TORBOX_STORAGE_KEY);
|
||||
if (storedConfig) {
|
||||
const parsedConfig = JSON.parse(storedConfig);
|
||||
setConfig(parsedConfig);
|
||||
|
||||
// Check if addon is actually installed
|
||||
const addons = await stremioService.getInstalledAddonsAsync();
|
||||
const torboxAddon = addons.find(addon =>
|
||||
addon.id?.includes('torbox') ||
|
||||
addon.url?.includes('torbox') ||
|
||||
(addon as any).transport?.includes('torbox')
|
||||
);
|
||||
|
||||
if (torboxAddon && !parsedConfig.isConnected) {
|
||||
// Update config if addon exists but config says not connected
|
||||
const updatedConfig = { ...parsedConfig, isConnected: true, addonId: torboxAddon.id };
|
||||
setConfig(updatedConfig);
|
||||
await mmkvStorage.setItem(TORBOX_STORAGE_KEY, JSON.stringify(updatedConfig));
|
||||
} else if (!torboxAddon && parsedConfig.isConnected) {
|
||||
// Update config if addon doesn't exist but config says connected
|
||||
const updatedConfig = { ...parsedConfig, isConnected: false, addonId: undefined };
|
||||
setConfig(updatedConfig);
|
||||
await mmkvStorage.setItem(TORBOX_STORAGE_KEY, JSON.stringify(updatedConfig));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load Torbox config:', error);
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUserData = useCallback(async () => {
|
||||
if (!config?.apiKey || !config?.isConnected) return;
|
||||
|
||||
setUserDataLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`${TORBOX_API_BASE}/api/user/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`
|
||||
},
|
||||
params: {
|
||||
settings: false
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
setUserData(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch Torbox user data:', error);
|
||||
// Don't show error to user, just log it
|
||||
} finally {
|
||||
setUserDataLoading(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.isConnected) {
|
||||
fetchUserData();
|
||||
}
|
||||
}, [config?.isConnected, fetchUserData]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await Promise.all([loadConfig(), fetchUserData()]);
|
||||
setRefreshing(false);
|
||||
}, [loadConfig, fetchUserData]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Please enter a valid API Key');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const manifestUrl = `https://stremio.torbox.app/${apiKey.trim()}/manifest.json`;
|
||||
|
||||
// Install the addon using stremioService
|
||||
await stremioService.installAddon(manifestUrl);
|
||||
|
||||
// Get the installed addon ID
|
||||
const addons = await stremioService.getInstalledAddonsAsync();
|
||||
const torboxAddon = addons.find(addon =>
|
||||
addon.id?.includes('torbox') ||
|
||||
addon.url?.includes('torbox') ||
|
||||
(addon as any).transport?.includes('torbox')
|
||||
);
|
||||
|
||||
// Save config
|
||||
const newConfig: TorboxConfig = {
|
||||
apiKey: apiKey.trim(),
|
||||
isConnected: true,
|
||||
isEnabled: true,
|
||||
addonId: torboxAddon?.id
|
||||
};
|
||||
await mmkvStorage.setItem(TORBOX_STORAGE_KEY, JSON.stringify(newConfig));
|
||||
setConfig(newConfig);
|
||||
setApiKey('');
|
||||
|
||||
setAlertTitle('Success');
|
||||
setAlertMessage('Torbox addon connected successfully!');
|
||||
setAlertActions([{
|
||||
label: 'OK',
|
||||
onPress: () => setAlertVisible(false)
|
||||
}]);
|
||||
setAlertVisible(true);
|
||||
} catch (error) {
|
||||
logger.error('Failed to install Torbox addon:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Failed to connect addon. Please check your API Key and try again.');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (enabled: boolean) => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
const updatedConfig = { ...config, isEnabled: enabled };
|
||||
await mmkvStorage.setItem(TORBOX_STORAGE_KEY, JSON.stringify(updatedConfig));
|
||||
setConfig(updatedConfig);
|
||||
|
||||
// Note: Since we can't disable/enable addons in the current stremioService,
|
||||
// we'll just track the state. The addon filtering will happen in AddonsScreen
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle Torbox addon:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setAlertTitle('Disconnect Torbox');
|
||||
setAlertMessage('Are you sure you want to disconnect Torbox? This will remove the addon and clear your saved API key.');
|
||||
setAlertActions([
|
||||
{ label: 'Cancel', onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } },
|
||||
{
|
||||
label: 'Disconnect',
|
||||
onPress: async () => {
|
||||
setAlertVisible(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
// Find and remove the torbox addon
|
||||
const addons = await stremioService.getInstalledAddonsAsync();
|
||||
const torboxAddon = addons.find(addon =>
|
||||
addon.id?.includes('torbox') ||
|
||||
addon.url?.includes('torbox') ||
|
||||
(addon as any).transport?.includes('torbox')
|
||||
);
|
||||
|
||||
if (torboxAddon) {
|
||||
await stremioService.removeAddon(torboxAddon.id);
|
||||
}
|
||||
|
||||
// Clear config
|
||||
await mmkvStorage.removeItem(TORBOX_STORAGE_KEY);
|
||||
setConfig(null);
|
||||
|
||||
setAlertTitle('Success');
|
||||
setAlertMessage('Torbox disconnected successfully');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
} catch (error) {
|
||||
logger.error('Failed to disconnect Torbox:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Failed to disconnect Torbox');
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
setAlertVisible(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
style: { color: colors.error || '#F44336' }
|
||||
}
|
||||
]);
|
||||
setAlertVisible(true);
|
||||
};
|
||||
|
||||
const openSubscription = () => {
|
||||
Linking.openURL('https://torbox.app/subscription?referral=493192f2-6403-440f-b414-768f72222ec7');
|
||||
};
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} />
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Feather name="arrow-left" size={24} color={colors.white} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Debrid Integration</Text>
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colors.primary}
|
||||
colors={[colors.primary]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{config?.isConnected ? (
|
||||
// Connected state
|
||||
<>
|
||||
<View style={styles.statusCard}>
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusLabel}>Status</Text>
|
||||
<Text style={[styles.statusValue, styles.statusConnected]}>Connected</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusLabel}>Enable Addon</Text>
|
||||
<Switch
|
||||
value={config.isEnabled}
|
||||
onValueChange={handleToggleEnabled}
|
||||
trackColor={{ false: colors.elevation2, true: colors.primary }}
|
||||
thumbColor={config.isEnabled ? colors.white : colors.mediumEmphasis}
|
||||
ios_backgroundColor={colors.elevation2}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.dangerButton, loading && styles.disabledButton]}
|
||||
onPress={handleDisconnect}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'Disconnecting...' : 'Disconnect & Remove'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* User Data Card */}
|
||||
{userData && (
|
||||
<View style={styles.userDataCard}>
|
||||
<View style={styles.userDataHeader}>
|
||||
<Text style={styles.userDataTitle}>Account Information</Text>
|
||||
{userDataLoading && (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.userDataRow}>
|
||||
<Text style={styles.userDataLabel}>Email</Text>
|
||||
<Text style={styles.userDataValue} numberOfLines={1}>
|
||||
{userData.base_email || userData.email}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.userDataRow}>
|
||||
<Text style={styles.userDataLabel}>Plan</Text>
|
||||
<View style={[
|
||||
styles.planBadge,
|
||||
userData.plan === 0 ? styles.planBadgeFree : styles.planBadgePaid
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.planBadgeText,
|
||||
userData.plan === 0 ? styles.planBadgeTextFree : styles.planBadgeTextPaid
|
||||
]}>
|
||||
{getPlanName(userData.plan)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.userDataRow}>
|
||||
<Text style={styles.userDataLabel}>Status</Text>
|
||||
<Text style={[
|
||||
styles.userDataValue,
|
||||
{ color: userData.is_subscribed ? (colors.success || '#4CAF50') : colors.mediumEmphasis }
|
||||
]}>
|
||||
{userData.is_subscribed ? 'Active' : 'Free'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{userData.premium_expires_at && (
|
||||
<View style={styles.userDataRow}>
|
||||
<Text style={styles.userDataLabel}>Expires</Text>
|
||||
<Text style={styles.userDataValue}>
|
||||
{new Date(userData.premium_expires_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.userDataRow}>
|
||||
<Text style={styles.userDataLabel}>Downloaded</Text>
|
||||
<Text style={styles.userDataValue}>
|
||||
{(userData.total_downloaded / (1024 * 1024 * 1024)).toFixed(2)} GB
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>✓ Connected to TorBox</Text>
|
||||
<Text style={styles.sectionText}>
|
||||
Your TorBox addon is active and providing premium streams.{config.isEnabled ? '' : ' (Currently disabled)'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Configure Addon</Text>
|
||||
<Text style={styles.sectionText}>
|
||||
Customize your streaming experience. Sort by quality, filter file sizes, and manage other integration settings.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.subscribeButton}
|
||||
onPress={() => Linking.openURL('https://torbox.app/settings?section=integration-settings')}
|
||||
>
|
||||
<Text style={styles.subscribeButtonText}>Open Settings</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
// Not connected state
|
||||
<>
|
||||
<Text style={styles.description}>
|
||||
Unlock 4K high-quality streams and lightning-fast speeds by integrating Torbox. Enter your API Key below to instantly upgrade your streaming experience.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://guides.viren070.me/stremio/technical-details#debrid-services')} style={styles.guideLink}>
|
||||
<Text style={styles.guideLinkText}>What is a Debrid Service?</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.label}>Torbox API Key</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Enter your API Key"
|
||||
placeholderTextColor={colors.mediumGray}
|
||||
value={apiKey}
|
||||
onChangeText={setApiKey}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.connectButton, loading && styles.disabledButton]}
|
||||
onPress={handleConnect}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.connectButtonText}>
|
||||
{loading ? 'Connecting...' : 'Connect & Install'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Unlock Premium Speeds</Text>
|
||||
<Text style={styles.sectionText}>
|
||||
Get a Torbox subscription to access cached high-quality streams with zero buffering.
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.subscribeButton} onPress={openSubscription}>
|
||||
<Text style={styles.subscribeButtonText}>Get Subscription</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={[styles.logoContainer, { marginTop: 60 }]}>
|
||||
<Text style={styles.poweredBy}>Powered by</Text>
|
||||
<View style={styles.logoRow}>
|
||||
<Image
|
||||
source={{ uri: 'https://torbox.app/assets/logo-57adbf99.svg' }}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.logoText}>TorBox</Text>
|
||||
</View>
|
||||
<Text style={styles.disclaimer}>Nuvio is not affiliated with Torbox in any way.</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
actions={alertActions}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebridIntegrationScreen;
|
||||
|
|
@ -263,7 +263,7 @@ const SettingsScreen: React.FC = () => {
|
|||
) => {
|
||||
setAlertTitle(title);
|
||||
setAlertMessage(message);
|
||||
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]);
|
||||
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]);
|
||||
setAlertVisible(true);
|
||||
};
|
||||
|
||||
|
|
@ -274,7 +274,7 @@ const SettingsScreen: React.FC = () => {
|
|||
try {
|
||||
const flag = await mmkvStorage.getItem('@update_badge_pending');
|
||||
if (mounted) setHasUpdateBadge(flag === 'true');
|
||||
} catch {}
|
||||
} catch { }
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
|
@ -437,7 +437,7 @@ const SettingsScreen: React.FC = () => {
|
|||
'Reset Settings',
|
||||
'Are you sure you want to reset all settings to default values?',
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{ label: 'Cancel', onPress: () => { } },
|
||||
{
|
||||
label: 'Reset',
|
||||
onPress: () => {
|
||||
|
|
@ -455,7 +455,7 @@ const SettingsScreen: React.FC = () => {
|
|||
'Clear MDBList Cache',
|
||||
'Are you sure you want to clear all cached MDBList data? This cannot be undone.',
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{ label: 'Cancel', onPress: () => { } },
|
||||
{
|
||||
label: 'Clear',
|
||||
onPress: async () => {
|
||||
|
|
@ -527,6 +527,14 @@ const SettingsScreen: React.FC = () => {
|
|||
onPress={() => navigation.navigate('Addons')}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Debrid Integration"
|
||||
description="Connect Torbox for premium streams"
|
||||
icon="link"
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('DebridIntegration')}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Plugins"
|
||||
description="Manage plugins and repositories"
|
||||
|
|
@ -756,6 +764,21 @@ const SettingsScreen: React.FC = () => {
|
|||
renderControl={ChevronRight}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Test Announcement"
|
||||
icon="bell"
|
||||
description="Show what's new overlay"
|
||||
onPress={async () => {
|
||||
try {
|
||||
await mmkvStorage.removeItem('announcement_v1.0.0_shown');
|
||||
openAlert('Success', 'Announcement reset. Restart the app to see the announcement overlay.');
|
||||
} catch (error) {
|
||||
openAlert('Error', 'Failed to reset announcement.');
|
||||
}
|
||||
}}
|
||||
renderControl={ChevronRight}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Clear All Data"
|
||||
icon="trash-2"
|
||||
|
|
@ -764,7 +787,7 @@ const SettingsScreen: React.FC = () => {
|
|||
'Clear All Data',
|
||||
'This will reset all settings and clear all cached data. Are you sure?',
|
||||
[
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{ label: 'Cancel', onPress: () => { } },
|
||||
{
|
||||
label: 'Clear',
|
||||
onPress: async () => {
|
||||
|
|
@ -824,7 +847,7 @@ const SettingsScreen: React.FC = () => {
|
|||
badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined}
|
||||
onPress={async () => {
|
||||
if (Platform.OS === 'android') {
|
||||
try { await mmkvStorage.removeItem('@update_badge_pending'); } catch {}
|
||||
try { await mmkvStorage.removeItem('@update_badge_pending'); } catch { }
|
||||
setHasUpdateBadge(false);
|
||||
}
|
||||
navigation.navigate('Update');
|
||||
|
|
|
|||
|
|
@ -928,8 +928,16 @@ class CatalogService {
|
|||
|
||||
public subscribeToLibraryUpdates(callback: (items: StreamingContent[]) => void): () => void {
|
||||
this.librarySubscribers.push(callback);
|
||||
// Initial callback with current items
|
||||
this.getLibraryItems().then(items => callback(items));
|
||||
// Defer initial callback to next tick to avoid synchronous state updates during render
|
||||
// This prevents infinite loops when the callback triggers setState in useEffect
|
||||
Promise.resolve().then(() => {
|
||||
this.getLibraryItems().then(items => {
|
||||
// Only call if still subscribed (callback might have been unsubscribed)
|
||||
if (this.librarySubscribers.includes(callback)) {
|
||||
callback(items);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ class NotificationService {
|
|||
private appStateSubscription: any = null;
|
||||
private lastSyncTime: number = 0;
|
||||
private readonly MIN_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes minimum between syncs
|
||||
// Download notification tracking - stores progress value (50) when notified
|
||||
private lastDownloadNotificationTime: Map<string, number> = new Map();
|
||||
|
||||
private constructor() {
|
||||
// Initialize notifications
|
||||
|
|
@ -157,8 +159,8 @@ class NotificationService {
|
|||
// Check if notification already exists for this episode
|
||||
const existingNotification = this.scheduledNotifications.find(
|
||||
notification => notification.seriesId === item.seriesId &&
|
||||
notification.season === item.season &&
|
||||
notification.episode === item.episode
|
||||
notification.season === item.season &&
|
||||
notification.episode === item.episode
|
||||
);
|
||||
if (existingNotification) {
|
||||
return null; // Don't schedule duplicate notifications
|
||||
|
|
@ -327,6 +329,21 @@ class NotificationService {
|
|||
try {
|
||||
if (!this.settings.enabled) return;
|
||||
if (AppState.currentState === 'active') return;
|
||||
|
||||
// Only notify at 50% progress
|
||||
if (progress < 50) {
|
||||
return; // Skip notifications before 50%
|
||||
}
|
||||
|
||||
// Check if we've already notified at 50% for this download
|
||||
const lastNotifiedProgress = this.lastDownloadNotificationTime.get(title) || 0;
|
||||
if (lastNotifiedProgress >= 50) {
|
||||
return; // Already notified at 50%, don't notify again
|
||||
}
|
||||
|
||||
// Mark that we've notified at 50%
|
||||
this.lastDownloadNotificationTime.set(title, 50);
|
||||
|
||||
const downloadedMb = Math.floor((downloadedBytes || 0) / (1024 * 1024));
|
||||
const totalMb = totalBytes ? Math.floor(totalBytes / (1024 * 1024)) : undefined;
|
||||
const body = `${progress}%` + (totalMb !== undefined ? ` • ${downloadedMb}MB / ${totalMb}MB` : '');
|
||||
|
|
@ -348,6 +365,7 @@ class NotificationService {
|
|||
try {
|
||||
if (!this.settings.enabled) return;
|
||||
if (AppState.currentState === 'active') return;
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: 'Download complete',
|
||||
|
|
@ -356,6 +374,9 @@ class NotificationService {
|
|||
},
|
||||
trigger: null,
|
||||
});
|
||||
|
||||
// Clean up tracking entry after completion to prevent memory leaks
|
||||
this.lastDownloadNotificationTime.delete(title);
|
||||
} catch (error) {
|
||||
logger.error('[NotificationService] notifyDownloadComplete error:', error);
|
||||
}
|
||||
|
|
@ -716,7 +737,7 @@ class NotificationService {
|
|||
this.scheduledNotifications = validNotifications;
|
||||
await this.saveScheduledNotifications();
|
||||
// Reduced logging verbosity
|
||||
// logger.log(`[NotificationService] Cleaned up ${this.scheduledNotifications.length - validNotifications.length} old notifications`);
|
||||
// logger.log(`[NotificationService] Cleaned up ${this.scheduledNotifications.length - validNotifications.length} old notifications`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[NotificationService] Error cleaning up notifications:', error);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ export interface TraktWatchedItem {
|
|||
};
|
||||
plays: number;
|
||||
last_watched_at: string;
|
||||
seasons?: {
|
||||
number: number;
|
||||
episodes: {
|
||||
number: number;
|
||||
plays: number;
|
||||
last_watched_at: string;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TraktWatchlistItem {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Single source of truth for the app version displayed in Settings
|
||||
// Update this when bumping app version
|
||||
|
||||
export const APP_VERSION = '1.2.9';
|
||||
export const APP_VERSION = '1.2.10';
|
||||
|
||||
export function getDisplayedAppVersion(): string {
|
||||
return APP_VERSION;
|
||||
|
|
|
|||
Loading…
Reference in a new issue