This commit is contained in:
tapframe 2025-12-12 16:08:14 +05:30
parent 076f33d6b7
commit 271aac9ae6
12 changed files with 425 additions and 522 deletions

16
App.tsx
View file

@ -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);

View file

@ -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;

View file

@ -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
}

View file

@ -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,

View file

@ -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>

View file

@ -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>

View file

@ -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',

View file

@ -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',

View file

@ -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}

View file

@ -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}

View file

@ -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"

View file

@ -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'
);