diff --git a/README.md b/README.md index b595ced..9b2e057 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,9 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio ### iOS +#### TestFlight (Recommended) + [![Join TestFlight](https://img.shields.io/badge/Join-TestFlight-blue?style=for-the-badge)](https://testflight.apple.com/join/QkKMGRqp) + #### AltStore [![Add to AltStore](https://img.shields.io/badge/Add%20to-AltStore-blue?style=for-the-badge)](https://tinyurl.com/NuvioAltstore) diff --git a/index.html b/index.html index d3242d1..e617241 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,12 @@ + Nuvio - Media Hub - + +
@@ -1554,20 +1626,21 @@

NUVIO

The Ultimate Open-Source Media Experience

- +
- Download for Android + Download for Android - Install for iOS + Install for iOS
- +
-
- Direct Download -
-
-
Direct Download
-
Download the IPA file directly to your device
-
-
- - -
- AltStore -
-
-
Install via AltStore
-
One-click installation through AltStore
-
-
- - -
- SideStore -
-
-
Install via SideStore
-
One-click installation through SideStore
-
-
- + +
+ TestFlight +
+
+
TestFlight (Recommended)
+
Install via Apple's official beta testing platform
+
+
+ + +
+ Direct Download +
+
+
Direct Download
+
Download the IPA file directly to your device
+
+
+ + +
+ AltStore +
+
+
Install via AltStore
+
One-click installation through AltStore
+
+
+ + +
+ SideStore +
+
+
Install via SideStore
+
One-click installation through SideStore
+
+
+
- Copy URL + Copy URL
Copy Source URL
@@ -1639,56 +1725,70 @@
- - - + + +

Stremio Addon Support

-

Full compatibility with Stremio addons, allowing you to access your favorite content providers seamlessly.

+

Full compatibility with Stremio addons, allowing you to access your favorite content providers + seamlessly.

- +
- +

Advanced Rating System

-

Comprehensive rating screens with IMDB, TMDB, Rotten Tomatoes, and Metacritic scores for informed viewing decisions.

+

Comprehensive rating screens with IMDB, TMDB, Rotten Tomatoes, and Metacritic scores for informed + viewing decisions.

- - + +

Deep Customization

-

Extensive customization options including themes, player settings, notification preferences, and personalized content discovery.

+

Extensive customization options including themes, player settings, notification preferences, and + personalized content discovery.

- - + +

Watch Progress Tracking

-

Seamless progress synchronization across devices with Trakt.tv integration and local watch history management.

+

Seamless progress synchronization across devices with Trakt.tv integration and local watch + history management.

- - + +

Multi-Platform Support

-

Available on iOS and Android platforms with consistent experience and cross-device synchronization

+

Available on iOS and Android platforms with consistent experience and cross-device + synchronization

@@ -1698,44 +1798,50 @@

SEE IT IN ACTION

-
- Home Screen -

Home Screen

-
-
- App Interface -

Details Page

-
-
- Home Screen 2 -

Home Screen 2

-
-
- Library -

Library

-
-
- Player Loading -

Player Loading

-
-
- Video Player -

Video Player

-
-
- Ratings -

Ratings

-
-
- Episodes & Seasons -

Episodes & Seasons

-
-
- Search & Details -

Search & Details

-
+
+ Home Screen +

Home Screen

+
+
+ App Interface +

Details Page

+
+
+ Home Screen 2 +

Home Screen 2

+
+
+ Library +

Library

+
+
+ Player Loading +

Player Loading

+
+
+ Video Player +

Video Player

+
+
+ Ratings +

Ratings

+
+
+ Episodes & Seasons +

Episodes & Seasons

+
+
+ Search & Details +

Search & Details

+
-
+
@@ -1745,49 +1851,65 @@

Privacy Policy

Last updated: January 2025

- +
-

Data Collection

-

Nuvio does not collect personal information. We only store:

- +

No Account Sync

+

Nuvio operates entirely offline regarding user data. We do not have servers to + store your account, preferences, or viewing history. All data is stored locally on your device. +

-

How We Use Your Data

-

Your data is stored for your own purposes:

+

Data Storage & Backup

+

We use React Native MMKV for high-performance local storage. This includes:

-

We do not personalize recommendations or use your data for any other purposes.

+

Important: Since data is stored only on your device, you are responsible for + backing it up. If you uninstall the app or clear its data without a backup, your personalized + data will be lost permanently.

Third-Party Services

-

Nuvio integrates with third-party services that have their own privacy policies:

+

Nuvio integrates with external services to provide content and features:

+
+

Content Disclaimer

+

Nuvio is a media player and aggregator. We do not host any content. All video + content is provided by user-installed addons. Nuvio has no control over and assumes no + responsibility for the content provided by third-party addons.

+
+

Open Source

-

Nuvio is open-source. You can review our code and data handling on our GitHub repository to verify our privacy practices.

+

Nuvio is open-source software. You can review our source code to verify our data handling + practices on our GitHub + repository.

Contact

-

Questions about this policy? Contact us through our GitHub repository.

+

Questions or concerns? Please reach out via our GitHub Issues. +

@@ -1798,31 +1920,37 @@

Special Thanks

-
- -
-
-
- - -
-
-
- -
-
- -
-
+
+ +
+
+
+ + +
+
+
+ +
+
+ +
+

Built with ❀️ using React Native & Expo

