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