diff --git a/App.tsx b/App.tsx index 39207b5..c82de82 100644 --- a/App.tsx +++ b/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); diff --git a/src/components/FirstTimeWelcome.tsx b/src/components/FirstTimeWelcome.tsx index f5edee5..1b23ac8 100644 --- a/src/components/FirstTimeWelcome.tsx +++ b/src/components/FirstTimeWelcome.tsx @@ -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>(); return ( - - - - - - Welcome to Nuvio! + Welcome to Nuvio - + - To get started, install some addons to access content from various sources. + Install addons to start browsing content navigation.navigate('Addons')} + activeOpacity={0.8} > - Install Addons @@ -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; \ No newline at end of file +export default FirstTimeWelcome; diff --git a/src/components/metadata/TrailersSection.tsx b/src/components/metadata/TrailersSection.tsx index 7237856..506cce0 100644 --- a/src/components/metadata/TrailersSection.tsx +++ b/src/components/metadata/TrailersSection.tsx @@ -74,7 +74,7 @@ const TrailersSection: React.FC = 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 = 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 = memo(({ return 16; // phone } }, [deviceType]); - + // Enhanced trailer card sizing const trailerCardWidth = useMemo(() => { switch (deviceType) { @@ -116,7 +116,7 @@ const TrailersSection: React.FC = memo(({ return 200; // phone } }, [deviceType]); - + const trailerCardSpacing = useMemo(() => { switch (deviceType) { case 'tv': @@ -293,7 +293,7 @@ const TrailersSection: React.FC = 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 = memo(({ } catch (error) { logger.warn('TrailersSection', 'Error pausing hero trailer:', error); } - + setSelectedTrailer(trailer); setModalVisible(true); }; @@ -449,6 +449,9 @@ const TrailersSection: React.FC = 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 = memo(({ return ( {/* Enhanced Header with Category Selector */} = memo(({ {trailerCategories.length > 0 && selectedCategory && ( = memo(({ > = memo(({ > = memo(({ color={currentTheme.colors.primary} /> - + {formatTrailerType(category)} = memo(({ = memo(({ {trailer.displayName || trailer.name} { `; - + 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 ( - + - + {/* Header */} - navigation.goBack()} style={styles.backButton} > - Settings - + {/* Empty for now, but ready for future actions */} - + AI Assistant - { {/* Info Card */} - @@ -190,7 +190,7 @@ const AISettingsScreen: React.FC = () => { 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. - + @@ -224,7 +224,7 @@ const AISettingsScreen: React.FC = () => { OPENROUTER API KEY - + API Key @@ -232,11 +232,11 @@ const AISettingsScreen: React.FC = () => { Enter your OpenRouter API key to enable AI chat features - + { onPress={handleSaveApiKey} disabled={loading} > - @@ -275,22 +275,22 @@ const AISettingsScreen: React.FC = () => { onPress={handleSaveApiKey} disabled={loading} > - Update - + - @@ -306,9 +306,9 @@ const AISettingsScreen: React.FC = () => { style={[styles.getKeyButton, { backgroundColor: currentTheme.colors.elevation2 }]} onPress={handleGetApiKey} > - @@ -320,7 +320,7 @@ const AISettingsScreen: React.FC = () => { {/* Enable Toggle (top) */} - + Enable AI Chat { {isKeySet && ( - @@ -368,6 +368,15 @@ const AISettingsScreen: React.FC = () => { {/* OpenRouter branding */} + + Powered by + diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index fa32b59..9974641 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -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 = () => { {/* Promotional Addon Section (hidden if installed) */} - {!isPromoInstalled && ( - - OFFICIAL ADDON - - - - {promoAddon.logo ? ( - - ) : ( - - - - )} - - {promoAddon.name} - - v{promoAddon.version} - - {promoAddon.types?.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')} - - - - {promoAddon.behaviorHints?.configurable && ( - handleConfigureAddon(promoAddon, PROMO_ADDON_URL)} - > - - - )} - handleAddAddon(PROMO_ADDON_URL)} - disabled={installing} - > - {installing ? ( - - ) : ( - - )} - - - - - {promoAddon.description} - - - Configure and install for full functionality. - - - - - )} + diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index fbb2c6e..8d8453d 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -135,7 +135,7 @@ const HomeScreen = () => { const [hasAddons, setHasAddons] = useState(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 = {}; 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)[] = []; - + // 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(() => , []); 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(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 ( + + + + + ); + } + return ( - + { 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({ }, placeholderPoster: { width: POSTER_WIDTH, - aspectRatio: 2/3, + aspectRatio: 2 / 3, borderRadius: 12, marginRight: 2, }, @@ -1203,7 +1215,7 @@ const styles = StyleSheet.create({ }, contentItem: { width: POSTER_WIDTH, - aspectRatio: 2/3, + aspectRatio: 2 / 3, margin: 0, borderRadius: 4, overflow: 'hidden', diff --git a/src/screens/PlayerSettingsScreen.tsx b/src/screens/PlayerSettingsScreen.tsx index a3e57cb..5eb214c 100644 --- a/src/screens/PlayerSettingsScreen.tsx +++ b/src/screens/PlayerSettingsScreen.tsx @@ -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', diff --git a/src/screens/PluginsScreen.tsx b/src/screens/PluginsScreen.tsx index 1267951..6246f51 100644 --- a/src/screens/PluginsScreen.tsx +++ b/src/screens/PluginsScreen.tsx @@ -1406,7 +1406,7 @@ const PluginsScreen: React.FC = () => { Enable Plugins - Allow the app to use installed plugins for finding streams + Allow the app to use installed plugins for enhanced content integration { styles={styles} > - 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. {/* Current Repository */} @@ -1752,7 +1752,7 @@ const PluginsScreen: React.FC = () => { Enable URL Validation - 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) { Group Plugin Streams - 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. { Sort by Quality First - 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. { Show Plugin Logos - Display plugin logos next to streaming links on the streams screen. + Display plugin logos next to source links on the sources screen. { About Plugins - 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. - - Note: Providers marked as "Limited" depend on external APIs that may stop working without notice. - + @@ -1941,13 +1939,13 @@ const PluginsScreen: React.FC = () => { 1. Enable Plugins - Turn on the main switch to allow plugins - 2. Add Repository - Add a GitHub raw URL or use the default repository + 2. Add Repository - Add a repository URL to discover plugins - 3. Refresh Repository - Download available plugins from the repository + 3. Refresh Repository - Update plugins from the repository - 4. Enable Plugins - Turn on the plugins you want to use for streaming + 4. Enable Plugins - Turn on the plugins you want to use { } }; + const [hasAddons, setHasAddons] = useState(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} /> + ) : hasAddons === false ? ( + + + + No Addons Installed + + + Install addons to enable search functionality + + ) : query.trim().length === 1 ? ( { onPress={() => navigation.navigate('Addons')} isTablet={isTablet} /> + + {/* { onPress={() => navigation.navigate('DebridIntegration')} isTablet={isTablet} /> + */} { onPress={() => navigation.navigate('PlayerSettings')} isTablet={isTablet} /> + + + {/* { )} isTablet={isTablet} /> + */} { // 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(['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(); - + 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(); - + 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 { 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 { 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 { 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 { 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' );