- - + + - - + - + - + - + + \ No newline at end of file diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index 3808363..ce6a175 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -477,7 +477,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; - PRODUCT_NAME = "Nuvio"; + PRODUCT_NAME = Nuvio; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -494,7 +494,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Nuvio/NuvioRelease.entitlements; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = NLXTHANK2N; + DEVELOPMENT_TEAM = 8QBDZ766S3; INFOPLIST_FILE = Nuvio/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -508,8 +508,8 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; - PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app"; - PRODUCT_NAME = "Nuvio"; + PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub; + PRODUCT_NAME = Nuvio; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/ios/Nuvio/Info.plist b/ios/Nuvio/Info.plist index 14731e5..40a35d5 100644 --- a/ios/Nuvio/Info.plist +++ b/ios/Nuvio/Info.plist @@ -1,103 +1,99 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Nuvio - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.2.10 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - nuvio - com.nuvio.app - - - - CFBundleURLSchemes - - exp+nuvio - - - - CFBundleVersion - 25 - LSMinimumSystemVersion - 12.0 - LSRequiresIPhoneOS - - LSSupportsOpeningDocumentsInPlace - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSBonjourServices - - _http._tcp - _googlecast._tcp - _CC1AD845._googlecast._tcp - - NSLocalNetworkUsageDescription - Allow $(PRODUCT_NAME) to access your local network - NSMicrophoneUsageDescription - This app does not require microphone access. - RCTNewArchEnabled - - RCTRootViewBackgroundColor - 4278322180 - UIBackgroundModes - - audio - - UIFileSharingEnabled - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Dark - UIViewControllerBasedStatusBarAppearance - - - \ No newline at end of file + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nuvio + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.10 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + nuvio + com.nuvio.app + + + + CFBundleURLSchemes + + exp+nuvio + + + + CFBundleVersion + 25 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _http._tcp + _googlecast._tcp + _CC1AD845._googlecast._tcp + + RCTNewArchEnabled + + RCTRootViewBackgroundColor + 4278322180 + UIBackgroundModes + + audio + + UIFileSharingEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Dark + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/Nuvio/NuvioRelease.entitlements b/ios/Nuvio/NuvioRelease.entitlements index a0bc443..903def2 100644 --- a/ios/Nuvio/NuvioRelease.entitlements +++ b/ios/Nuvio/NuvioRelease.entitlements @@ -1,10 +1,8 @@ - - aps-environment - development - com.apple.developer.associated-domains - - - \ No newline at end of file + + aps-environment + development + + diff --git a/src/components/common/ScreenHeader.tsx b/src/components/common/ScreenHeader.tsx new file mode 100644 index 0000000..2ab1a4f --- /dev/null +++ b/src/components/common/ScreenHeader.tsx @@ -0,0 +1,241 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + StatusBar, + Platform, +} from 'react-native'; +import { useTheme } from '../../contexts/ThemeContext'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Feather, MaterialIcons } from '@expo/vector-icons'; + +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +interface ScreenHeaderProps { + /** + * The main title displayed in the header + */ + title: string; + /** + * Optional right action button (icon name from Feather icons) + */ + rightActionIcon?: string; + /** + * Optional callback for right action button press + */ + onRightActionPress?: () => void; + /** + * Optional custom right action component (overrides rightActionIcon) + */ + rightActionComponent?: React.ReactNode; + /** + * Optional back button (shows arrow back icon) + */ + showBackButton?: boolean; + /** + * Optional callback for back button press + */ + onBackPress?: () => void; + /** + * Whether this screen is displayed on a tablet layout + */ + isTablet?: boolean; + /** + * Optional extra top padding for tablet navigation offset + */ + tabletNavOffset?: number; + /** + * Optional custom title component (overrides title text) + */ + titleComponent?: React.ReactNode; + /** + * Optional children to render below the title row (e.g., filters, search bar) + */ + children?: React.ReactNode; + /** + * Whether to hide the header title row (useful when showing only children) + */ + hideTitleRow?: boolean; + /** + * Use MaterialIcons instead of Feather for icons + */ + useMaterialIcons?: boolean; + /** + * Optional custom style for title + */ + titleStyle?: object; +} + +const ScreenHeader: React.FC = ({ + title, + rightActionIcon, + onRightActionPress, + rightActionComponent, + showBackButton = false, + onBackPress, + isTablet = false, + tabletNavOffset = 64, + titleComponent, + children, + hideTitleRow = false, + useMaterialIcons = false, + titleStyle, +}) => { + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); + + // Calculate header spacing + const topSpacing = + (Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT : insets.top) + + (isTablet ? tabletNavOffset : 0); + + const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; + const titleRowHeight = headerBaseHeight + topSpacing; + + const IconComponent = useMaterialIcons ? MaterialIcons : Feather; + const backIconName = useMaterialIcons ? 'arrow-back' : 'arrow-left'; + + return ( + <> + {/* Fixed position header background to prevent shifts */} + + + {/* Header Section */} + + {/* Title Row */} + {!hideTitleRow && ( + + + {showBackButton ? ( + + + + ) : null} + + {titleComponent ? ( + titleComponent + ) : ( + + {title} + + )} + + {/* Right Action */} + {rightActionComponent ? ( + {rightActionComponent} + ) : rightActionIcon && onRightActionPress ? ( + + + + ) : ( + + )} + + + )} + + {/* Children (filters, search bar, etc.) */} + {children} + + + ); +}; + +const styles = StyleSheet.create({ + headerBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 10, + }, + header: { + paddingHorizontal: 20, + zIndex: 11, + }, + titleRow: { + justifyContent: 'flex-end', + paddingBottom: 8, + }, + headerContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + backButton: { + padding: 8, + marginLeft: -8, + marginRight: 8, + }, + headerTitle: { + fontSize: 32, + fontWeight: '800', + letterSpacing: 0.5, + flex: 1, + }, + headerTitleWithBack: { + fontSize: 24, + flex: 0, + }, + rightActionContainer: { + minWidth: 40, + alignItems: 'flex-end', + }, + rightActionButton: { + padding: 8, + marginRight: -8, + }, + rightActionPlaceholder: { + width: 40, + }, +}); + +export default ScreenHeader; diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 9f25880..9fb85ce 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -39,6 +39,12 @@ import { useSettings } from '../../hooks/useSettings'; import { useTrailer } from '../../contexts/TrailerContext'; import TrailerService from '../../services/trailerService'; import TrailerPlayer from '../video/TrailerPlayer'; +import { useLibrary } from '../../hooks/useLibrary'; +import { useToast } from '../../contexts/ToastContext'; +import { useTraktContext } from '../../contexts/TraktContext'; +import { BlurView as ExpoBlurView } from 'expo-blur'; +import { useWatchProgress } from '../../hooks/useWatchProgress'; +import { streamCacheService } from '../../services/streamCacheService'; interface AppleTVHeroProps { featuredContent: StreamingContent | null; @@ -144,6 +150,16 @@ const AppleTVHero: React.FC = ({ const insets = useSafeAreaInsets(); const { settings, updateSetting } = useSettings(); const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer(); + const { toggleLibrary, isInLibrary: checkIsInLibrary } = useLibrary(); + const { showSaved, showTraktSaved, showRemoved, showTraktRemoved } = useToast(); + const { isAuthenticated: isTraktAuthenticated } = useTraktContext(); + + // Library and watch state + const [inLibrary, setInLibrary] = useState(false); + const [isInWatchlist, setIsInWatchlist] = useState(false); + const [isWatched, setIsWatched] = useState(false); + const [playButtonText, setPlayButtonText] = useState('Play'); + const [type, setType] = useState<'movie' | 'series'>('movie'); // Create internal scrollY if not provided externally const internalScrollY = useSharedValue(0); @@ -185,6 +201,18 @@ const AppleTVHero: React.FC = ({ const currentItem = items[currentIndex] || null; + // Use watch progress hook + const { + watchProgress, + getPlayButtonText: getProgressPlayButtonText, + loadWatchProgress + } = useWatchProgress( + currentItem?.id || '', + type, + undefined, + [] // Pass episodes if you have them for series + ); + // Animation values const dragProgress = useSharedValue(0); const dragDirection = useSharedValue(0); // -1 for left, 1 for right @@ -196,6 +224,15 @@ const AppleTVHero: React.FC = ({ const trailerMuted = settings?.trailerMuted ?? true; const heroOpacity = useSharedValue(0); // Start hidden for smooth fade-in + // Handler for trailer end + const handleTrailerEnd = useCallback(() => { + logger.info('[AppleTVHero] Trailer ended'); + setTrailerPlaying(false); + // Fade back to thumbnail + trailerOpacity.value = withTiming(0, { duration: 300 }); + thumbnailOpacity.value = withTiming(1, { duration: 300 }); + }, [setTrailerPlaying, trailerOpacity, thumbnailOpacity]); + // Animated style for trailer container - 60% height with zoom const trailerContainerStyle = useAnimatedStyle(() => { // Faster fade out during drag - complete fade by 0.3 progress instead of 1.0 @@ -480,19 +517,196 @@ const AppleTVHero: React.FC = ({ logger.error('[AppleTVHero] Trailer playback error'); }, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]); - // Handle trailer end - const handleTrailerEnd = useCallback(() => { - logger.info('[AppleTVHero] Trailer ended'); - setTrailerPlaying(false); + // Update state when current item changes and load watch progress + useEffect(() => { + if (currentItem) { + setType(currentItem.type as 'movie' | 'series'); + checkItemStatus(currentItem.id); + loadWatchProgress(); + } + }, [currentItem, loadWatchProgress]); - // Reset trailer state - setTrailerReady(false); - setTrailerPreloaded(false); + // Update play button text and watched state when watch progress changes + useEffect(() => { + if (currentItem) { + const buttonText = getProgressPlayButtonText(); + setPlayButtonText(buttonText); - // Smooth fade back to thumbnail - trailerOpacity.value = withTiming(0, { duration: 500 }); - thumbnailOpacity.value = withTiming(1, { duration: 500 }); - }, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]); + // Update watched state based on progress + if (watchProgress) { + const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; + setIsWatched(progressPercent >= 85); // Consider watched if 85% or more completed + } else { + setIsWatched(false); + } + } + }, [watchProgress, getProgressPlayButtonText, currentItem]); + + // Function to check item status + const checkItemStatus = useCallback(async (itemId: string) => { + try { + // Check if item is in library + const libraryStatus = checkIsInLibrary(itemId); + setInLibrary(libraryStatus); + + // TODO: Check Trakt watchlist status if authenticated + if (isTraktAuthenticated) { + // await traktService.isInWatchlist(itemId); + setIsInWatchlist(Math.random() > 0.5); // Replace with actual Trakt call + } + } catch (error) { + logger.error('[AppleTVHero] Error checking item status:', error); + } + }, [checkIsInLibrary, isTraktAuthenticated]); + + // Update the handleSaveAction function: + const handleSaveAction = useCallback(async (e?: any) => { + if (e) { + e.stopPropagation(); + e.preventDefault(); + } + + if (!currentItem) return; + + const wasInLibrary = inLibrary; + const wasInWatchlist = isInWatchlist; + + // Update local state immediately for responsiveness + setInLibrary(!wasInLibrary); + + try { + // Toggle library using the useLibrary hook + const success = await toggleLibrary(currentItem); + + if (success) { + logger.info('[AppleTVHero] Successfully toggled library:', currentItem.name); + } else { + logger.warn('[AppleTVHero] Library toggle returned false'); + } + + // If authenticated with Trakt, also toggle Trakt watchlist + if (isTraktAuthenticated) { + setIsInWatchlist(!wasInWatchlist); + + // TODO: Replace with your actual Trakt service call + // await traktService.toggleWatchlist(currentItem.id, !wasInWatchlist); + logger.info('[AppleTVHero] Toggled Trakt watchlist'); + } + + } catch (error) { + logger.error('[AppleTVHero] Error toggling library:', error); + // Revert state on error + setInLibrary(wasInLibrary); + if (isTraktAuthenticated) { + setIsInWatchlist(wasInWatchlist); + } + } + }, [currentItem, inLibrary, isInWatchlist, isTraktAuthenticated, toggleLibrary, showSaved, showTraktSaved, showRemoved, showTraktRemoved]); + + // Play button handler - navigates to Streams screen with progress data if available + const handlePlayAction = useCallback(async () => { + logger.info('[AppleTVHero] Play button pressed for:', currentItem?.name); + if (!currentItem) return; + + // Stop any playing trailer + try { + setTrailerPlaying(false); + } catch {} + + // Check if we should resume based on watch progress + const shouldResume = watchProgress && + watchProgress.currentTime > 0 && + (watchProgress.currentTime / watchProgress.duration) < 0.85; + + logger.info('[AppleTVHero] Should resume:', shouldResume, watchProgress); + + try { + // Check if we have a cached stream for this content + const episodeId = currentItem.type === 'series' && watchProgress?.episodeId + ? watchProgress.episodeId + : undefined; + + logger.info('[AppleTVHero] Looking for cached stream with episodeId:', episodeId); + + const cachedStream = await streamCacheService.getCachedStream(currentItem.id, currentItem.type, episodeId); + + if (cachedStream && cachedStream.stream?.url) { + // We have a valid cached stream, navigate directly to player + logger.info('[AppleTVHero] Using cached stream for:', currentItem.name); + + // Determine the player route based on platform + const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; + + // Navigate directly to player with cached stream data AND RESUME DATA + navigation.navigate(playerRoute as any, { + uri: cachedStream.stream.url, + title: cachedStream.metadata?.name || currentItem.name, + episodeTitle: cachedStream.episodeTitle, + season: cachedStream.season, + episode: cachedStream.episode, + quality: (cachedStream.stream.title?.match(/(\d+)p/) || [])[1] || undefined, + year: cachedStream.metadata?.year || currentItem.year, + streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name, + streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream', + headers: cachedStream.stream.headers || undefined, + forceVlc: false, + id: currentItem.id, + type: currentItem.type, + episodeId: episodeId, + imdbId: cachedStream.imdbId || cachedStream.metadata?.imdbId || currentItem.imdb_id, + backdrop: cachedStream.metadata?.backdrop || currentItem.banner, + videoType: undefined, // Let player auto-detect + // ADD RESUME DATA if we should resume + ...(shouldResume && watchProgress && { + resumeTime: watchProgress.currentTime, + duration: watchProgress.duration + }) + } as any); + + return; + } + + // No cached stream, navigate to Streams screen with resume data + logger.info('[AppleTVHero] No cached stream, navigating to StreamsScreen for:', currentItem.name); + + const navigationParams: any = { + id: currentItem.id, + type: currentItem.type, + title: currentItem.name, + metadata: { + poster: currentItem.poster, + banner: currentItem.banner, + releaseInfo: currentItem.releaseInfo, + genres: currentItem.genres + } + }; + + // Add resume data if we have progress that's not near completion + if (shouldResume && watchProgress) { + navigationParams.resumeTime = watchProgress.currentTime; + navigationParams.duration = watchProgress.duration; + navigationParams.episodeId = watchProgress.episodeId; + logger.info('[AppleTVHero] Passing resume data to Streams:', watchProgress.currentTime, watchProgress.duration); + } + + navigation.navigate('Streams', navigationParams); + + } catch (error) { + logger.error('[AppleTVHero] Error handling play action:', error); + // Fallback to StreamsScreen on any error + navigation.navigate('Streams', { + id: currentItem.id, + type: currentItem.type, + title: currentItem.name, + metadata: { + poster: currentItem.poster, + banner: currentItem.banner, + releaseInfo: currentItem.releaseInfo, + genres: currentItem.genres + } + }); + } + }, [currentItem, navigation, setTrailerPlaying, watchProgress]); // Handle fullscreen toggle const handleFullscreenToggle = useCallback(async () => { @@ -569,33 +783,6 @@ const AppleTVHero: React.FC = ({ ); }, [currentIndex, setTrailerPlaying, trailerOpacity, thumbnailOpacity]); - // Preload next and previous images for instant swiping - useEffect(() => { - if (items.length <= 1) return; - - const prevIdx = (currentIndex - 1 + items.length) % items.length; - const nextIdx = (currentIndex + 1) % items.length; - - const prevItem = items[prevIdx]; - const nextItem = items[nextIdx]; - - const urlsToPreload: { uri: string }[] = []; - - if (prevItem) { - const url = prevItem.banner || prevItem.poster; - if (url) urlsToPreload.push({ uri: url }); - } - - if (nextItem) { - const url = nextItem.banner || nextItem.poster; - if (url) urlsToPreload.push({ uri: url }); - } - - if (urlsToPreload.length > 0) { - FastImage.preload(urlsToPreload); - } - }, [currentIndex, items]); - // Callback for updating interaction time const updateInteractionTime = useCallback(() => { lastInteractionRef.current = Date.now(); @@ -972,37 +1159,61 @@ const AppleTVHero: React.FC = ({ style={logoAnimatedStyle} > {currentItem.logo && !logoError[currentIndex] ? ( - { - const { height } = event.nativeEvent.layout; - setLogoHeights((prev) => ({ ...prev, [currentIndex]: height })); - }} - > - setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))} - onError={() => { - setLogoError((prev) => ({ ...prev, [currentIndex]: true })); - logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo); + { + if (currentItem) { + navigation.navigate('Metadata', { + id: currentItem.id, + type: currentItem.type, + }); + } }} - /> - - ) : ( - - - {currentItem.name} - - - )} - + > + { + const { height } = event.nativeEvent.layout; + setLogoHeights((prev) => ({ ...prev, [currentIndex]: height })); + }} + > + setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))} + onError={() => { + setLogoError((prev) => ({ ...prev, [currentIndex]: true })); + logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo); + }} + /> + + + ) : ( + { + if (currentItem) { + navigation.navigate('Metadata', { + id: currentItem.id, + type: currentItem.type, + }); + } + }} + > + + + {currentItem.name} + + + + )} + {/* Metadata Badge - Always Visible */} @@ -1020,21 +1231,33 @@ const AppleTVHero: React.FC = ({ - {/* Action Buttons - Always Visible */} + {/* Action Buttons - Play and Save buttons */} - {/* Info Button */} + {/* Play Button */} { - navigation.navigate('Metadata', { - id: currentItem.id, - type: currentItem.type, - }); - }} - activeOpacity={0.8} + style={[styles.playButton]} + onPress={handlePlayAction} + activeOpacity={0.85} > - - Info + + {playButtonText} + + + {/* Save Button */} + + @@ -1171,25 +1394,25 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', backgroundColor: '#fff', - paddingVertical: 14, + paddingVertical: 11, paddingHorizontal: 32, - borderRadius: 24, + borderRadius: 40, gap: 8, - minWidth: 140, + minWidth: 130, }, playButtonText: { color: '#000', fontSize: 18, fontWeight: '700', }, - secondaryButton: { - width: 48, - height: 48, - borderRadius: 24, + saveButton: { + width: 52, + height: 52, + borderRadius: 30, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center', - borderWidth: 1, + borderWidth: 1.5, borderColor: 'rgba(255,255,255,0.3)', }, paginationContainer: { diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 9d062c2..40b4251 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -240,6 +240,44 @@ const ContinueWatchingSection = React.forwardRef((props, re } }, []); + // Helper function to find the next episode + const findNextEpisode = useCallback((currentSeason: number, currentEpisode: number, videos: any[]) => { + if (!videos || !Array.isArray(videos)) return null; + + // Sort videos to ensure correct order + const sortedVideos = [...videos].sort((a, b) => { + if (a.season !== b.season) return a.season - b.season; + return a.episode - b.episode; + }); + + // Strategy 1: Look for next episode in the same season + let nextEp = sortedVideos.find(v => v.season === currentSeason && v.episode === currentEpisode + 1); + + // Strategy 2: If not found, look for the first episode of the next season + if (!nextEp) { + nextEp = sortedVideos.find(v => v.season === currentSeason + 1 && v.episode === 1); + } + + // Strategy 3: Just find the very next video in the list after the current one + // This handles cases where episode numbering isn't sequential or S+1 E1 isn't the standard start + if (!nextEp) { + const currentIndex = sortedVideos.findIndex(v => v.season === currentSeason && v.episode === currentEpisode); + if (currentIndex !== -1 && currentIndex + 1 < sortedVideos.length) { + const candidate = sortedVideos[currentIndex + 1]; + // Ensure we didn't just jump to a random special; check reasonable bounds if needed, + // but generally taking the next sorted item is correct for sequential viewing. + nextEp = candidate; + } + } + + // Verify the found episode is released + if (nextEp && isEpisodeReleased(nextEp)) { + return nextEp; + } + + return null; + }, []); + // Modified loadContinueWatching to render incrementally const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => { if (isRefreshingRef.current) { @@ -432,42 +470,42 @@ const ContinueWatchingSection = React.forwardRef((props, re const { episodeId, progress, progressPercent } = episode; if (group.type === 'series' && progressPercent >= 85) { - let nextSeason: number | undefined; - let nextEpisode: number | undefined; + // Local progress completion check if (episodeId) { + let currentSeason: number | undefined; + let currentEpisode: number | undefined; + const match = episodeId.match(/s(\d+)e(\d+)/i); if (match) { - const currentSeason = parseInt(match[1], 10); - const currentEpisode = parseInt(match[2], 10); - nextSeason = currentSeason; - nextEpisode = currentEpisode + 1; + currentSeason = parseInt(match[1], 10); + currentEpisode = parseInt(match[2], 10); } else { const parts = episodeId.split(':'); if (parts.length >= 2) { const seasonNum = parseInt(parts[parts.length - 2], 10); const episodeNum = parseInt(parts[parts.length - 1], 10); if (!isNaN(seasonNum) && !isNaN(episodeNum)) { - nextSeason = seasonNum; - nextEpisode = episodeNum + 1; + currentSeason = seasonNum; + currentEpisode = episodeNum; } } } - } - if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) { - const nextEpisodeVideo = metadata.videos.find((video: any) => - video.season === nextSeason && video.episode === nextEpisode - ); - if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) { - batch.push({ - ...basicContent, - id: group.id, - type: group.type, - progress: 0, - lastUpdated: progress.lastUpdated, - season: nextSeason, - episode: nextEpisode, - episodeTitle: `Episode ${nextEpisode}`, - } as ContinueWatchingItem); + + if (currentSeason !== undefined && currentEpisode !== undefined && metadata?.videos) { + const nextEpisodeVideo = findNextEpisode(currentSeason, currentEpisode, metadata.videos); + + if (nextEpisodeVideo) { + batch.push({ + ...basicContent, + id: group.id, + type: group.type, + progress: 0, + lastUpdated: progress.lastUpdated, + season: nextEpisodeVideo.season, + episode: nextEpisodeVideo.episode, + episodeTitle: `Episode ${nextEpisodeVideo.episode}`, + } as ContinueWatchingItem); + } } } continue; @@ -532,23 +570,18 @@ const ContinueWatchingSection = React.forwardRef((props, re // If watched on Trakt, treat it as completed (try to find next episode) if (isWatchedOnTrakt) { - let nextSeason = season; - let nextEpisode = (episodeNumber || 0) + 1; - - if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) { - const nextEpisodeVideo = metadata.videos.find((video: any) => - video.season === nextSeason && video.episode === nextEpisode - ); - if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) { + if (season !== undefined && episodeNumber !== undefined && metadata?.videos) { + const nextEpisodeVideo = findNextEpisode(season, episodeNumber, metadata.videos); + if (nextEpisodeVideo) { batch.push({ ...basicContent, id: group.id, type: group.type, progress: 0, lastUpdated: progress.lastUpdated, - season: nextSeason, - episode: nextEpisode, - episodeTitle: `Episode ${nextEpisode}`, + season: nextEpisodeVideo.season, + episode: nextEpisodeVideo.episode, + episodeTitle: `Episode ${nextEpisodeVideo.episode}`, } as ContinueWatchingItem); } } @@ -614,28 +647,25 @@ const ContinueWatchingSection = React.forwardRef((props, re continue; } - const nextEpisode = info.episode + 1; const cachedData = await getCachedMetadata('series', showId); if (!cachedData?.basicContent) continue; const { metadata, basicContent } = cachedData; - let nextEpisodeVideo = null; - if (metadata?.videos && Array.isArray(metadata.videos)) { - nextEpisodeVideo = metadata.videos.find((video: any) => - video.season === info.season && video.episode === nextEpisode - ); - } - if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) { - logger.log(`βž• [TraktSync] Adding next episode for ${showId}: S${info.season}E${nextEpisode}`); - traktBatch.push({ - ...basicContent, - id: showId, - type: 'series', - progress: 0, - lastUpdated: info.watchedAt, - season: info.season, - episode: nextEpisode, - episodeTitle: `Episode ${nextEpisode}`, - } as ContinueWatchingItem); + + if (metadata?.videos) { + const nextEpisodeVideo = findNextEpisode(info.season, info.episode, metadata.videos); + if (nextEpisodeVideo) { + logger.log(`βž• [TraktSync] Adding next episode for ${showId}: S${nextEpisodeVideo.season}E${nextEpisodeVideo.episode}`); + traktBatch.push({ + ...basicContent, + id: showId, + type: 'series', + progress: 0, + lastUpdated: info.watchedAt, + season: nextEpisodeVideo.season, + episode: nextEpisodeVideo.episode, + episodeTitle: `Episode ${nextEpisodeVideo.episode}`, + } as ContinueWatchingItem); + } } // Persist "watched" progress for the episode that Trakt reported (only if not recently removed) diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index c790a16..152ddbb 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -52,11 +52,11 @@ const SeriesContentComponent: React.FC = ({ const { settings } = useSettings(); const { width } = useWindowDimensions(); const isDarkMode = useColorScheme() === 'dark'; - + // 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'; @@ -64,13 +64,13 @@ const SeriesContentComponent: React.FC = ({ 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 for seasons section const horizontalPadding = useMemo(() => { switch (deviceType) { @@ -124,7 +124,7 @@ const SeriesContentComponent: React.FC = ({ return 16; } }, [deviceType]); - + // Enhanced season poster sizing const seasonPosterWidth = useMemo(() => { switch (deviceType) { @@ -138,7 +138,7 @@ const SeriesContentComponent: React.FC = ({ return 100; // phone } }, [deviceType]); - + const seasonPosterHeight = useMemo(() => { switch (deviceType) { case 'tv': @@ -151,7 +151,7 @@ const SeriesContentComponent: React.FC = ({ return 150; // phone } }, [deviceType]); - + const seasonButtonSpacing = useMemo(() => { switch (deviceType) { case 'tv': @@ -164,7 +164,7 @@ const SeriesContentComponent: React.FC = ({ return 16; // phone } }, [deviceType]); - + const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({}); // Delay item entering animations to avoid FlashList initial layout glitches const [enableItemAnimations, setEnableItemAnimations] = useState(false); @@ -172,14 +172,14 @@ const SeriesContentComponent: React.FC = ({ const [tmdbEpisodeOverrides, setTmdbEpisodeOverrides] = useState<{ [epKey: string]: { vote_average?: number; runtime?: number; still_path?: string } }>({}); // IMDb ratings for episodes - using a map for O(1) lookups instead of array searches const [imdbRatingsMap, setImdbRatingsMap] = useState<{ [key: string]: number }>({}); - + // Add state for season view mode (persists for current show across navigation) const [seasonViewMode, setSeasonViewMode] = useState<'posters' | 'text'>('posters'); - + // View mode state (no animations) const [posterViewVisible, setPosterViewVisible] = useState(true); const [textViewVisible, setTextViewVisible] = useState(false); - + // Add refs for the scroll views const seasonScrollViewRef = useRef(null); const episodeScrollViewRef = useRef>(null); @@ -198,7 +198,7 @@ const SeriesContentComponent: React.FC = ({ if (__DEV__) console.log('[SeriesContent] Error loading global view mode preference:', error); } }; - + loadViewModePreference(); }, []); @@ -222,17 +222,17 @@ const SeriesContentComponent: React.FC = ({ if (__DEV__) console.log('[SeriesContent] Error saving global view mode preference:', error); }); }; - + // Add refs for the scroll views - + const loadEpisodesProgress = async () => { if (!metadata?.id) return; - + const allProgress = await storageService.getAllWatchProgress(); const progress: { [key: string]: { currentTime: number; duration: number; lastUpdated: number } } = {}; - + episodes.forEach(episode => { const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`; const key = `series:${metadata.id}:${episodeId}`; @@ -244,7 +244,7 @@ const SeriesContentComponent: React.FC = ({ }; } }); - + // ---------------- Trakt watched-history integration ---------------- try { const traktService = TraktService.getInstance(); @@ -254,7 +254,7 @@ const SeriesContentComponent: React.FC = ({ // Each page has up to 100 items by default, fetch enough to cover ~12+ seasons let allHistoryItems: any[] = []; const pageLimit = 10; // Fetch up to 10 pages (max 1000 items) to cover extensive libraries - + for (let page = 1; page <= pageLimit; page++) { const historyItems = await traktService.getWatchedEpisodesHistory(page, 100); if (!historyItems || historyItems.length === 0) { @@ -295,7 +295,7 @@ const SeriesContentComponent: React.FC = ({ } catch (err) { logger.error('[SeriesContent] Failed to merge Trakt history:', err); } - + setEpisodeProgress(progress); }; @@ -304,28 +304,28 @@ const SeriesContentComponent: React.FC = ({ if (!metadata?.id || !settings?.episodeLayoutStyle || settings.episodeLayoutStyle !== 'horizontal') { return; } - + const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || []; if (currentSeasonEpisodes.length === 0) { return; } - + // Find the most recently watched episode in the current season let mostRecentEpisodeIndex = -1; let mostRecentTimestamp = 0; let mostRecentEpisodeName = ''; - + currentSeasonEpisodes.forEach((episode, index) => { const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`; const progress = episodeProgress[episodeId]; - + if (progress && progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) { mostRecentTimestamp = progress.lastUpdated; mostRecentEpisodeIndex = index; mostRecentEpisodeName = episode.name; } }); - + // Scroll to the most recently watched episode if found if (mostRecentEpisodeIndex >= 0) { setTimeout(() => { @@ -369,7 +369,7 @@ const SeriesContentComponent: React.FC = ({ } else { logger.log('[SeriesContent] metadata.id does not start with tmdb: or tt:', metadata.id); } - + if (!tmdbShowId) { logger.warn('[SeriesContent] Could not resolve TMDB show ID, skipping IMDb ratings fetch'); return; @@ -378,10 +378,10 @@ const SeriesContentComponent: React.FC = ({ logger.log('[SeriesContent] Fetching IMDb ratings for TMDB ID:', tmdbShowId); // Fetch IMDb ratings for all seasons const ratings = await tmdbService.getIMDbRatings(tmdbShowId); - + if (ratings) { logger.log('[SeriesContent] IMDb ratings fetched successfully. Seasons:', ratings.length); - + // Create a lookup map for O(1) access: key format "season:episode" -> rating const ratingsMap: { [key: string]: number } = {}; ratings.forEach(season => { @@ -394,7 +394,7 @@ const SeriesContentComponent: React.FC = ({ }); } }); - + logger.log('[SeriesContent] IMDb ratings map created with', Object.keys(ratingsMap).length, 'episodes'); setImdbRatingsMap(ratingsMap); } else { @@ -472,7 +472,7 @@ const SeriesContentComponent: React.FC = ({ return () => { // Clear any pending timeouts if (__DEV__) console.log('[SeriesContent] Component unmounted, cleaning up memory'); - + // Force garbage collection if available (development only) if (__DEV__ && global.gc) { global.gc(); @@ -486,7 +486,7 @@ const SeriesContentComponent: React.FC = ({ // Find the index of the selected season const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b); const selectedIndex = seasons.findIndex(season => season === selectedSeason); - + if (selectedIndex !== -1) { // Wait a small amount of time for layout to be ready setTimeout(() => { @@ -540,11 +540,11 @@ const SeriesContentComponent: React.FC = ({ if (!groupedEpisodes || Object.keys(groupedEpisodes).length <= 1) { return null; } - - if (__DEV__) console.log('[SeriesContent] renderSeasonSelector called, current view mode:', seasonViewMode); - + + + const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b); - + return ( = ({ ]}> Seasons - + {/* Dropdown Toggle Button */} = ({ activeOpacity={0.7} > = ({ - + >} data={seasons} @@ -618,7 +618,7 @@ const SeriesContentComponent: React.FC = ({ windowSize={3} renderItem={({ item: season }) => { const seasonEpisodes = groupedEpisodes[season] || []; - + // Get season poster URL (needed for both views) let seasonPoster = DEFAULT_PLACEHOLDER; if (seasonEpisodes[0]?.season_poster_path) { @@ -627,12 +627,12 @@ const SeriesContentComponent: React.FC = ({ } else if (metadata?.poster) { seasonPoster = metadata.poster; } - + if (seasonViewMode === 'text') { // Text-only view - if (__DEV__) console.log('[SeriesContent] Rendering text view for season:', season, 'View mode ref:', seasonViewMode); + return ( - @@ -666,11 +666,11 @@ const SeriesContentComponent: React.FC = ({ ); } - + // Poster view (current implementation) - if (__DEV__) console.log('[SeriesContent] Rendering poster view for season:', season, 'View mode ref:', seasonViewMode); + return ( - @@ -710,10 +710,10 @@ const SeriesContentComponent: React.FC = ({ )} - = ({ Season {season} - - ); - }} + + ); + }} keyExtractor={season => season.toString()} /> @@ -763,11 +763,11 @@ const SeriesContentComponent: React.FC = ({ }; let episodeImage = resolveEpisodeImage(); - + const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : ''; const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : ''; const episodeString = seasonNumber && episodeNumber ? `S${seasonNumber.padStart(2, '0')}E${episodeNumber.padStart(2, '0')}` : ''; - + const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleDateString('en-US', { @@ -795,9 +795,9 @@ const SeriesContentComponent: React.FC = ({ const tmdbRating = tmdbOverride?.vote_average ?? episode.vote_average; const effectiveVote = imdbRating ?? tmdbRating ?? 0; const isImdbRating = imdbRating !== null; - - logger.log(`[SeriesContent] Vertical card S${episode.season_number}E${episode.episode_number}: IMDb=${imdbRating}, TMDB=${tmdbRating}, effective=${effectiveVote}, isImdb=${isImdbRating}`); - + + + const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime; if (!episode.still_path && tmdbOverride?.still_path) { const tmdbUrl = tmdbService.getImageUrl(tmdbOverride.still_path, 'original'); @@ -805,7 +805,7 @@ const SeriesContentComponent: React.FC = ({ } const progress = episodeProgress[episodeId]; const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0; - + // Don't show progress bar if episode is complete (>= 85%) const showProgress = progress && progressPercent < 85; @@ -813,8 +813,8 @@ const SeriesContentComponent: React.FC = ({ = ({ {showProgress && ( - )} @@ -907,7 +907,7 @@ const SeriesContentComponent: React.FC = ({ ]}> = ({ )} - = ({ }; let episodeImage = resolveEpisodeImage(); - + const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : ''; const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : ''; const episodeString = seasonNumber && episodeNumber ? `EPISODE ${episodeNumber}` : ''; - + const formatRuntime = (runtime: number) => { if (!runtime) return null; const hours = Math.floor(runtime / 60); @@ -1066,9 +1066,7 @@ const SeriesContentComponent: React.FC = ({ const effectiveVote = imdbRating ?? tmdbRating ?? 0; const isImdbRating = imdbRating !== null; const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime; - - logger.log(`[SeriesContent] Horizontal card S${episode.season_number}E${episode.episode_number}: IMDb=${imdbRating}, TMDB=${tmdbRating}, effective=${effectiveVote}, isImdb=${isImdbRating}`); - + const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleDateString('en-US', { @@ -1077,10 +1075,10 @@ const SeriesContentComponent: React.FC = ({ year: 'numeric' }); }; - + const progress = episodeProgress[episodeId]; const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0; - + // Don't show progress bar if episode is complete (>= 85%) const showProgress = progress && progressPercent < 85; @@ -1097,7 +1095,7 @@ const SeriesContentComponent: React.FC = ({ shadowRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8 }, // Gradient border styling - { + { borderWidth: 1, borderColor: 'rgba(255,255,255,0.12)', shadowColor: '#000', @@ -1115,12 +1113,12 @@ const SeriesContentComponent: React.FC = ({ style={styles.episodeBackgroundImage} resizeMode={FastImage.resizeMode.cover} /> - + {/* Standard Gradient Overlay */} = ({ marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6 } ]}> - {episodeString} + {episodeString} - + {/* Episode Title */} = ({ ]} numberOfLines={2}> {episode.name} - + {/* Episode Description */} - = ({ ]} numberOfLines={isLargeScreen ? 4 : 3}> {(episode.overview || (episode as any).description || (episode as any).plot || (episode as any).synopsis || 'No description available')} - + {/* Metadata Row */} = ({ )} - + {/* Progress Bar */} {showProgress && ( - )} - + {/* Completed Badge */} {progressPercent >= 85 && ( = ({ opacity: 0.9, }} /> )} - + ); @@ -1314,13 +1312,13 @@ const SeriesContentComponent: React.FC = ({ return ( - {renderSeasonSelector()} - - = ({ ]}> {currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'} - + {/* Show message when no episodes are available for selected season */} {currentSeasonEpisodes.length === 0 && ( @@ -1347,7 +1345,7 @@ const SeriesContentComponent: React.FC = ({ )} - + {/* Only render episode list if there are episodes */} {currentSeasonEpisodes.length > 0 && ( (settings?.episodeLayoutStyle === 'horizontal') ? ( @@ -1417,7 +1415,7 @@ const SeriesContentComponent: React.FC = ({ ref={episodeScrollViewRef} data={currentSeasonEpisodes} renderItem={({ item: episode, index }) => ( - {renderVerticalEpisodeCard(episode)} @@ -1474,7 +1472,7 @@ const styles = StyleSheet.create({ episodeList: { flex: 1, }, - + // Vertical Layout Styles episodeListContentVertical: { paddingBottom: 8, diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 604da70..7e43073 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, Image } from 'react-native'; +import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, Image, InteractionManager } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType, ViewType } from 'react-native-video'; import FastImage from '@d11/react-native-fast-image'; @@ -641,43 +641,51 @@ const AndroidVideoPlayer: React.FC = () => { // Prefetch backdrop and title logo for faster loading screen appearance useEffect(() => { - if (backdrop && typeof backdrop === 'string') { - // Reset loading state - setIsBackdropLoaded(false); - backdropImageOpacityAnim.setValue(0); + // Defer prefetching until after navigation animation completes + const task = InteractionManager.runAfterInteractions(() => { + if (backdrop && typeof backdrop === 'string') { + // Reset loading state + setIsBackdropLoaded(false); + backdropImageOpacityAnim.setValue(0); - // Prefetch the image - try { - FastImage.preload([{ uri: backdrop }]); - // Image prefetch initiated, fade it in smoothly + // Prefetch the image + try { + FastImage.preload([{ uri: backdrop }]); + // Image prefetch initiated, fade it in smoothly + setIsBackdropLoaded(true); + Animated.timing(backdropImageOpacityAnim, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }).start(); + } catch (error) { + // If prefetch fails, still show the image but without animation + if (__DEV__) logger.warn('[AndroidVideoPlayer] Backdrop prefetch failed, showing anyway:', error); + setIsBackdropLoaded(true); + backdropImageOpacityAnim.setValue(1); + } + } else { + // No backdrop provided, consider it "loaded" setIsBackdropLoaded(true); - Animated.timing(backdropImageOpacityAnim, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }).start(); - } catch (error) { - // If prefetch fails, still show the image but without animation - if (__DEV__) logger.warn('[AndroidVideoPlayer] Backdrop prefetch failed, showing anyway:', error); - setIsBackdropLoaded(true); - backdropImageOpacityAnim.setValue(1); + backdropImageOpacityAnim.setValue(0); } - } else { - // No backdrop provided, consider it "loaded" - setIsBackdropLoaded(true); - backdropImageOpacityAnim.setValue(0); - } + }); + return () => task.cancel(); }, [backdrop]); useEffect(() => { - const logoUrl = (metadata && (metadata as any).logo) as string | undefined; - if (logoUrl && typeof logoUrl === 'string') { - try { - FastImage.preload([{ uri: logoUrl }]); - } catch (error) { - // Silently ignore logo prefetch errors + // Defer logo prefetch until after navigation animation + const task = InteractionManager.runAfterInteractions(() => { + const logoUrl = (metadata && (metadata as any).logo) as string | undefined; + if (logoUrl && typeof logoUrl === 'string') { + try { + FastImage.preload([{ uri: logoUrl }]); + } catch (error) { + // Silently ignore logo prefetch errors + } } - } + }); + return () => task.cancel(); }, [metadata]); // Resolve current episode description for series diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 95a73ee..ede1699 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState } from 'react-native'; +import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, InteractionManager } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; import FastImage from '@d11/react-native-fast-image'; @@ -342,43 +342,51 @@ const KSPlayerCore: React.FC = () => { // Load custom backdrop on mount // Prefetch backdrop and title logo for faster loading screen appearance useEffect(() => { - if (backdrop && typeof backdrop === 'string') { - // Reset loading state - setIsBackdropLoaded(false); - backdropImageOpacityAnim.setValue(0); + // Defer prefetching until after navigation animation completes + const task = InteractionManager.runAfterInteractions(() => { + if (backdrop && typeof backdrop === 'string') { + // Reset loading state + setIsBackdropLoaded(false); + backdropImageOpacityAnim.setValue(0); - // Prefetch the image - try { - FastImage.preload([{ uri: backdrop }]); - // Image prefetch initiated, fade it in smoothly + // Prefetch the image + try { + FastImage.preload([{ uri: backdrop }]); + // Image prefetch initiated, fade it in smoothly + setIsBackdropLoaded(true); + Animated.timing(backdropImageOpacityAnim, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }).start(); + } catch (error) { + // If prefetch fails, still show the image but without animation + if (__DEV__) logger.warn('[VideoPlayer] Backdrop prefetch failed, showing anyway:', error); + setIsBackdropLoaded(true); + backdropImageOpacityAnim.setValue(1); + } + } else { + // No backdrop provided, consider it "loaded" setIsBackdropLoaded(true); - Animated.timing(backdropImageOpacityAnim, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }).start(); - } catch (error) { - // If prefetch fails, still show the image but without animation - if (__DEV__) logger.warn('[VideoPlayer] Backdrop prefetch failed, showing anyway:', error); - setIsBackdropLoaded(true); - backdropImageOpacityAnim.setValue(1); + backdropImageOpacityAnim.setValue(0); } - } else { - // No backdrop provided, consider it "loaded" - setIsBackdropLoaded(true); - backdropImageOpacityAnim.setValue(0); - } + }); + return () => task.cancel(); }, [backdrop]); useEffect(() => { - const logoUrl = (metadata && (metadata as any).logo) as string | undefined; - if (logoUrl && typeof logoUrl === 'string') { - try { - FastImage.preload([{ uri: logoUrl }]); - } catch (error) { - // Silently ignore logo prefetch errors + // Defer logo prefetch until after navigation animation + const task = InteractionManager.runAfterInteractions(() => { + const logoUrl = (metadata && (metadata as any).logo) as string | undefined; + if (logoUrl && typeof logoUrl === 'string') { + try { + FastImage.preload([{ uri: logoUrl }]); + } catch (error) { + // Silently ignore logo prefetch errors + } } - } + }); + return () => task.cancel(); }, [metadata]); // Log video source configuration with headers diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 7addb62..c0d97c2 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -87,6 +87,7 @@ export interface AppSettings { openMetadataScreenWhenCacheDisabled: boolean; // When cache disabled, open MetadataScreen instead of StreamsScreen streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour) enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile + useExternalPlayerForDownloads: boolean; // Enable/disable external player for downloaded content } export const DEFAULT_SETTINGS: AppSettings = { @@ -122,6 +123,7 @@ export const DEFAULT_SETTINGS: AppSettings = { alwaysResume: true, // Downloads enableDownloads: false, + useExternalPlayerForDownloads: false, // Theme defaults themeId: 'default', customThemes: [], @@ -162,12 +164,12 @@ export const useSettings = () => { useEffect(() => { loadSettings(); - + // Subscribe to settings changes const unsubscribe = settingsEmitter.addListener(() => { loadSettings(); }); - + return unsubscribe; }, []); @@ -183,13 +185,13 @@ export const useSettings = () => { const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`; - + // Use synchronous MMKV reads for better performance const [scopedJson, legacyJson] = await Promise.all([ mmkvStorage.getItem(scopedKey), mmkvStorage.getItem(SETTINGS_STORAGE_KEY), ]); - + const parsedScoped = scopedJson ? JSON.parse(scopedJson) : null; const parsedLegacy = legacyJson ? JSON.parse(legacyJson) : null; @@ -202,16 +204,16 @@ export const useSettings = () => { if (scoped) { try { merged = JSON.parse(scoped); - } catch {} + } catch { } } } const finalSettings = merged ? { ...DEFAULT_SETTINGS, ...merged } : DEFAULT_SETTINGS; - + // Update cache cachedSettings = finalSettings; settingsCacheTimestamp = now; - + setSettings(finalSettings); } catch (error) { if (__DEV__) console.error('Failed to load settings:', error); @@ -231,23 +233,23 @@ export const useSettings = () => { ) => { const newSettings = { ...settings, [key]: value }; try { - const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; - const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`; - // Write to both scoped key (multi-user aware) and legacy key for backward compatibility - await Promise.all([ - mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)), - mmkvStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings)), - ]); - // Ensure a current scope exists to avoid future loads missing the chosen scope - await mmkvStorage.setItem('@user:current', scope); - + const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; + const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`; + // Write to both scoped key (multi-user aware) and legacy key for backward compatibility + await Promise.all([ + mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)), + mmkvStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings)), + ]); + // Ensure a current scope exists to avoid future loads missing the chosen scope + await mmkvStorage.setItem('@user:current', scope); + // Update cache cachedSettings = newSettings; settingsCacheTimestamp = Date.now(); - + setSettings(newSettings); if (__DEV__) console.log(`Setting updated: ${key}`, value); - + // Notify all subscribers that settings have changed (if requested) if (emitEvent) { if (__DEV__) console.log('Emitting settings change event'); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 1156633..142e53a 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -1210,7 +1210,7 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta options={{ animation: 'default', animationDuration: 0, - // Force fullscreen presentation on iPad + // fullScreenModal required for proper video rendering on iOS presentation: 'fullScreenModal', // Disable gestures during video playback gestureEnabled: false, diff --git a/src/screens/DownloadsScreen.tsx b/src/screens/DownloadsScreen.tsx index 9873673..96710d3 100644 --- a/src/screens/DownloadsScreen.tsx +++ b/src/screens/DownloadsScreen.tsx @@ -11,6 +11,7 @@ import { Alert, Platform, Clipboard, + Linking, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; @@ -28,9 +29,12 @@ import { RootStackParamList } from '../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; import FastImage from '@d11/react-native-fast-image'; import { useDownloads } from '../contexts/DownloadsContext'; +import { useSettings } from '../hooks/useSettings'; +import { VideoPlayerService } from '../services/videoPlayerService'; import type { DownloadItem } from '../contexts/DownloadsContext'; import { useToast } from '../contexts/ToastContext'; import CustomAlert from '../components/CustomAlert'; +import ScreenHeader from '../components/common/ScreenHeader'; const { height, width } = Dimensions.get('window'); const isTablet = width >= 768; @@ -60,7 +64,7 @@ const optimizePosterUrl = (poster: string | undefined | null): string => { // Empty state component const EmptyDownloadsState: React.FC<{ navigation: NavigationProp }> = ({ navigation }) => { const { currentTheme } = useTheme(); - + return ( @@ -76,7 +80,7 @@ const EmptyDownloadsState: React.FC<{ navigation: NavigationProp Downloaded content will appear here for offline viewing - { navigation.navigate('Search'); @@ -129,12 +133,12 @@ const DownloadItemComponent: React.FC<{ const formatBytes = (bytes?: number) => { if (!bytes || bytes <= 0) return '0 B'; - const sizes = ['B','KB','MB','GB','TB']; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); const v = bytes / Math.pow(1024, i); return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${sizes[i]}`; }; - + const getStatusColor = () => { switch (item.status) { case 'downloading': @@ -218,10 +222,10 @@ const DownloadItemComponent: React.FC<{ - {item.title}{item.type === 'series' && item.season && item.episode ? ` S${String(item.season).padStart(2,'0')}E${String(item.episode).padStart(2,'0')}` : ''} + {item.title}{item.type === 'series' && item.season && item.episode ? ` S${String(item.season).padStart(2, '0')}E${String(item.episode).padStart(2, '0')}` : ''} - + {item.type === 'series' && ( S{item.season?.toString().padStart(2, '0')}E{item.episode?.toString().padStart(2, '0')} β€’ {item.episodeTitle} @@ -293,7 +297,7 @@ const DownloadItemComponent: React.FC<{ ]} /> - + {item.progress || 0}% @@ -322,7 +326,7 @@ const DownloadItemComponent: React.FC<{ /> )} - + onRequestRemove(item)} @@ -342,7 +346,7 @@ const DownloadItemComponent: React.FC<{ const DownloadsScreen: React.FC = () => { const navigation = useNavigation>(); const { currentTheme } = useTheme(); - const { top: safeAreaTop } = useSafeAreaInsets(); + const { settings } = useSettings(); const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads(); const { showSuccess, showInfo } = useToast(); @@ -352,9 +356,6 @@ const DownloadsScreen: React.FC = () => { const [showRemoveAlert, setShowRemoveAlert] = useState(false); const [pendingRemoveItem, setPendingRemoveItem] = useState(null); - // Animation values - const headerOpacity = useSharedValue(1); - // Filter downloads based on selected filter const filteredDownloads = useMemo(() => { if (selectedFilter === 'all') return downloads; @@ -394,7 +395,7 @@ const DownloadsScreen: React.FC = () => { setIsRefreshing(false); }, []); - const handleDownloadPress = useCallback((item: DownloadItem) => { + const handleDownloadPress = useCallback(async (item: DownloadItem) => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); if (item.status !== 'completed') { Alert.alert('Download not ready', 'Please wait until the download completes.'); @@ -411,33 +412,132 @@ const DownloadsScreen: React.FC = () => { const isMp4 = /\.mp4(\?|$)/i.test(lower); const videoType = isM3u8 ? 'm3u8' : isMpd ? 'mpd' : isMp4 ? 'mp4' : undefined; - // Build episodeId for series progress tracking (format: contentId:season:episode) - const episodeId = item.type === 'series' && item.season && item.episode - ? `${item.contentId}:${item.season}:${item.episode}` - : undefined; + // Use external player if enabled in settings + if (settings.useExternalPlayerForDownloads) { + if (Platform.OS === 'android') { + try { + // Use VideoPlayerService for Android external playback + const success = await VideoPlayerService.playVideo(uri, { + useExternalPlayer: true, + title: item.title, + episodeTitle: item.type === 'series' ? item.episodeTitle : undefined, + episodeNumber: item.type === 'series' && item.season && item.episode ? `S${item.season}E${item.episode}` : undefined, + }); - const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; - navigation.navigate(playerRoute as any, { - uri, - title: item.title, - episodeTitle: item.type === 'series' ? item.episodeTitle : undefined, - season: item.type === 'series' ? item.season : undefined, - episode: item.type === 'series' ? item.episode : undefined, - quality: item.quality, - year: undefined, - streamProvider: 'Downloads', - streamName: item.providerName || 'Offline', - headers: undefined, - forceVlc: Platform.OS === 'android' ? isMkv : false, - id: item.contentId, // Use contentId (base ID) instead of compound id for progress tracking - type: item.type, - episodeId: episodeId, // Pass episodeId for series progress tracking - imdbId: (item as any).imdbId || item.contentId, // Use imdbId if available, fallback to contentId - availableStreams: {}, - backdrop: undefined, - videoType, - } as any); - }, [navigation]); + if (success) return; + // Fall through to internal player if external fails + } catch (error) { + console.error('External player failed:', error); + // Fall through to internal player + } + } else if (Platform.OS === 'ios') { + const streamUrl = encodeURIComponent(uri); + let externalPlayerUrls: string[] = []; + + switch (settings.preferredPlayer) { + case 'vlc': + externalPlayerUrls = [ + `vlc://${uri}`, + `vlc-x-callback://x-callback-url/stream?url=${streamUrl}`, + `vlc://${streamUrl}` + ]; + break; + + case 'outplayer': + externalPlayerUrls = [ + `outplayer://${uri}`, + `outplayer://${streamUrl}`, + `outplayer://play?url=${streamUrl}`, + `outplayer://stream?url=${streamUrl}`, + `outplayer://play/browser?url=${streamUrl}` + ]; + break; + + case 'infuse': + externalPlayerUrls = [ + `infuse://x-callback-url/play?url=${streamUrl}`, + `infuse://play?url=${streamUrl}`, + `infuse://${streamUrl}` + ]; + break; + + case 'vidhub': + externalPlayerUrls = [ + `vidhub://play?url=${streamUrl}`, + `vidhub://${streamUrl}` + ]; + break; + + case 'infuse_livecontainer': + const infuseUrls = [ + `infuse://x-callback-url/play?url=${streamUrl}`, + `infuse://play?url=${streamUrl}`, + `infuse://${streamUrl}` + ]; + externalPlayerUrls = infuseUrls.map(infuseUrl => { + const encoded = Buffer.from(infuseUrl).toString('base64'); + return `livecontainer://open-url?url=${encoded}`; + }); + break; + + default: + // Internal logic will handle 'internal' choice + break; + } + + if (settings.preferredPlayer !== 'internal') { + // Try each URL format in sequence + const tryNextUrl = (index: number) => { + if (index >= externalPlayerUrls.length) { + // Fallback to internal player if all external attempts fail + openInternalPlayer(); + return; + } + + const url = externalPlayerUrls[index]; + Linking.openURL(url) + .catch(() => tryNextUrl(index + 1)); + }; + + if (externalPlayerUrls.length > 0) { + tryNextUrl(0); + return; + } + } + } + } + + const openInternalPlayer = () => { + // Build episodeId for series progress tracking (format: contentId:season:episode) + const episodeId = item.type === 'series' && item.season && item.episode + ? `${item.contentId}:${item.season}:${item.episode}` + : undefined; + + const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; + navigation.navigate(playerRoute as any, { + uri, + title: item.title, + episodeTitle: item.type === 'series' ? item.episodeTitle : undefined, + season: item.type === 'series' ? item.season : undefined, + episode: item.type === 'series' ? item.episode : undefined, + quality: item.quality, + year: undefined, + streamProvider: 'Downloads', + streamName: item.providerName || 'Offline', + headers: undefined, + forceVlc: Platform.OS === 'android' ? isMkv : false, + id: item.contentId, // Use contentId (base ID) instead of compound id for progress tracking + type: item.type, + episodeId: episodeId, // Pass episodeId for series progress tracking + imdbId: (item as any).imdbId || item.contentId, // Use imdbId if available, fallback to contentId + availableStreams: {}, + backdrop: undefined, + videoType, + } as any); + }; + + openInternalPlayer(); + }, [navigation, settings]); const handleDownloadAction = useCallback((item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => { if (action === 'pause') pauseDownload(item.id); @@ -468,19 +568,14 @@ const DownloadsScreen: React.FC = () => { }, []) ); - // Animated styles - const headerStyle = useAnimatedStyle(() => ({ - opacity: headerOpacity.value, - })); - const renderFilterButton = (filter: typeof selectedFilter, label: string, count: number) => ( { @@ -501,16 +596,16 @@ const DownloadsScreen: React.FC = () => { @@ -529,22 +624,10 @@ const DownloadsScreen: React.FC = () => { backgroundColor="transparent" /> - {/* Header */} - - - - Downloads - + {/* ScreenHeader Component */} + { color={currentTheme.colors.mediumEmphasis} /> - - + } + isTablet={isTablet} + > {downloads.length > 0 && ( {renderFilterButton('all', 'All', stats.total)} @@ -566,7 +650,7 @@ const DownloadsScreen: React.FC = () => { {renderFilterButton('paused', 'Paused', stats.paused)} )} - + {/* Content */} {downloads.length === 0 ? ( @@ -624,10 +708,10 @@ const DownloadsScreen: React.FC = () => { setShowRemoveAlert(false) }, - { label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: { } }, + { label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: {} }, ]} onClose={() => { setShowRemoveAlert(false); setPendingRemoveItem(null); }} /> @@ -639,23 +723,6 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - header: { - paddingHorizontal: isTablet ? 24 : Math.max(1, width * 0.05), - paddingBottom: isTablet ? 20 : 16, - borderBottomWidth: StyleSheet.hairlineWidth, - }, - headerTitleRow: { - flexDirection: 'row', - alignItems: 'flex-end', - justifyContent: 'space-between', - marginBottom: isTablet ? 20 : 16, - paddingBottom: 8, - }, - headerTitle: { - fontSize: isTablet ? 36 : Math.min(32, width * 0.08), - fontWeight: '800', - letterSpacing: 0.3, - }, helpButton: { padding: 8, marginLeft: 8, diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 71f048d..8b0e8e7 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -4,6 +4,7 @@ import { Share } from 'react-native'; import { mmkvStorage } from '../services/mmkvStorage'; import { useToast } from '../contexts/ToastContext'; import DropUpMenu from '../components/home/DropUpMenu'; +import ScreenHeader from '../components/common/ScreenHeader'; import { View, Text, @@ -217,7 +218,7 @@ const LibraryScreen = () => { const [selectedItem, setSelectedItem] = useState(null); const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); - const { settings } = useSettings(); // ADD THIS + const { settings } = useSettings(); // Trakt integration const { @@ -760,15 +761,15 @@ const LibraryScreen = () => { // Show collection folders return ( - renderTraktCollectionFolder({ folder: item })} keyExtractor={item => item.id} numColumns={numColumns} contentContainerStyle={styles.listContainer} showsVerticalScrollIndicator={false} - onEndReachedThreshold={0.7} - onEndReached={() => {}} + onEndReachedThreshold={0.7} + onEndReached={() => { }} /> ); } @@ -811,7 +812,7 @@ const LibraryScreen = () => { contentContainerStyle={{ paddingBottom: insets.bottom + 80 }} showsVerticalScrollIndicator={false} onEndReachedThreshold={0.7} - onEndReached={() => {}} + onEndReached={() => { }} /> ); }; @@ -910,21 +911,16 @@ const LibraryScreen = () => { contentContainerStyle={styles.listContainer} showsVerticalScrollIndicator={false} onEndReachedThreshold={0.7} - onEndReached={() => {}} + onEndReached={() => { }} /> ); }; - const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; // Tablet detection aligned with navigation tablet logic const isTablet = useMemo(() => { const smallestDimension = Math.min(width, height); return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768); }, [width, height]); - // Keep header below floating top navigator on tablets - const tabletNavOffset = isTablet ? 64 : 0; - const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset; - const headerHeight = headerBaseHeight + topSpacing; return ( @@ -993,9 +989,18 @@ const LibraryScreen = () => { )} - {showTraktContent ? renderTraktContent() : renderContent()} + {/* Content Container */} + + {!showTraktContent && ( + + {renderFilter('trakt', 'Trakt', 'pan-tool')} + {renderFilter('movies', 'Movies', 'movie')} + {renderFilter('series', 'TV Shows', 'live-tv')} - + )} + + {showTraktContent ? renderTraktContent() : renderContent()} + {/* DropUpMenu integration */} {selectedItem && ( @@ -1009,45 +1014,45 @@ const LibraryScreen = () => { if (!selectedItem) return; switch (option) { case 'library': { - try { - await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); - showInfo('Removed from Library', 'Item removed from your library'); - setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type))); - setMenuVisible(false); - } catch (error) { - showError('Failed to update Library', 'Unable to remove item from library'); - } - break; + try { + await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); + showInfo('Removed from Library', 'Item removed from your library'); + setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type))); + setMenuVisible(false); + } catch (error) { + showError('Failed to update Library', 'Unable to remove item from library'); + } + break; } case 'watched': { - try { - // Use AsyncStorage to store watched status by key - const key = `watched:${selectedItem.type}:${selectedItem.id}`; - const newWatched = !selectedItem.watched; - await mmkvStorage.setItem(key, newWatched ? 'true' : 'false'); - showInfo(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', newWatched ? 'Item marked as watched' : 'Item marked as unwatched'); - // Instantly update local state - setLibraryItems(prev => prev.map(item => - item.id === selectedItem.id && item.type === selectedItem.type - ? { ...item, watched: newWatched } - : item - )); - } catch (error) { - showError('Failed to update watched status', 'Unable to update watched status'); - } - break; + try { + // Use AsyncStorage to store watched status by key + const key = `watched:${selectedItem.type}:${selectedItem.id}`; + const newWatched = !selectedItem.watched; + await mmkvStorage.setItem(key, newWatched ? 'true' : 'false'); + showInfo(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', newWatched ? 'Item marked as watched' : 'Item marked as unwatched'); + // Instantly update local state + setLibraryItems(prev => prev.map(item => + item.id === selectedItem.id && item.type === selectedItem.type + ? { ...item, watched: newWatched } + : item + )); + } catch (error) { + showError('Failed to update watched status', 'Unable to update watched status'); + } + break; } case 'share': { - let url = ''; - if (selectedItem.id) { - url = `https://www.imdb.com/title/${selectedItem.id}/`; - } - const message = `${selectedItem.name}\n${url}`; - Share.share({ message, url, title: selectedItem.name }); - break; + let url = ''; + if (selectedItem.id) { + url = `https://www.imdb.com/title/${selectedItem.id}/`; + } + const message = `${selectedItem.name}\n${url}`; + Share.share({ message, url, title: selectedItem.name }); + break; } default: - break; + break; } }} /> @@ -1060,13 +1065,6 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - headerBackground: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - zIndex: 1, - }, watchedIndicator: { position: 'absolute', top: 8, @@ -1078,23 +1076,6 @@ const styles = StyleSheet.create({ contentContainer: { flex: 1, }, - header: { - paddingHorizontal: 20, - justifyContent: 'flex-end', - paddingBottom: 8, - backgroundColor: 'transparent', - zIndex: 2, - }, - headerContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - headerTitle: { - fontSize: 32, - fontWeight: '800', - letterSpacing: 0.5, - }, filtersContainer: { flexDirection: 'row', justifyContent: 'center', @@ -1148,7 +1129,7 @@ const styles = StyleSheet.create({ borderRadius: 12, overflow: 'hidden', backgroundColor: 'rgba(255,255,255,0.03)', - aspectRatio: 2/3, + aspectRatio: 2 / 3, elevation: 5, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, @@ -1271,7 +1252,7 @@ const styles = StyleSheet.create({ borderRadius: 8, overflow: 'hidden', backgroundColor: 'rgba(255,255,255,0.03)', - aspectRatio: 2/3, + aspectRatio: 2 / 3, elevation: 5, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, diff --git a/src/screens/PlayerSettingsScreen.tsx b/src/screens/PlayerSettingsScreen.tsx index 821791f..a3e57cb 100644 --- a/src/screens/PlayerSettingsScreen.tsx +++ b/src/screens/PlayerSettingsScreen.tsx @@ -35,7 +35,7 @@ const SettingItem: React.FC = ({ isLast, }) => { const { currentTheme } = useTheme(); - + return ( { Settings - + {/* Empty for now, but ready for future actions */} - + Video Player - @@ -229,7 +229,7 @@ const PlayerSettingsScreen: React.FC = () => { ))} - + { /> + + {/* External Player for Downloads */} + {((Platform.OS === 'android' && settings.useExternalPlayer) || + (Platform.OS === 'ios' && settings.preferredPlayer !== 'internal')) && ( + + + + + + + + External Player for Downloads + + + Play downloaded content in your preferred external player. + + + updateSetting('useExternalPlayerForDownloads', value)} + thumbColor={settings.useExternalPlayerForDownloads ? currentTheme.colors.primary : undefined} + /> + + + )} diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 04b74d8..13d9a5c 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -27,11 +27,11 @@ import debounce from 'lodash/debounce'; import { DropUpMenu } from '../components/home/DropUpMenu'; import { DeviceEventEmitter, Share } from 'react-native'; import { mmkvStorage } from '../services/mmkvStorage'; -import Animated, { - FadeIn, - FadeOut, - useAnimatedStyle, - useSharedValue, +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, + useSharedValue, withTiming, interpolate, withSpring, @@ -43,6 +43,7 @@ import { BlurView } from 'expo-blur'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; import LoadingSpinner from '../components/common/LoadingSpinner'; +import ScreenHeader from '../components/common/ScreenHeader'; const { width, height } = Dimensions.get('window'); @@ -110,21 +111,21 @@ const SkeletonLoader = () => { const renderSkeletonItem = () => ( @@ -138,7 +139,7 @@ const SkeletonLoader = () => { {index === 0 && ( )} @@ -157,7 +158,7 @@ const SimpleSearchAnimation = () => { const spinAnim = React.useRef(new RNAnimated.Value(0)).current; const fadeAnim = React.useRef(new RNAnimated.Value(0)).current; const { currentTheme } = useTheme(); - + React.useEffect(() => { // Rotation animation const spin = RNAnimated.loop( @@ -168,32 +169,32 @@ const SimpleSearchAnimation = () => { useNativeDriver: true, }) ); - + // Fade animation const fade = RNAnimated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true, }); - + // Start animations spin.start(); fade.start(); - + // Clean up return () => { spin.stop(); }; }, [spinAnim, fadeAnim]); - + // Simple rotation interpolation const spin = spinAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'], }); - + return ( - { styles.spinnerContainer, { transform: [{ rotate: spin }], backgroundColor: currentTheme.colors.primary } ]}> - Searching @@ -268,9 +269,9 @@ const SearchScreen = () => { StatusBar.setBackgroundColor('transparent'); } }; - + applyStatusBarConfig(); - + // Re-apply on focus const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); return unsubscribe; @@ -284,7 +285,7 @@ const SearchScreen = () => { useEffect(() => { loadRecentSearches(); - + // Cleanup function to cancel pending searches on unmount return () => { debouncedSearch.cancel(); @@ -302,12 +303,12 @@ const SearchScreen = () => { return { opacity: backButtonOpacity.value, transform: [ - { + { translateX: interpolate( backButtonOpacity.value, [0, 1], [-20, 0] - ) + ) } ] }; @@ -361,14 +362,14 @@ const SearchScreen = () => { const saveRecentSearch = async (searchQuery: string) => { try { setRecentSearches(prevSearches => { - const newRecentSearches = [ - searchQuery, + const newRecentSearches = [ + searchQuery, ...prevSearches.filter(s => s !== searchQuery) - ].slice(0, MAX_RECENT_SEARCHES); - + ].slice(0, MAX_RECENT_SEARCHES); + // Save to AsyncStorage mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); - + return newRecentSearches; }); } catch (error) { @@ -400,7 +401,7 @@ const SearchScreen = () => { const rank: Record = {}; addons.forEach((a, idx) => { rank[a.id] = idx; }); addonOrderRankRef.current = rank; - } catch {} + } catch { } const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => { // Append/update this addon section immediately with minimal changes @@ -444,7 +445,7 @@ const SearchScreen = () => { // Save to recents after first result batch try { await saveRecentSearch(searchQuery); - } catch {} + } catch { } }); liveSearchHandle.current = handle; }, 800); @@ -502,7 +503,7 @@ const SearchScreen = () => { if (!showRecent || recentSearches.length === 0) return null; return ( - @@ -586,10 +587,10 @@ const SearchScreen = () => { entering={FadeIn.duration(300).delay(index * 50)} activeOpacity={0.7} > - + }]}> { /> {/* Bookmark and watched icons top right, bookmark to the left of watched */} {inLibrary && ( - + )} {watched && ( - + )} {item.imdbRating && ( - + {item.imdbRating} )} - { {item.name} {item.year && ( - + {item.year} )} ); }; - + const hasResultsToShow = useMemo(() => { return results.byAddon.length > 0; }, [results]); // Memoized addon section to prevent re-rendering unchanged sections - const AddonSection = React.memo(({ - addonGroup, - addonIndex - }: { - addonGroup: AddonSearchResults; + const AddonSection = React.memo(({ + addonGroup, + addonIndex + }: { + addonGroup: AddonSearchResults; addonIndex: number; }) => { - const movieResults = useMemo(() => - addonGroup.results.filter(item => item.type === 'movie'), + const movieResults = useMemo(() => + addonGroup.results.filter(item => item.type === 'movie'), [addonGroup.results] ); - const seriesResults = useMemo(() => - addonGroup.results.filter(item => item.type === 'series'), + const seriesResults = useMemo(() => + addonGroup.results.filter(item => item.type === 'series'), [addonGroup.results] ); - const otherResults = useMemo(() => - addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'), + const otherResults = useMemo(() => + addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'), [addonGroup.results] ); @@ -679,15 +680,15 @@ const SearchScreen = () => { {/* Movies */} {movieResults.length > 0 && ( - + Movies ({movieResults.length}) { {/* TV Shows */} {seriesResults.length > 0 && ( - + TV Shows ({seriesResults.length}) { {/* Other types */} {otherResults.length > 0 && ( - + {otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length}) { return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex; }); - const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; - // Keep header below floating top navigator on tablets by adding extra offset - const tabletNavOffset = (isTV || isLargeTablet || isTablet) ? 64 : 0; - const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset; - const headerHeight = headerBaseHeight + topSpacing + 60; - // Set up listeners for watched status and library updates // These will trigger re-renders in individual SearchResultItem components useEffect(() => { @@ -809,11 +804,11 @@ const SearchScreen = () => { }, []); return ( - @@ -822,172 +817,170 @@ const SearchScreen = () => { backgroundColor="transparent" translucent /> - {/* Fixed position header background to prevent shifts */} - - - {/* Header Section with proper top spacing */} - - Search - + + {/* ScreenHeader Component */} + + {/* Search Bar */} + + - - - - {query.length > 0 && ( - - - - )} - + + + {query.length > 0 && ( + + + + )} - {/* Content Container */} - - {searching ? ( - - + + {/* Content Container */} + + {searching ? ( + + + + ) : query.trim().length === 1 ? ( + + + + Keep typing... + + + Type at least 2 characters to search + + + ) : searched && !hasResultsToShow ? ( + + + + No results found + + + Try different keywords or check your spelling + + + ) : ( + + {!query.trim() && renderRecentSearches()} + {/* Render results grouped by addon using memoized component */} + {results.byAddon.map((addonGroup, addonIndex) => ( + - - ) : query.trim().length === 1 ? ( - - - - Keep typing... - - - Type at least 2 characters to search - - - ) : searched && !hasResultsToShow ? ( - - - - No results found - - - Try different keywords or check your spelling - - - ) : ( - - {!query.trim() && renderRecentSearches()} - {/* Render results grouped by addon using memoized component */} - {results.byAddon.map((addonGroup, addonIndex) => ( - - ))} - - )} - - {/* DropUpMenu integration for search results */} - {selectedItem && ( - setMenuVisible(false)} - item={selectedItem} - isSaved={isSaved} - isWatched={isWatched} - onOptionSelect={async (option: string) => { - if (!selectedItem) return; - switch (option) { - case 'share': { - let url = ''; - if (selectedItem.id) { - url = `https://www.imdb.com/title/${selectedItem.id}/`; - } - const message = `${selectedItem.name}\n${url}`; - Share.share({ message, url, title: selectedItem.name }); - break; - } - case 'library': { - if (isSaved) { - await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); - setIsSaved(false); - } else { - await catalogService.addToLibrary(selectedItem); - setIsSaved(true); - } - break; - } - case 'watched': { - const key = `watched:${selectedItem.type}:${selectedItem.id}`; - const newWatched = !isWatched; - await mmkvStorage.setItem(key, newWatched ? 'true' : 'false'); - setIsWatched(newWatched); - break; - } - default: - break; - } - }} - /> + ))} + )} + {/* DropUpMenu integration for search results */} + {selectedItem && ( + setMenuVisible(false)} + item={selectedItem} + isSaved={isSaved} + isWatched={isWatched} + onOptionSelect={async (option: string) => { + if (!selectedItem) return; + switch (option) { + case 'share': { + let url = ''; + if (selectedItem.id) { + url = `https://www.imdb.com/title/${selectedItem.id}/`; + } + const message = `${selectedItem.name}\n${url}`; + Share.share({ message, url, title: selectedItem.name }); + break; + } + case 'library': { + if (isSaved) { + await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); + setIsSaved(false); + } else { + await catalogService.addToLibrary(selectedItem); + setIsSaved(true); + } + break; + } + case 'watched': { + const key = `watched:${selectedItem.type}:${selectedItem.id}`; + const newWatched = !isWatched; + await mmkvStorage.setItem(key, newWatched ? 'true' : 'false'); + setIsWatched(newWatched); + break; + } + default: + break; + } + }} + /> + )} ); }; @@ -996,30 +989,10 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - headerBackground: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - zIndex: 1, - }, contentContainer: { flex: 1, paddingTop: 0, }, - header: { - paddingHorizontal: 15, - justifyContent: 'flex-end', - paddingBottom: 0, - backgroundColor: 'transparent', - zIndex: 2, - }, - headerTitle: { - fontSize: 32, - fontWeight: '800', - letterSpacing: 0.5, - marginBottom: 12, - }, searchBarContainer: { flexDirection: 'row', alignItems: 'center', diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index bf7fa4e..2c7a4f2 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -33,6 +33,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as Sentry from '@sentry/react-native'; import { getDisplayedAppVersion } from '../utils/version'; import CustomAlert from '../components/CustomAlert'; +import ScreenHeader from '../components/common/ScreenHeader'; import PluginIcon from '../components/icons/PluginIcon'; import TraktIcon from '../components/icons/TraktIcon'; import TMDBIcon from '../components/icons/TMDBIcon'; @@ -86,7 +87,11 @@ const SettingsCard: React.FC = ({ children, title, isTablet = )} {children} @@ -134,9 +139,7 @@ const SettingItem: React.FC = ({ @@ -145,7 +148,7 @@ const SettingItem: React.FC = ({ ) : ( )} @@ -195,11 +198,18 @@ interface SidebarProps { const Sidebar: React.FC = ({ selectedCategory, onCategorySelect, currentTheme, categories, extraTopPadding = 0 }) => { return ( - + @@ -215,26 +225,37 @@ const Sidebar: React.FC = ({ selectedCategory, onCategorySelect, c styles.sidebarItem, selectedCategory === category.id && [ styles.sidebarItemActive, - { backgroundColor: `${currentTheme.colors.primary}15` } + { backgroundColor: currentTheme.colors.primary + '10' } ] ]} onPress={() => onCategorySelect(category.id)} + activeOpacity={0.6} > - + ]}> + + {category.title} @@ -863,11 +884,8 @@ const SettingsScreen: React.FC = () => { } }; - const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; // Keep headers below floating top navigator on tablets by adding extra offset const tabletNavOffset = isTablet ? 64 : 0; - const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset; - const headerHeight = headerBaseHeight + topSpacing; if (isTablet) { return ( @@ -917,7 +935,21 @@ const SettingsScreen: React.FC = () => { - + WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', { + presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET + })} + activeOpacity={0.7} + > + + + + Linking.openURL('https://discord.gg/6w8dr3TSDN')} @@ -930,23 +962,26 @@ const SettingsScreen: React.FC = () => { resizeMode={FastImage.resizeMode.contain} /> - Join Discord + Discord WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', { - presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET - })} + style={[styles.discordButton, { backgroundColor: '#FF4500' + '15' }]} + onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')} activeOpacity={0.7} > - + + + + Reddit + + @@ -973,12 +1008,10 @@ const SettingsScreen: React.FC = () => { { backgroundColor: currentTheme.colors.darkBackground } ]}> + - - - Settings - - { {/* Support & Community Buttons */} - + WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', { + presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET + })} + activeOpacity={0.7} + > + + + + Linking.openURL('https://discord.gg/6w8dr3TSDN')} @@ -1030,23 +1077,26 @@ const SettingsScreen: React.FC = () => { resizeMode={FastImage.resizeMode.contain} /> - Join Discord + Discord WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', { - presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET - })} + style={[styles.discordButton, { backgroundColor: '#FF4500' + '15' }]} + onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')} activeOpacity={0.7} > - + + + + Reddit + + @@ -1069,20 +1119,6 @@ const styles = StyleSheet.create({ flex: 1, }, // Mobile styles - header: { - paddingHorizontal: Math.max(1, width * 0.05), - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-end', - paddingBottom: 8, - backgroundColor: 'transparent', - zIndex: 2, - }, - headerTitle: { - fontSize: Math.min(32, width * 0.08), - fontWeight: '800', - letterSpacing: 0.3, - }, contentContainer: { flex: 1, zIndex: 1, @@ -1095,7 +1131,8 @@ const styles = StyleSheet.create({ scrollContent: { flexGrow: 1, width: '100%', - paddingBottom: 90, + paddingTop: 8, + paddingBottom: 100, }, // Tablet-specific styles @@ -1106,39 +1143,45 @@ const styles = StyleSheet.create({ sidebar: { width: 280, borderRightWidth: 1, - borderRightColor: 'rgba(255,255,255,0.1)', }, sidebarHeader: { - padding: 24, + paddingHorizontal: 24, + paddingBottom: 20, paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48, borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.1)', }, sidebarTitle: { - fontSize: 28, - fontWeight: '800', - letterSpacing: 0.3, + fontSize: 42, + fontWeight: '700', + letterSpacing: -0.3, }, sidebarContent: { flex: 1, - paddingTop: 16, + paddingTop: 12, + paddingBottom: 24, }, sidebarItem: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 24, - paddingVertical: 16, + paddingHorizontal: 16, + paddingVertical: 12, marginHorizontal: 12, marginVertical: 2, - borderRadius: 12, + borderRadius: 10, }, sidebarItemActive: { - borderRadius: 12, + borderRadius: 10, + }, + sidebarItemIconContainer: { + width: 32, + height: 32, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', }, sidebarItemText: { - fontSize: 16, - fontWeight: '500', - marginLeft: 16, + fontSize: 15, + marginLeft: 12, }, tabletContent: { flex: 1, @@ -1146,80 +1189,74 @@ const styles = StyleSheet.create({ }, tabletScrollView: { flex: 1, - paddingHorizontal: 32, + paddingHorizontal: 40, }, tabletScrollContent: { - paddingBottom: 32, + paddingTop: 8, + paddingBottom: 40, }, // Common card styles cardContainer: { width: '100%', - marginBottom: 20, + marginBottom: 24, }, tabletCardContainer: { - marginBottom: 32, + marginBottom: 28, }, cardTitle: { - fontSize: 13, + fontSize: 12, fontWeight: '600', - letterSpacing: 0.8, - marginLeft: Math.max(12, width * 0.04), - marginBottom: 8, + letterSpacing: 1, + marginLeft: Math.max(16, width * 0.045), + marginBottom: 10, + textTransform: 'uppercase', }, tabletCardTitle: { - fontSize: 14, - marginLeft: 0, + fontSize: 12, + marginLeft: 4, marginBottom: 12, }, card: { - marginHorizontal: Math.max(12, width * 0.04), - borderRadius: 16, + marginHorizontal: Math.max(16, width * 0.04), + borderRadius: 14, overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, width: undefined, }, tabletCard: { marginHorizontal: 0, - borderRadius: 20, - shadowOpacity: 0.15, - shadowRadius: 8, - elevation: 5, + borderRadius: 16, }, settingItem: { flexDirection: 'row', alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: Math.max(12, width * 0.04), - borderBottomWidth: 0.5, - minHeight: Math.max(54, width * 0.14), + paddingVertical: 14, + paddingHorizontal: Math.max(14, width * 0.04), + borderBottomWidth: StyleSheet.hairlineWidth, + minHeight: Math.max(60, width * 0.15), width: '100%', }, tabletSettingItem: { paddingVertical: 16, - paddingHorizontal: 24, - minHeight: 70, + paddingHorizontal: 20, + minHeight: 68, }, settingItemBorder: { // Border styling handled directly in the component with borderBottomWidth }, settingIconContainer: { - marginRight: 16, - width: 36, - height: 36, + marginRight: 14, + width: 38, + height: 38, borderRadius: 10, alignItems: 'center', justifyContent: 'center', }, tabletSettingIconContainer: { - width: 44, - height: 44, - borderRadius: 12, - marginRight: 20, + width: 42, + height: 42, + borderRadius: 11, + marginRight: 16, }, settingContent: { flex: 1, @@ -1230,32 +1267,33 @@ const styles = StyleSheet.create({ flex: 1, }, settingTitle: { - fontSize: Math.min(16, width * 0.042), + fontSize: Math.min(16, width * 0.04), + fontWeight: '500', + marginBottom: 2, + letterSpacing: -0.2, + }, + tabletSettingTitle: { + fontSize: 17, fontWeight: '500', marginBottom: 3, }, - tabletSettingTitle: { - fontSize: 18, - fontWeight: '600', - marginBottom: 4, - }, settingDescription: { - fontSize: Math.min(14, width * 0.037), - opacity: 0.8, + fontSize: Math.min(13, width * 0.034), + opacity: 0.7, }, tabletSettingDescription: { - fontSize: 16, - opacity: 0.7, + fontSize: 14, + opacity: 0.6, }, settingControl: { justifyContent: 'center', alignItems: 'center', - paddingLeft: 12, + paddingLeft: 10, }, badge: { - height: 22, - minWidth: 22, - borderRadius: 11, + height: 20, + minWidth: 20, + borderRadius: 10, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 6, @@ -1263,8 +1301,8 @@ const styles = StyleSheet.create({ }, badgeText: { color: 'white', - fontSize: 12, - fontWeight: '600', + fontSize: 11, + fontWeight: '700', }, segmentedControl: { flexDirection: 'row', @@ -1293,26 +1331,27 @@ const styles = StyleSheet.create({ footer: { alignItems: 'center', justifyContent: 'center', - marginTop: 10, - marginBottom: 8, + marginTop: 24, + marginBottom: 12, }, footerText: { - fontSize: 14, + fontSize: 13, opacity: 0.5, + letterSpacing: 0.2, }, - // New styles for Discord button + // Support buttons discordContainer: { - marginTop: 8, - marginBottom: 20, + marginTop: 12, + marginBottom: 24, alignItems: 'center', }, discordButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 8, - paddingHorizontal: 16, - borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 18, + borderRadius: 10, maxWidth: 200, }, discordButtonContent: { @@ -1320,34 +1359,34 @@ const styles = StyleSheet.create({ alignItems: 'center', }, discordLogo: { - width: 16, - height: 16, - marginRight: 8, + width: 18, + height: 18, + marginRight: 10, }, discordButtonText: { fontSize: 14, - fontWeight: '500', + fontWeight: '600', }, kofiImage: { - height: 32, - width: 150, + height: 34, + width: 155, }, downloadsContainer: { - marginTop: 20, - marginBottom: 12, + marginTop: 32, + marginBottom: 16, alignItems: 'center', }, downloadsNumber: { - fontSize: 32, + fontSize: 36, fontWeight: '800', - letterSpacing: 1, - marginBottom: 4, + letterSpacing: 0.5, + marginBottom: 6, }, downloadsLabel: { fontSize: 11, fontWeight: '600', - opacity: 0.6, - letterSpacing: 1.2, + opacity: 0.5, + letterSpacing: 1.5, textTransform: 'uppercase', }, loadingSpinner: { diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 32a23b1..791545d 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -16,12 +16,12 @@ import { Clipboard, Image as RNImage, } from 'react-native'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, withDelay, - runOnJS + runOnJS } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -110,7 +110,7 @@ const detectMkvViaHead = async (url: string, headers?: Record) = const QualityTag = React.memo(({ text, color, theme }: { text: string; color: string; theme: any }) => { const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); - + return ( {text} @@ -149,7 +149,7 @@ export const StreamsScreen = () => { const loadStartTimeRef = useRef(0); const hasDoneInitialLoadRef = useRef(false); const isLoadingStreamsRef = useRef(false); - + // CustomAlert state const [alertVisible, setAlertVisible] = useState(false); const [alertTitle, setAlertTitle] = useState(''); @@ -165,23 +165,23 @@ export const StreamsScreen = () => { if (!isMounted.current) { return; } - + try { setAlertTitle(title); setAlertMessage(message); - setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); + setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]); setAlertVisible(true); } catch (error) { console.warn('[StreamsScreen] Error showing alert:', error); } }, []); - + // Track when we started fetching streams so we can show an extended loading state const [streamsLoadStart, setStreamsLoadStart] = useState(null); - const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({}); - + const [providerLoadTimes, setProviderLoadTimes] = useState<{ [key: string]: number }>({}); + // Prevent excessive re-renders by using this guard const guardedSetState = useCallback((setter: () => void) => { if (isMounted.current) { @@ -202,7 +202,7 @@ export const StreamsScreen = () => { useEffect(() => { // Pause trailer when component mounts pauseTrailer(); - + // Resume trailer when component unmounts return () => { resumeTrailer(); @@ -228,7 +228,7 @@ export const StreamsScreen = () => { } = useMetadata({ id, type }); // Get backdrop from metadata assets - const setMetadataStub = useCallback(() => {}, []); + const setMetadataStub = useCallback(() => { }, []); const memoizedSettings = useMemo(() => settings, [settings.logoSourcePreference, settings.tmdbLanguagePreference, settings.enrichMetadataWithTMDB]); const { bannerImage } = useMetadataAssets(metadata, id, type, imdbId, memoizedSettings, setMetadataStub); @@ -240,8 +240,8 @@ export const StreamsScreen = () => { // Add state for provider loading status - const [loadingProviders, setLoadingProviders] = useState<{[key: string]: boolean}>({}); - + const [loadingProviders, setLoadingProviders] = useState<{ [key: string]: boolean }>({}); + // Add state for more detailed provider loading tracking const [providerStatus, setProviderStatus] = useState<{ [key: string]: { @@ -264,10 +264,10 @@ export const StreamsScreen = () => { // Add state for no sources error const [showNoSourcesError, setShowNoSourcesError] = useState(false); - + // State for movie logo loading error const [movieLogoError, setMovieLogoError] = useState(false); - + // Scraper logos map to avoid per-card async fetches const [scraperLogos, setScraperLogos] = useState>({}); // Preload scraper logos once and expose via state @@ -296,7 +296,7 @@ export const StreamsScreen = () => { const map: Record = {}; // No direct way to iterate Map keys safely without exposing it; copy known ids on demand during render setScraperLogos(prev => prev); // no-op to ensure consistency - }).catch(() => {}); + }).catch(() => { }); } }; preloadScraperLogos(); @@ -306,19 +306,19 @@ export const StreamsScreen = () => { useEffect(() => { // Skip processing if component is unmounting if (!isMounted.current) return; - + const currentStreamsData = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; if (__DEV__) console.log('[StreamsScreen] streams state changed', { providerKeys: Object.keys(currentStreamsData || {}), type }); - + // Update available providers immediately when streams change const providersWithStreams = Object.entries(currentStreamsData) .filter(([_, data]) => data.streams && data.streams.length > 0) .map(([providerId]) => providerId); - + if (providersWithStreams.length > 0) { logger.log(`πŸ“Š Providers with streams: ${providersWithStreams.join(', ')}`); const providersWithStreamsSet = new Set(providersWithStreams); - + // Only update if we have new providers, don't remove existing ones during loading setAvailableProviders(prevProviders => { const newProviders = new Set([...prevProviders, ...providersWithStreamsSet]); @@ -326,27 +326,27 @@ export const StreamsScreen = () => { return newProviders; }); } - + // Update loading states for individual providers const expectedProviders = ['stremio']; const now = Date.now(); - + setLoadingProviders(prevLoading => { const nextLoading = { ...prevLoading }; let changed = false; expectedProviders.forEach(providerId => { const providerExists = currentStreamsData[providerId]; const hasStreams = providerExists && - currentStreamsData[providerId].streams && - currentStreamsData[providerId].streams.length > 0; - + currentStreamsData[providerId].streams && + currentStreamsData[providerId].streams.length > 0; + // Stop loading if: // 1. Provider exists (completed) and has streams, OR // 2. Provider exists (completed) but has 0 streams, OR // 3. Overall loading is false const shouldStopLoading = providerExists || !(loadingStreams || loadingEpisodeStreams); const value = !shouldStopLoading; - + if (nextLoading[providerId] !== value) { nextLoading[providerId] = value; changed = true; @@ -355,7 +355,7 @@ export const StreamsScreen = () => { if (changed && __DEV__) console.log('[StreamsScreen] loadingProviders ->', nextLoading); return changed ? nextLoading : prevLoading; }); - + }, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type]); // Reset autoplay state when episode changes (but preserve fromPlayer logic) @@ -378,8 +378,8 @@ export const StreamsScreen = () => { // Check if provider exists in current streams data const currentStreamsData = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; const hasStreamsForProvider = currentStreamsData[selectedProvider] && - currentStreamsData[selectedProvider].streams && - currentStreamsData[selectedProvider].streams.length > 0; + currentStreamsData[selectedProvider].streams && + currentStreamsData[selectedProvider].streams.length > 0; // Only reset if the provider doesn't exist in available providers AND doesn't have streams const isAvailableProvider = availableProviders.has(selectedProvider); @@ -435,54 +435,54 @@ export const StreamsScreen = () => { }, 500); return () => clearTimeout(timer); } else { - // Removed cached streams pre-display logic + // Removed cached streams pre-display logic - // For series episodes, do not wait for metadata; load directly when episodeId is present - if (episodeId) { - logger.log(`🎬 Loading episode streams for: ${episodeId}`); - setLoadingProviders({ - 'stremio': true - }); - setSelectedEpisode(episodeId); - setStreamsLoadStart(Date.now()); - if (__DEV__) console.log('[StreamsScreen] calling loadEpisodeStreams', episodeId); - loadEpisodeStreams(episodeId); - } else if (type === 'movie') { - logger.log(`🎬 Loading movie streams for: ${id}`); - setStreamsLoadStart(Date.now()); - if (__DEV__) console.log('[StreamsScreen] calling loadStreams (movie)', id); - loadStreams(); - } else if (type === 'tv') { - // TV/live content – fetch streams directly - logger.log(`πŸ“Ί Loading TV streams for: ${id}`); - setLoadingProviders({ - 'stremio': true - }); - setStreamsLoadStart(Date.now()); - if (__DEV__) console.log('[StreamsScreen] calling loadStreams (tv)', id); - loadStreams(); - } else { - // Fallback: series without explicit episodeId (or other types) – fetch streams directly - logger.log(`🎬 Loading streams for: ${id}`); - setLoadingProviders({ - 'stremio': true - }); - setStreamsLoadStart(Date.now()); - if (__DEV__) console.log('[StreamsScreen] calling loadStreams (fallback)', id); - loadStreams(); - } + // For series episodes, do not wait for metadata; load directly when episodeId is present + if (episodeId) { + logger.log(`🎬 Loading episode streams for: ${episodeId}`); + setLoadingProviders({ + 'stremio': true + }); + setSelectedEpisode(episodeId); + setStreamsLoadStart(Date.now()); + if (__DEV__) console.log('[StreamsScreen] calling loadEpisodeStreams', episodeId); + loadEpisodeStreams(episodeId); + } else if (type === 'movie') { + logger.log(`🎬 Loading movie streams for: ${id}`); + setStreamsLoadStart(Date.now()); + if (__DEV__) console.log('[StreamsScreen] calling loadStreams (movie)', id); + loadStreams(); + } else if (type === 'tv') { + // TV/live content – fetch streams directly + logger.log(`πŸ“Ί Loading TV streams for: ${id}`); + setLoadingProviders({ + 'stremio': true + }); + setStreamsLoadStart(Date.now()); + if (__DEV__) console.log('[StreamsScreen] calling loadStreams (tv)', id); + loadStreams(); + } else { + // Fallback: series without explicit episodeId (or other types) – fetch streams directly + logger.log(`🎬 Loading streams for: ${id}`); + setLoadingProviders({ + 'stremio': true + }); + setStreamsLoadStart(Date.now()); + if (__DEV__) console.log('[StreamsScreen] calling loadStreams (fallback)', id); + loadStreams(); + } - // Reset autoplay state when content changes - setAutoplayTriggered(false); - if (settings.autoplayBestStream && !fromPlayer) { - setIsAutoplayWaiting(true); - logger.log('πŸ”„ Autoplay enabled, waiting for best stream...'); - } else { - setIsAutoplayWaiting(false); - if (fromPlayer) { - logger.log('🚫 Autoplay disabled: returning from player'); - } + // Reset autoplay state when content changes + setAutoplayTriggered(false); + if (settings.autoplayBestStream && !fromPlayer) { + setIsAutoplayWaiting(true); + logger.log('πŸ”„ Autoplay enabled, waiting for best stream...'); + } else { + setIsAutoplayWaiting(false); + if (fromPlayer) { + logger.log('🚫 Autoplay disabled: returning from player'); } + } } } finally { isLoadingStreamsRef.current = false; @@ -561,7 +561,7 @@ export const StreamsScreen = () => { // Check if any excluded language is found in the stream title or description const hasExcludedLanguage = settings.excludedLanguages.some(excludedLanguage => { const langLower = excludedLanguage.toLowerCase(); - + // Check multiple variations of the language name const variations = [langLower]; @@ -595,9 +595,9 @@ export const StreamsScreen = () => { } else if (langLower === 'hindi') { variations.push('hin'); } - + const matches = variations.some(variant => searchText.includes(variant)); - + if (matches) { console.log(`πŸ” [filterStreamsByLanguage] βœ• Excluding stream with ${excludedLanguage}:`, streamName.substring(0, 100)); } @@ -623,19 +623,19 @@ export const StreamsScreen = () => { // Helper function to extract quality as number const getQualityNumeric = (title: string | undefined): number => { if (!title) return 0; - + // Check for 4K first (treat as 2160p) if (/\b4k\b/i.test(title)) { return 2160; } - + const matchWithP = title.match(/(\d+)p/i); if (matchWithP) return parseInt(matchWithP[1], 10); - + const qualityPatterns = [ /\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i ]; - + for (const pattern of qualityPatterns) { const match = title.match(pattern); if (match) { @@ -651,12 +651,12 @@ export const StreamsScreen = () => { // Get Stremio addon installation order (earlier = higher priority) const installedAddons = stremioService.getInstalledAddons(); const addonIndex = installedAddons.findIndex(addon => addon.id === addonId); - + if (addonIndex !== -1) { // Higher priority for addons installed earlier (reverse index) return 50 - addonIndex; } - + return 0; // Unknown providers get lowest priority }; @@ -671,7 +671,7 @@ export const StreamsScreen = () => { // Apply quality and language filtering to streams before processing const qualityFiltered = filterStreamsByQuality(streams); const filteredStreams = filterStreamsByLanguage(qualityFiltered); - + filteredStreams.forEach(stream => { const quality = getQualityNumeric(stream.name || stream.title); const providerPriority = getProviderPriority(addonId); @@ -701,7 +701,7 @@ export const StreamsScreen = () => { }); logger.log(`🎯 Best stream selected: ${allStreams[0].stream.name || allStreams[0].stream.title} (Quality: ${allStreams[0].quality}p, Provider Priority: ${allStreams[0].providerPriority})`); - + return allStreams[0].stream; }, [filterStreamsByQuality]); @@ -710,8 +710,8 @@ export const StreamsScreen = () => { // Search through all episodes in all seasons const allEpisodes = Object.values(groupedEpisodes).flat(); - return allEpisodes.find(ep => - ep.stremioId === selectedEpisode || + return allEpisodes.find(ep => + ep.stremioId === selectedEpisode || `${id}:${ep.season_number}:${ep.episode_number}` === selectedEpisode ); }, [selectedEpisode, groupedEpisodes, id]); @@ -776,7 +776,7 @@ export const StreamsScreen = () => { // Fetch IMDb ratings for all seasons const ratings = await tmdbService.getIMDbRatings(tmdbShowId); - + if (ratings) { // Create a lookup map for O(1) access: key format "season:episode" -> rating const ratingsMap: { [key: string]: number } = {}; @@ -790,7 +790,7 @@ export const StreamsScreen = () => { }); } }); - + setImdbRatingsMap(ratingsMap); } } catch (err) { @@ -805,18 +805,18 @@ export const StreamsScreen = () => { // Filter headers for Vidrock - only send essential headers const filterHeadersForVidrock = (headers: Record | undefined): Record | undefined => { if (!headers) return undefined; - + // Only keep essential headers for Vidrock const essentialHeaders: Record = {}; if (headers['User-Agent']) essentialHeaders['User-Agent'] = headers['User-Agent']; if (headers['Referer']) essentialHeaders['Referer'] = headers['Referer']; if (headers['Origin']) essentialHeaders['Origin'] = headers['Origin']; - + return Object.keys(essentialHeaders).length > 0 ? essentialHeaders : undefined; }; const finalHeaders = filterHeadersForVidrock(options?.headers || stream.headers); - + // Add logging here console.log('[StreamsScreen] Navigating to player with headers:', { streamHeaders: stream.headers, @@ -825,17 +825,14 @@ export const StreamsScreen = () => { streamUrl: stream.url, streamName: stream.name || stream.title }); - - // Add 50ms delay before navigating to player - await new Promise(resolve => setTimeout(resolve, 50)); - + // Prepare available streams for the change source feature const streamsToPass = (type === 'series' || (type === 'other' && selectedEpisode)) ? episodeStreams : groupedStreams; - + // Determine the stream name using the same logic as StreamCard const streamName = stream.name || stream.title || 'Unnamed Stream'; const streamProvider = stream.addonId || stream.addonName || stream.name; - + // Do NOT pre-force VLC. Let ExoPlayer try first; fallback occurs on decoder error in the player. let forceVlc = !!options?.forceVlc; @@ -845,7 +842,7 @@ export const StreamsScreen = () => { const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined; const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined; const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined; - + await streamCacheService.saveStreamToCache( id, type, @@ -864,7 +861,7 @@ export const StreamsScreen = () => { // Show a quick full-screen black overlay to mask rotation flicker // by setting a transient state that renders a covering View (implementation already supported by dark backgrounds) - + // Infer video type for player (helps Android ExoPlayer choose correct extractor) const inferVideoTypeFromUrl = (u?: string): string | undefined => { if (!u) return undefined; @@ -881,11 +878,11 @@ export const StreamsScreen = () => { if (!videoType && /xprime/i.test(providerId)) { videoType = 'm3u8'; } - } catch {} + } catch { } // Simple platform check - iOS uses KSPlayerCore, Android uses AndroidVideoPlayer const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; - + navigation.navigate(playerRoute as any, { uri: stream.url, title: metadata?.name || '', @@ -920,7 +917,7 @@ export const StreamsScreen = () => { if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) { try { openAlert('Not supported', 'Torrent streaming is not supported yet.'); - } catch (_e) {} + } catch (_e) { } return; } // If stream is actually MKV format, force the in-app VLC-based player on iOS @@ -975,14 +972,14 @@ export const StreamsScreen = () => { useExternalPlayer: settings.useExternalPlayer, preferredPlayer: settings.preferredPlayer }); - + // For iOS, try to open with the preferred external player if (Platform.OS === 'ios' && settings.preferredPlayer !== 'internal') { try { // Format the URL for the selected player const streamUrl = encodeURIComponent(stream.url); let externalPlayerUrls: string[] = []; - + // Configure URL formats based on the selected player switch (settings.preferredPlayer) { case 'vlc': @@ -992,7 +989,7 @@ export const StreamsScreen = () => { `vlc://${streamUrl}` ]; break; - + case 'outplayer': externalPlayerUrls = [ `outplayer://${stream.url}`, @@ -1002,7 +999,7 @@ export const StreamsScreen = () => { `outplayer://play/browser?url=${streamUrl}` ]; break; - + case 'infuse': externalPlayerUrls = [ `infuse://x-callback-url/play?url=${streamUrl}`, @@ -1010,14 +1007,14 @@ export const StreamsScreen = () => { `infuse://${streamUrl}` ]; break; - + case 'vidhub': externalPlayerUrls = [ `vidhub://play?url=${streamUrl}`, `vidhub://${streamUrl}` ]; break; - + case 'infuse_livecontainer': const infuseUrls = [ `infuse://x-callback-url/play?url=${streamUrl}`, @@ -1029,15 +1026,15 @@ export const StreamsScreen = () => { return `livecontainer://open-url?url=${encoded}`; }); break; - + default: // If no matching player or the setting is somehow invalid, use internal player navigateToPlayer(stream); return; } - + if (__DEV__) console.log(`Attempting to open stream in ${settings.preferredPlayer}`); - + // Try each URL format in sequence const tryNextUrl = (index: number) => { if (index >= externalPlayerUrls.length) { @@ -1051,10 +1048,10 @@ export const StreamsScreen = () => { }); return; } - + const url = externalPlayerUrls[index]; if (__DEV__) console.log(`Trying ${settings.preferredPlayer} URL format ${index + 1}: ${url}`); - + Linking.openURL(url) .then(() => { if (__DEV__) console.log(`Successfully opened stream with ${settings.preferredPlayer} format ${index + 1}`); }) .catch(err => { @@ -1062,31 +1059,31 @@ export const StreamsScreen = () => { tryNextUrl(index + 1); }); }; - + // Start with the first URL format tryNextUrl(0); - + } catch (error) { if (__DEV__) console.error(`Error with ${settings.preferredPlayer}:`, error); // Fallback to the built-in player navigateToPlayer(stream); } - } + } // For Android with external player preference else if (Platform.OS === 'android' && settings.useExternalPlayer) { try { if (__DEV__) console.log('Opening stream with Android native app chooser'); - + // For Android, determine if the URL is a direct http/https URL or a magnet link const isMagnet = typeof stream.url === 'string' && stream.url.startsWith('magnet:'); - + if (isMagnet) { // For magnet links, open directly which will trigger the torrent app chooser if (__DEV__) console.log('Opening magnet link directly'); Linking.openURL(stream.url) .then(() => { if (__DEV__) console.log('Successfully opened magnet link'); }) - .catch(err => { - if (__DEV__) console.error('Failed to open magnet link:', err); + .catch(err => { + if (__DEV__) console.error('Failed to open magnet link:', err); // No good fallback for magnet links navigateToPlayer(stream); }); @@ -1098,10 +1095,10 @@ export const StreamsScreen = () => { episodeTitle: (type === 'series' || type === 'other') ? currentEpisode?.name : undefined, episodeNumber: (type === 'series' || type === 'other') && currentEpisode ? `S${currentEpisode.season_number}E${currentEpisode.episode_number}` : undefined, }); - + if (!success) { if (__DEV__) console.log('VideoPlayerService failed, falling back to built-in player'); - navigateToPlayer(stream); + navigateToPlayer(stream); } } } catch (error) { @@ -1132,32 +1129,32 @@ export const StreamsScreen = () => { // Trigger a small state update to force re-render setStreamsLoadStart(prev => prev); }, 100); - + return () => { clearTimeout(renderTimer); }; } - return () => {}; + return () => { }; }, []) ); // Autoplay effect - triggers immediately when streams are available and autoplay is enabled useEffect(() => { if ( - settings.autoplayBestStream && - !autoplayTriggered && + settings.autoplayBestStream && + !autoplayTriggered && isAutoplayWaiting ) { const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; - + if (Object.keys(streams).length > 0) { const bestStream = getBestStream(streams); - + if (bestStream) { logger.log('πŸš€ Autoplay: Best stream found, starting playback immediately...'); setAutoplayTriggered(true); setIsAutoplayWaiting(false); - + // Start playback immediately - no delay needed handleStreamPress(bestStream); } else { @@ -1180,20 +1177,20 @@ export const StreamsScreen = () => { const filterItems = useMemo(() => { const installedAddons = stremioService.getInstalledAddons(); const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; - + // Only include providers that actually have streams const providersWithStreams = Object.keys(streams).filter(key => { const providerData = streams[key]; if (!providerData || !providerData.streams) { return false; } - + // Only show providers (addons or plugins) if they have actual streams return providerData.streams.length > 0; }); - + const allProviders = new Set([ - ...Array.from(availableProviders).filter((provider: string) => + ...Array.from(availableProviders).filter((provider: string) => streams[provider] && streams[provider].streams && streams[provider].streams.length > 0 ), ...providersWithStreams @@ -1203,7 +1200,7 @@ export const StreamsScreen = () => { if (settings.streamDisplayMode === 'grouped') { const addonProviders: string[] = []; const pluginProviders: string[] = []; - + Array.from(allProviders).forEach(provider => { const isInstalledAddon = installedAddons.some(addon => addon.id === provider); if (isInstalledAddon) { @@ -1212,9 +1209,9 @@ export const StreamsScreen = () => { pluginProviders.push(provider); } }); - + const filterChips = [{ id: 'all', name: 'All Providers' }]; - + // Add individual addon chips addonProviders .sort((a, b) => { @@ -1226,12 +1223,12 @@ export const StreamsScreen = () => { const installedAddon = installedAddons.find(addon => addon.id === provider); filterChips.push({ id: provider, name: installedAddon?.name || provider }); }); - + // Add single grouped plugins chip if there are any plugins with streams if (pluginProviders.length > 0) { filterChips.push({ id: 'grouped-plugins', name: localScraperService.getRepositoryName() }); } - + return filterChips; } @@ -1243,7 +1240,7 @@ export const StreamsScreen = () => { // Sort by Stremio addon installation order const indexA = installedAddons.findIndex(addon => addon.id === a); const indexB = installedAddons.findIndex(addon => addon.id === b); - + if (indexA !== -1 && indexB !== -1) return indexA - indexB; if (indexA !== -1) return -1; if (indexB !== -1) return 1; @@ -1251,14 +1248,14 @@ export const StreamsScreen = () => { }) .map(provider => { const addonInfo = streams[provider]; - + // Standard handling for Stremio addons const installedAddon = installedAddons.find(addon => addon.id === provider); - + let displayName = provider; if (installedAddon) displayName = installedAddon.name; else if (addonInfo?.addonName) displayName = addonInfo.addonName; - + return { id: provider, name: displayName }; }) ]; @@ -1267,7 +1264,7 @@ export const StreamsScreen = () => { const sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null> = useMemo(() => { const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; const installedAddons = stremioService.getInstalledAddons(); - + console.log('πŸ” [StreamsScreen] Sections debug:', { streamsKeys: Object.keys(streams), installedAddons: installedAddons.map(a => ({ id: a.id, name: a.name })), @@ -1297,7 +1294,7 @@ export const StreamsScreen = () => { // Otherwise only show the selected provider return addonId === selectedProvider; }); - + console.log('πŸ” [StreamsScreen] Filtered entries:', { filteredCount: filteredEntries.length, filteredEntries: filteredEntries.map(([addonId, data]) => ({ @@ -1306,24 +1303,24 @@ export const StreamsScreen = () => { streamCount: data.streams?.length || 0 })) }); - + const sortedEntries = filteredEntries.sort(([addonIdA], [addonIdB]) => { - // Sort by response order (actual order addons responded) - const indexA = addonResponseOrder.indexOf(addonIdA); - const indexB = addonResponseOrder.indexOf(addonIdB); - - // If both are in response order, sort by response order - if (indexA !== -1 && indexB !== -1) { - return indexA - indexB; - } - - // If only one is in response order, prioritize it - if (indexA !== -1) return -1; - if (indexB !== -1) return 1; - - // If neither is in response order, maintain original order - return 0; - }); + // Sort by response order (actual order addons responded) + const indexA = addonResponseOrder.indexOf(addonIdA); + const indexB = addonResponseOrder.indexOf(addonIdB); + + // If both are in response order, sort by response order + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } + + // If only one is in response order, prioritize it + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + + // If neither is in response order, maintain original order + return 0; + }); // Check if we should group all streams under one section if (settings.streamDisplayMode === 'grouped') { @@ -1436,7 +1433,7 @@ export const StreamsScreen = () => { data: combinedStreams, isEmptyDueToQualityFilter: false }]; - + console.log('πŸ” [StreamsScreen] Grouped mode result:', { resultCount: result.length, combinedStreamsCount: combinedStreams.length, @@ -1444,7 +1441,7 @@ export const StreamsScreen = () => { pluginStreamsCount: pluginStreams.length, totalOriginalCount }); - + return result; } else { // Use separate sections for each provider (current behavior) @@ -1554,7 +1551,7 @@ export const StreamsScreen = () => { data: processedStreams, isEmptyDueToQualityFilter: false }; - + console.log('πŸ” [StreamsScreen] Individual mode result:', { addonId, addonName, @@ -1562,12 +1559,12 @@ export const StreamsScreen = () => { originalCount, isInstalledAddon }); - + return result; }).filter(Boolean); // Filter out null values } }, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, addonResponseOrder, settings.streamSortMode, selectedEpisode, metadata]); - + // Debug log for sections result React.useEffect(() => { console.log('πŸ” [StreamsScreen] Final sections:', { @@ -1611,13 +1608,13 @@ export const StreamsScreen = () => { // Effective rating for hero (series) - prioritize IMDb, fallback to TMDB const effectiveEpisodeVote = useMemo(() => { if (!currentEpisode) return 0; - + // Try IMDb rating first const imdbRating = getIMDbRating(currentEpisode.season_number, currentEpisode.episode_number); if (imdbRating !== null) { return imdbRating; } - + // Fallback to TMDB const v = (tmdbEpisodeOverride?.vote_average ?? currentEpisode.vote_average) || 0; return typeof v === 'number' ? v : Number(v) || 0; @@ -1646,14 +1643,14 @@ export const StreamsScreen = () => { return bannerImage; } } - + // For movies: prioritize bannerImage if (type === 'movie') { if (bannerImage) { return bannerImage; } } - + // For other types or when no specific image available return bannerImage || episodeImage; }, [type, selectedEpisode, episodeImage, bannerImage]); @@ -1664,7 +1661,7 @@ export const StreamsScreen = () => { if (!settings.enableStreamsBackdrop) { return null; } - + if (type === 'series' || (type === 'other' && selectedEpisode)) { // Only use episodeImage - don't fallback to bannerImage // This ensures we get episode-specific colors, not show-wide colors @@ -1687,7 +1684,7 @@ export const StreamsScreen = () => { } // Deduplicate and prefetch Array.from(new Set(urls)).forEach(u => { - RNImage.prefetch(u).catch(() => {}); + RNImage.prefetch(u).catch(() => { }); }); }, [episodeImage, bannerImage, metadata]); @@ -1697,10 +1694,10 @@ export const StreamsScreen = () => { if (settings.enableStreamsBackdrop) { return ['rgba(0,0,0,0)', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.6)', 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.95)']; } - + // When backdrop is disabled, use theme background gradient const themeBg = colors.darkBackground; - + // Handle hex color format (e.g., #1a1a1a) if (themeBg.startsWith('#')) { const r = parseInt(themeBg.substr(1, 2), 16); @@ -1714,7 +1711,7 @@ export const StreamsScreen = () => { `rgba(${r},${g},${b},0.95)`, ]; } - + // Handle rgb color format (e.g., rgb(26, 26, 26)) const rgbMatch = themeBg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (rgbMatch) { @@ -1727,17 +1724,17 @@ export const StreamsScreen = () => { `rgba(${r},${g},${b},0.95)`, ]; } - + if (!baseColor || baseColor === '#1a1a1a') { // Fallback to black gradient with stronger bottom edge return ['rgba(0,0,0,0)', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.6)', 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.95)']; } - + // Convert hex to RGB const r = parseInt(baseColor.substr(1, 2), 16); const g = parseInt(baseColor.substr(3, 2), 16); const b = parseInt(baseColor.substr(5, 2), 16); - + // Create gradient stops with much stronger opacity at bottom return [ `rgba(${r},${g},${b},0)`, @@ -1748,8 +1745,8 @@ export const StreamsScreen = () => { ]; }, [settings.enableStreamsBackdrop, colors.darkBackground]); - const gradientColors = useMemo(() => - createGradientColors(dominantColor), + const gradientColors = useMemo(() => + createGradientColors(dominantColor), [dominantColor, createGradientColors] ); @@ -1757,7 +1754,7 @@ export const StreamsScreen = () => { const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; // Determine extended loading phases - const streamsEmpty = Object.keys(streams).length === 0 || + const streamsEmpty = Object.keys(streams).length === 0 || Object.values(streams).every(provider => !provider.streams || provider.streams.length === 0); const loadElapsed = streamsLoadStart ? Date.now() - streamsLoadStart : 0; const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000); @@ -1818,266 +1815,266 @@ export const StreamsScreen = () => { return ( - - - - - {Platform.OS !== 'ios' && ( - - - - - {metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? 'Back to Episodes' : 'Back to Info'} - - - - )} - - {isTablet ? ( - + - ) : ( - // PHONE LAYOUT (existing structure) - <> - {/* Full Screen Background for Mobile */} - {settings.enableStreamsBackdrop ? ( - - {mobileBackdropSource ? ( - - ) : ( - - )} - {Platform.OS === 'android' && AndroidBlurView ? ( - - ) : ( - - )} - {/* Dark overlay to reduce brightness */} - {Platform.OS === 'ios' && ( - - )} - - ) : ( - - )} - {type === 'movie' && metadata && ( - - - {metadata.logo && !movieLogoError ? ( - setMovieLogoError(true)} - /> - ) : ( - - {metadata.name} - - )} - - - )} -{currentEpisode && ( - + {Platform.OS !== 'ios' && ( + + + + + {metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? 'Back to Episodes' : 'Back to Info'} + + + + )} + + {isTablet ? ( + + ) : ( + // PHONE LAYOUT (existing structure) + <> + {/* Full Screen Background for Mobile */} + {settings.enableStreamsBackdrop ? ( - + {mobileBackdropSource ? ( - - - {currentEpisode ? ( - - - {currentEpisode.episodeString} - - - {currentEpisode.name} - - {!!currentEpisode.overview && ( - - - {currentEpisode.overview} - - - )} - - - {tmdbService.formatAirDate(currentEpisode.air_date)} - - {effectiveEpisodeVote > 0 && ( - - {hasIMDbRating ? ( - <> - - - {effectiveEpisodeVote.toFixed(1)} - - - ) : ( - <> - - - {effectiveEpisodeVote.toFixed(1)} - - - )} - - )} - {!!effectiveEpisodeRuntime && ( - - - - {effectiveEpisodeRuntime >= 60 - ? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m` - : `${effectiveEpisodeRuntime}m`} - - - )} - - - ) : ( - // Placeholder to reserve space and avoid layout shift while loading - - )} - - - + ) : ( + + )} + {Platform.OS === 'android' && AndroidBlurView ? ( + + ) : ( + + )} + {/* Dark overlay to reduce brightness */} + {Platform.OS === 'ios' && ( + + )} - - )} + ) : ( + + )} - {/* Gradient overlay to blend hero section with streams container */} - {metadata?.videos && metadata.videos.length > 1 && selectedEpisode && ( - - - - )} - - - - {!streamsEmpty && ( - - )} - - - {/* Active Scrapers Status */} - {activeFetchingScrapers.length > 0 && ( - - Fetching from: - - {activeFetchingScrapers.map((scraperName, index) => ( - - ))} + {type === 'movie' && metadata && ( + + + {metadata.logo && !movieLogoError ? ( + setMovieLogoError(true)} + /> + ) : ( + + {metadata.name} + + )} )} - {/* Update the streams/loading state display logic */} - { showNoSourcesError ? ( - + + + + + + {currentEpisode ? ( + + + {currentEpisode.episodeString} + + + {currentEpisode.name} + + {!!currentEpisode.overview && ( + + + {currentEpisode.overview} + + + )} + + + {tmdbService.formatAirDate(currentEpisode.air_date)} + + {effectiveEpisodeVote > 0 && ( + + {hasIMDbRating ? ( + <> + + + {effectiveEpisodeVote.toFixed(1)} + + + ) : ( + <> + + + {effectiveEpisodeVote.toFixed(1)} + + + )} + + )} + {!!effectiveEpisodeRuntime && ( + + + + {effectiveEpisodeRuntime >= 60 + ? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m` + : `${effectiveEpisodeRuntime}m`} + + + )} + + + ) : ( + // Placeholder to reserve space and avoid layout shift while loading + + )} + + + + + + )} + + {/* Gradient overlay to blend hero section with streams container */} + {metadata?.videos && metadata.videos.length > 1 && selectedEpisode && ( + + + + )} + + + + {!streamsEmpty && ( + + )} + + + {/* Active Scrapers Status */} + {activeFetchingScrapers.length > 0 && ( + + Fetching from: + + {activeFetchingScrapers.map((scraperName, index) => ( + + ))} + + + )} + + {/* Update the streams/loading state display logic */} + {showNoSourcesError ? ( + @@ -2092,140 +2089,140 @@ export const StreamsScreen = () => { Add Sources - ) : streamsEmpty ? ( - showInitialLoading ? ( - - - - {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'} - - - ) : showStillFetching ? ( - - - Still fetching streams… - - ) : ( - // No streams and not loading = no streams available - - - No streams available - - ) - ) : ( - // Show streams immediately when available, even if still loading others - - {/* Show autoplay loading overlay if waiting for autoplay */} - {isAutoplayWaiting && !autoplayTriggered && ( - - - - Starting best stream... - + + + {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'} + - )} - - - {sections.filter(Boolean).map((section, sectionIndex) => ( - - {/* Section Header */} - {renderSectionHeader({ section: section! })} - - {/* Stream Cards using FlatList */} - {section!.data && section!.data.length > 0 ? ( - { - if (item && item.url) { - return `${item.url}-${sectionIndex}-${index}`; - } - return `empty-${sectionIndex}-${index}`; - }} - renderItem={({ item, index }) => ( - - handleStreamPress(item)} - index={index} - isLoading={false} - statusMessage={undefined} - theme={currentTheme} - showLogos={settings.showScraperLogos} - scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogoCache.get((item.addonId || (item as any).addon) as string) || null : null} - showAlert={(t, m) => openAlert(t, m)} - parentTitle={metadata?.name} - parentType={type as 'movie' | 'series'} - parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined} - parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined} - parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined} - parentPosterUrl={episodeImage || metadata?.poster || undefined} - providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))} - parentId={id} - parentImdbId={imdbId || undefined} - /> - - )} - scrollEnabled={false} - initialNumToRender={6} - maxToRenderPerBatch={2} - windowSize={3} - removeClippedSubviews={true} - showsVerticalScrollIndicator={false} - getItemLayout={(data, index) => ({ - length: 78, // Approximate height of StreamCard (68 minHeight + 10 marginBottom) - offset: 78 * index, - index, - })} - /> - ) : null} - - ))} - - {/* Footer Loading */} - {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && ( - - - Loading more sources... + ) : showStillFetching ? ( + + + Still fetching streams… + + ) : ( + // No streams and not loading = no streams available + + + No streams available + + ) + ) : ( + // Show streams immediately when available, even if still loading others + + {/* Show autoplay loading overlay if waiting for autoplay */} + {isAutoplayWaiting && !autoplayTriggered && ( + + + + Starting best stream... + )} - - - )} - - - )} - setAlertVisible(false)} - /> - + + + {sections.filter(Boolean).map((section, sectionIndex) => ( + + {/* Section Header */} + {renderSectionHeader({ section: section! })} + + {/* Stream Cards using FlatList */} + {section!.data && section!.data.length > 0 ? ( + { + if (item && item.url) { + return `${item.url}-${sectionIndex}-${index}`; + } + return `empty-${sectionIndex}-${index}`; + }} + renderItem={({ item, index }) => ( + + handleStreamPress(item)} + index={index} + isLoading={false} + statusMessage={undefined} + theme={currentTheme} + showLogos={settings.showScraperLogos} + scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogoCache.get((item.addonId || (item as any).addon) as string) || null : null} + showAlert={(t, m) => openAlert(t, m)} + parentTitle={metadata?.name} + parentType={type as 'movie' | 'series'} + parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined} + parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined} + parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined} + parentPosterUrl={episodeImage || metadata?.poster || undefined} + providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))} + parentId={id} + parentImdbId={imdbId || undefined} + /> + + )} + scrollEnabled={false} + initialNumToRender={6} + maxToRenderPerBatch={2} + windowSize={3} + removeClippedSubviews={true} + showsVerticalScrollIndicator={false} + getItemLayout={(data, index) => ({ + length: 78, // Approximate height of StreamCard (68 minHeight + 10 marginBottom) + offset: 78 * index, + index, + })} + /> + ) : null} + + ))} + + {/* Footer Loading */} + {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && ( + + + Loading more sources... + + )} + + + )} + + + )} + setAlertVisible(false)} + /> + ); }; diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 4c7b360..9a2b4f7 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -29,7 +29,7 @@ class StorageService { private watchProgressCacheTimestamp = 0; private readonly WATCH_PROGRESS_CACHE_TTL = 5000; // 5 seconds - private constructor() {} + private constructor() { } public static getInstance(): StorageService { if (!StorageService.instance) { @@ -88,7 +88,7 @@ class StorageService { const map = JSON.parse(json) as Record; map[this.buildWpKeyString(id, type, episodeId)] = deletedAtMs || Date.now(); await mmkvStorage.setItem(key, JSON.stringify(map)); - } catch {} + } catch { } } public async clearWatchProgressTombstone( @@ -105,7 +105,7 @@ class StorageService { delete map[k]; await mmkvStorage.setItem(key, JSON.stringify(map)); } - } catch {} + } catch { } } public async getWatchProgressTombstones(): Promise> { @@ -220,7 +220,7 @@ class StorageService { lastUpdated: Date.now() }; await this.setWatchProgress(id, type, updatedProgress, episodeId); - logger.log(`[StorageService] Updated progress duration from ${(existingProgress.duration/60).toFixed(0)}min to ${(newDuration/60).toFixed(0)}min`); + logger.log(`[StorageService] Updated progress duration from ${(existingProgress.duration / 60).toFixed(0)}min to ${(newDuration / 60).toFixed(0)}min`); } } catch (error) { logger.error('Error updating progress duration:', error); @@ -247,15 +247,15 @@ class StorageService { if (newestTombAt && (progress.lastUpdated == null || progress.lastUpdated <= newestTombAt)) { return; } - } catch {} - + } catch { } + // Check if progress has actually changed significantly, unless forceWrite is requested if (!options?.forceWrite) { const existingProgress = await this.getWatchProgress(id, type, episodeId); if (existingProgress) { const timeDiff = Math.abs(progress.currentTime - existingProgress.currentTime); const durationDiff = Math.abs(progress.duration - existingProgress.duration); - + // Only update if there's a significant change (>5 seconds or duration change) if (timeDiff < 5 && durationDiff < 1) { return; // Skip update for minor changes @@ -266,9 +266,24 @@ class StorageService { const timestamp = (options?.preserveTimestamp && typeof progress.lastUpdated === 'number') ? progress.lastUpdated : Date.now(); + + + try { + const removedMap = await this.getContinueWatchingRemoved(); + const removedKey = this.buildWpKeyString(id, type); + const removedAt = removedMap[removedKey]; + + if (removedAt != null && timestamp > removedAt) { + logger.log(`♻️ [StorageService] restoring content to continue watching due to new progress: ${type}:${id}`); + await this.removeContinueWatchingRemoved(id, type); + } + } catch (e) { + // Ignore error checks for restoration to prevent blocking save + } + const updated = { ...progress, lastUpdated: timestamp }; await mmkvStorage.setItem(key, JSON.stringify(updated)); - + // Invalidate cache this.invalidateWatchProgressCache(); @@ -285,12 +300,12 @@ class StorageService { private debouncedNotifySubscribers(): void { const now = Date.now(); - + // Clear existing timer if (this.notificationDebounceTimer) { clearTimeout(this.notificationDebounceTimer); } - + // If we notified recently, debounce longer const timeSinceLastNotification = now - this.lastNotificationTime; if (timeSinceLastNotification < this.MIN_NOTIFICATION_INTERVAL) { @@ -306,16 +321,16 @@ class StorageService { private notifyWatchProgressSubscribers(): void { this.lastNotificationTime = Date.now(); this.notificationDebounceTimer = null; - + // Only notify if we have subscribers if (this.watchProgressSubscribers.length > 0) { - this.watchProgressSubscribers.forEach(callback => callback()); + this.watchProgressSubscribers.forEach(callback => callback()); } } public subscribeToWatchProgressUpdates(callback: () => void): () => void { this.watchProgressSubscribers.push(callback); - + // Return unsubscribe function return () => { const index = this.watchProgressSubscribers.indexOf(callback); @@ -334,7 +349,7 @@ class StorageService { } public async getWatchProgress( - id: string, + id: string, type: string, episodeId?: string ): Promise { @@ -349,7 +364,7 @@ class StorageService { } public async removeWatchProgress( - id: string, + id: string, type: string, episodeId?: string ): Promise { @@ -357,14 +372,14 @@ class StorageService { const key = await this.getWatchProgressKeyScoped(id, type, episodeId); await mmkvStorage.removeItem(key); await this.addWatchProgressTombstone(id, type, episodeId); - + // Invalidate cache this.invalidateWatchProgressCache(); - + // Notify subscribers this.notifyWatchProgressSubscribers(); // Emit explicit remove event for sync layer - try { this.watchProgressRemoveListeners.forEach(l => l(id, type, episodeId)); } catch {} + try { this.watchProgressRemoveListeners.forEach(l => l(id, type, episodeId)); } catch { } } catch (error) { logger.error('Error removing watch progress:', error); } @@ -383,25 +398,25 @@ class StorageService { const keys = await mmkvStorage.getAllKeys(); const watchProgressKeys = keys.filter(key => key.startsWith(prefix)); const pairs = await mmkvStorage.multiGet(watchProgressKeys); - + const result = pairs.reduce((acc, [key, value]) => { if (value) { acc[key.replace(prefix, '')] = JSON.parse(value); } return acc; }, {} as Record); - + // Update cache this.watchProgressCache = result; this.watchProgressCacheTimestamp = now; - + return result; } catch (error) { logger.error('Error getting all watch progress:', error); return {}; } } - + private invalidateWatchProgressCache(): void { this.watchProgressCache = null; this.watchProgressCacheTimestamp = 0; @@ -419,7 +434,7 @@ class StorageService { exactTime?: number ): Promise { try { - const existingProgress = await this.getWatchProgress(id, type, episodeId); + const existingProgress = await this.getWatchProgress(id, type, episodeId); if (existingProgress) { // Preserve the highest Trakt progress and currentTime values to avoid accidental regressions const highestTraktProgress = (() => { @@ -479,9 +494,9 @@ class StorageService { continue; } // Check if needs sync (either never synced or local progress is newer) - const needsSync = !progress.traktSynced || + const needsSync = !progress.traktSynced || (progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced); - + if (needsSync) { const parts = key.split(':'); const type = parts[0]; @@ -517,14 +532,14 @@ class StorageService { ): Promise { try { logger.log(`πŸ—‘οΈ [StorageService] removeAllWatchProgressForContent called for ${type}:${id}`); - + const all = await this.getAllWatchProgress(); const prefix = `${type}:${id}`; logger.log(`πŸ” [StorageService] Looking for keys with prefix: ${prefix}`); - + const matchingKeys = Object.keys(all).filter(key => key === prefix || key.startsWith(`${prefix}:`)); logger.log(`πŸ“Š [StorageService] Found ${matchingKeys.length} matching keys:`, matchingKeys); - + const removals: Array> = []; for (const key of matchingKeys) { // Compute episodeId if present @@ -532,16 +547,16 @@ class StorageService { logger.log(`πŸ—‘οΈ [StorageService] Removing progress for key: ${key} (episodeId: ${episodeId})`); removals.push(this.removeWatchProgress(id, type, episodeId)); } - + await Promise.allSettled(removals); logger.log(`βœ… [StorageService] All watch progress removals completed`); - + if (options?.addBaseTombstone) { logger.log(`πŸͺ¦ [StorageService] Adding tombstone for ${type}:${id}`); await this.addWatchProgressTombstone(id, type); logger.log(`βœ… [StorageService] Tombstone added successfully`); } - + logger.log(`βœ… [StorageService] removeAllWatchProgressForContent completed for ${type}:${id}`); } catch (error) { logger.error(`❌ [StorageService] Error removing all watch progress for content ${type}:${id}:`, error); @@ -562,12 +577,12 @@ class StorageService { try { const localProgress = await this.getWatchProgress(id, type, episodeId); const traktTimestamp = new Date(traktPausedAt).getTime(); - + if (!localProgress) { // No local progress - use stored duration or estimate let duration = await this.getContentDuration(id, type, episodeId); let currentTime: number; - + if (exactTime && exactTime > 0) { // Use exact time from Trakt if available currentTime = exactTime; @@ -589,7 +604,7 @@ class StorageService { } currentTime = (traktProgress / 100) * duration; } - + const newProgress: WatchProgress = { currentTime, duration, @@ -599,41 +614,41 @@ class StorageService { traktProgress }; await this.setWatchProgress(id, type, newProgress, episodeId); - + // Progress creation logging removed } else { // Local progress exists - merge intelligently const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100; - + // Only proceed if there's a significant difference (>5% or different completion status) const progressDiff = Math.abs(traktProgress - localProgressPercent); if (progressDiff < 5 && traktProgress < 100 && localProgressPercent < 100) { return; // Skip minor updates } - + let currentTime: number; let duration = localProgress.duration; - + if (exactTime && exactTime > 0 && localProgress.duration > 0) { // Use exact time from Trakt, keep local duration currentTime = exactTime; - + // If exact time doesn't match the duration well, recalculate duration const calculatedDuration = (exactTime / traktProgress) * 100; const durationDiff = Math.abs(calculatedDuration - localProgress.duration); if (durationDiff > 300) { // More than 5 minutes difference duration = calculatedDuration; - logger.log(`[StorageService] Updated duration based on exact time: ${(localProgress.duration/60).toFixed(0)}min β†’ ${(duration/60).toFixed(0)}min`); + logger.log(`[StorageService] Updated duration based on exact time: ${(localProgress.duration / 60).toFixed(0)}min β†’ ${(duration / 60).toFixed(0)}min`); } } else if (localProgress.duration > 0) { // Use percentage calculation with local duration currentTime = (traktProgress / 100) * localProgress.duration; } else { - // No local duration, check stored duration - const storedDuration = await this.getContentDuration(id, type, episodeId); - duration = storedDuration || 0; - - if (!duration || duration <= 0) { + // No local duration, check stored duration + const storedDuration = await this.getContentDuration(id, type, episodeId); + duration = storedDuration || 0; + + if (!duration || duration <= 0) { if (exactTime && exactTime > 0) { duration = (exactTime / traktProgress) * 100; currentTime = exactTime; @@ -649,21 +664,21 @@ class StorageService { currentTime = (traktProgress / 100) * duration; } } else { - currentTime = exactTime && exactTime > 0 ? exactTime : (traktProgress / 100) * duration; + currentTime = exactTime && exactTime > 0 ? exactTime : (traktProgress / 100) * duration; } } - - const updatedProgress: WatchProgress = { + + const updatedProgress: WatchProgress = { ...localProgress, currentTime, duration, - lastUpdated: traktTimestamp, - traktSynced: true, - traktLastSynced: Date.now(), - traktProgress - }; - await this.setWatchProgress(id, type, updatedProgress, episodeId); - + lastUpdated: traktTimestamp, + traktSynced: true, + traktLastSynced: Date.now(), + traktProgress + }; + await this.setWatchProgress(id, type, updatedProgress, episodeId); + // Progress update logging removed } } catch (error) {