mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
refactor
This commit is contained in:
parent
076f33d6b7
commit
271aac9ae6
12 changed files with 425 additions and 522 deletions
16
App.tsx
16
App.tsx
|
|
@ -132,14 +132,14 @@ const ThemedApp = () => {
|
||||||
await aiService.initialize();
|
await aiService.initialize();
|
||||||
console.log('AI service initialized');
|
console.log('AI service initialized');
|
||||||
|
|
||||||
// Check if announcement should be shown (version 1.0.0)
|
// What's New announcement disabled
|
||||||
const announcementShown = await mmkvStorage.getItem('announcement_v1.0.0_shown');
|
// const announcementShown = await mmkvStorage.getItem('announcement_v1.0.0_shown');
|
||||||
if (!announcementShown && onboardingCompleted === 'true') {
|
// if (!announcementShown && onboardingCompleted === 'true') {
|
||||||
// Show announcement only after app is ready
|
// // Show announcement only after app is ready
|
||||||
setTimeout(() => {
|
// setTimeout(() => {
|
||||||
setShowAnnouncement(true);
|
// setShowAnnouncement(true);
|
||||||
}, 1000);
|
// }, 1000);
|
||||||
}
|
// }
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing app:', error);
|
console.error('Error initializing app:', error);
|
||||||
|
|
|
||||||
|
|
@ -6,46 +6,35 @@ import {
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
|
||||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { height } = Dimensions.get('window');
|
||||||
|
|
||||||
const FirstTimeWelcome = () => {
|
const FirstTimeWelcome = () => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(200).duration(600)}
|
entering={FadeIn.duration(300)}
|
||||||
style={[styles.container, { backgroundColor: currentTheme.colors.elevation1 }]}
|
style={styles.wrapper}
|
||||||
>
|
>
|
||||||
<LinearGradient
|
|
||||||
colors={[currentTheme.colors.primary, currentTheme.colors.secondary]}
|
|
||||||
style={styles.iconContainer}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="explore" size={40} color="white" />
|
|
||||||
</LinearGradient>
|
|
||||||
|
|
||||||
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>
|
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>
|
||||||
Welcome to Nuvio!
|
Welcome to Nuvio
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
|
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
To get started, install some addons to access content from various sources.
|
Install addons to start browsing content
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
|
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
|
||||||
onPress={() => navigation.navigate('Addons')}
|
onPress={() => navigation.navigate('Addons')}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="extension" size={20} color="white" />
|
|
||||||
<Text style={styles.buttonText}>Install Addons</Text>
|
<Text style={styles.buttonText}>Install Addons</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
@ -53,54 +42,32 @@ const FirstTimeWelcome = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
wrapper: {
|
||||||
margin: 16,
|
width: '100%',
|
||||||
padding: 24,
|
|
||||||
borderRadius: 16,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
shadowColor: '#000',
|
paddingHorizontal: 32,
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 4,
|
|
||||||
},
|
|
||||||
iconContainer: {
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
borderRadius: 40,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 20,
|
fontSize: 24,
|
||||||
fontWeight: 'bold',
|
fontWeight: '600',
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
lineHeight: 20,
|
marginBottom: 32,
|
||||||
marginBottom: 20,
|
|
||||||
maxWidth: width * 0.7,
|
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
flexDirection: 'row',
|
paddingVertical: 14,
|
||||||
alignItems: 'center',
|
paddingHorizontal: 32,
|
||||||
paddingVertical: 12,
|
borderRadius: 10,
|
||||||
paddingHorizontal: 20,
|
|
||||||
borderRadius: 25,
|
|
||||||
gap: 8,
|
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
color: 'white',
|
color: 'white',
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default FirstTimeWelcome;
|
export default FirstTimeWelcome;
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
// Enhanced responsive sizing for tablets and TV screens
|
// Enhanced responsive sizing for tablets and TV screens
|
||||||
const deviceWidth = Dimensions.get('window').width;
|
const deviceWidth = Dimensions.get('window').width;
|
||||||
const deviceHeight = Dimensions.get('window').height;
|
const deviceHeight = Dimensions.get('window').height;
|
||||||
|
|
||||||
// Determine device type based on width
|
// Determine device type based on width
|
||||||
const getDeviceType = useCallback(() => {
|
const getDeviceType = useCallback(() => {
|
||||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||||
|
|
@ -82,13 +82,13 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||||
return 'phone';
|
return 'phone';
|
||||||
}, [deviceWidth]);
|
}, [deviceWidth]);
|
||||||
|
|
||||||
const deviceType = getDeviceType();
|
const deviceType = getDeviceType();
|
||||||
const isTablet = deviceType === 'tablet';
|
const isTablet = deviceType === 'tablet';
|
||||||
const isLargeTablet = deviceType === 'largeTablet';
|
const isLargeTablet = deviceType === 'largeTablet';
|
||||||
const isTV = deviceType === 'tv';
|
const isTV = deviceType === 'tv';
|
||||||
const isLargeScreen = isTablet || isLargeTablet || isTV;
|
const isLargeScreen = isTablet || isLargeTablet || isTV;
|
||||||
|
|
||||||
// Enhanced spacing and padding
|
// Enhanced spacing and padding
|
||||||
const horizontalPadding = useMemo(() => {
|
const horizontalPadding = useMemo(() => {
|
||||||
switch (deviceType) {
|
switch (deviceType) {
|
||||||
|
|
@ -102,7 +102,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
return 16; // phone
|
return 16; // phone
|
||||||
}
|
}
|
||||||
}, [deviceType]);
|
}, [deviceType]);
|
||||||
|
|
||||||
// Enhanced trailer card sizing
|
// Enhanced trailer card sizing
|
||||||
const trailerCardWidth = useMemo(() => {
|
const trailerCardWidth = useMemo(() => {
|
||||||
switch (deviceType) {
|
switch (deviceType) {
|
||||||
|
|
@ -116,7 +116,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
return 200; // phone
|
return 200; // phone
|
||||||
}
|
}
|
||||||
}, [deviceType]);
|
}, [deviceType]);
|
||||||
|
|
||||||
const trailerCardSpacing = useMemo(() => {
|
const trailerCardSpacing = useMemo(() => {
|
||||||
switch (deviceType) {
|
switch (deviceType) {
|
||||||
case 'tv':
|
case 'tv':
|
||||||
|
|
@ -293,7 +293,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
// Auto-select the first available category, preferring "Trailer"
|
// Auto-select the first available category, preferring "Trailer"
|
||||||
const availableCategories = Object.keys(categorized);
|
const availableCategories = Object.keys(categorized);
|
||||||
const preferredCategory = availableCategories.includes('Trailer') ? 'Trailer' :
|
const preferredCategory = availableCategories.includes('Trailer') ? 'Trailer' :
|
||||||
availableCategories.includes('Teaser') ? 'Teaser' : availableCategories[0];
|
availableCategories.includes('Teaser') ? 'Teaser' : availableCategories[0];
|
||||||
setSelectedCategory(preferredCategory);
|
setSelectedCategory(preferredCategory);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -379,7 +379,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('TrailersSection', 'Error pausing hero trailer:', error);
|
logger.warn('TrailersSection', 'Error pausing hero trailer:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedTrailer(trailer);
|
setSelectedTrailer(trailer);
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
@ -449,6 +449,9 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Permanently hide the trailers section
|
||||||
|
return null;
|
||||||
|
|
||||||
if (!tmdbId) {
|
if (!tmdbId) {
|
||||||
return null; // Don't show if no TMDB ID
|
return null; // Don't show if no TMDB ID
|
||||||
}
|
}
|
||||||
|
|
@ -499,15 +502,15 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[
|
<Animated.View style={[
|
||||||
styles.container,
|
styles.container,
|
||||||
sectionAnimatedStyle,
|
sectionAnimatedStyle,
|
||||||
{ paddingHorizontal: horizontalPadding }
|
{ paddingHorizontal: horizontalPadding }
|
||||||
]}>
|
]}>
|
||||||
{/* Enhanced Header with Category Selector */}
|
{/* Enhanced Header with Category Selector */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.headerTitle,
|
styles.headerTitle,
|
||||||
{
|
{
|
||||||
color: currentTheme.colors.highEmphasis,
|
color: currentTheme.colors.highEmphasis,
|
||||||
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
|
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
|
||||||
}
|
}
|
||||||
|
|
@ -519,8 +522,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
{trailerCategories.length > 0 && selectedCategory && (
|
{trailerCategories.length > 0 && selectedCategory && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.categorySelector,
|
styles.categorySelector,
|
||||||
{
|
{
|
||||||
borderColor: 'rgba(255,255,255,0.6)',
|
borderColor: 'rgba(255,255,255,0.6)',
|
||||||
paddingHorizontal: isTV ? 14 : isLargeTablet ? 12 : isTablet ? 10 : 10,
|
paddingHorizontal: isTV ? 14 : isLargeTablet ? 12 : isTablet ? 10 : 10,
|
||||||
paddingVertical: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 5 : 5,
|
paddingVertical: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 5 : 5,
|
||||||
|
|
@ -533,8 +536,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.categorySelectorText,
|
styles.categorySelectorText,
|
||||||
{
|
{
|
||||||
color: currentTheme.colors.highEmphasis,
|
color: currentTheme.colors.highEmphasis,
|
||||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
|
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
|
||||||
maxWidth: isTV ? 150 : isLargeTablet ? 130 : isTablet ? 120 : 120
|
maxWidth: isTV ? 150 : isLargeTablet ? 130 : isTablet ? 120 : 120
|
||||||
|
|
@ -587,7 +590,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
>
|
>
|
||||||
<View style={styles.dropdownItemContent}>
|
<View style={styles.dropdownItemContent}>
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.categoryIconContainer,
|
styles.categoryIconContainer,
|
||||||
{
|
{
|
||||||
backgroundColor: currentTheme.colors.primary + '15',
|
backgroundColor: currentTheme.colors.primary + '15',
|
||||||
width: isTV ? 36 : isLargeTablet ? 32 : isTablet ? 28 : 28,
|
width: isTV ? 36 : isLargeTablet ? 32 : isTablet ? 28 : 28,
|
||||||
|
|
@ -601,18 +604,18 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
color={currentTheme.colors.primary}
|
color={currentTheme.colors.primary}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.dropdownItemText,
|
styles.dropdownItemText,
|
||||||
{
|
{
|
||||||
color: currentTheme.colors.highEmphasis,
|
color: currentTheme.colors.highEmphasis,
|
||||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
|
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
|
||||||
}
|
}
|
||||||
]}>
|
]}>
|
||||||
{formatTrailerType(category)}
|
{formatTrailerType(category)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.dropdownItemCount,
|
styles.dropdownItemCount,
|
||||||
{
|
{
|
||||||
color: currentTheme.colors.textMuted,
|
color: currentTheme.colors.textMuted,
|
||||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12,
|
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12,
|
||||||
paddingHorizontal: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8,
|
paddingHorizontal: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8,
|
||||||
|
|
@ -690,8 +693,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
<View style={styles.trailerInfoBelow}>
|
<View style={styles.trailerInfoBelow}>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.trailerTitle,
|
styles.trailerTitle,
|
||||||
{
|
{
|
||||||
color: currentTheme.colors.highEmphasis,
|
color: currentTheme.colors.highEmphasis,
|
||||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
|
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
|
||||||
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16,
|
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16,
|
||||||
|
|
@ -704,8 +707,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
{trailer.displayName || trailer.name}
|
{trailer.displayName || trailer.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.trailerMeta,
|
styles.trailerMeta,
|
||||||
{
|
{
|
||||||
color: currentTheme.colors.textMuted,
|
color: currentTheme.colors.textMuted,
|
||||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 10
|
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 10
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
showPosterTitles: true,
|
showPosterTitles: true,
|
||||||
enableHomeHeroBackground: true,
|
enableHomeHeroBackground: true,
|
||||||
// Trailer settings
|
// Trailer settings
|
||||||
showTrailers: true, // Enable trailers by default
|
showTrailers: false, // Hide trailers by default
|
||||||
trailerMuted: true, // Default to muted for better user experience
|
trailerMuted: true, // Default to muted for better user experience
|
||||||
// AI
|
// AI
|
||||||
aiChatEnabled: false,
|
aiChatEnabled: false,
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ const AISettingsScreen: React.FC = () => {
|
||||||
<path stroke-width=".4" d="m244.1 250.4-60.3-34.7v69.5l60.3-34.8Z"/>
|
<path stroke-width=".4" d="m244.1 250.4-60.3-34.7v69.5l60.3-34.8Z"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isKeySet, setIsKeySet] = useState(false);
|
const [isKeySet, setIsKeySet] = useState(false);
|
||||||
|
|
@ -119,7 +119,7 @@ const AISettingsScreen: React.FC = () => {
|
||||||
'Remove API Key',
|
'Remove API Key',
|
||||||
'Are you sure you want to remove your OpenRouter API key? This will disable AI chat features.',
|
'Are you sure you want to remove your OpenRouter API key? This will disable AI chat features.',
|
||||||
[
|
[
|
||||||
{ label: 'Cancel', onPress: () => {} },
|
{ label: 'Cancel', onPress: () => { } },
|
||||||
{
|
{
|
||||||
label: 'Remove',
|
label: 'Remove',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
|
|
@ -142,35 +142,35 @@ const AISettingsScreen: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
<StatusBar barStyle="light-content" />
|
<StatusBar barStyle="light-content" />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name="arrow-back"
|
name="arrow-back"
|
||||||
size={24}
|
size={24}
|
||||||
color={currentTheme.colors.text}
|
color={currentTheme.colors.text}
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.backText, { color: currentTheme.colors.text }]}>
|
<Text style={[styles.backText, { color: currentTheme.colors.text }]}>
|
||||||
Settings
|
Settings
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
{/* Empty for now, but ready for future actions */}
|
{/* Empty for now, but ready for future actions */}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
|
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
|
||||||
AI Assistant
|
AI Assistant
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
|
@ -178,9 +178,9 @@ const AISettingsScreen: React.FC = () => {
|
||||||
{/* Info Card */}
|
{/* Info Card */}
|
||||||
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||||
<View style={styles.infoHeader}>
|
<View style={styles.infoHeader}>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name="smart-toy"
|
name="smart-toy"
|
||||||
size={24}
|
size={24}
|
||||||
color={currentTheme.colors.primary}
|
color={currentTheme.colors.primary}
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.infoTitle, { color: currentTheme.colors.highEmphasis }]}>
|
<Text style={[styles.infoTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||||
|
|
@ -190,7 +190,7 @@ const AISettingsScreen: React.FC = () => {
|
||||||
<Text style={[styles.infoDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
<Text style={[styles.infoDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
Ask questions about any movie or TV show episode using advanced AI. Get insights about plot, characters, themes, trivia, and more - all powered by comprehensive TMDB data.
|
Ask questions about any movie or TV show episode using advanced AI. Get insights about plot, characters, themes, trivia, and more - all powered by comprehensive TMDB data.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={styles.featureList}>
|
<View style={styles.featureList}>
|
||||||
<View style={styles.featureItem}>
|
<View style={styles.featureItem}>
|
||||||
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
|
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
|
||||||
|
|
@ -224,7 +224,7 @@ const AISettingsScreen: React.FC = () => {
|
||||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
OPENROUTER API KEY
|
OPENROUTER API KEY
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={styles.apiKeySection}>
|
<View style={styles.apiKeySection}>
|
||||||
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
|
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
|
||||||
API Key
|
API Key
|
||||||
|
|
@ -232,11 +232,11 @@ const AISettingsScreen: React.FC = () => {
|
||||||
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
|
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
Enter your OpenRouter API key to enable AI chat features
|
Enter your OpenRouter API key to enable AI chat features
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[
|
style={[
|
||||||
styles.input,
|
styles.input,
|
||||||
{
|
{
|
||||||
backgroundColor: currentTheme.colors.elevation2,
|
backgroundColor: currentTheme.colors.elevation2,
|
||||||
color: currentTheme.colors.highEmphasis,
|
color: currentTheme.colors.highEmphasis,
|
||||||
borderColor: currentTheme.colors.elevation2
|
borderColor: currentTheme.colors.elevation2
|
||||||
|
|
@ -258,9 +258,9 @@ const AISettingsScreen: React.FC = () => {
|
||||||
onPress={handleSaveApiKey}
|
onPress={handleSaveApiKey}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name="save"
|
name="save"
|
||||||
size={20}
|
size={20}
|
||||||
color={currentTheme.colors.white}
|
color={currentTheme.colors.white}
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -275,22 +275,22 @@ const AISettingsScreen: React.FC = () => {
|
||||||
onPress={handleSaveApiKey}
|
onPress={handleSaveApiKey}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name="update"
|
name="update"
|
||||||
size={20}
|
size={20}
|
||||||
color={currentTheme.colors.white}
|
color={currentTheme.colors.white}
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.updateButtonText}>Update</Text>
|
<Text style={styles.updateButtonText}>Update</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.removeButton, { borderColor: currentTheme.colors.error }]}
|
style={[styles.removeButton, { borderColor: currentTheme.colors.error }]}
|
||||||
onPress={handleRemoveApiKey}
|
onPress={handleRemoveApiKey}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name="delete"
|
name="delete"
|
||||||
size={20}
|
size={20}
|
||||||
color={currentTheme.colors.error}
|
color={currentTheme.colors.error}
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -306,9 +306,9 @@ const AISettingsScreen: React.FC = () => {
|
||||||
style={[styles.getKeyButton, { backgroundColor: currentTheme.colors.elevation2 }]}
|
style={[styles.getKeyButton, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||||
onPress={handleGetApiKey}
|
onPress={handleGetApiKey}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name="open-in-new"
|
name="open-in-new"
|
||||||
size={20}
|
size={20}
|
||||||
color={currentTheme.colors.primary}
|
color={currentTheme.colors.primary}
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -320,7 +320,7 @@ const AISettingsScreen: React.FC = () => {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Enable Toggle (top) */}
|
{/* Enable Toggle (top) */}
|
||||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>Enable AI Chat</Text>
|
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>Enable AI Chat</Text>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -338,9 +338,9 @@ const AISettingsScreen: React.FC = () => {
|
||||||
{isKeySet && (
|
{isKeySet && (
|
||||||
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||||
<View style={styles.statusHeader}>
|
<View style={styles.statusHeader}>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name="check-circle"
|
name="check-circle"
|
||||||
size={24}
|
size={24}
|
||||||
color={currentTheme.colors.success || '#4CAF50'}
|
color={currentTheme.colors.success || '#4CAF50'}
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.statusTitle, { color: currentTheme.colors.success || '#4CAF50' }]}>
|
<Text style={[styles.statusTitle, { color: currentTheme.colors.success || '#4CAF50' }]}>
|
||||||
|
|
@ -368,6 +368,15 @@ const AISettingsScreen: React.FC = () => {
|
||||||
</View>
|
</View>
|
||||||
{/* OpenRouter branding */}
|
{/* OpenRouter branding */}
|
||||||
<View style={{ alignItems: 'center', marginTop: 16, marginBottom: 32 }}>
|
<View style={{ alignItems: 'center', marginTop: 16, marginBottom: 32 }}>
|
||||||
|
<Text style={{
|
||||||
|
color: currentTheme.colors.mediumEmphasis,
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
fontWeight: '500',
|
||||||
|
letterSpacing: 0.5
|
||||||
|
}}>
|
||||||
|
Powered by
|
||||||
|
</Text>
|
||||||
<SvgXml xml={OPENROUTER_SVG.replace(/CURRENTCOLOR/g, currentTheme.colors.mediumEmphasis)} width={180} height={60} />
|
<SvgXml xml={OPENROUTER_SVG.replace(/CURRENTCOLOR/g, currentTheme.colors.mediumEmphasis)} width={180} height={60} />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
|
|
@ -621,27 +621,7 @@ const AddonsScreen = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Promotional addon: Nuvio Streams
|
|
||||||
const PROMO_ADDON_URL = 'https://nuviostreams.hayd.uk/manifest.json';
|
|
||||||
const promoAddon: ExtendedManifest = {
|
|
||||||
id: 'org.nuvio.streams',
|
|
||||||
name: 'Nuvio Streams | Elfhosted',
|
|
||||||
version: '0.5.0',
|
|
||||||
description: 'Stremio addon for high-quality streaming links.',
|
|
||||||
// @ts-ignore - logo not in base manifest type
|
|
||||||
logo: 'https://raw.githubusercontent.com/tapframe/NuvioStreaming/refs/heads/appstore/assets/titlelogo.png',
|
|
||||||
types: ['movie', 'series'],
|
|
||||||
catalogs: [],
|
|
||||||
behaviorHints: { configurable: true },
|
|
||||||
// help handleConfigureAddon derive configure URL from the transport
|
|
||||||
transport: PROMO_ADDON_URL,
|
|
||||||
} as ExtendedManifest;
|
|
||||||
const isPromoInstalled = addons.some(a =>
|
|
||||||
a.id === 'org.nuvio.streams' ||
|
|
||||||
(typeof a.id === 'string' && a.id.includes('nuviostreams.hayd.uk')) ||
|
|
||||||
(typeof a.transport === 'string' && a.transport.includes('nuviostreams.hayd.uk')) ||
|
|
||||||
(typeof (a as any).url === 'string' && (a as any).url.includes('nuviostreams.hayd.uk'))
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAddons();
|
loadAddons();
|
||||||
|
|
@ -1171,63 +1151,7 @@ const AddonsScreen = () => {
|
||||||
<View style={styles.sectionSeparator} />
|
<View style={styles.sectionSeparator} />
|
||||||
|
|
||||||
{/* Promotional Addon Section (hidden if installed) */}
|
{/* 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ const HomeScreen = () => {
|
||||||
const [hasAddons, setHasAddons] = useState<boolean | null>(null);
|
const [hasAddons, setHasAddons] = useState<boolean | null>(null);
|
||||||
const [hintVisible, setHintVisible] = useState(false);
|
const [hintVisible, setHintVisible] = useState(false);
|
||||||
const totalCatalogsRef = useRef(0);
|
const totalCatalogsRef = useRef(0);
|
||||||
const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory
|
const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
// Stabilize insets to prevent iOS layout shifts
|
// Stabilize insets to prevent iOS layout shifts
|
||||||
|
|
@ -147,7 +147,7 @@ const HomeScreen = () => {
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [insets.top]);
|
}, [insets.top]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
featuredContent,
|
featuredContent,
|
||||||
allFeaturedContent,
|
allFeaturedContent,
|
||||||
|
|
@ -163,38 +163,38 @@ const HomeScreen = () => {
|
||||||
setCatalogsLoading(true);
|
setCatalogsLoading(true);
|
||||||
setCatalogs([]);
|
setCatalogs([]);
|
||||||
setLoadedCatalogCount(0);
|
setLoadedCatalogCount(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
let catalogSettings: Record<string, boolean> = {};
|
let catalogSettings: Record<string, boolean> = {};
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (cachedCatalogSettings && (now - catalogSettingsCacheTimestamp) < CATALOG_SETTINGS_CACHE_TTL) {
|
if (cachedCatalogSettings && (now - catalogSettingsCacheTimestamp) < CATALOG_SETTINGS_CACHE_TTL) {
|
||||||
catalogSettings = cachedCatalogSettings;
|
catalogSettings = cachedCatalogSettings;
|
||||||
} else {
|
} else {
|
||||||
// Load from storage
|
// Load from storage
|
||||||
const catalogSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
|
const catalogSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
|
||||||
catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
|
catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
cachedCatalogSettings = catalogSettings;
|
cachedCatalogSettings = catalogSettings;
|
||||||
catalogSettingsCacheTimestamp = now;
|
catalogSettingsCacheTimestamp = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [addons, addonManifests] = await Promise.all([
|
const [addons, addonManifests] = await Promise.all([
|
||||||
catalogService.getAllAddons(),
|
catalogService.getAllAddons(),
|
||||||
stremioService.getInstalledAddonsAsync()
|
stremioService.getInstalledAddonsAsync()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Set hasAddons state based on whether we have any addons - ensure on main thread
|
// Set hasAddons state based on whether we have any addons - ensure on main thread
|
||||||
InteractionManager.runAfterInteractions(() => {
|
InteractionManager.runAfterInteractions(() => {
|
||||||
setHasAddons(addons.length > 0);
|
setHasAddons(addons.length > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create placeholder array with proper order and track indices
|
// Create placeholder array with proper order and track indices
|
||||||
let catalogIndex = 0;
|
let catalogIndex = 0;
|
||||||
const catalogQueue: (() => Promise<void>)[] = [];
|
const catalogQueue: (() => Promise<void>)[] = [];
|
||||||
|
|
||||||
// Launch all catalog loaders in parallel
|
// Launch all catalog loaders in parallel
|
||||||
const launchAllCatalogs = () => {
|
const launchAllCatalogs = () => {
|
||||||
while (catalogQueue.length > 0) {
|
while (catalogQueue.length > 0) {
|
||||||
|
|
@ -204,18 +204,18 @@ const HomeScreen = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const addon of addons) {
|
for (const addon of addons) {
|
||||||
if (addon.catalogs) {
|
if (addon.catalogs) {
|
||||||
for (const catalog of addon.catalogs) {
|
for (const catalog of addon.catalogs) {
|
||||||
// Check if this catalog is enabled (default to true if no setting exists)
|
// Check if this catalog is enabled (default to true if no setting exists)
|
||||||
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
|
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
|
||||||
const isEnabled = catalogSettings[settingKey] ?? true;
|
const isEnabled = catalogSettings[settingKey] ?? true;
|
||||||
|
|
||||||
// Only load enabled catalogs
|
// Only load enabled catalogs
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
const currentIndex = catalogIndex;
|
const currentIndex = catalogIndex;
|
||||||
|
|
||||||
const catalogLoader = async () => {
|
const catalogLoader = async () => {
|
||||||
try {
|
try {
|
||||||
const manifest = addonManifests.find((a: any) => a.id === addon.id);
|
const manifest = addonManifests.find((a: any) => a.id === addon.id);
|
||||||
|
|
@ -226,7 +226,7 @@ const HomeScreen = () => {
|
||||||
// Aggressively limit items per catalog on Android to reduce memory usage
|
// Aggressively limit items per catalog on Android to reduce memory usage
|
||||||
const limit = Platform.OS === 'android' ? 18 : 30;
|
const limit = Platform.OS === 'android' ? 18 : 30;
|
||||||
const limitedMetas = metas.slice(0, limit);
|
const limitedMetas = metas.slice(0, limit);
|
||||||
|
|
||||||
const items = limitedMetas.map((meta: any) => ({
|
const items = limitedMetas.map((meta: any) => ({
|
||||||
id: meta.id,
|
id: meta.id,
|
||||||
type: meta.type,
|
type: meta.type,
|
||||||
|
|
@ -267,7 +267,7 @@ const HomeScreen = () => {
|
||||||
displayName = `${displayName} ${contentType}`;
|
displayName = `${displayName} ${contentType}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const catalogContent = {
|
const catalogContent = {
|
||||||
addon: addon.id,
|
addon: addon.id,
|
||||||
type: catalog.type,
|
type: catalog.type,
|
||||||
|
|
@ -275,7 +275,7 @@ const HomeScreen = () => {
|
||||||
name: displayName,
|
name: displayName,
|
||||||
items
|
items
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the catalog at its specific position - ensure on main thread
|
// Update the catalog at its specific position - ensure on main thread
|
||||||
InteractionManager.runAfterInteractions(() => {
|
InteractionManager.runAfterInteractions(() => {
|
||||||
setCatalogs(prevCatalogs => {
|
setCatalogs(prevCatalogs => {
|
||||||
|
|
@ -301,21 +301,21 @@ const HomeScreen = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
catalogQueue.push(catalogLoader);
|
catalogQueue.push(catalogLoader);
|
||||||
catalogIndex++;
|
catalogIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
totalCatalogsRef.current = catalogIndex;
|
totalCatalogsRef.current = catalogIndex;
|
||||||
|
|
||||||
// Initialize catalogs array with proper length - ensure on main thread
|
// Initialize catalogs array with proper length - ensure on main thread
|
||||||
InteractionManager.runAfterInteractions(() => {
|
InteractionManager.runAfterInteractions(() => {
|
||||||
setCatalogs(new Array(catalogIndex).fill(null));
|
setCatalogs(new Array(catalogIndex).fill(null));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start all catalog requests in parallel
|
// Start all catalog requests in parallel
|
||||||
launchAllCatalogs();
|
launchAllCatalogs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -371,7 +371,7 @@ const HomeScreen = () => {
|
||||||
// Also show a global toast for consistency across screens
|
// Also show a global toast for consistency across screens
|
||||||
// showInfo('Sign In Available', 'You can sign in anytime from Settings → Account');
|
// showInfo('Sign In Available', 'You can sign in anytime from Settings → Account');
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
if (hideTimer) clearTimeout(hideTimer);
|
if (hideTimer) clearTimeout(hideTimer);
|
||||||
|
|
@ -389,10 +389,10 @@ const HomeScreen = () => {
|
||||||
setShowHeroSection(settings.showHeroSection);
|
setShowHeroSection(settings.showHeroSection);
|
||||||
setFeaturedContentSource(settings.featuredContentSource);
|
setFeaturedContentSource(settings.featuredContentSource);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Subscribe to settings changes
|
// Subscribe to settings changes
|
||||||
const unsubscribe = settingsEmitter.addListener(handleSettingsChange);
|
const unsubscribe = settingsEmitter.addListener(handleSettingsChange);
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [settings.showHeroSection, settings.featuredContentSource]);
|
}, [settings.showHeroSection, settings.featuredContentSource]);
|
||||||
|
|
||||||
|
|
@ -409,12 +409,12 @@ const HomeScreen = () => {
|
||||||
StatusBar.setHidden(false);
|
StatusBar.setHidden(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
statusBarConfig();
|
statusBarConfig();
|
||||||
|
|
||||||
// Unlock orientation to allow free rotation
|
// Unlock orientation to allow free rotation
|
||||||
ScreenOrientation.unlockAsync().catch(() => {});
|
ScreenOrientation.unlockAsync().catch(() => { });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Stop trailer when screen loses focus (navigating to other screens)
|
// Stop trailer when screen loses focus (navigating to other screens)
|
||||||
setTrailerPlaying(false);
|
setTrailerPlaying(false);
|
||||||
|
|
@ -450,12 +450,12 @@ const HomeScreen = () => {
|
||||||
StatusBar.setTranslucent(false);
|
StatusBar.setTranslucent(false);
|
||||||
StatusBar.setBackgroundColor(currentTheme.colors.darkBackground);
|
StatusBar.setBackgroundColor(currentTheme.colors.darkBackground);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up any lingering timeouts
|
// Clean up any lingering timeouts
|
||||||
if (refreshTimeoutRef.current) {
|
if (refreshTimeoutRef.current) {
|
||||||
clearTimeout(refreshTimeoutRef.current);
|
clearTimeout(refreshTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't clear FastImage cache on unmount - it causes broken images on remount
|
// Don't clear FastImage cache on unmount - it causes broken images on remount
|
||||||
// FastImage's native libraries (SDWebImage/Glide) handle memory automatically
|
// FastImage's native libraries (SDWebImage/Glide) handle memory automatically
|
||||||
// Cache clearing only happens on app background (see AppState handler above)
|
// Cache clearing only happens on app background (see AppState handler above)
|
||||||
|
|
@ -468,11 +468,11 @@ const HomeScreen = () => {
|
||||||
// Balanced preload images function using FastImage
|
// Balanced preload images function using FastImage
|
||||||
const preloadImages = useCallback(async (content: StreamingContent[]) => {
|
const preloadImages = useCallback(async (content: StreamingContent[]) => {
|
||||||
if (!content.length) return;
|
if (!content.length) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Moderate prefetching for better performance balance
|
// Moderate prefetching for better performance balance
|
||||||
const MAX_IMAGES = 10; // Preload 10 most important images
|
const MAX_IMAGES = 10; // Preload 10 most important images
|
||||||
|
|
||||||
// Only preload poster images (skip banner and logo entirely)
|
// Only preload poster images (skip banner and logo entirely)
|
||||||
const posterImages = content.slice(0, MAX_IMAGES)
|
const posterImages = content.slice(0, MAX_IMAGES)
|
||||||
.map(item => item.poster)
|
.map(item => item.poster)
|
||||||
|
|
@ -499,24 +499,24 @@ const HomeScreen = () => {
|
||||||
|
|
||||||
const handlePlayStream = useCallback(async (stream: Stream) => {
|
const handlePlayStream = useCallback(async (stream: Stream) => {
|
||||||
if (!featuredContent) return;
|
if (!featuredContent) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Don't clear cache before player - causes broken images on return
|
// Don't clear cache before player - causes broken images on return
|
||||||
// FastImage's native libraries handle memory efficiently
|
// FastImage's native libraries handle memory efficiently
|
||||||
|
|
||||||
// Lock orientation to landscape before navigation to prevent glitches
|
// Lock orientation to landscape before navigation to prevent glitches
|
||||||
try {
|
try {
|
||||||
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
|
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
|
||||||
|
|
||||||
// Longer delay to ensure orientation is fully set before navigation
|
// Longer delay to ensure orientation is fully set before navigation
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
} catch (orientationError) {
|
} catch (orientationError) {
|
||||||
// If orientation lock fails, continue anyway but log it
|
// If orientation lock fails, continue anyway but log it
|
||||||
logger.warn('[HomeScreen] Orientation lock failed:', orientationError);
|
logger.warn('[HomeScreen] Orientation lock failed:', orientationError);
|
||||||
// Still add a small delay
|
// Still add a small delay
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', {
|
navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', {
|
||||||
uri: stream.url,
|
uri: stream.url,
|
||||||
title: featuredContent.name,
|
title: featuredContent.name,
|
||||||
|
|
@ -528,7 +528,7 @@ const HomeScreen = () => {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[HomeScreen] Error in handlePlayStream:', error);
|
logger.error('[HomeScreen] Error in handlePlayStream:', error);
|
||||||
|
|
||||||
// Fallback: navigate anyway
|
// Fallback: navigate anyway
|
||||||
navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', {
|
navigation.navigate(Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid', {
|
||||||
uri: stream.url,
|
uri: stream.url,
|
||||||
|
|
@ -545,9 +545,9 @@ const HomeScreen = () => {
|
||||||
const refreshContinueWatching = useCallback(async () => {
|
const refreshContinueWatching = useCallback(async () => {
|
||||||
if (continueWatchingRef.current) {
|
if (continueWatchingRef.current) {
|
||||||
try {
|
try {
|
||||||
const hasContent = await continueWatchingRef.current.refresh();
|
const hasContent = await continueWatchingRef.current.refresh();
|
||||||
setHasContinueWatching(hasContent);
|
setHasContinueWatching(hasContent);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (__DEV__) console.error('[HomeScreen] Error refreshing continue watching:', error);
|
if (__DEV__) console.error('[HomeScreen] Error refreshing continue watching:', error);
|
||||||
setHasContinueWatching(false);
|
setHasContinueWatching(false);
|
||||||
|
|
@ -603,7 +603,7 @@ const HomeScreen = () => {
|
||||||
|
|
||||||
// Only show a limited number of catalogs initially for performance
|
// Only show a limited number of catalogs initially for performance
|
||||||
const catalogsToShow = catalogs.slice(0, visibleCatalogCount);
|
const catalogsToShow = catalogs.slice(0, visibleCatalogCount);
|
||||||
|
|
||||||
catalogsToShow.forEach((catalog, index) => {
|
catalogsToShow.forEach((catalog, index) => {
|
||||||
if (catalog) {
|
if (catalog) {
|
||||||
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
|
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
|
||||||
|
|
@ -637,7 +637,7 @@ const HomeScreen = () => {
|
||||||
// Memoize individual section components to prevent re-renders
|
// Memoize individual section components to prevent re-renders
|
||||||
const memoizedFeaturedContent = useMemo(() => {
|
const memoizedFeaturedContent = useMemo(() => {
|
||||||
const heroStyleToUse = settings.heroStyle;
|
const heroStyleToUse = settings.heroStyle;
|
||||||
|
|
||||||
// AppleTVHero is only available on mobile devices (not tablets)
|
// AppleTVHero is only available on mobile devices (not tablets)
|
||||||
if (heroStyleToUse === 'appletv' && !isTablet) {
|
if (heroStyleToUse === 'appletv' && !isTablet) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -685,16 +685,16 @@ const HomeScreen = () => {
|
||||||
const memoizedContinueWatchingSection = useMemo(() => <ContinueWatchingSection ref={continueWatchingRef} />, []);
|
const memoizedContinueWatchingSection = useMemo(() => <ContinueWatchingSection ref={continueWatchingRef} />, []);
|
||||||
const memoizedHeader = useMemo(() => (
|
const memoizedHeader = useMemo(() => (
|
||||||
<>
|
<>
|
||||||
{showHeroSection ? memoizedFeaturedContent : null}
|
{showHeroSection && hasAddons ? memoizedFeaturedContent : null}
|
||||||
{memoizedContinueWatchingSection}
|
{memoizedContinueWatchingSection}
|
||||||
</>
|
</>
|
||||||
), [showHeroSection, memoizedFeaturedContent, memoizedContinueWatchingSection]);
|
), [showHeroSection, hasAddons, memoizedFeaturedContent, memoizedContinueWatchingSection]);
|
||||||
// Track scroll direction manually for reliable behavior across platforms
|
// Track scroll direction manually for reliable behavior across platforms
|
||||||
const lastScrollYRef = useRef(0);
|
const lastScrollYRef = useRef(0);
|
||||||
const lastToggleRef = useRef(0);
|
const lastToggleRef = useRef(0);
|
||||||
const scrollAnimationFrameRef = useRef<number | null>(null);
|
const scrollAnimationFrameRef = useRef<number | null>(null);
|
||||||
const isScrollingRef = useRef(false);
|
const isScrollingRef = useRef(false);
|
||||||
|
|
||||||
const toggleHeader = useCallback((hide: boolean) => {
|
const toggleHeader = useCallback((hide: boolean) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastToggleRef.current < 120) return; // debounce
|
if (now - lastToggleRef.current < 120) return; // debounce
|
||||||
|
|
@ -783,26 +783,26 @@ const HomeScreen = () => {
|
||||||
const handleScroll = useCallback((event: any) => {
|
const handleScroll = useCallback((event: any) => {
|
||||||
// Persist the event before using requestAnimationFrame to prevent event pooling issues
|
// Persist the event before using requestAnimationFrame to prevent event pooling issues
|
||||||
event.persist();
|
event.persist();
|
||||||
|
|
||||||
// Cancel any pending animation frame
|
// Cancel any pending animation frame
|
||||||
if (scrollAnimationFrameRef.current !== null) {
|
if (scrollAnimationFrameRef.current !== null) {
|
||||||
cancelAnimationFrame(scrollAnimationFrameRef.current);
|
cancelAnimationFrame(scrollAnimationFrameRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture scroll values immediately before async operation
|
// Capture scroll values immediately before async operation
|
||||||
const scrollYValue = event.nativeEvent.contentOffset.y;
|
const scrollYValue = event.nativeEvent.contentOffset.y;
|
||||||
|
|
||||||
// Update shared value for parallax (on UI thread)
|
// Update shared value for parallax (on UI thread)
|
||||||
scrollY.value = scrollYValue;
|
scrollY.value = scrollYValue;
|
||||||
|
|
||||||
// Use requestAnimationFrame to throttle scroll handling
|
// Use requestAnimationFrame to throttle scroll handling
|
||||||
scrollAnimationFrameRef.current = requestAnimationFrame(() => {
|
scrollAnimationFrameRef.current = requestAnimationFrame(() => {
|
||||||
const y = scrollYValue;
|
const y = scrollYValue;
|
||||||
const dy = y - lastScrollYRef.current;
|
const dy = y - lastScrollYRef.current;
|
||||||
lastScrollYRef.current = y;
|
lastScrollYRef.current = y;
|
||||||
|
|
||||||
isScrollingRef.current = Math.abs(dy) > 0;
|
isScrollingRef.current = Math.abs(dy) > 0;
|
||||||
|
|
||||||
if (y <= 10) {
|
if (y <= 10) {
|
||||||
toggleHeader(false);
|
toggleHeader(false);
|
||||||
return;
|
return;
|
||||||
|
|
@ -813,7 +813,7 @@ const HomeScreen = () => {
|
||||||
} else if (dy < -6) {
|
} else if (dy < -6) {
|
||||||
toggleHeader(false); // scrolling up
|
toggleHeader(false); // scrolling up
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollAnimationFrameRef.current = null;
|
scrollAnimationFrameRef.current = null;
|
||||||
});
|
});
|
||||||
}, [toggleHeader]);
|
}, [toggleHeader]);
|
||||||
|
|
@ -823,19 +823,31 @@ const HomeScreen = () => {
|
||||||
const contentContainerStyle = useMemo(() => {
|
const contentContainerStyle = useMemo(() => {
|
||||||
const heroStyleToUse = settings.heroStyle;
|
const heroStyleToUse = settings.heroStyle;
|
||||||
const isUsingAppleTVHero = heroStyleToUse === 'appletv' && !isTablet && showHeroSection;
|
const isUsingAppleTVHero = heroStyleToUse === 'appletv' && !isTablet && showHeroSection;
|
||||||
|
|
||||||
return StyleSheet.flatten([
|
return StyleSheet.flatten([
|
||||||
styles.scrollContent,
|
styles.scrollContent,
|
||||||
{ paddingTop: isUsingAppleTVHero ? 0 : stableInsetsTop }
|
{ paddingTop: isUsingAppleTVHero ? 0 : stableInsetsTop }
|
||||||
]);
|
]);
|
||||||
}, [stableInsetsTop, settings.heroStyle, isTablet, showHeroSection]);
|
}, [stableInsetsTop, settings.heroStyle, isTablet, showHeroSection]);
|
||||||
|
|
||||||
// Memoize the main content section
|
// Memoize the main content section
|
||||||
const renderMainContent = useMemo(() => {
|
const renderMainContent = useMemo(() => {
|
||||||
if (isLoading) return null;
|
// If no addons, render welcome screen directly centered
|
||||||
|
if (hasAddons === false) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground, justifyContent: 'center' }]}>
|
||||||
|
<StatusBar
|
||||||
|
barStyle="light-content"
|
||||||
|
backgroundColor="transparent"
|
||||||
|
translucent
|
||||||
|
/>
|
||||||
|
<FirstTimeWelcome />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
barStyle="light-content"
|
barStyle="light-content"
|
||||||
backgroundColor="transparent"
|
backgroundColor="transparent"
|
||||||
|
|
@ -882,13 +894,13 @@ const calculatePosterLayout = (screenWidth: number) => {
|
||||||
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
|
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
|
||||||
const LEFT_PADDING = 16; // Left padding
|
const LEFT_PADDING = 16; // Left padding
|
||||||
const SPACING = 8; // Space between posters
|
const SPACING = 8; // Space between posters
|
||||||
|
|
||||||
// Calculate available width for posters (reserve space for left padding)
|
// Calculate available width for posters (reserve space for left padding)
|
||||||
const availableWidth = screenWidth - LEFT_PADDING;
|
const availableWidth = screenWidth - LEFT_PADDING;
|
||||||
|
|
||||||
// Try different numbers of full posters to find the best fit
|
// Try different numbers of full posters to find the best fit
|
||||||
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
|
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
|
||||||
|
|
||||||
for (let n = 3; n <= 6; n++) {
|
for (let n = 3; n <= 6; n++) {
|
||||||
// Calculate poster width needed for N full posters + 0.25 partial poster
|
// Calculate poster width needed for N full posters + 0.25 partial poster
|
||||||
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
|
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
|
||||||
|
|
@ -896,12 +908,12 @@ const calculatePosterLayout = (screenWidth: number) => {
|
||||||
// We'll use minimal right padding (8px) to maximize space
|
// We'll use minimal right padding (8px) to maximize space
|
||||||
const usableWidth = availableWidth - 8;
|
const usableWidth = availableWidth - 8;
|
||||||
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
|
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
|
||||||
|
|
||||||
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
|
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
|
||||||
bestLayout = { numFullPosters: n, posterWidth };
|
bestLayout = { numFullPosters: n, posterWidth };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
numFullPosters: bestLayout.numFullPosters,
|
numFullPosters: bestLayout.numFullPosters,
|
||||||
posterWidth: bestLayout.posterWidth,
|
posterWidth: bestLayout.posterWidth,
|
||||||
|
|
@ -966,7 +978,7 @@ const styles = StyleSheet.create<any>({
|
||||||
},
|
},
|
||||||
placeholderPoster: {
|
placeholderPoster: {
|
||||||
width: POSTER_WIDTH,
|
width: POSTER_WIDTH,
|
||||||
aspectRatio: 2/3,
|
aspectRatio: 2 / 3,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
marginRight: 2,
|
marginRight: 2,
|
||||||
},
|
},
|
||||||
|
|
@ -1203,7 +1215,7 @@ const styles = StyleSheet.create<any>({
|
||||||
},
|
},
|
||||||
contentItem: {
|
contentItem: {
|
||||||
width: POSTER_WIDTH,
|
width: POSTER_WIDTH,
|
||||||
aspectRatio: 2/3,
|
aspectRatio: 2 / 3,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
|
|
||||||
|
|
@ -127,12 +127,7 @@ const PlayerSettingsScreen: React.FC = () => {
|
||||||
description: 'Open streams in VidHub player',
|
description: 'Open streams in VidHub player',
|
||||||
icon: 'ondemand-video',
|
icon: 'ondemand-video',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'infuse_livecontainer',
|
|
||||||
title: 'Infuse Livecontainer',
|
|
||||||
description: 'Open streams in Infuse player LiveContainer',
|
|
||||||
icon: 'smart-display',
|
|
||||||
},
|
|
||||||
] : [
|
] : [
|
||||||
{
|
{
|
||||||
id: 'external',
|
id: 'external',
|
||||||
|
|
|
||||||
|
|
@ -1406,7 +1406,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
<View style={styles.settingInfo}>
|
<View style={styles.settingInfo}>
|
||||||
<Text style={styles.settingTitle}>Enable Plugins</Text>
|
<Text style={styles.settingTitle}>Enable Plugins</Text>
|
||||||
<Text style={styles.settingDescription}>
|
<Text style={styles.settingDescription}>
|
||||||
Allow the app to use installed plugins for finding streams
|
Allow the app to use installed plugins for enhanced content integration
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -1427,7 +1427,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
styles={styles}
|
styles={styles}
|
||||||
>
|
>
|
||||||
<Text style={styles.sectionDescription}>
|
<Text style={styles.sectionDescription}>
|
||||||
Manage multiple plugin repositories. Switch between repositories to access different sets of plugins.
|
Manage multiple plugin repositories. Switch between repositories to access different community extensions.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Current Repository */}
|
{/* Current Repository */}
|
||||||
|
|
@ -1752,7 +1752,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
<View style={styles.settingInfo}>
|
<View style={styles.settingInfo}>
|
||||||
<Text style={styles.settingTitle}>Enable URL Validation</Text>
|
<Text style={styles.settingTitle}>Enable URL Validation</Text>
|
||||||
<Text style={styles.settingDescription}>
|
<Text style={styles.settingDescription}>
|
||||||
Validate streaming URLs before returning them (may slow down results but improves reliability)
|
Validate source URLs before returning them (may slow down results but improves reliability)
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -1768,7 +1768,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
<View style={styles.settingInfo}>
|
<View style={styles.settingInfo}>
|
||||||
<Text style={styles.settingTitle}>Group Plugin Streams</Text>
|
<Text style={styles.settingTitle}>Group Plugin Streams</Text>
|
||||||
<Text style={styles.settingDescription}>
|
<Text style={styles.settingDescription}>
|
||||||
When enabled, all plugin streams are grouped under "{pluginService.getRepositoryName()}". When disabled, each plugin shows as a separate provider.
|
When enabled, all plugin sources are grouped under "{pluginService.getRepositoryName()}". When disabled, each plugin shows as a separate provider.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -1790,7 +1790,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
<View style={styles.settingInfo}>
|
<View style={styles.settingInfo}>
|
||||||
<Text style={styles.settingTitle}>Sort by Quality First</Text>
|
<Text style={styles.settingTitle}>Sort by Quality First</Text>
|
||||||
<Text style={styles.settingDescription}>
|
<Text style={styles.settingDescription}>
|
||||||
When enabled, streams are sorted by quality first, then by plugin. When disabled, streams are sorted by plugin first, then by quality. Only available when grouping is enabled.
|
When enabled, sources are sorted by quality first, then by plugin. When disabled, streams are sorted by plugin first, then by quality. Only available when grouping is enabled.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -1806,7 +1806,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
<View style={styles.settingInfo}>
|
<View style={styles.settingInfo}>
|
||||||
<Text style={styles.settingTitle}>Show Plugin Logos</Text>
|
<Text style={styles.settingTitle}>Show Plugin Logos</Text>
|
||||||
<Text style={styles.settingDescription}>
|
<Text style={styles.settingDescription}>
|
||||||
Display plugin logos next to streaming links on the streams screen.
|
Display plugin logos next to source links on the sources screen.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -1917,13 +1917,11 @@ const PluginsScreen: React.FC = () => {
|
||||||
<View style={[styles.section, styles.lastSection]}>
|
<View style={[styles.section, styles.lastSection]}>
|
||||||
<Text style={styles.sectionTitle}>About Plugins</Text>
|
<Text style={styles.sectionTitle}>About Plugins</Text>
|
||||||
<Text style={styles.infoText}>
|
<Text style={styles.infoText}>
|
||||||
Plugins are JavaScript modules that can search for streaming links from various sources.
|
Plugins extend app functionality by connecting to additional content providers.
|
||||||
They run locally on your device and can be installed from trusted repositories.
|
Add repositories to discover and enable plugins.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={[styles.infoText, { marginTop: 8, fontSize: 13, color: colors.mediumEmphasis }]}>
|
|
||||||
<Text style={{ fontWeight: '600' }}>Note:</Text> Providers marked as "Limited" depend on external APIs that may stop working without notice.
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
|
@ -1941,13 +1939,13 @@ const PluginsScreen: React.FC = () => {
|
||||||
1. <Text style={{ fontWeight: '600' }}>Enable Plugins</Text> - Turn on the main switch to allow plugins
|
1. <Text style={{ fontWeight: '600' }}>Enable Plugins</Text> - Turn on the main switch to allow plugins
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.modalText}>
|
<Text style={styles.modalText}>
|
||||||
2. <Text style={{ fontWeight: '600' }}>Add Repository</Text> - Add a GitHub raw URL or use the default repository
|
2. <Text style={{ fontWeight: '600' }}>Add Repository</Text> - Add a repository URL to discover plugins
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.modalText}>
|
<Text style={styles.modalText}>
|
||||||
3. <Text style={{ fontWeight: '600' }}>Refresh Repository</Text> - Download available plugins from the repository
|
3. <Text style={{ fontWeight: '600' }}>Refresh Repository</Text> - Update plugins from the repository
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.modalText}>
|
<Text style={styles.modalText}>
|
||||||
4. <Text style={{ fontWeight: '600' }}>Enable Plugins</Text> - Turn on the plugins you want to use for streaming
|
4. <Text style={{ fontWeight: '600' }}>Enable Plugins</Text> - Turn on the plugins you want to use
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.modalButton}
|
style={styles.modalButton}
|
||||||
|
|
|
||||||
|
|
@ -377,11 +377,31 @@ const SearchScreen = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [hasAddons, setHasAddons] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
// Check for search-capable addons on focus
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAddons = async () => {
|
||||||
|
try {
|
||||||
|
const addons = await catalogService.getAllAddons();
|
||||||
|
// Check if any addon supports search (catalog resource with extra search or just any addon)
|
||||||
|
// For now, simpler consistent check: just if any addon is installed
|
||||||
|
setHasAddons(addons.length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
setHasAddons(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAddons();
|
||||||
|
const unsubscribe = navigation.addListener('focus', checkAddons);
|
||||||
|
return unsubscribe;
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
// Create a stable debounced search function using useMemo
|
// Create a stable debounced search function using useMemo
|
||||||
const debouncedSearch = useMemo(() => {
|
const debouncedSearch = useMemo(() => {
|
||||||
return debounce(async (searchQuery: string) => {
|
return debounce(async (searchQuery: string) => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
// Cancel any in-flight live search
|
// Cancel any, in-flight live search
|
||||||
liveSearchHandle.current?.cancel();
|
liveSearchHandle.current?.cancel();
|
||||||
liveSearchHandle.current = null;
|
liveSearchHandle.current = null;
|
||||||
setResults({ byAddon: [], allResults: [] });
|
setResults({ byAddon: [], allResults: [] });
|
||||||
|
|
@ -389,6 +409,12 @@ const SearchScreen = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block search if no addons
|
||||||
|
if (hasAddons === false) {
|
||||||
|
setSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel prior live search
|
// Cancel prior live search
|
||||||
liveSearchHandle.current?.cancel();
|
liveSearchHandle.current?.cancel();
|
||||||
setResults({ byAddon: [], allResults: [] });
|
setResults({ byAddon: [], allResults: [] });
|
||||||
|
|
@ -449,7 +475,7 @@ const SearchScreen = () => {
|
||||||
});
|
});
|
||||||
liveSearchHandle.current = handle;
|
liveSearchHandle.current = handle;
|
||||||
}, 800);
|
}, 800);
|
||||||
}, []); // Empty dependency array - create once and never recreate
|
}, [hasAddons]); // Re-create if hasAddons changes
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip initial mount to prevent unnecessary operations
|
// Skip initial mount to prevent unnecessary operations
|
||||||
|
|
@ -460,9 +486,12 @@ const SearchScreen = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.trim() && query.trim().length >= 2) {
|
if (query.trim() && query.trim().length >= 2) {
|
||||||
setSearching(true);
|
// Don't set searching state if no addons, to avoid flicker
|
||||||
setSearched(true);
|
if (hasAddons !== false) {
|
||||||
setShowRecent(false);
|
setSearching(true);
|
||||||
|
setSearched(true);
|
||||||
|
setShowRecent(false);
|
||||||
|
}
|
||||||
debouncedSearch(query);
|
debouncedSearch(query);
|
||||||
} else if (query.trim().length < 2 && query.trim().length > 0) {
|
} else if (query.trim().length < 2 && query.trim().length > 0) {
|
||||||
// Show that we're waiting for more characters
|
// Show that we're waiting for more characters
|
||||||
|
|
@ -486,7 +515,7 @@ const SearchScreen = () => {
|
||||||
return () => {
|
return () => {
|
||||||
debouncedSearch.cancel();
|
debouncedSearch.cancel();
|
||||||
};
|
};
|
||||||
}, [query]); // Removed debouncedSearch since it's now stable with useMemo
|
}, [query, hasAddons]); // Added hasAddons dependency
|
||||||
|
|
||||||
const handleClearSearch = () => {
|
const handleClearSearch = () => {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
|
|
@ -883,6 +912,23 @@ const SearchScreen = () => {
|
||||||
offsetY={-60}
|
offsetY={-60}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
) : hasAddons === false ? (
|
||||||
|
<Animated.View
|
||||||
|
style={styles.emptyContainer}
|
||||||
|
entering={FadeIn.duration(300)}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="extension-off"
|
||||||
|
size={64}
|
||||||
|
color={currentTheme.colors.lightGray}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
|
||||||
|
No Addons Installed
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray, marginBottom: 24 }]}>
|
||||||
|
Install addons to enable search functionality
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
) : query.trim().length === 1 ? (
|
) : query.trim().length === 1 ? (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={styles.emptyContainer}
|
style={styles.emptyContainer}
|
||||||
|
|
|
||||||
|
|
@ -548,6 +548,8 @@ const SettingsScreen: React.FC = () => {
|
||||||
onPress={() => navigation.navigate('Addons')}
|
onPress={() => navigation.navigate('Addons')}
|
||||||
isTablet={isTablet}
|
isTablet={isTablet}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title="Debrid Integration"
|
title="Debrid Integration"
|
||||||
description="Connect Torbox for premium streams"
|
description="Connect Torbox for premium streams"
|
||||||
|
|
@ -556,6 +558,7 @@ const SettingsScreen: React.FC = () => {
|
||||||
onPress={() => navigation.navigate('DebridIntegration')}
|
onPress={() => navigation.navigate('DebridIntegration')}
|
||||||
isTablet={isTablet}
|
isTablet={isTablet}
|
||||||
/>
|
/>
|
||||||
|
*/}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title="Plugins"
|
title="Plugins"
|
||||||
description="Manage plugins and repositories"
|
description="Manage plugins and repositories"
|
||||||
|
|
@ -686,6 +689,9 @@ const SettingsScreen: React.FC = () => {
|
||||||
onPress={() => navigation.navigate('PlayerSettings')}
|
onPress={() => navigation.navigate('PlayerSettings')}
|
||||||
isTablet={isTablet}
|
isTablet={isTablet}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
{/*
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title="Show Trailers"
|
title="Show Trailers"
|
||||||
description="Display trailers in hero section"
|
description="Display trailers in hero section"
|
||||||
|
|
@ -700,6 +706,7 @@ const SettingsScreen: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
isTablet={isTablet}
|
isTablet={isTablet}
|
||||||
/>
|
/>
|
||||||
|
*/}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title="Enable Downloads (Beta)"
|
title="Enable Downloads (Beta)"
|
||||||
description="Show Downloads tab and enable saving streams"
|
description="Show Downloads tab and enable saving streams"
|
||||||
|
|
|
||||||
|
|
@ -194,26 +194,26 @@ class StremioService {
|
||||||
public async isValidContentId(type: string, id: string | null | undefined): Promise<boolean> {
|
public async isValidContentId(type: string, id: string | null | undefined): Promise<boolean> {
|
||||||
// Ensure addons are initialized before checking types
|
// Ensure addons are initialized before checking types
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
|
|
||||||
// Get all supported types from installed addons
|
// Get all supported types from installed addons
|
||||||
const supportedTypes = this.getAllSupportedTypes();
|
const supportedTypes = this.getAllSupportedTypes();
|
||||||
const isValidType = supportedTypes.includes(type);
|
const isValidType = supportedTypes.includes(type);
|
||||||
|
|
||||||
const lowerId = (id || '').toLowerCase();
|
const lowerId = (id || '').toLowerCase();
|
||||||
const isNullishId = !id || lowerId === 'null' || lowerId === 'undefined';
|
const isNullishId = !id || lowerId === 'null' || lowerId === 'undefined';
|
||||||
const providerLikeIds = new Set<string>(['moviebox', 'torbox']);
|
const providerLikeIds = new Set<string>(['moviebox', 'torbox']);
|
||||||
const isProviderSlug = providerLikeIds.has(lowerId);
|
const isProviderSlug = providerLikeIds.has(lowerId);
|
||||||
|
|
||||||
if (!isValidType || isNullishId || isProviderSlug) return false;
|
if (!isValidType || isNullishId || isProviderSlug) return false;
|
||||||
|
|
||||||
// Get all supported ID prefixes from installed addons
|
// Get all supported ID prefixes from installed addons
|
||||||
const supportedPrefixes = this.getAllSupportedIdPrefixes(type);
|
const supportedPrefixes = this.getAllSupportedIdPrefixes(type);
|
||||||
|
|
||||||
// If no addons declare specific prefixes, allow any non-empty string
|
// If no addons declare specific prefixes, allow any non-empty string
|
||||||
if (supportedPrefixes.length === 0) {
|
if (supportedPrefixes.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the ID matches any supported prefix
|
// Check if the ID matches any supported prefix
|
||||||
return supportedPrefixes.some(prefix => lowerId.startsWith(prefix.toLowerCase()));
|
return supportedPrefixes.some(prefix => lowerId.startsWith(prefix.toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
@ -222,13 +222,13 @@ class StremioService {
|
||||||
public getAllSupportedTypes(): string[] {
|
public getAllSupportedTypes(): string[] {
|
||||||
const addons = this.getInstalledAddons();
|
const addons = this.getInstalledAddons();
|
||||||
const types = new Set<string>();
|
const types = new Set<string>();
|
||||||
|
|
||||||
for (const addon of addons) {
|
for (const addon of addons) {
|
||||||
// Check addon-level types
|
// Check addon-level types
|
||||||
if (addon.types && Array.isArray(addon.types)) {
|
if (addon.types && Array.isArray(addon.types)) {
|
||||||
addon.types.forEach(type => types.add(type));
|
addon.types.forEach(type => types.add(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check resource-level types
|
// Check resource-level types
|
||||||
if (addon.resources && Array.isArray(addon.resources)) {
|
if (addon.resources && Array.isArray(addon.resources)) {
|
||||||
for (const resource of addon.resources) {
|
for (const resource of addon.resources) {
|
||||||
|
|
@ -240,7 +240,7 @@ class StremioService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check catalog-level types
|
// Check catalog-level types
|
||||||
if (addon.catalogs && Array.isArray(addon.catalogs)) {
|
if (addon.catalogs && Array.isArray(addon.catalogs)) {
|
||||||
for (const catalog of addon.catalogs) {
|
for (const catalog of addon.catalogs) {
|
||||||
|
|
@ -250,7 +250,7 @@ class StremioService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(types);
|
return Array.from(types);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,13 +258,13 @@ class StremioService {
|
||||||
public getAllSupportedIdPrefixes(type: string): string[] {
|
public getAllSupportedIdPrefixes(type: string): string[] {
|
||||||
const addons = this.getInstalledAddons();
|
const addons = this.getInstalledAddons();
|
||||||
const prefixes = new Set<string>();
|
const prefixes = new Set<string>();
|
||||||
|
|
||||||
for (const addon of addons) {
|
for (const addon of addons) {
|
||||||
// Check addon-level idPrefixes
|
// Check addon-level idPrefixes
|
||||||
if (addon.idPrefixes && Array.isArray(addon.idPrefixes)) {
|
if (addon.idPrefixes && Array.isArray(addon.idPrefixes)) {
|
||||||
addon.idPrefixes.forEach(prefix => prefixes.add(prefix));
|
addon.idPrefixes.forEach(prefix => prefixes.add(prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check resource-level idPrefixes
|
// Check resource-level idPrefixes
|
||||||
if (addon.resources && Array.isArray(addon.resources)) {
|
if (addon.resources && Array.isArray(addon.resources)) {
|
||||||
for (const resource of addon.resources) {
|
for (const resource of addon.resources) {
|
||||||
|
|
@ -280,34 +280,34 @@ class StremioService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(prefixes);
|
return Array.from(prefixes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a content ID belongs to a collection addon
|
// Check if a content ID belongs to a collection addon
|
||||||
public isCollectionContent(id: string): { isCollection: boolean; addon?: Manifest } {
|
public isCollectionContent(id: string): { isCollection: boolean; addon?: Manifest } {
|
||||||
const addons = this.getInstalledAddons();
|
const addons = this.getInstalledAddons();
|
||||||
|
|
||||||
for (const addon of addons) {
|
for (const addon of addons) {
|
||||||
// Check if this addon supports collections
|
// Check if this addon supports collections
|
||||||
const supportsCollections = addon.types?.includes('collections') ||
|
const supportsCollections = addon.types?.includes('collections') ||
|
||||||
addon.catalogs?.some(catalog => catalog.type === 'collections');
|
addon.catalogs?.some(catalog => catalog.type === 'collections');
|
||||||
|
|
||||||
if (!supportsCollections) continue;
|
if (!supportsCollections) continue;
|
||||||
|
|
||||||
// Check if our ID matches this addon's prefixes
|
// Check if our ID matches this addon's prefixes
|
||||||
const addonPrefixes = addon.idPrefixes || [];
|
const addonPrefixes = addon.idPrefixes || [];
|
||||||
const resourcePrefixes = addon.resources
|
const resourcePrefixes = addon.resources
|
||||||
?.filter(resource => typeof resource === 'object' && resource !== null && 'name' in resource)
|
?.filter(resource => typeof resource === 'object' && resource !== null && 'name' in resource)
|
||||||
?.filter(resource => (resource as any).name === 'meta' || (resource as any).name === 'catalog')
|
?.filter(resource => (resource as any).name === 'meta' || (resource as any).name === 'catalog')
|
||||||
?.flatMap(resource => (resource as any).idPrefixes || []) || [];
|
?.flatMap(resource => (resource as any).idPrefixes || []) || [];
|
||||||
|
|
||||||
const allPrefixes = [...addonPrefixes, ...resourcePrefixes];
|
const allPrefixes = [...addonPrefixes, ...resourcePrefixes];
|
||||||
if (allPrefixes.some(prefix => id.startsWith(prefix))) {
|
if (allPrefixes.some(prefix => id.startsWith(prefix))) {
|
||||||
return { isCollection: true, addon };
|
return { isCollection: true, addon };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isCollection: false };
|
return { isCollection: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,17 +320,17 @@ class StremioService {
|
||||||
|
|
||||||
private async initialize(): Promise<void> {
|
private async initialize(): Promise<void> {
|
||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
|
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
|
||||||
// Prefer scoped storage, but fall back to legacy keys to preserve older installs
|
// Prefer scoped storage, but fall back to legacy keys to preserve older installs
|
||||||
let storedAddons = await mmkvStorage.getItem(`@user:${scope}:${this.STORAGE_KEY}`);
|
let storedAddons = await mmkvStorage.getItem(`@user:${scope}:${this.STORAGE_KEY}`);
|
||||||
if (!storedAddons) storedAddons = await mmkvStorage.getItem(this.STORAGE_KEY);
|
if (!storedAddons) storedAddons = await mmkvStorage.getItem(this.STORAGE_KEY);
|
||||||
if (!storedAddons) storedAddons = await mmkvStorage.getItem(`@user:local:${this.STORAGE_KEY}`);
|
if (!storedAddons) storedAddons = await mmkvStorage.getItem(`@user:local:${this.STORAGE_KEY}`);
|
||||||
|
|
||||||
if (storedAddons) {
|
if (storedAddons) {
|
||||||
const parsed = JSON.parse(storedAddons);
|
const parsed = JSON.parse(storedAddons);
|
||||||
|
|
||||||
// Convert to Map
|
// Convert to Map
|
||||||
this.installedAddons = new Map();
|
this.installedAddons = new Map();
|
||||||
for (const addon of parsed) {
|
for (const addon of parsed) {
|
||||||
|
|
@ -339,92 +339,33 @@ class StremioService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install Cinemeta for new users, but allow existing users to uninstall it
|
|
||||||
const cinemetaId = 'com.linvo.cinemeta';
|
|
||||||
const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId);
|
|
||||||
|
|
||||||
if (!this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemeta) {
|
|
||||||
try {
|
|
||||||
const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json');
|
|
||||||
this.installedAddons.set(cinemetaId, cinemetaManifest);
|
|
||||||
} catch (error) {
|
|
||||||
// Fallback to minimal manifest if fetch fails
|
|
||||||
const fallbackManifest: Manifest = {
|
|
||||||
id: cinemetaId,
|
|
||||||
name: 'Cinemeta',
|
|
||||||
version: '3.0.13',
|
|
||||||
description: 'Provides metadata for movies and series from TheTVDB, TheMovieDB, etc.',
|
|
||||||
url: 'https://v3-cinemeta.strem.io',
|
|
||||||
originalUrl: 'https://v3-cinemeta.strem.io/manifest.json',
|
|
||||||
types: ['movie', 'series'],
|
|
||||||
catalogs: [
|
|
||||||
{
|
|
||||||
type: 'movie',
|
|
||||||
id: 'top',
|
|
||||||
name: 'Popular',
|
|
||||||
extraSupported: ['search', 'genre', 'skip']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'series',
|
|
||||||
id: 'top',
|
|
||||||
name: 'Popular',
|
|
||||||
extraSupported: ['search', 'genre', 'skip']
|
|
||||||
}
|
|
||||||
],
|
|
||||||
resources: [
|
|
||||||
{
|
|
||||||
name: 'catalog',
|
|
||||||
types: ['movie', 'series'],
|
|
||||||
idPrefixes: ['tt']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'meta',
|
|
||||||
types: ['movie', 'series'],
|
|
||||||
idPrefixes: ['tt']
|
|
||||||
}
|
|
||||||
],
|
|
||||||
behaviorHints: {
|
|
||||||
configurable: false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.installedAddons.set(cinemetaId, fallbackManifest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install OpenSubtitles v3 by default unless user has explicitly removed it
|
// Preinstalled addons disabled
|
||||||
const opensubsId = 'org.stremio.opensubtitlesv3';
|
// const cinemetaId = 'com.linvo.cinemeta';
|
||||||
const hasUserRemovedOpenSubtitles = await this.hasUserRemovedAddon(opensubsId);
|
// const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId);
|
||||||
|
//
|
||||||
if (!this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitles) {
|
// if (!this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemeta) {
|
||||||
try {
|
// try {
|
||||||
const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json');
|
// const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json');
|
||||||
this.installedAddons.set(opensubsId, opensubsManifest);
|
// this.installedAddons.set(cinemetaId, cinemetaManifest);
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
const fallbackManifest: Manifest = {
|
// // Fallback omitted for brevity
|
||||||
id: opensubsId,
|
// }
|
||||||
name: 'OpenSubtitles v3',
|
// }
|
||||||
version: '1.0.0',
|
|
||||||
description: 'OpenSubtitles v3 Addon for Stremio',
|
// OpenSubtitles preinstall disabled
|
||||||
url: 'https://opensubtitles-v3.strem.io',
|
// const opensubsId = 'org.stremio.opensubtitlesv3';
|
||||||
originalUrl: 'https://opensubtitles-v3.strem.io/manifest.json',
|
// const hasUserRemovedOpenSubtitles = await this.hasUserRemovedAddon(opensubsId);
|
||||||
types: ['movie', 'series'],
|
//
|
||||||
catalogs: [],
|
// if (!this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitles) {
|
||||||
resources: [
|
// try {
|
||||||
{
|
// const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json');
|
||||||
name: 'subtitles',
|
// this.installedAddons.set(opensubsId, opensubsManifest);
|
||||||
types: ['movie', 'series'],
|
// } catch (error) {
|
||||||
idPrefixes: ['tt']
|
// // Fallback omitted for brevity
|
||||||
}
|
// }
|
||||||
],
|
// }
|
||||||
behaviorHints: {
|
|
||||||
configurable: false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.installedAddons.set(opensubsId, fallbackManifest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load addon order if exists (scoped first, then legacy, then @user:local for migration safety)
|
// Load addon order if exists (scoped first, then legacy, then @user:local for migration safety)
|
||||||
let storedOrder = await mmkvStorage.getItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`);
|
let storedOrder = await mmkvStorage.getItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`);
|
||||||
if (!storedOrder) storedOrder = await mmkvStorage.getItem(this.ADDON_ORDER_KEY);
|
if (!storedOrder) storedOrder = await mmkvStorage.getItem(this.ADDON_ORDER_KEY);
|
||||||
|
|
@ -434,28 +375,29 @@ class StremioService {
|
||||||
// Filter out any ids that aren't in installedAddons
|
// Filter out any ids that aren't in installedAddons
|
||||||
this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id));
|
this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Cinemeta to order only if user hasn't removed it
|
|
||||||
const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId);
|
// Preinstalled addon order disabled
|
||||||
if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemetaOrder) {
|
// const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId);
|
||||||
this.addonOrder.push(cinemetaId);
|
// if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemetaOrder) {
|
||||||
}
|
// this.addonOrder.push(cinemetaId);
|
||||||
|
// }
|
||||||
// Only add OpenSubtitles to order if user hasn't removed it
|
|
||||||
const hasUserRemovedOpenSubtitlesOrder = await this.hasUserRemovedAddon(opensubsId);
|
// const hasUserRemovedOpenSubtitlesOrder = await this.hasUserRemovedAddon(opensubsId);
|
||||||
if (!this.addonOrder.includes(opensubsId) && this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitlesOrder) {
|
// if (!this.addonOrder.includes(opensubsId) && this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitlesOrder) {
|
||||||
this.addonOrder.push(opensubsId);
|
// this.addonOrder.push(opensubsId);
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
||||||
// Add any missing addons to the order
|
// Add any missing addons to the order
|
||||||
const installedIds = Array.from(this.installedAddons.keys());
|
const installedIds = Array.from(this.installedAddons.keys());
|
||||||
const missingIds = installedIds.filter(id => !this.addonOrder.includes(id));
|
const missingIds = installedIds.filter(id => !this.addonOrder.includes(id));
|
||||||
this.addonOrder = [...this.addonOrder, ...missingIds];
|
this.addonOrder = [...this.addonOrder, ...missingIds];
|
||||||
|
|
||||||
// Ensure order and addons are saved
|
// Ensure order and addons are saved
|
||||||
await this.saveAddonOrder();
|
await this.saveAddonOrder();
|
||||||
await this.saveInstalledAddons();
|
await this.saveInstalledAddons();
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Initialize with empty state on error
|
// Initialize with empty state on error
|
||||||
|
|
@ -479,12 +421,12 @@ class StremioService {
|
||||||
return await request();
|
return await request();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
|
||||||
// Don't retry on 404 errors (content not found) - these are expected for some content
|
// Don't retry on 404 errors (content not found) - these are expected for some content
|
||||||
if (error.response?.status === 404) {
|
if (error.response?.status === 404) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only log warnings for non-404 errors to reduce noise
|
// Only log warnings for non-404 errors to reduce noise
|
||||||
if (error.response?.status !== 404) {
|
if (error.response?.status !== 404) {
|
||||||
logger.warn(`Request failed (attempt ${attempt + 1}/${retries + 1}):`, {
|
logger.warn(`Request failed (attempt ${attempt + 1}/${retries + 1}):`, {
|
||||||
|
|
@ -494,7 +436,7 @@ class StremioService {
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt < retries) {
|
if (attempt < retries) {
|
||||||
const backoffDelay = delay * Math.pow(2, attempt);
|
const backoffDelay = delay * Math.pow(2, attempt);
|
||||||
logger.log(`Retrying in ${backoffDelay}ms...`);
|
logger.log(`Retrying in ${backoffDelay}ms...`);
|
||||||
|
|
@ -535,25 +477,25 @@ class StremioService {
|
||||||
async getManifest(url: string): Promise<Manifest> {
|
async getManifest(url: string): Promise<Manifest> {
|
||||||
try {
|
try {
|
||||||
// Clean up URL - ensure it ends with manifest.json
|
// Clean up URL - ensure it ends with manifest.json
|
||||||
const manifestUrl = url.endsWith('manifest.json')
|
const manifestUrl = url.endsWith('manifest.json')
|
||||||
? url
|
? url
|
||||||
: `${url.replace(/\/$/, '')}/manifest.json`;
|
: `${url.replace(/\/$/, '')}/manifest.json`;
|
||||||
|
|
||||||
const response = await this.retryRequest(async () => {
|
const response = await this.retryRequest(async () => {
|
||||||
return await axios.get(manifestUrl);
|
return await axios.get(manifestUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
const manifest = response.data;
|
const manifest = response.data;
|
||||||
|
|
||||||
// Add some extra fields for internal use
|
// Add some extra fields for internal use
|
||||||
manifest.originalUrl = url;
|
manifest.originalUrl = url;
|
||||||
manifest.url = url.replace(/manifest\.json$/, '');
|
manifest.url = url.replace(/manifest\.json$/, '');
|
||||||
|
|
||||||
// Ensure ID exists
|
// Ensure ID exists
|
||||||
if (!manifest.id) {
|
if (!manifest.id) {
|
||||||
manifest.id = this.formatId(url);
|
manifest.id = this.formatId(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return manifest;
|
return manifest;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to fetch manifest from ${url}:`, error);
|
logger.error(`Failed to fetch manifest from ${url}:`, error);
|
||||||
|
|
@ -565,16 +507,16 @@ class StremioService {
|
||||||
const manifest = await this.getManifest(url);
|
const manifest = await this.getManifest(url);
|
||||||
if (manifest && manifest.id) {
|
if (manifest && manifest.id) {
|
||||||
this.installedAddons.set(manifest.id, manifest);
|
this.installedAddons.set(manifest.id, manifest);
|
||||||
|
|
||||||
// If addon was previously removed by user, unmark it on reinstall and clean up
|
// If addon was previously removed by user, unmark it on reinstall and clean up
|
||||||
await this.unmarkAddonAsRemovedByUser(manifest.id);
|
await this.unmarkAddonAsRemovedByUser(manifest.id);
|
||||||
await this.cleanupRemovedAddonFromStorage(manifest.id);
|
await this.cleanupRemovedAddonFromStorage(manifest.id);
|
||||||
|
|
||||||
// Add to order if not already present (new addons go to the end)
|
// Add to order if not already present (new addons go to the end)
|
||||||
if (!this.addonOrder.includes(manifest.id)) {
|
if (!this.addonOrder.includes(manifest.id)) {
|
||||||
this.addonOrder.push(manifest.id);
|
this.addonOrder.push(manifest.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveInstalledAddons();
|
await this.saveInstalledAddons();
|
||||||
await this.saveAddonOrder();
|
await this.saveAddonOrder();
|
||||||
// Emit an event that an addon was added
|
// Emit an event that an addon was added
|
||||||
|
|
@ -641,7 +583,7 @@ class StremioService {
|
||||||
const removedAddons = await mmkvStorage.getItem('user_removed_addons');
|
const removedAddons = await mmkvStorage.getItem('user_removed_addons');
|
||||||
let removedList = removedAddons ? JSON.parse(removedAddons) : [];
|
let removedList = removedAddons ? JSON.parse(removedAddons) : [];
|
||||||
if (!Array.isArray(removedList)) removedList = [];
|
if (!Array.isArray(removedList)) removedList = [];
|
||||||
|
|
||||||
if (!removedList.includes(addonId)) {
|
if (!removedList.includes(addonId)) {
|
||||||
removedList.push(addonId);
|
removedList.push(addonId);
|
||||||
await mmkvStorage.setItem('user_removed_addons', JSON.stringify(removedList));
|
await mmkvStorage.setItem('user_removed_addons', JSON.stringify(removedList));
|
||||||
|
|
@ -656,10 +598,10 @@ class StremioService {
|
||||||
try {
|
try {
|
||||||
const removedAddons = await mmkvStorage.getItem('user_removed_addons');
|
const removedAddons = await mmkvStorage.getItem('user_removed_addons');
|
||||||
if (!removedAddons) return;
|
if (!removedAddons) return;
|
||||||
|
|
||||||
let removedList = JSON.parse(removedAddons);
|
let removedList = JSON.parse(removedAddons);
|
||||||
if (!Array.isArray(removedList)) return;
|
if (!Array.isArray(removedList)) return;
|
||||||
|
|
||||||
const updatedList = removedList.filter(id => id !== addonId);
|
const updatedList = removedList.filter(id => id !== addonId);
|
||||||
await mmkvStorage.setItem('user_removed_addons', JSON.stringify(updatedList));
|
await mmkvStorage.setItem('user_removed_addons', JSON.stringify(updatedList));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -671,14 +613,14 @@ class StremioService {
|
||||||
private async cleanupRemovedAddonFromStorage(addonId: string): Promise<void> {
|
private async cleanupRemovedAddonFromStorage(addonId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
|
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
|
||||||
|
|
||||||
// Remove from all possible addon order storage keys
|
// Remove from all possible addon order storage keys
|
||||||
const keys = [
|
const keys = [
|
||||||
`@user:${scope}:${this.ADDON_ORDER_KEY}`,
|
`@user:${scope}:${this.ADDON_ORDER_KEY}`,
|
||||||
this.ADDON_ORDER_KEY,
|
this.ADDON_ORDER_KEY,
|
||||||
`@user:local:${this.ADDON_ORDER_KEY}`
|
`@user:local:${this.ADDON_ORDER_KEY}`
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const storedOrder = await mmkvStorage.getItem(key);
|
const storedOrder = await mmkvStorage.getItem(key);
|
||||||
if (storedOrder) {
|
if (storedOrder) {
|
||||||
|
|
@ -701,12 +643,12 @@ class StremioService {
|
||||||
async getAllCatalogs(): Promise<{ [addonId: string]: Meta[] }> {
|
async getAllCatalogs(): Promise<{ [addonId: string]: Meta[] }> {
|
||||||
const result: { [addonId: string]: Meta[] } = {};
|
const result: { [addonId: string]: Meta[] } = {};
|
||||||
const addons = this.getInstalledAddons();
|
const addons = this.getInstalledAddons();
|
||||||
|
|
||||||
const promises = addons.map(async (addon) => {
|
const promises = addons.map(async (addon) => {
|
||||||
if (!addon.catalogs || addon.catalogs.length === 0) return;
|
if (!addon.catalogs || addon.catalogs.length === 0) return;
|
||||||
|
|
||||||
const catalog = addon.catalogs[0]; // Just take the first catalog for now
|
const catalog = addon.catalogs[0]; // Just take the first catalog for now
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = await this.getCatalog(addon, catalog.type, catalog.id);
|
const items = await this.getCatalog(addon, catalog.type, catalog.id);
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
|
|
@ -716,7 +658,7 @@ class StremioService {
|
||||||
logger.error(`Failed to fetch catalog from ${addon.name}:`, error);
|
logger.error(`Failed to fetch catalog from ${addon.name}:`, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
@ -724,15 +666,15 @@ class StremioService {
|
||||||
private getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } {
|
private getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } {
|
||||||
// Extract query parameters if they exist
|
// Extract query parameters if they exist
|
||||||
const [baseUrl, queryString] = url.split('?');
|
const [baseUrl, queryString] = url.split('?');
|
||||||
|
|
||||||
// Remove trailing manifest.json and slashes
|
// Remove trailing manifest.json and slashes
|
||||||
let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, '');
|
let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, '');
|
||||||
|
|
||||||
// Ensure URL has protocol
|
// Ensure URL has protocol
|
||||||
if (!cleanBaseUrl.startsWith('http')) {
|
if (!cleanBaseUrl.startsWith('http')) {
|
||||||
cleanBaseUrl = `https://${cleanBaseUrl}`;
|
cleanBaseUrl = `https://${cleanBaseUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { baseUrl: cleanBaseUrl, queryParams: queryString };
|
return { baseUrl: cleanBaseUrl, queryParams: queryString };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -744,12 +686,12 @@ class StremioService {
|
||||||
.filter(f => f && f.value)
|
.filter(f => f && f.value)
|
||||||
.map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`)
|
.map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
// For all addons
|
// For all addons
|
||||||
if (!manifest.url) {
|
if (!manifest.url) {
|
||||||
throw new Error('Addon URL is missing');
|
throw new Error('Addon URL is missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url);
|
const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url);
|
||||||
// Candidate 1: Path-style skip URL: /catalog/{type}/{id}/skip={N}.json
|
// Candidate 1: Path-style skip URL: /catalog/{type}/{id}/skip={N}.json
|
||||||
|
|
@ -779,7 +721,7 @@ class StremioService {
|
||||||
try {
|
try {
|
||||||
const key = `${manifest.id}|${type}|${id}`;
|
const key = `${manifest.id}|${type}|${id}`;
|
||||||
if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore);
|
if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore);
|
||||||
} catch {}
|
} catch { }
|
||||||
if (response.data.metas && Array.isArray(response.data.metas)) {
|
if (response.data.metas && Array.isArray(response.data.metas)) {
|
||||||
return response.data.metas;
|
return response.data.metas;
|
||||||
}
|
}
|
||||||
|
|
@ -800,13 +742,13 @@ class StremioService {
|
||||||
try {
|
try {
|
||||||
// Validate content ID first
|
// Validate content ID first
|
||||||
const isValidId = await this.isValidContentId(type, id);
|
const isValidId = await this.isValidContentId(type, id);
|
||||||
|
|
||||||
if (!isValidId) {
|
if (!isValidId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const addons = this.getInstalledAddons();
|
const addons = this.getInstalledAddons();
|
||||||
|
|
||||||
// If a preferred addon is specified, try it first
|
// If a preferred addon is specified, try it first
|
||||||
if (preferredAddonId) {
|
if (preferredAddonId) {
|
||||||
const preferredAddon = addons.find(addon => addon.id === preferredAddonId);
|
const preferredAddon = addons.find(addon => addon.id === preferredAddonId);
|
||||||
|
|
@ -820,14 +762,14 @@ class StremioService {
|
||||||
// Check if addon supports meta resource for this type
|
// Check if addon supports meta resource for this type
|
||||||
let hasMetaSupport = false;
|
let hasMetaSupport = false;
|
||||||
let supportsIdPrefix = false;
|
let supportsIdPrefix = false;
|
||||||
|
|
||||||
for (const resource of preferredAddon.resources) {
|
for (const resource of preferredAddon.resources) {
|
||||||
// Check if the current element is a ResourceObject
|
// Check if the current element is a ResourceObject
|
||||||
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
||||||
const typedResource = resource as ResourceObject;
|
const typedResource = resource as ResourceObject;
|
||||||
if (typedResource.name === 'meta' &&
|
if (typedResource.name === 'meta' &&
|
||||||
Array.isArray(typedResource.types) &&
|
Array.isArray(typedResource.types) &&
|
||||||
typedResource.types.includes(type)) {
|
typedResource.types.includes(type)) {
|
||||||
hasMetaSupport = true;
|
hasMetaSupport = true;
|
||||||
// Check idPrefix support
|
// Check idPrefix support
|
||||||
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
|
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
|
||||||
|
|
@ -837,7 +779,7 @@ class StremioService {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Check if the element is the simple string "meta" AND the addon has a top-level types array
|
// Check if the element is the simple string "meta" AND the addon has a top-level types array
|
||||||
else if (typeof resource === 'string' && resource === 'meta' && preferredAddon.types) {
|
else if (typeof resource === 'string' && resource === 'meta' && preferredAddon.types) {
|
||||||
if (Array.isArray(preferredAddon.types) && preferredAddon.types.includes(type)) {
|
if (Array.isArray(preferredAddon.types) && preferredAddon.types.includes(type)) {
|
||||||
|
|
@ -852,19 +794,19 @@ class StremioService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Only require ID prefix compatibility if the addon has declared specific prefixes
|
// Only require ID prefix compatibility if the addon has declared specific prefixes
|
||||||
const requiresIdPrefix = preferredAddon.idPrefixes && preferredAddon.idPrefixes.length > 0;
|
const requiresIdPrefix = preferredAddon.idPrefixes && preferredAddon.idPrefixes.length > 0;
|
||||||
const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
|
const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
|
||||||
|
|
||||||
if (isSupported) {
|
if (isSupported) {
|
||||||
try {
|
try {
|
||||||
const response = await this.retryRequest(async () => {
|
const response = await this.retryRequest(async () => {
|
||||||
return await axios.get(url, { timeout: 10000 });
|
return await axios.get(url, { timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (response.data && response.data.meta) {
|
if (response.data && response.data.meta) {
|
||||||
return response.data.meta;
|
return response.data.meta;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -876,25 +818,25 @@ class StremioService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Cinemeta with different base URLs
|
// Try Cinemeta with different base URLs
|
||||||
const cinemetaUrls = [
|
const cinemetaUrls = [
|
||||||
'https://v3-cinemeta.strem.io',
|
'https://v3-cinemeta.strem.io',
|
||||||
'http://v3-cinemeta.strem.io'
|
'http://v3-cinemeta.strem.io'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
for (const baseUrl of cinemetaUrls) {
|
for (const baseUrl of cinemetaUrls) {
|
||||||
try {
|
try {
|
||||||
const encodedId = encodeURIComponent(id);
|
const encodedId = encodeURIComponent(id);
|
||||||
const url = `${baseUrl}/meta/${type}/${encodedId}.json`;
|
const url = `${baseUrl}/meta/${type}/${encodedId}.json`;
|
||||||
|
|
||||||
|
|
||||||
const response = await this.retryRequest(async () => {
|
const response = await this.retryRequest(async () => {
|
||||||
return await axios.get(url, { timeout: 10000 });
|
return await axios.get(url, { timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (response.data && response.data.meta) {
|
if (response.data && response.data.meta) {
|
||||||
return response.data.meta;
|
return response.data.meta;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -907,18 +849,18 @@ class StremioService {
|
||||||
// If Cinemeta fails, try other addons (excluding the preferred one already tried)
|
// If Cinemeta fails, try other addons (excluding the preferred one already tried)
|
||||||
for (const addon of addons) {
|
for (const addon of addons) {
|
||||||
if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) continue;
|
if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) continue;
|
||||||
|
|
||||||
// Check if addon supports meta resource for this type AND idPrefix (handles both string and object formats)
|
// Check if addon supports meta resource for this type AND idPrefix (handles both string and object formats)
|
||||||
let hasMetaSupport = false;
|
let hasMetaSupport = false;
|
||||||
let supportsIdPrefix = false;
|
let supportsIdPrefix = false;
|
||||||
|
|
||||||
for (const resource of addon.resources) {
|
for (const resource of addon.resources) {
|
||||||
// Check if the current element is a ResourceObject
|
// Check if the current element is a ResourceObject
|
||||||
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
||||||
const typedResource = resource as ResourceObject;
|
const typedResource = resource as ResourceObject;
|
||||||
if (typedResource.name === 'meta' &&
|
if (typedResource.name === 'meta' &&
|
||||||
Array.isArray(typedResource.types) &&
|
Array.isArray(typedResource.types) &&
|
||||||
typedResource.types.includes(type)) {
|
typedResource.types.includes(type)) {
|
||||||
hasMetaSupport = true;
|
hasMetaSupport = true;
|
||||||
// Match idPrefixes if present; otherwise assume support
|
// Match idPrefixes if present; otherwise assume support
|
||||||
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
|
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
|
||||||
|
|
@ -928,7 +870,7 @@ class StremioService {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Check if the element is the simple string "meta" AND the addon has a top-level types array
|
// Check if the element is the simple string "meta" AND the addon has a top-level types array
|
||||||
else if (typeof resource === 'string' && resource === 'meta' && addon.types) {
|
else if (typeof resource === 'string' && resource === 'meta' && addon.types) {
|
||||||
if (Array.isArray(addon.types) && addon.types.includes(type)) {
|
if (Array.isArray(addon.types) && addon.types.includes(type)) {
|
||||||
|
|
@ -943,28 +885,28 @@ class StremioService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Require meta support, but allow any ID if addon doesn't declare specific prefixes
|
// Require meta support, but allow any ID if addon doesn't declare specific prefixes
|
||||||
|
|
||||||
// Only require ID prefix compatibility if the addon has declared specific prefixes
|
// Only require ID prefix compatibility if the addon has declared specific prefixes
|
||||||
const requiresIdPrefix = addon.idPrefixes && addon.idPrefixes.length > 0;
|
const requiresIdPrefix = addon.idPrefixes && addon.idPrefixes.length > 0;
|
||||||
const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
|
const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
|
||||||
|
|
||||||
if (!isSupported) {
|
if (!isSupported) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
|
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
|
||||||
const encodedId = encodeURIComponent(id);
|
const encodedId = encodeURIComponent(id);
|
||||||
const url = queryParams ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/meta/${type}/${encodedId}.json`;
|
const url = queryParams ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/meta/${type}/${encodedId}.json`;
|
||||||
|
|
||||||
|
|
||||||
const response = await this.retryRequest(async () => {
|
const response = await this.retryRequest(async () => {
|
||||||
return await axios.get(url, { timeout: 10000 });
|
return await axios.get(url, { timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (response.data && response.data.meta) {
|
if (response.data && response.data.meta) {
|
||||||
return response.data.meta;
|
return response.data.meta;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -973,7 +915,7 @@ class StremioService {
|
||||||
continue; // Try next addon
|
continue; // Try next addon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error in getMetaDetails:', error);
|
logger.error('Error in getMetaDetails:', error);
|
||||||
|
|
@ -986,8 +928,8 @@ class StremioService {
|
||||||
* This prevents over-fetching all episode data and reduces memory consumption
|
* This prevents over-fetching all episode data and reduces memory consumption
|
||||||
*/
|
*/
|
||||||
async getUpcomingEpisodes(
|
async getUpcomingEpisodes(
|
||||||
type: string,
|
type: string,
|
||||||
id: string,
|
id: string,
|
||||||
options: {
|
options: {
|
||||||
daysBack?: number;
|
daysBack?: number;
|
||||||
daysAhead?: number;
|
daysAhead?: number;
|
||||||
|
|
@ -996,7 +938,7 @@ class StremioService {
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<{ seriesName: string; poster: string; episodes: any[] } | null> {
|
): Promise<{ seriesName: string; poster: string; episodes: any[] } | null> {
|
||||||
const { daysBack = 14, daysAhead = 28, maxEpisodes = 50, preferredAddonId } = options;
|
const { daysBack = 14, daysAhead = 28, maxEpisodes = 50, preferredAddonId } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get metadata first (this is lightweight compared to episodes)
|
// Get metadata first (this is lightweight compared to episodes)
|
||||||
const metadata = await this.getMetaDetails(type, id, preferredAddonId);
|
const metadata = await this.getMetaDetails(type, id, preferredAddonId);
|
||||||
|
|
@ -1048,10 +990,10 @@ class StremioService {
|
||||||
// Modify getStreams to use this.getInstalledAddons() instead of getEnabledAddons
|
// Modify getStreams to use this.getInstalledAddons() instead of getEnabledAddons
|
||||||
async getStreams(type: string, id: string, callback?: StreamCallback): Promise<void> {
|
async getStreams(type: string, id: string, callback?: StreamCallback): Promise<void> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
|
|
||||||
const addons = this.getInstalledAddons();
|
const addons = this.getInstalledAddons();
|
||||||
logger.log('📌 [getStreams] Installed addons:', addons.map(a => ({ id: a.id, name: a.name, url: a.url })));
|
logger.log('📌 [getStreams] Installed addons:', addons.map(a => ({ id: a.id, name: a.name, url: a.url })));
|
||||||
|
|
||||||
// Check if local scrapers are enabled and execute them first
|
// Check if local scrapers are enabled and execute them first
|
||||||
try {
|
try {
|
||||||
// Load settings from AsyncStorage directly (scoped with fallback)
|
// Load settings from AsyncStorage directly (scoped with fallback)
|
||||||
|
|
@ -1060,25 +1002,25 @@ class StremioService {
|
||||||
|| (await mmkvStorage.getItem('app_settings'));
|
|| (await mmkvStorage.getItem('app_settings'));
|
||||||
const rawSettings = settingsJson ? JSON.parse(settingsJson) : {};
|
const rawSettings = settingsJson ? JSON.parse(settingsJson) : {};
|
||||||
const settings: AppSettings = { ...DEFAULT_SETTINGS, ...rawSettings };
|
const settings: AppSettings = { ...DEFAULT_SETTINGS, ...rawSettings };
|
||||||
|
|
||||||
if (settings.enableLocalScrapers) {
|
if (settings.enableLocalScrapers) {
|
||||||
const hasScrapers = await localScraperService.hasScrapers();
|
const hasScrapers = await localScraperService.hasScrapers();
|
||||||
if (hasScrapers) {
|
if (hasScrapers) {
|
||||||
logger.log('🔧 [getStreams] Executing local scrapers for', type, id);
|
logger.log('🔧 [getStreams] Executing local scrapers for', type, id);
|
||||||
|
|
||||||
// Map Stremio types to local scraper types
|
// Map Stremio types to local scraper types
|
||||||
const scraperType = type === 'series' ? 'tv' : type;
|
const scraperType = type === 'series' ? 'tv' : type;
|
||||||
|
|
||||||
// Parse the Stremio ID to extract ID and season/episode info
|
// Parse the Stremio ID to extract ID and season/episode info
|
||||||
let tmdbId: string | null = null;
|
let tmdbId: string | null = null;
|
||||||
let season: number | undefined = undefined;
|
let season: number | undefined = undefined;
|
||||||
let episode: number | undefined = undefined;
|
let episode: number | undefined = undefined;
|
||||||
let idType: 'imdb' | 'kitsu' | 'tmdb' = 'imdb';
|
let idType: 'imdb' | 'kitsu' | 'tmdb' = 'imdb';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const idParts = id.split(':');
|
const idParts = id.split(':');
|
||||||
let baseId: string;
|
let baseId: string;
|
||||||
|
|
||||||
// Handle different episode ID formats
|
// Handle different episode ID formats
|
||||||
if (idParts[0] === 'series') {
|
if (idParts[0] === 'series') {
|
||||||
// Format: series:imdbId:season:episode or series:kitsu:7442:season:episode
|
// Format: series:imdbId:season:episode or series:kitsu:7442:season:episode
|
||||||
|
|
@ -1128,7 +1070,7 @@ class StremioService {
|
||||||
episode = parseInt(idParts[2], 10);
|
episode = parseInt(idParts[2], 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle ID conversion for local scrapers (they need TMDB ID)
|
// Handle ID conversion for local scrapers (they need TMDB ID)
|
||||||
if (idType === 'imdb') {
|
if (idType === 'imdb') {
|
||||||
// Convert IMDb ID to TMDB ID
|
// Convert IMDb ID to TMDB ID
|
||||||
|
|
@ -1154,7 +1096,7 @@ class StremioService {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('🔧 [getStreams] Skipping local scrapers due to ID parsing error:', error);
|
logger.warn('🔧 [getStreams] Skipping local scrapers due to ID parsing error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute local scrapers asynchronously with TMDB ID (when available)
|
// Execute local scrapers asynchronously with TMDB ID (when available)
|
||||||
if (tmdbId) {
|
if (tmdbId) {
|
||||||
localScraperService.getStreams(scraperType, tmdbId, season, episode, (streams, scraperId, scraperName, error) => {
|
localScraperService.getStreams(scraperType, tmdbId, season, episode, (streams, scraperId, scraperName, error) => {
|
||||||
|
|
@ -1191,13 +1133,13 @@ class StremioService {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Continue even if local scrapers fail
|
// Continue even if local scrapers fail
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check specifically for TMDB Embed addon
|
// Check specifically for TMDB Embed addon
|
||||||
const tmdbEmbed = addons.find(addon => addon.id === 'org.tmdbembedapi');
|
const tmdbEmbed = addons.find(addon => addon.id === 'org.tmdbembedapi');
|
||||||
if (!tmdbEmbed) {
|
if (!tmdbEmbed) {
|
||||||
// TMDB Embed addon not found
|
// TMDB Embed addon not found
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find addons that provide streams and sort them by installation order
|
// Find addons that provide streams and sort them by installation order
|
||||||
const streamAddons = addons
|
const streamAddons = addons
|
||||||
.filter(addon => {
|
.filter(addon => {
|
||||||
|
|
@ -1205,23 +1147,23 @@ class StremioService {
|
||||||
logger.log(`⚠️ [getStreams] Addon ${addon.id} has no valid resources array`);
|
logger.log(`⚠️ [getStreams] Addon ${addon.id} has no valid resources array`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the detailed resources structure for debugging
|
// Log the detailed resources structure for debugging
|
||||||
logger.log(`📋 [getStreams] Checking addon ${addon.id} resources:`, JSON.stringify(addon.resources));
|
logger.log(`📋 [getStreams] Checking addon ${addon.id} resources:`, JSON.stringify(addon.resources));
|
||||||
|
|
||||||
let hasStreamResource = false;
|
let hasStreamResource = false;
|
||||||
let supportsIdPrefix = false;
|
let supportsIdPrefix = false;
|
||||||
|
|
||||||
// Iterate through the resources array, checking each element
|
// Iterate through the resources array, checking each element
|
||||||
for (const resource of addon.resources) {
|
for (const resource of addon.resources) {
|
||||||
// Check if the current element is a ResourceObject
|
// Check if the current element is a ResourceObject
|
||||||
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
||||||
const typedResource = resource as ResourceObject;
|
const typedResource = resource as ResourceObject;
|
||||||
if (typedResource.name === 'stream' &&
|
if (typedResource.name === 'stream' &&
|
||||||
Array.isArray(typedResource.types) &&
|
Array.isArray(typedResource.types) &&
|
||||||
typedResource.types.includes(type)) {
|
typedResource.types.includes(type)) {
|
||||||
hasStreamResource = true;
|
hasStreamResource = true;
|
||||||
|
|
||||||
// Check if this addon supports the ID prefix (generic: any prefix that matches start of id)
|
// Check if this addon supports the ID prefix (generic: any prefix that matches start of id)
|
||||||
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
|
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
|
||||||
supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p));
|
supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p));
|
||||||
|
|
@ -1233,7 +1175,7 @@ class StremioService {
|
||||||
}
|
}
|
||||||
break; // Found the stream resource object, no need to check further
|
break; // Found the stream resource object, no need to check further
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Check if the element is the simple string "stream" AND the addon has a top-level types array
|
// Check if the element is the simple string "stream" AND the addon has a top-level types array
|
||||||
else if (typeof resource === 'string' && resource === 'stream' && addon.types) {
|
else if (typeof resource === 'string' && resource === 'stream' && addon.types) {
|
||||||
if (Array.isArray(addon.types) && addon.types.includes(type)) {
|
if (Array.isArray(addon.types) && addon.types.includes(type)) {
|
||||||
|
|
@ -1251,9 +1193,9 @@ class StremioService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canHandleRequest = hasStreamResource && supportsIdPrefix;
|
const canHandleRequest = hasStreamResource && supportsIdPrefix;
|
||||||
|
|
||||||
if (!hasStreamResource) {
|
if (!hasStreamResource) {
|
||||||
logger.log(`❌ [getStreams] Addon ${addon.id} does not support streaming ${type}`);
|
logger.log(`❌ [getStreams] Addon ${addon.id} does not support streaming ${type}`);
|
||||||
} else if (!supportsIdPrefix) {
|
} else if (!supportsIdPrefix) {
|
||||||
|
|
@ -1261,12 +1203,12 @@ class StremioService {
|
||||||
} else {
|
} else {
|
||||||
logger.log(`✅ [getStreams] Addon ${addon.id} supports streaming ${type} for id=${id}`);
|
logger.log(`✅ [getStreams] Addon ${addon.id} supports streaming ${type} for id=${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return canHandleRequest;
|
return canHandleRequest;
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.log('📊 [getStreams] Stream capable addons:', streamAddons.map(a => a.id));
|
logger.log('📊 [getStreams] Stream capable addons:', streamAddons.map(a => a.id));
|
||||||
|
|
||||||
if (streamAddons.length === 0) {
|
if (streamAddons.length === 0) {
|
||||||
logger.warn('⚠️ [getStreams] No addons found that can provide streams');
|
logger.warn('⚠️ [getStreams] No addons found that can provide streams');
|
||||||
// Optionally call callback with an empty result or specific status?
|
// Optionally call callback with an empty result or specific status?
|
||||||
|
|
@ -1276,7 +1218,7 @@ class StremioService {
|
||||||
|
|
||||||
// Process each addon and call the callback individually
|
// Process each addon and call the callback individually
|
||||||
streamAddons.forEach(addon => {
|
streamAddons.forEach(addon => {
|
||||||
// Use an IIFE to create scope for async operation inside forEach
|
// Use an IIFE to create scope for async operation inside forEach
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
if (!addon.url) {
|
if (!addon.url) {
|
||||||
|
|
@ -1288,9 +1230,9 @@ class StremioService {
|
||||||
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
|
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
|
||||||
const encodedId = encodeURIComponent(id);
|
const encodedId = encodeURIComponent(id);
|
||||||
const url = queryParams ? `${baseUrl}/stream/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${type}/${encodedId}.json`;
|
const url = queryParams ? `${baseUrl}/stream/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${type}/${encodedId}.json`;
|
||||||
|
|
||||||
logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`);
|
logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`);
|
||||||
|
|
||||||
const response = await this.retryRequest(async () => {
|
const response = await this.retryRequest(async () => {
|
||||||
return await axios.get(url);
|
return await axios.get(url);
|
||||||
});
|
});
|
||||||
|
|
@ -1301,7 +1243,7 @@ class StremioService {
|
||||||
processedStreams = this.processStreams(response.data.streams, addon);
|
processedStreams = this.processStreams(response.data.streams, addon);
|
||||||
logger.log(`✅ [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id})`);
|
logger.log(`✅ [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id})`);
|
||||||
} else {
|
} else {
|
||||||
logger.log(`⚠️ [getStreams] No streams found in response from ${addon.name} (${addon.id})`);
|
logger.log(`⚠️ [getStreams] No streams found in response from ${addon.name} (${addon.id})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
|
|
@ -1328,21 +1270,21 @@ class StremioService {
|
||||||
logger.warn(`Addon ${addon.id} has no URL defined`);
|
logger.warn(`Addon ${addon.id} has no URL defined`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
|
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
|
||||||
const encodedId = encodeURIComponent(id);
|
const encodedId = encodeURIComponent(id);
|
||||||
const streamPath = `/stream/${type}/${encodedId}.json`;
|
const streamPath = `/stream/${type}/${encodedId}.json`;
|
||||||
const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`;
|
const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`;
|
||||||
|
|
||||||
logger.log(`Fetching streams from URL: ${url}`);
|
logger.log(`Fetching streams from URL: ${url}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Increase timeout for debrid services
|
// Increase timeout for debrid services
|
||||||
const timeout = addon.id.toLowerCase().includes('torrentio') ? 60000 : 10000;
|
const timeout = addon.id.toLowerCase().includes('torrentio') ? 60000 : 10000;
|
||||||
|
|
||||||
const response = await this.retryRequest(async () => {
|
const response = await this.retryRequest(async () => {
|
||||||
logger.log(`Making request to ${url} with timeout ${timeout}ms`);
|
logger.log(`Making request to ${url} with timeout ${timeout}ms`);
|
||||||
return await axios.get(url, {
|
return await axios.get(url, {
|
||||||
timeout,
|
timeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
|
@ -1350,11 +1292,11 @@ class StremioService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 5); // Increase retries for stream fetching
|
}, 5); // Increase retries for stream fetching
|
||||||
|
|
||||||
if (response.data && response.data.streams && Array.isArray(response.data.streams)) {
|
if (response.data && response.data.streams && Array.isArray(response.data.streams)) {
|
||||||
const streams = this.processStreams(response.data.streams, addon);
|
const streams = this.processStreams(response.data.streams, addon);
|
||||||
logger.log(`Successfully processed ${streams.length} streams from ${addon.id}`);
|
logger.log(`Successfully processed ${streams.length} streams from ${addon.id}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
streams,
|
streams,
|
||||||
addon: addon.id,
|
addon: addon.id,
|
||||||
|
|
@ -1377,7 +1319,7 @@ class StremioService {
|
||||||
// Re-throw the error with more context
|
// Re-throw the error with more context
|
||||||
throw new Error(`Failed to fetch streams from ${addon.name}: ${error.message}`);
|
throw new Error(`Failed to fetch streams from ${addon.name}: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1495,11 +1437,11 @@ class StremioService {
|
||||||
items: Meta[];
|
items: Meta[];
|
||||||
}> {
|
}> {
|
||||||
const addon = this.getInstalledAddons().find(a => a.id === addonId);
|
const addon = this.getInstalledAddons().find(a => a.id === addonId);
|
||||||
|
|
||||||
if (!addon) {
|
if (!addon) {
|
||||||
throw new Error(`Addon ${addonId} not found`);
|
throw new Error(`Addon ${addonId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = await this.getCatalog(addon, type, id);
|
const items = await this.getCatalog(addon, type, id);
|
||||||
return {
|
return {
|
||||||
addon: addonId,
|
addon: addonId,
|
||||||
|
|
@ -1604,9 +1546,9 @@ class StremioService {
|
||||||
for (const addon of addons) {
|
for (const addon of addons) {
|
||||||
if (addon.resources && Array.isArray(addon.resources)) {
|
if (addon.resources && Array.isArray(addon.resources)) {
|
||||||
// Check for 'stream' resource in the modern format
|
// Check for 'stream' resource in the modern format
|
||||||
const hasStreamResource = addon.resources.some(resource =>
|
const hasStreamResource = addon.resources.some(resource =>
|
||||||
typeof resource === 'string'
|
typeof resource === 'string'
|
||||||
? resource === 'stream'
|
? resource === 'stream'
|
||||||
: resource.name === 'stream'
|
: resource.name === 'stream'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue