From bd1d8e30ece7a044e99a19cd91988f0c0ab67f0a Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 18:35:04 +0530 Subject: [PATCH 01/88] Refactor FeaturedContent logo fetching logic for clarity and efficiency This update enhances the FeaturedContent component by improving the logo fetching logic. Key changes include clearer variable naming, streamlined ID extraction, and optimized error handling. The logic now better handles logo source preferences and ensures that existing logos are used as fallbacks when necessary. Additionally, the dependency array for the fetch effect has been refined for better performance. Overall, these modifications enhance code readability and maintainability. --- src/components/home/FeaturedContent.tsx | 217 +++++++++++------------- src/components/metadata/HeroSection.tsx | 7 +- src/hooks/useMetadataAssets.ts | 10 +- src/navigation/AppNavigator.tsx | 10 +- src/screens/LogoSourceSettings.tsx | 87 ++-------- 5 files changed, 132 insertions(+), 199 deletions(-) diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index eb4467f..2f88399 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -122,153 +122,132 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat if (!featuredContent || logoFetchInProgress.current) return; const fetchLogo = async () => { - // Set fetch in progress flag logoFetchInProgress.current = true; try { const contentId = featuredContent.id; + const contentData = featuredContent; // Use a clearer variable name + const currentLogo = contentData.logo; - // Get logo source preference from settings - const logoPreference = settings.logoSourcePreference || 'metahub'; // Default to metahub if not set - const preferredLanguage = settings.tmdbLanguagePreference || 'en'; // Get preferred language + // Get preferences + const logoPreference = settings.logoSourcePreference || 'metahub'; + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - // Check if current logo matches preferences - const currentLogo = featuredContent.logo; - if (currentLogo) { - const isCurrentMetahub = isMetahubUrl(currentLogo); - const isCurrentTmdb = isTmdbUrl(currentLogo); - - // If logo already matches preference, use it - if ((logoPreference === 'metahub' && isCurrentMetahub) || - (logoPreference === 'tmdb' && isCurrentTmdb)) { - setLogoUrl(currentLogo); - logoFetchInProgress.current = false; - return; - } + // Reset state for new fetch + setLogoUrl(null); + setLogoLoadError(false); + + // Extract IDs + let imdbId: string | null = null; + if (contentData.id.startsWith('tt')) { + imdbId = contentData.id; + } else if ((contentData as any).imdbId) { + imdbId = (contentData as any).imdbId; + } else if ((contentData as any).externalIds?.imdb_id) { + imdbId = (contentData as any).externalIds.imdb_id; } - // Extract IMDB ID if available - let imdbId = null; - if (featuredContent.id.startsWith('tt')) { - // If the ID itself is an IMDB ID - imdbId = featuredContent.id; - } else if ((featuredContent as any).imdbId) { - // Try to get IMDB ID from the content object if available - imdbId = (featuredContent as any).imdbId; + let tmdbId: string | null = null; + if (contentData.id.startsWith('tmdb:')) { + tmdbId = contentData.id.split(':')[1]; + } else if ((contentData as any).tmdb_id) { + tmdbId = String((contentData as any).tmdb_id); } - // Extract TMDB ID if available - let tmdbId = null; - if (contentId.startsWith('tmdb:')) { - tmdbId = contentId.split(':')[1]; - } - - // First source based on preference - if (logoPreference === 'metahub' && imdbId) { - // Try to get logo from Metahub first - const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - + // If we only have IMDB ID, try to find TMDB ID proactively + if (imdbId && !tmdbId) { try { - const response = await fetch(metahubUrl, { method: 'HEAD' }); - if (response.ok) { - setLogoUrl(metahubUrl); - logoFetchInProgress.current = false; - return; // Exit if Metahub logo was found + const tmdbService = TMDBService.getInstance(); + const foundData = await tmdbService.findTMDBIdByIMDB(imdbId); + if (foundData) { + tmdbId = String(foundData); } - } catch (error) { - // Removed logger.warn + } catch (findError) { + // logger.warn(`[FeaturedContent] Failed to find TMDB ID for ${imdbId}:`, findError); } - - // Fall back to TMDB if Metahub fails and we have a TMDB ID - if (tmdbId) { - const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie'; - try { - const tmdbService = TMDBService.getInstance(); - const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); - - if (logoUrl) { - setLogoUrl(logoUrl); - } else if (currentLogo) { - // If TMDB fails too, use existing logo if any - setLogoUrl(currentLogo); - } - } catch (error) { - // Removed logger.error - if (currentLogo) setLogoUrl(currentLogo); - } - } else if (currentLogo) { - // Use existing logo if we don't have TMDB ID - setLogoUrl(currentLogo); - } - } else if (logoPreference === 'tmdb') { - // Try to get logo from TMDB first - if (tmdbId) { - const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie'; - try { - const tmdbService = TMDBService.getInstance(); - const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); - - if (logoUrl) { - setLogoUrl(logoUrl); - logoFetchInProgress.current = false; - return; // Exit if TMDB logo was found - } - } catch (error) { - // Removed logger.error - } - } else if (imdbId) { - // If we have IMDB ID but no TMDB ID, try to find TMDB ID - try { - const tmdbService = TMDBService.getInstance(); - const foundTmdbId = await tmdbService.findTMDBIdByIMDB(imdbId); - - if (foundTmdbId) { - const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie'; - const logoUrl = await tmdbService.getContentLogo(tmdbType, foundTmdbId.toString(), preferredLanguage); - - if (logoUrl) { - setLogoUrl(logoUrl); - logoFetchInProgress.current = false; - return; // Exit if TMDB logo was found - } - } - } catch (error) { - // Removed logger.error - } - } - - // Fall back to Metahub if TMDB fails and we have an IMDB ID + } + + const tmdbType = contentData.type === 'series' ? 'tv' : 'movie'; + let finalLogoUrl: string | null = null; + let primaryAttempted = false; + let fallbackAttempted = false; + + // --- Logo Fetching Logic --- + + if (logoPreference === 'metahub') { + // Primary: Metahub (needs imdbId) if (imdbId) { + primaryAttempted = true; const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - try { const response = await fetch(metahubUrl, { method: 'HEAD' }); if (response.ok) { - setLogoUrl(metahubUrl); - } else if (currentLogo) { - // If Metahub fails too, use existing logo if any - setLogoUrl(currentLogo); + finalLogoUrl = metahubUrl; } - } catch (error) { - // Removed logger.warn - if (currentLogo) setLogoUrl(currentLogo); - } - } else if (currentLogo) { - // Use existing logo if we don't have IMDB ID - setLogoUrl(currentLogo); + } catch (error) { /* Log if needed */ } + } + + // Fallback: TMDB (needs tmdbId) + if (!finalLogoUrl && tmdbId) { + fallbackAttempted = true; + try { + const tmdbService = TMDBService.getInstance(); + const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); + if (logoUrl) { + finalLogoUrl = logoUrl; + } + } catch (error) { /* Log if needed */ } + } + + } else { // logoPreference === 'tmdb' + // Primary: TMDB (needs tmdbId) + if (tmdbId) { + primaryAttempted = true; + try { + const tmdbService = TMDBService.getInstance(); + const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); + if (logoUrl) { + finalLogoUrl = logoUrl; + } + } catch (error) { /* Log if needed */ } + } + + // Fallback: Metahub (needs imdbId) + if (!finalLogoUrl && imdbId) { + fallbackAttempted = true; + const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; + try { + const response = await fetch(metahubUrl, { method: 'HEAD' }); + if (response.ok) { + finalLogoUrl = metahubUrl; + } + } catch (error) { /* Log if needed */ } } } + + // --- Set Final Logo --- + if (finalLogoUrl) { + setLogoUrl(finalLogoUrl); + } else if (currentLogo) { + // Use existing logo only if primary and fallback failed or weren't applicable + setLogoUrl(currentLogo); + } else { + // No logo found from any source + setLogoLoadError(true); + // logger.warn(`[FeaturedContent] No logo found for ${contentData.name} (${contentId}) with preference ${logoPreference}. Primary attempted: ${primaryAttempted}, Fallback attempted: ${fallbackAttempted}`); + } + } catch (error) { - // Removed logger.error - // Optionally set a fallback logo or handle the error state - setLogoUrl(featuredContent.logo ?? null); // Fallback to initial logo or null + // logger.error('[FeaturedContent] Error in fetchLogo:', error); + setLogoLoadError(true); } finally { logoFetchInProgress.current = false; } }; + // Trigger fetch when content changes fetchLogo(); - }, [featuredContent?.id, settings.logoSourcePreference, settings.tmdbLanguagePreference]); + }, [featuredContent, settings.logoSourcePreference, settings.tmdbLanguagePreference]); // Load poster and logo useEffect(() => { diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index cef9ec4..be3c211 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -286,9 +286,10 @@ const HeroSection: React.FC = ({ })); const parallaxImageStyle = useAnimatedStyle(() => ({ - width: '100%', - height: '120%', + width: '120%', + height: '100%', top: '-10%', + left: '-10%', transform: [ { translateY: interpolate( @@ -302,7 +303,7 @@ const HeroSection: React.FC = ({ scale: interpolate( dampedScrollY.value, [0, 150, 300], - [1.1, 1.02, 0.95], + [1.05, 1.02, 0.99], Extrapolate.CLAMP ) } diff --git a/src/hooks/useMetadataAssets.ts b/src/hooks/useMetadataAssets.ts index 0b44bf0..5bd17f4 100644 --- a/src/hooks/useMetadataAssets.ts +++ b/src/hooks/useMetadataAssets.ts @@ -196,7 +196,15 @@ export const useMetadataAssets = ( else if (shouldFetchLogo && logoFetchInProgress.current) { logger.log(`[useMetadataAssets:Logo] Skipping logo fetch because logoFetchInProgress is true.`); } - }, [id, type, metadata, setMetadata, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference]); // Added tmdbLanguagePreference dependency + }, [ + id, + type, + imdbId, + metadata?.logo, // Depend on the logo value itself, not the whole object + settings.logoSourcePreference, + settings.tmdbLanguagePreference, + setMetadata // Keep setMetadata, but ensure it's memoized in parent + ]); // Fetch banner image based on logo source preference - optimized version useEffect(() => { diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 6720cda..8d8c4b8 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -686,7 +686,8 @@ const AppNavigator = () => { /> { { { settings.logoSourcePreference || 'metahub' ); - // TMDB Language Preference - const [selectedTmdbLanguage, setSelectedTmdbLanguage] = useState( - settings.tmdbLanguagePreference || 'en' - ); - // Make sure logoSource stays in sync with settings useEffect(() => { setLogoSource(settings.logoSourcePreference || 'metahub'); }, [settings.logoSourcePreference]); - // Keep selectedTmdbLanguage in sync with settings - useEffect(() => { - setSelectedTmdbLanguage(settings.tmdbLanguagePreference || 'en'); - }, [settings.tmdbLanguagePreference]); - - // Force reload settings from AsyncStorage when component mounts - useEffect(() => { - const loadSettingsFromStorage = async () => { - try { - const settingsJson = await AsyncStorage.getItem('app_settings'); - if (settingsJson) { - const storedSettings = JSON.parse(settingsJson); - - // Update local state to match stored settings - if (storedSettings.logoSourcePreference) { - setLogoSource(storedSettings.logoSourcePreference); - } - - if (storedSettings.tmdbLanguagePreference) { - setSelectedTmdbLanguage(storedSettings.tmdbLanguagePreference); - } - - logger.log('[LogoSourceSettings] Successfully loaded settings from AsyncStorage'); - } - } catch (error) { - logger.error('[LogoSourceSettings] Error loading settings from AsyncStorage:', error); - } - }; - - loadSettingsFromStorage(); - }, []); - // Selected example show const [selectedShow, setSelectedShow] = useState(EXAMPLE_SHOWS[0]); @@ -429,6 +392,9 @@ const LogoSourceSettings = () => { logger.log(`[LogoSourceSettings] Fetching ${show.name} with TMDB ID: ${tmdbId}, IMDB ID: ${imdbId}`); + // Get preferred language directly from settings + const preferredTmdbLanguage = settings.tmdbLanguagePreference || 'en'; + // Get TMDB logo and banner try { const apiKey = TMDB_API_KEY; @@ -451,15 +417,15 @@ const LogoSourceSettings = () => { // Find initial logo (prefer selectedTmdbLanguage, then 'en') let initialLogoPath: string | null = null; - let initialLanguage = selectedTmdbLanguage; + let initialLanguage = preferredTmdbLanguage; // First try to find a logo in the user's preferred language - const preferredLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === selectedTmdbLanguage); + const preferredLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === preferredTmdbLanguage); if (preferredLogo) { initialLogoPath = preferredLogo.file_path; - initialLanguage = selectedTmdbLanguage; - logger.log(`[LogoSourceSettings] Found initial ${selectedTmdbLanguage} TMDB logo for ${show.name}`); + initialLanguage = preferredTmdbLanguage; + logger.log(`[LogoSourceSettings] Found initial ${preferredTmdbLanguage} TMDB logo for ${show.name}`); } else { // Fallback to English logo const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === 'en'); @@ -478,7 +444,6 @@ const LogoSourceSettings = () => { if (initialLogoPath) { setTmdbLogo(`https://image.tmdb.org/t/p/original${initialLogoPath}`); - setSelectedTmdbLanguage(initialLanguage); // Set selected language based on found logo } else { logger.warn(`[LogoSourceSettings] No valid initial TMDB logo found for ${show.name}`); } @@ -588,9 +553,6 @@ const LogoSourceSettings = () => { // Handle TMDB language selection const handleTmdbLanguageSelect = (languageCode: string) => { - // First set local state for immediate UI updates - setSelectedTmdbLanguage(languageCode); - // Update the preview logo if possible if (tmdbLogosData) { const selectedLogoData = tmdbLogosData.find(logo => logo.iso_639_1 === languageCode); @@ -606,6 +568,9 @@ const LogoSourceSettings = () => { saveLanguagePreference(languageCode); }; + // Get preferred language directly from settings for UI rendering + const preferredTmdbLanguage = settings.tmdbLanguagePreference || 'en'; + // Save language preference with proper persistence const saveLanguagePreference = async (languageCode: string) => { logger.log(`[LogoSourceSettings] Saving TMDB language preference: ${languageCode}`); @@ -614,34 +579,6 @@ const LogoSourceSettings = () => { // First use the settings hook to update the setting - this is crucial updateSetting('tmdbLanguagePreference', languageCode); - // For extra assurance, also save directly to AsyncStorage - // Get current settings from AsyncStorage - const settingsJson = await AsyncStorage.getItem('app_settings'); - - if (settingsJson) { - const currentSettings = JSON.parse(settingsJson); - - // Update the language preference - const updatedSettings = { - ...currentSettings, - tmdbLanguagePreference: languageCode - }; - - // Save back to AsyncStorage using await to ensure it completes - await AsyncStorage.setItem('app_settings', JSON.stringify(updatedSettings)); - logger.log(`[LogoSourceSettings] Successfully saved TMDB language preference '${languageCode}' to AsyncStorage`); - } else { - // If no settings exist yet, create new settings object with this preference - const newSettings = { - ...DEFAULT_SETTINGS, - tmdbLanguagePreference: languageCode - }; - - // Save to AsyncStorage - await AsyncStorage.setItem('app_settings', JSON.stringify(newSettings)); - logger.log(`[LogoSourceSettings] Created new settings with TMDB language preference '${languageCode}'`); - } - // Clear any cached logo data await AsyncStorage.removeItem('_last_logos_'); @@ -875,7 +812,7 @@ const LogoSourceSettings = () => { key={langCode} // Use the unique code as key style={[ styles.languageItem, - selectedTmdbLanguage === langCode && styles.selectedLanguageItem + preferredTmdbLanguage === langCode && styles.selectedLanguageItem ]} onPress={() => handleTmdbLanguageSelect(langCode)} activeOpacity={0.7} @@ -884,7 +821,7 @@ const LogoSourceSettings = () => { {(langCode || '').toUpperCase() || '??'} -- 2.45.2 From d6ddaca0f59638bb79f49b0c61b28f12a78c7712 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 18:37:40 +0530 Subject: [PATCH 02/88] Update HeroSection component to enhance parallax effect and responsiveness This update modifies the HeroSection component by adjusting the parallax image height and refining the interpolation values for translateY and scale transformations. The changes improve the visual dynamics of the parallax effect, enhancing the overall user experience and responsiveness of the component. These modifications contribute to a more polished and engaging interface. --- src/components/metadata/HeroSection.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index be3c211..8436a73 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -287,7 +287,7 @@ const HeroSection: React.FC = ({ const parallaxImageStyle = useAnimatedStyle(() => ({ width: '120%', - height: '100%', + height: '110%', top: '-10%', left: '-10%', transform: [ @@ -295,7 +295,7 @@ const HeroSection: React.FC = ({ translateY: interpolate( dampedScrollY.value, [0, 100, 300], - [20, -20, -60], + [0, -30, -80], Extrapolate.CLAMP ) }, @@ -303,7 +303,7 @@ const HeroSection: React.FC = ({ scale: interpolate( dampedScrollY.value, [0, 150, 300], - [1.05, 1.02, 0.99], + [1.05, 1.03, 1.01], Extrapolate.CLAMP ) } -- 2.45.2 From 8a31483a4c084a1ae48f348ac1515138cf83c185 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 May 2025 18:42:39 +0530 Subject: [PATCH 03/88] Update padding across multiple screens for improved layout consistency This update modifies the padding settings in the CatalogsList, HomeScreen, LibraryScreen, SettingsScreen, and discoverStyles files, increasing the bottom padding to 90 units. These changes enhance the overall layout and visual consistency across the application, ensuring a more cohesive user experience. --- src/components/discover/CatalogsList.tsx | 1 + src/screens/HomeScreen.tsx | 4 ++-- src/screens/LibraryScreen.tsx | 2 ++ src/screens/SettingsScreen.tsx | 2 +- src/styles/screens/discoverStyles.ts | 1 + 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/discover/CatalogsList.tsx b/src/components/discover/CatalogsList.tsx index 5b07495..6a8bcdf 100644 --- a/src/components/discover/CatalogsList.tsx +++ b/src/components/discover/CatalogsList.tsx @@ -37,6 +37,7 @@ const CatalogsList = ({ catalogs, selectedCategory }: CatalogsListProps) => { const styles = StyleSheet.create({ container: { paddingVertical: 8, + paddingBottom: 90, }, }); diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 1b82a1e..bd6684d 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -636,13 +636,13 @@ const styles = StyleSheet.create({ flex: 1, }, scrollContent: { - paddingBottom: 40, + paddingBottom: 90, }, loadingMainContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', - paddingBottom: 40, + paddingBottom: 90, }, loadingText: { marginTop: 12, diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 160ac13..0cf6aff 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -373,6 +373,7 @@ const styles = StyleSheet.create({ listContainer: { paddingHorizontal: 12, paddingVertical: 16, + paddingBottom: 90, }, columnWrapper: { justifyContent: 'space-between', @@ -463,6 +464,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32, + paddingBottom: 90, }, emptyText: { fontSize: 20, diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index a0cd3b7..c21e6fe 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -545,7 +545,7 @@ const styles = StyleSheet.create({ scrollContent: { flexGrow: 1, width: '100%', - paddingBottom: 32, + paddingBottom: 90, }, cardContainer: { width: '100%', diff --git a/src/styles/screens/discoverStyles.ts b/src/styles/screens/discoverStyles.ts index 18d1bee..0e00c21 100644 --- a/src/styles/screens/discoverStyles.ts +++ b/src/styles/screens/discoverStyles.ts @@ -55,6 +55,7 @@ const useDiscoverStyles = () => { justifyContent: 'center', alignItems: 'center', paddingTop: 80, + paddingBottom: 90, }, emptyText: { color: currentTheme.colors.mediumGray, -- 2.45.2 From 0d6408ab63c54da9d8c2363db4ebd047040d4907 Mon Sep 17 00:00:00 2001 From: tapframe Date: Fri, 9 May 2025 16:50:06 +0530 Subject: [PATCH 04/88] Enhance loading state management in useMetadata hook and adjust StreamsScreen styles This update improves the useMetadata hook by adding a delay before marking loading states as complete, allowing Stremio addons more time to load streams. Additionally, the StreamsScreen component has been refined with updated gradient colors and layout adjustments, including padding and height modifications for a more consistent user interface. These changes enhance the overall user experience and visual appeal of the application. --- src/hooks/useMetadata.ts | 27 ++++++++++++++++----------- src/screens/StreamsScreen.tsx | 22 +++++++++++----------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index d464c31..49b4e42 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -150,8 +150,12 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = if (isEpisode) { setEpisodeStreams(updateState); + // Turn off loading when we get streams + setLoadingEpisodeStreams(false); } else { setGroupedStreams(updateState); + // Turn off loading when we get streams + setLoadingStreams(false); } } else { logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found for addon ${addonName} (${addonId})`); @@ -634,15 +638,15 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = return prev; }); + // Add a delay before marking loading as complete to give Stremio addons more time + setTimeout(() => { + setLoadingStreams(false); + }, 10000); // 10 second delay to allow streams to load + } catch (error) { console.error('❌ [loadStreams] Failed to load streams:', error); setError('Failed to load streams'); - } finally { - // Loading is now complete when external sources finish, Stremio updates happen independently. - // We need a better way to track overall completion if we want a final 'FINISHED' log. - const endTime = Date.now() - startTime; - console.log(`🏁 [loadStreams] External sources FINISHED in ${endTime}ms`); - setLoadingStreams(false); // Mark loading=false, but Stremio might still be working + setLoadingStreams(false); } }; @@ -716,14 +720,15 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = return prev; }); + // Add a delay before marking loading as complete to give Stremio addons more time + setTimeout(() => { + setLoadingEpisodeStreams(false); + }, 10000); // 10 second delay to allow streams to load + } catch (error) { console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error); setError('Failed to load episode streams'); - } finally { - // Loading is now complete when external sources finish - const endTime = Date.now() - startTime; - console.log(`🏁 [loadEpisodeStreams] External sources FINISHED in ${endTime}ms`); - setLoadingEpisodeStreams(false); // Mark loading=false, but Stremio might still be working + setLoadingEpisodeStreams(false); } }; diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index c31daea..7dd820c 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -691,12 +691,12 @@ export const StreamsScreen = () => { > @@ -738,12 +738,12 @@ export const StreamsScreen = () => { @@ -810,7 +810,7 @@ export const StreamsScreen = () => { )} - {isLoading && Object.keys(streams).length === 0 ? ( + {isLoading || (Object.keys(streams).length === 0 && (loadingStreams || loadingEpisodeStreams)) ? ( StyleSheet.create({ alignItems: 'center', gap: 8, padding: 14, - paddingTop: Platform.OS === 'android' ? 35 : 45, + paddingTop: Platform.OS === 'android' ? 20 : 15, }, backButtonText: { color: colors.highEmphasis, @@ -892,7 +892,7 @@ const createStyles = (colors: any) => StyleSheet.create({ zIndex: 1, }, streamsMainContentMovie: { - paddingTop: Platform.OS === 'android' ? 90 : 100, + paddingTop: Platform.OS === 'android' ? 10 : 15, }, filterContainer: { paddingHorizontal: 16, @@ -1074,7 +1074,7 @@ const createStyles = (colors: any) => StyleSheet.create({ }, streamsHeroContainer: { width: '100%', - height: 300, + height: 220, marginBottom: 0, position: 'relative', backgroundColor: colors.black, @@ -1203,7 +1203,7 @@ const createStyles = (colors: any) => StyleSheet.create({ }, movieTitleContainer: { width: '100%', - height: 180, + height: 200, backgroundColor: colors.black, pointerEvents: 'box-none', }, -- 2.45.2 From c92ffa7d5e3c9e37d3f621bf4ad723efef724010 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 18 May 2025 22:04:22 +0530 Subject: [PATCH 05/88] Remove patch-package.js and clean up package.json by removing postinstall script This update deletes the patch-package.js file, which was previously used for applying patches, and removes the postinstall script from package.json. The changes streamline the project setup by eliminating unnecessary files and scripts, contributing to a cleaner codebase. --- package.json | 4 ++-- patch-package.js | 42 ------------------------------------------ 2 files changed, 2 insertions(+), 44 deletions(-) delete mode 100644 patch-package.js diff --git a/package.json b/package.json index 116472a..84aadc1 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", - "web": "expo start --web", - "postinstall": "node patch-package.js" + "web": "expo start --web" + }, "dependencies": { "@expo/metro-runtime": "~4.0.1", diff --git a/patch-package.js b/patch-package.js deleted file mode 100644 index 6998999..0000000 --- a/patch-package.js +++ /dev/null @@ -1,42 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); - -// Directory containing patches -const patchesDir = path.join(__dirname, 'src/patches'); - -// Check if the directory exists -if (!fs.existsSync(patchesDir)) { - console.error(`Patches directory not found: ${patchesDir}`); - process.exit(1); -} - -// Get all patch files -const patches = fs.readdirSync(patchesDir).filter(file => file.endsWith('.patch')); - -if (patches.length === 0) { - console.log('No patch files found.'); - process.exit(0); -} - -console.log(`Found ${patches.length} patch files.`); - -// Apply each patch -patches.forEach(patchFile => { - const patchPath = path.join(patchesDir, patchFile); - console.log(`Applying patch: ${patchFile}`); - - try { - // Use the patch command to apply the patch file - execSync(`patch -p1 < ${patchPath}`, { - stdio: 'inherit', - cwd: process.cwd() - }); - console.log(`✅ Successfully applied patch: ${patchFile}`); - } catch (error) { - console.error(`❌ Failed to apply patch ${patchFile}:`, error.message); - // Continue with other patches even if one fails - } -}); - -console.log('Patch process completed.'); \ No newline at end of file -- 2.45.2 From 783948de38cf0927555d7e57f951ed2f8ac706f3 Mon Sep 17 00:00:00 2001 From: tapframe Date: Fri, 23 May 2025 19:18:53 +0530 Subject: [PATCH 06/88] Update package dependencies and refactor StremioService for improved URL handling This update adds the @movie-web/providers package to both package.json and package-lock.json, enhancing the project's capabilities. Additionally, the StremioService class has been refactored to improve the handling of addon base URLs, allowing for the extraction of query parameters and ensuring URLs are correctly formatted. These changes enhance the overall functionality and maintainability of the code. --- package-lock.json | 323 ++++++++++++++++++++++++++++++++- package.json | 2 +- src/services/stremioService.ts | 36 ++-- 3 files changed, 343 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 183de35..13a14f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,11 @@ "": { "name": "nuvio", "version": "1.0.0", - "hasInstallScript": true, "dependencies": { "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "^14.1.0", "@gorhom/bottom-sheet": "^5.1.2", + "@movie-web/providers": "^2.4.13", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/blur": "^4.4.1", "@react-native-community/slider": "^4.5.6", @@ -3248,6 +3248,59 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@movie-web/providers": { + "version": "2.4.13", + "resolved": "https://registry.npmjs.org/@movie-web/providers/-/providers-2.4.13.tgz", + "integrity": "sha512-UDnAdFR6cIy1RxGF1DQa43aF5w1sPhp3H90j2zKPKit7xODspf7PxgJnlnRdg4XhUpHOaEV/eT9W2oaeDMA2VA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "cheerio": "^1.0.0-rc.12", + "cookie": "^0.6.0", + "crypto-js": "^4.2.0", + "form-data": "^4.0.0", + "hls-parser": "^0.13.2", + "iso-639-1": "^3.1.2", + "nanoid": "^3.3.7", + "node-fetch": "^3.3.2", + "set-cookie-parser": "^2.6.0", + "unpacker": "^1.0.1" + } + }, + "node_modules/@movie-web/providers/node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@movie-web/providers/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5498,6 +5551,48 @@ "node": "*" } }, + "node_modules/cheerio": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=18.17" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -5841,6 +5936,15 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/core-js-compat": { "version": "3.41.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", @@ -5901,6 +6005,12 @@ "node": "*" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", @@ -6011,6 +6121,15 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -6347,6 +6466,19 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", + "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -7158,6 +7290,29 @@ "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", "license": "MIT" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fetch-retry": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-4.1.1.tgz", @@ -7335,6 +7490,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/freeport-async": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", @@ -7671,6 +7838,12 @@ "hermes-estree": "0.23.1" } }, + "node_modules/hls-parser": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/hls-parser/-/hls-parser-0.13.5.tgz", + "integrity": "sha512-UJyTCcNZwOdBmEJo86vViEpgtaUhxrgAsBb65+Lk6fjzyOfIDVF9Y0TyE0KJ2Gc5YHfHx7xevuj/RR0itP3vaA==", + "license": "MIT" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -7704,6 +7877,25 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -7744,6 +7936,18 @@ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -8152,6 +8356,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/iso-639-1": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-3.1.5.tgz", + "integrity": "sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", @@ -9716,6 +9929,26 @@ "node": ">= 0.10.5" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -10182,6 +10415,55 @@ "node": ">=10" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", + "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -11638,6 +11920,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -11846,6 +12134,12 @@ "node": ">= 0.8" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -12906,6 +13200,12 @@ "node": ">= 4.0.0" } }, + "node_modules/unpacker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unpacker/-/unpacker-1.0.1.tgz", + "integrity": "sha512-0HTljwp8+JBdITpoHcK1LWi7X9U2BspUmWv78UWZh7NshYhbh1nec8baY/iSbe2OQTZ2bhAtVdnr6/BTD0DKVg==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -13066,12 +13366,33 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/package.json b/package.json index 84aadc1..62d1742 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,12 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web" - }, "dependencies": { "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "^14.1.0", "@gorhom/bottom-sheet": "^5.1.2", + "@movie-web/providers": "^2.4.13", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/blur": "^4.4.1", "@react-native-community/slider": "^4.5.6", diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index ab28bf6..f30e075 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -379,17 +379,20 @@ class StremioService { return result; } - private getAddonBaseURL(url: string): string { - // Remove trailing manifest.json if present - let baseUrl = url.replace(/manifest\.json$/, '').replace(/\/$/, ''); + private getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } { + // Extract query parameters if they exist + const [baseUrl, queryString] = url.split('?'); + + // Remove trailing manifest.json and slashes + let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, ''); // Ensure URL has protocol - if (!baseUrl.startsWith('http')) { - baseUrl = `https://${baseUrl}`; + if (!cleanBaseUrl.startsWith('http')) { + cleanBaseUrl = `https://${cleanBaseUrl}`; } - logger.log('Addon base URL:', baseUrl); - return baseUrl; + logger.log('Addon base URL:', cleanBaseUrl, queryString ? `with query: ${queryString}` : ''); + return { baseUrl: cleanBaseUrl, queryParams: queryString }; } async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise { @@ -431,7 +434,7 @@ class StremioService { } try { - const baseUrl = this.getAddonBaseURL(manifest.url); + const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url); // Build the catalog URL let url = `${baseUrl}/catalog/${type}/${id}.json`; @@ -504,8 +507,8 @@ class StremioService { if (!metaResource) continue; try { - const baseUrl = this.getAddonBaseURL(addon.url || ''); - const url = `${baseUrl}/meta/${type}/${id}.json`; + const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || ''); + const url = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`; const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); @@ -612,8 +615,8 @@ class StremioService { return; } - const baseUrl = this.getAddonBaseURL(addon.url); - const url = `${baseUrl}/stream/${type}/${id}.json`; + const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); + const url = queryParams ? `${baseUrl}/stream/${type}/${id}.json?${queryParams}` : `${baseUrl}/stream/${type}/${id}.json`; logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`); @@ -656,8 +659,9 @@ class StremioService { return null; } - const baseUrl = this.getAddonBaseURL(addon.url); - const url = `${baseUrl}/stream/${type}/${id}.json`; + const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); + const streamPath = `/stream/${type}/${id}.json`; + const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`; logger.log(`Fetching streams from URL: ${url}`); @@ -671,7 +675,7 @@ class StremioService { timeout, headers: { 'Accept': 'application/json', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36' } }); }, 5); // Increase retries for stream fetching @@ -868,7 +872,7 @@ class StremioService { } try { - const baseUrl = this.getAddonBaseURL(openSubtitlesAddon.url || ''); + const baseUrl = this.getAddonBaseURL(openSubtitlesAddon.url || '').baseUrl; // Construct the query URL with the correct format // For series episodes, use the videoId directly which includes series ID + episode info -- 2.45.2 From 5751d755db402378f0f6202c7bf5f736693a1bd8 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 27 May 2025 19:56:24 +0530 Subject: [PATCH 07/88] Add HDRezka support to metadata processing and update package dependencies --- hdrezkas.js | 516 +++++++++++++++++++++++++++++++++ package-lock.json | 1 + package.json | 3 +- scripts/test-hdrezka.js | 434 +++++++++++++++++++++++++++ src/hooks/useMetadata.ts | 90 ++++-- src/screens/StreamsScreen.tsx | 57 +++- src/services/hdrezkaService.ts | 499 +++++++++++++++++++++++++++++++ src/testHDRezka.js | 61 ++++ 8 files changed, 1627 insertions(+), 34 deletions(-) create mode 100644 hdrezkas.js create mode 100644 scripts/test-hdrezka.js create mode 100644 src/services/hdrezkaService.ts create mode 100644 src/testHDRezka.js diff --git a/hdrezkas.js b/hdrezkas.js new file mode 100644 index 0000000..77749ae --- /dev/null +++ b/hdrezkas.js @@ -0,0 +1,516 @@ +// Simplified standalone script to test hdrezka scraper flow +import fetch from 'node-fetch'; +import readline from 'readline'; + +// Constants +const rezkaBase = 'https://hdrezka.ag/'; +const baseHeaders = { + 'X-Hdrezka-Android-App': '1', + 'X-Hdrezka-Android-App-Version': '2.2.0', +}; + +// Parse command line arguments +const args = process.argv.slice(2); +const argOptions = { + title: null, + type: null, + year: null, + season: null, + episode: null +}; + +// Process command line arguments +for (let i = 0; i < args.length; i++) { + if (args[i] === '--title' || args[i] === '-t') { + argOptions.title = args[i + 1]; + i++; + } else if (args[i] === '--type' || args[i] === '-m') { + argOptions.type = args[i + 1].toLowerCase(); + i++; + } else if (args[i] === '--year' || args[i] === '-y') { + argOptions.year = parseInt(args[i + 1]); + i++; + } else if (args[i] === '--season' || args[i] === '-s') { + argOptions.season = parseInt(args[i + 1]); + i++; + } else if (args[i] === '--episode' || args[i] === '-e') { + argOptions.episode = parseInt(args[i + 1]); + i++; + } else if (args[i] === '--help' || args[i] === '-h') { + console.log(` +HDRezka Scraper Test Script + +Usage: + node hdrezka-test.js [options] + +Options: + --title, -t Title to search for + --type, -m <type> Media type (movie or show) + --year, -y <year> Release year + --season, -s <number> Season number (for shows) + --episode, -e <number> Episode number (for shows) + --help, -h Show this help message + +Examples: + node hdrezka-test.js --title "Breaking Bad" --type show --season 1 --episode 3 + node hdrezka-test.js --title "Inception" --type movie --year 2010 + node hdrezka-test.js (interactive mode) +`); + process.exit(0); + } +} + +// Create readline interface for user input +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +// Function to prompt user for input +function prompt(question) { + return new Promise((resolve) => { + rl.question(question, (answer) => { + resolve(answer); + }); + }); +} + +// Helper functions +function generateRandomFavs() { + const randomHex = () => Math.floor(Math.random() * 16).toString(16); + const generateSegment = (length) => Array.from({ length }, randomHex).join(''); + + return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(12)}`; +} + +function extractTitleAndYear(input) { + const regex = /^(.*?),.*?(\d{4})/; + const match = input.match(regex); + + if (match) { + const title = match[1]; + const year = match[2]; + return { title: title.trim(), year: year ? parseInt(year, 10) : null }; + } + return null; +} + +function parseVideoLinks(inputString) { + if (!inputString) { + throw new Error('No video links found'); + } + + console.log(`[PARSE] Parsing video links from stream URL data`); + const linksArray = inputString.split(','); + const result = {}; + + linksArray.forEach((link) => { + // Handle different quality formats: + // 1. Simple format: [360p]https://example.com/video.mp4 + // 2. HTML format: [<span class="pjs-registered-quality">1080p<img...>]https://example.com/video.mp4 + + // Try simple format first (non-HTML) + let match = link.match(/\[([^<\]]+)\](https?:\/\/[^\s,]+\.mp4|null)/); + + // If not found, try HTML format with more flexible pattern + if (!match) { + // Extract quality text from HTML span + const qualityMatch = link.match(/\[<span[^>]*>([^<]+)/); + // Extract URL separately + const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/); + + if (qualityMatch && urlMatch) { + match = [null, qualityMatch[1].trim(), urlMatch[1]]; + } + } + + if (match) { + const qualityText = match[1].trim(); + const mp4Url = match[2]; + + // Extract the quality value (e.g., "360p", "1080p Ultra") + let quality = qualityText; + + // Skip null URLs (premium content that requires login) + if (mp4Url !== 'null') { + result[quality] = { type: 'mp4', url: mp4Url }; + console.log(`[QUALITY] Found ${quality}: ${mp4Url}`); + } else { + console.log(`[QUALITY] Premium quality ${quality} requires login (null URL)`); + } + } else { + console.log(`[WARNING] Could not parse quality from: ${link}`); + } + }); + + console.log(`[PARSE] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`); + return result; +} + +function parseSubtitles(inputString) { + if (!inputString) { + console.log('[SUBTITLES] No subtitles found'); + return []; + } + + console.log(`[PARSE] Parsing subtitles data`); + const linksArray = inputString.split(','); + const captions = []; + + linksArray.forEach((link) => { + const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/); + + if (match) { + const language = match[1]; + const url = match[2]; + + captions.push({ + id: url, + language, + hasCorsRestrictions: false, + type: 'vtt', + url: url, + }); + console.log(`[SUBTITLE] Found ${language}: ${url}`); + } + }); + + console.log(`[PARSE] Found ${captions.length} subtitles`); + return captions; +} + +// Main scraper functions +async function searchAndFindMediaId(media) { + console.log(`[STEP 1] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`); + + const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g; + const idRegexPattern = /\/(\d+)-[^/]+\.html$/; + + const fullUrl = new URL('/engine/ajax/search.php', rezkaBase); + fullUrl.searchParams.append('q', media.title); + + console.log(`[REQUEST] Making search request to: ${fullUrl.toString()}`); + const response = await fetch(fullUrl.toString(), { + headers: baseHeaders + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const searchData = await response.text(); + console.log(`[RESPONSE] Search response length: ${searchData.length}`); + + const movieData = []; + let match; + + while ((match = itemRegexPattern.exec(searchData)) !== null) { + const url = match[1]; + const titleAndYear = match[3]; + + const result = extractTitleAndYear(titleAndYear); + if (result !== null) { + const id = url.match(idRegexPattern)?.[1] || null; + const isMovie = url.includes('/films/'); + const isShow = url.includes('/series/'); + const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown'; + + movieData.push({ + id: id ?? '', + year: result.year ?? 0, + type, + url, + title: match[2] + }); + console.log(`[MATCH] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`); + } + } + + // If year is provided, filter by year + let filteredItems = movieData; + if (media.releaseYear) { + filteredItems = movieData.filter(item => item.year === media.releaseYear); + console.log(`[FILTER] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`); + } + + // If type is provided, filter by type + if (media.type) { + filteredItems = filteredItems.filter(item => item.type === media.type); + console.log(`[FILTER] Items filtered by type ${media.type}: ${filteredItems.length}`); + } + + if (filteredItems.length === 0 && movieData.length > 0) { + console.log(`[WARNING] No items match the exact criteria. Showing all results:`); + movieData.forEach((item, index) => { + console.log(` ${index + 1}. ${item.title} (${item.year}) - ${item.type}`); + }); + + // Let user select from results + const selection = await prompt("Enter the number of the item you want to select (or press Enter to use the first result): "); + const selectedIndex = parseInt(selection) - 1; + + if (!isNaN(selectedIndex) && selectedIndex >= 0 && selectedIndex < movieData.length) { + console.log(`[RESULT] Selected item: id=${movieData[selectedIndex].id}, title=${movieData[selectedIndex].title}`); + return movieData[selectedIndex]; + } else if (movieData.length > 0) { + console.log(`[RESULT] Using first result: id=${movieData[0].id}, title=${movieData[0].title}`); + return movieData[0]; + } + + return null; + } + + if (filteredItems.length > 0) { + console.log(`[RESULT] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`); + return filteredItems[0]; + } else { + console.log(`[ERROR] No matching items found`); + return null; + } +} + +async function getTranslatorId(url, id, media) { + console.log(`[STEP 2] Getting translator ID for url=${url}, id=${id}`); + + // Make sure the URL is absolute + const fullUrl = url.startsWith('http') ? url : `${rezkaBase}${url.startsWith('/') ? url.substring(1) : url}`; + console.log(`[REQUEST] Making request to: ${fullUrl}`); + + const response = await fetch(fullUrl, { + headers: baseHeaders, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseText = await response.text(); + console.log(`[RESPONSE] Translator page response length: ${responseText.length}`); + + // Translator ID 238 represents the Original + subtitles player. + if (responseText.includes(`data-translator_id="238"`)) { + console.log(`[RESULT] Found translator ID 238 (Original + subtitles)`); + return '238'; + } + + const functionName = media.type === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents'; + const regexPattern = new RegExp(`sof\\.tv\\.${functionName}\\(${id}, ([^,]+)`, 'i'); + const match = responseText.match(regexPattern); + const translatorId = match ? match[1] : null; + + console.log(`[RESULT] Extracted translator ID: ${translatorId}`); + return translatorId; +} + +async function getStream(id, translatorId, media) { + console.log(`[STEP 3] Getting stream for id=${id}, translatorId=${translatorId}`); + + const searchParams = new URLSearchParams(); + searchParams.append('id', id); + searchParams.append('translator_id', translatorId); + + if (media.type === 'show') { + searchParams.append('season', media.season.number.toString()); + searchParams.append('episode', media.episode.number.toString()); + console.log(`[PARAMS] Show params: season=${media.season.number}, episode=${media.episode.number}`); + } + + const randomFavs = generateRandomFavs(); + searchParams.append('favs', randomFavs); + searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie'); + + const fullUrl = `${rezkaBase}ajax/get_cdn_series/`; + console.log(`[REQUEST] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`); + + // Log the request details + console.log('[HDRezka][FETCH DEBUG]', { + url: fullUrl, + method: 'POST', + headers: baseHeaders, + body: searchParams.toString() + }); + + const response = await fetch(fullUrl, { + method: 'POST', + body: searchParams, + headers: baseHeaders, + }); + + // Log the response details + let responseHeaders = {}; + if (response.headers && typeof response.headers.forEach === 'function') { + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + } else if (response.headers && response.headers.entries) { + for (const [key, value] of response.headers.entries()) { + responseHeaders[key] = value; + } + } + const responseText = await response.clone().text(); + console.log('[HDRezka][FETCH RESPONSE]', { + status: response.status, + headers: responseHeaders, + text: responseText + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const rawText = await response.text(); + console.log(`[RESPONSE] Stream response length: ${rawText.length}`); + + // Response content-type is text/html, but it's actually JSON + try { + const parsedResponse = JSON.parse(rawText); + console.log(`[RESULT] Parsed response successfully`); + + // Process video qualities and subtitles + const qualities = parseVideoLinks(parsedResponse.url); + const captions = parseSubtitles(parsedResponse.subtitle); + + // Add the parsed data to the response + parsedResponse.formattedQualities = qualities; + parsedResponse.formattedCaptions = captions; + + return parsedResponse; + } catch (e) { + console.error(`[ERROR] Failed to parse JSON response: ${e.message}`); + console.log(`[ERROR] Raw response: ${rawText.substring(0, 200)}...`); + return null; + } +} + +// Main execution +async function main() { + try { + console.log('=== HDREZKA SCRAPER TEST ==='); + + let media; + + // Check if we have command line arguments + if (argOptions.title) { + // Use command line arguments + media = { + type: argOptions.type || 'show', + title: argOptions.title, + releaseYear: argOptions.year || null + }; + + // If it's a show, add season and episode + if (media.type === 'show') { + media.season = { number: argOptions.season || 1 }; + media.episode = { number: argOptions.episode || 1 }; + + console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${media.season.number}E${media.episode.number}`); + } else { + console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`); + } + } else { + // Get user input interactively + const title = await prompt('Enter title to search: '); + const mediaType = await prompt('Enter media type (movie/show): ').then(type => + type.toLowerCase() === 'movie' || type.toLowerCase() === 'show' ? type.toLowerCase() : 'show' + ); + const releaseYear = await prompt('Enter release year (optional): ').then(year => + year ? parseInt(year) : null + ); + + // Create media object + media = { + type: mediaType, + title: title, + releaseYear: releaseYear + }; + + // If it's a show, get season and episode + if (mediaType === 'show') { + const seasonNum = await prompt('Enter season number: ').then(num => parseInt(num) || 1); + const episodeNum = await prompt('Enter episode number: ').then(num => parseInt(num) || 1); + + media.season = { number: seasonNum }; + media.episode = { number: episodeNum }; + + console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${media.season.number}E${media.episode.number}`); + } else { + console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`); + } + } + + // Step 1: Search and find media ID + const result = await searchAndFindMediaId(media); + if (!result || !result.id) { + console.log('No result found, exiting'); + rl.close(); + return; + } + + // Step 2: Get translator ID + const translatorId = await getTranslatorId(result.url, result.id, media); + if (!translatorId) { + console.log('No translator ID found, exiting'); + rl.close(); + return; + } + + // Step 3: Get stream + const streamData = await getStream(result.id, translatorId, media); + if (!streamData) { + console.log('No stream data found, exiting'); + rl.close(); + return; + } + + // Format output in clean JSON similar to CLI output + const formattedOutput = { + embeds: [], + stream: [ + { + id: 'primary', + type: 'file', + flags: ['cors-allowed', 'ip-locked'], + captions: streamData.formattedCaptions.map(caption => ({ + id: caption.url, + language: caption.language === 'Русский' ? 'ru' : + caption.language === 'Українська' ? 'uk' : + caption.language === 'English' ? 'en' : caption.language.toLowerCase(), + hasCorsRestrictions: false, + type: 'vtt', + url: caption.url + })), + qualities: Object.entries(streamData.formattedQualities).reduce((acc, [quality, data]) => { + // Convert quality format to match CLI output + // "360p" -> "360", "1080p Ultra" -> "1080" (or keep as is if needed) + let qualityKey = quality; + const numericMatch = quality.match(/^(\d+)p/); + if (numericMatch) { + qualityKey = numericMatch[1]; + } + + acc[qualityKey] = { + type: data.type, + url: data.url + }; + return acc; + }, {}) + } + ] + }; + + // Display the formatted output + console.log('✓ Done!'); + console.log(JSON.stringify(formattedOutput, null, 2).replace(/"([^"]+)":/g, '$1:')); + + console.log('=== SCRAPING COMPLETE ==='); + } catch (error) { + console.error(`Error: ${error.message}`); + if (error.cause) { + console.error(`Cause: ${error.cause.message}`); + } + } finally { + rl.close(); + } +} + +main(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 13a14f0..cbedefb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "expo-system-ui": "^4.0.9", "expo-web-browser": "~14.0.2", "lodash": "^4.17.21", + "node-fetch": "^2.6.7", "react": "18.3.1", "react-native": "0.76.9", "react-native-awesome-slider": "^2.9.0", diff --git a/package.json b/package.json index 62d1742..a9a4d86 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,8 @@ "react-native-video": "^6.12.0", "react-native-web": "~0.19.13", "react-native-wheel-color-picker": "^1.3.1", - "subsrt": "^1.1.1" + "subsrt": "^1.1.1", + "node-fetch": "^2.6.7" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/scripts/test-hdrezka.js b/scripts/test-hdrezka.js new file mode 100644 index 0000000..d1e8ab1 --- /dev/null +++ b/scripts/test-hdrezka.js @@ -0,0 +1,434 @@ +// Test script for HDRezka service +// Run with: node scripts/test-hdrezka.js + +const fetch = require('node-fetch'); +const readline = require('readline'); + +// Constants +const REZKA_BASE = 'https://hdrezka.ag/'; +const BASE_HEADERS = { + 'X-Hdrezka-Android-App': '1', + 'X-Hdrezka-Android-App-Version': '2.2.0', +}; + +// Create readline interface for user input +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +// Function to prompt user for input +function prompt(question) { + return new Promise((resolve) => { + rl.question(question, (answer) => { + resolve(answer); + }); + }); +} + +// Helper functions +function generateRandomFavs() { + const randomHex = () => Math.floor(Math.random() * 16).toString(16); + const generateSegment = (length) => Array.from({ length }, randomHex).join(''); + + return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(12)}`; +} + +function extractTitleAndYear(input) { + const regex = /^(.*?),.*?(\d{4})/; + const match = input.match(regex); + + if (match) { + const title = match[1]; + const year = match[2]; + return { title: title.trim(), year: year ? parseInt(year, 10) : null }; + } + return null; +} + +function parseVideoLinks(inputString) { + if (!inputString) { + console.warn('No video links found'); + return {}; + } + + console.log(`[PARSE] Parsing video links from stream URL data`); + const linksArray = inputString.split(','); + const result = {}; + + linksArray.forEach((link) => { + // Handle different quality formats + let match = link.match(/\[([^<\]]+)\](https?:\/\/[^\s,]+\.mp4|null)/); + + // If not found, try HTML format with more flexible pattern + if (!match) { + const qualityMatch = link.match(/\[<span[^>]*>([^<]+)/); + const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/); + + if (qualityMatch && urlMatch) { + match = [null, qualityMatch[1].trim(), urlMatch[1]]; + } + } + + if (match) { + const qualityText = match[1].trim(); + const mp4Url = match[2]; + + // Skip null URLs (premium content that requires login) + if (mp4Url !== 'null') { + result[qualityText] = { type: 'mp4', url: mp4Url }; + console.log(`[QUALITY] Found ${qualityText}: ${mp4Url}`); + } else { + console.log(`[QUALITY] Premium quality ${qualityText} requires login (null URL)`); + } + } else { + console.log(`[WARNING] Could not parse quality from: ${link}`); + } + }); + + console.log(`[PARSE] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`); + return result; +} + +function parseSubtitles(inputString) { + if (!inputString) { + console.log('[SUBTITLES] No subtitles found'); + return []; + } + + console.log(`[PARSE] Parsing subtitles data`); + const linksArray = inputString.split(','); + const captions = []; + + linksArray.forEach((link) => { + const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/); + + if (match) { + const language = match[1]; + const url = match[2]; + + captions.push({ + id: url, + language, + hasCorsRestrictions: false, + type: 'vtt', + url: url, + }); + console.log(`[SUBTITLE] Found ${language}: ${url}`); + } + }); + + console.log(`[PARSE] Found ${captions.length} subtitles`); + return captions; +} + +// Main scraper functions +async function searchAndFindMediaId(media) { + console.log(`[STEP 1] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`); + + const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g; + const idRegexPattern = /\/(\d+)-[^/]+\.html$/; + + const fullUrl = new URL('/engine/ajax/search.php', REZKA_BASE); + fullUrl.searchParams.append('q', media.title); + + console.log(`[REQUEST] Making search request to: ${fullUrl.toString()}`); + const response = await fetch(fullUrl.toString(), { + headers: BASE_HEADERS + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const searchData = await response.text(); + console.log(`[RESPONSE] Search response length: ${searchData.length}`); + + const movieData = []; + let match; + + while ((match = itemRegexPattern.exec(searchData)) !== null) { + const url = match[1]; + const titleAndYear = match[3]; + + const result = extractTitleAndYear(titleAndYear); + if (result !== null) { + const id = url.match(idRegexPattern)?.[1] || null; + const isMovie = url.includes('/films/'); + const isShow = url.includes('/series/'); + const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown'; + + movieData.push({ + id: id ?? '', + year: result.year ?? 0, + type, + url, + title: match[2] + }); + console.log(`[MATCH] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`); + } + } + + // If year is provided, filter by year + let filteredItems = movieData; + if (media.releaseYear) { + filteredItems = movieData.filter(item => item.year === media.releaseYear); + console.log(`[FILTER] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`); + } + + // If type is provided, filter by type + if (media.type) { + filteredItems = filteredItems.filter(item => item.type === media.type); + console.log(`[FILTER] Items filtered by type ${media.type}: ${filteredItems.length}`); + } + + if (filteredItems.length === 0 && movieData.length > 0) { + console.log(`[WARNING] No items match the exact criteria. Showing all results:`); + movieData.forEach((item, index) => { + console.log(` ${index + 1}. ${item.title} (${item.year}) - ${item.type}`); + }); + + // Let user select from results + const selection = await prompt("Enter the number of the item you want to select (or press Enter to use the first result): "); + const selectedIndex = parseInt(selection) - 1; + + if (!isNaN(selectedIndex) && selectedIndex >= 0 && selectedIndex < movieData.length) { + console.log(`[RESULT] Selected item: id=${movieData[selectedIndex].id}, title=${movieData[selectedIndex].title}`); + return movieData[selectedIndex]; + } else if (movieData.length > 0) { + console.log(`[RESULT] Using first result: id=${movieData[0].id}, title=${movieData[0].title}`); + return movieData[0]; + } + + return null; + } + + if (filteredItems.length > 0) { + console.log(`[RESULT] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`); + return filteredItems[0]; + } else { + console.log(`[ERROR] No matching items found`); + return null; + } +} + +async function getTranslatorId(url, id, media) { + console.log(`[STEP 2] Getting translator ID for url=${url}, id=${id}`); + + // Make sure the URL is absolute + const fullUrl = url.startsWith('http') ? url : `${REZKA_BASE}${url.startsWith('/') ? url.substring(1) : url}`; + console.log(`[REQUEST] Making request to: ${fullUrl}`); + + const response = await fetch(fullUrl, { + headers: BASE_HEADERS, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseText = await response.text(); + console.log(`[RESPONSE] Translator page response length: ${responseText.length}`); + + // Translator ID 238 represents the Original + subtitles player. + if (responseText.includes(`data-translator_id="238"`)) { + console.log(`[RESULT] Found translator ID 238 (Original + subtitles)`); + return '238'; + } + + const functionName = media.type === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents'; + const regexPattern = new RegExp(`sof\\.tv\\.${functionName}\\(${id}, ([^,]+)`, 'i'); + const match = responseText.match(regexPattern); + const translatorId = match ? match[1] : null; + + console.log(`[RESULT] Extracted translator ID: ${translatorId}`); + return translatorId; +} + +async function getStream(id, translatorId, media) { + console.log(`[STEP 3] Getting stream for id=${id}, translatorId=${translatorId}`); + + const searchParams = new URLSearchParams(); + searchParams.append('id', id); + searchParams.append('translator_id', translatorId); + + if (media.type === 'show') { + searchParams.append('season', media.season.number.toString()); + searchParams.append('episode', media.episode.number.toString()); + console.log(`[PARAMS] Show params: season=${media.season.number}, episode=${media.episode.number}`); + } + + const randomFavs = generateRandomFavs(); + searchParams.append('favs', randomFavs); + searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie'); + + const fullUrl = `${REZKA_BASE}ajax/get_cdn_series/`; + console.log(`[REQUEST] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`); + + const response = await fetch(fullUrl, { + method: 'POST', + body: searchParams, + headers: BASE_HEADERS, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseText = await response.text(); + console.log(`[RESPONSE] Stream response length: ${responseText.length}`); + + // Response content-type is text/html, but it's actually JSON + try { + const parsedResponse = JSON.parse(responseText); + console.log(`[RESULT] Parsed response successfully`); + + // Process video qualities and subtitles + const qualities = parseVideoLinks(parsedResponse.url); + const captions = parseSubtitles(parsedResponse.subtitle); + + return { + qualities, + captions + }; + } catch (e) { + console.error(`[ERROR] Failed to parse JSON response: ${e.message}`); + return null; + } +} + +async function getStreams(mediaId, mediaType, season, episode) { + try { + console.log(`[HDRezka] Getting streams for ${mediaType} with ID: ${mediaId}`); + + // Check if the mediaId appears to be an ID rather than a title + let title = mediaId; + let year; + + // If it's an ID format (starts with 'tt' for IMDB or contains ':' like TMDB IDs) + // For testing, we'll replace it with an example title instead of implementing full TMDB API calls + if (mediaId.startsWith('tt') || mediaId.includes(':')) { + console.log(`[HDRezka] ID format detected for "${mediaId}". Using title search instead.`); + + // For demo purposes only - you would actually get this from TMDB API in real implementation + if (mediaType === 'movie') { + title = "Inception"; // Example movie + year = 2010; + } else { + title = "Breaking Bad"; // Example show + year = 2008; + } + + console.log(`[HDRezka] Using title "${title}" (${year}) for search instead of ID`); + } + + const media = { + title, + type: mediaType === 'movie' ? 'movie' : 'show', + releaseYear: year + }; + + // Step 1: Search and find media ID + const searchResult = await searchAndFindMediaId(media); + if (!searchResult || !searchResult.id) { + console.log('[HDRezka] No search results found'); + return []; + } + + // Step 2: Get translator ID + const translatorId = await getTranslatorId( + searchResult.url, + searchResult.id, + media + ); + + if (!translatorId) { + console.log('[HDRezka] No translator ID found'); + return []; + } + + // Step 3: Get stream + const streamParams = { + type: media.type, + season: season ? { number: season } : undefined, + episode: episode ? { number: episode } : undefined + }; + + const streamData = await getStream(searchResult.id, translatorId, streamParams); + if (!streamData) { + console.log('[HDRezka] No stream data found'); + return []; + } + + // Convert to Stream format + const streams = []; + + Object.entries(streamData.qualities).forEach(([quality, data]) => { + streams.push({ + name: 'HDRezka', + title: quality, + url: data.url, + behaviorHints: { + notWebReady: false + } + }); + }); + + console.log(`[HDRezka] Found ${streams.length} streams`); + return streams; + } catch (error) { + console.error(`[HDRezka] Error getting streams: ${error}`); + return []; + } +} + +// Main execution +async function main() { + try { + console.log('=== HDREZKA SCRAPER TEST ==='); + + // Get user input interactively + const title = await prompt('Enter title to search: '); + const mediaType = await prompt('Enter media type (movie/show): ').then(type => + type.toLowerCase() === 'movie' || type.toLowerCase() === 'show' ? type.toLowerCase() : 'show' + ); + const releaseYear = await prompt('Enter release year (optional): ').then(year => + year ? parseInt(year) : null + ); + + // Create media object + let media = { + title, + type: mediaType, + releaseYear + }; + + let seasonNum, episodeNum; + + // If it's a show, get season and episode + if (mediaType === 'show') { + seasonNum = await prompt('Enter season number: ').then(num => parseInt(num) || 1); + episodeNum = await prompt('Enter episode number: ').then(num => parseInt(num) || 1); + + console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${seasonNum}E${episodeNum}`); + } else { + console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`); + } + + const streams = await getStreams(title, mediaType, seasonNum, episodeNum); + + if (streams && streams.length > 0) { + console.log('✓ Found streams:'); + console.log(JSON.stringify(streams, null, 2)); + } else { + console.log('✗ No streams found'); + } + + } catch (error) { + console.error(`Error: ${error.message}`); + } finally { + rl.close(); + } +} + +main(); \ No newline at end of file diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 49b4e42..ee0a895 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -3,6 +3,7 @@ import { StreamingContent } from '../services/catalogService'; import { catalogService } from '../services/catalogService'; import { stremioService } from '../services/stremioService'; import { tmdbService } from '../services/tmdbService'; +import { hdrezkaService } from '../services/hdrezkaService'; import { cacheService } from '../services/cacheService'; import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata'; import { TMDBService } from '../services/tmdbService'; @@ -177,6 +178,43 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = // Loading indicators should probably be managed based on callbacks completing. }; + const processHDRezkaSource = async (type: string, id: string, season?: number, episode?: number, isEpisode = false) => { + const sourceStartTime = Date.now(); + const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams'; + const sourceName = 'hdrezka'; + + logger.log(`🔍 [${logPrefix}:${sourceName}] Starting fetch`); + + try { + const streams = await hdrezkaService.getStreams( + id, + type, + season, + episode + ); + + const processTime = Date.now() - sourceStartTime; + + if (streams && streams.length > 0) { + logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams after ${processTime}ms`); + + // Format response similar to Stremio format for the UI + return { + 'hdrezka': { + addonName: 'HDRezka', + streams + } + }; + } else { + logger.log(`⚠️ [${logPrefix}:${sourceName}] No streams found after ${processTime}ms`); + return {}; + } + } catch (error) { + logger.error(`❌ [${logPrefix}:${sourceName}] Error:`, error); + return {}; + } + }; + const processExternalSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => { const sourceStartTime = Date.now(); const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams'; @@ -603,15 +641,18 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = // Start Stremio request using the callback method processStremioSource(type, id, false); - // No external sources are used anymore - const fetchPromises: Promise<any>[] = []; + // Add HDRezka source + const hdrezkaPromise = processExternalSource('hdrezka', processHDRezkaSource(type, id), false); + + // Include HDRezka in fetchPromises array + const fetchPromises: Promise<any>[] = [hdrezkaPromise]; - // Wait only for external promises now (none in this case) + // Wait only for external promises now const results = await Promise.allSettled(fetchPromises); const totalTime = Date.now() - startTime; console.log(`✅ [loadStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); - const sourceTypes: string[] = []; // No external sources + const sourceTypes: string[] = ['hdrezka']; results.forEach((result, index) => { const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; console.log(`📊 [loadStreams:${source}] Status: ${result.status}`); @@ -679,19 +720,25 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = console.log('🔄 [loadEpisodeStreams] Starting stream requests'); - const fetchPromises: Promise<any>[] = []; - // Start Stremio request using the callback method processStremioSource('series', episodeId, true); + + // Add HDRezka source for episodes + const seasonNum = parseInt(season, 10); + const episodeNum = parseInt(episode, 10); + const hdrezkaPromise = processExternalSource('hdrezka', + processHDRezkaSource('series', id, seasonNum, episodeNum, true), + true + ); + + const fetchPromises: Promise<any>[] = [hdrezkaPromise]; - // No external sources are used anymore - - // Wait only for external promises now (none in this case) + // Wait only for external promises now const results = await Promise.allSettled(fetchPromises); const totalTime = Date.now() - startTime; console.log(`✅ [loadEpisodeStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); - const sourceTypes: string[] = []; // No external sources + const sourceTypes: string[] = ['hdrezka']; results.forEach((result, index) => { const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`); @@ -703,24 +750,15 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = console.log('🧮 [loadEpisodeStreams] Summary:'); console.log(' Total time for external sources:', totalTime + 'ms'); - // Log the final states - might not include all Stremio addons yet - console.log('📦 [loadEpisodeStreams] Current combined streams count:', - Object.keys(episodeStreams).length > 0 ? - Object.values(episodeStreams).reduce((acc, group: any) => acc + group.streams.length, 0) : - 0 - ); - - // Cache the final streams state - Might be incomplete - setEpisodeStreams(prev => { - // Cache episode streams - maybe incrementally? - setPreloadedEpisodeStreams(currentPreloaded => ({ - ...currentPreloaded, - [episodeId]: prev + // Update preloaded episode streams for future use + if (Object.keys(episodeStreams).length > 0) { + setPreloadedEpisodeStreams(prev => ({ + ...prev, + [episodeId]: { ...episodeStreams } })); - return prev; - }); + } - // Add a delay before marking loading as complete to give Stremio addons more time + // Add a delay before marking loading as complete to give addons more time setTimeout(() => { setLoadingEpisodeStreams(false); }, 10000); // 10 second delay to allow streams to load diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 7dd820c..40b04b2 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -68,9 +68,13 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }: const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV'); const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1]; const isDebrid = stream.behaviorHints?.cached; + + // Determine if this is a HDRezka stream + const isHDRezka = stream.name === 'HDRezka'; - const displayTitle = stream.name || stream.title || 'Unnamed Stream'; - const displayAddonName = stream.title || ''; + // For HDRezka streams, the title contains the quality information + const displayTitle = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream'); + const displayAddonName = isHDRezka ? '' : (stream.title || ''); return ( <TouchableOpacity @@ -126,6 +130,13 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }: <Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text> </View> )} + + {/* Special badge for HDRezka streams */} + {isHDRezka && ( + <View style={[styles.chip, { backgroundColor: theme.colors.accent }]}> + <Text style={[styles.chipText, { color: theme.colors.white }]}>HDREZKA</Text> + </View> + )} </View> </View> @@ -264,7 +275,7 @@ export const StreamsScreen = () => { setLoadStartTime(now); setProviderLoadTimes({}); - // Reset provider status - only for stremio addons + // Reset provider status - include HDRezka setProviderStatus({ 'stremio': { loading: true, @@ -273,12 +284,21 @@ export const StreamsScreen = () => { message: 'Loading...', timeStarted: now, timeCompleted: 0 + }, + 'hdrezka': { + loading: true, + success: false, + error: false, + message: 'Loading...', + timeStarted: now, + timeCompleted: 0 } }); - // Also update the simpler loading state - only for stremio + // Also update the simpler loading state - include HDRezka setLoadingProviders({ - 'stremio': true + 'stremio': true, + 'hdrezka': true }); } }, [loadingStreams, loadingEpisodeStreams]); @@ -287,14 +307,16 @@ export const StreamsScreen = () => { if (type === 'series' && episodeId) { logger.log(`🎬 Loading episode streams for: ${episodeId}`); setLoadingProviders({ - 'stremio': true + 'stremio': true, + 'hdrezka': true }); setSelectedEpisode(episodeId); loadEpisodeStreams(episodeId); } else if (type === 'movie') { logger.log(`🎬 Loading movie streams for: ${id}`); setLoadingProviders({ - 'stremio': true + 'stremio': true, + 'hdrezka': true }); loadStreams(); } @@ -550,6 +572,11 @@ export const StreamsScreen = () => { { id: 'all', name: 'All Providers' }, ...Array.from(availableProviders) .sort((a, b) => { + // Always put HDRezka at the top + if (a === 'hdrezka') return -1; + if (b === 'hdrezka') return 1; + + // Then sort Stremio addons by installation order const indexA = installedAddons.findIndex(addon => addon.id === a); const indexB = installedAddons.findIndex(addon => addon.id === b); @@ -560,6 +587,13 @@ export const StreamsScreen = () => { }) .map(provider => { const addonInfo = streams[provider]; + + // Special handling for HDRezka + if (provider === 'hdrezka') { + return { id: provider, name: 'HDRezka' }; + } + + // Standard handling for Stremio addons const installedAddon = installedAddons.find(addon => addon.id === provider); let displayName = provider; @@ -586,6 +620,11 @@ export const StreamsScreen = () => { return addonId === selectedProvider; }) .sort(([addonIdA], [addonIdB]) => { + // Always put HDRezka at the top + if (addonIdA === 'hdrezka') return -1; + if (addonIdB === 'hdrezka') return 1; + + // Then sort by Stremio addon installation order const indexA = installedAddons.findIndex(addon => addon.id === addonIdA); const indexB = installedAddons.findIndex(addon => addon.id === addonIdB); @@ -637,6 +676,10 @@ export const StreamsScreen = () => { const stream = item; const isLoading = loadingProviders[section.addonId]; + // Special handling for HDRezka streams + const quality = stream.title?.match(/(\d+)p/)?.[1] || null; + const isHDRezka = section.addonId === 'hdrezka'; + return ( <StreamCard key={`${stream.url}-${index}`} diff --git a/src/services/hdrezkaService.ts b/src/services/hdrezkaService.ts new file mode 100644 index 0000000..9d97ca7 --- /dev/null +++ b/src/services/hdrezkaService.ts @@ -0,0 +1,499 @@ +import { logger } from '../utils/logger'; +import { Stream } from '../types/metadata'; +import { tmdbService } from './tmdbService'; +import axios from 'axios'; + +// Use node-fetch if available, otherwise fallback to global fetch +let fetchImpl: typeof fetch; +try { + // @ts-ignore + fetchImpl = require('node-fetch'); +} catch { + fetchImpl = fetch; +} + +// Constants +const REZKA_BASE = 'https://hdrezka.ag/'; +const BASE_HEADERS = { + 'X-Hdrezka-Android-App': '1', + 'X-Hdrezka-Android-App-Version': '2.2.0', +}; + +class HDRezkaService { + private MAX_RETRIES = 3; + private RETRY_DELAY = 1000; // 1 second + + // No cookies/session logic needed for Android app API + private getHeaders() { + return { + ...BASE_HEADERS, + 'User-Agent': 'okhttp/4.9.0', + }; + } + + private generateRandomFavs(): string { + const randomHex = () => Math.floor(Math.random() * 16).toString(16); + const generateSegment = (length: number) => Array.from({ length }, () => randomHex()).join(''); + + return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(12)}`; + } + + private extractTitleAndYear(input: string): { title: string; year: number | null } | null { + // Handle multiple formats + + // Format 1: "Title, YEAR, Additional info" + const regex1 = /^(.*?),.*?(\d{4})/; + const match1 = input.match(regex1); + if (match1) { + const title = match1[1]; + const year = match1[2]; + return { title: title.trim(), year: year ? parseInt(year, 10) : null }; + } + + // Format 2: "Title (YEAR)" + const regex2 = /^(.*?)\s*\((\d{4})\)/; + const match2 = input.match(regex2); + if (match2) { + const title = match2[1]; + const year = match2[2]; + return { title: title.trim(), year: year ? parseInt(year, 10) : null }; + } + + // Format 3: Look for any 4-digit year in the string + const yearMatch = input.match(/(\d{4})/); + if (yearMatch) { + const year = yearMatch[1]; + // Remove the year and any surrounding brackets/parentheses from the title + let title = input.replace(/\s*\(\d{4}\)|\s*\[\d{4}\]|\s*\d{4}/, ''); + return { title: title.trim(), year: year ? parseInt(year, 10) : null }; + } + + // If no year found but we have a title + if (input.trim()) { + return { title: input.trim(), year: null }; + } + + return null; + } + + private parseVideoLinks(inputString: string | undefined): Record<string, { type: string; url: string }> { + if (!inputString) { + logger.log('[HDRezka] No video links found'); + return {}; + } + + logger.log(`[HDRezka] Parsing video links from stream URL data`); + const linksArray = inputString.split(','); + const result: Record<string, { type: string; url: string }> = {}; + + linksArray.forEach((link) => { + // Handle different quality formats: + // 1. Simple format: [360p]https://example.com/video.mp4 + // 2. HTML format: [<span class="pjs-registered-quality">1080p<img...>]https://example.com/video.mp4 + + // Try simple format first (non-HTML) + let match = link.match(/\[([^<\]]+)\](https?:\/\/[^\s,]+\.mp4|null)/); + + // If not found, try HTML format with more flexible pattern + if (!match) { + // Extract quality text from HTML span + const qualityMatch = link.match(/\[<span[^>]*>([^<]+)/); + // Extract URL separately + const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/); + + if (qualityMatch && urlMatch) { + match = [link, qualityMatch[1].trim(), urlMatch[1]] as RegExpMatchArray; + } + } + + if (match) { + const qualityText = match[1].trim(); + const mp4Url = match[2]; + + // Skip null URLs (premium content that requires login) + if (mp4Url !== 'null') { + result[qualityText] = { type: 'mp4', url: mp4Url }; + logger.log(`[HDRezka] Found ${qualityText}: ${mp4Url}`); + } else { + logger.log(`[HDRezka] Premium quality ${qualityText} requires login (null URL)`); + } + } else { + logger.log(`[HDRezka] Could not parse quality from: ${link}`); + } + }); + + logger.log(`[HDRezka] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`); + return result; + } + + private parseSubtitles(inputString: string | undefined): Array<{ + id: string; + language: string; + hasCorsRestrictions: boolean; + type: string; + url: string; + }> { + if (!inputString) { + logger.log('[HDRezka] No subtitles found'); + return []; + } + + logger.log(`[HDRezka] Parsing subtitles data`); + const linksArray = inputString.split(','); + const captions: Array<{ + id: string; + language: string; + hasCorsRestrictions: boolean; + type: string; + url: string; + }> = []; + + linksArray.forEach((link) => { + const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/); + + if (match) { + const language = match[1]; + const url = match[2]; + + captions.push({ + id: url, + language, + hasCorsRestrictions: false, + type: 'vtt', + url: url, + }); + logger.log(`[HDRezka] Found subtitle ${language}: ${url}`); + } + }); + + logger.log(`[HDRezka] Found ${captions.length} subtitles`); + return captions; + } + + async searchAndFindMediaId(media: { title: string; type: string; releaseYear?: number }): Promise<{ + id: string; + year: number; + type: string; + url: string; + title: string; + } | null> { + logger.log(`[HDRezka] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`); + + const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g; + const idRegexPattern = /\/(\d+)-[^/]+\.html$/; + + const fullUrl = new URL('/engine/ajax/search.php', REZKA_BASE); + fullUrl.searchParams.append('q', media.title); + + logger.log(`[HDRezka] Making search request to: ${fullUrl.toString()}`); + try { + const response = await fetchImpl(fullUrl.toString(), { + method: 'GET', + headers: this.getHeaders() + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const searchData = await response.text(); + logger.log(`[HDRezka] Search response length: ${searchData.length}`); + + const movieData: Array<{ + id: string; + year: number; + type: string; + url: string; + title: string; + }> = []; + + let match; + + while ((match = itemRegexPattern.exec(searchData)) !== null) { + const url = match[1]; + const titleAndYear = match[3]; + + const result = this.extractTitleAndYear(titleAndYear); + if (result !== null) { + const id = url.match(idRegexPattern)?.[1] || null; + const isMovie = url.includes('/films/'); + const isShow = url.includes('/series/'); + const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown'; + + movieData.push({ + id: id ?? '', + year: result.year ?? 0, + type, + url, + title: match[2] + }); + logger.log(`[HDRezka] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`); + } + } + + // If year is provided, filter by year + let filteredItems = movieData; + if (media.releaseYear) { + filteredItems = movieData.filter(item => item.year === media.releaseYear); + logger.log(`[HDRezka] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`); + } + + // If type is provided, filter by type + if (media.type) { + filteredItems = filteredItems.filter(item => item.type === media.type); + logger.log(`[HDRezka] Items filtered by type ${media.type}: ${filteredItems.length}`); + } + + if (filteredItems.length > 0) { + logger.log(`[HDRezka] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`); + return filteredItems[0]; + } else if (movieData.length > 0) { + logger.log(`[HDRezka] No exact match, using first result: id=${movieData[0].id}, title=${movieData[0].title}`); + return movieData[0]; + } else { + logger.log(`[HDRezka] No matching items found`); + return null; + } + } catch (error) { + logger.error(`[HDRezka] Search request failed: ${error}`); + return null; + } + } + + async getTranslatorId(url: string, id: string, mediaType: string): Promise<string | null> { + logger.log(`[HDRezka] Getting translator ID for url=${url}, id=${id}`); + + // Make sure the URL is absolute + const fullUrl = url.startsWith('http') ? url : `${REZKA_BASE}${url.startsWith('/') ? url.substring(1) : url}`; + logger.log(`[HDRezka] Making request to: ${fullUrl}`); + + try { + const response = await fetchImpl(fullUrl, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const responseText = await response.text(); + logger.log(`[HDRezka] Translator page response length: ${responseText.length}`); + + // Translator ID 238 represents the Original + subtitles player. + if (responseText.includes(`data-translator_id="238"`)) { + logger.log(`[HDRezka] Found translator ID 238 (Original + subtitles)`); + return '238'; + } + + const functionName = mediaType === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents'; + const regexPattern = new RegExp(`sof\\.tv\\.${functionName}\\(${id}, ([^,]+)`, 'i'); + const match = responseText.match(regexPattern); + const translatorId = match ? match[1] : null; + + logger.log(`[HDRezka] Extracted translator ID: ${translatorId}`); + return translatorId; + } catch (error) { + logger.error(`[HDRezka] Failed to get translator ID: ${error}`); + return null; + } + } + + async getStream(id: string, translatorId: string, media: { + type: string; + season?: { number: number }; + episode?: { number: number }; + }): Promise<any> { + logger.log(`[HDRezka] Getting stream for id=${id}, translatorId=${translatorId}`); + + const searchParams = new URLSearchParams(); + searchParams.append('id', id); + searchParams.append('translator_id', translatorId); + + if (media.type === 'show' && media.season && media.episode) { + searchParams.append('season', media.season.number.toString()); + searchParams.append('episode', media.episode.number.toString()); + logger.log(`[HDRezka] Show params: season=${media.season.number}, episode=${media.episode.number}`); + } + + const randomFavs = this.generateRandomFavs(); + searchParams.append('favs', randomFavs); + searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie'); + + const fullUrl = `${REZKA_BASE}ajax/get_cdn_series/`; + logger.log(`[HDRezka] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`); + + let attempts = 0; + const maxAttempts = 3; + + while (attempts < maxAttempts) { + attempts++; + try { + // Log the request details + logger.log('[HDRezka][AXIOS DEBUG]', { + url: fullUrl, + method: 'POST', + headers: this.getHeaders(), + data: searchParams.toString() + }); + const axiosResponse = await axios.post(fullUrl, searchParams.toString(), { + headers: { + ...this.getHeaders(), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + validateStatus: () => true, + }); + logger.log('[HDRezka][AXIOS RESPONSE]', { + status: axiosResponse.status, + headers: axiosResponse.headers, + data: axiosResponse.data + }); + if (axiosResponse.status !== 200) { + throw new Error(`HTTP error! status: ${axiosResponse.status}`); + } + const responseText = typeof axiosResponse.data === 'string' ? axiosResponse.data : JSON.stringify(axiosResponse.data); + logger.log(`[HDRezka] Stream response length: ${responseText.length}`); + try { + const parsedResponse = typeof axiosResponse.data === 'object' ? axiosResponse.data : JSON.parse(responseText); + logger.log(`[HDRezka] Parsed response successfully: ${JSON.stringify(parsedResponse)}`); + if (!parsedResponse.success && parsedResponse.message) { + logger.error(`[HDRezka] Server returned error: ${parsedResponse.message}`); + if (attempts < maxAttempts) { + logger.log(`[HDRezka] Retrying stream request (attempt ${attempts + 1}/${maxAttempts})...`); + continue; + } + return null; + } + const qualities = this.parseVideoLinks(parsedResponse.url); + const captions = this.parseSubtitles(parsedResponse.subtitle); + return { + qualities, + captions + }; + } catch (e: unknown) { + const error = e instanceof Error ? e.message : String(e); + logger.error(`[HDRezka] Failed to parse JSON response: ${error}`); + if (attempts < maxAttempts) { + logger.log(`[HDRezka] Retrying stream request (attempt ${attempts + 1}/${maxAttempts})...`); + continue; + } + return null; + } + } catch (error) { + logger.error(`[HDRezka] Stream request failed: ${error}`); + if (attempts < maxAttempts) { + logger.log(`[HDRezka] Retrying stream request (attempt ${attempts + 1}/${maxAttempts})...`); + continue; + } + return null; + } + } + logger.error(`[HDRezka] All stream request attempts failed`); + return null; + } + + async getStreams(mediaId: string, mediaType: string, season?: number, episode?: number): Promise<Stream[]> { + try { + logger.log(`[HDRezka] Getting streams for ${mediaType} with ID: ${mediaId}`); + + // First, extract the actual title from TMDB if this is an ID + let title = mediaId; + let year: number | undefined = undefined; + + if (mediaId.startsWith('tt') || mediaId.startsWith('tmdb:')) { + let tmdbId: number | null = null; + + // Handle IMDB IDs + if (mediaId.startsWith('tt')) { + logger.log(`[HDRezka] Converting IMDB ID to TMDB ID: ${mediaId}`); + tmdbId = await tmdbService.findTMDBIdByIMDB(mediaId); + } + // Handle TMDB IDs + else if (mediaId.startsWith('tmdb:')) { + tmdbId = parseInt(mediaId.split(':')[1], 10); + } + + if (tmdbId) { + // Fetch metadata from TMDB API + if (mediaType === 'movie') { + logger.log(`[HDRezka] Fetching movie details from TMDB for ID: ${tmdbId}`); + const movieDetails = await tmdbService.getMovieDetails(tmdbId.toString()); + if (movieDetails) { + title = movieDetails.title; + year = movieDetails.release_date ? parseInt(movieDetails.release_date.substring(0, 4), 10) : undefined; + logger.log(`[HDRezka] Using movie title "${title}" (${year}) for search`); + } + } else { + logger.log(`[HDRezka] Fetching TV show details from TMDB for ID: ${tmdbId}`); + const showDetails = await tmdbService.getTVShowDetails(tmdbId); + if (showDetails) { + title = showDetails.name; + year = showDetails.first_air_date ? parseInt(showDetails.first_air_date.substring(0, 4), 10) : undefined; + logger.log(`[HDRezka] Using TV show title "${title}" (${year}) for search`); + } + } + } + } + + const media = { + title, + type: mediaType === 'movie' ? 'movie' : 'show', + releaseYear: year + }; + + // Step 1: Search and find media ID + const searchResult = await this.searchAndFindMediaId(media); + if (!searchResult || !searchResult.id) { + logger.log('[HDRezka] No search results found'); + return []; + } + + // Step 2: Get translator ID + const translatorId = await this.getTranslatorId( + searchResult.url, + searchResult.id, + media.type + ); + + if (!translatorId) { + logger.log('[HDRezka] No translator ID found'); + return []; + } + + // Step 3: Get stream + const streamParams = { + type: media.type, + season: season ? { number: season } : undefined, + episode: episode ? { number: episode } : undefined + }; + + const streamData = await this.getStream(searchResult.id, translatorId, streamParams); + if (!streamData) { + logger.log('[HDRezka] No stream data found'); + return []; + } + + // Convert to Stream format + const streams: Stream[] = []; + + Object.entries(streamData.qualities).forEach(([quality, data]: [string, any]) => { + streams.push({ + name: 'HDRezka', + title: quality, + url: data.url, + behaviorHints: { + notWebReady: false + } + }); + }); + + logger.log(`[HDRezka] Found ${streams.length} streams`); + return streams; + } catch (error) { + logger.error(`[HDRezka] Error getting streams: ${error}`); + return []; + } + } +} + +export const hdrezkaService = new HDRezkaService(); \ No newline at end of file diff --git a/src/testHDRezka.js b/src/testHDRezka.js new file mode 100644 index 0000000..19154c7 --- /dev/null +++ b/src/testHDRezka.js @@ -0,0 +1,61 @@ +// Test script for HDRezka service +const { hdrezkaService } = require('./services/hdrezkaService'); + +// Enable more detailed console logging +const originalConsoleLog = console.log; +console.log = function(...args) { + const timestamp = new Date().toISOString(); + originalConsoleLog(`[${timestamp}]`, ...args); +}; + +// Test function to get streams from HDRezka +async function testHDRezka() { + console.log('Testing HDRezka service...'); + + // Test a popular movie - "Deadpool & Wolverine" (2024) + const movieId = 'tt6263850'; + console.log(`Testing movie ID: ${movieId}`); + + try { + const streams = await hdrezkaService.getStreams(movieId, 'movie'); + console.log('Streams found:', streams.length); + if (streams.length > 0) { + console.log('First stream:', { + name: streams[0].name, + title: streams[0].title, + url: streams[0].url.substring(0, 100) + '...' // Only show part of the URL + }); + } else { + console.log('No streams found.'); + } + } catch (error) { + console.error('Error testing HDRezka:', error); + } + + // Test a TV show - "House of the Dragon" with a specific episode + const showId = 'tt11198330'; + console.log(`\nTesting TV show ID: ${showId}, Season 2 Episode 1`); + + try { + const streams = await hdrezkaService.getStreams(showId, 'series', 2, 1); + console.log('Streams found:', streams.length); + if (streams.length > 0) { + console.log('First stream:', { + name: streams[0].name, + title: streams[0].title, + url: streams[0].url.substring(0, 100) + '...' // Only show part of the URL + }); + } else { + console.log('No streams found.'); + } + } catch (error) { + console.error('Error testing HDRezka TV show:', error); + } +} + +// Run the test +testHDRezka().then(() => { + console.log('Test completed.'); +}).catch(error => { + console.error('Test failed:', error); +}); \ No newline at end of file -- 2.45.2 From a4b09e6afe5d20c7fce2a103a996dc26f4cf0495 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 27 May 2025 21:21:48 +0530 Subject: [PATCH 08/88] Enhance StreamsScreen loading state management and provider status updates This update improves the loading state management in the StreamsScreen component by refining how loading cycles are tracked and provider statuses are updated. It introduces a more dynamic approach to handling expected providers, ensuring accurate loading indicators and success/error messages based on the availability of streams. Additionally, a helper function for sorting stream quality has been added, enhancing the user experience when selecting streams. Overall, these changes contribute to a more robust and user-friendly streaming experience. --- src/screens/StreamsScreen.tsx | 166 +++++++++++++++++++++++++--------- 1 file changed, 122 insertions(+), 44 deletions(-) diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 40b04b2..456e3fc 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -267,41 +267,108 @@ export const StreamsScreen = () => { } }>({}); - // Monitor streams loading start + // Monitor streams loading start and completion useEffect(() => { + const now = Date.now(); + // Define all providers you expect to load. This could be dynamic. + const expectedProviders = ['stremio', 'hdrezka']; + if (loadingStreams || loadingEpisodeStreams) { - logger.log("⏱️ Stream loading started"); - const now = Date.now(); - setLoadStartTime(now); - setProviderLoadTimes({}); + // --- Stream Loading has STARTED or is IN PROGRESS --- + logger.log("⏱️ Stream loading started or in progress..."); - // Reset provider status - include HDRezka - setProviderStatus({ - 'stremio': { - loading: true, - success: false, - error: false, - message: 'Loading...', - timeStarted: now, - timeCompleted: 0 - }, - 'hdrezka': { - loading: true, - success: false, - error: false, - message: 'Loading...', - timeStarted: now, - timeCompleted: 0 - } - }); + // Set load start time only if this is the beginning of a new loading cycle + if (loadStartTime === 0) { + setLoadStartTime(now); + } - // Also update the simpler loading state - include HDRezka - setLoadingProviders({ - 'stremio': true, - 'hdrezka': true + setProviderLoadTimes({}); // Reset individual provider load times tracker + + // Update provider status to loading for all expected providers + setProviderStatus(prevStatus => { + const newStatus = { ...prevStatus }; + expectedProviders.forEach(providerId => { + // If not already marked as loading, or if it's a fresh cycle, set to loading + if (!newStatus[providerId] || !newStatus[providerId].loading || loadStartTime === 0) { + newStatus[providerId] = { + loading: true, + success: false, + error: false, + message: 'Loading...', + timeStarted: (newStatus[providerId]?.loading && newStatus[providerId]?.timeStarted) ? newStatus[providerId].timeStarted : now, + timeCompleted: 0, + }; + } + }); + return newStatus; }); + + // Update simple loading flag for all expected providers + setLoadingProviders(prevLoading => { + const newLoading = { ...prevLoading }; + expectedProviders.forEach(providerId => { + newLoading[providerId] = true; + }); + return newLoading; + }); + + } else if (loadStartTime > 0) { + // --- Stream Loading has FINISHED --- + // (loadStartTime > 0 implies a loading cycle was active and has now completed) + logger.log("🏁 Stream loading finished. Processing results."); + + const currentStreamsData = type === 'series' ? episodeStreams : groupedStreams; + + // Update simple loading flag: all expected providers are no longer loading + setLoadingProviders(prevLoading => { + const newLoading = { ...prevLoading }; + expectedProviders.forEach(providerId => { + newLoading[providerId] = false; + }); + return newLoading; + }); + + // Update detailed provider status based on results + setProviderStatus(prevStatus => { + const newStatus = { ...prevStatus }; + expectedProviders.forEach(providerId => { + if (newStatus[providerId]) { // Ensure the provider entry exists + const providerHasStreams = currentStreamsData[providerId] && + currentStreamsData[providerId].streams && + currentStreamsData[providerId].streams.length > 0; + + newStatus[providerId] = { + ...newStatus[providerId], // Preserve timeStarted + loading: false, + success: providerHasStreams, + // Mark error if it was loading and now no streams, and wasn't already successful + error: !providerHasStreams && newStatus[providerId].loading && !newStatus[providerId].success, + message: providerHasStreams ? 'Loaded successfully' : (newStatus[providerId].error ? 'Error or no streams' : 'No streams found'), + timeCompleted: now, + }; + } else { + // Fallback if somehow not initialized (should be caught by loading phase) + newStatus[providerId] = { + loading: false, + success: false, + error: true, + message: 'Provider status error (not initialized)', + timeStarted: 0, + timeCompleted: now, + }; + } + }); + return newStatus; + }); + + // Update the set of available providers based on what actually loaded streams + const providersWithStreams = new Set(Object.keys(currentStreamsData)); + setAvailableProviders(providersWithStreams); + + // Reset loadStartTime to signify the end of this loading cycle + setLoadStartTime(0); } - }, [loadingStreams, loadingEpisodeStreams]); + }, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type /* loadStartTime is intentionally omitted from deps here */]); React.useEffect(() => { if (type === 'series' && episodeId) { @@ -314,20 +381,14 @@ export const StreamsScreen = () => { loadEpisodeStreams(episodeId); } else if (type === 'movie') { logger.log(`🎬 Loading movie streams for: ${id}`); - setLoadingProviders({ - 'stremio': true, - 'hdrezka': true - }); + // setLoadingProviders({ // This is now handled by the main effect + // 'stremio': true, + // 'hdrezka': true + // }); loadStreams(); } }, [type, episodeId]); - React.useEffect(() => { - const streams = type === 'series' ? episodeStreams : groupedStreams; - const providers = new Set(Object.keys(streams)); - setAvailableProviders(providers); - }, [type, groupedStreams, episodeStreams]); - React.useEffect(() => { // Trigger entrance animations headerOpacity.value = withTiming(1, { duration: 400 }); @@ -609,6 +670,13 @@ export const StreamsScreen = () => { const streams = type === 'series' ? episodeStreams : groupedStreams; const installedAddons = stremioService.getInstalledAddons(); + // Helper function to extract quality as a number for sorting + const getQualityNumeric = (title: string | undefined): number => { + if (!title) return 0; + const match = title.match(/(\d+)p/); + return match ? parseInt(match[1], 10) : 0; + }; + // Filter streams by selected provider - only if not "all" const filteredEntries = Object.entries(streams) .filter(([addonId]) => { @@ -633,11 +701,21 @@ export const StreamsScreen = () => { if (indexB !== -1) return 1; return 0; }) - .map(([addonId, { addonName, streams }]) => ({ - title: addonName, - addonId, - data: streams - })); + .map(([addonId, { addonName, streams: providerStreams }]) => { + let sortedProviderStreams = providerStreams; + if (addonId === 'hdrezka') { + sortedProviderStreams = [...providerStreams].sort((a, b) => { + const qualityA = getQualityNumeric(a.title); + const qualityB = getQualityNumeric(b.title); + return qualityB - qualityA; // Sort descending (e.g., 1080p before 720p) + }); + } + return { + title: addonName, + addonId, + data: sortedProviderStreams + }; + }); return filteredEntries; }, [selectedProvider, type, episodeStreams, groupedStreams]); -- 2.45.2 From 259d071e95a87c1765eec31eef9b8a3864585c51 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 27 May 2025 22:11:16 +0530 Subject: [PATCH 09/88] Refactor HomeScreen and DropUpMenu components for performance and clarity This update enhances the HomeScreen and DropUpMenu components by implementing React.memo for performance optimization and using useMemo and useCallback hooks to prevent unnecessary re-renders. Additionally, the loading state management has been improved, and the logic for handling menu options has been streamlined. The changes contribute to a more efficient rendering process and a cleaner codebase, enhancing the overall user experience. --- src/screens/HomeScreen.tsx | 283 ++++++++++++++++++--------------- src/services/hdrezkaService.ts | 36 ++++- 2 files changed, 185 insertions(+), 134 deletions(-) diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index bd6684d..5af6b37 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { View, Text, @@ -83,7 +83,7 @@ interface ContinueWatchingRef { refresh: () => Promise<boolean>; } -const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => { +const DropUpMenu = React.memo(({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => { const translateY = useSharedValue(300); const opacity = useSharedValue(0); const isDarkMode = useColorScheme() === 'dark'; @@ -98,9 +98,15 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) opacity.value = withTiming(0, { duration: 200 }); translateY.value = withTiming(300, { duration: 300 }); } + + // Cleanup animations when component unmounts + return () => { + opacity.value = 0; + translateY.value = 300; + }; }, [visible]); - const gesture = Gesture.Pan() + const gesture = useMemo(() => Gesture.Pan() .onStart(() => { // Store initial position if needed }) @@ -124,7 +130,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) translateY.value = withTiming(0, { duration: 300 }); opacity.value = withTiming(1, { duration: 200 }); } - }); + }), [onClose]); const overlayStyle = useAnimatedStyle(() => ({ opacity: opacity.value, @@ -138,7 +144,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white, })); - const menuOptions = [ + const menuOptions = useMemo(() => [ { icon: item.inLibrary ? 'bookmark' : 'bookmark-border', label: item.inLibrary ? 'Remove from Library' : 'Add to Library', @@ -159,7 +165,12 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) label: 'Share', action: 'share' } - ]; + ], [item.inLibrary]); + + const handleOptionSelect = useCallback((action: string) => { + onOptionSelect(action); + onClose(); + }, [onOptionSelect, onClose]); return ( <Modal @@ -200,10 +211,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) { borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)' }, index === menuOptions.length - 1 && styles.lastMenuOption ]} - onPress={() => { - onOptionSelect(option.action); - onClose(); - }} + onPress={() => handleOptionSelect(option.action)} > <MaterialIcons name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"} @@ -225,9 +233,9 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) </GestureHandlerRootView> </Modal> ); -}; +}); -const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { +const ContentItem = React.memo(({ item: initialItem, onPress }: ContentItemProps) => { const [menuVisible, setMenuVisible] = useState(false); const [localItem, setLocalItem] = useState(initialItem); const [isWatched, setIsWatched] = useState(false); @@ -256,8 +264,8 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { setIsWatched(prev => !prev); break; case 'playlist': - break; case 'share': + // These options don't have implementations yet break; } }, [localItem]); @@ -266,16 +274,20 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { setMenuVisible(false); }, []); + // Only update localItem when initialItem changes useEffect(() => { setLocalItem(initialItem); }, [initialItem]); + // Subscribe to library updates useEffect(() => { const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => { const isInLibrary = libraryItems.some( libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type ); - setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary })); + if (isInLibrary !== localItem.inLibrary) { + setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary })); + } }); return () => unsubscribe(); @@ -330,15 +342,24 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { </View> </TouchableOpacity> - <DropUpMenu - visible={menuVisible} - onClose={handleMenuClose} - item={localItem} - onOptionSelect={handleOptionSelect} - /> + {menuVisible && ( + <DropUpMenu + visible={menuVisible} + onClose={handleMenuClose} + item={localItem} + onOptionSelect={handleOptionSelect} + /> + )} </> ); -}; +}, (prevProps, nextProps) => { + // Custom comparison function to prevent unnecessary re-renders + return ( + prevProps.item.id === nextProps.item.id && + prevProps.item.inLibrary === nextProps.item.inLibrary && + prevProps.onPress === nextProps.onPress + ); +}); // Sample categories (real app would get these from API) const SAMPLE_CATEGORIES: Category[] = [ @@ -347,7 +368,7 @@ const SAMPLE_CATEGORIES: Category[] = [ { id: 'channel', name: 'Channels' }, ]; -const SkeletonCatalog = () => { +const SkeletonCatalog = React.memo(() => { const { currentTheme } = useTheme(); return ( <View style={styles.catalogContainer}> @@ -356,7 +377,7 @@ const SkeletonCatalog = () => { </View> </View> ); -}; +}); const HomeScreen = () => { const navigation = useNavigation<NavigationProp<RootStackParamList>>(); @@ -385,7 +406,11 @@ const HomeScreen = () => { } = useFeaturedContent(); // Only count feature section as loading if it's enabled in settings - const isLoading = (showHeroSection ? featuredLoading : false) || catalogsLoading; + const isLoading = useMemo(() => + (showHeroSection ? featuredLoading : false) || catalogsLoading, + [showHeroSection, featuredLoading, catalogsLoading] + ); + const isRefreshing = catalogsRefreshing; // React to settings changes @@ -399,9 +424,6 @@ const HomeScreen = () => { const handleSettingsChange = () => { setShowHeroSection(settings.showHeroSection); setFeaturedContentSource(settings.featuredContentSource); - - // The featured content refresh is now handled by the useFeaturedContent hook - // No need to call refreshFeatured() here to avoid duplicate refreshes }; // Subscribe to settings changes @@ -410,18 +432,6 @@ const HomeScreen = () => { return unsubscribe; }, [settings]); - // Update the featured content refresh logic to handle persistence - useEffect(() => { - // This effect was causing duplicate refreshes - it's now handled in useFeaturedContent - // We'll keep it just to sync the local state with settings - if (showHeroSection && featuredContentSource !== settings.featuredContentSource) { - // Just update the local state - setFeaturedContentSource(settings.featuredContentSource); - } - - // No timeout needed since we're not refreshing here - }, [settings.featuredContentSource, showHeroSection]); - useFocusEffect( useCallback(() => { const statusBarConfig = () => { @@ -451,16 +461,15 @@ const HomeScreen = () => { StatusBar.setTranslucent(false); StatusBar.setBackgroundColor(currentTheme.colors.darkBackground); } + + // Clean up any lingering timeouts + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } }; }, [currentTheme.colors.darkBackground]); - useEffect(() => { - navigation.addListener('beforeRemove', () => {}); - return () => { - navigation.removeListener('beforeRemove', () => {}); - }; - }, [navigation]); - + // Preload images function - memoized to avoid recreating on every render const preloadImages = useCallback(async (content: StreamingContent[]) => { if (!content.length) return; @@ -530,20 +539,37 @@ const HomeScreen = () => { }, []); useEffect(() => { - const handlePlaybackComplete = () => { - refreshContinueWatching(); - }; - const unsubscribe = navigation.addListener('focus', () => { refreshContinueWatching(); }); - return () => { - unsubscribe(); - }; + return unsubscribe; }, [navigation, refreshContinueWatching]); - if (isLoading && !isRefreshing) { + // Memoize the loading screen to prevent unnecessary re-renders + const renderLoadingScreen = useMemo(() => { + if (isLoading && !isRefreshing) { + return ( + <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> + <StatusBar + barStyle="light-content" + backgroundColor="transparent" + translucent + /> + <View style={styles.loadingMainContainer}> + <ActivityIndicator size="large" color={currentTheme.colors.primary} /> + <Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>Loading your content...</Text> + </View> + </View> + ); + } + return null; + }, [isLoading, isRefreshing, currentTheme.colors]); + + // Memoize the main content section + const renderMainContent = useMemo(() => { + if (isLoading && !isRefreshing) return null; + return ( <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <StatusBar @@ -551,81 +577,84 @@ const HomeScreen = () => { backgroundColor="transparent" translucent /> - <View style={styles.loadingMainContainer}> - <ActivityIndicator size="large" color={currentTheme.colors.primary} /> - <Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>Loading your content...</Text> - </View> + <ScrollView + refreshControl={ + <RefreshControl + refreshing={isRefreshing} + onRefresh={handleRefresh} + tintColor={currentTheme.colors.primary} + colors={[currentTheme.colors.primary, currentTheme.colors.secondary]} + /> + } + contentContainerStyle={[ + styles.scrollContent, + { paddingTop: Platform.OS === 'ios' ? 100 : 90 } + ]} + showsVerticalScrollIndicator={false} + removeClippedSubviews={true} + > + {showHeroSection && ( + <FeaturedContent + key={`featured-${showHeroSection}-${featuredContentSource}`} + featuredContent={featuredContent} + isSaved={isSaved} + handleSaveToLibrary={handleSaveToLibrary} + /> + )} + + <Animated.View entering={FadeIn.duration(400).delay(150)}> + <ThisWeekSection /> + </Animated.View> + + {hasContinueWatching && ( + <Animated.View entering={FadeIn.duration(400).delay(250)}> + <ContinueWatchingSection ref={continueWatchingRef} /> + </Animated.View> + )} + + {catalogs.length > 0 ? ( + catalogs.map((catalog, index) => ( + <View key={`${catalog.addon}-${catalog.id}-${index}`}> + <CatalogSection catalog={catalog} /> + </View> + )) + ) : ( + !catalogsLoading && ( + <View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}> + <MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} /> + <Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}> + No content available + </Text> + <TouchableOpacity + style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]} + onPress={() => navigation.navigate('Settings')} + > + <MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} /> + <Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text> + </TouchableOpacity> + </View> + ) + )} + </ScrollView> </View> ); - } + }, [ + isLoading, + isRefreshing, + currentTheme.colors, + showHeroSection, + featuredContent, + isSaved, + handleSaveToLibrary, + hasContinueWatching, + catalogs, + catalogsLoading, + handleRefresh, + navigation, + featuredContentSource + ]); - return ( - <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> - <StatusBar - barStyle="light-content" - backgroundColor="transparent" - translucent - /> - <ScrollView - refreshControl={ - <RefreshControl - refreshing={isRefreshing} - onRefresh={handleRefresh} - tintColor={currentTheme.colors.primary} - colors={[currentTheme.colors.primary, currentTheme.colors.secondary]} - /> - } - contentContainerStyle={[ - styles.scrollContent, - { paddingTop: Platform.OS === 'ios' ? 100 : 90 } - ]} - showsVerticalScrollIndicator={false} - > - {showHeroSection && ( - <FeaturedContent - key={`featured-${showHeroSection}`} - featuredContent={featuredContent} - isSaved={isSaved} - handleSaveToLibrary={handleSaveToLibrary} - /> - )} - - <Animated.View entering={FadeIn.duration(400).delay(150)}> - <ThisWeekSection /> - </Animated.View> - - {hasContinueWatching && ( - <Animated.View entering={FadeIn.duration(400).delay(250)}> - <ContinueWatchingSection ref={continueWatchingRef} /> - </Animated.View> - )} - - {catalogs.length > 0 ? ( - catalogs.map((catalog, index) => ( - <View key={`${catalog.addon}-${catalog.id}-${index}`}> - <CatalogSection catalog={catalog} /> - </View> - )) - ) : ( - !catalogsLoading && ( - <View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}> - <MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} /> - <Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}> - No content available - </Text> - <TouchableOpacity - style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]} - onPress={() => navigation.navigate('Settings')} - > - <MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} /> - <Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text> - </TouchableOpacity> - </View> - ) - )} - </ScrollView> - </View> - ); + return isLoading && !isRefreshing ? renderLoadingScreen : renderMainContent; }; const { width, height } = Dimensions.get('window'); @@ -1045,4 +1074,4 @@ const styles = StyleSheet.create<any>({ }, }); -export default HomeScreen; \ No newline at end of file +export default React.memo(HomeScreen); \ No newline at end of file diff --git a/src/services/hdrezkaService.ts b/src/services/hdrezkaService.ts index 9d97ca7..eb3dd6c 100644 --- a/src/services/hdrezkaService.ts +++ b/src/services/hdrezkaService.ts @@ -280,19 +280,41 @@ class HDRezkaService { const responseText = await response.text(); logger.log(`[HDRezka] Translator page response length: ${responseText.length}`); - // Translator ID 238 represents the Original + subtitles player. + // 1. Check for "Original + Subtitles" specific ID (often ID 238) if (responseText.includes(`data-translator_id="238"`)) { - logger.log(`[HDRezka] Found translator ID 238 (Original + subtitles)`); + logger.log(`[HDRezka] Found specific translator ID 238 (Original + subtitles)`); return '238'; } + // 2. Try to extract from the main CDN init function (e.g., initCDNMoviesEvents, initCDNSeriesEvents) const functionName = mediaType === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents'; - const regexPattern = new RegExp(`sof\\.tv\\.${functionName}\\(${id}, ([^,]+)`, 'i'); - const match = responseText.match(regexPattern); - const translatorId = match ? match[1] : null; + const cdnEventsRegex = new RegExp(`sof\.tv\.${functionName}\(${id}, ([^,]+)`, 'i'); + const cdnEventsMatch = responseText.match(cdnEventsRegex); - logger.log(`[HDRezka] Extracted translator ID: ${translatorId}`); - return translatorId; + if (cdnEventsMatch && cdnEventsMatch[1]) { + const translatorIdFromCdn = cdnEventsMatch[1].trim().replace(/['"]/g, ''); // Remove potential quotes + if (translatorIdFromCdn && translatorIdFromCdn !== 'false' && translatorIdFromCdn !== 'null') { + logger.log(`[HDRezka] Extracted translator ID from CDN init: ${translatorIdFromCdn}`); + return translatorIdFromCdn; + } + } + logger.log(`[HDRezka] CDN init function did not yield a valid translator ID.`); + + // 3. Fallback: Try to find any other data-translator_id attribute in the HTML + // This regex looks for data-translator_id="<digits>" + const anyTranslatorRegex = /data-translator_id="(\d+)"/; + const anyTranslatorMatch = responseText.match(anyTranslatorRegex); + + if (anyTranslatorMatch && anyTranslatorMatch[1]) { + const fallbackTranslatorId = anyTranslatorMatch[1].trim(); + logger.log(`[HDRezka] Found fallback translator ID from data attribute: ${fallbackTranslatorId}`); + return fallbackTranslatorId; + } + logger.log(`[HDRezka] No fallback data-translator_id found.`); + + // If all attempts fail + logger.log(`[HDRezka] Could not find any translator ID for id ${id} on page ${fullUrl}`); + return null; } catch (error) { logger.error(`[HDRezka] Failed to get translator ID: ${error}`); return null; -- 2.45.2 From 10aa799626474dffd7334e3ca1221617ab0aafbf Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 27 May 2025 22:16:43 +0530 Subject: [PATCH 10/88] Add internal providers toggle to settings and update HDRezka service logic --- src/hooks/useSettings.ts | 2 ++ src/screens/SettingsScreen.tsx | 11 ++++++++ src/screens/StreamsScreen.tsx | 48 +++++++++++++++++++++++++++++----- src/services/hdrezkaService.ts | 12 +++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 3f55f4e..aa63dce 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -34,6 +34,7 @@ export interface AppSettings { selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code) + enableInternalProviders: boolean; // Toggle for internal providers like HDRezka } export const DEFAULT_SETTINGS: AppSettings = { @@ -50,6 +51,7 @@ export const DEFAULT_SETTINGS: AppSettings = { selectedHeroCatalogs: [], // Empty array means all catalogs are selected logoSourcePreference: 'metahub', // Default to Metahub as first source tmdbLanguagePreference: 'en', // Default to English + enableInternalProviders: true, // Enable internal providers by default }; const SETTINGS_STORAGE_KEY = 'app_settings'; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index c21e6fe..9dbe102 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -406,6 +406,17 @@ const SettingsScreen: React.FC = () => { onPress={() => navigation.navigate('CatalogSettings')} badge={catalogCount} /> + <SettingItem + title="Internal Providers" + description="Enable or disable built-in providers like HDRezka" + icon="source" + renderControl={() => ( + <CustomSwitch + value={settings.enableInternalProviders} + onValueChange={(value) => updateSetting('enableInternalProviders', value)} + /> + )} + /> <SettingItem title="Home Screen" description="Customize layout and content" diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 456e3fc..eddfc1b 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -318,6 +318,13 @@ export const StreamsScreen = () => { logger.log("🏁 Stream loading finished. Processing results."); const currentStreamsData = type === 'series' ? episodeStreams : groupedStreams; + + // Find all providers that returned streams + const providersWithStreams = Object.entries(currentStreamsData) + .filter(([_, data]) => data.streams && data.streams.length > 0) + .map(([providerId]) => providerId); + + logger.log(`📊 Providers with streams: ${providersWithStreams.join(', ')}`); // Update simple loading flag: all expected providers are no longer loading setLoadingProviders(prevLoading => { @@ -334,8 +341,8 @@ export const StreamsScreen = () => { expectedProviders.forEach(providerId => { if (newStatus[providerId]) { // Ensure the provider entry exists const providerHasStreams = currentStreamsData[providerId] && - currentStreamsData[providerId].streams && - currentStreamsData[providerId].streams.length > 0; + currentStreamsData[providerId].streams && + currentStreamsData[providerId].streams.length > 0; newStatus[providerId] = { ...newStatus[providerId], // Preserve timeStarted @@ -362,14 +369,33 @@ export const StreamsScreen = () => { }); // Update the set of available providers based on what actually loaded streams - const providersWithStreams = new Set(Object.keys(currentStreamsData)); - setAvailableProviders(providersWithStreams); + const providersWithStreamsSet = new Set(providersWithStreams); + setAvailableProviders(providersWithStreamsSet); // Reset loadStartTime to signify the end of this loading cycle - setLoadStartTime(0); + setLoadStartTime(0); } }, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type /* loadStartTime is intentionally omitted from deps here */]); + // Add useEffect to update availableProviders whenever streams change + useEffect(() => { + if (!loadingStreams && !loadingEpisodeStreams) { + const streams = type === 'series' ? episodeStreams : groupedStreams; + // Only include providers that actually have streams + const providers = new Set( + Object.entries(streams) + .filter(([_, data]) => data.streams && data.streams.length > 0) + .map(([providerId]) => providerId) + ); + setAvailableProviders(providers); + + // Also reset the selected provider to 'all' if the current selection is no longer available + if (selectedProvider !== 'all' && !providers.has(selectedProvider)) { + setSelectedProvider('all'); + } + } + }, [type, groupedStreams, episodeStreams, loadingStreams, loadingEpisodeStreams, selectedProvider]); + React.useEffect(() => { if (type === 'series' && episodeId) { logger.log(`🎬 Loading episode streams for: ${episodeId}`); @@ -628,10 +654,20 @@ export const StreamsScreen = () => { const filterItems = useMemo(() => { const installedAddons = stremioService.getInstalledAddons(); const streams = type === 'series' ? episodeStreams : groupedStreams; + + // Make sure we include all providers with streams, not just those in availableProviders + const allProviders = new Set([ + ...availableProviders, + ...Object.keys(streams).filter(key => + streams[key] && + streams[key].streams && + streams[key].streams.length > 0 + ) + ]); return [ { id: 'all', name: 'All Providers' }, - ...Array.from(availableProviders) + ...Array.from(allProviders) .sort((a, b) => { // Always put HDRezka at the top if (a === 'hdrezka') return -1; diff --git a/src/services/hdrezkaService.ts b/src/services/hdrezkaService.ts index eb3dd6c..98b39e3 100644 --- a/src/services/hdrezkaService.ts +++ b/src/services/hdrezkaService.ts @@ -2,6 +2,8 @@ import { logger } from '../utils/logger'; import { Stream } from '../types/metadata'; import { tmdbService } from './tmdbService'; import axios from 'axios'; +import { settingsEmitter } from '../hooks/useSettings'; +import AsyncStorage from '@react-native-async-storage/async-storage'; // Use node-fetch if available, otherwise fallback to global fetch let fetchImpl: typeof fetch; @@ -418,6 +420,16 @@ class HDRezkaService { try { logger.log(`[HDRezka] Getting streams for ${mediaType} with ID: ${mediaId}`); + // First check if internal providers are enabled + const settingsJson = await AsyncStorage.getItem('app_settings'); + if (settingsJson) { + const settings = JSON.parse(settingsJson); + if (settings.enableInternalProviders === false) { + logger.log('[HDRezka] Internal providers are disabled in settings, skipping HDRezka'); + return []; + } + } + // First, extract the actual title from TMDB if this is an ID let title = mediaId; let year: number | undefined = undefined; -- 2.45.2 From 92704f09987b12461f9b6aad32212975c50fafca Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 27 May 2025 22:19:08 +0530 Subject: [PATCH 11/88] Enhance StreamsScreen animations and layout for improved user experience This update introduces staggered animations for stream cards and provider filters, enhancing the visual appeal during loading states. The layout has been refined to ensure a smoother transition and better organization of stream details and actions. Additionally, the use of Animated.View components improves performance and responsiveness, contributing to a more engaging user interface. --- src/screens/StreamsScreen.tsx | 221 ++++++++++++++++++---------------- 1 file changed, 120 insertions(+), 101 deletions(-) diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index eddfc1b..ac5ef76 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -42,7 +42,8 @@ import Animated, { Extrapolate, runOnJS, cancelAnimation, - SharedValue + SharedValue, + Layout } from 'react-native-reanimated'; import { logger } from '../utils/logger'; @@ -76,78 +77,86 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }: const displayTitle = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream'); const displayAddonName = isHDRezka ? '' : (stream.title || ''); + // Animation delay based on index - stagger effect + const enterDelay = 100 + (index * 50); + return ( - <TouchableOpacity - style={[ - styles.streamCard, - isLoading && styles.streamCardLoading - ]} - onPress={onPress} - disabled={isLoading} - activeOpacity={0.7} + <Animated.View + entering={FadeInDown.duration(300).delay(enterDelay).springify()} + layout={Layout.springify()} > - <View style={styles.streamDetails}> - <View style={styles.streamNameRow}> - <View style={styles.streamTitleContainer}> - <Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}> - {displayTitle} - </Text> - {displayAddonName && displayAddonName !== displayTitle && ( - <Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}> - {displayAddonName} + <TouchableOpacity + style={[ + styles.streamCard, + isLoading && styles.streamCardLoading + ]} + onPress={onPress} + disabled={isLoading} + activeOpacity={0.7} + > + <View style={styles.streamDetails}> + <View style={styles.streamNameRow}> + <View style={styles.streamTitleContainer}> + <Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}> + {displayTitle} </Text> + {displayAddonName && displayAddonName !== displayTitle && ( + <Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}> + {displayAddonName} + </Text> + )} + </View> + + {/* Show loading indicator if stream is loading */} + {isLoading && ( + <View style={styles.loadingIndicator}> + <ActivityIndicator size="small" color={theme.colors.primary} /> + <Text style={[styles.loadingText, { color: theme.colors.primary }]}> + {statusMessage || "Loading..."} + </Text> + </View> )} </View> - {/* Show loading indicator if stream is loading */} - {isLoading && ( - <View style={styles.loadingIndicator}> - <ActivityIndicator size="small" color={theme.colors.primary} /> - <Text style={[styles.loadingText, { color: theme.colors.primary }]}> - {statusMessage || "Loading..."} - </Text> - </View> - )} + <View style={styles.streamMetaRow}> + {quality && quality >= "720" && ( + <QualityBadge type="HD" /> + )} + + {isDolby && ( + <QualityBadge type="VISION" /> + )} + + {size && ( + <View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}> + <Text style={[styles.chipText, { color: theme.colors.white }]}>{size}</Text> + </View> + )} + + {isDebrid && ( + <View style={[styles.chip, { backgroundColor: theme.colors.success }]}> + <Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text> + </View> + )} + + {/* Special badge for HDRezka streams */} + {isHDRezka && ( + <View style={[styles.chip, { backgroundColor: theme.colors.accent }]}> + <Text style={[styles.chipText, { color: theme.colors.white }]}>HDREZKA</Text> + </View> + )} + </View> </View> - <View style={styles.streamMetaRow}> - {quality && quality >= "720" && ( - <QualityBadge type="HD" /> - )} - - {isDolby && ( - <QualityBadge type="VISION" /> - )} - - {size && ( - <View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}> - <Text style={[styles.chipText, { color: theme.colors.white }]}>{size}</Text> - </View> - )} - - {isDebrid && ( - <View style={[styles.chip, { backgroundColor: theme.colors.success }]}> - <Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text> - </View> - )} - - {/* Special badge for HDRezka streams */} - {isHDRezka && ( - <View style={[styles.chip, { backgroundColor: theme.colors.accent }]}> - <Text style={[styles.chipText, { color: theme.colors.white }]}>HDREZKA</Text> - </View> - )} + <View style={styles.streamAction}> + <MaterialIcons + name="play-arrow" + size={24} + color={theme.colors.primary} + /> </View> - </View> - - <View style={styles.streamAction}> - <MaterialIcons - name="play-arrow" - size={24} - color={theme.colors.primary} - /> - </View> - </TouchableOpacity> + </TouchableOpacity> + </Animated.View> ); }; @@ -174,44 +183,53 @@ const ProviderFilter = memo(({ }) => { const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); - const renderItem = useCallback(({ item }: { item: { id: string; name: string } }) => ( - <TouchableOpacity - key={item.id} - style={[ - styles.filterChip, - selectedProvider === item.id && styles.filterChipSelected - ]} - onPress={() => onSelect(item.id)} + const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => ( + <Animated.View + entering={FadeIn.duration(300).delay(100 + index * 40)} + layout={Layout.springify()} > - <Text style={[ - styles.filterChipText, - selectedProvider === item.id && styles.filterChipTextSelected - ]}> - {item.name} - </Text> - </TouchableOpacity> + <TouchableOpacity + key={item.id} + style={[ + styles.filterChip, + selectedProvider === item.id && styles.filterChipSelected + ]} + onPress={() => onSelect(item.id)} + > + <Text style={[ + styles.filterChipText, + selectedProvider === item.id && styles.filterChipTextSelected + ]}> + {item.name} + </Text> + </TouchableOpacity> + </Animated.View> ), [selectedProvider, onSelect, styles]); return ( - <FlatList - data={providers} - renderItem={renderItem} - keyExtractor={item => item.id} - horizontal - showsHorizontalScrollIndicator={false} - style={styles.filterScroll} - bounces={true} - overScrollMode="never" - decelerationRate="fast" - initialNumToRender={5} - maxToRenderPerBatch={3} - windowSize={3} - getItemLayout={(data, index) => ({ - length: 100, // Approximate width of each item - offset: 100 * index, - index, - })} - /> + <Animated.View + entering={FadeIn.duration(300)} + > + <FlatList + data={providers} + renderItem={renderItem} + keyExtractor={item => item.id} + horizontal + showsHorizontalScrollIndicator={false} + style={styles.filterScroll} + bounces={true} + overScrollMode="never" + decelerationRate="fast" + initialNumToRender={5} + maxToRenderPerBatch={3} + windowSize={3} + getItemLayout={(data, index) => ({ + length: 100, // Approximate width of each item + offset: 100 * index, + index, + })} + /> + </Animated.View> ); }); @@ -807,13 +825,14 @@ export const StreamsScreen = () => { ); }, [handleStreamPress, loadingProviders, providerStatus, currentTheme]); - const renderSectionHeader = useCallback(({ section }: { section: { title: string } }) => ( + const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string } }) => ( <Animated.View - entering={FadeIn.duration(300)} + entering={FadeIn.duration(400)} + layout={Layout.springify()} > <Text style={styles.streamGroupTitle}>{section.title}</Text> </Animated.View> - ), []); + ), [styles.streamGroupTitle]); return ( <View style={styles.container}> -- 2.45.2 From 9465486a471e368b211b3f5fdc66b0389f4df506 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Sun, 8 Jun 2025 13:16:43 +0530 Subject: [PATCH 12/88] Add VLC media player support to VideoPlayer component and update dependencies This update integrates the react-native-vlc-media-player into the VideoPlayer component, replacing the previous video player implementation. It introduces new state management for audio and subtitle tracks specific to VLC, enhances buffering handling, and updates the UI for audio and subtitle selection modals. Additionally, package.json and package-lock.json have been updated to include the new VLC media player dependency and node-fetch. The TypeScript configuration has also been adjusted to support JSX and module interoperability. --- package-lock.json | 104 ++++++++ package.json | 5 +- src/screens/VideoPlayer.tsx | 516 ++++++++++++++++++++++-------------- tsconfig.json | 4 +- 4 files changed, 434 insertions(+), 195 deletions(-) diff --git a/package-lock.json b/package-lock.json index cbedefb..60caca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "react-native-tab-view": "^4.0.10", "react-native-url-polyfill": "^2.0.0", "react-native-video": "^6.12.0", + "react-native-vlc-media-player": "^1.0.87", "react-native-web": "~0.19.13", "react-native-wheel-color-picker": "^1.3.1", "subsrt": "^1.1.1" @@ -11294,6 +11295,15 @@ "react-native": "*" } }, + "node_modules/react-native-slider": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/react-native-slider/-/react-native-slider-0.11.0.tgz", + "integrity": "sha512-jV9K87eu9uWr0uJIyrSpBLnCKvVlOySC2wynq9TFCdV9oGgjt7Niq8Q1A8R8v+5GHsuBw/s8vEj1AAkkUi+u+w==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.6" + } + }, "node_modules/react-native-svg": { "version": "15.11.2", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.11.2.tgz", @@ -11455,6 +11465,100 @@ "react-native": "*" } }, + "node_modules/react-native-vlc-media-player": { + "version": "1.0.87", + "resolved": "https://registry.npmjs.org/react-native-vlc-media-player/-/react-native-vlc-media-player-1.0.87.tgz", + "integrity": "sha512-b05fW2WXVEFoatUcEcszi49FyiBF6ca9HZNQgpJYahL79obLHXRUMejh1RMlxC511UKS+TsDIe2pMJfi8NFbaA==", + "license": "MIT", + "dependencies": { + "react-native-slider": "^0.11.0", + "react-native-vector-icons": "^9.2.0" + } + }, + "node_modules/react-native-vlc-media-player/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/react-native-vlc-media-player/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/react-native-vlc-media-player/node_modules/react-native-vector-icons": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-9.2.0.tgz", + "integrity": "sha512-wKYLaFuQST/chH3AJRjmOLoLy3JEs1JR6zMNgTaemFpNoXs0ztRnTxcxFD9xhX7cJe1/zoN5BpQYe7kL0m5yyA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2", + "yargs": "^16.1.1" + }, + "bin": { + "fa5-upgrade": "bin/fa5-upgrade.sh", + "generate-icon": "bin/generate-icon.js" + } + }, + "node_modules/react-native-vlc-media-player/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-native-vlc-media-player/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-native-vlc-media-player/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native-vlc-media-player/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/react-native-web": { "version": "0.19.13", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz", diff --git a/package.json b/package.json index a9a4d86..7d70e09 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "expo-system-ui": "^4.0.9", "expo-web-browser": "~14.0.2", "lodash": "^4.17.21", + "node-fetch": "^2.6.7", "react": "18.3.1", "react-native": "0.76.9", "react-native-awesome-slider": "^2.9.0", @@ -61,10 +62,10 @@ "react-native-tab-view": "^4.0.10", "react-native-url-polyfill": "^2.0.0", "react-native-video": "^6.12.0", + "react-native-vlc-media-player": "^1.0.87", "react-native-web": "~0.19.13", "react-native-wheel-color-picker": "^1.3.1", - "subsrt": "^1.1.1", - "node-fetch": "^2.6.7" + "subsrt": "^1.1.1" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/src/screens/VideoPlayer.tsx b/src/screens/VideoPlayer.tsx index 7aa4b47..caf4673 100644 --- a/src/screens/VideoPlayer.tsx +++ b/src/screens/VideoPlayer.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { View, TouchableOpacity, StyleSheet, Text, Dimensions, Modal, Pressable, StatusBar, Platform, ScrollView, Animated } from 'react-native'; -import Video from 'react-native-video'; +import { VLCPlayer } from 'react-native-vlc-media-player'; import { Ionicons } from '@expo/vector-icons'; import { Slider } from 'react-native-awesome-slider'; import { LinearGradient } from 'expo-linear-gradient'; @@ -69,9 +69,9 @@ interface TextTrack { type?: string | null; // Adjusting type based on linter error } -// Define the possible resize modes -type ResizeModeType = 'contain' | 'cover' | 'stretch' | 'none'; -const resizeModes: ResizeModeType[] = ['contain', 'cover', 'stretch']; +// Define the possible resize modes - adjust to match VLCPlayer's PlayerResizeMode options +type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none'; +const resizeModes: ResizeModeType[] = ['contain', 'cover', 'fill']; // Add language code to name mapping const languageMap: {[key: string]: string} = { @@ -132,6 +132,18 @@ const formatLanguage = (code?: string): string => { return languageMap[normalized] || code.toUpperCase(); }; +// Add VLC specific interface for their event structure +interface VlcMediaEvent { + currentTime: number; + duration: number; + bufferTime?: number; + isBuffering?: boolean; + audioTracks?: Array<{id: number, name: string, language?: string}>; + textTracks?: Array<{id: number, name: string, language?: string}>; + selectedAudioTrack?: number; + selectedTextTrack?: number; +} + const VideoPlayer: React.FC = () => { const navigation = useNavigation(); const route = useRoute<RouteProp<RootStackParamList, 'Player'>>(); @@ -176,7 +188,8 @@ const VideoPlayer: React.FC = () => { const [textTracks, setTextTracks] = useState<TextTrack[]>([]); const [selectedTextTrack, setSelectedTextTrack] = useState<SelectedTrack | null>({ type: 'disabled' }); const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain'); // State for resize mode - const videoRef = useRef<any>(null); + const [buffered, setBuffered] = useState(0); // Add buffered state + const vlcRef = useRef<any>(null); const progress = useSharedValue(0); const min = useSharedValue(0); const max = useSharedValue(duration); @@ -199,8 +212,12 @@ const VideoPlayer: React.FC = () => { // Add animated value for controls opacity const fadeAnim = useRef(new Animated.Value(1)).current; - // Add buffered state - const [buffered, setBuffered] = useState<number>(0); + // Add VLC specific state and refs + const [isBuffering, setIsBuffering] = useState(false); + + // Modify audio tracks handling for VLC + const [vlcAudioTracks, setVlcAudioTracks] = useState<Array<{id: number, name: string, language?: string}>>([]); + const [vlcTextTracks, setVlcTextTracks] = useState<Array<{id: number, name: string, language?: string}>>([]); // Lock screen to landscape when component mounts useEffect(() => { @@ -344,22 +361,29 @@ const VideoPlayer: React.FC = () => { }; const onSliderValueChange = (value: number) => { - if (videoRef.current) { + if (vlcRef.current) { const newTime = Math.floor(value); - videoRef.current.seek(newTime); + vlcRef.current.seek(newTime); setCurrentTime(newTime); progress.value = newTime; } }; const togglePlayback = () => { - setPaused(!paused); + if (vlcRef.current) { + if (paused) { + vlcRef.current.resume(); + } else { + vlcRef.current.pause(); + } + setPaused(!paused); + } }; const skip = (seconds: number) => { - if (videoRef.current) { + if (vlcRef.current) { const newTime = Math.max(0, Math.min(currentTime + seconds, duration)); - videoRef.current.seek(newTime); + vlcRef.current.seek(newTime); setCurrentTime(newTime); progress.value = newTime; } @@ -370,21 +394,21 @@ const VideoPlayer: React.FC = () => { progress.value = data.currentTime; }; - const onLoad = (data: { duration: number }) => { - setDuration(data.duration); - max.value = data.duration; + const onLoad = (data: any) => { + setDuration(data.duration / 1000); // VLC returns duration in milliseconds + max.value = data.duration / 1000; - logger.log(`[VideoPlayer] Video loaded with duration: ${data.duration}`); + logger.log(`[VideoPlayer] Video loaded with duration: ${data.duration / 1000}`); // If we have an initial position to seek to, do it now - if (initialPosition !== null && !isInitialSeekComplete && videoRef.current) { + if (initialPosition !== null && !isInitialSeekComplete && vlcRef.current) { logger.log(`[VideoPlayer] Will seek to saved position: ${initialPosition}`); // Seek immediately with a small delay setTimeout(() => { - if (videoRef.current) { + if (vlcRef.current) { try { - videoRef.current.seek(initialPosition); + vlcRef.current.seek(initialPosition); setCurrentTime(initialPosition); progress.value = initialPosition; setIsInitialSeekComplete(true); @@ -393,7 +417,7 @@ const VideoPlayer: React.FC = () => { logger.error('[VideoPlayer] Error seeking to saved position:', error); } } else { - logger.error('[VideoPlayer] videoRef is no longer valid when attempting to seek'); + logger.error('[VideoPlayer] vlcRef is no longer valid when attempting to seek'); } }, 1000); // Increase delay to ensure video is fully loaded } else { @@ -402,7 +426,7 @@ const VideoPlayer: React.FC = () => { } else if (isInitialSeekComplete) { logger.log(`[VideoPlayer] Initial seek already completed`); } else { - logger.log(`[VideoPlayer] videoRef not available for seeking`); + logger.log(`[VideoPlayer] vlcRef not available for seeking`); } } }; @@ -484,12 +508,12 @@ const VideoPlayer: React.FC = () => { } }, [showSubtitleModal, textTracks]); - // Attempt to seek once videoRef is available + // Attempt to seek once vlcRef is available useEffect(() => { - if (initialPosition !== null && !isInitialSeekComplete && videoRef.current) { - logger.log(`[VideoPlayer] videoRef is now available, attempting to seek to: ${initialPosition}`); + if (initialPosition !== null && !isInitialSeekComplete && vlcRef.current) { + logger.log(`[VideoPlayer] vlcRef is now available, attempting to seek to: ${initialPosition}`); try { - videoRef.current.seek(initialPosition); + vlcRef.current.seek(initialPosition); setCurrentTime(initialPosition); progress.value = initialPosition; setIsInitialSeekComplete(true); @@ -498,7 +522,7 @@ const VideoPlayer: React.FC = () => { logger.error('[VideoPlayer] Error seeking to position on ref available:', error); } } - }, [videoRef.current, initialPosition, isInitialSeekComplete]); + }, [vlcRef.current, initialPosition, isInitialSeekComplete]); // Load resume preference on mount useEffect(() => { @@ -539,9 +563,9 @@ const VideoPlayer: React.FC = () => { } }; - // Handle resume from overlay - modified to save preference + // Handle resume from overlay - modified for VLC const handleResume = async () => { - if (resumePosition !== null && videoRef.current) { + if (resumePosition !== null && vlcRef.current) { logger.log(`[VideoPlayer] Resuming from ${resumePosition}`); // Save preference if remember choice is checked @@ -558,10 +582,17 @@ const VideoPlayer: React.FC = () => { setInitialPosition(resumePosition); // Hide overlay setShowResumeOverlay(false); + + // Seek to position with VLC + setTimeout(() => { + if (vlcRef.current) { + vlcRef.current.seek(resumePosition); + } + }, 500); } }; - // Handle start from beginning - modified to save preference + // Handle start from beginning - modified for VLC const handleStartFromBeginning = async () => { logger.log(`[VideoPlayer] Starting from beginning`); @@ -580,8 +611,8 @@ const VideoPlayer: React.FC = () => { // Set initial position to 0 setInitialPosition(0); // Make sure we seek to beginning - if (videoRef.current) { - videoRef.current.seek(0); + if (vlcRef.current) { + vlcRef.current.seek(0); setCurrentTime(0); progress.value = 0; } @@ -600,45 +631,269 @@ const VideoPlayer: React.FC = () => { setShowControls(!showControls); }; - // Add onBuffer handler to Video component - const onBuffer = ({ isBuffering }: { isBuffering: boolean }) => { - // You can use this to show a loading indicator if needed - logger.log(`[VideoPlayer] Buffering: ${isBuffering}`); - }; - - // Add onProgress handler to track buffered data - const onLoadStart = () => { - setBuffered(0); - }; - - const handleProgress = (data: { currentTime: number, playableDuration: number, seekableDuration?: number }) => { - setCurrentTime(data.currentTime); - progress.value = data.currentTime; + // Handle VLC progress updates + const handleProgress = (event: any) => { + const currentTimeInSeconds = event.currentTime / 1000; // VLC gives time in milliseconds + setCurrentTime(currentTimeInSeconds); + progress.value = currentTimeInSeconds; + + // Update buffered position + const bufferedTime = event.bufferTime / 1000 || currentTimeInSeconds; + setBuffered(bufferedTime); - // Ensure playableDuration is always at least equal to currentTime - const effectivePlayableDuration = Math.max(data.currentTime, data.playableDuration); - setBuffered(effectivePlayableDuration); - // Calculate buffer ahead (cannot be negative) - const bufferAhead = Math.max(0, effectivePlayableDuration - data.currentTime); - const bufferPercentage = ((effectivePlayableDuration / (duration || 1)) * 100); + const bufferAhead = Math.max(0, bufferedTime - currentTimeInSeconds); + const bufferPercentage = ((bufferedTime / (duration || 1)) * 100); // Add detailed buffer logging logger.log(`[VideoPlayer] Buffer Status: - Current Time: ${data.currentTime.toFixed(2)}s - Playable Duration: ${effectivePlayableDuration.toFixed(2)}s + Current Time: ${currentTimeInSeconds.toFixed(2)}s + Buffered: ${bufferedTime.toFixed(2)}s Buffered Ahead: ${bufferAhead.toFixed(2)}s - Seekable Duration: ${data.seekableDuration?.toFixed(2) || 'N/A'}s Buffer Percentage: ${bufferPercentage.toFixed(1)}% `); }; - // Add onError handler + // Handle VLC errors const handleError = (error: any) => { logger.error('[VideoPlayer] Playback Error:', error); // Optionally, you could show an error message to the user here }; + // Handle VLC buffering + const onBuffering = (event: any) => { + setIsBuffering(event.isBuffering); + logger.log(`[VideoPlayer] Buffering: ${event.isBuffering}`); + }; + + // Handle VLC playback ended + const onEnd = () => { + // Your existing playback ended logic here + }; + + // Function to get audio tracks from VLC + const getAudioTracks = () => { + if (vlcRef.current) { + vlcRef.current.getAudioTracks().then((tracks: any) => { + setVlcAudioTracks(tracks || []); + logger.log("[VideoPlayer] Available VLC audio tracks:", tracks); + }).catch((error: any) => { + logger.error("[VideoPlayer] Failed to get audio tracks:", error); + }); + } + }; + + // Function to select audio track in VLC + const selectAudioTrack = (trackId: number) => { + if (vlcRef.current) { + vlcRef.current.setAudioTrack(trackId); + setSelectedAudioTrack(trackId); + } + }; + + // Function to get subtitle tracks from VLC + const getTextTracks = () => { + if (vlcRef.current) { + vlcRef.current.getTextTracks().then((tracks: any) => { + setVlcTextTracks(tracks || []); + logger.log("[VideoPlayer] Available VLC subtitle tracks:", tracks); + }).catch((error: any) => { + logger.error("[VideoPlayer] Failed to get subtitle tracks:", error); + }); + } + }; + + // Function to select subtitle track in VLC + const selectTextTrack = (trackId: number) => { + if (vlcRef.current) { + vlcRef.current.setTextTrack(trackId); + // Update your state accordingly + setSelectedTextTrack({ type: 'index', value: trackId }); + } + }; + + // Add this useEffect to get audio and subtitle tracks after player is loaded + useEffect(() => { + if (duration > 0 && vlcRef.current) { + // Wait a bit for VLC to fully initialize and recognize tracks + setTimeout(() => { + getAudioTracks(); + getTextTracks(); + }, 2000); + } + }, [duration]); + + // Update audio modal to use VLC audio tracks + const renderAudioModal = () => { + if (!showAudioModal) return null; + + return ( + <View style={styles.fullscreenOverlay}> + <View style={styles.enhancedModalContainer}> + <View style={styles.enhancedModalHeader}> + <Text style={styles.enhancedModalTitle}>Audio</Text> + <TouchableOpacity + style={styles.enhancedCloseButton} + onPress={() => setShowAudioModal(false)} + > + <Ionicons name="close" size={24} color="white" /> + </TouchableOpacity> + </View> + + <ScrollView style={styles.trackListScrollContainer}> + <View style={styles.trackListContainer}> + {vlcAudioTracks.length > 0 ? vlcAudioTracks.map(track => ( + <TouchableOpacity + key={track.id} + style={styles.enhancedTrackItem} + onPress={() => { + selectAudioTrack(track.id); + setShowAudioModal(false); + }} + > + <View style={styles.trackInfoContainer}> + <Text style={styles.trackPrimaryText}> + {formatLanguage(track.language) || track.name || `Track ${track.id}`} + </Text> + {(track.name && track.language) && ( + <Text style={styles.trackSecondaryText}>{track.name}</Text> + )} + </View> + {selectedAudioTrack === track.id && ( + <View style={styles.selectedIndicatorContainer}> + <Ionicons name="checkmark" size={22} color="#E50914" /> + </View> + )} + </TouchableOpacity> + )) : ( + <View style={styles.emptyStateContainer}> + <Ionicons name="alert-circle-outline" size={40} color="#888" /> + <Text style={styles.emptyStateText}>No audio tracks available</Text> + </View> + )} + </View> + </ScrollView> + </View> + </View> + ); + }; + + // Update subtitle modal to use VLC subtitle tracks + const renderSubtitleModal = () => { + if (!showSubtitleModal) return null; + + return ( + <View style={styles.fullscreenOverlay}> + <View style={styles.enhancedModalContainer}> + <View style={styles.enhancedModalHeader}> + <Text style={styles.enhancedModalTitle}>Subtitles</Text> + <TouchableOpacity + style={styles.enhancedCloseButton} + onPress={() => setShowSubtitleModal(false)} + > + <Ionicons name="close" size={24} color="white" /> + </TouchableOpacity> + </View> + + <ScrollView style={styles.trackListScrollContainer}> + <View style={styles.trackListContainer}> + {/* Off option with improved design */} + <TouchableOpacity + style={styles.enhancedTrackItem} + onPress={() => { + selectTextTrack(-1); // -1 typically disables subtitles in VLC + setShowSubtitleModal(false); + }} + > + <View style={styles.trackInfoContainer}> + <Text style={styles.trackPrimaryText}>Off</Text> + </View> + {(selectedTextTrack?.type === 'disabled' || + (selectedTextTrack?.type === 'index' && selectedTextTrack.value === -1)) && ( + <View style={styles.selectedIndicatorContainer}> + <Ionicons name="checkmark" size={22} color="#E50914" /> + </View> + )} + </TouchableOpacity> + + {/* Available subtitle tracks with improved design */} + {vlcTextTracks.length > 0 ? vlcTextTracks.map(track => ( + <TouchableOpacity + key={track.id} + style={styles.enhancedTrackItem} + onPress={() => { + selectTextTrack(track.id); + setShowSubtitleModal(false); + }} + > + <View style={styles.trackInfoContainer}> + <Text style={styles.trackPrimaryText}> + {formatLanguage(track.language) || track.name || `Subtitle ${track.id}`} + </Text> + {(track.name && track.language) && ( + <Text style={styles.trackSecondaryText}>{track.name}</Text> + )} + </View> + {selectedTextTrack?.type === 'index' && + selectedTextTrack?.value === track.id && ( + <View style={styles.selectedIndicatorContainer}> + <Ionicons name="checkmark" size={22} color="#E50914" /> + </View> + )} + </TouchableOpacity> + )) : ( + <View style={styles.emptyStateContainer}> + <Ionicons name="alert-circle-outline" size={40} color="#888" /> + <Text style={styles.emptyStateText}>No subtitle tracks available</Text> + </View> + )} + </View> + </ScrollView> + </View> + </View> + ); + }; + + // Update the getInfo method for VLC + const getInfo = async () => { + if (vlcRef.current) { + try { + const position = await vlcRef.current.getPosition(); + const lengthResult = await vlcRef.current.getLength(); + return { + currentTime: position, + duration: lengthResult / 1000 // Convert to seconds + }; + } catch (e) { + logger.error('[VideoPlayer] Error getting playback info:', e); + return { + currentTime: currentTime, + duration: duration + }; + } + } + return { + currentTime: 0, + duration: 0 + }; + }; + + // VLC specific method to set playback speed + const changePlaybackSpeed = (speed: number) => { + if (vlcRef.current) { + vlcRef.current.setRate(speed); + setPlaybackSpeed(speed); + } + }; + + // VLC specific method for volume control + const setVolume = (volumeLevel: number) => { + if (vlcRef.current) { + // VLC volume is typically between 0-200 + vlcRef.current.setVolume(volumeLevel * 200); + } + }; + return ( <View style={styles.container}> <TouchableOpacity @@ -646,26 +901,23 @@ const VideoPlayer: React.FC = () => { onPress={toggleControls} activeOpacity={1} > - <Video - ref={videoRef} - source={{ uri }} + <VLCPlayer + ref={vlcRef} + source={{ + uri: uri, + }} style={styles.video} paused={paused || showResumeOverlay} - resizeMode={resizeMode} + resizeMode={resizeMode as any} // Type cast to avoid type error onLoad={onLoad} onProgress={handleProgress} rate={playbackSpeed} - progressUpdateInterval={250} - selectedAudioTrack={selectedAudioTrack !== null ? - { type: 'index', value: selectedAudioTrack } as any : - undefined - } - onAudioTracks={onAudioTracks} - selectedTextTrack={selectedTextTrack as any} - onTextTracks={onTextTracks} - onBuffer={onBuffer} - onLoadStart={onLoadStart} onError={handleError} + onEnd={onEnd} + // VLC specific props + autoAspectRatio={true} + // autoReloadOnError={true} - Removed, not supported by VLCPlayer + // Note: VLC handles audio tracks differently, we'll need to adjust the UI for this /> {/* Slider Container with buffer indicator */} @@ -870,129 +1122,9 @@ const VideoPlayer: React.FC = () => { )} </TouchableOpacity> - {/* Audio Selection Modal - Updated language display */} - {showAudioModal && ( - <View style={styles.fullscreenOverlay}> - <View style={styles.enhancedModalContainer}> - <View style={styles.enhancedModalHeader}> - <Text style={styles.enhancedModalTitle}>Audio</Text> - <TouchableOpacity - style={styles.enhancedCloseButton} - onPress={() => setShowAudioModal(false)} - > - <Ionicons name="close" size={24} color="white" /> - </TouchableOpacity> - </View> - - <ScrollView style={styles.trackListScrollContainer}> - <View style={styles.trackListContainer}> - {audioTracks.length > 0 ? audioTracks.map(track => ( - <TouchableOpacity - key={track.index} - style={styles.enhancedTrackItem} - onPress={() => { - setSelectedAudioTrack(track.index); - setShowAudioModal(false); - }} - > - <View style={styles.trackInfoContainer}> - <Text style={styles.trackPrimaryText}> - {formatLanguage(track.language) || track.title || `Track ${track.index + 1}`} - </Text> - {(track.title && track.language) && ( - <Text style={styles.trackSecondaryText}>{track.title}</Text> - )} - {track.type && <Text style={styles.trackSecondaryText}>{track.type}</Text>} - </View> - {selectedAudioTrack === track.index && ( - <View style={styles.selectedIndicatorContainer}> - <Ionicons name="checkmark" size={22} color="#E50914" /> - </View> - )} - </TouchableOpacity> - )) : ( - <View style={styles.emptyStateContainer}> - <Ionicons name="alert-circle-outline" size={40} color="#888" /> - <Text style={styles.emptyStateText}>No audio tracks available</Text> - </View> - )} - </View> - </ScrollView> - </View> - </View> - )} - - {/* Subtitle Selection Modal - Updated language display */} - {showSubtitleModal && ( - <View style={styles.fullscreenOverlay}> - <View style={styles.enhancedModalContainer}> - <View style={styles.enhancedModalHeader}> - <Text style={styles.enhancedModalTitle}>Subtitles</Text> - <TouchableOpacity - style={styles.enhancedCloseButton} - onPress={() => setShowSubtitleModal(false)} - > - <Ionicons name="close" size={24} color="white" /> - </TouchableOpacity> - </View> - - <ScrollView style={styles.trackListScrollContainer}> - <View style={styles.trackListContainer}> - {/* Off option with improved design */} - <TouchableOpacity - style={styles.enhancedTrackItem} - onPress={() => { - setSelectedTextTrack({ type: 'disabled' }); - setShowSubtitleModal(false); - }} - > - <View style={styles.trackInfoContainer}> - <Text style={styles.trackPrimaryText}>Off</Text> - </View> - {selectedTextTrack?.type === 'disabled' && ( - <View style={styles.selectedIndicatorContainer}> - <Ionicons name="checkmark" size={22} color="#E50914" /> - </View> - )} - </TouchableOpacity> - - {/* Available subtitle tracks with improved design */} - {textTracks.length > 0 ? textTracks.map(track => ( - <TouchableOpacity - key={track.index} - style={styles.enhancedTrackItem} - onPress={() => { - setSelectedTextTrack({ type: 'index', value: track.index }); - setShowSubtitleModal(false); - }} - > - <View style={styles.trackInfoContainer}> - <Text style={styles.trackPrimaryText}> - {formatLanguage(track.language) || track.title || `Subtitle ${track.index + 1}`} - </Text> - {(track.title && track.language) && ( - <Text style={styles.trackSecondaryText}>{track.title}</Text> - )} - {track.type && <Text style={styles.trackSecondaryText}>{track.type}</Text>} - </View> - {selectedTextTrack?.type === 'index' && - selectedTextTrack?.value === track.index && ( - <View style={styles.selectedIndicatorContainer}> - <Ionicons name="checkmark" size={22} color="#E50914" /> - </View> - )} - </TouchableOpacity> - )) : ( - <View style={styles.emptyStateContainer}> - <Ionicons name="alert-circle-outline" size={40} color="#888" /> - <Text style={styles.emptyStateText}>No subtitle tracks available</Text> - </View> - )} - </View> - </ScrollView> - </View> - </View> - )} + {/* Use the new modal rendering functions */} + {renderAudioModal()} + {renderSubtitleModal()} </View> ); }; diff --git a/tsconfig.json b/tsconfig.json index b9567f6..ff3c18d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "expo/tsconfig.base", "compilerOptions": { - "strict": true + "strict": true, + "jsx": "react-jsx", + "esModuleInterop": true } } -- 2.45.2 From 45b2d4a124f7f2bd4198518d11ec9a8456e220e7 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Sun, 8 Jun 2025 16:11:15 +0530 Subject: [PATCH 13/88] Enhance HomeScreen and StreamsScreen with landscape orientation locking and improved loading state management This update introduces the use of the expo-screen-orientation library to lock the screen orientation to landscape mode when navigating to the Player component from both HomeScreen and StreamsScreen. Additionally, it refines loading state management in StreamsScreen by implementing guards to prevent excessive re-renders and ensuring accurate provider status updates. The changes contribute to a smoother user experience during video playback and improved performance across the application. --- src/screens/HomeScreen.tsx | 45 +- src/screens/StreamsScreen.tsx | 245 ++++--- src/screens/VideoPlayer.tsx | 1226 ++++++++++++++++++++------------- 3 files changed, 939 insertions(+), 577 deletions(-) diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 5af6b37..958b773 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react'; import { View, Text, @@ -16,7 +16,8 @@ import { Platform, Image, Modal, - Pressable + Pressable, + Alert } from 'react-native'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -60,6 +61,7 @@ import { SkeletonFeatured } from '../components/home/SkeletonLoaders'; import homeStyles, { sharedStyles } from '../styles/homeStyles'; import { useTheme } from '../contexts/ThemeContext'; import type { Theme } from '../contexts/ThemeContext'; +import * as ScreenOrientation from 'expo-screen-orientation'; // Define interfaces for our data interface Category { @@ -517,18 +519,37 @@ const HomeScreen = () => { navigation.navigate('Metadata', { id, type }); }, [navigation]); - const handlePlayStream = useCallback((stream: Stream) => { + const handlePlayStream = useCallback(async (stream: Stream) => { if (!featuredContent) return; - navigation.navigate('Player', { - uri: stream.url, - title: featuredContent.name, - year: featuredContent.year, - quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, - streamProvider: stream.name, - id: featuredContent.id, - type: featuredContent.type - }); + try { + // Lock orientation to landscape before navigation to prevent glitches + await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); + + // Small delay to ensure orientation is set before navigation + await new Promise(resolve => setTimeout(resolve, 100)); + + navigation.navigate('Player', { + uri: stream.url, + title: featuredContent.name, + year: featuredContent.year, + quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, + streamProvider: stream.name, + id: featuredContent.id, + type: featuredContent.type + }); + } catch (error) { + // Fallback: navigate anyway + navigation.navigate('Player', { + uri: stream.url, + title: featuredContent.name, + year: featuredContent.year, + quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, + streamProvider: stream.name, + id: featuredContent.id, + type: featuredContent.type + }); + } }, [featuredContent, navigation]); const refreshContinueWatching = useCallback(async () => { diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index ac5ef76..5ac6a52 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, memo, useState, useEffect } from 'react'; +import React, { useCallback, useMemo, memo, useState, useEffect, useRef, useLayoutEffect } from 'react'; import { View, Text, @@ -13,8 +13,9 @@ import { StatusBar, Alert, Dimensions, - Linking + Linking, } from 'react-native'; +import * as ScreenOrientation from 'expo-screen-orientation'; import { useRoute, useNavigation } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -241,10 +242,22 @@ export const StreamsScreen = () => { const { currentTheme } = useTheme(); const { colors } = currentTheme; + // Add ref to prevent excessive updates + const isMounted = useRef(true); + const loadStartTimeRef = useRef(0); + const hasDoneInitialLoadRef = useRef(false); + // Add timing logs const [loadStartTime, setLoadStartTime] = useState(0); const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({}); + // Prevent excessive re-renders by using this guard + const guardedSetState = useCallback((setter: () => void) => { + if (isMounted.current) { + setter(); + } + }, []); + const { metadata, episodes, @@ -285,54 +298,64 @@ export const StreamsScreen = () => { } }>({}); - // Monitor streams loading start and completion + // Monitor streams loading start and completion - FIXED to prevent loops useEffect(() => { + // Skip processing if component is unmounting + if (!isMounted.current) return; + const now = Date.now(); // Define all providers you expect to load. This could be dynamic. const expectedProviders = ['stremio', 'hdrezka']; + // Prevent infinite rerendering by using refs if (loadingStreams || loadingEpisodeStreams) { // --- Stream Loading has STARTED or is IN PROGRESS --- - logger.log("⏱️ Stream loading started or in progress..."); - - // Set load start time only if this is the beginning of a new loading cycle - if (loadStartTime === 0) { + // Only log once when loading starts + if (loadStartTimeRef.current === 0) { + logger.log("⏱️ Stream loading started or in progress..."); + // Update ref directly to avoid render cycle + loadStartTimeRef.current = now; + // Also update state for components that need it setLoadStartTime(now); } - setProviderLoadTimes({}); // Reset individual provider load times tracker + // Only update these once per loading cycle + if (!hasDoneInitialLoadRef.current) { + hasDoneInitialLoadRef.current = true; + + // Use the guarded setState to prevent issues after unmount + guardedSetState(() => setProviderLoadTimes({})); - // Update provider status to loading for all expected providers - setProviderStatus(prevStatus => { - const newStatus = { ...prevStatus }; - expectedProviders.forEach(providerId => { - // If not already marked as loading, or if it's a fresh cycle, set to loading - if (!newStatus[providerId] || !newStatus[providerId].loading || loadStartTime === 0) { - newStatus[providerId] = { - loading: true, - success: false, - error: false, - message: 'Loading...', - timeStarted: (newStatus[providerId]?.loading && newStatus[providerId]?.timeStarted) ? newStatus[providerId].timeStarted : now, - timeCompleted: 0, - }; - } - }); - return newStatus; - }); + // Update provider status to loading for all expected providers + guardedSetState(() => setProviderStatus(prevStatus => { + const newStatus = { ...prevStatus }; + expectedProviders.forEach(providerId => { + // If not already marked as loading, or if it's a fresh cycle, set to loading + if (!newStatus[providerId] || !newStatus[providerId].loading) { + newStatus[providerId] = { + loading: true, + success: false, + error: false, + message: 'Loading...', + timeStarted: (newStatus[providerId]?.loading && newStatus[providerId]?.timeStarted) ? newStatus[providerId].timeStarted : now, + timeCompleted: 0, + }; + } + }); + return newStatus; + })); - // Update simple loading flag for all expected providers - setLoadingProviders(prevLoading => { - const newLoading = { ...prevLoading }; - expectedProviders.forEach(providerId => { - newLoading[providerId] = true; - }); - return newLoading; - }); - - } else if (loadStartTime > 0) { + // Update simple loading flag for all expected providers + guardedSetState(() => setLoadingProviders(prevLoading => { + const newLoading = { ...prevLoading }; + expectedProviders.forEach(providerId => { + newLoading[providerId] = true; + }); + return newLoading; + })); + } + } else if (loadStartTimeRef.current > 0) { // --- Stream Loading has FINISHED --- - // (loadStartTime > 0 implies a loading cycle was active and has now completed) logger.log("🏁 Stream loading finished. Processing results."); const currentStreamsData = type === 'series' ? episodeStreams : groupedStreams; @@ -344,56 +367,53 @@ export const StreamsScreen = () => { logger.log(`📊 Providers with streams: ${providersWithStreams.join(', ')}`); - // Update simple loading flag: all expected providers are no longer loading - setLoadingProviders(prevLoading => { - const newLoading = { ...prevLoading }; - expectedProviders.forEach(providerId => { - newLoading[providerId] = false; - }); - return newLoading; - }); + // Reset refs for next load cycle + loadStartTimeRef.current = 0; + hasDoneInitialLoadRef.current = false; + + // Update states only if component is still mounted + if (isMounted.current) { + // Update simple loading flag: all expected providers are no longer loading + guardedSetState(() => setLoadingProviders(prevLoading => { + const newLoading = { ...prevLoading }; + expectedProviders.forEach(providerId => { + newLoading[providerId] = false; + }); + return newLoading; + })); - // Update detailed provider status based on results - setProviderStatus(prevStatus => { - const newStatus = { ...prevStatus }; - expectedProviders.forEach(providerId => { - if (newStatus[providerId]) { // Ensure the provider entry exists - const providerHasStreams = currentStreamsData[providerId] && - currentStreamsData[providerId].streams && - currentStreamsData[providerId].streams.length > 0; - - newStatus[providerId] = { - ...newStatus[providerId], // Preserve timeStarted - loading: false, - success: providerHasStreams, - // Mark error if it was loading and now no streams, and wasn't already successful - error: !providerHasStreams && newStatus[providerId].loading && !newStatus[providerId].success, - message: providerHasStreams ? 'Loaded successfully' : (newStatus[providerId].error ? 'Error or no streams' : 'No streams found'), - timeCompleted: now, - }; - } else { - // Fallback if somehow not initialized (should be caught by loading phase) - newStatus[providerId] = { - loading: false, - success: false, - error: true, - message: 'Provider status error (not initialized)', - timeStarted: 0, - timeCompleted: now, - }; - } - }); - return newStatus; - }); + // Update detailed provider status based on results + guardedSetState(() => setProviderStatus(prevStatus => { + const newStatus = { ...prevStatus }; + expectedProviders.forEach(providerId => { + if (newStatus[providerId]) { // Ensure the provider entry exists + const providerHasStreams = currentStreamsData[providerId] && + currentStreamsData[providerId].streams && + currentStreamsData[providerId].streams.length > 0; + + newStatus[providerId] = { + ...newStatus[providerId], // Preserve timeStarted + loading: false, + success: providerHasStreams, + // Mark error if it was loading and now no streams, and wasn't already successful + error: !providerHasStreams && newStatus[providerId].loading && !newStatus[providerId].success, + message: providerHasStreams ? 'Loaded successfully' : (newStatus[providerId].error ? 'Error or no streams' : 'No streams found'), + timeCompleted: now, + }; + } + }); + return newStatus; + })); - // Update the set of available providers based on what actually loaded streams - const providersWithStreamsSet = new Set(providersWithStreams); - setAvailableProviders(providersWithStreamsSet); + // Update the set of available providers based on what actually loaded streams + const providersWithStreamsSet = new Set(providersWithStreams); + guardedSetState(() => setAvailableProviders(providersWithStreamsSet)); - // Reset loadStartTime to signify the end of this loading cycle - setLoadStartTime(0); + // Reset loadStartTime to signify the end of this loading cycle + guardedSetState(() => setLoadStartTime(0)); + } } - }, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type /* loadStartTime is intentionally omitted from deps here */]); + }, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type, guardedSetState]); // Add useEffect to update availableProviders whenever streams change useEffect(() => { @@ -487,20 +507,44 @@ export const StreamsScreen = () => { ); }, [selectedEpisode, groupedEpisodes, id]); - const navigateToPlayer = useCallback((stream: Stream) => { - navigation.navigate('Player', { - uri: stream.url, - title: metadata?.name || '', - episodeTitle: type === 'series' ? currentEpisode?.name : undefined, - season: type === 'series' ? currentEpisode?.season_number : undefined, - episode: type === 'series' ? currentEpisode?.episode_number : undefined, - quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, - year: metadata?.year, - streamProvider: stream.name, - id, - type, - episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined - }); + const navigateToPlayer = useCallback(async (stream: Stream) => { + try { + // Lock orientation to landscape before navigation to prevent glitches + await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); + + // Small delay to ensure orientation is set before navigation + await new Promise(resolve => setTimeout(resolve, 100)); + + navigation.navigate('Player', { + uri: stream.url, + title: metadata?.name || '', + episodeTitle: type === 'series' ? currentEpisode?.name : undefined, + season: type === 'series' ? currentEpisode?.season_number : undefined, + episode: type === 'series' ? currentEpisode?.episode_number : undefined, + quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, + year: metadata?.year, + streamProvider: stream.name, + id, + type, + episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined + }); + } catch (error) { + logger.error('[StreamsScreen] Error locking orientation before navigation:', error); + // Fallback: navigate anyway + navigation.navigate('Player', { + uri: stream.url, + title: metadata?.name || '', + episodeTitle: type === 'series' ? currentEpisode?.name : undefined, + season: type === 'series' ? currentEpisode?.season_number : undefined, + episode: type === 'series' ? currentEpisode?.episode_number : undefined, + quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, + year: metadata?.year, + streamProvider: stream.name, + id, + type, + episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined + }); + } }, [metadata, type, currentEpisode, navigation, id, selectedEpisode]); // Update handleStreamPress @@ -834,6 +878,13 @@ export const StreamsScreen = () => { </Animated.View> ), [styles.streamGroupTitle]); + // Cleanup on unmount + useEffect(() => { + return () => { + isMounted.current = false; + }; + }, []); + return ( <View style={styles.container}> <StatusBar diff --git a/src/screens/VideoPlayer.tsx b/src/screens/VideoPlayer.tsx index caf4673..90f81da 100644 --- a/src/screens/VideoPlayer.tsx +++ b/src/screens/VideoPlayer.tsx @@ -1,8 +1,7 @@ import React, { useState, useRef, useEffect } from 'react'; -import { View, TouchableOpacity, StyleSheet, Text, Dimensions, Modal, Pressable, StatusBar, Platform, ScrollView, Animated } from 'react-native'; +import { View, TouchableOpacity, StyleSheet, Text, Dimensions, Modal, Pressable, StatusBar, Platform, ScrollView, Animated, ActivityIndicator } from 'react-native'; import { VLCPlayer } from 'react-native-vlc-media-player'; import { Ionicons } from '@expo/vector-icons'; -import { Slider } from 'react-native-awesome-slider'; import { LinearGradient } from 'expo-linear-gradient'; import { useSharedValue, runOnJS, withTiming } from 'react-native-reanimated'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; @@ -20,6 +19,27 @@ import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; +// Debug flag - set back to false to disable verbose logging +// WARNING: Setting this to true currently causes infinite render loops +// Use selective logging instead if debugging is needed +const DEBUG_MODE = true; + +// Safer debug function that won't cause render loops +// Call this with any debugging info you need instead of using inline DEBUG_MODE checks +const safeDebugLog = (message: string, data?: any) => { + // This function only runs once per call site, avoiding render loops + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (DEBUG_MODE) { + if (data) { + logger.log(`[VideoPlayer] ${message}`, data); + } else { + logger.log(`[VideoPlayer] ${message}`); + } + } + }, []); // Empty dependency array means this only runs once per mount +}; + // Constants for resume preferences - add after type definitions const RESUME_PREF_KEY = '@video_resume_preference'; const RESUME_PREF = { @@ -129,7 +149,16 @@ const languageMap: {[key: string]: string} = { const formatLanguage = (code?: string): string => { if (!code) return 'Unknown'; const normalized = code.toLowerCase(); - return languageMap[normalized] || code.toUpperCase(); + const languageName = languageMap[normalized] || code.toUpperCase(); + + // Debug logs removed to prevent render loops + + // If the result is still the uppercased code, it means we couldn't find it in our map. + if (languageName === code.toUpperCase()) { + return `Unknown (${code})`; + } + + return languageName; }; // Add VLC specific interface for their event structure @@ -144,6 +173,20 @@ interface VlcMediaEvent { selectedTextTrack?: number; } +// Helper function to extract a display name from the track's name property +const getTrackDisplayName = (track: { name?: string, id: number }): string => { + if (!track || !track.name) return `Track ${track.id}`; + + // Try to extract language from name like "Some Info - [English]" + const languageMatch = track.name.match(/\[(.*?)\]/); + if (languageMatch && languageMatch[1]) { + return languageMatch[1]; + } + + // If no language in brackets, or if the name is simple, use the full name + return track.name; +}; + const VideoPlayer: React.FC = () => { const navigation = useNavigation(); const route = useRoute<RouteProp<RootStackParamList, 'Player'>>(); @@ -163,19 +206,10 @@ const VideoPlayer: React.FC = () => { episodeId } = route.params; - // Log received props for debugging - logger.log("[VideoPlayer] Received props:", { - uri, - title, - season, - episode, - episodeTitle, - quality, - year, - streamProvider, - id, - type, - episodeId + // Use safer debug logging for props + safeDebugLog("Component mounted with props", { + uri, title, season, episode, episodeTitle, quality, year, + streamProvider, id, type, episodeId }); const [paused, setPaused] = useState(false); @@ -186,7 +220,7 @@ const VideoPlayer: React.FC = () => { const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]); const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null); const [textTracks, setTextTracks] = useState<TextTrack[]>([]); - const [selectedTextTrack, setSelectedTextTrack] = useState<SelectedTrack | null>({ type: 'disabled' }); + const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1); // Use -1 for "disabled" const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain'); // State for resize mode const [buffered, setBuffered] = useState(0); // Add buffered state const vlcRef = useRef<any>(null); @@ -212,6 +246,12 @@ const VideoPlayer: React.FC = () => { // Add animated value for controls opacity const fadeAnim = useRef(new Animated.Value(1)).current; + // Add opening animation states and values + const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); + const openingFadeAnim = useRef(new Animated.Value(0)).current; + const openingScaleAnim = useRef(new Animated.Value(0.8)).current; + const backgroundFadeAnim = useRef(new Animated.Value(1)).current; + // Add VLC specific state and refs const [isBuffering, setIsBuffering] = useState(false); @@ -219,17 +259,35 @@ const VideoPlayer: React.FC = () => { const [vlcAudioTracks, setVlcAudioTracks] = useState<Array<{id: number, name: string, language?: string}>>([]); const [vlcTextTracks, setVlcTextTracks] = useState<Array<{id: number, name: string, language?: string}>>([]); + // Add a new state to track if the player is ready for seeking + const [isPlayerReady, setIsPlayerReady] = useState(false); + + // Animated value for smooth progress bar + const progressAnim = useRef(new Animated.Value(0)).current; + + // Add ref for progress bar container to measure its width + const progressBarRef = useRef<View>(null); + + // Add state for progress bar touch tracking + const [isDragging, setIsDragging] = useState(false); + + // Add a ref for debouncing seek operations + const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null); + const pendingSeekValue = useRef<number | null>(null); + const lastSeekTime = useRef<number>(0); + // Lock screen to landscape when component mounts useEffect(() => { - const lockToLandscape = async () => { - await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); + // Since orientation is now locked before navigation, we can start immediately + const initializePlayer = () => { + // Enable immersive mode + enableImmersiveMode(); + + // Start the opening animation immediately + startOpeningAnimation(); }; - // Lock to landscape - lockToLandscape(); - - // Enable immersive mode when component mounts - enableImmersiveMode(); + initializePlayer(); // Restore screen orientation and disable immersive mode when component unmounts return () => { @@ -241,24 +299,68 @@ const VideoPlayer: React.FC = () => { }; }, []); + // Opening animation sequence + const startOpeningAnimation = () => { + // Much shorter delay since rotation is already handled + setTimeout(() => { + // Start the main animation sequence + Animated.parallel([ + // Fade in the video player + Animated.timing(openingFadeAnim, { + toValue: 1, + duration: 600, // Reduced back to original duration + useNativeDriver: true, + }), + // Scale up from 80% to 100% + Animated.timing(openingScaleAnim, { + toValue: 1, + duration: 700, // Reduced back to original duration + useNativeDriver: true, + }), + // Fade out the black background overlay + Animated.timing(backgroundFadeAnim, { + toValue: 0, + duration: 800, // Reduced back to original duration + useNativeDriver: true, + }), + ]).start(() => { + // Animation is complete + setIsOpeningAnimationComplete(true); + + // Hide the background overlay completely after animation + setTimeout(() => { + backgroundFadeAnim.setValue(0); + }, 100); + }); + }, 150); // Much shorter delay since no rotation is needed + }; + // Load saved watch progress on mount useEffect(() => { const loadWatchProgress = async () => { if (id && type) { try { - logger.log(`[VideoPlayer] Checking for saved progress with id=${id}, type=${type}, episodeId=${episodeId || 'none'}`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Checking for saved progress with id=${id}, type=${type}, episodeId=${episodeId || 'none'}`); + } const savedProgress = await storageService.getWatchProgress(id, type, episodeId); if (savedProgress) { - logger.log(`[VideoPlayer] Found saved progress:`, savedProgress); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Found saved progress:`, savedProgress); + } if (savedProgress.currentTime > 0) { // Only auto-resume if less than 95% watched (not effectively complete) const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; - logger.log(`[VideoPlayer] Progress percent: ${progressPercent.toFixed(2)}%`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Progress percent: ${progressPercent.toFixed(2)}%`); + } if (progressPercent < 95) { - logger.log(`[VideoPlayer] Setting initial position to ${savedProgress.currentTime}`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Setting initial position to ${savedProgress.currentTime}`); + } // Set resume position setResumePosition(savedProgress.currentTime); @@ -266,25 +368,29 @@ const VideoPlayer: React.FC = () => { const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); if (pref === RESUME_PREF.ALWAYS_RESUME) { setInitialPosition(savedProgress.currentTime); - logger.log(`[VideoPlayer] Auto-resuming based on saved preference`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Auto-resuming based on saved preference`); + } } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { setInitialPosition(0); - logger.log(`[VideoPlayer] Auto-starting from beginning based on saved preference`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Auto-starting from beginning based on saved preference`); + } } else { // Only show resume overlay if no preference or ALWAYS_ASK setShowResumeOverlay(true); } - } else { + } else if (DEBUG_MODE) { logger.log(`[VideoPlayer] Progress >= 95%, starting from beginning`); } } - } else { + } else if (DEBUG_MODE) { logger.log(`[VideoPlayer] No saved progress found`); } } catch (error) { logger.error('[VideoPlayer] Error loading watch progress:', error); } - } else { + } else if (DEBUG_MODE) { logger.log(`[VideoPlayer] Missing id or type, can't load progress. id=${id}, type=${type}`); } }; @@ -335,11 +441,10 @@ const VideoPlayer: React.FC = () => { try { await storageService.setWatchProgress(id, type, progress, episodeId); - logger.log(`[VideoPlayer] Saved progress: ${currentTime.toFixed(1)}/${duration.toFixed(1)} (${((currentTime/duration)*100).toFixed(1)}%)`); } catch (error) { logger.error('[VideoPlayer] Error saving watch progress:', error); } - } else { + } else if (DEBUG_MODE) { logger.log(`[VideoPlayer] Cannot save progress: id=${id}, type=${type}, currentTime=${currentTime}, duration=${duration}`); } }; @@ -360,94 +465,160 @@ const VideoPlayer: React.FC = () => { } }; - const onSliderValueChange = (value: number) => { - if (vlcRef.current) { - const newTime = Math.floor(value); - vlcRef.current.seek(newTime); - setCurrentTime(newTime); - progress.value = newTime; + // Replace the reset seek value effect + // useEffect(() => { + // if (seekValue !== undefined) { + // const timer = setTimeout(() => { + // if (isMounted.current) { + // setSeekValue(undefined); + // } + // }, 1000); // Longer timeout to ensure VLC processes the seek properly + + // return () => clearTimeout(timer); + // } + // }, [seekValue]); + + // Simplify the seekToTime function to use VLC's direct methods + const seekToTime = (timeInSeconds: number) => { + if (!isPlayerReady || duration <= 0 || !vlcRef.current) return; + + // Calculate normalized position (0-1) for VLC + const normalizedPosition = Math.max(0, Math.min(timeInSeconds / duration, 1)); + + try { + // Use VLC's direct setPosition method + if (typeof vlcRef.current.setPosition === 'function') { + vlcRef.current.setPosition(normalizedPosition); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Called setPosition with ${normalizedPosition} for time: ${timeInSeconds}s`); + } + } else if (typeof vlcRef.current.seek === 'function') { + // Fallback to seek method if available + vlcRef.current.seek(normalizedPosition); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Called seek with ${normalizedPosition} for time: ${timeInSeconds}s`); + } + } else { + logger.error('[VideoPlayer] No seek method available on VLC player'); + } + + // Update UI immediately for responsiveness + const progressPercent = timeInSeconds / duration; + progressAnim.setValue(progressPercent); + + } catch (error) { + logger.error('[VideoPlayer] Error during seek operation:', error); } }; - const togglePlayback = () => { - if (vlcRef.current) { - if (paused) { - vlcRef.current.resume(); - } else { - vlcRef.current.pause(); + // Simplify handleProgress to always update state + const handleProgress = (event: any) => { + const currentTimeInSeconds = event.currentTime / 1000; // VLC gives time in milliseconds + + // Always update state - let VLC manage the timing + if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { + safeSetState(() => setCurrentTime(currentTimeInSeconds)); + progress.value = currentTimeInSeconds; + + // Animate the progress bar smoothly + const progressPercent = duration > 0 ? currentTimeInSeconds / duration : 0; + Animated.timing(progressAnim, { + toValue: progressPercent, + duration: 250, + useNativeDriver: false, + }).start(); + + // Update buffered position + const bufferedTime = event.bufferTime / 1000 || currentTimeInSeconds; + safeSetState(() => setBuffered(bufferedTime)); + } + }; + + // Enhanced onLoad handler to mark player as ready + const onLoad = (data: any) => { + setDuration(data.duration / 1000); // VLC returns duration in milliseconds + max.value = data.duration / 1000; + + // Mark player as ready for seeking + setIsPlayerReady(true); + + // Get audio and subtitle tracks from onLoad data + const audioTracksFromLoad = data.audioTracks || []; + const textTracksFromLoad = data.textTracks || []; + setVlcAudioTracks(audioTracksFromLoad); + setVlcTextTracks(textTracksFromLoad); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Video loaded with duration: ${data.duration / 1000}`); + const methods = Object.keys(vlcRef.current || {}).filter( + key => typeof vlcRef.current[key] === 'function' + ); + logger.log('[VideoPlayer] Available VLC methods:', methods); + logger.log('[VideoPlayer] Available audio tracks:', audioTracksFromLoad); + logger.log('[VideoPlayer] Available subtitle tracks:', textTracksFromLoad); + } + + // Set default selected tracks + if (audioTracksFromLoad.length > 1) { // More than just "Disable" + const firstEnabledAudio = audioTracksFromLoad.find((t: any) => t.id !== -1); + if(firstEnabledAudio) { + setSelectedAudioTrack(firstEnabledAudio.id); + } + } else if (audioTracksFromLoad.length > 0) { + setSelectedAudioTrack(audioTracksFromLoad[0].id); + } + // Subtitles default to disabled (-1) + + // If we have an initial position to seek to, do it now + if (initialPosition !== null && !isInitialSeekComplete) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Will seek to saved position: ${initialPosition}`); } - setPaused(!paused); + + // Seek with a short delay to ensure video is ready + setTimeout(() => { + if (vlcRef.current && duration > 0 && isMounted.current) { + seekToTime(initialPosition); + setIsInitialSeekComplete(true); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Initial seek completed to position: ${initialPosition}s`); + } + } + }, 1000); } }; const skip = (seconds: number) => { if (vlcRef.current) { const newTime = Math.max(0, Math.min(currentTime + seconds, duration)); - vlcRef.current.seek(newTime); - setCurrentTime(newTime); - progress.value = newTime; - } - }; - - const onProgress = (data: { currentTime: number }) => { - setCurrentTime(data.currentTime); - progress.value = data.currentTime; - }; - - const onLoad = (data: any) => { - setDuration(data.duration / 1000); // VLC returns duration in milliseconds - max.value = data.duration / 1000; - - logger.log(`[VideoPlayer] Video loaded with duration: ${data.duration / 1000}`); - - // If we have an initial position to seek to, do it now - if (initialPosition !== null && !isInitialSeekComplete && vlcRef.current) { - logger.log(`[VideoPlayer] Will seek to saved position: ${initialPosition}`); - - // Seek immediately with a small delay - setTimeout(() => { - if (vlcRef.current) { - try { - vlcRef.current.seek(initialPosition); - setCurrentTime(initialPosition); - progress.value = initialPosition; - setIsInitialSeekComplete(true); - logger.log(`[VideoPlayer] Successfully seeked to saved position: ${initialPosition}`); - } catch (error) { - logger.error('[VideoPlayer] Error seeking to saved position:', error); - } - } else { - logger.error('[VideoPlayer] vlcRef is no longer valid when attempting to seek'); - } - }, 1000); // Increase delay to ensure video is fully loaded - } else { - if (initialPosition === null) { - logger.log(`[VideoPlayer] No initial position to seek to`); - } else if (isInitialSeekComplete) { - logger.log(`[VideoPlayer] Initial seek already completed`); - } else { - logger.log(`[VideoPlayer] vlcRef not available for seeking`); - } + seekToTime(newTime); + // Let seekToTime handle all state updates } }; const onAudioTracks = (data: { audioTracks: AudioTrack[] }) => { const tracks = data.audioTracks || []; setAudioTracks(tracks); - logger.log(`[VideoPlayer] Available audio tracks:`, tracks); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Available audio tracks:`, tracks); + } }; const onTextTracks = (e: Readonly<{ textTracks: TextTrack[] }>) => { const tracks = e.textTracks || []; setTextTracks(tracks); - logger.log(`[VideoPlayer] Available subtitle tracks:`, tracks); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Available subtitle tracks:`, tracks); + } }; // Toggle through aspect ratio modes const cycleAspectRatio = () => { const currentIndex = resizeModes.indexOf(resizeMode); const nextIndex = (currentIndex + 1) % resizeModes.length; - logger.log(`[VideoPlayer] Changing aspect ratio from ${resizeMode} to ${resizeModes[nextIndex]}`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Changing aspect ratio from ${resizeMode} to ${resizeModes[nextIndex]}`); + } setResizeMode(resizeModes[nextIndex]); }; @@ -495,14 +666,14 @@ const VideoPlayer: React.FC = () => { // Add debug logs for modal visibility useEffect(() => { - if (showAudioModal) { + if (showAudioModal && DEBUG_MODE) { logger.log("[VideoPlayer] Audio modal should be visible now"); logger.log("[VideoPlayer] Available audio tracks:", audioTracks); } }, [showAudioModal, audioTracks]); useEffect(() => { - if (showSubtitleModal) { + if (showSubtitleModal && DEBUG_MODE) { logger.log("[VideoPlayer] Subtitle modal should be visible now"); logger.log("[VideoPlayer] Available text tracks:", textTracks); } @@ -511,13 +682,15 @@ const VideoPlayer: React.FC = () => { // Attempt to seek once vlcRef is available useEffect(() => { if (initialPosition !== null && !isInitialSeekComplete && vlcRef.current) { - logger.log(`[VideoPlayer] vlcRef is now available, attempting to seek to: ${initialPosition}`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] vlcRef is now available, attempting to seek to: ${initialPosition}`); + } try { - vlcRef.current.seek(initialPosition); - setCurrentTime(initialPosition); - progress.value = initialPosition; + seekToTime(initialPosition); setIsInitialSeekComplete(true); - logger.log(`[VideoPlayer] Successfully seeked to position: ${initialPosition}`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Successfully seeked to position: ${initialPosition}`); + } } catch (error) { logger.error('[VideoPlayer] Error seeking to position on ref available:', error); } @@ -531,17 +704,23 @@ const VideoPlayer: React.FC = () => { const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); if (pref) { setResumePreference(pref); - logger.log(`[VideoPlayer] Loaded resume preference: ${pref}`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Loaded resume preference: ${pref}`); + } // If user has a preference, apply it automatically if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) { setShowResumeOverlay(false); setInitialPosition(resumePosition); - logger.log(`[VideoPlayer] Auto-resuming based on saved preference`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Auto-resuming based on saved preference`); + } } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { setShowResumeOverlay(false); setInitialPosition(0); - logger.log(`[VideoPlayer] Auto-starting from beginning based on saved preference`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Auto-starting from beginning based on saved preference`); + } } } } catch (error) { @@ -557,7 +736,9 @@ const VideoPlayer: React.FC = () => { try { await AsyncStorage.removeItem(RESUME_PREF_KEY); setResumePreference(null); - logger.log(`[VideoPlayer] Reset resume preference`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Reset resume preference`); + } } catch (error) { logger.error('[VideoPlayer] Error resetting resume preference:', error); } @@ -566,13 +747,17 @@ const VideoPlayer: React.FC = () => { // Handle resume from overlay - modified for VLC const handleResume = async () => { if (resumePosition !== null && vlcRef.current) { - logger.log(`[VideoPlayer] Resuming from ${resumePosition}`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Resuming from ${resumePosition}`); + } // Save preference if remember choice is checked if (rememberChoice) { try { await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME); - logger.log(`[VideoPlayer] Saved resume preference: ${RESUME_PREF.ALWAYS_RESUME}`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Saved resume preference: ${RESUME_PREF.ALWAYS_RESUME}`); + } } catch (error) { logger.error('[VideoPlayer] Error saving resume preference:', error); } @@ -586,7 +771,7 @@ const VideoPlayer: React.FC = () => { // Seek to position with VLC setTimeout(() => { if (vlcRef.current) { - vlcRef.current.seek(resumePosition); + seekToTime(resumePosition); } }, 500); } @@ -594,13 +779,17 @@ const VideoPlayer: React.FC = () => { // Handle start from beginning - modified for VLC const handleStartFromBeginning = async () => { - logger.log(`[VideoPlayer] Starting from beginning`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Starting from beginning`); + } // Save preference if remember choice is checked if (rememberChoice) { try { await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_START_OVER); - logger.log(`[VideoPlayer] Saved resume preference: ${RESUME_PREF.ALWAYS_START_OVER}`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Saved resume preference: ${RESUME_PREF.ALWAYS_START_OVER}`); + } } catch (error) { logger.error('[VideoPlayer] Error saving resume preference:', error); } @@ -612,7 +801,7 @@ const VideoPlayer: React.FC = () => { setInitialPosition(0); // Make sure we seek to beginning if (vlcRef.current) { - vlcRef.current.seek(0); + seekToTime(0); setCurrentTime(0); progress.value = 0; } @@ -631,29 +820,6 @@ const VideoPlayer: React.FC = () => { setShowControls(!showControls); }; - // Handle VLC progress updates - const handleProgress = (event: any) => { - const currentTimeInSeconds = event.currentTime / 1000; // VLC gives time in milliseconds - setCurrentTime(currentTimeInSeconds); - progress.value = currentTimeInSeconds; - - // Update buffered position - const bufferedTime = event.bufferTime / 1000 || currentTimeInSeconds; - setBuffered(bufferedTime); - - // Calculate buffer ahead (cannot be negative) - const bufferAhead = Math.max(0, bufferedTime - currentTimeInSeconds); - const bufferPercentage = ((bufferedTime / (duration || 1)) * 100); - - // Add detailed buffer logging - logger.log(`[VideoPlayer] Buffer Status: - Current Time: ${currentTimeInSeconds.toFixed(2)}s - Buffered: ${bufferedTime.toFixed(2)}s - Buffered Ahead: ${bufferAhead.toFixed(2)}s - Buffer Percentage: ${bufferPercentage.toFixed(1)}% - `); - }; - // Handle VLC errors const handleError = (error: any) => { logger.error('[VideoPlayer] Playback Error:', error); @@ -663,7 +829,9 @@ const VideoPlayer: React.FC = () => { // Handle VLC buffering const onBuffering = (event: any) => { setIsBuffering(event.isBuffering); - logger.log(`[VideoPlayer] Buffering: ${event.isBuffering}`); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Buffering: ${event.isBuffering}`); + } }; // Handle VLC playback ended @@ -671,111 +839,14 @@ const VideoPlayer: React.FC = () => { // Your existing playback ended logic here }; - // Function to get audio tracks from VLC - const getAudioTracks = () => { - if (vlcRef.current) { - vlcRef.current.getAudioTracks().then((tracks: any) => { - setVlcAudioTracks(tracks || []); - logger.log("[VideoPlayer] Available VLC audio tracks:", tracks); - }).catch((error: any) => { - logger.error("[VideoPlayer] Failed to get audio tracks:", error); - }); - } - }; - // Function to select audio track in VLC const selectAudioTrack = (trackId: number) => { - if (vlcRef.current) { - vlcRef.current.setAudioTrack(trackId); - setSelectedAudioTrack(trackId); - } - }; - - // Function to get subtitle tracks from VLC - const getTextTracks = () => { - if (vlcRef.current) { - vlcRef.current.getTextTracks().then((tracks: any) => { - setVlcTextTracks(tracks || []); - logger.log("[VideoPlayer] Available VLC subtitle tracks:", tracks); - }).catch((error: any) => { - logger.error("[VideoPlayer] Failed to get subtitle tracks:", error); - }); - } + setSelectedAudioTrack(trackId); }; // Function to select subtitle track in VLC const selectTextTrack = (trackId: number) => { - if (vlcRef.current) { - vlcRef.current.setTextTrack(trackId); - // Update your state accordingly - setSelectedTextTrack({ type: 'index', value: trackId }); - } - }; - - // Add this useEffect to get audio and subtitle tracks after player is loaded - useEffect(() => { - if (duration > 0 && vlcRef.current) { - // Wait a bit for VLC to fully initialize and recognize tracks - setTimeout(() => { - getAudioTracks(); - getTextTracks(); - }, 2000); - } - }, [duration]); - - // Update audio modal to use VLC audio tracks - const renderAudioModal = () => { - if (!showAudioModal) return null; - - return ( - <View style={styles.fullscreenOverlay}> - <View style={styles.enhancedModalContainer}> - <View style={styles.enhancedModalHeader}> - <Text style={styles.enhancedModalTitle}>Audio</Text> - <TouchableOpacity - style={styles.enhancedCloseButton} - onPress={() => setShowAudioModal(false)} - > - <Ionicons name="close" size={24} color="white" /> - </TouchableOpacity> - </View> - - <ScrollView style={styles.trackListScrollContainer}> - <View style={styles.trackListContainer}> - {vlcAudioTracks.length > 0 ? vlcAudioTracks.map(track => ( - <TouchableOpacity - key={track.id} - style={styles.enhancedTrackItem} - onPress={() => { - selectAudioTrack(track.id); - setShowAudioModal(false); - }} - > - <View style={styles.trackInfoContainer}> - <Text style={styles.trackPrimaryText}> - {formatLanguage(track.language) || track.name || `Track ${track.id}`} - </Text> - {(track.name && track.language) && ( - <Text style={styles.trackSecondaryText}>{track.name}</Text> - )} - </View> - {selectedAudioTrack === track.id && ( - <View style={styles.selectedIndicatorContainer}> - <Ionicons name="checkmark" size={22} color="#E50914" /> - </View> - )} - </TouchableOpacity> - )) : ( - <View style={styles.emptyStateContainer}> - <Ionicons name="alert-circle-outline" size={40} color="#888" /> - <Text style={styles.emptyStateText}>No audio tracks available</Text> - </View> - )} - </View> - </ScrollView> - </View> - </View> - ); + setSelectedTextTrack(trackId); }; // Update subtitle modal to use VLC subtitle tracks @@ -808,8 +879,7 @@ const VideoPlayer: React.FC = () => { <View style={styles.trackInfoContainer}> <Text style={styles.trackPrimaryText}>Off</Text> </View> - {(selectedTextTrack?.type === 'disabled' || - (selectedTextTrack?.type === 'index' && selectedTextTrack.value === -1)) && ( + {selectedTextTrack === -1 && ( <View style={styles.selectedIndicatorContainer}> <Ionicons name="checkmark" size={22} color="#E50914" /> </View> @@ -828,14 +898,13 @@ const VideoPlayer: React.FC = () => { > <View style={styles.trackInfoContainer}> <Text style={styles.trackPrimaryText}> - {formatLanguage(track.language) || track.name || `Subtitle ${track.id}`} + {getTrackDisplayName(track)} </Text> {(track.name && track.language) && ( <Text style={styles.trackSecondaryText}>{track.name}</Text> )} </View> - {selectedTextTrack?.type === 'index' && - selectedTextTrack?.value === track.id && ( + {selectedTextTrack === track.id && ( <View style={styles.selectedIndicatorContainer}> <Ionicons name="checkmark" size={22} color="#E50914" /> </View> @@ -881,7 +950,11 @@ const VideoPlayer: React.FC = () => { // VLC specific method to set playback speed const changePlaybackSpeed = (speed: number) => { if (vlcRef.current) { - vlcRef.current.setRate(speed); + if (typeof vlcRef.current.setRate === 'function') { + vlcRef.current.setRate(speed); + } else if (typeof vlcRef.current.setPlaybackRate === 'function') { + vlcRef.current.setPlaybackRate(speed); + } setPlaybackSpeed(speed); } }; @@ -890,237 +963,422 @@ const VideoPlayer: React.FC = () => { const setVolume = (volumeLevel: number) => { if (vlcRef.current) { // VLC volume is typically between 0-200 - vlcRef.current.setVolume(volumeLevel * 200); + if (typeof vlcRef.current.setVolume === 'function') { + vlcRef.current.setVolume(volumeLevel * 200); + } } }; + // Added back the togglePlayback function + const togglePlayback = () => { + if (vlcRef.current) { + if (paused) { + // Check if resume function exists + if (typeof vlcRef.current.resume === 'function') { + vlcRef.current.resume(); + } else if (typeof vlcRef.current.play === 'function') { + vlcRef.current.play(); + } else { + // Fallback - use setPaused method or property if available + vlcRef.current.setPaused && vlcRef.current.setPaused(false); + } + } else { + // Check if pause function exists + if (typeof vlcRef.current.pause === 'function') { + vlcRef.current.pause(); + } else { + // Fallback - use setPaused method or property if available + vlcRef.current.setPaused && vlcRef.current.setPaused(true); + } + } + setPaused(!paused); + } + }; + + // Re-add the renderAudioModal function + const renderAudioModal = () => { + if (!showAudioModal) return null; + + return ( + <View style={styles.fullscreenOverlay}> + <View style={styles.enhancedModalContainer}> + <View style={styles.enhancedModalHeader}> + <Text style={styles.enhancedModalTitle}>Audio</Text> + <TouchableOpacity + style={styles.enhancedCloseButton} + onPress={() => setShowAudioModal(false)} + > + <Ionicons name="close" size={24} color="white" /> + </TouchableOpacity> + </View> + + <ScrollView style={styles.trackListScrollContainer}> + <View style={styles.trackListContainer}> + {vlcAudioTracks.length > 0 ? vlcAudioTracks.map(track => ( + <TouchableOpacity + key={track.id} + style={styles.enhancedTrackItem} + onPress={() => { + selectAudioTrack(track.id); + setShowAudioModal(false); + }} + > + <View style={styles.trackInfoContainer}> + <Text style={styles.trackPrimaryText}> + {getTrackDisplayName(track)} + </Text> + {(track.name && track.language) && ( + <Text style={styles.trackSecondaryText}>{track.name}</Text> + )} + </View> + {selectedAudioTrack === track.id && ( + <View style={styles.selectedIndicatorContainer}> + <Ionicons name="checkmark" size={22} color="#E50914" /> + </View> + )} + </TouchableOpacity> + )) : ( + <View style={styles.emptyStateContainer}> + <Ionicons name="alert-circle-outline" size={40} color="#888" /> + <Text style={styles.emptyStateText}>No audio tracks available</Text> + </View> + )} + </View> + </ScrollView> + </View> + </View> + ); + }; + + // Use a ref to track if we're mounted to prevent state updates after unmount + // This helps prevent potential memory leaks and strange behaviors with navigation + const isMounted = useRef(true); + + // Clean up when component unmounts + useEffect(() => { + return () => { + isMounted.current = false; + if (seekDebounceTimer.current) { + clearTimeout(seekDebounceTimer.current); + } + }; + }, []); + + // Wrap all setState calls with this check + const safeSetState = (setter: any) => { + if (isMounted.current) { + setter(); + } + }; + + // Enhanced progress bar touch handling with drag support + const handleProgressBarTouch = (event: any) => { + if (!duration || duration <= 0) return; + + const { locationX } = event.nativeEvent; + processProgressTouch(locationX); + }; + + const handleProgressBarDragStart = () => { + setIsDragging(true); + }; + + const handleProgressBarDragMove = (event: any) => { + if (!isDragging || !duration || duration <= 0) return; + + const { locationX } = event.nativeEvent; + processProgressTouch(locationX); + }; + + const handleProgressBarDragEnd = () => { + setIsDragging(false); + }; + + // Helper function to process touch position and seek + const processProgressTouch = (locationX: number) => { + progressBarRef.current?.measure((x, y, width, height, pageX, pageY) => { + // Calculate percentage of touch position relative to progress bar width + const percentage = Math.max(0, Math.min(locationX / width, 1)); + // Calculate time to seek to + const seekTime = percentage * duration; + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Seeking to: ${seekTime}s (${percentage * 100}%)`); + } + + // Seek to the calculated time + seekToTime(seekTime); + }); + }; + return ( <View style={styles.container}> - <TouchableOpacity - style={styles.videoContainer} - onPress={toggleControls} - activeOpacity={1} + {/* Opening Animation Overlay - covers the entire screen during transition */} + <Animated.View + style={[ + styles.openingOverlay, + { + opacity: backgroundFadeAnim, + zIndex: isOpeningAnimationComplete ? -1 : 3000, + } + ]} + pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'} > - <VLCPlayer - ref={vlcRef} - source={{ - uri: uri, - }} - style={styles.video} - paused={paused || showResumeOverlay} - resizeMode={resizeMode as any} // Type cast to avoid type error - onLoad={onLoad} - onProgress={handleProgress} - rate={playbackSpeed} - onError={handleError} - onEnd={onEnd} - // VLC specific props - autoAspectRatio={true} - // autoReloadOnError={true} - Removed, not supported by VLCPlayer - // Note: VLC handles audio tracks differently, we'll need to adjust the UI for this - /> + <View style={styles.openingContent}> + <ActivityIndicator size="large" color="#E50914" /> + <Text style={styles.openingText}>Loading video...</Text> + </View> + </Animated.View> - {/* Slider Container with buffer indicator */} - <Animated.View style={[styles.sliderContainer, { opacity: fadeAnim }]}> - <View style={styles.sliderBackground}> - {/* Buffered Progress */} - <View style={[styles.bufferProgress, { - width: `${(buffered / (duration || 1)) * 100}%` - }]} /> - </View> - <Slider - progress={progress} - minimumValue={min} - maximumValue={max} - style={styles.slider} - onValueChange={onSliderValueChange} - theme={{ - minimumTrackTintColor: '#E50914', - maximumTrackTintColor: 'transparent', - bubbleBackgroundColor: '#E50914', + {/* Animated Video Player Container */} + <Animated.View + style={[ + styles.videoPlayerContainer, + { + opacity: openingFadeAnim, + transform: [{ scale: openingScaleAnim }], + } + ]} + > + <TouchableOpacity + style={styles.videoContainer} + onPress={toggleControls} + activeOpacity={1} + > + <VLCPlayer + ref={vlcRef} + source={{ + uri: uri, }} + style={styles.video} + paused={paused || showResumeOverlay} + resizeMode={resizeMode as any} + onLoad={onLoad} + onProgress={handleProgress} + rate={playbackSpeed} + onError={handleError} + onEnd={onEnd} + audioTrack={selectedAudioTrack ?? undefined} + textTrack={selectedTextTrack} + autoAspectRatio={true} /> - <View style={styles.timeDisplay}> - <Text style={styles.duration}>{formatTime(currentTime)}</Text> - <Text style={styles.duration}>{formatTime(duration)}</Text> - </View> - </Animated.View> - {/* Controls Overlay - Using Animated.View */} - <Animated.View style={[styles.controlsContainer, { opacity: fadeAnim }]}> - {/* Top Gradient & Header */} - <LinearGradient - colors={['rgba(0,0,0,0.7)', 'transparent']} - style={styles.topGradient} - > - <View style={styles.header}> - {/* Title Section - Enhanced with metadata */} - <View style={styles.titleSection}> - <Text style={styles.title}>{title}</Text> - {/* Show season and episode for series */} - {season && episode && ( - <Text style={styles.episodeInfo}> - S{season}E{episode} {episodeTitle && `• ${episodeTitle}`} - </Text> - )} - {/* Show year, quality, and provider */} - <View style={styles.metadataRow}> - {year && <Text style={styles.metadataText}>{year}</Text>} - {quality && <View style={styles.qualityBadge}><Text style={styles.qualityText}>{quality}</Text></View>} - {streamProvider && <Text style={styles.providerText}>via {streamProvider}</Text>} - </View> - </View> - <TouchableOpacity style={styles.closeButton} onPress={handleClose}> - <Ionicons name="close" size={24} color="white" /> - </TouchableOpacity> - </View> - </LinearGradient> - - {/* Center Controls (Play/Pause, Skip) */} - <View style={styles.controls}> - <TouchableOpacity onPress={() => skip(-10)} style={styles.skipButton}> - <Ionicons name="play-back" size={24} color="white" /> - <Text style={styles.skipText}>10</Text> - </TouchableOpacity> - <TouchableOpacity onPress={togglePlayback} style={styles.playButton}> - <Ionicons name={paused ? "play" : "pause"} size={40} color="white" /> - </TouchableOpacity> - <TouchableOpacity onPress={() => skip(10)} style={styles.skipButton}> - <Ionicons name="play-forward" size={24} color="white" /> - <Text style={styles.skipText}>10</Text> - </TouchableOpacity> - </View> - - {/* Bottom Gradient */} - <LinearGradient - colors={['transparent', 'rgba(0,0,0,0.7)']} - style={styles.bottomGradient} - > - <View style={styles.bottomControls}> - {/* Bottom Buttons Row */} - <View style={styles.bottomButtons}> - {/* Speed Button */} - <TouchableOpacity style={styles.bottomButton}> - <Ionicons name="speedometer" size={20} color="white" /> - <Text style={styles.bottomButtonText}>Speed ({playbackSpeed}x)</Text> - </TouchableOpacity> - - {/* Aspect Ratio Button - Added */} - <TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}> - <Ionicons name="resize" size={20} color="white" /> - <Text style={styles.bottomButtonText}> - Aspect ({resizeMode}) - </Text> - </TouchableOpacity> - - {/* Audio Button - Updated language display */} - <TouchableOpacity - style={styles.bottomButton} - onPress={() => setShowAudioModal(true)} - disabled={audioTracks.length <= 1} - > - <Ionicons name="volume-high" size={20} color={audioTracks.length <= 1 ? 'grey' : 'white'} /> - <Text style={[styles.bottomButtonText, audioTracks.length <= 1 && {color: 'grey'}]}> - {audioTracks.length > 0 && selectedAudioTrack !== null - ? `Audio: ${formatLanguage(audioTracks.find(t => t.index === selectedAudioTrack)?.language)}` - : 'Audio: Default'} - </Text> - </TouchableOpacity> - - {/* Subtitle Button - Updated language display */} - <TouchableOpacity - style={styles.bottomButton} - onPress={() => setShowSubtitleModal(true)} - disabled={textTracks.length === 0} - > - <Ionicons name="text" size={20} color={textTracks.length === 0 ? 'grey' : 'white'} /> - <Text style={[styles.bottomButtonText, textTracks.length === 0 && {color: 'grey'}]}> - {selectedTextTrack?.type === 'disabled' - ? 'Subtitles: Off' - : `Subtitles: ${formatLanguage(textTracks.find(t => t.index === selectedTextTrack?.value)?.language)}`} - </Text> - </TouchableOpacity> - </View> - </View> - </LinearGradient> - </Animated.View> - - {/* Resume Overlay */} - {showResumeOverlay && resumePosition !== null && ( - <View style={styles.resumeOverlay}> - <LinearGradient - colors={['rgba(0,0,0,0.9)', 'rgba(0,0,0,0.7)']} - style={styles.resumeContainer} + {/* Progress bar with enhanced touch handling */} + <Animated.View style={[styles.sliderContainer, { opacity: fadeAnim }]}> + <View + style={styles.progressTouchArea} + onTouchStart={handleProgressBarDragStart} + onTouchMove={handleProgressBarDragMove} + onTouchEnd={handleProgressBarDragEnd} > - <View style={styles.resumeContent}> - <View style={styles.resumeIconContainer}> - <Ionicons name="play-circle" size={40} color="#E50914" /> - </View> - <View style={styles.resumeTextContainer}> - <Text style={styles.resumeTitle}>Continue Watching</Text> - <Text style={styles.resumeInfo}> - {title} - {season && episode && ` • S${season}E${episode}`} - </Text> - <View style={styles.resumeProgressContainer}> - <View style={styles.resumeProgressBar}> - <View - style={[ - styles.resumeProgressFill, - { width: `${duration > 0 ? (resumePosition / duration) * 100 : 0}%` } - ]} - /> - </View> - <Text style={styles.resumeTimeText}> - {formatTime(resumePosition)} {duration > 0 ? `/ ${formatTime(duration)}` : ''} - </Text> - </View> - </View> - </View> - - {/* Remember choice checkbox */} - <TouchableOpacity - style={styles.rememberChoiceContainer} - onPress={() => setRememberChoice(!rememberChoice)} - activeOpacity={0.7} + <TouchableOpacity + activeOpacity={0.8} + onPress={handleProgressBarTouch} + style={{width: '100%'}} > - <View style={styles.checkboxContainer}> - <View style={[styles.checkbox, rememberChoice && styles.checkboxChecked]}> - {rememberChoice && <Ionicons name="checkmark" size={12} color="white" />} - </View> - <Text style={styles.rememberChoiceText}>Remember my choice</Text> + <View + ref={progressBarRef} + style={styles.progressBarContainer} + > + {/* Buffered Progress */} + <View style={[styles.bufferProgress, { + width: `${(buffered / (duration || 1)) * 100}%` + }]} /> + {/* Animated Progress */} + <Animated.View + style={[ + styles.progressBarFill, + { + width: progressAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0%', '100%'] + }) + } + ]} + /> </View> - - {resumePreference && ( - <TouchableOpacity - onPress={resetResumePreference} - style={styles.resetPreferenceButton} - > - <Text style={styles.resetPreferenceText}>Reset</Text> - </TouchableOpacity> - )} </TouchableOpacity> + </View> + <View style={styles.timeDisplay}> + <Text style={styles.duration}>{formatTime(currentTime)}</Text> + <Text style={styles.duration}>{formatTime(duration)}</Text> + </View> + </Animated.View> - <View style={styles.resumeButtons}> - <TouchableOpacity - style={styles.resumeButton} - onPress={handleStartFromBeginning} - > - <Ionicons name="refresh" size={16} color="white" style={styles.buttonIcon} /> - <Text style={styles.resumeButtonText}>Start Over</Text> - </TouchableOpacity> - <TouchableOpacity - style={[styles.resumeButton, styles.resumeFromButton]} - onPress={handleResume} - > - <Ionicons name="play" size={16} color="white" style={styles.buttonIcon} /> - <Text style={styles.resumeButtonText}>Resume</Text> + {/* Controls Overlay - Using Animated.View */} + <Animated.View style={[styles.controlsContainer, { opacity: fadeAnim }]}> + {/* Top Gradient & Header */} + <LinearGradient + colors={['rgba(0,0,0,0.7)', 'transparent']} + style={styles.topGradient} + > + <View style={styles.header}> + {/* Title Section - Enhanced with metadata */} + <View style={styles.titleSection}> + <Text style={styles.title}>{title}</Text> + {/* Show season and episode for series */} + {season && episode && ( + <Text style={styles.episodeInfo}> + S{season}E{episode} {episodeTitle && `• ${episodeTitle}`} + </Text> + )} + {/* Show year, quality, and provider */} + <View style={styles.metadataRow}> + {year && <Text style={styles.metadataText}>{year}</Text>} + {quality && <View style={styles.qualityBadge}><Text style={styles.qualityText}>{quality}</Text></View>} + {streamProvider && <Text style={styles.providerText}>via {streamProvider}</Text>} + </View> + </View> + <TouchableOpacity style={styles.closeButton} onPress={handleClose}> + <Ionicons name="close" size={24} color="white" /> </TouchableOpacity> </View> </LinearGradient> - </View> - )} - </TouchableOpacity> + + {/* Center Controls (Play/Pause, Skip) */} + <View style={styles.controls}> + <TouchableOpacity onPress={() => skip(-10)} style={styles.skipButton}> + <Ionicons name="play-back" size={24} color="white" /> + <Text style={styles.skipText}>10</Text> + </TouchableOpacity> + <TouchableOpacity onPress={togglePlayback} style={styles.playButton}> + <Ionicons name={paused ? "play" : "pause"} size={40} color="white" /> + </TouchableOpacity> + <TouchableOpacity onPress={() => skip(10)} style={styles.skipButton}> + <Ionicons name="play-forward" size={24} color="white" /> + <Text style={styles.skipText}>10</Text> + </TouchableOpacity> + </View> + + {/* Bottom Gradient */} + <LinearGradient + colors={['transparent', 'rgba(0,0,0,0.7)']} + style={styles.bottomGradient} + > + <View style={styles.bottomControls}> + {/* Bottom Buttons Row */} + <View style={styles.bottomButtons}> + {/* Speed Button */} + <TouchableOpacity style={styles.bottomButton}> + <Ionicons name="speedometer" size={20} color="white" /> + <Text style={styles.bottomButtonText}>Speed ({playbackSpeed}x)</Text> + </TouchableOpacity> + + {/* Aspect Ratio Button - Added */} + <TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}> + <Ionicons name="resize" size={20} color="white" /> + <Text style={styles.bottomButtonText}> + Aspect ({resizeMode}) + </Text> + </TouchableOpacity> + + {/* Audio Button - Updated to use vlcAudioTracks */} + <TouchableOpacity + style={styles.bottomButton} + onPress={() => setShowAudioModal(true)} + disabled={vlcAudioTracks.length <= 1} + > + <Ionicons name="volume-high" size={20} color={vlcAudioTracks.length <= 1 ? 'grey' : 'white'} /> + <Text style={[styles.bottomButtonText, vlcAudioTracks.length <= 1 && {color: 'grey'}]}> + {`Audio: ${getTrackDisplayName(vlcAudioTracks.find(t => t.id === selectedAudioTrack) || {id: -1, name: 'Default'})}`} + </Text> + </TouchableOpacity> + + {/* Subtitle Button - Updated to use vlcTextTracks */} + <TouchableOpacity + style={styles.bottomButton} + onPress={() => setShowSubtitleModal(true)} + disabled={vlcTextTracks.length === 0} + > + <Ionicons name="text" size={20} color={vlcTextTracks.length === 0 ? 'grey' : 'white'} /> + <Text style={[styles.bottomButtonText, vlcTextTracks.length === 0 && {color: 'grey'}]}> + {(selectedTextTrack === -1) + ? 'Subtitles' + : `Subtitles: ${getTrackDisplayName(vlcTextTracks.find(t => t.id === selectedTextTrack) || {id: -1, name: 'On'})}`} + </Text> + </TouchableOpacity> + </View> + </View> + </LinearGradient> + </Animated.View> + + {/* Resume Overlay */} + {showResumeOverlay && resumePosition !== null && ( + <View style={styles.resumeOverlay}> + <LinearGradient + colors={['rgba(0,0,0,0.9)', 'rgba(0,0,0,0.7)']} + style={styles.resumeContainer} + > + <View style={styles.resumeContent}> + <View style={styles.resumeIconContainer}> + <Ionicons name="play-circle" size={40} color="#E50914" /> + </View> + <View style={styles.resumeTextContainer}> + <Text style={styles.resumeTitle}>Continue Watching</Text> + <Text style={styles.resumeInfo}> + {title} + {season && episode && ` • S${season}E${episode}`} + </Text> + <View style={styles.resumeProgressContainer}> + <View style={styles.resumeProgressBar}> + <View + style={[ + styles.resumeProgressFill, + { width: `${duration > 0 ? (resumePosition / duration) * 100 : 0}%` } + ]} + /> + </View> + <Text style={styles.resumeTimeText}> + {formatTime(resumePosition)} {duration > 0 ? `/ ${formatTime(duration)}` : ''} + </Text> + </View> + </View> + </View> + + {/* Remember choice checkbox */} + <TouchableOpacity + style={styles.rememberChoiceContainer} + onPress={() => setRememberChoice(!rememberChoice)} + activeOpacity={0.7} + > + <View style={styles.checkboxContainer}> + <View style={[styles.checkbox, rememberChoice && styles.checkboxChecked]}> + {rememberChoice && <Ionicons name="checkmark" size={12} color="white" />} + </View> + <Text style={styles.rememberChoiceText}>Remember my choice</Text> + </View> + + {resumePreference && ( + <TouchableOpacity + onPress={resetResumePreference} + style={styles.resetPreferenceButton} + > + <Text style={styles.resetPreferenceText}>Reset</Text> + </TouchableOpacity> + )} + </TouchableOpacity> + + <View style={styles.resumeButtons}> + <TouchableOpacity + style={styles.resumeButton} + onPress={handleStartFromBeginning} + > + <Ionicons name="refresh" size={16} color="white" style={styles.buttonIcon} /> + <Text style={styles.resumeButtonText}>Start Over</Text> + </TouchableOpacity> + <TouchableOpacity + style={[styles.resumeButton, styles.resumeFromButton]} + onPress={handleResume} + > + <Ionicons name="play" size={16} color="white" style={styles.buttonIcon} /> + <Text style={styles.resumeButtonText}>Resume</Text> + </TouchableOpacity> + </View> + </LinearGradient> + </View> + )} + </TouchableOpacity> + </Animated.View> {/* Use the new modal rendering functions */} {renderAudioModal()} @@ -1242,16 +1500,18 @@ const styles = StyleSheet.create({ paddingHorizontal: 20, zIndex: 1000, }, - sliderBackground: { - position: 'absolute', - left: 0, - right: 0, - height: 3, + progressTouchArea: { + height: 30, // Increase touch target height for easier interaction + justifyContent: 'center', + width: '100%', + }, + progressBarContainer: { + height: 4, backgroundColor: 'rgba(255, 255, 255, 0.2)', - borderRadius: 1.5, + borderRadius: 2, overflow: 'hidden', - marginHorizontal: 20, - top: 13.5, // Center with the slider thumb + marginHorizontal: 4, + position: 'relative', }, bufferProgress: { position: 'absolute', @@ -1260,17 +1520,20 @@ const styles = StyleSheet.create({ bottom: 0, backgroundColor: 'rgba(255, 255, 255, 0.4)', }, - slider: { - width: '100%', - height: 30, - zIndex: 1, + progressBarFill: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + backgroundColor: '#E50914', + height: '100%', }, timeDisplay: { flexDirection: 'row', justifyContent: 'space-between', width: '100%', paddingHorizontal: 4, - marginTop: -4, // Reduced space between slider and time + marginTop: 4, // Increased space between progress bar and time marginBottom: 8, // Added space between time and buttons }, duration: { @@ -1565,6 +1828,33 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: 'bold', }, + openingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.85)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 2000, + }, + openingContent: { + padding: 20, + backgroundColor: 'rgba(0,0,0,0.85)', + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', + }, + openingText: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + marginTop: 20, + }, + videoPlayerContainer: { + flex: 1, + }, }); export default VideoPlayer; \ No newline at end of file -- 2.45.2 From 0d2b33a9956eca2092cb9e29843a88a6ca099d31 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Sun, 8 Jun 2025 16:12:33 +0530 Subject: [PATCH 14/88] Refactor VideoPlayer component to improve loading animations and state management This update disables the debug mode for production and introduces a new state to track video loading status. The opening animation sequence has been modified to wait for the video to load before displaying the player, enhancing the user experience during playback. Additionally, unnecessary code related to seeking has been removed for clarity and performance improvements. --- src/screens/VideoPlayer.tsx | 116 ++++++++++++++---------------------- 1 file changed, 45 insertions(+), 71 deletions(-) diff --git a/src/screens/VideoPlayer.tsx b/src/screens/VideoPlayer.tsx index 90f81da..1f65cee 100644 --- a/src/screens/VideoPlayer.tsx +++ b/src/screens/VideoPlayer.tsx @@ -22,7 +22,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; // Debug flag - set back to false to disable verbose logging // WARNING: Setting this to true currently causes infinite render loops // Use selective logging instead if debugging is needed -const DEBUG_MODE = true; +const DEBUG_MODE = false; // Safer debug function that won't cause render loops // Call this with any debugging info you need instead of using inline DEBUG_MODE checks @@ -276,6 +276,9 @@ const VideoPlayer: React.FC = () => { const pendingSeekValue = useRef<number | null>(null); const lastSeekTime = useRef<number>(0); + // Add state for tracking if the video is loaded + const [isVideoLoaded, setIsVideoLoaded] = useState(false); + // Lock screen to landscape when component mounts useEffect(() => { // Since orientation is now locked before navigation, we can start immediately @@ -299,40 +302,44 @@ const VideoPlayer: React.FC = () => { }; }, []); - // Opening animation sequence + // Opening animation sequence - modified to wait for video load const startOpeningAnimation = () => { - // Much shorter delay since rotation is already handled - setTimeout(() => { - // Start the main animation sequence - Animated.parallel([ - // Fade in the video player - Animated.timing(openingFadeAnim, { - toValue: 1, - duration: 600, // Reduced back to original duration - useNativeDriver: true, - }), - // Scale up from 80% to 100% - Animated.timing(openingScaleAnim, { - toValue: 1, - duration: 700, // Reduced back to original duration - useNativeDriver: true, - }), - // Fade out the black background overlay - Animated.timing(backgroundFadeAnim, { - toValue: 0, - duration: 800, // Reduced back to original duration - useNativeDriver: true, - }), - ]).start(() => { - // Animation is complete - setIsOpeningAnimationComplete(true); - - // Hide the background overlay completely after animation - setTimeout(() => { - backgroundFadeAnim.setValue(0); - }, 100); - }); - }, 150); // Much shorter delay since no rotation is needed + // Keep everything black until video loads + // Only show loading indicator, no video player fade-in yet + // Note: All animations will be triggered by onLoad when video is ready + }; + + // Complete the opening animation when video loads + const completeOpeningAnimation = () => { + // Start all animations together when video is ready + Animated.parallel([ + // Fade in the video player + Animated.timing(openingFadeAnim, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }), + // Scale up from 80% to 100% + Animated.timing(openingScaleAnim, { + toValue: 1, + duration: 700, + useNativeDriver: true, + }), + // Fade out the black background overlay + Animated.timing(backgroundFadeAnim, { + toValue: 0, + duration: 800, + useNativeDriver: true, + }), + ]).start(() => { + // Animation is complete + setIsOpeningAnimationComplete(true); + + // Hide the background overlay completely after animation + setTimeout(() => { + backgroundFadeAnim.setValue(0); + }, 100); + }); }; // Load saved watch progress on mount @@ -465,19 +472,6 @@ const VideoPlayer: React.FC = () => { } }; - // Replace the reset seek value effect - // useEffect(() => { - // if (seekValue !== undefined) { - // const timer = setTimeout(() => { - // if (isMounted.current) { - // setSeekValue(undefined); - // } - // }, 1000); // Longer timeout to ensure VLC processes the seek properly - - // return () => clearTimeout(timer); - // } - // }, [seekValue]); - // Simplify the seekToTime function to use VLC's direct methods const seekToTime = (timeInSeconds: number) => { if (!isPlayerReady || duration <= 0 || !vlcRef.current) return; @@ -501,11 +495,6 @@ const VideoPlayer: React.FC = () => { } else { logger.error('[VideoPlayer] No seek method available on VLC player'); } - - // Update UI immediately for responsiveness - const progressPercent = timeInSeconds / duration; - progressAnim.setValue(progressPercent); - } catch (error) { logger.error('[VideoPlayer] Error during seek operation:', error); } @@ -586,13 +575,16 @@ const VideoPlayer: React.FC = () => { } }, 1000); } + + // Mark video as loaded and complete opening animation + setIsVideoLoaded(true); + completeOpeningAnimation(); }; const skip = (seconds: number) => { if (vlcRef.current) { const newTime = Math.max(0, Math.min(currentTime + seconds, duration)); seekToTime(newTime); - // Let seekToTime handle all state updates } }; @@ -679,24 +671,6 @@ const VideoPlayer: React.FC = () => { } }, [showSubtitleModal, textTracks]); - // Attempt to seek once vlcRef is available - useEffect(() => { - if (initialPosition !== null && !isInitialSeekComplete && vlcRef.current) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] vlcRef is now available, attempting to seek to: ${initialPosition}`); - } - try { - seekToTime(initialPosition); - setIsInitialSeekComplete(true); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Successfully seeked to position: ${initialPosition}`); - } - } catch (error) { - logger.error('[VideoPlayer] Error seeking to position on ref available:', error); - } - } - }, [vlcRef.current, initialPosition, isInitialSeekComplete]); - // Load resume preference on mount useEffect(() => { const loadResumePreference = async () => { -- 2.45.2 From 10e12fa6bd67ae32ea4f7ca1be5d49f65659b0e8 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Sun, 8 Jun 2025 18:29:17 +0530 Subject: [PATCH 15/88] Add IMDb ID support to navigation and video player components This update introduces the optional IMDb ID parameter to the navigation types and the VideoPlayer component, enhancing subtitle fetching capabilities. The StreamsScreen has been updated to utilize the new IMDb ID, ensuring that external subtitles can be automatically searched based on the provided ID. These changes improve the overall functionality and user experience when accessing video content. --- src/navigation/AppNavigator.tsx | 1 + src/screens/StreamsScreen.tsx | 9 +- src/screens/VideoPlayer.tsx | 1321 ++++++++++++++++++++++++++++--- src/types/navigation.d.ts | 1 + 4 files changed, 1214 insertions(+), 118 deletions(-) diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 8d8c4b8..16d3e89 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -77,6 +77,7 @@ export type RootStackParamList = { id?: string; type?: string; episodeId?: string; + imdbId?: string; }; Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string }; Credits: { mediaId: string; mediaType: string }; diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 5ac6a52..81e61aa 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -270,6 +270,7 @@ export const StreamsScreen = () => { loadEpisodeStreams, setSelectedEpisode, groupedEpisodes, + imdbId, } = useMetadata({ id, type }); // Create styles using current theme colors @@ -526,7 +527,8 @@ export const StreamsScreen = () => { streamProvider: stream.name, id, type, - episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined + episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, + imdbId: imdbId || undefined, }); } catch (error) { logger.error('[StreamsScreen] Error locking orientation before navigation:', error); @@ -542,10 +544,11 @@ export const StreamsScreen = () => { streamProvider: stream.name, id, type, - episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined + episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, + imdbId: imdbId || undefined, }); } - }, [metadata, type, currentEpisode, navigation, id, selectedEpisode]); + }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId]); // Update handleStreamPress const handleStreamPress = useCallback(async (stream: Stream) => { diff --git a/src/screens/VideoPlayer.tsx b/src/screens/VideoPlayer.tsx index 1f65cee..07fd996 100644 --- a/src/screens/VideoPlayer.tsx +++ b/src/screens/VideoPlayer.tsx @@ -1,13 +1,13 @@ import React, { useState, useRef, useEffect } from 'react'; -import { View, TouchableOpacity, StyleSheet, Text, Dimensions, Modal, Pressable, StatusBar, Platform, ScrollView, Animated, ActivityIndicator } from 'react-native'; +import { View, TouchableOpacity, StyleSheet, Text, Dimensions, Modal, Pressable, StatusBar, Platform, ScrollView, Animated, ActivityIndicator, Image } from 'react-native'; import { VLCPlayer } from 'react-native-vlc-media-player'; import { Ionicons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { useSharedValue, runOnJS, withTiming } from 'react-native-reanimated'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; -// Remove Gesture Handler imports -// import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'; +// Add Gesture Handler imports for pinch zoom +import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'; // Import for navigation bar hiding import { NativeModules } from 'react-native'; // Import immersive mode package @@ -22,7 +22,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; // Debug flag - set back to false to disable verbose logging // WARNING: Setting this to true currently causes infinite render loops // Use selective logging instead if debugging is needed -const DEBUG_MODE = false; +const DEBUG_MODE = true; // Safer debug function that won't cause render loops // Call this with any debugging info you need instead of using inline DEBUG_MODE checks @@ -69,6 +69,7 @@ interface VideoPlayerProps { id?: string; type?: string; episodeId?: string; + imdbId?: string; // Add IMDb ID for subtitle fetching } // Match the react-native-video AudioTrack type @@ -89,9 +90,9 @@ interface TextTrack { type?: string | null; // Adjusting type based on linter error } -// Define the possible resize modes - adjust to match VLCPlayer's PlayerResizeMode options -type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none'; -const resizeModes: ResizeModeType[] = ['contain', 'cover', 'fill']; +// Define the possible resize modes - force to stretch for absolute full screen +type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none' | 'stretch'; +const resizeModes: ResizeModeType[] = ['stretch']; // Force stretch mode for absolute full screen // Add language code to name mapping const languageMap: {[key: string]: string} = { @@ -187,6 +188,30 @@ const getTrackDisplayName = (track: { name?: string, id: number }): string => { return track.name; }; +// Add subtitle-related constants and types +const SUBTITLE_SIZE_KEY = '@subtitle_size_preference'; +const DEFAULT_SUBTITLE_SIZE = 16; + +interface SubtitleCue { + start: number; + end: number; + text: string; +} + +// Add interface for Wyzie subtitle API response +interface WyzieSubtitle { + id: string; + url: string; + flagUrl: string; + format: string; + encoding: string; + media: string; + display: string; + language: string; + isHearingImpaired: boolean; + source: string; +} + const VideoPlayer: React.FC = () => { const navigation = useNavigation(); const route = useRoute<RouteProp<RootStackParamList, 'Player'>>(); @@ -203,15 +228,20 @@ const VideoPlayer: React.FC = () => { streamProvider, id, type, - episodeId + episodeId, + imdbId } = route.params; // Use safer debug logging for props safeDebugLog("Component mounted with props", { uri, title, season, episode, episodeTitle, quality, year, - streamProvider, id, type, episodeId + streamProvider, id, type, episodeId, imdbId }); + // Get exact screen dimensions + const screenData = Dimensions.get('screen'); // Use 'screen' instead of 'window' to include system UI areas + const [screenDimensions, setScreenDimensions] = useState(screenData); + const [paused, setPaused] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); @@ -221,7 +251,7 @@ const VideoPlayer: React.FC = () => { const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null); const [textTracks, setTextTracks] = useState<TextTrack[]>([]); const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1); // Use -1 for "disabled" - const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain'); // State for resize mode + const [resizeMode, setResizeMode] = useState<ResizeModeType>('stretch'); // Force stretch mode for absolute full screen const [buffered, setBuffered] = useState(0); // Add buffered state const vlcRef = useRef<any>(null); const progress = useSharedValue(0); @@ -279,11 +309,114 @@ const VideoPlayer: React.FC = () => { // Add state for tracking if the video is loaded const [isVideoLoaded, setIsVideoLoaded] = useState(false); + // Add state for tracking video aspect ratio + const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null); + const [is16by9Content, setIs16by9Content] = useState(false); + const [customVideoStyles, setCustomVideoStyles] = useState<any>({}); + + // Add zoom state for pinch gesture + const [zoomScale, setZoomScale] = useState(1); + const [zoomTranslateX, setZoomTranslateX] = useState(0); + const [zoomTranslateY, setZoomTranslateY] = useState(0); + const [lastZoomScale, setLastZoomScale] = useState(1); + const [lastTranslateX, setLastTranslateX] = useState(0); + const [lastTranslateY, setLastTranslateY] = useState(0); + const pinchRef = useRef<PinchGestureHandler>(null); + + // Add subtitle-related state + const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]); + const [currentSubtitle, setCurrentSubtitle] = useState<string>(''); + const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE); + const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false); + const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false); + + // Add Wyzie subtitle states + const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]); + const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState<boolean>(false); + const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false); + + // Calculate custom video styles based on aspect ratios - simplified approach + const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { + // Always return full screen styles - let VLC resize modes handle the rest + return { + position: 'absolute', + top: 0, + left: 0, + width: screenWidth, + height: screenHeight, + backgroundColor: '#000', + }; + }; + + // Pinch gesture handler for zoom functionality - center zoom only, no panning + const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => { + const { scale } = event.nativeEvent; + + // Calculate new scale (limit between 1x and 1.1x) + const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1)); + + // Only apply scale, no translation - always zoom from center + setZoomScale(newScale); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`); + } + }; + + const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => { + if (event.nativeEvent.state === State.END) { + // Save the current scale as the new baseline, no translation + setLastZoomScale(zoomScale); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`); + } + } + }; + + // Reset zoom to appropriate level (1.1x for 16:9, 1x for others) + const resetZoom = () => { + const targetZoom = is16by9Content ? 1.1 : 1; + + setZoomScale(targetZoom); + setLastZoomScale(targetZoom); + // No translation needed for center zoom + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`); + } + }; + + // Recalculate video styles when screen dimensions change + useEffect(() => { + if (videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) { + const styles = calculateVideoStyles( + videoAspectRatio * 1000, // Reconstruct width from aspect ratio + 1000, // Use 1000 as base height + screenDimensions.width, + screenDimensions.height + ); + setCustomVideoStyles(styles); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Screen dimensions changed, recalculated styles:`, styles); + } + } + }, [screenDimensions, videoAspectRatio]); + // Lock screen to landscape when component mounts useEffect(() => { + // Update screen dimensions when they change (orientation changes) + const subscription = Dimensions.addEventListener('change', ({ screen }) => { + setScreenDimensions(screen); + }); + // Since orientation is now locked before navigation, we can start immediately const initializePlayer = () => { - // Enable immersive mode + // Force StatusBar to be completely hidden + StatusBar.setHidden(true, 'none'); + + // Enable immersive mode with more aggressive settings enableImmersiveMode(); // Start the opening animation immediately @@ -294,6 +427,7 @@ const VideoPlayer: React.FC = () => { // Restore screen orientation and disable immersive mode when component unmounts return () => { + subscription?.remove(); const unlockOrientation = async () => { await ScreenOrientation.unlockAsync(); }; @@ -319,7 +453,7 @@ const VideoPlayer: React.FC = () => { duration: 600, useNativeDriver: true, }), - // Scale up from 80% to 100% + // Scale up from 80% to 100% and ensure it stays at 100% Animated.timing(openingScaleAnim, { toValue: 1, duration: 700, @@ -332,7 +466,9 @@ const VideoPlayer: React.FC = () => { useNativeDriver: true, }), ]).start(() => { - // Animation is complete + // Animation is complete - ensure scale is exactly 1 + openingScaleAnim.setValue(1); + openingFadeAnim.setValue(1); setIsOpeningAnimationComplete(true); // Hide the background overlay completely after animation @@ -523,11 +659,65 @@ const VideoPlayer: React.FC = () => { } }; - // Enhanced onLoad handler to mark player as ready + // Enhanced onLoad handler to detect aspect ratio and mark player as ready const onLoad = (data: any) => { setDuration(data.duration / 1000); // VLC returns duration in milliseconds max.value = data.duration / 1000; + // Calculate and detect aspect ratio with custom styling + if (data.videoSize && data.videoSize.width && data.videoSize.height) { + const aspectRatio = data.videoSize.width / data.videoSize.height; + setVideoAspectRatio(aspectRatio); + + // Check if it's 16:9 content (1.777... ≈ 16/9) + const is16x9 = Math.abs(aspectRatio - (16/9)) < 0.1; + setIs16by9Content(is16x9); + + // Auto-zoom 16:9 content to 1.1x to fill more screen + if (is16x9) { + setZoomScale(1.1); + setLastZoomScale(1.1); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Auto-zoomed 16:9 content to 1.1x`); + } + } else { + // Reset zoom for non-16:9 content + setZoomScale(1); + setLastZoomScale(1); + } + + // Calculate custom video styles for precise control + const styles = calculateVideoStyles( + data.videoSize.width, + data.videoSize.height, + screenDimensions.width, + screenDimensions.height + ); + setCustomVideoStyles(styles); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Video aspect ratio: ${aspectRatio.toFixed(3)} (16:9: ${is16x9})`); + logger.log(`[VideoPlayer] Applied custom styles:`, styles); + } + } else { + // Fallback: assume 16:9 and apply default styles with auto-zoom + setIs16by9Content(true); + setZoomScale(1.1); + setLastZoomScale(1.1); + const defaultStyles = { + position: 'absolute', + top: 0, + left: 0, + width: screenDimensions.width, + height: screenDimensions.height, + }; + setCustomVideoStyles(defaultStyles); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Could not detect video size, using default 16:9 styles with 1.1x zoom`); + } + } + // Mark player as ready for seeking setIsPlayerReady(true); @@ -539,10 +729,22 @@ const VideoPlayer: React.FC = () => { if (DEBUG_MODE) { logger.log(`[VideoPlayer] Video loaded with duration: ${data.duration / 1000}`); + logger.log(`[VideoPlayer] Screen dimensions: ${screenDimensions.width}x${screenDimensions.height}`); + logger.log(`[VideoPlayer] VLC Player custom styles applied`); const methods = Object.keys(vlcRef.current || {}).filter( key => typeof vlcRef.current[key] === 'function' ); logger.log('[VideoPlayer] Available VLC methods:', methods); + + // Log track-related methods specifically + const trackMethods = methods.filter(method => + method.toLowerCase().includes('track') || + method.toLowerCase().includes('audio') || + method.toLowerCase().includes('subtitle') || + method.toLowerCase().includes('text') + ); + logger.log('[VideoPlayer] Track-related VLC methods:', trackMethods); + logger.log('[VideoPlayer] Available audio tracks:', audioTracksFromLoad); logger.log('[VideoPlayer] Available subtitle tracks:', textTracksFromLoad); } @@ -558,6 +760,16 @@ const VideoPlayer: React.FC = () => { } // Subtitles default to disabled (-1) + // Prefer external subtitles: Auto-search for external subtitles if IMDb ID is available + if (imdbId && !customSubtitles.length) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Auto-searching for external subtitles with IMDb ID: ${imdbId}`); + } + setTimeout(() => { + fetchAvailableSubtitles(imdbId); + }, 2000); // Delay to let video start playing first + } + // If we have an initial position to seek to, do it now if (initialPosition !== null && !isInitialSeekComplete) { if (DEBUG_MODE) { @@ -604,27 +816,46 @@ const VideoPlayer: React.FC = () => { } }; - // Toggle through aspect ratio modes + // Custom aspect ratio control - now toggles between 1x and 1.1x zoom const cycleAspectRatio = () => { - const currentIndex = resizeModes.indexOf(resizeMode); - const nextIndex = (currentIndex + 1) % resizeModes.length; + const newZoom = zoomScale === 1.1 ? 1 : 1.1; + + setZoomScale(newZoom); + setZoomTranslateX(0); + setZoomTranslateY(0); + setLastZoomScale(newZoom); + setLastTranslateX(0); + setLastTranslateY(0); + if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Changing aspect ratio from ${resizeMode} to ${resizeModes[nextIndex]}`); + logger.log(`[VideoPlayer] Toggled zoom to ${newZoom}x`); } - setResizeMode(resizeModes[nextIndex]); }; - // Function to enable immersive mode + // Enhanced immersive mode function const enableImmersiveMode = () => { - StatusBar.setHidden(true); + // Force hide status bar immediately without animation + StatusBar.setHidden(true, 'none'); if (Platform.OS === 'android') { - // Full immersive mode - hides both status and navigation bars - // Use setBarMode with 'FullSticky' mode to hide all bars with sticky behavior - RNImmersiveMode.setBarMode('FullSticky'); - - // Alternative: if you want to use fullLayout method (which is in the TypeScript definition) - RNImmersiveMode.fullLayout(true); + // Use multiple methods to ensure complete immersion + try { + // Method 1: RNImmersiveMode + RNImmersiveMode.setBarMode('FullSticky'); + RNImmersiveMode.fullLayout(true); + + // Method 2: Additional native module call if available + if (NativeModules.StatusBarManager) { + NativeModules.StatusBarManager.setHidden(true); + } + } catch (error) { + console.log('Immersive mode error:', error); + } + } + + // For iOS, ensure status bar is hidden + if (Platform.OS === 'ios') { + StatusBar.setHidden(true, 'none'); } }; @@ -816,25 +1047,204 @@ const VideoPlayer: React.FC = () => { // Function to select audio track in VLC const selectAudioTrack = (trackId: number) => { setSelectedAudioTrack(trackId); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Selected audio track ID: ${trackId}`); + } }; // Function to select subtitle track in VLC const selectTextTrack = (trackId: number) => { - setSelectedTextTrack(trackId); + if (trackId === -999) { // Special ID for custom subtitles + setUseCustomSubtitles(true); + setSelectedTextTrack(-1); // Disable VLC subtitles + } else { + setUseCustomSubtitles(false); + setSelectedTextTrack(trackId); + } + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Selected subtitle track ID: ${trackId}, custom: ${trackId === -999}`); + } }; // Update subtitle modal to use VLC subtitle tracks const renderSubtitleModal = () => { if (!showSubtitleModal) return null; + return ( + <View style={styles.fullscreenOverlay}> + <View style={styles.modernModalContainer}> + <View style={styles.modernModalHeader}> + <Text style={styles.modernModalTitle}>Subtitle Settings</Text> + <TouchableOpacity + style={styles.modernCloseButton} + onPress={() => setShowSubtitleModal(false)} + > + <Ionicons name="close" size={24} color="white" /> + </TouchableOpacity> + </View> + + <ScrollView style={styles.modernTrackListScrollContainer} showsVerticalScrollIndicator={false}> + <View style={styles.modernTrackListContainer}> + + {/* External Subtitles Section - Priority */} + <View style={styles.sectionContainer}> + <Text style={styles.sectionTitle}>External Subtitles</Text> + <Text style={styles.sectionDescription}>High quality subtitles with size control</Text> + + {/* Custom subtitles option - show if loaded */} + {customSubtitles.length > 0 ? ( + <TouchableOpacity + style={[styles.modernTrackItem, useCustomSubtitles && styles.modernSelectedTrackItem]} + onPress={() => { + selectTextTrack(-999); + setShowSubtitleModal(false); + }} + > + <View style={styles.trackIconContainer}> + <Ionicons name="document-text" size={20} color="#4CAF50" /> + </View> + <View style={styles.modernTrackInfoContainer}> + <Text style={styles.modernTrackPrimaryText}>Custom Subtitles</Text> + <Text style={styles.modernTrackSecondaryText}> + {customSubtitles.length} cues • Size adjustable + </Text> + </View> + {useCustomSubtitles && ( + <View style={styles.modernSelectedIndicator}> + <Ionicons name="checkmark-circle" size={24} color="#4CAF50" /> + </View> + )} + </TouchableOpacity> + ) : null} + + {/* Search for external subtitles */} + <TouchableOpacity + style={styles.searchSubtitlesButton} + onPress={() => { + setShowSubtitleModal(false); + fetchAvailableSubtitles(); + }} + disabled={isLoadingSubtitleList} + > + <View style={styles.searchButtonContent}> + {isLoadingSubtitleList ? ( + <ActivityIndicator size="small" color="#2196F3" /> + ) : ( + <Ionicons name="search" size={20} color="#2196F3" /> + )} + <Text style={styles.searchSubtitlesText}> + {isLoadingSubtitleList ? 'Searching...' : 'Search Online Subtitles'} + </Text> + </View> + </TouchableOpacity> + </View> + + {/* Subtitle Size Controls - Only for custom subtitles */} + {useCustomSubtitles && ( + <View style={styles.sectionContainer}> + <Text style={styles.sectionTitle}>Size Control</Text> + <View style={styles.modernSubtitleSizeContainer}> + <TouchableOpacity + style={styles.modernSizeButton} + onPress={decreaseSubtitleSize} + > + <Ionicons name="remove" size={20} color="white" /> + </TouchableOpacity> + <View style={styles.sizeDisplayContainer}> + <Text style={styles.modernSubtitleSizeText}>{subtitleSize}px</Text> + <Text style={styles.sizeLabel}>Font Size</Text> + </View> + <TouchableOpacity + style={styles.modernSizeButton} + onPress={increaseSubtitleSize} + > + <Ionicons name="add" size={20} color="white" /> + </TouchableOpacity> + </View> + </View> + )} + + {/* Built-in Subtitles Section */} + <View style={styles.sectionContainer}> + <Text style={styles.sectionTitle}>Built-in Subtitles</Text> + <Text style={styles.sectionDescription}>System default sizing • No customization</Text> + + {/* Off option */} + <TouchableOpacity + style={[styles.modernTrackItem, (selectedTextTrack === -1 && !useCustomSubtitles) && styles.modernSelectedTrackItem]} + onPress={() => { + selectTextTrack(-1); + setShowSubtitleModal(false); + }} + > + <View style={styles.trackIconContainer}> + <Ionicons name="close-circle" size={20} color="#9E9E9E" /> + </View> + <View style={styles.modernTrackInfoContainer}> + <Text style={styles.modernTrackPrimaryText}>Disabled</Text> + <Text style={styles.modernTrackSecondaryText}>No subtitles</Text> + </View> + {(selectedTextTrack === -1 && !useCustomSubtitles) && ( + <View style={styles.modernSelectedIndicator}> + <Ionicons name="checkmark-circle" size={24} color="#9E9E9E" /> + </View> + )} + </TouchableOpacity> + + {/* Available built-in subtitle tracks */} + {vlcTextTracks.length > 0 ? vlcTextTracks.map(track => ( + <TouchableOpacity + key={track.id} + style={[styles.modernTrackItem, (selectedTextTrack === track.id && !useCustomSubtitles) && styles.modernSelectedTrackItem]} + onPress={() => { + selectTextTrack(track.id); + setShowSubtitleModal(false); + }} + > + <View style={styles.trackIconContainer}> + <Ionicons name="text" size={20} color="#FF9800" /> + </View> + <View style={styles.modernTrackInfoContainer}> + <Text style={styles.modernTrackPrimaryText}> + {getTrackDisplayName(track)} + </Text> + <Text style={styles.modernTrackSecondaryText}> + Built-in track • System font size + </Text> + </View> + {(selectedTextTrack === track.id && !useCustomSubtitles) && ( + <View style={styles.modernSelectedIndicator}> + <Ionicons name="checkmark-circle" size={24} color="#FF9800" /> + </View> + )} + </TouchableOpacity> + )) : ( + <View style={styles.modernEmptyStateContainer}> + <Ionicons name="information-circle-outline" size={24} color="#666" /> + <Text style={styles.modernEmptyStateText}>No built-in subtitles available</Text> + </View> + )} + </View> + </View> + </ScrollView> + </View> + </View> + ); + }; + + // Render subtitle language selection modal + const renderSubtitleLanguageModal = () => { + if (!showSubtitleLanguageModal) return null; + return ( <View style={styles.fullscreenOverlay}> <View style={styles.enhancedModalContainer}> <View style={styles.enhancedModalHeader}> - <Text style={styles.enhancedModalTitle}>Subtitles</Text> + <Text style={styles.enhancedModalTitle}>Select Language</Text> <TouchableOpacity style={styles.enhancedCloseButton} - onPress={() => setShowSubtitleModal(false)} + onPress={() => setShowSubtitleLanguageModal(false)} > <Ionicons name="close" size={24} color="white" /> </TouchableOpacity> @@ -842,52 +1252,38 @@ const VideoPlayer: React.FC = () => { <ScrollView style={styles.trackListScrollContainer}> <View style={styles.trackListContainer}> - {/* Off option with improved design */} - <TouchableOpacity - style={styles.enhancedTrackItem} - onPress={() => { - selectTextTrack(-1); // -1 typically disables subtitles in VLC - setShowSubtitleModal(false); - }} - > - <View style={styles.trackInfoContainer}> - <Text style={styles.trackPrimaryText}>Off</Text> - </View> - {selectedTextTrack === -1 && ( - <View style={styles.selectedIndicatorContainer}> - <Ionicons name="checkmark" size={22} color="#E50914" /> - </View> - )} - </TouchableOpacity> - - {/* Available subtitle tracks with improved design */} - {vlcTextTracks.length > 0 ? vlcTextTracks.map(track => ( + {availableSubtitles.length > 0 ? availableSubtitles.map(subtitle => ( <TouchableOpacity - key={track.id} + key={subtitle.id} style={styles.enhancedTrackItem} - onPress={() => { - selectTextTrack(track.id); - setShowSubtitleModal(false); - }} + onPress={() => loadWyzieSubtitle(subtitle)} + disabled={isLoadingSubtitles} > - <View style={styles.trackInfoContainer}> - <Text style={styles.trackPrimaryText}> - {getTrackDisplayName(track)} - </Text> - {(track.name && track.language) && ( - <Text style={styles.trackSecondaryText}>{track.name}</Text> - )} - </View> - {selectedTextTrack === track.id && ( - <View style={styles.selectedIndicatorContainer}> - <Ionicons name="checkmark" size={22} color="#E50914" /> + <View style={styles.subtitleLanguageItem}> + <Image + source={{ uri: subtitle.flagUrl }} + style={styles.flagIcon} + resizeMode="cover" + /> + <View style={styles.trackInfoContainer}> + <Text style={styles.trackPrimaryText}> + {formatLanguage(subtitle.language)} + </Text> + <Text style={styles.trackSecondaryText}> + {subtitle.display} + </Text> </View> + </View> + {isLoadingSubtitles && ( + <ActivityIndicator size="small" color="#E50914" /> )} </TouchableOpacity> )) : ( <View style={styles.emptyStateContainer}> <Ionicons name="alert-circle-outline" size={40} color="#888" /> - <Text style={styles.emptyStateText}>No subtitle tracks available</Text> + <Text style={styles.emptyStateText}> + No subtitles found for this content + </Text> </View> )} </View> @@ -1085,8 +1481,330 @@ const VideoPlayer: React.FC = () => { }); }; + // Add subtitle size management functions + const loadSubtitleSize = async () => { + try { + const savedSize = await AsyncStorage.getItem(SUBTITLE_SIZE_KEY); + if (savedSize) { + setSubtitleSize(parseInt(savedSize, 10)); + } + } catch (error) { + logger.error('[VideoPlayer] Error loading subtitle size:', error); + } + }; + + const saveSubtitleSize = async (size: number) => { + try { + await AsyncStorage.setItem(SUBTITLE_SIZE_KEY, size.toString()); + setSubtitleSize(size); + } catch (error) { + logger.error('[VideoPlayer] Error saving subtitle size:', error); + } + }; + + // Enhanced SRT parser function - more robust + const parseSRT = (srtContent: string): SubtitleCue[] => { + const cues: SubtitleCue[] = []; + + if (!srtContent || srtContent.trim().length === 0) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Empty content provided`); + } + return cues; + } + + // Normalize line endings and clean up the content + const normalizedContent = srtContent + .replace(/\r\n/g, '\n') // Convert Windows line endings + .replace(/\r/g, '\n') // Convert Mac line endings + .trim(); + + // Split by double newlines, but also handle cases with multiple empty lines + const blocks = normalizedContent.split(/\n\s*\n/).filter(block => block.trim().length > 0); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Found ${blocks.length} blocks after normalization`); + logger.log(`[VideoPlayer] SRT Parser: First few characters: "${normalizedContent.substring(0, 300)}"`); + } + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i].trim(); + const lines = block.split('\n').map(line => line.trim()).filter(line => line.length > 0); + + if (lines.length >= 3) { + // Find the timestamp line (could be line 1 or 2, depending on numbering) + let timeLineIndex = -1; + let timeMatch = null; + + for (let j = 0; j < Math.min(3, lines.length); j++) { + // More flexible time pattern matching + timeMatch = lines[j].match(/(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})/); + if (timeMatch) { + timeLineIndex = j; + break; + } + } + + if (timeMatch && timeLineIndex !== -1) { + try { + const startTime = + parseInt(timeMatch[1]) * 3600 + + parseInt(timeMatch[2]) * 60 + + parseInt(timeMatch[3]) + + parseInt(timeMatch[4]) / 1000; + + const endTime = + parseInt(timeMatch[5]) * 3600 + + parseInt(timeMatch[6]) * 60 + + parseInt(timeMatch[7]) + + parseInt(timeMatch[8]) / 1000; + + // Get text lines (everything after the timestamp line) + const textLines = lines.slice(timeLineIndex + 1); + if (textLines.length > 0) { + const text = textLines + .join('\n') + .replace(/<[^>]*>/g, '') // Remove HTML tags + .replace(/\{[^}]*\}/g, '') // Remove subtitle formatting tags like {italic} + .replace(/\\N/g, '\n') // Handle \N newlines + .trim(); + + if (text.length > 0) { + cues.push({ + start: startTime, + end: endTime, + text: text + }); + + if (DEBUG_MODE && (i < 5 || cues.length <= 10)) { + logger.log(`[VideoPlayer] SRT Parser: Cue ${cues.length}: ${startTime.toFixed(3)}s-${endTime.toFixed(3)}s: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); + } + } + } + } catch (error) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Error parsing times for block ${i + 1}: ${error}`); + } + } + } else if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: No valid timestamp found in block ${i + 1}. Lines: ${JSON.stringify(lines.slice(0, 3))}`); + } + } else if (DEBUG_MODE && block.length > 0) { + logger.log(`[VideoPlayer] SRT Parser: Block ${i + 1} has insufficient lines (${lines.length}): "${block.substring(0, 100)}"`); + } + } + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Successfully parsed ${cues.length} subtitle cues`); + if (cues.length > 0) { + logger.log(`[VideoPlayer] SRT Parser: Time range: ${cues[0].start.toFixed(1)}s to ${cues[cues.length-1].end.toFixed(1)}s`); + } + } + + return cues; + }; + + // Fetch available subtitles from Wyzie API + const fetchAvailableSubtitles = async (imdbIdParam?: string) => { + const targetImdbId = imdbIdParam || imdbId; + if (!targetImdbId) { + logger.error('[VideoPlayer] No IMDb ID available for subtitle search'); + return; + } + + setIsLoadingSubtitleList(true); + try { + // Build search URL with season and episode parameters for TV shows + let searchUrl = `https://sub.wyzie.ru/search?id=${targetImdbId}&encoding=utf-8&source=all`; + + // Add season and episode parameters if available (for TV shows) + if (season && episode) { + searchUrl += `&season=${season}&episode=${episode}`; + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Searching for subtitles with IMDb ID: ${targetImdbId}, Season: ${season}, Episode: ${episode}`); + } + } else { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Searching for subtitles with IMDb ID: ${targetImdbId} (movie or no season/episode info)`); + } + } + + const response = await fetch(searchUrl); + const subtitles: WyzieSubtitle[] = await response.json(); + + // Filter out duplicates and sort by language + const uniqueSubtitles = subtitles.reduce((acc, current) => { + const exists = acc.find(item => item.language === current.language); + if (!exists) { + acc.push(current); + } + return acc; + }, [] as WyzieSubtitle[]); + + // Sort alphabetically by display name + uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display)); + + setAvailableSubtitles(uniqueSubtitles); + setShowSubtitleLanguageModal(true); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Found ${uniqueSubtitles.length} unique subtitle languages for search`); + } + } catch (error) { + logger.error('[VideoPlayer] Error fetching subtitles from Wyzie API:', error); + } finally { + setIsLoadingSubtitleList(false); + } + }; + + // Load subtitle from selected Wyzie entry + const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => { + setShowSubtitleLanguageModal(false); + setIsLoadingSubtitles(true); + + try { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Loading subtitle: ${subtitle.display} from ${subtitle.url}`); + } + + const response = await fetch(subtitle.url); + const srtContent = await response.text(); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Downloaded subtitle content length: ${srtContent.length} characters`); + logger.log(`[VideoPlayer] First 200 characters of subtitle: ${srtContent.substring(0, 200)}`); + } + + const parsedCues = parseSRT(srtContent); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Parsed ${parsedCues.length} subtitle cues`); + if (parsedCues.length > 0) { + logger.log(`[VideoPlayer] First cue: ${parsedCues[0].start}s-${parsedCues[0].end}s: "${parsedCues[0].text}"`); + logger.log(`[VideoPlayer] Last cue: ${parsedCues[parsedCues.length-1].start}s-${parsedCues[parsedCues.length-1].end}s: "${parsedCues[parsedCues.length-1].text}"`); + } + } + + setCustomSubtitles(parsedCues); + setUseCustomSubtitles(true); + + // Disable VLC's built-in subtitles when using custom ones + setSelectedTextTrack(-1); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Successfully loaded subtitle: useCustomSubtitles=true, customSubtitles.length=${parsedCues.length}`); + } + } catch (error) { + logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error); + } finally { + setIsLoadingSubtitles(false); + } + }; + + // Load external subtitle file (keep for backwards compatibility) + const loadExternalSubtitles = async (subtitleUrl: string) => { + if (!subtitleUrl) return; + + setIsLoadingSubtitles(true); + try { + const response = await fetch(subtitleUrl); + const srtContent = await response.text(); + const parsedCues = parseSRT(srtContent); + setCustomSubtitles(parsedCues); + setUseCustomSubtitles(true); + + // Disable VLC's built-in subtitles when using custom ones + setSelectedTextTrack(-1); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Loaded ${parsedCues.length} subtitle cues from external file`); + } + } catch (error) { + logger.error('[VideoPlayer] Error loading external subtitles:', error); + } finally { + setIsLoadingSubtitles(false); + } + }; + + // Update current subtitle based on playback time + useEffect(() => { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Subtitle useEffect - useCustomSubtitles: ${useCustomSubtitles}, customSubtitles.length: ${customSubtitles.length}, currentTime: ${currentTime.toFixed(3)}`); + + // Show detailed info about subtitle cues for debugging + if (useCustomSubtitles && customSubtitles.length > 0 && customSubtitles.length <= 5) { + logger.log(`[VideoPlayer] All ${customSubtitles.length} subtitle cues:`); + customSubtitles.forEach((cue, index) => { + const isActive = currentTime >= cue.start && currentTime <= cue.end; + logger.log(`[VideoPlayer] Cue ${index + 1}: ${cue.start.toFixed(3)}s-${cue.end.toFixed(3)}s ${isActive ? '(ACTIVE)' : ''}: "${cue.text.substring(0, 50)}${cue.text.length > 50 ? '...' : ''}"`); + }); + } else if (useCustomSubtitles && customSubtitles.length > 5) { + // For larger subtitle files, just show nearby cues + const nearbyCues = customSubtitles.filter(cue => + Math.abs(cue.start - currentTime) <= 10 || Math.abs(cue.end - currentTime) <= 10 + ); + if (nearbyCues.length > 0) { + logger.log(`[VideoPlayer] Nearby subtitle cues (within 10s):`); + nearbyCues.slice(0, 3).forEach((cue, index) => { + const isActive = currentTime >= cue.start && currentTime <= cue.end; + logger.log(`[VideoPlayer] Nearby cue: ${cue.start.toFixed(3)}s-${cue.end.toFixed(3)}s ${isActive ? '(ACTIVE)' : ''}: "${cue.text.substring(0, 50)}${cue.text.length > 50 ? '...' : ''}"`); + }); + } + } + } + + if (!useCustomSubtitles || customSubtitles.length === 0) { + if (currentSubtitle !== '') { + setCurrentSubtitle(''); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Cleared subtitle - useCustomSubtitles: ${useCustomSubtitles}, customSubtitles.length: ${customSubtitles.length}`); + } + } + return; + } + + const currentCue = customSubtitles.find(cue => + currentTime >= cue.start && currentTime <= cue.end + ); + + const newSubtitle = currentCue ? currentCue.text : ''; + + if (DEBUG_MODE && newSubtitle !== currentSubtitle) { + logger.log(`[VideoPlayer] Subtitle changed from "${currentSubtitle}" to "${newSubtitle}" at time ${currentTime.toFixed(3)}`); + if (currentCue) { + logger.log(`[VideoPlayer] Current cue: ${currentCue.start.toFixed(3)}s - ${currentCue.end.toFixed(3)}s: "${currentCue.text}"`); + } + } + + setCurrentSubtitle(newSubtitle); + }, [currentTime, customSubtitles, useCustomSubtitles]); + + // Load subtitle size preference on mount + useEffect(() => { + loadSubtitleSize(); + }, []); + + // Add subtitle size adjustment functions + const increaseSubtitleSize = () => { + const newSize = Math.min(subtitleSize + 2, 32); + saveSubtitleSize(newSize); + }; + + const decreaseSubtitleSize = () => { + const newSize = Math.max(subtitleSize - 2, 8); + saveSubtitleSize(newSize); + }; + + + return ( - <View style={styles.container}> + <View style={[styles.container, { + width: screenDimensions.width, + height: screenDimensions.height, + position: 'absolute', + top: 0, + left: 0, + }]}> {/* Opening Animation Overlay - covers the entire screen during transition */} <Animated.View style={[ @@ -1094,6 +1812,8 @@ const VideoPlayer: React.FC = () => { { opacity: backgroundFadeAnim, zIndex: isOpeningAnimationComplete ? -1 : 3000, + width: screenDimensions.width, + height: screenDimensions.height, } ]} pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'} @@ -1104,38 +1824,86 @@ const VideoPlayer: React.FC = () => { </View> </Animated.View> - {/* Animated Video Player Container */} + {/* Animated Video Player Container - ensure no transform issues */} <Animated.View style={[ styles.videoPlayerContainer, { opacity: openingFadeAnim, - transform: [{ scale: openingScaleAnim }], + transform: isOpeningAnimationComplete ? [] : [{ scale: openingScaleAnim }], + width: screenDimensions.width, + height: screenDimensions.height, } ]} > <TouchableOpacity - style={styles.videoContainer} + style={[styles.videoContainer, { + width: screenDimensions.width, + height: screenDimensions.height, + }]} onPress={toggleControls} activeOpacity={1} > - <VLCPlayer - ref={vlcRef} - source={{ - uri: uri, - }} - style={styles.video} - paused={paused || showResumeOverlay} - resizeMode={resizeMode as any} - onLoad={onLoad} - onProgress={handleProgress} - rate={playbackSpeed} - onError={handleError} - onEnd={onEnd} - audioTrack={selectedAudioTrack ?? undefined} - textTrack={selectedTextTrack} - autoAspectRatio={true} - /> + <PinchGestureHandler + ref={pinchRef} + onGestureEvent={onPinchGestureEvent} + onHandlerStateChange={onPinchHandlerStateChange} + > + <View style={{ + position: 'absolute', + top: 0, + left: 0, + width: screenDimensions.width, + height: screenDimensions.height, + backgroundColor: '#000', + }}> + <TouchableOpacity + style={{ flex: 1 }} + activeOpacity={1} + onPress={toggleControls} + onLongPress={resetZoom} + delayLongPress={300} + > + <VLCPlayer + ref={vlcRef} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: screenDimensions.width, + height: screenDimensions.height, + backgroundColor: '#000', + transform: [ + { scale: zoomScale }, + ], + }} + source={{ + uri: uri, + initOptions: [ + '--rtsp-tcp', + '--network-caching=150', + '--rtsp-caching=150', + '--no-audio-time-stretch', + '--clock-jitter=0', + '--clock-synchro=0', + '--drop-late-frames', + '--skip-frames', + ], + }} + paused={paused} + autoplay={true} + autoAspectRatio={false} + resizeMode={'stretch' as any} + audioTrack={selectedAudioTrack || undefined} + textTrack={selectedTextTrack === -1 ? undefined : selectedTextTrack} + onLoad={onLoad} + onProgress={handleProgress} + onEnd={onEnd} + onError={handleError} + /> + </TouchableOpacity> + </View> + </PinchGestureHandler> {/* Progress bar with enhanced touch handling */} <Animated.View style={[styles.sliderContainer, { opacity: fadeAnim }]}> @@ -1238,11 +2006,11 @@ const VideoPlayer: React.FC = () => { <Text style={styles.bottomButtonText}>Speed ({playbackSpeed}x)</Text> </TouchableOpacity> - {/* Aspect Ratio Button - Added */} + {/* Fill/Cover Button - Updated to show fill/cover modes */} <TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}> <Ionicons name="resize" size={20} color="white" /> - <Text style={styles.bottomButtonText}> - Aspect ({resizeMode}) + <Text style={[styles.bottomButtonText, { fontSize: 14, textAlign: 'center' }]}> + {zoomScale === 1.1 ? 'Fill' : 'Cover'} </Text> </TouchableOpacity> @@ -1258,15 +2026,16 @@ const VideoPlayer: React.FC = () => { </Text> </TouchableOpacity> - {/* Subtitle Button - Updated to use vlcTextTracks */} + {/* Subtitle Button - Always available for external subtitle search */} <TouchableOpacity style={styles.bottomButton} onPress={() => setShowSubtitleModal(true)} - disabled={vlcTextTracks.length === 0} > - <Ionicons name="text" size={20} color={vlcTextTracks.length === 0 ? 'grey' : 'white'} /> - <Text style={[styles.bottomButtonText, vlcTextTracks.length === 0 && {color: 'grey'}]}> - {(selectedTextTrack === -1) + <Ionicons name="text" size={20} color="white" /> + <Text style={styles.bottomButtonText}> + {useCustomSubtitles + ? 'Subtitles: Custom' + : (selectedTextTrack === -1) ? 'Subtitles' : `Subtitles: ${getTrackDisplayName(vlcTextTracks.find(t => t.id === selectedTextTrack) || {id: -1, name: 'On'})}`} </Text> @@ -1276,6 +2045,28 @@ const VideoPlayer: React.FC = () => { </LinearGradient> </Animated.View> + {/* Custom Subtitle Overlay - Enhanced visibility and debugging */} + {(useCustomSubtitles && currentSubtitle) && ( + <View style={styles.customSubtitleContainer} pointerEvents="none"> + <View style={styles.customSubtitleWrapper}> + <Text style={[styles.customSubtitleText, { fontSize: subtitleSize }]}> + {currentSubtitle} + </Text> + </View> + </View> + )} + + {/* Debug subtitle info when controls are visible */} + {DEBUG_MODE && showControls && ( + <View style={styles.debugSubtitleInfo} pointerEvents="none"> + <Text style={styles.debugText}> + Custom Subs: {useCustomSubtitles ? 'ON' : 'OFF'} | + Cues: {customSubtitles.length} | + Current: "{currentSubtitle}" + </Text> + </View> + )} + {/* Resume Overlay */} {showResumeOverlay && resumePosition !== null && ( <View style={styles.resumeOverlay}> @@ -1357,44 +2148,64 @@ const VideoPlayer: React.FC = () => { {/* Use the new modal rendering functions */} {renderAudioModal()} {renderSubtitleModal()} + {renderSubtitleLanguageModal()} </View> ); }; const styles = StyleSheet.create({ container: { - flex: 1, backgroundColor: '#000', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, }, videoContainer: { - flex: 1, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, }, video: { - flex: 1, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, }, controlsContainer: { ...StyleSheet.absoluteFillObject, justifyContent: 'space-between', + margin: 0, + padding: 0, }, topGradient: { - paddingTop: Platform.OS === 'ios' ? 50 : 20, // Adjust top padding for safe area + paddingTop: 20, paddingHorizontal: 20, - paddingBottom: 10, // Add some padding at the bottom of the gradient + paddingBottom: 10, }, bottomGradient: { - paddingBottom: Platform.OS === 'ios' ? 30 : 20, + paddingBottom: 20, paddingHorizontal: 20, paddingTop: 20, }, header: { flexDirection: 'row', justifyContent: 'space-between', - alignItems: 'flex-start', // Align items to the top + alignItems: 'flex-start', }, - // Styles for the title section and metadata titleSection: { - flex: 1, // Allow title section to take available space - marginRight: 10, // Add margin to avoid overlap with close button + flex: 1, + marginRight: 10, }, title: { color: 'white', @@ -1410,7 +2221,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', marginTop: 5, - flexWrap: 'wrap', // Allow items to wrap if needed + flexWrap: 'wrap', }, metadataText: { color: 'rgba(255, 255, 255, 0.7)', @@ -1432,7 +2243,7 @@ const styles = StyleSheet.create({ providerText: { color: 'rgba(255, 255, 255, 0.7)', fontSize: 12, - fontStyle: 'italic', // Italicize provider text + fontStyle: 'italic', }, closeButton: { padding: 8, @@ -1446,7 +2257,7 @@ const styles = StyleSheet.create({ left: 0, right: 0, top: '50%', - transform: [{ translateY: -30 }], // Half the height of play button to center it perfectly + transform: [{ translateY: -30 }], zIndex: 1000, }, playButton: { @@ -1468,14 +2279,14 @@ const styles = StyleSheet.create({ }, sliderContainer: { position: 'absolute', - bottom: 55, // Moved closer to bottom buttons + bottom: 55, left: 0, right: 0, paddingHorizontal: 20, zIndex: 1000, }, progressTouchArea: { - height: 30, // Increase touch target height for easier interaction + height: 30, justifyContent: 'center', width: '100%', }, @@ -1507,8 +2318,8 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', width: '100%', paddingHorizontal: 4, - marginTop: 4, // Increased space between progress bar and time - marginBottom: 8, // Added space between time and buttons + marginTop: 4, + marginBottom: 8, }, duration: { color: 'white', @@ -1585,7 +2396,6 @@ const styles = StyleSheet.create({ textAlign: 'center', padding: 20, }, - // New simplified modal styles fullscreenOverlay: { position: 'absolute', top: 0, @@ -1674,7 +2484,6 @@ const styles = StyleSheet.create({ marginTop: 8, textAlign: 'center', }, - // Resume overlay styles resumeOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0, 0, 0, 0.7)', @@ -1812,6 +2621,8 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', zIndex: 2000, + margin: 0, + padding: 0, }, openingContent: { padding: 20, @@ -1827,8 +2638,288 @@ const styles = StyleSheet.create({ marginTop: 20, }, videoPlayerContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, + }, + subtitleSizeContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 10, + paddingVertical: 12, + marginBottom: 8, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 6, + }, + subtitleSizeLabel: { + color: 'white', + fontSize: 14, + fontWeight: 'bold', + }, + subtitleSizeControls: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + sizeButton: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + subtitleSizeText: { + color: 'white', + fontSize: 14, + fontWeight: 'bold', + minWidth: 40, + textAlign: 'center', + }, + customSubtitleContainer: { + position: 'absolute', + bottom: 120, // Position above controls and progress bar + left: 20, + right: 20, + alignItems: 'center', + zIndex: 1500, // Higher z-index to appear above other elements + }, + customSubtitleText: { + color: 'white', + textAlign: 'center', + textShadowColor: 'rgba(0, 0, 0, 0.9)', + textShadowOffset: { width: 2, height: 2 }, + textShadowRadius: 4, + lineHeight: undefined, // Let React Native calculate line height + fontWeight: '500', + }, + loadSubtitlesButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + marginTop: 8, + borderRadius: 6, + backgroundColor: 'rgba(229, 9, 20, 0.2)', + borderWidth: 1, + borderColor: '#E50914', + }, + loadSubtitlesText: { + color: '#E50914', + fontSize: 14, + fontWeight: 'bold', + marginLeft: 8, + }, + disabledContainer: { + opacity: 0.5, + }, + disabledText: { + color: '#666', + }, + disabledButton: { + backgroundColor: '#666', + }, + noteContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + noteText: { + color: '#aaa', + fontSize: 12, + marginLeft: 5, + }, + subtitleLanguageItem: { + flexDirection: 'row', + alignItems: 'center', flex: 1, }, + flagIcon: { + width: 24, + height: 18, + marginRight: 12, + borderRadius: 2, + }, + modernModalContainer: { + width: '90%', + maxWidth: 500, + backgroundColor: '#181818', + borderRadius: 10, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.4, + shadowRadius: 10, + elevation: 8, + }, + modernModalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#333', + }, + modernModalTitle: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + }, + modernCloseButton: { + padding: 4, + }, + modernTrackListScrollContainer: { + maxHeight: 350, + }, + modernTrackListContainer: { + padding: 6, + }, + sectionContainer: { + marginBottom: 20, + }, + sectionTitle: { + color: 'white', + fontSize: 16, + fontWeight: 'bold', + marginBottom: 8, + }, + sectionDescription: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginBottom: 12, + }, + trackIconContainer: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', + }, + modernTrackInfoContainer: { + flex: 1, + marginLeft: 10, + }, + modernTrackPrimaryText: { + color: 'white', + fontSize: 14, + fontWeight: '500', + }, + modernTrackSecondaryText: { + color: '#aaa', + fontSize: 11, + marginTop: 2, + }, + modernSelectedIndicator: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + modernEmptyStateContainer: { + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + modernEmptyStateText: { + color: '#888', + fontSize: 14, + marginTop: 8, + textAlign: 'center', + }, + searchSubtitlesButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + marginTop: 8, + borderRadius: 6, + backgroundColor: 'rgba(229, 9, 20, 0.2)', + borderWidth: 1, + borderColor: '#E50914', + }, + searchButtonContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + searchSubtitlesText: { + color: '#E50914', + fontSize: 14, + fontWeight: 'bold', + marginLeft: 8, + }, + modernSubtitleSizeContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + modernSizeButton: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + modernTrackItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 12, + marginVertical: 4, + borderRadius: 8, + backgroundColor: '#222', + }, + modernSelectedTrackItem: { + backgroundColor: 'rgba(76, 175, 80, 0.15)', + borderWidth: 1, + borderColor: 'rgba(76, 175, 80, 0.3)', + }, + sizeDisplayContainer: { + alignItems: 'center', + flex: 1, + marginHorizontal: 20, + }, + modernSubtitleSizeText: { + color: 'white', + fontSize: 16, + fontWeight: 'bold', + }, + sizeLabel: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginTop: 2, + }, + customSubtitleWrapper: { + backgroundColor: 'rgba(0, 0, 0, 0.7)', + padding: 10, + borderRadius: 5, + }, + debugSubtitleInfo: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + padding: 5, + borderRadius: 5, + margin: 10, + zIndex: 1000, + }, + debugText: { + color: 'white', + fontSize: 12, + }, }); export default VideoPlayer; \ No newline at end of file diff --git a/src/types/navigation.d.ts b/src/types/navigation.d.ts index 83775e9..c01523f 100644 --- a/src/types/navigation.d.ts +++ b/src/types/navigation.d.ts @@ -27,6 +27,7 @@ export type RootStackParamList = { url: string; lang: string; }>; + imdbId?: string; }; Catalog: { addonId?: string; -- 2.45.2 From c7110edcb85dd87d93a29717d0be797b8fa9a5f2 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Sun, 8 Jun 2025 18:48:05 +0530 Subject: [PATCH 16/88] Adjust custom subtitle container position in VideoPlayer component for improved visibility --- src/screens/VideoPlayer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/VideoPlayer.tsx b/src/screens/VideoPlayer.tsx index 07fd996..64ee75a 100644 --- a/src/screens/VideoPlayer.tsx +++ b/src/screens/VideoPlayer.tsx @@ -2683,7 +2683,7 @@ const styles = StyleSheet.create({ }, customSubtitleContainer: { position: 'absolute', - bottom: 120, // Position above controls and progress bar + bottom: 40, // Position above controls and progress bar left: 20, right: 20, alignItems: 'center', -- 2.45.2 From 076446aac620cede771728ad4d863f83ac1a0da2 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Sun, 8 Jun 2025 18:48:13 +0530 Subject: [PATCH 17/88] Enhance VideoPlayer component with improved progress bar touch handling and drag support. Refactor subtitle fetching to auto-select English subtitles when available, and update related logging for clarity. Remove deprecated debug information for a cleaner UI. --- src/screens/VideoPlayer.tsx | 170 +++++++++++++++++++----------------- 1 file changed, 92 insertions(+), 78 deletions(-) diff --git a/src/screens/VideoPlayer.tsx b/src/screens/VideoPlayer.tsx index 64ee75a..df7c968 100644 --- a/src/screens/VideoPlayer.tsx +++ b/src/screens/VideoPlayer.tsx @@ -636,8 +636,65 @@ const VideoPlayer: React.FC = () => { } }; - // Simplify handleProgress to always update state + // Enhanced progress bar touch handling with drag support + const handleProgressBarTouch = (event: any) => { + if (!duration || duration <= 0) return; + + const { locationX } = event.nativeEvent; + processProgressTouch(locationX); + }; + + const handleProgressBarDragStart = () => { + setIsDragging(true); + }; + + const handleProgressBarDragMove = (event: any) => { + if (!isDragging || !duration || duration <= 0) return; + + const { locationX } = event.nativeEvent; + processProgressTouch(locationX, true); // Pass true to indicate dragging + }; + + const handleProgressBarDragEnd = () => { + setIsDragging(false); + // Apply the final seek when drag ends + if (pendingSeekValue.current !== null) { + seekToTime(pendingSeekValue.current); + pendingSeekValue.current = null; + } + }; + + // Helper function to process touch position and seek + const processProgressTouch = (locationX: number, isDragging = false) => { + progressBarRef.current?.measure((x, y, width, height, pageX, pageY) => { + // Calculate percentage of touch position relative to progress bar width + const percentage = Math.max(0, Math.min(locationX / width, 1)); + // Calculate time to seek to + const seekTime = percentage * duration; + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Progress touch: ${seekTime.toFixed(1)}s (${(percentage * 100).toFixed(1)}%)`); + } + + // Update the visual progress immediately + progress.value = seekTime; + progressAnim.setValue(percentage); + + // If dragging, update currentTime for visual feedback but don't seek yet + if (isDragging) { + pendingSeekValue.current = seekTime; + setCurrentTime(seekTime); + } else { + // If it's a tap (not dragging), seek immediately + seekToTime(seekTime); + } + }); + }; + + // Update the handleProgress function to not update progress while dragging const handleProgress = (event: any) => { + if (isDragging) return; // Don't update progress while user is dragging + const currentTimeInSeconds = event.currentTime / 1000; // VLC gives time in milliseconds // Always update state - let VLC manage the timing @@ -760,13 +817,13 @@ const VideoPlayer: React.FC = () => { } // Subtitles default to disabled (-1) - // Prefer external subtitles: Auto-search for external subtitles if IMDb ID is available + // Auto-search for English subtitles if IMDb ID is available if (imdbId && !customSubtitles.length) { if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-searching for external subtitles with IMDb ID: ${imdbId}`); + logger.log(`[VideoPlayer] Auto-searching for English subtitles with IMDb ID: ${imdbId}`); } setTimeout(() => { - fetchAvailableSubtitles(imdbId); + fetchAvailableSubtitles(imdbId, true); // true for autoSelectEnglish }, 2000); // Delay to let video start playing first } @@ -1441,46 +1498,6 @@ const VideoPlayer: React.FC = () => { } }; - // Enhanced progress bar touch handling with drag support - const handleProgressBarTouch = (event: any) => { - if (!duration || duration <= 0) return; - - const { locationX } = event.nativeEvent; - processProgressTouch(locationX); - }; - - const handleProgressBarDragStart = () => { - setIsDragging(true); - }; - - const handleProgressBarDragMove = (event: any) => { - if (!isDragging || !duration || duration <= 0) return; - - const { locationX } = event.nativeEvent; - processProgressTouch(locationX); - }; - - const handleProgressBarDragEnd = () => { - setIsDragging(false); - }; - - // Helper function to process touch position and seek - const processProgressTouch = (locationX: number) => { - progressBarRef.current?.measure((x, y, width, height, pageX, pageY) => { - // Calculate percentage of touch position relative to progress bar width - const percentage = Math.max(0, Math.min(locationX / width, 1)); - // Calculate time to seek to - const seekTime = percentage * duration; - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Seeking to: ${seekTime}s (${percentage * 100}%)`); - } - - // Seek to the calculated time - seekToTime(seekTime); - }); - }; - // Add subtitle size management functions const loadSubtitleSize = async () => { try { @@ -1605,7 +1622,7 @@ const VideoPlayer: React.FC = () => { }; // Fetch available subtitles from Wyzie API - const fetchAvailableSubtitles = async (imdbIdParam?: string) => { + const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => { const targetImdbId = imdbIdParam || imdbId; if (!targetImdbId) { logger.error('[VideoPlayer] No IMDb ID available for subtitle search'); @@ -1645,10 +1662,33 @@ const VideoPlayer: React.FC = () => { uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display)); setAvailableSubtitles(uniqueSubtitles); - setShowSubtitleLanguageModal(true); + + if (autoSelectEnglish) { + // Try to find English subtitles + const englishSubtitle = uniqueSubtitles.find(sub => + sub.language.toLowerCase() === 'eng' || + sub.language.toLowerCase() === 'en' || + sub.display.toLowerCase().includes('english') + ); + + if (englishSubtitle) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Auto-selecting English subtitle: ${englishSubtitle.display}`); + } + loadWyzieSubtitle(englishSubtitle); + return; + } else if (DEBUG_MODE) { + logger.log(`[VideoPlayer] No English subtitles found for auto-selection`); + } + } + + // Only show the modal if not auto-selecting or if no English subtitles found + if (!autoSelectEnglish) { + setShowSubtitleLanguageModal(true); + } if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Found ${uniqueSubtitles.length} unique subtitle languages for search`); + logger.log(`[VideoPlayer] Found ${uniqueSubtitles.length} unique subtitle languages`); } } catch (error) { logger.error('[VideoPlayer] Error fetching subtitles from Wyzie API:', error); @@ -2056,17 +2096,6 @@ const VideoPlayer: React.FC = () => { </View> )} - {/* Debug subtitle info when controls are visible */} - {DEBUG_MODE && showControls && ( - <View style={styles.debugSubtitleInfo} pointerEvents="none"> - <Text style={styles.debugText}> - Custom Subs: {useCustomSubtitles ? 'ON' : 'OFF'} | - Cues: {customSubtitles.length} | - Current: "{currentSubtitle}" - </Text> - </View> - )} - {/* Resume Overlay */} {showResumeOverlay && resumePosition !== null && ( <View style={styles.resumeOverlay}> @@ -2689,6 +2718,11 @@ const styles = StyleSheet.create({ alignItems: 'center', zIndex: 1500, // Higher z-index to appear above other elements }, + customSubtitleWrapper: { + backgroundColor: 'rgba(0, 0, 0, 0.7)', + padding: 10, + borderRadius: 5, + }, customSubtitleText: { color: 'white', textAlign: 'center', @@ -2900,26 +2934,6 @@ const styles = StyleSheet.create({ fontSize: 12, marginTop: 2, }, - customSubtitleWrapper: { - backgroundColor: 'rgba(0, 0, 0, 0.7)', - padding: 10, - borderRadius: 5, - }, - debugSubtitleInfo: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - backgroundColor: 'rgba(0, 0, 0, 0.7)', - padding: 5, - borderRadius: 5, - margin: 10, - zIndex: 1000, - }, - debugText: { - color: 'white', - fontSize: 12, - }, }); export default VideoPlayer; \ No newline at end of file -- 2.45.2 From 19bb898b6bd260afac960100f67254d86e76bc42 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Sun, 8 Jun 2025 20:01:27 +0530 Subject: [PATCH 18/88] Enhance FeaturedContent component with improved animations and content transition handling. Introduce new animation states for poster scaling, overlay opacity, and content visibility during content changes. Refactor image loading logic to support smoother transitions and add a subtle content overlay for better readability. Update styles for improved layout and proportions. --- src/components/home/FeaturedContent.tsx | 362 +++++++++++++++--------- 1 file changed, 227 insertions(+), 135 deletions(-) diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index 2f88399..858d60b 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -64,9 +64,18 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat // Add a ref to track logo fetch in progress const logoFetchInProgress = useRef<boolean>(false); + // Enhanced poster transition animations + const posterScale = useSharedValue(1); + const posterTranslateY = useSharedValue(0); + const overlayOpacity = useSharedValue(0.15); + // Animation values const posterAnimatedStyle = useAnimatedStyle(() => ({ opacity: posterOpacity.value, + transform: [ + { scale: posterScale.value }, + { translateY: posterTranslateY.value } + ], })); const logoAnimatedStyle = useAnimatedStyle(() => ({ @@ -84,6 +93,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat opacity: buttonsOpacity.value, })); + const overlayAnimatedStyle = useAnimatedStyle(() => ({ + opacity: overlayOpacity.value, + })); + // Preload the image const preloadImage = async (url: string): Promise<boolean> => { if (!url) return false; @@ -255,41 +268,92 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const posterUrl = featuredContent.banner || featuredContent.poster; const contentId = featuredContent.id; + const isContentChange = contentId !== prevContentIdRef.current; - // Reset states for new content - if (contentId !== prevContentIdRef.current) { - posterOpacity.value = 0; + // Enhanced content change detection and animations + if (isContentChange) { + // Animate out current content + if (prevContentIdRef.current) { + posterOpacity.value = withTiming(0, { + duration: 300, + easing: Easing.out(Easing.cubic) + }); + posterScale.value = withTiming(0.95, { + duration: 300, + easing: Easing.out(Easing.cubic) + }); + overlayOpacity.value = withTiming(0.6, { + duration: 300, + easing: Easing.out(Easing.cubic) + }); + contentOpacity.value = withTiming(0.3, { + duration: 200, + easing: Easing.out(Easing.cubic) + }); + buttonsOpacity.value = withTiming(0.3, { + duration: 200, + easing: Easing.out(Easing.cubic) + }); + } else { + // Initial load - start from 0 + posterOpacity.value = 0; + posterScale.value = 1.1; + overlayOpacity.value = 0; + contentOpacity.value = 0; + buttonsOpacity.value = 0; + } logoOpacity.value = 0; } prevContentIdRef.current = contentId; - // Set poster URL immediately for instant display + // Set poster URL for immediate display if (posterUrl) setBannerUrl(posterUrl); - // Load images in background + // Load images with enhanced animations const loadImages = async () => { - // Load poster + // Small delay to allow fade out animation to complete + await new Promise(resolve => setTimeout(resolve, isContentChange && prevContentIdRef.current ? 300 : 0)); + + // Load poster with enhanced transition if (posterUrl) { const posterSuccess = await preloadImage(posterUrl); if (posterSuccess) { - posterOpacity.value = withTiming(1, { - duration: 600, - easing: Easing.bezier(0.25, 0.1, 0.25, 1) + // Animate in new poster with scale and fade + posterScale.value = withTiming(1, { + duration: 800, + easing: Easing.out(Easing.cubic) }); + posterOpacity.value = withTiming(1, { + duration: 700, + easing: Easing.out(Easing.cubic) + }); + overlayOpacity.value = withTiming(0.15, { + duration: 600, + easing: Easing.out(Easing.cubic) + }); + + // Animate content back in with delay + contentOpacity.value = withDelay(200, withTiming(1, { + duration: 600, + easing: Easing.out(Easing.cubic) + })); + buttonsOpacity.value = withDelay(400, withTiming(1, { + duration: 500, + easing: Easing.out(Easing.cubic) + })); } } - // Load logo if available + // Load logo if available with enhanced timing if (logoUrl) { const logoSuccess = await preloadImage(logoUrl); if (logoSuccess) { - logoOpacity.value = withDelay(300, withTiming(1, { - duration: 500, - easing: Easing.bezier(0.25, 0.1, 0.25, 1) + logoOpacity.value = withDelay(500, withTiming(1, { + duration: 600, + easing: Easing.out(Easing.cubic) })); } else { - // If prefetch fails, mark as error to show title text instead setLogoLoadError(true); console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${logoUrl}`); } @@ -304,131 +368,146 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } return ( - <TouchableOpacity - activeOpacity={0.9} - onPress={() => { - navigation.navigate('Metadata', { - id: featuredContent.id, - type: featuredContent.type - }); - }} - style={styles.featuredContainer as ViewStyle} + <Animated.View + entering={FadeIn.duration(800).easing(Easing.out(Easing.cubic))} > - <Animated.View style={[styles.imageContainer, posterAnimatedStyle]}> - <ImageBackground - source={{ uri: bannerUrl || featuredContent.poster }} - style={styles.featuredImage as ViewStyle} - resizeMode="cover" - > - <LinearGradient - colors={[ - 'transparent', - 'rgba(0,0,0,0.1)', - 'rgba(0,0,0,0.7)', - currentTheme.colors.darkBackground, - ]} - locations={[0, 0.3, 0.7, 1]} - style={styles.featuredGradient as ViewStyle} + <TouchableOpacity + activeOpacity={0.95} + onPress={() => { + navigation.navigate('Metadata', { + id: featuredContent.id, + type: featuredContent.type + }); + }} + style={styles.featuredContainer as ViewStyle} + > + <Animated.View style={[styles.imageContainer, posterAnimatedStyle]}> + <ImageBackground + source={{ uri: bannerUrl || featuredContent.poster }} + style={styles.featuredImage as ViewStyle} + resizeMode="cover" > - <Animated.View - style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]} + {/* Subtle content overlay for better readability */} + <Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} /> + + <LinearGradient + colors={[ + 'rgba(0,0,0,0.1)', + 'rgba(0,0,0,0.2)', + 'rgba(0,0,0,0.4)', + 'rgba(0,0,0,0.8)', + currentTheme.colors.darkBackground, + ]} + locations={[0, 0.2, 0.5, 0.8, 1]} + style={styles.featuredGradient as ViewStyle} > - {logoUrl && !logoLoadError ? ( - <Animated.View style={logoAnimatedStyle}> - <ExpoImage - source={{ uri: logoUrl }} - style={styles.featuredLogo as ImageStyle} - contentFit="contain" - cachePolicy="memory-disk" - transition={400} - onError={() => { - console.warn(`[FeaturedContent] Logo failed to load: ${logoUrl}`); - setLogoLoadError(true); - }} + <Animated.View + style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]} + > + {logoUrl && !logoLoadError ? ( + <Animated.View style={logoAnimatedStyle}> + <ExpoImage + source={{ uri: logoUrl }} + style={styles.featuredLogo as ImageStyle} + contentFit="contain" + cachePolicy="memory-disk" + transition={400} + onError={() => { + console.warn(`[FeaturedContent] Logo failed to load: ${logoUrl}`); + setLogoLoadError(true); + }} + /> + </Animated.View> + ) : ( + <Text style={[styles.featuredTitleText as TextStyle, { color: currentTheme.colors.highEmphasis }]}> + {featuredContent.name} + </Text> + )} + <View style={styles.genreContainer as ViewStyle}> + {featuredContent.genres?.slice(0, 3).map((genre, index, array) => ( + <React.Fragment key={index}> + <Text style={[styles.genreText as TextStyle, { color: currentTheme.colors.white }]}> + {genre} + </Text> + {index < array.length - 1 && ( + <Text style={[styles.genreDot as TextStyle, { color: currentTheme.colors.white }]}>•</Text> + )} + </React.Fragment> + ))} + </View> + </Animated.View> + + <Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}> + <TouchableOpacity + style={styles.myListButton as ViewStyle} + onPress={handleSaveToLibrary} + > + <MaterialIcons + name={isSaved ? "bookmark" : "bookmark-border"} + size={24} + color={currentTheme.colors.white} /> - </Animated.View> - ) : ( - <Text style={[styles.featuredTitleText as TextStyle, { color: currentTheme.colors.highEmphasis }]}> - {featuredContent.name} - </Text> - )} - <View style={styles.genreContainer as ViewStyle}> - {featuredContent.genres?.slice(0, 3).map((genre, index, array) => ( - <React.Fragment key={index}> - <Text style={[styles.genreText as TextStyle, { color: currentTheme.colors.white }]}> - {genre} - </Text> - {index < array.length - 1 && ( - <Text style={[styles.genreDot as TextStyle, { color: currentTheme.colors.white }]}>•</Text> - )} - </React.Fragment> - ))} - </View> - </Animated.View> + <Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}> + {isSaved ? "Saved" : "Save"} + </Text> + </TouchableOpacity> + + <TouchableOpacity + style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]} + onPress={() => { + if (featuredContent) { + navigation.navigate('Streams', { + id: featuredContent.id, + type: featuredContent.type + }); + } + }} + > + <MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} /> + <Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}> + Play + </Text> + </TouchableOpacity> - <Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}> - <TouchableOpacity - style={styles.myListButton as ViewStyle} - onPress={handleSaveToLibrary} - > - <MaterialIcons - name={isSaved ? "bookmark" : "bookmark-border"} - size={24} - color={currentTheme.colors.white} - /> - <Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}> - {isSaved ? "Saved" : "Save"} - </Text> - </TouchableOpacity> - - <TouchableOpacity - style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]} - onPress={() => { - if (featuredContent) { - navigation.navigate('Streams', { - id: featuredContent.id, - type: featuredContent.type - }); - } - }} - > - <MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} /> - <Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}> - Play - </Text> - </TouchableOpacity> - - <TouchableOpacity - style={styles.infoButton as ViewStyle} - onPress={() => { - if (featuredContent) { - navigation.navigate('Metadata', { - id: featuredContent.id, - type: featuredContent.type - }); - } - }} - > - <MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} /> - <Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}> - Info - </Text> - </TouchableOpacity> - </Animated.View> - </LinearGradient> - </ImageBackground> - </Animated.View> - </TouchableOpacity> + <TouchableOpacity + style={styles.infoButton as ViewStyle} + onPress={() => { + if (featuredContent) { + navigation.navigate('Metadata', { + id: featuredContent.id, + type: featuredContent.type + }); + } + }} + > + <MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} /> + <Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}> + Info + </Text> + </TouchableOpacity> + </Animated.View> + </LinearGradient> + </ImageBackground> + </Animated.View> + </TouchableOpacity> + </Animated.View> ); }; const styles = StyleSheet.create({ featuredContainer: { width: '100%', - height: height * 0.48, + height: height * 0.55, // Slightly taller for better proportions marginTop: 0, - marginBottom: 8, + marginBottom: 12, position: 'relative', + borderRadius: 12, + overflow: 'hidden', + elevation: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, }, imageContainer: { width: '100%', @@ -443,6 +522,7 @@ const styles = StyleSheet.create({ featuredImage: { width: '100%', height: '100%', + transform: [{ scale: 1.05 }], // Subtle zoom for depth }, backgroundFallback: { position: 'absolute', @@ -458,12 +538,14 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', justifyContent: 'space-between', + paddingTop: 20, }, featuredContentContainer: { flex: 1, justifyContent: 'flex-end', - paddingHorizontal: 16, - paddingBottom: 4, + paddingHorizontal: 20, + paddingBottom: 8, + paddingTop: 40, }, featuredLogo: { width: width * 0.7, @@ -502,19 +584,20 @@ const styles = StyleSheet.create({ }, featuredButtons: { flexDirection: 'row', - alignItems: 'flex-end', + alignItems: 'center', justifyContent: 'space-evenly', width: '100%', - flex: 1, - maxHeight: 55, - paddingTop: 0, + minHeight: 70, + paddingTop: 12, + paddingBottom: 20, + paddingHorizontal: 8, }, playButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 14, - paddingHorizontal: 32, + paddingVertical: 12, + paddingHorizontal: 28, borderRadius: 30, elevation: 4, shadowColor: '#000', @@ -522,7 +605,7 @@ const styles = StyleSheet.create({ shadowOpacity: 0.3, shadowRadius: 4, flex: 0, - width: 150, + width: 140, }, myListButton: { flexDirection: 'column', @@ -557,6 +640,15 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: '500', }, + contentOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.15)', + zIndex: 1, + }, }); export default FeaturedContent; \ No newline at end of file -- 2.45.2 From a37d4f5a8b409a58afab3b68409413323ef2a18b Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Sun, 8 Jun 2025 20:06:39 +0530 Subject: [PATCH 19/88] Update FeaturedContent component to enhance button feedback and interaction. Adjust activeOpacity for buttons to improve user experience and add pointerEvents style to the container for better touch handling. --- src/components/home/FeaturedContent.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index 858d60b..89389d8 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -441,6 +441,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat <TouchableOpacity style={styles.myListButton as ViewStyle} onPress={handleSaveToLibrary} + activeOpacity={0.7} > <MaterialIcons name={isSaved ? "bookmark" : "bookmark-border"} @@ -462,6 +463,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat }); } }} + activeOpacity={0.8} > <MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} /> <Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}> @@ -479,6 +481,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat }); } }} + activeOpacity={0.7} > <MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} /> <Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}> @@ -648,6 +651,7 @@ const styles = StyleSheet.create({ bottom: 0, backgroundColor: 'rgba(0,0,0,0.15)', zIndex: 1, + pointerEvents: 'none', }, }); -- 2.45.2 From 4a94e6248db2584945604efe8e1bd86505168ef3 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Mon, 9 Jun 2025 00:44:00 +0530 Subject: [PATCH 20/88] Enhance MetadataScreen with improved loading transitions and content visibility. Introduce state management for smooth transitions between loading and content display, utilizing animated styles for opacity and scaling effects. Refactor HeroSection integration to support new animation properties, enhancing the overall user experience during content loading. --- eas.json | 3 +- .../loading/MetadataLoadingScreen.tsx | 305 ++ src/components/metadata/HeroSection.tsx | 128 +- src/components/player/VideoPlayer.tsx | 900 +++++ .../player/controls/PlayerControls.tsx | 217 ++ .../player/modals/AudioTrackModal.tsx | 75 + .../player/modals/ResumeOverlay.tsx | 115 + .../player/modals/SubtitleModals.tsx | 281 ++ .../player/subtitles/CustomSubtitles.tsx | 29 + src/components/player/utils/playerStyles.ts | 755 +++++ src/components/player/utils/playerTypes.ts | 88 + src/components/player/utils/playerUtils.ts | 219 ++ src/hooks/useMetadataAnimations.ts | 361 +- src/navigation/AppNavigator.tsx | 517 +-- src/screens/MetadataScreen.tsx | 299 +- src/screens/VideoPlayer.tsx | 2939 ----------------- 16 files changed, 3804 insertions(+), 3427 deletions(-) create mode 100644 src/components/loading/MetadataLoadingScreen.tsx create mode 100644 src/components/player/VideoPlayer.tsx create mode 100644 src/components/player/controls/PlayerControls.tsx create mode 100644 src/components/player/modals/AudioTrackModal.tsx create mode 100644 src/components/player/modals/ResumeOverlay.tsx create mode 100644 src/components/player/modals/SubtitleModals.tsx create mode 100644 src/components/player/subtitles/CustomSubtitles.tsx create mode 100644 src/components/player/utils/playerStyles.ts create mode 100644 src/components/player/utils/playerTypes.ts create mode 100644 src/components/player/utils/playerUtils.ts delete mode 100644 src/screens/VideoPlayer.tsx diff --git a/eas.json b/eas.json index 8a48076..afd500a 100644 --- a/eas.json +++ b/eas.json @@ -12,7 +12,8 @@ "distribution": "internal" }, "production": { - "autoIncrement": true + "autoIncrement": true, + "extends": "apk" }, "release": { "distribution": "store", diff --git a/src/components/loading/MetadataLoadingScreen.tsx b/src/components/loading/MetadataLoadingScreen.tsx new file mode 100644 index 0000000..39388bb --- /dev/null +++ b/src/components/loading/MetadataLoadingScreen.tsx @@ -0,0 +1,305 @@ +import React, { useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + Dimensions, + Animated, + StatusBar, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useTheme } from '../../contexts/ThemeContext'; + +const { width, height } = Dimensions.get('window'); + +interface MetadataLoadingScreenProps { + type?: 'movie' | 'series'; +} + +export const MetadataLoadingScreen: React.FC<MetadataLoadingScreenProps> = ({ + type = 'movie' +}) => { + const { currentTheme } = useTheme(); + + // Animation values + const fadeAnim = useRef(new Animated.Value(0)).current; + const pulseAnim = useRef(new Animated.Value(0.3)).current; + const shimmerAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + // Start entrance animation + Animated.timing(fadeAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }).start(); + + // Continuous pulse animation for skeleton elements + const pulseAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1, + duration: 1200, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 0.3, + duration: 1200, + useNativeDriver: true, + }), + ]) + ); + + // Shimmer effect for skeleton elements + const shimmerAnimation = Animated.loop( + Animated.timing(shimmerAnim, { + toValue: 1, + duration: 1500, + useNativeDriver: true, + }) + ); + + pulseAnimation.start(); + shimmerAnimation.start(); + + return () => { + pulseAnimation.stop(); + shimmerAnimation.stop(); + }; + }, []); + + const shimmerTranslateX = shimmerAnim.interpolate({ + inputRange: [0, 1], + outputRange: [-width, width], + }); + + const SkeletonElement = ({ + width: elementWidth, + height: elementHeight, + borderRadius = 8, + marginBottom = 8, + style = {}, + }: { + width: number | string; + height: number; + borderRadius?: number; + marginBottom?: number; + style?: any; + }) => ( + <View style={[ + { + width: elementWidth, + height: elementHeight, + borderRadius, + marginBottom, + backgroundColor: currentTheme.colors.card, + overflow: 'hidden', + }, + style + ]}> + <Animated.View style={[ + StyleSheet.absoluteFill, + { + opacity: pulseAnim, + backgroundColor: currentTheme.colors.primary + '20', + } + ]} /> + <Animated.View style={[ + StyleSheet.absoluteFill, + { + transform: [{ translateX: shimmerTranslateX }], + } + ]}> + <LinearGradient + colors={[ + 'transparent', + currentTheme.colors.white + '20', + 'transparent' + ]} + style={StyleSheet.absoluteFill} + start={{ x: 0, y: 0 }} + end={{ x: 1, y: 0 }} + /> + </Animated.View> + </View> + ); + + return ( + <SafeAreaView + style={[styles.container, { + backgroundColor: currentTheme.colors.darkBackground, + }]} + edges={['bottom']} + > + <StatusBar + translucent={true} + backgroundColor="transparent" + barStyle="light-content" + /> + + <Animated.View style={[ + styles.content, + { opacity: fadeAnim } + ]}> + {/* Hero Skeleton */} + <View style={styles.heroSection}> + <SkeletonElement + width="100%" + height={height * 0.6} + borderRadius={0} + marginBottom={0} + /> + + {/* Overlay content on hero */} + <View style={styles.heroOverlay}> + <LinearGradient + colors={[ + 'transparent', + 'rgba(0,0,0,0.4)', + 'rgba(0,0,0,0.8)', + currentTheme.colors.darkBackground, + ]} + style={StyleSheet.absoluteFill} + /> + + {/* Bottom hero content skeleton */} + <View style={styles.heroBottomContent}> + <SkeletonElement width="60%" height={32} borderRadius={16} /> + <SkeletonElement width="40%" height={20} borderRadius={10} /> + <View style={styles.genresRow}> + <SkeletonElement width={80} height={24} borderRadius={12} marginBottom={0} style={{ marginRight: 8 }} /> + <SkeletonElement width={90} height={24} borderRadius={12} marginBottom={0} style={{ marginRight: 8 }} /> + <SkeletonElement width={70} height={24} borderRadius={12} marginBottom={0} /> + </View> + <View style={styles.buttonsRow}> + <SkeletonElement width={120} height={44} borderRadius={22} marginBottom={0} style={{ marginRight: 12 }} /> + <SkeletonElement width={100} height={44} borderRadius={22} marginBottom={0} /> + </View> + </View> + </View> + </View> + + {/* Content Section Skeletons */} + <View style={styles.contentSection}> + {/* Synopsis skeleton */} + <View style={styles.synopsisSection}> + <SkeletonElement width="30%" height={24} borderRadius={12} /> + <SkeletonElement width="100%" height={16} borderRadius={8} /> + <SkeletonElement width="95%" height={16} borderRadius={8} /> + <SkeletonElement width="80%" height={16} borderRadius={8} /> + </View> + + {/* Cast section skeleton */} + <View style={styles.castSection}> + <SkeletonElement width="20%" height={24} borderRadius={12} /> + <View style={styles.castRow}> + {[1, 2, 3, 4].map((item) => ( + <View key={item} style={styles.castItem}> + <SkeletonElement width={80} height={80} borderRadius={40} marginBottom={8} /> + <SkeletonElement width={60} height={12} borderRadius={6} marginBottom={4} /> + <SkeletonElement width={70} height={10} borderRadius={5} marginBottom={0} /> + </View> + ))} + </View> + </View> + + {/* Episodes/Details skeleton based on type */} + {type === 'series' ? ( + <View style={styles.episodesSection}> + <SkeletonElement width="25%" height={24} borderRadius={12} /> + <SkeletonElement width={150} height={36} borderRadius={18} /> + {[1, 2, 3].map((item) => ( + <View key={item} style={styles.episodeItem}> + <SkeletonElement width={120} height={68} borderRadius={8} marginBottom={0} style={{ marginRight: 12 }} /> + <View style={styles.episodeInfo}> + <SkeletonElement width="80%" height={16} borderRadius={8} /> + <SkeletonElement width="60%" height={14} borderRadius={7} /> + <SkeletonElement width="90%" height={12} borderRadius={6} /> + </View> + </View> + ))} + </View> + ) : ( + <View style={styles.detailsSection}> + <SkeletonElement width="25%" height={24} borderRadius={12} /> + <View style={styles.detailsGrid}> + <SkeletonElement width="48%" height={60} borderRadius={8} /> + <SkeletonElement width="48%" height={60} borderRadius={8} /> + </View> + </View> + )} + </View> + </Animated.View> + </SafeAreaView> + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + }, + heroSection: { + height: height * 0.6, + position: 'relative', + }, + heroOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'flex-end', + }, + heroBottomContent: { + position: 'absolute', + bottom: 20, + left: 20, + right: 20, + }, + genresRow: { + flexDirection: 'row', + marginBottom: 16, + }, + buttonsRow: { + flexDirection: 'row', + marginBottom: 8, + }, + contentSection: { + padding: 20, + }, + synopsisSection: { + marginBottom: 32, + }, + castSection: { + marginBottom: 32, + }, + castRow: { + flexDirection: 'row', + marginTop: 16, + }, + castItem: { + alignItems: 'center', + marginRight: 16, + }, + episodesSection: { + marginBottom: 32, + }, + episodeItem: { + flexDirection: 'row', + marginBottom: 16, + alignItems: 'center', + }, + episodeInfo: { + flex: 1, + }, + detailsSection: { + marginBottom: 32, + }, + detailsGrid: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 16, + }, +}); + +export default MetadataLoadingScreen; \ No newline at end of file diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 8436a73..3354fcf 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -31,14 +31,19 @@ interface HeroSectionProps { heroHeight: Animated.SharedValue<number>; heroOpacity: Animated.SharedValue<number>; heroScale: Animated.SharedValue<number>; + heroRotate: Animated.SharedValue<number>; logoOpacity: Animated.SharedValue<number>; logoScale: Animated.SharedValue<number>; + logoRotate: Animated.SharedValue<number>; genresOpacity: Animated.SharedValue<number>; genresTranslateY: Animated.SharedValue<number>; + genresScale: Animated.SharedValue<number>; buttonsOpacity: Animated.SharedValue<number>; buttonsTranslateY: Animated.SharedValue<number>; + buttonsScale: Animated.SharedValue<number>; watchProgressOpacity: Animated.SharedValue<number>; watchProgressScaleY: Animated.SharedValue<number>; + watchProgressWidth: Animated.SharedValue<number>; watchProgress: { currentTime: number; duration: number; @@ -167,17 +172,19 @@ const ActionButtons = React.memo(({ ); }); -// Memoized WatchProgress Component +// Memoized WatchProgress Component with enhanced animations const WatchProgressDisplay = React.memo(({ watchProgress, type, getEpisodeDetails, - animatedStyle + animatedStyle, + progressBarStyle }: { watchProgress: { currentTime: number; duration: number; lastUpdated: number; episodeId?: string } | null; type: 'movie' | 'series'; getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; animatedStyle: any; + progressBarStyle: any; }) => { const { currentTheme } = useTheme(); if (!watchProgress || watchProgress.duration === 0) { @@ -198,9 +205,10 @@ const WatchProgressDisplay = React.memo(({ return ( <Animated.View style={[styles.watchProgressContainer, animatedStyle]}> <View style={styles.watchProgressBar}> - <View + <Animated.View style={[ styles.watchProgressFill, + progressBarStyle, { width: `${progressPercent}%`, backgroundColor: currentTheme.colors.primary @@ -225,14 +233,19 @@ const HeroSection: React.FC<HeroSectionProps> = ({ heroHeight, heroOpacity, heroScale, + heroRotate, logoOpacity, logoScale, + logoRotate, genresOpacity, genresTranslateY, + genresScale, buttonsOpacity, buttonsTranslateY, + buttonsScale, watchProgressOpacity, watchProgressScaleY, + watchProgressWidth, watchProgress, type, getEpisodeDetails, @@ -246,18 +259,45 @@ const HeroSection: React.FC<HeroSectionProps> = ({ setLogoLoadError, }) => { const { currentTheme } = useTheme(); - // Animated styles + // Enhanced animated styles with sophisticated micro-animations const heroAnimatedStyle = useAnimatedStyle(() => ({ width: '100%', height: heroHeight.value, backgroundColor: currentTheme.colors.black, - transform: [{ scale: heroScale.value }], + transform: [ + { scale: heroScale.value }, + { + rotateZ: `${interpolate( + heroRotate.value, + [0, 1], + [0, 0.2], + Extrapolate.CLAMP + )}deg` + } + ], opacity: heroOpacity.value, })); const logoAnimatedStyle = useAnimatedStyle(() => ({ opacity: logoOpacity.value, - transform: [{ scale: logoScale.value }] + transform: [ + { + scale: interpolate( + logoScale.value, + [0, 1], + [0.95, 1], + Extrapolate.CLAMP + ) + }, + { + rotateZ: `${interpolate( + logoRotate.value, + [0, 1], + [0, 0.5], + Extrapolate.CLAMP + )}deg` + } + ] })); const watchProgressAnimatedStyle = useAnimatedStyle(() => ({ @@ -267,22 +307,50 @@ const HeroSection: React.FC<HeroSectionProps> = ({ translateY: interpolate( watchProgressScaleY.value, [0, 1], - [-8, 0], + [-12, 0], Extrapolate.CLAMP ) }, - { scaleY: watchProgressScaleY.value } + { scaleY: watchProgressScaleY.value }, + { scaleX: interpolate(watchProgressScaleY.value, [0, 1], [0.9, 1]) } + ] + })); + + const watchProgressBarStyle = useAnimatedStyle(() => ({ + width: `${watchProgressWidth.value * 100}%`, + transform: [ + { scaleX: interpolate(watchProgressWidth.value, [0, 1], [0.8, 1]) } ] })); const genresAnimatedStyle = useAnimatedStyle(() => ({ opacity: genresOpacity.value, - transform: [{ translateY: genresTranslateY.value }] + transform: [ + { translateY: genresTranslateY.value }, + { scale: genresScale.value } + ] })); const buttonsAnimatedStyle = useAnimatedStyle(() => ({ opacity: buttonsOpacity.value, - transform: [{ translateY: buttonsTranslateY.value }] + transform: [ + { + translateY: interpolate( + buttonsTranslateY.value, + [0, 20], + [0, 8], + Extrapolate.CLAMP + ) + }, + { + scale: interpolate( + buttonsScale.value, + [0, 1], + [0.98, 1], + Extrapolate.CLAMP + ) + } + ] })); const parallaxImageStyle = useAnimatedStyle(() => ({ @@ -295,7 +363,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ translateY: interpolate( dampedScrollY.value, [0, 100, 300], - [0, -30, -80], + [0, -35, -90], Extrapolate.CLAMP ) }, @@ -303,9 +371,17 @@ const HeroSection: React.FC<HeroSectionProps> = ({ scale: interpolate( dampedScrollY.value, [0, 150, 300], - [1.05, 1.03, 1.01], + [1.08, 1.05, 1.02], Extrapolate.CLAMP ) + }, + { + rotateZ: interpolate( + dampedScrollY.value, + [0, 300], + [0, -0.1], + Extrapolate.CLAMP + ) + 'deg' } ], })); @@ -389,6 +465,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ type={type} getEpisodeDetails={getEpisodeDetails} animatedStyle={watchProgressAnimatedStyle} + progressBarStyle={watchProgressBarStyle} /> {/* Genre Tags */} @@ -495,13 +572,14 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 10, - borderRadius: 100, - elevation: 4, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 28, + elevation: 6, shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 4, + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.4, + shadowRadius: 6, flex: 1, }, playButton: { @@ -513,19 +591,19 @@ const styles = StyleSheet.create({ borderColor: '#fff', }, iconButton: { - width: 48, - height: 48, - borderRadius: 24, + width: 52, + height: 52, + borderRadius: 26, backgroundColor: 'rgba(255,255,255,0.2)', borderWidth: 2, borderColor: '#fff', alignItems: 'center', justifyContent: 'center', - elevation: 4, + elevation: 6, shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 4, + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.4, + shadowRadius: 6, }, playButtonText: { color: '#000', diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx new file mode 100644 index 0000000..418f0d1 --- /dev/null +++ b/src/components/player/VideoPlayer.tsx @@ -0,0 +1,900 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text } from 'react-native'; +import { VLCPlayer } from 'react-native-vlc-media-player'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'; +import RNImmersiveMode from 'react-native-immersive-mode'; +import * as ScreenOrientation from 'expo-screen-orientation'; +import { storageService } from '../../services/storageService'; +import { logger } from '../../utils/logger'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { + DEFAULT_SUBTITLE_SIZE, + AudioTrack, + TextTrack, + ResizeModeType, + WyzieSubtitle, + SubtitleCue, + RESUME_PREF_KEY, + RESUME_PREF, + SUBTITLE_SIZE_KEY +} from './utils/playerTypes'; +import { safeDebugLog, parseSRT, DEBUG_MODE, formatTime } from './utils/playerUtils'; +import { styles } from './utils/playerStyles'; +import SubtitleModals from './modals/SubtitleModals'; +import AudioTrackModal from './modals/AudioTrackModal'; +import ResumeOverlay from './modals/ResumeOverlay'; +import PlayerControls from './controls/PlayerControls'; +import CustomSubtitles from './subtitles/CustomSubtitles'; + +const VideoPlayer: React.FC = () => { + const navigation = useNavigation(); + const route = useRoute<RouteProp<RootStackParamList, 'Player'>>(); + + const { + uri, + title = 'Episode Name', + season, + episode, + episodeTitle, + quality, + year, + streamProvider, + id, + type, + episodeId, + imdbId + } = route.params; + + safeDebugLog("Component mounted with props", { + uri, title, season, episode, episodeTitle, quality, year, + streamProvider, id, type, episodeId, imdbId + }); + + const screenData = Dimensions.get('screen'); + const [screenDimensions, setScreenDimensions] = useState(screenData); + + const [paused, setPaused] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [showControls, setShowControls] = useState(true); + const [playbackSpeed, setPlaybackSpeed] = useState(1); + const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]); + const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null); + const [textTracks, setTextTracks] = useState<TextTrack[]>([]); + const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1); + const [resizeMode, setResizeMode] = useState<ResizeModeType>('stretch'); + const [buffered, setBuffered] = useState(0); + const vlcRef = useRef<any>(null); + const [showAudioModal, setShowAudioModal] = useState(false); + const [showSubtitleModal, setShowSubtitleModal] = useState(false); + const [initialPosition, setInitialPosition] = useState<number | null>(null); + const [progressSaveInterval, setProgressSaveInterval] = useState<NodeJS.Timeout | null>(null); + const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false); + const [showResumeOverlay, setShowResumeOverlay] = useState(false); + const [resumePosition, setResumePosition] = useState<number | null>(null); + const [rememberChoice, setRememberChoice] = useState(false); + const [resumePreference, setResumePreference] = useState<string | null>(null); + const fadeAnim = useRef(new Animated.Value(1)).current; + const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); + const openingFadeAnim = useRef(new Animated.Value(0)).current; + const openingScaleAnim = useRef(new Animated.Value(0.8)).current; + const backgroundFadeAnim = useRef(new Animated.Value(1)).current; + const [isBuffering, setIsBuffering] = useState(false); + const [vlcAudioTracks, setVlcAudioTracks] = useState<Array<{id: number, name: string, language?: string}>>([]); + const [vlcTextTracks, setVlcTextTracks] = useState<Array<{id: number, name: string, language?: string}>>([]); + const [isPlayerReady, setIsPlayerReady] = useState(false); + const progressAnim = useRef(new Animated.Value(0)).current; + const progressBarRef = useRef<View>(null); + const [isDragging, setIsDragging] = useState(false); + const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null); + const pendingSeekValue = useRef<number | null>(null); + const lastSeekTime = useRef<number>(0); + const [isVideoLoaded, setIsVideoLoaded] = useState(false); + const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null); + const [is16by9Content, setIs16by9Content] = useState(false); + const [customVideoStyles, setCustomVideoStyles] = useState<any>({}); + const [zoomScale, setZoomScale] = useState(1); + const [zoomTranslateX, setZoomTranslateX] = useState(0); + const [zoomTranslateY, setZoomTranslateY] = useState(0); + const [lastZoomScale, setLastZoomScale] = useState(1); + const [lastTranslateX, setLastTranslateX] = useState(0); + const [lastTranslateY, setLastTranslateY] = useState(0); + const pinchRef = useRef<PinchGestureHandler>(null); + const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]); + const [currentSubtitle, setCurrentSubtitle] = useState<string>(''); + const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE); + const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false); + const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false); + const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]); + const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState<boolean>(false); + const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false); + const isMounted = useRef(true); + + const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { + return { + position: 'absolute', + top: 0, + left: 0, + width: screenWidth, + height: screenHeight, + backgroundColor: '#000', + }; + }; + + const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => { + const { scale } = event.nativeEvent; + const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1)); + setZoomScale(newScale); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`); + } + }; + + const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => { + if (event.nativeEvent.state === State.END) { + setLastZoomScale(zoomScale); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`); + } + } + }; + + const resetZoom = () => { + const targetZoom = is16by9Content ? 1.1 : 1; + setZoomScale(targetZoom); + setLastZoomScale(targetZoom); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`); + } + }; + + useEffect(() => { + if (videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) { + const styles = calculateVideoStyles( + videoAspectRatio * 1000, + 1000, + screenDimensions.width, + screenDimensions.height + ); + setCustomVideoStyles(styles); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Screen dimensions changed, recalculated styles:`, styles); + } + } + }, [screenDimensions, videoAspectRatio]); + + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ screen }) => { + setScreenDimensions(screen); + }); + const initializePlayer = () => { + StatusBar.setHidden(true, 'none'); + enableImmersiveMode(); + startOpeningAnimation(); + }; + initializePlayer(); + return () => { + subscription?.remove(); + const unlockOrientation = async () => { + await ScreenOrientation.unlockAsync(); + }; + unlockOrientation(); + disableImmersiveMode(); + }; + }, []); + + const startOpeningAnimation = () => { + // Animation logic here + }; + + const completeOpeningAnimation = () => { + Animated.parallel([ + Animated.timing(openingFadeAnim, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }), + Animated.timing(openingScaleAnim, { + toValue: 1, + duration: 700, + useNativeDriver: true, + }), + Animated.timing(backgroundFadeAnim, { + toValue: 0, + duration: 800, + useNativeDriver: true, + }), + ]).start(() => { + openingScaleAnim.setValue(1); + openingFadeAnim.setValue(1); + setIsOpeningAnimationComplete(true); + setTimeout(() => { + backgroundFadeAnim.setValue(0); + }, 100); + }); + }; + + useEffect(() => { + const loadWatchProgress = async () => { + if (id && type) { + try { + const savedProgress = await storageService.getWatchProgress(id, type, episodeId); + if (savedProgress) { + const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; + if (progressPercent < 95) { + setResumePosition(savedProgress.currentTime); + const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); + if (pref === RESUME_PREF.ALWAYS_RESUME) { + setInitialPosition(savedProgress.currentTime); + } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { + setInitialPosition(0); + } else { + setShowResumeOverlay(true); + } + } + } + } catch (error) { + logger.error('[VideoPlayer] Error loading watch progress:', error); + } + } + }; + loadWatchProgress(); + }, [id, type, episodeId]); + + const saveWatchProgress = async () => { + if (id && type && currentTime > 0 && duration > 0) { + const progress = { + currentTime, + duration, + lastUpdated: Date.now() + }; + try { + await storageService.setWatchProgress(id, type, progress, episodeId); + } catch (error) { + logger.error('[VideoPlayer] Error saving watch progress:', error); + } + } + }; + + useEffect(() => { + if (id && type && !paused && duration > 0) { + if (progressSaveInterval) { + clearInterval(progressSaveInterval); + } + const interval = setInterval(() => { + saveWatchProgress(); + }, 5000); + setProgressSaveInterval(interval); + return () => { + clearInterval(interval); + setProgressSaveInterval(null); + }; + } + }, [id, type, paused, currentTime, duration]); + + useEffect(() => { + return () => { + if (id && type && duration > 0) { + saveWatchProgress(); + } + }; + }, [id, type, currentTime, duration]); + + const seekToTime = (timeInSeconds: number) => { + if (!isPlayerReady || duration <= 0 || !vlcRef.current) return; + const normalizedPosition = Math.max(0, Math.min(timeInSeconds / duration, 1)); + try { + if (typeof vlcRef.current.setPosition === 'function') { + vlcRef.current.setPosition(normalizedPosition); + } else if (typeof vlcRef.current.seek === 'function') { + vlcRef.current.seek(normalizedPosition); + } else { + logger.error('[VideoPlayer] No seek method available on VLC player'); + } + } catch (error) { + logger.error('[VideoPlayer] Error during seek operation:', error); + } + }; + + const handleProgressBarTouch = (event: any) => { + if (!duration || duration <= 0) return; + const { locationX } = event.nativeEvent; + processProgressTouch(locationX); + }; + + const handleProgressBarDragStart = () => { + setIsDragging(true); + }; + + const handleProgressBarDragMove = (event: any) => { + if (!isDragging || !duration || duration <= 0) return; + const { locationX } = event.nativeEvent; + processProgressTouch(locationX, true); + }; + + const handleProgressBarDragEnd = () => { + setIsDragging(false); + if (pendingSeekValue.current !== null) { + seekToTime(pendingSeekValue.current); + pendingSeekValue.current = null; + } + }; + + const processProgressTouch = (locationX: number, isDragging = false) => { + progressBarRef.current?.measure((x, y, width, height, pageX, pageY) => { + const percentage = Math.max(0, Math.min(locationX / width, 1)); + const seekTime = percentage * duration; + progressAnim.setValue(percentage); + if (isDragging) { + pendingSeekValue.current = seekTime; + setCurrentTime(seekTime); + } else { + seekToTime(seekTime); + } + }); + }; + + const handleProgress = (event: any) => { + if (isDragging) return; + const currentTimeInSeconds = event.currentTime / 1000; + if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { + safeSetState(() => setCurrentTime(currentTimeInSeconds)); + const progressPercent = duration > 0 ? currentTimeInSeconds / duration : 0; + Animated.timing(progressAnim, { + toValue: progressPercent, + duration: 250, + useNativeDriver: false, + }).start(); + const bufferedTime = event.bufferTime / 1000 || currentTimeInSeconds; + safeSetState(() => setBuffered(bufferedTime)); + } + }; + + const onLoad = (data: any) => { + setDuration(data.duration / 1000); + if (data.videoSize && data.videoSize.width && data.videoSize.height) { + const aspectRatio = data.videoSize.width / data.videoSize.height; + setVideoAspectRatio(aspectRatio); + const is16x9 = Math.abs(aspectRatio - (16/9)) < 0.1; + setIs16by9Content(is16x9); + if (is16x9) { + setZoomScale(1.1); + setLastZoomScale(1.1); + } else { + setZoomScale(1); + setLastZoomScale(1); + } + const styles = calculateVideoStyles( + data.videoSize.width, + data.videoSize.height, + screenDimensions.width, + screenDimensions.height + ); + setCustomVideoStyles(styles); + } else { + setIs16by9Content(true); + setZoomScale(1.1); + setLastZoomScale(1.1); + const defaultStyles = { + position: 'absolute', + top: 0, + left: 0, + width: screenDimensions.width, + height: screenDimensions.height, + }; + setCustomVideoStyles(defaultStyles); + } + setIsPlayerReady(true); + const audioTracksFromLoad = data.audioTracks || []; + const textTracksFromLoad = data.textTracks || []; + setVlcAudioTracks(audioTracksFromLoad); + setVlcTextTracks(textTracksFromLoad); + if (audioTracksFromLoad.length > 1) { + const firstEnabledAudio = audioTracksFromLoad.find((t: any) => t.id !== -1); + if(firstEnabledAudio) { + setSelectedAudioTrack(firstEnabledAudio.id); + } + } else if (audioTracksFromLoad.length > 0) { + setSelectedAudioTrack(audioTracksFromLoad[0].id); + } + if (imdbId && !customSubtitles.length) { + setTimeout(() => { + fetchAvailableSubtitles(imdbId, true); + }, 2000); + } + if (initialPosition !== null && !isInitialSeekComplete) { + setTimeout(() => { + if (vlcRef.current && duration > 0 && isMounted.current) { + seekToTime(initialPosition); + setIsInitialSeekComplete(true); + } + }, 1000); + } + setIsVideoLoaded(true); + completeOpeningAnimation(); + }; + + const skip = (seconds: number) => { + if (vlcRef.current) { + const newTime = Math.max(0, Math.min(currentTime + seconds, duration)); + seekToTime(newTime); + } + }; + + const onAudioTracks = (data: { audioTracks: AudioTrack[] }) => { + setAudioTracks(data.audioTracks || []); + }; + + const onTextTracks = (e: Readonly<{ textTracks: TextTrack[] }>) => { + setTextTracks(e.textTracks || []); + }; + + const cycleAspectRatio = () => { + const newZoom = zoomScale === 1.1 ? 1 : 1.1; + setZoomScale(newZoom); + setZoomTranslateX(0); + setZoomTranslateY(0); + setLastZoomScale(newZoom); + setLastTranslateX(0); + setLastTranslateY(0); + }; + + const enableImmersiveMode = () => { + StatusBar.setHidden(true, 'none'); + if (Platform.OS === 'android') { + try { + RNImmersiveMode.setBarMode('FullSticky'); + RNImmersiveMode.fullLayout(true); + if (NativeModules.StatusBarManager) { + NativeModules.StatusBarManager.setHidden(true); + } + } catch (error) { + console.log('Immersive mode error:', error); + } + } + }; + + const disableImmersiveMode = () => { + StatusBar.setHidden(false); + if (Platform.OS === 'android') { + RNImmersiveMode.setBarMode('Normal'); + RNImmersiveMode.fullLayout(false); + } + }; + + const handleClose = () => { + ScreenOrientation.unlockAsync().then(() => { + disableImmersiveMode(); + navigation.goBack(); + }); + }; + + useEffect(() => { + const loadResumePreference = async () => { + try { + const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); + if (pref) { + setResumePreference(pref); + if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) { + setShowResumeOverlay(false); + setInitialPosition(resumePosition); + } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { + setShowResumeOverlay(false); + setInitialPosition(0); + } + } + } catch (error) { + logger.error('[VideoPlayer] Error loading resume preference:', error); + } + }; + loadResumePreference(); + }, [resumePosition]); + + const resetResumePreference = async () => { + try { + await AsyncStorage.removeItem(RESUME_PREF_KEY); + setResumePreference(null); + } catch (error) { + logger.error('[VideoPlayer] Error resetting resume preference:', error); + } + }; + + const handleResume = async () => { + if (resumePosition !== null && vlcRef.current) { + if (rememberChoice) { + try { + await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME); + } catch (error) { + logger.error('[VideoPlayer] Error saving resume preference:', error); + } + } + setInitialPosition(resumePosition); + setShowResumeOverlay(false); + setTimeout(() => { + if (vlcRef.current) { + seekToTime(resumePosition); + } + }, 500); + } + }; + + const handleStartFromBeginning = async () => { + if (rememberChoice) { + try { + await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_START_OVER); + } catch (error) { + logger.error('[VideoPlayer] Error saving resume preference:', error); + } + } + setShowResumeOverlay(false); + setInitialPosition(0); + if (vlcRef.current) { + seekToTime(0); + setCurrentTime(0); + } + }; + + const toggleControls = () => { + setShowControls(previousState => !previousState); + }; + + useEffect(() => { + Animated.timing(fadeAnim, { + toValue: showControls ? 1 : 0, + duration: 300, + useNativeDriver: true, + }).start(); + }, [showControls]); + + const handleError = (error: any) => { + logger.error('[VideoPlayer] Playback Error:', error); + }; + + const onBuffering = (event: any) => { + setIsBuffering(event.isBuffering); + }; + + const onEnd = () => { + // End logic here + }; + + const selectAudioTrack = (trackId: number) => { + setSelectedAudioTrack(trackId); + }; + + const selectTextTrack = (trackId: number) => { + if (trackId === -999) { + setUseCustomSubtitles(true); + setSelectedTextTrack(-1); + } else { + setUseCustomSubtitles(false); + setSelectedTextTrack(trackId); + } + }; + + const loadSubtitleSize = async () => { + try { + const savedSize = await AsyncStorage.getItem(SUBTITLE_SIZE_KEY); + if (savedSize) { + setSubtitleSize(parseInt(savedSize, 10)); + } + } catch (error) { + logger.error('[VideoPlayer] Error loading subtitle size:', error); + } + }; + + const saveSubtitleSize = async (size: number) => { + try { + await AsyncStorage.setItem(SUBTITLE_SIZE_KEY, size.toString()); + setSubtitleSize(size); + } catch (error) { + logger.error('[VideoPlayer] Error saving subtitle size:', error); + } + }; + + const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => { + const targetImdbId = imdbIdParam || imdbId; + if (!targetImdbId) { + logger.error('[VideoPlayer] No IMDb ID available for subtitle search'); + return; + } + setIsLoadingSubtitleList(true); + try { + let searchUrl = `https://sub.wyzie.ru/search?id=${targetImdbId}&encoding=utf-8&source=all`; + if (season && episode) { + searchUrl += `&season=${season}&episode=${episode}`; + } + const response = await fetch(searchUrl); + const subtitles: WyzieSubtitle[] = await response.json(); + const uniqueSubtitles = subtitles.reduce((acc, current) => { + const exists = acc.find(item => item.language === current.language); + if (!exists) { + acc.push(current); + } + return acc; + }, [] as WyzieSubtitle[]); + uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display)); + setAvailableSubtitles(uniqueSubtitles); + if (autoSelectEnglish) { + const englishSubtitle = uniqueSubtitles.find(sub => + sub.language.toLowerCase() === 'eng' || + sub.language.toLowerCase() === 'en' || + sub.display.toLowerCase().includes('english') + ); + if (englishSubtitle) { + loadWyzieSubtitle(englishSubtitle); + return; + } + } + if (!autoSelectEnglish) { + setShowSubtitleLanguageModal(true); + } + } catch (error) { + logger.error('[VideoPlayer] Error fetching subtitles from Wyzie API:', error); + } finally { + setIsLoadingSubtitleList(false); + } + }; + + const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => { + setShowSubtitleLanguageModal(false); + setIsLoadingSubtitles(true); + try { + const response = await fetch(subtitle.url); + const srtContent = await response.text(); + const parsedCues = parseSRT(srtContent); + setCustomSubtitles(parsedCues); + setUseCustomSubtitles(true); + setSelectedTextTrack(-1); + } catch (error) { + logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error); + } finally { + setIsLoadingSubtitles(false); + } + }; + + const togglePlayback = () => { + if (vlcRef.current) { + setPaused(!paused); + } + }; + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + if (seekDebounceTimer.current) { + clearTimeout(seekDebounceTimer.current); + } + }; + }, []); + + const safeSetState = (setter: any) => { + if (isMounted.current) { + setter(); + } + }; + + useEffect(() => { + if (!useCustomSubtitles || customSubtitles.length === 0) { + if (currentSubtitle !== '') { + setCurrentSubtitle(''); + } + return; + } + const currentCue = customSubtitles.find(cue => + currentTime >= cue.start && currentTime <= cue.end + ); + const newSubtitle = currentCue ? currentCue.text : ''; + setCurrentSubtitle(newSubtitle); + }, [currentTime, customSubtitles, useCustomSubtitles]); + + useEffect(() => { + loadSubtitleSize(); + }, []); + + const increaseSubtitleSize = () => { + const newSize = Math.min(subtitleSize + 2, 32); + saveSubtitleSize(newSize); + }; + + const decreaseSubtitleSize = () => { + const newSize = Math.max(subtitleSize - 2, 8); + saveSubtitleSize(newSize); + }; + + return ( + <View style={[styles.container, { + width: screenDimensions.width, + height: screenDimensions.height, + position: 'absolute', + top: 0, + left: 0, + }]}> + <Animated.View + style={[ + styles.openingOverlay, + { + opacity: backgroundFadeAnim, + zIndex: isOpeningAnimationComplete ? -1 : 3000, + width: screenDimensions.width, + height: screenDimensions.height, + } + ]} + pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'} + > + <View style={styles.openingContent}> + <ActivityIndicator size="large" color="#E50914" /> + <Text style={styles.openingText}>Loading video...</Text> + </View> + </Animated.View> + + <Animated.View + style={[ + styles.videoPlayerContainer, + { + opacity: openingFadeAnim, + transform: isOpeningAnimationComplete ? [] : [{ scale: openingScaleAnim }], + width: screenDimensions.width, + height: screenDimensions.height, + } + ]} + > + <TouchableOpacity + style={[styles.videoContainer, { + width: screenDimensions.width, + height: screenDimensions.height, + }]} + onPress={toggleControls} + activeOpacity={1} + > + <PinchGestureHandler + ref={pinchRef} + onGestureEvent={onPinchGestureEvent} + onHandlerStateChange={onPinchHandlerStateChange} + > + <View style={{ + position: 'absolute', + top: 0, + left: 0, + width: screenDimensions.width, + height: screenDimensions.height, + backgroundColor: '#000', + }}> + <TouchableOpacity + style={{ flex: 1 }} + activeOpacity={1} + onPress={toggleControls} + onLongPress={resetZoom} + delayLongPress={300} + > + <VLCPlayer + ref={vlcRef} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: screenDimensions.width, + height: screenDimensions.height, + transform: [ + { scale: zoomScale }, + ], + }} + source={{ + uri: uri, + initOptions: [ + '--rtsp-tcp', + '--network-caching=150', + '--rtsp-caching=150', + '--no-audio-time-stretch', + '--clock-jitter=0', + '--clock-synchro=0', + '--drop-late-frames', + '--skip-frames', + ], + }} + paused={paused} + autoplay={true} + autoAspectRatio={false} + resizeMode={'stretch' as any} + audioTrack={selectedAudioTrack || undefined} + textTrack={selectedTextTrack === -1 ? undefined : selectedTextTrack} + onLoad={onLoad} + onProgress={handleProgress} + onEnd={onEnd} + onError={handleError} + /> + </TouchableOpacity> + </View> + </PinchGestureHandler> + + <PlayerControls + showControls={showControls} + fadeAnim={fadeAnim} + paused={paused} + title={title} + episodeTitle={episodeTitle} + season={season} + episode={episode} + quality={quality} + year={year} + streamProvider={streamProvider} + currentTime={currentTime} + duration={duration} + playbackSpeed={playbackSpeed} + zoomScale={zoomScale} + vlcAudioTracks={vlcAudioTracks} + selectedAudioTrack={selectedAudioTrack} + togglePlayback={togglePlayback} + skip={skip} + handleClose={handleClose} + cycleAspectRatio={cycleAspectRatio} + setShowAudioModal={setShowAudioModal} + setShowSubtitleModal={setShowSubtitleModal} + progressBarRef={progressBarRef} + progressAnim={progressAnim} + handleProgressBarTouch={handleProgressBarTouch} + handleProgressBarDragStart={handleProgressBarDragStart} + handleProgressBarDragMove={handleProgressBarDragMove} + handleProgressBarDragEnd={handleProgressBarDragEnd} + buffered={buffered} + formatTime={formatTime} + /> + + <CustomSubtitles + useCustomSubtitles={useCustomSubtitles} + currentSubtitle={currentSubtitle} + subtitleSize={subtitleSize} + /> + + <ResumeOverlay + showResumeOverlay={showResumeOverlay} + resumePosition={resumePosition} + duration={duration} + title={title} + season={season} + episode={episode} + rememberChoice={rememberChoice} + setRememberChoice={setRememberChoice} + resumePreference={resumePreference} + resetResumePreference={resetResumePreference} + handleResume={handleResume} + handleStartFromBeginning={handleStartFromBeginning} + /> + </TouchableOpacity> + </Animated.View> + + <AudioTrackModal + showAudioModal={showAudioModal} + setShowAudioModal={setShowAudioModal} + vlcAudioTracks={vlcAudioTracks} + selectedAudioTrack={selectedAudioTrack} + selectAudioTrack={selectAudioTrack} + /> + <SubtitleModals + showSubtitleModal={showSubtitleModal} + setShowSubtitleModal={setShowSubtitleModal} + showSubtitleLanguageModal={showSubtitleLanguageModal} + setShowSubtitleLanguageModal={setShowSubtitleLanguageModal} + isLoadingSubtitleList={isLoadingSubtitleList} + isLoadingSubtitles={isLoadingSubtitles} + customSubtitles={customSubtitles} + availableSubtitles={availableSubtitles} + vlcTextTracks={vlcTextTracks} + selectedTextTrack={selectedTextTrack} + useCustomSubtitles={useCustomSubtitles} + subtitleSize={subtitleSize} + fetchAvailableSubtitles={fetchAvailableSubtitles} + loadWyzieSubtitle={loadWyzieSubtitle} + selectTextTrack={selectTextTrack} + increaseSubtitleSize={increaseSubtitleSize} + decreaseSubtitleSize={decreaseSubtitleSize} + /> + </View> + ); +}; + +export default VideoPlayer; \ No newline at end of file diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx new file mode 100644 index 0000000..cc73fb5 --- /dev/null +++ b/src/components/player/controls/PlayerControls.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, Animated, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { styles } from '../utils/playerStyles'; +import { getTrackDisplayName } from '../utils/playerUtils'; + +interface PlayerControlsProps { + showControls: boolean; + fadeAnim: Animated.Value; + paused: boolean; + title: string; + episodeTitle?: string; + season?: number; + episode?: number; + quality?: string; + year?: number; + streamProvider?: string; + currentTime: number; + duration: number; + playbackSpeed: number; + zoomScale: number; + vlcAudioTracks: Array<{id: number, name: string, language?: string}>; + selectedAudioTrack: number | null; + togglePlayback: () => void; + skip: (seconds: number) => void; + handleClose: () => void; + cycleAspectRatio: () => void; + setShowAudioModal: (show: boolean) => void; + setShowSubtitleModal: (show: boolean) => void; + progressBarRef: React.RefObject<View>; + progressAnim: Animated.Value; + handleProgressBarTouch: (event: any) => void; + handleProgressBarDragStart: () => void; + handleProgressBarDragMove: (event: any) => void; + handleProgressBarDragEnd: () => void; + buffered: number; + formatTime: (seconds: number) => string; +} + +export const PlayerControls: React.FC<PlayerControlsProps> = ({ + showControls, + fadeAnim, + paused, + title, + episodeTitle, + season, + episode, + quality, + year, + streamProvider, + currentTime, + duration, + playbackSpeed, + zoomScale, + vlcAudioTracks, + selectedAudioTrack, + togglePlayback, + skip, + handleClose, + cycleAspectRatio, + setShowAudioModal, + setShowSubtitleModal, + progressBarRef, + progressAnim, + handleProgressBarTouch, + handleProgressBarDragStart, + handleProgressBarDragMove, + handleProgressBarDragEnd, + buffered, + formatTime, +}) => { + return ( + <Animated.View + style={[StyleSheet.absoluteFill, { opacity: fadeAnim }]} + pointerEvents={showControls ? 'auto' : 'none'} + > + {/* Progress bar with enhanced touch handling */} + <View style={styles.sliderContainer}> + <View + style={styles.progressTouchArea} + onTouchStart={handleProgressBarDragStart} + onTouchMove={handleProgressBarDragMove} + onTouchEnd={handleProgressBarDragEnd} + > + <TouchableOpacity + activeOpacity={0.8} + onPress={handleProgressBarTouch} + style={{width: '100%'}} + > + <View + ref={progressBarRef} + style={styles.progressBarContainer} + > + {/* Buffered Progress */} + <View style={[styles.bufferProgress, { + width: `${(buffered / (duration || 1)) * 100}%` + }]} /> + {/* Animated Progress */} + <Animated.View + style={[ + styles.progressBarFill, + { + width: progressAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0%', '100%'] + }) + } + ]} + /> + </View> + </TouchableOpacity> + </View> + <View style={styles.timeDisplay}> + <Text style={styles.duration}>{formatTime(currentTime)}</Text> + <Text style={styles.duration}>{formatTime(duration)}</Text> + </View> + </View> + + {/* Controls Overlay */} + <View style={styles.controlsContainer}> + {/* Top Gradient & Header */} + <LinearGradient + colors={['rgba(0,0,0,0.7)', 'transparent']} + style={styles.topGradient} + > + <View style={styles.header}> + {/* Title Section - Enhanced with metadata */} + <View style={styles.titleSection}> + <Text style={styles.title}>{title}</Text> + {/* Show season and episode for series */} + {season && episode && ( + <Text style={styles.episodeInfo}> + S{season}E{episode} {episodeTitle && `• ${episodeTitle}`} + </Text> + )} + {/* Show year, quality, and provider */} + <View style={styles.metadataRow}> + {year && <Text style={styles.metadataText}>{year}</Text>} + {quality && <View style={styles.qualityBadge}><Text style={styles.qualityText}>{quality}</Text></View>} + {streamProvider && <Text style={styles.providerText}>via {streamProvider}</Text>} + </View> + </View> + <TouchableOpacity style={styles.closeButton} onPress={handleClose}> + <Ionicons name="close" size={24} color="white" /> + </TouchableOpacity> + </View> + </LinearGradient> + + {/* Center Controls (Play/Pause, Skip) */} + <View style={styles.controls}> + <TouchableOpacity onPress={() => skip(-10)} style={styles.skipButton}> + <Ionicons name="play-back" size={24} color="white" /> + <Text style={styles.skipText}>10</Text> + </TouchableOpacity> + <TouchableOpacity onPress={togglePlayback} style={styles.playButton}> + <Ionicons name={paused ? "play" : "pause"} size={40} color="white" /> + </TouchableOpacity> + <TouchableOpacity onPress={() => skip(10)} style={styles.skipButton}> + <Ionicons name="play-forward" size={24} color="white" /> + <Text style={styles.skipText}>10</Text> + </TouchableOpacity> + </View> + + {/* Bottom Gradient */} + <LinearGradient + colors={['transparent', 'rgba(0,0,0,0.7)']} + style={styles.bottomGradient} + > + <View style={styles.bottomControls}> + {/* Bottom Buttons Row */} + <View style={styles.bottomButtons}> + {/* Speed Button */} + <TouchableOpacity style={styles.bottomButton}> + <Ionicons name="speedometer" size={20} color="white" /> + <Text style={styles.bottomButtonText}>Speed ({playbackSpeed}x)</Text> + </TouchableOpacity> + + {/* Fill/Cover Button - Updated to show fill/cover modes */} + <TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}> + <Ionicons name="resize" size={20} color="white" /> + <Text style={[styles.bottomButtonText, { fontSize: 14, textAlign: 'center' }]}> + {zoomScale === 1.1 ? 'Fill' : 'Cover'} + </Text> + </TouchableOpacity> + + {/* Audio Button - Updated to use vlcAudioTracks */} + <TouchableOpacity + style={styles.bottomButton} + onPress={() => setShowAudioModal(true)} + disabled={vlcAudioTracks.length <= 1} + > + <Ionicons name="volume-high" size={20} color={vlcAudioTracks.length <= 1 ? 'grey' : 'white'} /> + <Text style={[styles.bottomButtonText, vlcAudioTracks.length <= 1 && {color: 'grey'}]}> + {`Audio: ${getTrackDisplayName(vlcAudioTracks.find(t => t.id === selectedAudioTrack) || {id: -1, name: 'Default'})}`} + </Text> + </TouchableOpacity> + + {/* Subtitle Button - Always available for external subtitle search */} + <TouchableOpacity + style={styles.bottomButton} + onPress={() => setShowSubtitleModal(true)} + > + <Ionicons name="text" size={20} color="white" /> + <Text style={styles.bottomButtonText}> + Subtitles + </Text> + </TouchableOpacity> + </View> + </View> + </LinearGradient> + </View> + </Animated.View> + ); +}; + +export default PlayerControls; \ No newline at end of file diff --git a/src/components/player/modals/AudioTrackModal.tsx b/src/components/player/modals/AudioTrackModal.tsx new file mode 100644 index 0000000..d35e4b6 --- /dev/null +++ b/src/components/player/modals/AudioTrackModal.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { styles } from '../utils/playerStyles'; +import { getTrackDisplayName } from '../utils/playerUtils'; + +interface AudioTrackModalProps { + showAudioModal: boolean; + setShowAudioModal: (show: boolean) => void; + vlcAudioTracks: Array<{id: number, name: string, language?: string}>; + selectedAudioTrack: number | null; + selectAudioTrack: (trackId: number) => void; +} + +export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({ + showAudioModal, + setShowAudioModal, + vlcAudioTracks, + selectedAudioTrack, + selectAudioTrack, +}) => { + if (!showAudioModal) return null; + + return ( + <View style={styles.fullscreenOverlay}> + <View style={styles.enhancedModalContainer}> + <View style={styles.enhancedModalHeader}> + <Text style={styles.enhancedModalTitle}>Audio</Text> + <TouchableOpacity + style={styles.enhancedCloseButton} + onPress={() => setShowAudioModal(false)} + > + <Ionicons name="close" size={24} color="white" /> + </TouchableOpacity> + </View> + + <ScrollView style={styles.trackListScrollContainer}> + <View style={styles.trackListContainer}> + {vlcAudioTracks.length > 0 ? vlcAudioTracks.map(track => ( + <TouchableOpacity + key={track.id} + style={styles.enhancedTrackItem} + onPress={() => { + selectAudioTrack(track.id); + setShowAudioModal(false); + }} + > + <View style={styles.trackInfoContainer}> + <Text style={styles.trackPrimaryText}> + {getTrackDisplayName(track)} + </Text> + {(track.name && track.language) && ( + <Text style={styles.trackSecondaryText}>{track.name}</Text> + )} + </View> + {selectedAudioTrack === track.id && ( + <View style={styles.selectedIndicatorContainer}> + <Ionicons name="checkmark" size={22} color="#E50914" /> + </View> + )} + </TouchableOpacity> + )) : ( + <View style={styles.emptyStateContainer}> + <Ionicons name="alert-circle-outline" size={40} color="#888" /> + <Text style={styles.emptyStateText}>No audio tracks available</Text> + </View> + )} + </View> + </ScrollView> + </View> + </View> + ); +}; + +export default AudioTrackModal; \ No newline at end of file diff --git a/src/components/player/modals/ResumeOverlay.tsx b/src/components/player/modals/ResumeOverlay.tsx new file mode 100644 index 0000000..0c1f4e0 --- /dev/null +++ b/src/components/player/modals/ResumeOverlay.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { View, Text, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import { styles } from '../utils/playerStyles'; +import { formatTime } from '../utils/playerUtils'; + +interface ResumeOverlayProps { + showResumeOverlay: boolean; + resumePosition: number | null; + duration: number; + title: string; + season?: number; + episode?: number; + rememberChoice: boolean; + setRememberChoice: (remember: boolean) => void; + resumePreference: string | null; + resetResumePreference: () => void; + handleResume: () => void; + handleStartFromBeginning: () => void; +} + +export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({ + showResumeOverlay, + resumePosition, + duration, + title, + season, + episode, + rememberChoice, + setRememberChoice, + resumePreference, + resetResumePreference, + handleResume, + handleStartFromBeginning, +}) => { + if (!showResumeOverlay || resumePosition === null) return null; + + return ( + <View style={styles.resumeOverlay}> + <LinearGradient + colors={['rgba(0,0,0,0.9)', 'rgba(0,0,0,0.7)']} + style={styles.resumeContainer} + > + <View style={styles.resumeContent}> + <View style={styles.resumeIconContainer}> + <Ionicons name="play-circle" size={40} color="#E50914" /> + </View> + <View style={styles.resumeTextContainer}> + <Text style={styles.resumeTitle}>Continue Watching</Text> + <Text style={styles.resumeInfo}> + {title} + {season && episode && ` • S${season}E${episode}`} + </Text> + <View style={styles.resumeProgressContainer}> + <View style={styles.resumeProgressBar}> + <View + style={[ + styles.resumeProgressFill, + { width: `${duration > 0 ? (resumePosition / duration) * 100 : 0}%` } + ]} + /> + </View> + <Text style={styles.resumeTimeText}> + {formatTime(resumePosition)} {duration > 0 ? `/ ${formatTime(duration)}` : ''} + </Text> + </View> + </View> + </View> + + {/* Remember choice checkbox */} + <TouchableOpacity + style={styles.rememberChoiceContainer} + onPress={() => setRememberChoice(!rememberChoice)} + activeOpacity={0.7} + > + <View style={styles.checkboxContainer}> + <View style={[styles.checkbox, rememberChoice && styles.checkboxChecked]}> + {rememberChoice && <Ionicons name="checkmark" size={12} color="white" />} + </View> + <Text style={styles.rememberChoiceText}>Remember my choice</Text> + </View> + + {resumePreference && ( + <TouchableOpacity + onPress={resetResumePreference} + style={styles.resetPreferenceButton} + > + <Text style={styles.resetPreferenceText}>Reset</Text> + </TouchableOpacity> + )} + </TouchableOpacity> + + <View style={styles.resumeButtons}> + <TouchableOpacity + style={styles.resumeButton} + onPress={handleStartFromBeginning} + > + <Ionicons name="refresh" size={16} color="white" style={styles.buttonIcon} /> + <Text style={styles.resumeButtonText}>Start Over</Text> + </TouchableOpacity> + <TouchableOpacity + style={[styles.resumeButton, styles.resumeFromButton]} + onPress={handleResume} + > + <Ionicons name="play" size={16} color="white" style={styles.buttonIcon} /> + <Text style={styles.resumeButtonText}>Resume</Text> + </TouchableOpacity> + </View> + </LinearGradient> + </View> + ); +}; + +export default ResumeOverlay; \ No newline at end of file diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx new file mode 100644 index 0000000..582cf71 --- /dev/null +++ b/src/components/player/modals/SubtitleModals.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Image } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { styles } from '../utils/playerStyles'; +import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes'; +import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils'; + +interface SubtitleModalsProps { + showSubtitleModal: boolean; + setShowSubtitleModal: (show: boolean) => void; + showSubtitleLanguageModal: boolean; + setShowSubtitleLanguageModal: (show: boolean) => void; + isLoadingSubtitleList: boolean; + isLoadingSubtitles: boolean; + customSubtitles: SubtitleCue[]; + availableSubtitles: WyzieSubtitle[]; + vlcTextTracks: Array<{id: number, name: string, language?: string}>; + selectedTextTrack: number; + useCustomSubtitles: boolean; + subtitleSize: number; + fetchAvailableSubtitles: () => void; + loadWyzieSubtitle: (subtitle: WyzieSubtitle) => void; + selectTextTrack: (trackId: number) => void; + increaseSubtitleSize: () => void; + decreaseSubtitleSize: () => void; +} + +export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ + showSubtitleModal, + setShowSubtitleModal, + showSubtitleLanguageModal, + setShowSubtitleLanguageModal, + isLoadingSubtitleList, + isLoadingSubtitles, + customSubtitles, + availableSubtitles, + vlcTextTracks, + selectedTextTrack, + useCustomSubtitles, + subtitleSize, + fetchAvailableSubtitles, + loadWyzieSubtitle, + selectTextTrack, + increaseSubtitleSize, + decreaseSubtitleSize, +}) => { + // Render subtitle settings modal + const renderSubtitleModal = () => { + if (!showSubtitleModal) return null; + + return ( + <View style={styles.fullscreenOverlay}> + <View style={styles.modernModalContainer}> + <View style={styles.modernModalHeader}> + <Text style={styles.modernModalTitle}>Subtitle Settings</Text> + <TouchableOpacity + style={styles.modernCloseButton} + onPress={() => setShowSubtitleModal(false)} + > + <Ionicons name="close" size={24} color="white" /> + </TouchableOpacity> + </View> + + <ScrollView style={styles.modernTrackListScrollContainer} showsVerticalScrollIndicator={false}> + <View style={styles.modernTrackListContainer}> + + {/* External Subtitles Section - Priority */} + <View style={styles.sectionContainer}> + <Text style={styles.sectionTitle}>External Subtitles</Text> + <Text style={styles.sectionDescription}>High quality subtitles with size control</Text> + + {/* Custom subtitles option - show if loaded */} + {customSubtitles.length > 0 ? ( + <TouchableOpacity + style={[styles.modernTrackItem, useCustomSubtitles && styles.modernSelectedTrackItem]} + onPress={() => { + selectTextTrack(-999); + setShowSubtitleModal(false); + }} + > + <View style={styles.trackIconContainer}> + <Ionicons name="document-text" size={20} color="#4CAF50" /> + </View> + <View style={styles.modernTrackInfoContainer}> + <Text style={styles.modernTrackPrimaryText}>Custom Subtitles</Text> + <Text style={styles.modernTrackSecondaryText}> + {customSubtitles.length} cues • Size adjustable + </Text> + </View> + {useCustomSubtitles && ( + <View style={styles.modernSelectedIndicator}> + <Ionicons name="checkmark-circle" size={24} color="#4CAF50" /> + </View> + )} + </TouchableOpacity> + ) : null} + + {/* Search for external subtitles */} + <TouchableOpacity + style={styles.searchSubtitlesButton} + onPress={() => { + setShowSubtitleModal(false); + fetchAvailableSubtitles(); + }} + disabled={isLoadingSubtitleList} + > + <View style={styles.searchButtonContent}> + {isLoadingSubtitleList ? ( + <ActivityIndicator size="small" color="#2196F3" /> + ) : ( + <Ionicons name="search" size={20} color="#2196F3" /> + )} + <Text style={styles.searchSubtitlesText}> + {isLoadingSubtitleList ? 'Searching...' : 'Search Online Subtitles'} + </Text> + </View> + </TouchableOpacity> + </View> + + {/* Subtitle Size Controls - Only for custom subtitles */} + {useCustomSubtitles && ( + <View style={styles.sectionContainer}> + <Text style={styles.sectionTitle}>Size Control</Text> + <View style={styles.modernSubtitleSizeContainer}> + <TouchableOpacity + style={styles.modernSizeButton} + onPress={decreaseSubtitleSize} + > + <Ionicons name="remove" size={20} color="white" /> + </TouchableOpacity> + <View style={styles.sizeDisplayContainer}> + <Text style={styles.modernSubtitleSizeText}>{subtitleSize}px</Text> + <Text style={styles.sizeLabel}>Font Size</Text> + </View> + <TouchableOpacity + style={styles.modernSizeButton} + onPress={increaseSubtitleSize} + > + <Ionicons name="add" size={20} color="white" /> + </TouchableOpacity> + </View> + </View> + )} + + {/* Built-in Subtitles Section */} + <View style={styles.sectionContainer}> + <Text style={styles.sectionTitle}>Built-in Subtitles</Text> + <Text style={styles.sectionDescription}>System default sizing • No customization</Text> + + {/* Off option */} + <TouchableOpacity + style={[styles.modernTrackItem, (selectedTextTrack === -1 && !useCustomSubtitles) && styles.modernSelectedTrackItem]} + onPress={() => { + selectTextTrack(-1); + setShowSubtitleModal(false); + }} + > + <View style={styles.trackIconContainer}> + <Ionicons name="close-circle" size={20} color="#9E9E9E" /> + </View> + <View style={styles.modernTrackInfoContainer}> + <Text style={styles.modernTrackPrimaryText}>Disabled</Text> + <Text style={styles.modernTrackSecondaryText}>No subtitles</Text> + </View> + {(selectedTextTrack === -1 && !useCustomSubtitles) && ( + <View style={styles.modernSelectedIndicator}> + <Ionicons name="checkmark-circle" size={24} color="#9E9E9E" /> + </View> + )} + </TouchableOpacity> + + {/* Available built-in subtitle tracks */} + {vlcTextTracks.length > 0 ? vlcTextTracks.map(track => ( + <TouchableOpacity + key={track.id} + style={[styles.modernTrackItem, (selectedTextTrack === track.id && !useCustomSubtitles) && styles.modernSelectedTrackItem]} + onPress={() => { + selectTextTrack(track.id); + setShowSubtitleModal(false); + }} + > + <View style={styles.trackIconContainer}> + <Ionicons name="text" size={20} color="#FF9800" /> + </View> + <View style={styles.modernTrackInfoContainer}> + <Text style={styles.modernTrackPrimaryText}> + {getTrackDisplayName(track)} + </Text> + <Text style={styles.modernTrackSecondaryText}> + Built-in track • System font size + </Text> + </View> + {(selectedTextTrack === track.id && !useCustomSubtitles) && ( + <View style={styles.modernSelectedIndicator}> + <Ionicons name="checkmark-circle" size={24} color="#FF9800" /> + </View> + )} + </TouchableOpacity> + )) : ( + <View style={styles.modernEmptyStateContainer}> + <Ionicons name="information-circle-outline" size={24} color="#666" /> + <Text style={styles.modernEmptyStateText}>No built-in subtitles available</Text> + </View> + )} + </View> + </View> + </ScrollView> + </View> + </View> + ); + }; + + // Render subtitle language selection modal + const renderSubtitleLanguageModal = () => { + if (!showSubtitleLanguageModal) return null; + + return ( + <View style={styles.fullscreenOverlay}> + <View style={styles.enhancedModalContainer}> + <View style={styles.enhancedModalHeader}> + <Text style={styles.enhancedModalTitle}>Select Language</Text> + <TouchableOpacity + style={styles.enhancedCloseButton} + onPress={() => setShowSubtitleLanguageModal(false)} + > + <Ionicons name="close" size={24} color="white" /> + </TouchableOpacity> + </View> + + <ScrollView style={styles.trackListScrollContainer}> + <View style={styles.trackListContainer}> + {availableSubtitles.length > 0 ? availableSubtitles.map(subtitle => ( + <TouchableOpacity + key={subtitle.id} + style={styles.enhancedTrackItem} + onPress={() => loadWyzieSubtitle(subtitle)} + disabled={isLoadingSubtitles} + > + <View style={styles.subtitleLanguageItem}> + <Image + source={{ uri: subtitle.flagUrl }} + style={styles.flagIcon} + resizeMode="cover" + /> + <View style={styles.trackInfoContainer}> + <Text style={styles.trackPrimaryText}> + {formatLanguage(subtitle.language)} + </Text> + <Text style={styles.trackSecondaryText}> + {subtitle.display} + </Text> + </View> + </View> + {isLoadingSubtitles && ( + <ActivityIndicator size="small" color="#E50914" /> + )} + </TouchableOpacity> + )) : ( + <View style={styles.emptyStateContainer}> + <Ionicons name="alert-circle-outline" size={40} color="#888" /> + <Text style={styles.emptyStateText}> + No subtitles found for this content + </Text> + </View> + )} + </View> + </ScrollView> + </View> + </View> + ); + }; + + return ( + <> + {renderSubtitleModal()} + {renderSubtitleLanguageModal()} + </> + ); +}; + +export default SubtitleModals; \ No newline at end of file diff --git a/src/components/player/subtitles/CustomSubtitles.tsx b/src/components/player/subtitles/CustomSubtitles.tsx new file mode 100644 index 0000000..66bbedf --- /dev/null +++ b/src/components/player/subtitles/CustomSubtitles.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import { styles } from '../utils/playerStyles'; + +interface CustomSubtitlesProps { + useCustomSubtitles: boolean; + currentSubtitle: string; + subtitleSize: number; +} + +export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({ + useCustomSubtitles, + currentSubtitle, + subtitleSize, +}) => { + if (!useCustomSubtitles || !currentSubtitle) return null; + + return ( + <View style={styles.customSubtitleContainer} pointerEvents="none"> + <View style={styles.customSubtitleWrapper}> + <Text style={[styles.customSubtitleText, { fontSize: subtitleSize }]}> + {currentSubtitle} + </Text> + </View> + </View> + ); +}; + +export default CustomSubtitles; \ No newline at end of file diff --git a/src/components/player/utils/playerStyles.ts b/src/components/player/utils/playerStyles.ts new file mode 100644 index 0000000..561e0d5 --- /dev/null +++ b/src/components/player/utils/playerStyles.ts @@ -0,0 +1,755 @@ +import { StyleSheet } from 'react-native'; + +export const styles = StyleSheet.create({ + container: { + backgroundColor: '#000', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, + }, + videoContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, + }, + video: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, + }, + controlsContainer: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'space-between', + margin: 0, + padding: 0, + }, + topGradient: { + paddingTop: 20, + paddingHorizontal: 20, + paddingBottom: 10, + }, + bottomGradient: { + paddingBottom: 20, + paddingHorizontal: 20, + paddingTop: 20, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + titleSection: { + flex: 1, + marginRight: 10, + }, + title: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + }, + episodeInfo: { + color: 'rgba(255, 255, 255, 0.9)', + fontSize: 14, + marginTop: 3, + }, + metadataRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 5, + flexWrap: 'wrap', + }, + metadataText: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginRight: 8, + }, + qualityBadge: { + backgroundColor: '#E50914', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + marginRight: 8, + }, + qualityText: { + color: 'white', + fontSize: 10, + fontWeight: 'bold', + }, + providerText: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + fontStyle: 'italic', + }, + closeButton: { + padding: 8, + }, + controls: { + position: 'absolute', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 40, + left: 0, + right: 0, + top: '50%', + transform: [{ translateY: -30 }], + zIndex: 1000, + }, + playButton: { + justifyContent: 'center', + alignItems: 'center', + padding: 10, + }, + skipButton: { + alignItems: 'center', + justifyContent: 'center', + }, + skipText: { + color: 'white', + fontSize: 12, + marginTop: 2, + }, + bottomControls: { + gap: 12, + }, + sliderContainer: { + position: 'absolute', + bottom: 55, + left: 0, + right: 0, + paddingHorizontal: 20, + zIndex: 1000, + }, + progressTouchArea: { + height: 30, + justifyContent: 'center', + width: '100%', + }, + progressBarContainer: { + height: 4, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + borderRadius: 2, + overflow: 'hidden', + marginHorizontal: 4, + position: 'relative', + }, + bufferProgress: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + backgroundColor: 'rgba(255, 255, 255, 0.4)', + }, + progressBarFill: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + backgroundColor: '#E50914', + height: '100%', + }, + timeDisplay: { + flexDirection: 'row', + justifyContent: 'space-between', + width: '100%', + paddingHorizontal: 4, + marginTop: 4, + marginBottom: 8, + }, + duration: { + color: 'white', + fontSize: 12, + fontWeight: '500', + }, + bottomButtons: { + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + }, + bottomButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + }, + bottomButtonText: { + color: 'white', + fontSize: 12, + }, + modalOverlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.7)', + }, + modalContent: { + width: '80%', + maxHeight: '70%', + backgroundColor: '#222', + borderRadius: 10, + overflow: 'hidden', + zIndex: 1000, + elevation: 5, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.8, + shadowRadius: 5, + }, + modalHeader: { + padding: 16, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderBottomWidth: 1, + borderBottomColor: '#333', + }, + modalTitle: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + }, + trackList: { + padding: 10, + }, + trackItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 15, + borderRadius: 5, + marginVertical: 5, + }, + selectedTrackItem: { + backgroundColor: 'rgba(229, 9, 20, 0.2)', + }, + trackLabel: { + color: 'white', + fontSize: 16, + }, + noTracksText: { + color: 'white', + fontSize: 16, + textAlign: 'center', + padding: 20, + }, + fullscreenOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.85)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 2000, + }, + enhancedModalContainer: { + width: 300, + maxHeight: '70%', + backgroundColor: '#181818', + borderRadius: 8, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.4, + shadowRadius: 10, + elevation: 8, + }, + enhancedModalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#333', + }, + enhancedModalTitle: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + }, + enhancedCloseButton: { + padding: 4, + }, + trackListScrollContainer: { + maxHeight: 350, + }, + trackListContainer: { + padding: 6, + }, + enhancedTrackItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 10, + marginVertical: 2, + borderRadius: 6, + backgroundColor: '#222', + }, + trackInfoContainer: { + flex: 1, + marginRight: 8, + }, + trackPrimaryText: { + color: 'white', + fontSize: 14, + fontWeight: '500', + }, + trackSecondaryText: { + color: '#aaa', + fontSize: 11, + marginTop: 2, + }, + selectedIndicatorContainer: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(229, 9, 20, 0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + emptyStateContainer: { + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + emptyStateText: { + color: '#888', + fontSize: 14, + marginTop: 8, + textAlign: 'center', + }, + resumeOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }, + resumeContainer: { + width: '80%', + maxWidth: 500, + borderRadius: 12, + padding: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 6, + elevation: 8, + }, + resumeContent: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 20, + }, + resumeIconContainer: { + marginRight: 16, + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: 'rgba(229, 9, 20, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + resumeTextContainer: { + flex: 1, + }, + resumeTitle: { + color: 'white', + fontSize: 20, + fontWeight: 'bold', + marginBottom: 4, + }, + resumeInfo: { + color: 'rgba(255, 255, 255, 0.9)', + fontSize: 14, + }, + resumeProgressContainer: { + marginTop: 12, + }, + resumeProgressBar: { + height: 4, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + borderRadius: 2, + overflow: 'hidden', + marginBottom: 6, + }, + resumeProgressFill: { + height: '100%', + backgroundColor: '#E50914', + }, + resumeTimeText: { + color: 'rgba(255,255,255,0.7)', + fontSize: 12, + }, + resumeButtons: { + flexDirection: 'row', + justifyContent: 'flex-end', + width: '100%', + gap: 12, + }, + resumeButton: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 6, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + minWidth: 110, + justifyContent: 'center', + }, + buttonIcon: { + marginRight: 6, + }, + resumeButtonText: { + color: 'white', + fontWeight: 'bold', + fontSize: 14, + }, + resumeFromButton: { + backgroundColor: '#E50914', + }, + rememberChoiceContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 16, + paddingHorizontal: 2, + }, + checkboxContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + checkbox: { + width: 18, + height: 18, + borderRadius: 3, + borderWidth: 2, + borderColor: 'rgba(255, 255, 255, 0.5)', + marginRight: 8, + justifyContent: 'center', + alignItems: 'center', + }, + checkboxChecked: { + backgroundColor: '#E50914', + borderColor: '#E50914', + }, + rememberChoiceText: { + color: 'rgba(255, 255, 255, 0.8)', + fontSize: 14, + }, + resetPreferenceButton: { + padding: 4, + }, + resetPreferenceText: { + color: '#E50914', + fontSize: 12, + fontWeight: 'bold', + }, + openingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.85)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 2000, + margin: 0, + padding: 0, + }, + openingContent: { + padding: 20, + backgroundColor: 'rgba(0,0,0,0.85)', + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', + }, + openingText: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + marginTop: 20, + }, + videoPlayerContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, + }, + subtitleSizeContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 10, + paddingVertical: 12, + marginBottom: 8, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 6, + }, + subtitleSizeLabel: { + color: 'white', + fontSize: 14, + fontWeight: 'bold', + }, + subtitleSizeControls: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + sizeButton: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + subtitleSizeText: { + color: 'white', + fontSize: 14, + fontWeight: 'bold', + minWidth: 40, + textAlign: 'center', + }, + customSubtitleContainer: { + position: 'absolute', + bottom: 40, // Position above controls and progress bar + left: 20, + right: 20, + alignItems: 'center', + zIndex: 1500, // Higher z-index to appear above other elements + }, + customSubtitleWrapper: { + backgroundColor: 'rgba(0, 0, 0, 0.7)', + padding: 10, + borderRadius: 5, + }, + customSubtitleText: { + color: 'white', + textAlign: 'center', + textShadowColor: 'rgba(0, 0, 0, 0.9)', + textShadowOffset: { width: 2, height: 2 }, + textShadowRadius: 4, + lineHeight: undefined, // Let React Native calculate line height + fontWeight: '500', + }, + loadSubtitlesButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + marginTop: 8, + borderRadius: 6, + backgroundColor: 'rgba(229, 9, 20, 0.2)', + borderWidth: 1, + borderColor: '#E50914', + }, + loadSubtitlesText: { + color: '#E50914', + fontSize: 14, + fontWeight: 'bold', + marginLeft: 8, + }, + disabledContainer: { + opacity: 0.5, + }, + disabledText: { + color: '#666', + }, + disabledButton: { + backgroundColor: '#666', + }, + noteContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + noteText: { + color: '#aaa', + fontSize: 12, + marginLeft: 5, + }, + subtitleLanguageItem: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + flagIcon: { + width: 24, + height: 18, + marginRight: 12, + borderRadius: 2, + }, + modernModalContainer: { + width: '90%', + maxWidth: 500, + backgroundColor: '#181818', + borderRadius: 10, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.4, + shadowRadius: 10, + elevation: 8, + }, + modernModalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#333', + }, + modernModalTitle: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + }, + modernCloseButton: { + padding: 4, + }, + modernTrackListScrollContainer: { + maxHeight: 350, + }, + modernTrackListContainer: { + padding: 6, + }, + sectionContainer: { + marginBottom: 20, + }, + sectionTitle: { + color: 'white', + fontSize: 16, + fontWeight: 'bold', + marginBottom: 8, + }, + sectionDescription: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginBottom: 12, + }, + trackIconContainer: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', + }, + modernTrackInfoContainer: { + flex: 1, + marginLeft: 10, + }, + modernTrackPrimaryText: { + color: 'white', + fontSize: 14, + fontWeight: '500', + }, + modernTrackSecondaryText: { + color: '#aaa', + fontSize: 11, + marginTop: 2, + }, + modernSelectedIndicator: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + modernEmptyStateContainer: { + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + modernEmptyStateText: { + color: '#888', + fontSize: 14, + marginTop: 8, + textAlign: 'center', + }, + searchSubtitlesButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + marginTop: 8, + borderRadius: 6, + backgroundColor: 'rgba(229, 9, 20, 0.2)', + borderWidth: 1, + borderColor: '#E50914', + }, + searchButtonContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + searchSubtitlesText: { + color: '#E50914', + fontSize: 14, + fontWeight: 'bold', + marginLeft: 8, + }, + modernSubtitleSizeContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + modernSizeButton: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + modernTrackItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 12, + marginVertical: 4, + borderRadius: 8, + backgroundColor: '#222', + }, + modernSelectedTrackItem: { + backgroundColor: 'rgba(76, 175, 80, 0.15)', + borderWidth: 1, + borderColor: 'rgba(76, 175, 80, 0.3)', + }, + sizeDisplayContainer: { + alignItems: 'center', + flex: 1, + marginHorizontal: 20, + }, + modernSubtitleSizeText: { + color: 'white', + fontSize: 16, + fontWeight: 'bold', + }, + sizeLabel: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginTop: 2, + }, +}); \ No newline at end of file diff --git a/src/components/player/utils/playerTypes.ts b/src/components/player/utils/playerTypes.ts new file mode 100644 index 0000000..3f2c5d8 --- /dev/null +++ b/src/components/player/utils/playerTypes.ts @@ -0,0 +1,88 @@ +// Player constants +export const RESUME_PREF_KEY = '@video_resume_preference'; +export const RESUME_PREF = { + ALWAYS_ASK: 'always_ask', + ALWAYS_RESUME: 'always_resume', + ALWAYS_START_OVER: 'always_start_over' +}; + +export const SUBTITLE_SIZE_KEY = '@subtitle_size_preference'; +export const DEFAULT_SUBTITLE_SIZE = 16; + +// Define the TrackPreferenceType for audio/text tracks +export type TrackPreferenceType = 'system' | 'disabled' | 'title' | 'language' | 'index'; + +// Define the SelectedTrack type for audio/text tracks +export interface SelectedTrack { + type: TrackPreferenceType; + value?: string | number; // value is optional for 'system' and 'disabled' +} + +export interface VideoPlayerProps { + uri: string; + title?: string; + season?: number; + episode?: number; + episodeTitle?: string; + quality?: string; + year?: number; + streamProvider?: string; + id?: string; + type?: string; + episodeId?: string; + imdbId?: string; // Add IMDb ID for subtitle fetching +} + +// Match the react-native-video AudioTrack type +export interface AudioTrack { + index: number; + title?: string; + language?: string; + bitrate?: number; + type?: string; + selected?: boolean; +} + +// Define TextTrack interface based on react-native-video expected structure +export interface TextTrack { + index: number; + title?: string; + language?: string; + type?: string | null; // Adjusting type based on linter error +} + +// Define the possible resize modes - force to stretch for absolute full screen +export type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none' | 'stretch'; +export const resizeModes: ResizeModeType[] = ['stretch']; // Force stretch mode for absolute full screen + +// Add VLC specific interface for their event structure +export interface VlcMediaEvent { + currentTime: number; + duration: number; + bufferTime?: number; + isBuffering?: boolean; + audioTracks?: Array<{id: number, name: string, language?: string}>; + textTracks?: Array<{id: number, name: string, language?: string}>; + selectedAudioTrack?: number; + selectedTextTrack?: number; +} + +export interface SubtitleCue { + start: number; + end: number; + text: string; +} + +// Add interface for Wyzie subtitle API response +export interface WyzieSubtitle { + id: string; + url: string; + flagUrl: string; + format: string; + encoding: string; + media: string; + display: string; + language: string; + isHearingImpaired: boolean; + source: string; +} \ No newline at end of file diff --git a/src/components/player/utils/playerUtils.ts b/src/components/player/utils/playerUtils.ts new file mode 100644 index 0000000..72aeb0c --- /dev/null +++ b/src/components/player/utils/playerUtils.ts @@ -0,0 +1,219 @@ +import { logger } from '../../../utils/logger'; +import { useEffect } from 'react'; +import { SubtitleCue } from './playerTypes'; + +// Debug flag - set back to false to disable verbose logging +// WARNING: Setting this to true currently causes infinite render loops +// Use selective logging instead if debugging is needed +export const DEBUG_MODE = false; + +// Safer debug function that won't cause render loops +// Call this with any debugging info you need instead of using inline DEBUG_MODE checks +export const safeDebugLog = (message: string, data?: any) => { + // This function only runs once per call site, avoiding render loops + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (DEBUG_MODE) { + if (data) { + logger.log(`[VideoPlayer] ${message}`, data); + } else { + logger.log(`[VideoPlayer] ${message}`); + } + } + }, []); // Empty dependency array means this only runs once per mount +}; + +// Add language code to name mapping +export const languageMap: {[key: string]: string} = { + 'en': 'English', + 'eng': 'English', + 'es': 'Spanish', + 'spa': 'Spanish', + 'fr': 'French', + 'fre': 'French', + 'de': 'German', + 'ger': 'German', + 'it': 'Italian', + 'ita': 'Italian', + 'ja': 'Japanese', + 'jpn': 'Japanese', + 'ko': 'Korean', + 'kor': 'Korean', + 'zh': 'Chinese', + 'chi': 'Chinese', + 'ru': 'Russian', + 'rus': 'Russian', + 'pt': 'Portuguese', + 'por': 'Portuguese', + 'hi': 'Hindi', + 'hin': 'Hindi', + 'ar': 'Arabic', + 'ara': 'Arabic', + 'nl': 'Dutch', + 'dut': 'Dutch', + 'sv': 'Swedish', + 'swe': 'Swedish', + 'no': 'Norwegian', + 'nor': 'Norwegian', + 'fi': 'Finnish', + 'fin': 'Finnish', + 'da': 'Danish', + 'dan': 'Danish', + 'pl': 'Polish', + 'pol': 'Polish', + 'tr': 'Turkish', + 'tur': 'Turkish', + 'cs': 'Czech', + 'cze': 'Czech', + 'hu': 'Hungarian', + 'hun': 'Hungarian', + 'el': 'Greek', + 'gre': 'Greek', + 'th': 'Thai', + 'tha': 'Thai', + 'vi': 'Vietnamese', + 'vie': 'Vietnamese', +}; + +// Function to format language code to readable name +export const formatLanguage = (code?: string): string => { + if (!code) return 'Unknown'; + const normalized = code.toLowerCase(); + const languageName = languageMap[normalized] || code.toUpperCase(); + + // If the result is still the uppercased code, it means we couldn't find it in our map. + if (languageName === code.toUpperCase()) { + return `Unknown (${code})`; + } + + return languageName; +}; + +// Helper function to extract a display name from the track's name property +export const getTrackDisplayName = (track: { name?: string, id: number }): string => { + if (!track || !track.name) return `Track ${track.id}`; + + // Try to extract language from name like "Some Info - [English]" + const languageMatch = track.name.match(/\[(.*?)\]/); + if (languageMatch && languageMatch[1]) { + return languageMatch[1]; + } + + // If no language in brackets, or if the name is simple, use the full name + return track.name; +}; + +// Format time function for the player +export const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}:${mins < 10 ? '0' : ''}${mins}:${secs < 10 ? '0' : ''}${secs}`; + } else { + return `${mins}:${secs < 10 ? '0' : ''}${secs}`; + } +}; + +// Enhanced SRT parser function - more robust +export const parseSRT = (srtContent: string): SubtitleCue[] => { + const cues: SubtitleCue[] = []; + + if (!srtContent || srtContent.trim().length === 0) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Empty content provided`); + } + return cues; + } + + // Normalize line endings and clean up the content + const normalizedContent = srtContent + .replace(/\r\n/g, '\n') // Convert Windows line endings + .replace(/\r/g, '\n') // Convert Mac line endings + .trim(); + + // Split by double newlines, but also handle cases with multiple empty lines + const blocks = normalizedContent.split(/\n\s*\n/).filter(block => block.trim().length > 0); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Found ${blocks.length} blocks after normalization`); + logger.log(`[VideoPlayer] SRT Parser: First few characters: "${normalizedContent.substring(0, 300)}"`); + } + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i].trim(); + const lines = block.split('\n').map(line => line.trim()).filter(line => line.length > 0); + + if (lines.length >= 3) { + // Find the timestamp line (could be line 1 or 2, depending on numbering) + let timeLineIndex = -1; + let timeMatch = null; + + for (let j = 0; j < Math.min(3, lines.length); j++) { + // More flexible time pattern matching + timeMatch = lines[j].match(/(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})/); + if (timeMatch) { + timeLineIndex = j; + break; + } + } + + if (timeMatch && timeLineIndex !== -1) { + try { + const startTime = + parseInt(timeMatch[1]) * 3600 + + parseInt(timeMatch[2]) * 60 + + parseInt(timeMatch[3]) + + parseInt(timeMatch[4]) / 1000; + + const endTime = + parseInt(timeMatch[5]) * 3600 + + parseInt(timeMatch[6]) * 60 + + parseInt(timeMatch[7]) + + parseInt(timeMatch[8]) / 1000; + + // Get text lines (everything after the timestamp line) + const textLines = lines.slice(timeLineIndex + 1); + if (textLines.length > 0) { + const text = textLines + .join('\n') + .replace(/<[^>]*>/g, '') // Remove HTML tags + .replace(/\{[^}]*\}/g, '') // Remove subtitle formatting tags like {italic} + .replace(/\\N/g, '\n') // Handle \N newlines + .trim(); + + if (text.length > 0) { + cues.push({ + start: startTime, + end: endTime, + text: text + }); + + if (DEBUG_MODE && (i < 5 || cues.length <= 10)) { + logger.log(`[VideoPlayer] SRT Parser: Cue ${cues.length}: ${startTime.toFixed(3)}s-${endTime.toFixed(3)}s: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); + } + } + } + } catch (error) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Error parsing times for block ${i + 1}: ${error}`); + } + } + } else if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: No valid timestamp found in block ${i + 1}. Lines: ${JSON.stringify(lines.slice(0, 3))}`); + } + } else if (DEBUG_MODE && block.length > 0) { + logger.log(`[VideoPlayer] SRT Parser: Block ${i + 1} has insufficient lines (${lines.length}): "${block.substring(0, 100)}"`); + } + } + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Successfully parsed ${cues.length} subtitle cues`); + if (cues.length > 0) { + logger.log(`[VideoPlayer] SRT Parser: Time range: ${cues[0].start.toFixed(1)}s to ${cues[cues.length-1].end.toFixed(1)}s`); + } + } + + return cues; +}; \ No newline at end of file diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts index 7ef53e6..8b7f12e 100644 --- a/src/hooks/useMetadataAnimations.ts +++ b/src/hooks/useMetadataAnimations.ts @@ -4,6 +4,8 @@ import { useSharedValue, withTiming, withSpring, + withSequence, + withDelay, Easing, useAnimatedScrollHandler, interpolate, @@ -12,233 +14,344 @@ import { const { width, height } = Dimensions.get('window'); -// Animation constants +// Refined animation configurations const springConfig = { - damping: 20, - mass: 1, - stiffness: 100 + damping: 25, + mass: 0.8, + stiffness: 120, + overshootClamping: false, + restDisplacementThreshold: 0.01, + restSpeedThreshold: 0.01, }; -// Animation timing constants for staggered appearance -const ANIMATION_DELAY_CONSTANTS = { - HERO: 100, - LOGO: 250, - PROGRESS: 350, - GENRES: 400, - BUTTONS: 450, - CONTENT: 500 +const microSpringConfig = { + damping: 20, + mass: 0.5, + stiffness: 150, + overshootClamping: true, + restDisplacementThreshold: 0.001, + restSpeedThreshold: 0.001, +}; + +// Sophisticated easing curves +const easings = { + // Smooth entrance with slight overshoot + entrance: Easing.bezier(0.34, 1.56, 0.64, 1), + // Gentle bounce for micro-interactions + microBounce: Easing.bezier(0.68, -0.55, 0.265, 1.55), + // Smooth exit + exit: Easing.bezier(0.25, 0.46, 0.45, 0.94), + // Natural movement + natural: Easing.bezier(0.25, 0.1, 0.25, 1), + // Subtle emphasis + emphasis: Easing.bezier(0.19, 1, 0.22, 1), +}; + +// Refined timing constants for orchestrated entrance +const TIMING = { + // Quick initial setup + SCREEN_PREP: 50, + // Staggered content appearance + HERO_BASE: 150, + LOGO: 280, + PROGRESS: 380, + GENRES: 450, + BUTTONS: 520, + CONTENT: 650, + // Micro-delays for polish + MICRO_DELAY: 50, }; export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) => { - // Animation values for screen entrance - const screenScale = useSharedValue(0.92); + // Enhanced screen entrance with micro-animations + const screenScale = useSharedValue(0.96); const screenOpacity = useSharedValue(0); + const screenBlur = useSharedValue(5); - // Animation values for hero section + // Refined hero section animations const heroHeight = useSharedValue(height * 0.5); - const heroScale = useSharedValue(1.05); + const heroScale = useSharedValue(1.08); const heroOpacity = useSharedValue(0); - - // Animation values for content - const contentTranslateY = useSharedValue(60); + const heroRotate = useSharedValue(-0.5); - // Animation values for logo + // Enhanced content animations + const contentTranslateY = useSharedValue(40); + const contentScale = useSharedValue(0.98); + + // Sophisticated logo animations const logoOpacity = useSharedValue(0); - const logoScale = useSharedValue(0.9); + const logoScale = useSharedValue(0.85); + const logoRotate = useSharedValue(2); - // Animation values for progress + // Enhanced progress animations const watchProgressOpacity = useSharedValue(0); const watchProgressScaleY = useSharedValue(0); + const watchProgressWidth = useSharedValue(0); - // Animation values for genres + // Refined genre animations const genresOpacity = useSharedValue(0); - const genresTranslateY = useSharedValue(20); + const genresTranslateY = useSharedValue(15); + const genresScale = useSharedValue(0.95); - // Animation values for buttons + // Enhanced button animations const buttonsOpacity = useSharedValue(0); - const buttonsTranslateY = useSharedValue(30); + const buttonsTranslateY = useSharedValue(20); + const buttonsScale = useSharedValue(0.95); - // Scroll values for parallax effect + // Scroll values with enhanced parallax const scrollY = useSharedValue(0); const dampedScrollY = useSharedValue(0); + const velocityY = useSharedValue(0); - // Header animation values + // Sophisticated header animations const headerOpacity = useSharedValue(0); - const headerElementsY = useSharedValue(-10); + const headerElementsY = useSharedValue(-15); const headerElementsOpacity = useSharedValue(0); + const headerBlur = useSharedValue(10); - // Start entrance animation + // Orchestrated entrance animation sequence useEffect(() => { - // Use a timeout to ensure the animations starts after the component is mounted - const animationTimeout = setTimeout(() => { - // 1. First animate the container - screenScale.value = withSpring(1, springConfig); - screenOpacity.value = withSpring(1, springConfig); + const startAnimation = setTimeout(() => { + // Phase 1: Screen preparation with subtle bounce + screenScale.value = withSequence( + withTiming(1.02, { duration: 200, easing: easings.entrance }), + withTiming(1, { duration: 150, easing: easings.natural }) + ); + screenOpacity.value = withTiming(1, { + duration: 300, + easing: easings.emphasis + }); + screenBlur.value = withTiming(0, { + duration: 400, + easing: easings.natural + }); - // 2. Then animate the hero section with a slight delay + // Phase 2: Hero section with parallax feel setTimeout(() => { - heroOpacity.value = withSpring(1, { - damping: 14, - stiffness: 80 + heroOpacity.value = withSequence( + withTiming(0.8, { duration: 200, easing: easings.entrance }), + withTiming(1, { duration: 100, easing: easings.natural }) + ); + heroScale.value = withSequence( + withTiming(1.02, { duration: 300, easing: easings.entrance }), + withTiming(1, { duration: 200, easing: easings.natural }) + ); + heroRotate.value = withTiming(0, { + duration: 500, + easing: easings.emphasis }); - heroScale.value = withSpring(1, { - damping: 18, - stiffness: 100 - }); - }, ANIMATION_DELAY_CONSTANTS.HERO); + }, TIMING.HERO_BASE); - // 3. Then animate the logo + // Phase 3: Logo with micro-bounce setTimeout(() => { - logoOpacity.value = withSpring(1, { - damping: 12, - stiffness: 100 + logoOpacity.value = withTiming(1, { + duration: 300, + easing: easings.entrance }); - logoScale.value = withSpring(1, { - damping: 14, - stiffness: 90 + logoScale.value = withSequence( + withTiming(1.05, { duration: 150, easing: easings.microBounce }), + withTiming(1, { duration: 100, easing: easings.natural }) + ); + logoRotate.value = withTiming(0, { + duration: 300, + easing: easings.emphasis }); - }, ANIMATION_DELAY_CONSTANTS.LOGO); + }, TIMING.LOGO); - // 4. Then animate the watch progress if applicable + // Phase 4: Progress bar with width animation setTimeout(() => { if (watchProgress && watchProgress.duration > 0) { - watchProgressOpacity.value = withSpring(1, { - damping: 14, - stiffness: 100 - }); - watchProgressScaleY.value = withSpring(1, { - damping: 18, - stiffness: 120 + watchProgressOpacity.value = withTiming(1, { + duration: 250, + easing: easings.entrance }); + watchProgressScaleY.value = withSpring(1, microSpringConfig); + watchProgressWidth.value = withDelay( + 100, + withTiming(1, { duration: 600, easing: easings.emphasis }) + ); } - }, ANIMATION_DELAY_CONSTANTS.PROGRESS); + }, TIMING.PROGRESS); - // 5. Then animate the genres + // Phase 5: Genres with staggered scale setTimeout(() => { - genresOpacity.value = withSpring(1, { - damping: 14, - stiffness: 100 + genresOpacity.value = withTiming(1, { + duration: 250, + easing: easings.entrance }); - genresTranslateY.value = withSpring(0, { - damping: 18, - stiffness: 120 - }); - }, ANIMATION_DELAY_CONSTANTS.GENRES); + genresTranslateY.value = withSpring(0, microSpringConfig); + genresScale.value = withSequence( + withTiming(1.02, { duration: 150, easing: easings.microBounce }), + withTiming(1, { duration: 100, easing: easings.natural }) + ); + }, TIMING.GENRES); - // 6. Then animate the buttons + // Phase 6: Buttons with sophisticated bounce setTimeout(() => { - buttonsOpacity.value = withSpring(1, { - damping: 14, - stiffness: 100 + buttonsOpacity.value = withTiming(1, { + duration: 300, + easing: easings.entrance }); - buttonsTranslateY.value = withSpring(0, { - damping: 18, - stiffness: 120 - }); - }, ANIMATION_DELAY_CONSTANTS.BUTTONS); + buttonsTranslateY.value = withSpring(0, springConfig); + buttonsScale.value = withSequence( + withTiming(1.03, { duration: 200, easing: easings.microBounce }), + withTiming(1, { duration: 150, easing: easings.natural }) + ); + }, TIMING.BUTTONS); - // 7. Finally animate the content section + // Phase 7: Content with layered entrance setTimeout(() => { contentTranslateY.value = withSpring(0, { - damping: 25, - mass: 1, - stiffness: 100 + ...springConfig, + damping: 30, + stiffness: 100, }); - }, ANIMATION_DELAY_CONSTANTS.CONTENT); - }, 50); // Small timeout to ensure component is fully mounted + contentScale.value = withSequence( + withTiming(1.01, { duration: 200, easing: easings.entrance }), + withTiming(1, { duration: 150, easing: easings.natural }) + ); + }, TIMING.CONTENT); + }, TIMING.SCREEN_PREP); - return () => clearTimeout(animationTimeout); + return () => clearTimeout(startAnimation); }, []); - // Effect to animate watch progress when it changes + // Enhanced watch progress animation with width effect useEffect(() => { if (watchProgress && watchProgress.duration > 0) { - watchProgressOpacity.value = withSpring(1, { - mass: 0.2, - stiffness: 100, - damping: 14 - }); - watchProgressScaleY.value = withSpring(1, { - mass: 0.3, - stiffness: 120, - damping: 18 + watchProgressOpacity.value = withTiming(1, { + duration: 300, + easing: easings.entrance }); + watchProgressScaleY.value = withSpring(1, microSpringConfig); + watchProgressWidth.value = withDelay( + 150, + withTiming(1, { duration: 800, easing: easings.emphasis }) + ); } else { - watchProgressOpacity.value = withSpring(0, { - mass: 0.2, - stiffness: 100, - damping: 14 + watchProgressOpacity.value = withTiming(0, { + duration: 200, + easing: easings.exit }); - watchProgressScaleY.value = withSpring(0, { - mass: 0.3, - stiffness: 120, - damping: 18 + watchProgressScaleY.value = withTiming(0, { + duration: 200, + easing: easings.exit + }); + watchProgressWidth.value = withTiming(0, { + duration: 150, + easing: easings.exit }); } - }, [watchProgress, watchProgressOpacity, watchProgressScaleY]); + }, [watchProgress, watchProgressOpacity, watchProgressScaleY, watchProgressWidth]); - // Effect to animate logo when it's available + // Enhanced logo animation with micro-interactions const animateLogo = (hasLogo: boolean) => { if (hasLogo) { logoOpacity.value = withTiming(1, { - duration: 500, - easing: Easing.out(Easing.ease) + duration: 400, + easing: easings.entrance }); + logoScale.value = withSequence( + withTiming(1.05, { duration: 200, easing: easings.microBounce }), + withTiming(1, { duration: 150, easing: easings.natural }) + ); } else { logoOpacity.value = withTiming(0, { - duration: 200, - easing: Easing.in(Easing.ease) + duration: 250, + easing: easings.exit + }); + logoScale.value = withTiming(0.9, { + duration: 250, + easing: easings.exit }); } }; - // Scroll handler + // Enhanced scroll handler with velocity tracking const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { const rawScrollY = event.contentOffset.y; - scrollY.value = rawScrollY; + const lastScrollY = scrollY.value; - // Apply spring-like damping for smoother transitions + scrollY.value = rawScrollY; + velocityY.value = rawScrollY - lastScrollY; + + // Enhanced damped scroll with velocity-based easing + const dynamicDuration = Math.min(400, Math.max(200, Math.abs(velocityY.value) * 10)); dampedScrollY.value = withTiming(rawScrollY, { - duration: 300, - easing: Easing.bezier(0.16, 1, 0.3, 1), // Custom spring-like curve + duration: dynamicDuration, + easing: easings.natural, }); - // Update header opacity based on scroll position - const headerThreshold = height * 0.5 - safeAreaTop - 70; // Hero height - inset - buffer + // Sophisticated header animation with blur effect + const headerThreshold = height * 0.5 - safeAreaTop - 60; + const progress = Math.min(1, Math.max(0, (rawScrollY - headerThreshold + 50) / 100)); + if (rawScrollY > headerThreshold) { - headerOpacity.value = withTiming(1, { duration: 200 }); - headerElementsY.value = withTiming(0, { duration: 300 }); - headerElementsOpacity.value = withTiming(1, { duration: 450 }); + headerOpacity.value = withTiming(1, { + duration: 300, + easing: easings.entrance + }); + headerElementsY.value = withSpring(0, microSpringConfig); + headerElementsOpacity.value = withTiming(1, { + duration: 400, + easing: easings.emphasis + }); + headerBlur.value = withTiming(0, { + duration: 300, + easing: easings.natural + }); } else { - headerOpacity.value = withTiming(0, { duration: 150 }); - headerElementsY.value = withTiming(-10, { duration: 200 }); - headerElementsOpacity.value = withTiming(0, { duration: 200 }); + headerOpacity.value = withTiming(0, { + duration: 200, + easing: easings.exit + }); + headerElementsY.value = withTiming(-15, { + duration: 200, + easing: easings.exit + }); + headerElementsOpacity.value = withTiming(0, { + duration: 150, + easing: easings.exit + }); + headerBlur.value = withTiming(5, { + duration: 200, + easing: easings.natural + }); } }, }); return { - // Animated values + // Enhanced animated values screenScale, screenOpacity, + screenBlur, heroHeight, heroScale, heroOpacity, + heroRotate, contentTranslateY, + contentScale, logoOpacity, logoScale, + logoRotate, watchProgressOpacity, watchProgressScaleY, + watchProgressWidth, genresOpacity, genresTranslateY, + genresScale, buttonsOpacity, buttonsTranslateY, + buttonsScale, scrollY, dampedScrollY, + velocityY, headerOpacity, headerElementsY, headerElementsOpacity, + headerBlur, // Functions scrollHandler, diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 16d3e89..2a5b2c1 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -21,7 +21,7 @@ import DiscoverScreen from '../screens/DiscoverScreen'; import LibraryScreen from '../screens/LibraryScreen'; import SettingsScreen from '../screens/SettingsScreen'; import MetadataScreen from '../screens/MetadataScreen'; -import VideoPlayer from '../screens/VideoPlayer'; +import VideoPlayer from '../components/player/VideoPlayer'; import CatalogScreen from '../screens/CatalogScreen'; import AddonsScreen from '../screens/AddonsScreen'; import SearchScreen from '../screens/SearchScreen'; @@ -662,6 +662,15 @@ const MainTabs = () => { const AppNavigator = () => { const { currentTheme } = useTheme(); + // Handle Android-specific optimizations + useEffect(() => { + if (Platform.OS === 'android') { + // Ensure consistent background color for Android + StatusBar.setBackgroundColor('transparent', true); + StatusBar.setTranslucent(true); + } + }, []); + return ( <SafeAreaProvider> <StatusBar @@ -670,221 +679,307 @@ const AppNavigator = () => { barStyle="light-content" /> <PaperProvider theme={CustomDarkTheme}> - <Stack.Navigator - screenOptions={{ - headerShown: false, - // Disable animations for smoother transitions - animation: 'none', - // Ensure content is not popping in and out - contentStyle: { - backgroundColor: currentTheme.colors.darkBackground, - } - }} - > - <Stack.Screen - name="MainTabs" - component={MainTabs as any} - /> - <Stack.Screen - name="Metadata" - component={MetadataScreen} - options={{ headerShown: false, animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }} - /> - <Stack.Screen - name="Streams" - component={StreamsScreen as any} - options={{ - headerShown: false, - animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'fade_from_bottom', - ...(Platform.OS === 'ios' && { presentation: 'modal' }), - }} - /> - <Stack.Screen - name="Player" - component={VideoPlayer as any} - options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }} - /> - <Stack.Screen - name="Catalog" - component={CatalogScreen as any} - options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }} - /> - <Stack.Screen - name="Addons" - component={AddonsScreen as any} - options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }} - /> - <Stack.Screen - name="Search" - component={SearchScreen as any} - options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }} - /> - <Stack.Screen - name="CatalogSettings" - component={CatalogSettingsScreen as any} - options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }} - /> - <Stack.Screen - name="HomeScreenSettings" - component={HomeScreenSettings} - options={{ - animation: 'fade', - animationDuration: 200, - presentation: 'card', - gestureEnabled: true, - gestureDirection: 'horizontal', + <View style={{ + flex: 1, + backgroundColor: currentTheme.colors.darkBackground, + ...(Platform.OS === 'android' && { + // Prevent white flashes on Android + opacity: 1, + }) + }}> + <Stack.Navigator + screenOptions={{ headerShown: false, + // Use slide_from_right for consistency and smooth transitions + animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right', + animationDuration: Platform.OS === 'android' ? 250 : 300, + // Ensure consistent background during transitions contentStyle: { backgroundColor: currentTheme.colors.darkBackground, }, + // Improve Android performance with custom interpolator + ...(Platform.OS === 'android' && { + cardStyleInterpolator: ({ current, layouts }: any) => { + return { + cardStyle: { + transform: [ + { + translateX: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [layouts.screen.width, 0], + }), + }, + ], + backgroundColor: currentTheme.colors.darkBackground, + }, + }; + }, + }), }} - /> - <Stack.Screen - name="HeroCatalogs" - component={HeroCatalogsScreen} - options={{ - animation: 'fade', - animationDuration: 200, - presentation: 'card', - gestureEnabled: true, - gestureDirection: 'horizontal', - headerShown: false, - contentStyle: { - backgroundColor: currentTheme.colors.darkBackground, - }, - }} - /> - <Stack.Screen - name="ShowRatings" - component={ShowRatingsScreen} - options={{ - animation: 'fade', - animationDuration: 200, - ...(Platform.OS === 'ios' && { presentation: 'modal' }), - gestureEnabled: true, - gestureDirection: 'horizontal', - headerShown: false, - contentStyle: { - backgroundColor: 'transparent', - }, - }} - /> - <Stack.Screen - name="Calendar" - component={CalendarScreen as any} - options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }} - /> - <Stack.Screen - name="NotificationSettings" - component={NotificationSettingsScreen as any} - options={{ animation: Platform.OS === 'ios' ? 'slide_from_right' : 'default' }} - /> - <Stack.Screen - name="MDBListSettings" - component={MDBListSettingsScreen} - options={{ - animation: 'fade', - animationDuration: 200, - presentation: 'card', - gestureEnabled: true, - gestureDirection: 'horizontal', - headerShown: false, - contentStyle: { - backgroundColor: currentTheme.colors.darkBackground, - }, - }} - /> - <Stack.Screen - name="TMDBSettings" - component={TMDBSettingsScreen} - options={{ - animation: 'fade', - animationDuration: 200, - presentation: 'card', - gestureEnabled: true, - gestureDirection: 'horizontal', - headerShown: false, - contentStyle: { - backgroundColor: currentTheme.colors.darkBackground, - }, - }} - /> - <Stack.Screen - name="TraktSettings" - component={TraktSettingsScreen} - options={{ - animation: 'fade', - animationDuration: 200, - presentation: 'card', - gestureEnabled: true, - gestureDirection: 'horizontal', - headerShown: false, - contentStyle: { - backgroundColor: currentTheme.colors.darkBackground, - }, - }} - /> - <Stack.Screen - name="PlayerSettings" - component={PlayerSettingsScreen} - options={{ - animation: 'fade', - animationDuration: 200, - presentation: 'card', - gestureEnabled: true, - gestureDirection: 'horizontal', - headerShown: false, - contentStyle: { - backgroundColor: currentTheme.colors.darkBackground, - }, - }} - /> - <Stack.Screen - name="LogoSourceSettings" - component={LogoSourceSettings} - options={{ - animation: 'fade', - animationDuration: 200, - presentation: 'card', - gestureEnabled: true, - gestureDirection: 'horizontal', - headerShown: false, - contentStyle: { - backgroundColor: currentTheme.colors.darkBackground, - }, - }} - /> - <Stack.Screen - name="ThemeSettings" - component={ThemeScreen} - options={{ - animation: 'fade', - animationDuration: 200, - presentation: 'card', - gestureEnabled: true, - gestureDirection: 'horizontal', - headerShown: false, - contentStyle: { - backgroundColor: currentTheme.colors.darkBackground, - }, - }} - /> - <Stack.Screen - name="ProfilesSettings" - component={ProfilesScreen} - options={{ - animation: 'fade', - animationDuration: 200, - presentation: 'card', - gestureEnabled: true, - gestureDirection: 'horizontal', - headerShown: false, - contentStyle: { - backgroundColor: currentTheme.colors.darkBackground, - }, - }} - /> - </Stack.Navigator> + > + <Stack.Screen + name="MainTabs" + component={MainTabs as any} + options={{ + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="Metadata" + component={MetadataScreen} + options={{ + headerShown: false, + animation: 'slide_from_right', + animationDuration: Platform.OS === 'android' ? 250 : 300, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="Streams" + component={StreamsScreen as any} + options={{ + headerShown: false, + animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'fade_from_bottom', + animationDuration: Platform.OS === 'android' ? 200 : 300, + ...(Platform.OS === 'ios' && { presentation: 'modal' }), + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="Player" + component={VideoPlayer as any} + options={{ + animation: 'slide_from_right', + animationDuration: Platform.OS === 'android' ? 200 : 300, + contentStyle: { + backgroundColor: '#000000', // Pure black for video player + }, + }} + /> + <Stack.Screen + name="Catalog" + component={CatalogScreen as any} + options={{ + animation: 'slide_from_right', + animationDuration: Platform.OS === 'android' ? 250 : 300, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="Addons" + component={AddonsScreen as any} + options={{ + animation: 'slide_from_right', + animationDuration: Platform.OS === 'android' ? 250 : 300, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="Search" + component={SearchScreen as any} + options={{ + animation: 'slide_from_right', + animationDuration: Platform.OS === 'android' ? 250 : 300, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="CatalogSettings" + component={CatalogSettingsScreen as any} + options={{ + animation: 'slide_from_right', + animationDuration: Platform.OS === 'android' ? 250 : 300, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="HomeScreenSettings" + component={HomeScreenSettings} + options={{ + animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', + animationDuration: Platform.OS === 'android' ? 250 : 200, + presentation: 'card', + gestureEnabled: true, + gestureDirection: 'horizontal', + headerShown: false, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="HeroCatalogs" + component={HeroCatalogsScreen} + options={{ + animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', + animationDuration: Platform.OS === 'android' ? 250 : 200, + presentation: 'card', + gestureEnabled: true, + gestureDirection: 'horizontal', + headerShown: false, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="ShowRatings" + component={ShowRatingsScreen} + options={{ + animation: Platform.OS === 'android' ? 'fade_from_bottom' : 'fade', + animationDuration: Platform.OS === 'android' ? 200 : 200, + ...(Platform.OS === 'ios' && { presentation: 'modal' }), + gestureEnabled: true, + gestureDirection: 'horizontal', + headerShown: false, + contentStyle: { + backgroundColor: 'transparent', + }, + }} + /> + <Stack.Screen + name="Calendar" + component={CalendarScreen as any} + options={{ + animation: 'slide_from_right', + animationDuration: Platform.OS === 'android' ? 250 : 300, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="NotificationSettings" + component={NotificationSettingsScreen as any} + options={{ + animation: 'slide_from_right', + animationDuration: Platform.OS === 'android' ? 250 : 300, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="MDBListSettings" + component={MDBListSettingsScreen} + options={{ + animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', + animationDuration: Platform.OS === 'android' ? 250 : 200, + presentation: 'card', + gestureEnabled: true, + gestureDirection: 'horizontal', + headerShown: false, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="TMDBSettings" + component={TMDBSettingsScreen} + options={{ + animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', + animationDuration: Platform.OS === 'android' ? 250 : 200, + presentation: 'card', + gestureEnabled: true, + gestureDirection: 'horizontal', + headerShown: false, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="TraktSettings" + component={TraktSettingsScreen} + options={{ + animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', + animationDuration: Platform.OS === 'android' ? 250 : 200, + presentation: 'card', + gestureEnabled: true, + gestureDirection: 'horizontal', + headerShown: false, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="PlayerSettings" + component={PlayerSettingsScreen} + options={{ + animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', + animationDuration: Platform.OS === 'android' ? 250 : 200, + presentation: 'card', + gestureEnabled: true, + gestureDirection: 'horizontal', + headerShown: false, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="LogoSourceSettings" + component={LogoSourceSettings} + options={{ + animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', + animationDuration: Platform.OS === 'android' ? 250 : 200, + presentation: 'card', + gestureEnabled: true, + gestureDirection: 'horizontal', + headerShown: false, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="ThemeSettings" + component={ThemeScreen} + options={{ + animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', + animationDuration: Platform.OS === 'android' ? 250 : 200, + presentation: 'card', + gestureEnabled: true, + gestureDirection: 'horizontal', + headerShown: false, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + <Stack.Screen + name="ProfilesSettings" + component={ProfilesScreen} + options={{ + animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', + animationDuration: Platform.OS === 'android' ? 250 : 200, + presentation: 'card', + gestureEnabled: true, + gestureDirection: 'horizontal', + headerShown: false, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> + </Stack.Navigator> + </View> </PaperProvider> </SafeAreaProvider> ); diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 2cf547e..d4e5fad 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import { View, Text, @@ -24,11 +24,15 @@ import Animated, { useAnimatedStyle, interpolate, Extrapolate, + useSharedValue, + withTiming, + runOnJS, } from 'react-native-reanimated'; import { RouteProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { useSettings } from '../hooks/useSettings'; +import { MetadataLoadingScreen } from '../components/loading/MetadataLoadingScreen'; // Import our new components and hooks import HeroSection from '../components/metadata/HeroSection'; @@ -54,6 +58,11 @@ const MetadataScreen = () => { // Get safe area insets const { top: safeAreaTop } = useSafeAreaInsets(); + // Add transition state management + const [showContent, setShowContent] = useState(false); + const loadingOpacity = useSharedValue(1); + const contentOpacity = useSharedValue(0); + const { metadata, loading, @@ -91,6 +100,27 @@ const MetadataScreen = () => { const animations = useMetadataAnimations(safeAreaTop, watchProgress); + // Handle smooth transition from loading to content + useEffect(() => { + if (!loading && metadata && !showContent) { + // Delay content appearance slightly to ensure everything is ready + const timer = setTimeout(() => { + setShowContent(true); + + // Animate transition + loadingOpacity.value = withTiming(0, { duration: 300 }); + contentOpacity.value = withTiming(1, { duration: 300 }); + }, 100); + + return () => clearTimeout(timer); + } else if (loading && showContent) { + // Reset states when going back to loading + setShowContent(false); + loadingOpacity.value = 1; + contentOpacity.value = 0; + } + }, [loading, metadata, showContent]); + // Add wrapper for toggleLibrary that includes haptic feedback const handleToggleLibrary = useCallback(() => { // Trigger appropriate haptic feedback based on action @@ -165,45 +195,53 @@ const MetadataScreen = () => { navigation.goBack(); }, [navigation]); - // Animated styles + // Enhanced animated styles with sophisticated effects const containerAnimatedStyle = useAnimatedStyle(() => ({ flex: 1, - transform: [{ scale: animations.screenScale.value }], - opacity: animations.screenOpacity.value + transform: [ + { scale: animations.screenScale.value }, + { rotateZ: `${animations.heroRotate.value}deg` } + ], + opacity: animations.screenOpacity.value, })); const contentAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: animations.contentTranslateY.value }], + transform: [ + { translateY: animations.contentTranslateY.value }, + { scale: animations.contentScale.value } + ], opacity: interpolate( animations.contentTranslateY.value, - [60, 0], + [40, 0], [0, 1], Extrapolate.CLAMP ) })); - if (loading) { + // Enhanced loading screen animated style + const loadingAnimatedStyle = useAnimatedStyle(() => ({ + opacity: loadingOpacity.value, + transform: [ + { scale: interpolate(loadingOpacity.value, [1, 0], [1, 0.98]) } + ] + })); + + // Enhanced content animated style for transition + const contentTransitionStyle = useAnimatedStyle(() => ({ + opacity: contentOpacity.value, + transform: [ + { scale: interpolate(contentOpacity.value, [0, 1], [0.98, 1]) }, + { translateY: interpolate(contentOpacity.value, [0, 1], [10, 0]) } + ] + })); + + if (loading || !showContent) { return ( - <SafeAreaView - style={[styles.container, { - backgroundColor: currentTheme.colors.darkBackground - }]} - edges={['bottom']} - > - <StatusBar - translucent={true} - backgroundColor="transparent" - barStyle="light-content" + <Animated.View style={[StyleSheet.absoluteFill, loadingAnimatedStyle]}> + <MetadataLoadingScreen + type={metadata?.type === 'movie' ? 'movie' : 'series'} /> - <View style={styles.loadingContainer}> - <ActivityIndicator size="large" color={currentTheme.colors.primary} /> - <Text style={[styles.loadingText, { - color: currentTheme.colors.mediumEmphasis - }]}> - Loading content... - </Text> - </View> - </SafeAreaView> + </Animated.View> ); } @@ -263,119 +301,126 @@ const MetadataScreen = () => { } return ( - <SafeAreaView - style={[containerAnimatedStyle, styles.container, { - backgroundColor: currentTheme.colors.darkBackground - }]} - edges={['bottom']} - > - <StatusBar - translucent={true} - backgroundColor="transparent" - barStyle="light-content" - animated={true} - /> - <Animated.View style={containerAnimatedStyle}> - {/* Floating Header */} - <FloatingHeader - metadata={metadata} - logoLoadError={logoLoadError} - handleBack={handleBack} - handleToggleLibrary={handleToggleLibrary} - inLibrary={inLibrary} - headerOpacity={animations.headerOpacity} - headerElementsY={animations.headerElementsY} - headerElementsOpacity={animations.headerElementsOpacity} - safeAreaTop={safeAreaTop} - setLogoLoadError={setLogoLoadError} + <Animated.View style={[StyleSheet.absoluteFill, contentTransitionStyle]}> + <SafeAreaView + style={[containerAnimatedStyle, styles.container, { + backgroundColor: currentTheme.colors.darkBackground + }]} + edges={['bottom']} + > + <StatusBar + translucent={true} + backgroundColor="transparent" + barStyle="light-content" + animated={true} /> - - <Animated.ScrollView - style={styles.scrollView} - showsVerticalScrollIndicator={false} - onScroll={animations.scrollHandler} - scrollEventThrottle={16} - > - {/* Hero Section */} - <HeroSection + <Animated.View style={containerAnimatedStyle}> + {/* Floating Header */} + <FloatingHeader metadata={metadata} - bannerImage={bannerImage} - loadingBanner={loadingBanner} logoLoadError={logoLoadError} - scrollY={animations.scrollY} - dampedScrollY={animations.dampedScrollY} - heroHeight={animations.heroHeight} - heroOpacity={animations.heroOpacity} - heroScale={animations.heroScale} - logoOpacity={animations.logoOpacity} - logoScale={animations.logoScale} - genresOpacity={animations.genresOpacity} - genresTranslateY={animations.genresTranslateY} - buttonsOpacity={animations.buttonsOpacity} - buttonsTranslateY={animations.buttonsTranslateY} - watchProgressOpacity={animations.watchProgressOpacity} - watchProgressScaleY={animations.watchProgressScaleY} - watchProgress={watchProgress} - type={type as 'movie' | 'series'} - getEpisodeDetails={getEpisodeDetails} - handleShowStreams={handleShowStreams} + handleBack={handleBack} handleToggleLibrary={handleToggleLibrary} - inLibrary={inLibrary} - id={id} - navigation={navigation} - getPlayButtonText={getPlayButtonText} - setBannerImage={setBannerImage} + inLibrary={inLibrary} + headerOpacity={animations.headerOpacity} + headerElementsY={animations.headerElementsY} + headerElementsOpacity={animations.headerElementsOpacity} + safeAreaTop={safeAreaTop} setLogoLoadError={setLogoLoadError} - /> + /> - {/* Main Content */} - <Animated.View style={contentAnimatedStyle}> - {/* Metadata Details */} - <MetadataDetails + <Animated.ScrollView + style={styles.scrollView} + showsVerticalScrollIndicator={false} + onScroll={animations.scrollHandler} + scrollEventThrottle={16} + > + {/* Hero Section */} + <HeroSection metadata={metadata} - imdbId={imdbId} + bannerImage={bannerImage} + loadingBanner={loadingBanner} + logoLoadError={logoLoadError} + scrollY={animations.scrollY} + dampedScrollY={animations.dampedScrollY} + heroHeight={animations.heroHeight} + heroOpacity={animations.heroOpacity} + heroScale={animations.heroScale} + heroRotate={animations.heroRotate} + logoOpacity={animations.logoOpacity} + logoScale={animations.logoScale} + logoRotate={animations.logoRotate} + genresOpacity={animations.genresOpacity} + genresTranslateY={animations.genresTranslateY} + genresScale={animations.genresScale} + buttonsOpacity={animations.buttonsOpacity} + buttonsTranslateY={animations.buttonsTranslateY} + buttonsScale={animations.buttonsScale} + watchProgressOpacity={animations.watchProgressOpacity} + watchProgressScaleY={animations.watchProgressScaleY} + watchProgressWidth={animations.watchProgressWidth} + watchProgress={watchProgress} type={type as 'movie' | 'series'} - renderRatings={() => imdbId ? ( - <RatingsSection - imdbId={imdbId} - type={type === 'series' ? 'show' : 'movie'} - /> - ) : null} + getEpisodeDetails={getEpisodeDetails} + handleShowStreams={handleShowStreams} + handleToggleLibrary={handleToggleLibrary} + inLibrary={inLibrary} + id={id} + navigation={navigation} + getPlayButtonText={getPlayButtonText} + setBannerImage={setBannerImage} + setLogoLoadError={setLogoLoadError} /> - {/* Cast Section */} - <CastSection - cast={cast} - loadingCast={loadingCast} - onSelectCastMember={handleSelectCastMember} - /> - - {/* More Like This Section - Only for movies */} - {type === 'movie' && ( - <MoreLikeThisSection - recommendations={recommendations} - loadingRecommendations={loadingRecommendations} - /> - )} - - {/* Type-specific content */} - {type === 'series' ? ( - <SeriesContent - episodes={episodes} - selectedSeason={selectedSeason} - loadingSeasons={loadingSeasons} - onSeasonChange={handleSeasonChangeWithHaptics} - onSelectEpisode={handleEpisodeSelect} - groupedEpisodes={groupedEpisodes} + {/* Main Content */} + <Animated.View style={contentAnimatedStyle}> + {/* Metadata Details */} + <MetadataDetails metadata={metadata} + imdbId={imdbId} + type={type as 'movie' | 'series'} + renderRatings={() => imdbId ? ( + <RatingsSection + imdbId={imdbId} + type={type === 'series' ? 'show' : 'movie'} + /> + ) : null} /> - ) : ( - <MovieContent metadata={metadata} /> - )} - </Animated.View> - </Animated.ScrollView> - </Animated.View> - </SafeAreaView> + + {/* Cast Section */} + <CastSection + cast={cast} + loadingCast={loadingCast} + onSelectCastMember={handleSelectCastMember} + /> + + {/* More Like This Section - Only for movies */} + {type === 'movie' && ( + <MoreLikeThisSection + recommendations={recommendations} + loadingRecommendations={loadingRecommendations} + /> + )} + + {/* Type-specific content */} + {type === 'series' ? ( + <SeriesContent + episodes={episodes} + selectedSeason={selectedSeason} + loadingSeasons={loadingSeasons} + onSeasonChange={handleSeasonChangeWithHaptics} + onSelectEpisode={handleEpisodeSelect} + groupedEpisodes={groupedEpisodes} + metadata={metadata} + /> + ) : ( + <MovieContent metadata={metadata} /> + )} + </Animated.View> + </Animated.ScrollView> + </Animated.View> + </SafeAreaView> + </Animated.View> ); }; diff --git a/src/screens/VideoPlayer.tsx b/src/screens/VideoPlayer.tsx deleted file mode 100644 index df7c968..0000000 --- a/src/screens/VideoPlayer.tsx +++ /dev/null @@ -1,2939 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { View, TouchableOpacity, StyleSheet, Text, Dimensions, Modal, Pressable, StatusBar, Platform, ScrollView, Animated, ActivityIndicator, Image } from 'react-native'; -import { VLCPlayer } from 'react-native-vlc-media-player'; -import { Ionicons } from '@expo/vector-icons'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useSharedValue, runOnJS, withTiming } from 'react-native-reanimated'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { RootStackParamList } from '../navigation/AppNavigator'; -// Add Gesture Handler imports for pinch zoom -import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'; -// Import for navigation bar hiding -import { NativeModules } from 'react-native'; -// Import immersive mode package -import RNImmersiveMode from 'react-native-immersive-mode'; -// Import screen orientation lock -import * as ScreenOrientation from 'expo-screen-orientation'; -// Import storage service for progress tracking -import { storageService } from '../services/storageService'; -import { logger } from '../utils/logger'; -import AsyncStorage from '@react-native-async-storage/async-storage'; - -// Debug flag - set back to false to disable verbose logging -// WARNING: Setting this to true currently causes infinite render loops -// Use selective logging instead if debugging is needed -const DEBUG_MODE = true; - -// Safer debug function that won't cause render loops -// Call this with any debugging info you need instead of using inline DEBUG_MODE checks -const safeDebugLog = (message: string, data?: any) => { - // This function only runs once per call site, avoiding render loops - // eslint-disable-next-line react-hooks/rules-of-hooks - useEffect(() => { - if (DEBUG_MODE) { - if (data) { - logger.log(`[VideoPlayer] ${message}`, data); - } else { - logger.log(`[VideoPlayer] ${message}`); - } - } - }, []); // Empty dependency array means this only runs once per mount -}; - -// Constants for resume preferences - add after type definitions -const RESUME_PREF_KEY = '@video_resume_preference'; -const RESUME_PREF = { - ALWAYS_ASK: 'always_ask', - ALWAYS_RESUME: 'always_resume', - ALWAYS_START_OVER: 'always_start_over' -}; - -// Define the TrackPreferenceType for audio/text tracks -type TrackPreferenceType = 'system' | 'disabled' | 'title' | 'language' | 'index'; - -// Define the SelectedTrack type for audio/text tracks -interface SelectedTrack { - type: TrackPreferenceType; - value?: string | number; // value is optional for 'system' and 'disabled' -} - -interface VideoPlayerProps { - uri: string; - title?: string; - season?: number; - episode?: number; - episodeTitle?: string; - quality?: string; - year?: number; - streamProvider?: string; - id?: string; - type?: string; - episodeId?: string; - imdbId?: string; // Add IMDb ID for subtitle fetching -} - -// Match the react-native-video AudioTrack type -interface AudioTrack { - index: number; - title?: string; - language?: string; - bitrate?: number; - type?: string; - selected?: boolean; -} - -// Define TextTrack interface based on react-native-video expected structure -interface TextTrack { - index: number; - title?: string; - language?: string; - type?: string | null; // Adjusting type based on linter error -} - -// Define the possible resize modes - force to stretch for absolute full screen -type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none' | 'stretch'; -const resizeModes: ResizeModeType[] = ['stretch']; // Force stretch mode for absolute full screen - -// Add language code to name mapping -const languageMap: {[key: string]: string} = { - 'en': 'English', - 'eng': 'English', - 'es': 'Spanish', - 'spa': 'Spanish', - 'fr': 'French', - 'fre': 'French', - 'de': 'German', - 'ger': 'German', - 'it': 'Italian', - 'ita': 'Italian', - 'ja': 'Japanese', - 'jpn': 'Japanese', - 'ko': 'Korean', - 'kor': 'Korean', - 'zh': 'Chinese', - 'chi': 'Chinese', - 'ru': 'Russian', - 'rus': 'Russian', - 'pt': 'Portuguese', - 'por': 'Portuguese', - 'hi': 'Hindi', - 'hin': 'Hindi', - 'ar': 'Arabic', - 'ara': 'Arabic', - 'nl': 'Dutch', - 'dut': 'Dutch', - 'sv': 'Swedish', - 'swe': 'Swedish', - 'no': 'Norwegian', - 'nor': 'Norwegian', - 'fi': 'Finnish', - 'fin': 'Finnish', - 'da': 'Danish', - 'dan': 'Danish', - 'pl': 'Polish', - 'pol': 'Polish', - 'tr': 'Turkish', - 'tur': 'Turkish', - 'cs': 'Czech', - 'cze': 'Czech', - 'hu': 'Hungarian', - 'hun': 'Hungarian', - 'el': 'Greek', - 'gre': 'Greek', - 'th': 'Thai', - 'tha': 'Thai', - 'vi': 'Vietnamese', - 'vie': 'Vietnamese', -}; - -// Function to format language code to readable name -const formatLanguage = (code?: string): string => { - if (!code) return 'Unknown'; - const normalized = code.toLowerCase(); - const languageName = languageMap[normalized] || code.toUpperCase(); - - // Debug logs removed to prevent render loops - - // If the result is still the uppercased code, it means we couldn't find it in our map. - if (languageName === code.toUpperCase()) { - return `Unknown (${code})`; - } - - return languageName; -}; - -// Add VLC specific interface for their event structure -interface VlcMediaEvent { - currentTime: number; - duration: number; - bufferTime?: number; - isBuffering?: boolean; - audioTracks?: Array<{id: number, name: string, language?: string}>; - textTracks?: Array<{id: number, name: string, language?: string}>; - selectedAudioTrack?: number; - selectedTextTrack?: number; -} - -// Helper function to extract a display name from the track's name property -const getTrackDisplayName = (track: { name?: string, id: number }): string => { - if (!track || !track.name) return `Track ${track.id}`; - - // Try to extract language from name like "Some Info - [English]" - const languageMatch = track.name.match(/\[(.*?)\]/); - if (languageMatch && languageMatch[1]) { - return languageMatch[1]; - } - - // If no language in brackets, or if the name is simple, use the full name - return track.name; -}; - -// Add subtitle-related constants and types -const SUBTITLE_SIZE_KEY = '@subtitle_size_preference'; -const DEFAULT_SUBTITLE_SIZE = 16; - -interface SubtitleCue { - start: number; - end: number; - text: string; -} - -// Add interface for Wyzie subtitle API response -interface WyzieSubtitle { - id: string; - url: string; - flagUrl: string; - format: string; - encoding: string; - media: string; - display: string; - language: string; - isHearingImpaired: boolean; - source: string; -} - -const VideoPlayer: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute<RouteProp<RootStackParamList, 'Player'>>(); - - // Extract props from route.params - const { - uri, - title = 'Episode Name', - season, - episode, - episodeTitle, - quality, - year, - streamProvider, - id, - type, - episodeId, - imdbId - } = route.params; - - // Use safer debug logging for props - safeDebugLog("Component mounted with props", { - uri, title, season, episode, episodeTitle, quality, year, - streamProvider, id, type, episodeId, imdbId - }); - - // Get exact screen dimensions - const screenData = Dimensions.get('screen'); // Use 'screen' instead of 'window' to include system UI areas - const [screenDimensions, setScreenDimensions] = useState(screenData); - - const [paused, setPaused] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const [showControls, setShowControls] = useState(true); - const [playbackSpeed, setPlaybackSpeed] = useState(1); - const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]); - const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null); - const [textTracks, setTextTracks] = useState<TextTrack[]>([]); - const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1); // Use -1 for "disabled" - const [resizeMode, setResizeMode] = useState<ResizeModeType>('stretch'); // Force stretch mode for absolute full screen - const [buffered, setBuffered] = useState(0); // Add buffered state - const vlcRef = useRef<any>(null); - const progress = useSharedValue(0); - const min = useSharedValue(0); - const max = useSharedValue(duration); - const [showAudioModal, setShowAudioModal] = useState(false); - const [showSubtitleModal, setShowSubtitleModal] = useState(false); - - // Add state for tracking initial position to seek to - const [initialPosition, setInitialPosition] = useState<number | null>(null); - const [progressSaveInterval, setProgressSaveInterval] = useState<NodeJS.Timeout | null>(null); - const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false); - - // Add state for showing resume overlay - const [showResumeOverlay, setShowResumeOverlay] = useState(false); - const [resumePosition, setResumePosition] = useState<number | null>(null); - - // Add state for remembering choice - const [rememberChoice, setRememberChoice] = useState(false); - const [resumePreference, setResumePreference] = useState<string | null>(null); - - // Add animated value for controls opacity - const fadeAnim = useRef(new Animated.Value(1)).current; - - // Add opening animation states and values - const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); - const openingFadeAnim = useRef(new Animated.Value(0)).current; - const openingScaleAnim = useRef(new Animated.Value(0.8)).current; - const backgroundFadeAnim = useRef(new Animated.Value(1)).current; - - // Add VLC specific state and refs - const [isBuffering, setIsBuffering] = useState(false); - - // Modify audio tracks handling for VLC - const [vlcAudioTracks, setVlcAudioTracks] = useState<Array<{id: number, name: string, language?: string}>>([]); - const [vlcTextTracks, setVlcTextTracks] = useState<Array<{id: number, name: string, language?: string}>>([]); - - // Add a new state to track if the player is ready for seeking - const [isPlayerReady, setIsPlayerReady] = useState(false); - - // Animated value for smooth progress bar - const progressAnim = useRef(new Animated.Value(0)).current; - - // Add ref for progress bar container to measure its width - const progressBarRef = useRef<View>(null); - - // Add state for progress bar touch tracking - const [isDragging, setIsDragging] = useState(false); - - // Add a ref for debouncing seek operations - const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null); - const pendingSeekValue = useRef<number | null>(null); - const lastSeekTime = useRef<number>(0); - - // Add state for tracking if the video is loaded - const [isVideoLoaded, setIsVideoLoaded] = useState(false); - - // Add state for tracking video aspect ratio - const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null); - const [is16by9Content, setIs16by9Content] = useState(false); - const [customVideoStyles, setCustomVideoStyles] = useState<any>({}); - - // Add zoom state for pinch gesture - const [zoomScale, setZoomScale] = useState(1); - const [zoomTranslateX, setZoomTranslateX] = useState(0); - const [zoomTranslateY, setZoomTranslateY] = useState(0); - const [lastZoomScale, setLastZoomScale] = useState(1); - const [lastTranslateX, setLastTranslateX] = useState(0); - const [lastTranslateY, setLastTranslateY] = useState(0); - const pinchRef = useRef<PinchGestureHandler>(null); - - // Add subtitle-related state - const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]); - const [currentSubtitle, setCurrentSubtitle] = useState<string>(''); - const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE); - const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false); - const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false); - - // Add Wyzie subtitle states - const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]); - const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState<boolean>(false); - const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false); - - // Calculate custom video styles based on aspect ratios - simplified approach - const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { - // Always return full screen styles - let VLC resize modes handle the rest - return { - position: 'absolute', - top: 0, - left: 0, - width: screenWidth, - height: screenHeight, - backgroundColor: '#000', - }; - }; - - // Pinch gesture handler for zoom functionality - center zoom only, no panning - const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => { - const { scale } = event.nativeEvent; - - // Calculate new scale (limit between 1x and 1.1x) - const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1)); - - // Only apply scale, no translation - always zoom from center - setZoomScale(newScale); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`); - } - }; - - const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => { - if (event.nativeEvent.state === State.END) { - // Save the current scale as the new baseline, no translation - setLastZoomScale(zoomScale); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`); - } - } - }; - - // Reset zoom to appropriate level (1.1x for 16:9, 1x for others) - const resetZoom = () => { - const targetZoom = is16by9Content ? 1.1 : 1; - - setZoomScale(targetZoom); - setLastZoomScale(targetZoom); - // No translation needed for center zoom - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`); - } - }; - - // Recalculate video styles when screen dimensions change - useEffect(() => { - if (videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) { - const styles = calculateVideoStyles( - videoAspectRatio * 1000, // Reconstruct width from aspect ratio - 1000, // Use 1000 as base height - screenDimensions.width, - screenDimensions.height - ); - setCustomVideoStyles(styles); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Screen dimensions changed, recalculated styles:`, styles); - } - } - }, [screenDimensions, videoAspectRatio]); - - // Lock screen to landscape when component mounts - useEffect(() => { - // Update screen dimensions when they change (orientation changes) - const subscription = Dimensions.addEventListener('change', ({ screen }) => { - setScreenDimensions(screen); - }); - - // Since orientation is now locked before navigation, we can start immediately - const initializePlayer = () => { - // Force StatusBar to be completely hidden - StatusBar.setHidden(true, 'none'); - - // Enable immersive mode with more aggressive settings - enableImmersiveMode(); - - // Start the opening animation immediately - startOpeningAnimation(); - }; - - initializePlayer(); - - // Restore screen orientation and disable immersive mode when component unmounts - return () => { - subscription?.remove(); - const unlockOrientation = async () => { - await ScreenOrientation.unlockAsync(); - }; - unlockOrientation(); - disableImmersiveMode(); - }; - }, []); - - // Opening animation sequence - modified to wait for video load - const startOpeningAnimation = () => { - // Keep everything black until video loads - // Only show loading indicator, no video player fade-in yet - // Note: All animations will be triggered by onLoad when video is ready - }; - - // Complete the opening animation when video loads - const completeOpeningAnimation = () => { - // Start all animations together when video is ready - Animated.parallel([ - // Fade in the video player - Animated.timing(openingFadeAnim, { - toValue: 1, - duration: 600, - useNativeDriver: true, - }), - // Scale up from 80% to 100% and ensure it stays at 100% - Animated.timing(openingScaleAnim, { - toValue: 1, - duration: 700, - useNativeDriver: true, - }), - // Fade out the black background overlay - Animated.timing(backgroundFadeAnim, { - toValue: 0, - duration: 800, - useNativeDriver: true, - }), - ]).start(() => { - // Animation is complete - ensure scale is exactly 1 - openingScaleAnim.setValue(1); - openingFadeAnim.setValue(1); - setIsOpeningAnimationComplete(true); - - // Hide the background overlay completely after animation - setTimeout(() => { - backgroundFadeAnim.setValue(0); - }, 100); - }); - }; - - // Load saved watch progress on mount - useEffect(() => { - const loadWatchProgress = async () => { - if (id && type) { - try { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Checking for saved progress with id=${id}, type=${type}, episodeId=${episodeId || 'none'}`); - } - const savedProgress = await storageService.getWatchProgress(id, type, episodeId); - - if (savedProgress) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Found saved progress:`, savedProgress); - } - - if (savedProgress.currentTime > 0) { - // Only auto-resume if less than 95% watched (not effectively complete) - const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Progress percent: ${progressPercent.toFixed(2)}%`); - } - - if (progressPercent < 95) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Setting initial position to ${savedProgress.currentTime}`); - } - // Set resume position - setResumePosition(savedProgress.currentTime); - - // Check for saved preference - const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); - if (pref === RESUME_PREF.ALWAYS_RESUME) { - setInitialPosition(savedProgress.currentTime); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-resuming based on saved preference`); - } - } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { - setInitialPosition(0); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-starting from beginning based on saved preference`); - } - } else { - // Only show resume overlay if no preference or ALWAYS_ASK - setShowResumeOverlay(true); - } - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Progress >= 95%, starting from beginning`); - } - } - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] No saved progress found`); - } - } catch (error) { - logger.error('[VideoPlayer] Error loading watch progress:', error); - } - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Missing id or type, can't load progress. id=${id}, type=${type}`); - } - }; - - loadWatchProgress(); - }, [id, type, episodeId]); - - // Set up interval to save watch progress periodically (every 5 seconds) - useEffect(() => { - if (id && type && !paused && duration > 0) { - // Clear any existing interval - if (progressSaveInterval) { - clearInterval(progressSaveInterval); - } - - // Set up new interval to save progress - const interval = setInterval(() => { - saveWatchProgress(); - }, 5000); - - setProgressSaveInterval(interval); - - // Clean up interval on pause or unmount - return () => { - clearInterval(interval); - setProgressSaveInterval(null); - }; - } - }, [id, type, paused, currentTime, duration]); - - // Save progress one more time when component unmounts - useEffect(() => { - return () => { - if (id && type && duration > 0) { - saveWatchProgress(); - } - }; - }, [id, type, currentTime, duration]); - - // Function to save watch progress - const saveWatchProgress = async () => { - if (id && type && currentTime > 0 && duration > 0) { - const progress = { - currentTime, - duration, - lastUpdated: Date.now() - }; - - try { - await storageService.setWatchProgress(id, type, progress, episodeId); - } catch (error) { - logger.error('[VideoPlayer] Error saving watch progress:', error); - } - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Cannot save progress: id=${id}, type=${type}, currentTime=${currentTime}, duration=${duration}`); - } - }; - - useEffect(() => { - max.value = duration; - }, [duration]); - - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - if (hours > 0) { - return `${hours}:${mins < 10 ? '0' : ''}${mins}:${secs < 10 ? '0' : ''}${secs}`; - } else { - return `${mins}:${secs < 10 ? '0' : ''}${secs}`; - } - }; - - // Simplify the seekToTime function to use VLC's direct methods - const seekToTime = (timeInSeconds: number) => { - if (!isPlayerReady || duration <= 0 || !vlcRef.current) return; - - // Calculate normalized position (0-1) for VLC - const normalizedPosition = Math.max(0, Math.min(timeInSeconds / duration, 1)); - - try { - // Use VLC's direct setPosition method - if (typeof vlcRef.current.setPosition === 'function') { - vlcRef.current.setPosition(normalizedPosition); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Called setPosition with ${normalizedPosition} for time: ${timeInSeconds}s`); - } - } else if (typeof vlcRef.current.seek === 'function') { - // Fallback to seek method if available - vlcRef.current.seek(normalizedPosition); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Called seek with ${normalizedPosition} for time: ${timeInSeconds}s`); - } - } else { - logger.error('[VideoPlayer] No seek method available on VLC player'); - } - } catch (error) { - logger.error('[VideoPlayer] Error during seek operation:', error); - } - }; - - // Enhanced progress bar touch handling with drag support - const handleProgressBarTouch = (event: any) => { - if (!duration || duration <= 0) return; - - const { locationX } = event.nativeEvent; - processProgressTouch(locationX); - }; - - const handleProgressBarDragStart = () => { - setIsDragging(true); - }; - - const handleProgressBarDragMove = (event: any) => { - if (!isDragging || !duration || duration <= 0) return; - - const { locationX } = event.nativeEvent; - processProgressTouch(locationX, true); // Pass true to indicate dragging - }; - - const handleProgressBarDragEnd = () => { - setIsDragging(false); - // Apply the final seek when drag ends - if (pendingSeekValue.current !== null) { - seekToTime(pendingSeekValue.current); - pendingSeekValue.current = null; - } - }; - - // Helper function to process touch position and seek - const processProgressTouch = (locationX: number, isDragging = false) => { - progressBarRef.current?.measure((x, y, width, height, pageX, pageY) => { - // Calculate percentage of touch position relative to progress bar width - const percentage = Math.max(0, Math.min(locationX / width, 1)); - // Calculate time to seek to - const seekTime = percentage * duration; - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Progress touch: ${seekTime.toFixed(1)}s (${(percentage * 100).toFixed(1)}%)`); - } - - // Update the visual progress immediately - progress.value = seekTime; - progressAnim.setValue(percentage); - - // If dragging, update currentTime for visual feedback but don't seek yet - if (isDragging) { - pendingSeekValue.current = seekTime; - setCurrentTime(seekTime); - } else { - // If it's a tap (not dragging), seek immediately - seekToTime(seekTime); - } - }); - }; - - // Update the handleProgress function to not update progress while dragging - const handleProgress = (event: any) => { - if (isDragging) return; // Don't update progress while user is dragging - - const currentTimeInSeconds = event.currentTime / 1000; // VLC gives time in milliseconds - - // Always update state - let VLC manage the timing - if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { - safeSetState(() => setCurrentTime(currentTimeInSeconds)); - progress.value = currentTimeInSeconds; - - // Animate the progress bar smoothly - const progressPercent = duration > 0 ? currentTimeInSeconds / duration : 0; - Animated.timing(progressAnim, { - toValue: progressPercent, - duration: 250, - useNativeDriver: false, - }).start(); - - // Update buffered position - const bufferedTime = event.bufferTime / 1000 || currentTimeInSeconds; - safeSetState(() => setBuffered(bufferedTime)); - } - }; - - // Enhanced onLoad handler to detect aspect ratio and mark player as ready - const onLoad = (data: any) => { - setDuration(data.duration / 1000); // VLC returns duration in milliseconds - max.value = data.duration / 1000; - - // Calculate and detect aspect ratio with custom styling - if (data.videoSize && data.videoSize.width && data.videoSize.height) { - const aspectRatio = data.videoSize.width / data.videoSize.height; - setVideoAspectRatio(aspectRatio); - - // Check if it's 16:9 content (1.777... ≈ 16/9) - const is16x9 = Math.abs(aspectRatio - (16/9)) < 0.1; - setIs16by9Content(is16x9); - - // Auto-zoom 16:9 content to 1.1x to fill more screen - if (is16x9) { - setZoomScale(1.1); - setLastZoomScale(1.1); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-zoomed 16:9 content to 1.1x`); - } - } else { - // Reset zoom for non-16:9 content - setZoomScale(1); - setLastZoomScale(1); - } - - // Calculate custom video styles for precise control - const styles = calculateVideoStyles( - data.videoSize.width, - data.videoSize.height, - screenDimensions.width, - screenDimensions.height - ); - setCustomVideoStyles(styles); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Video aspect ratio: ${aspectRatio.toFixed(3)} (16:9: ${is16x9})`); - logger.log(`[VideoPlayer] Applied custom styles:`, styles); - } - } else { - // Fallback: assume 16:9 and apply default styles with auto-zoom - setIs16by9Content(true); - setZoomScale(1.1); - setLastZoomScale(1.1); - const defaultStyles = { - position: 'absolute', - top: 0, - left: 0, - width: screenDimensions.width, - height: screenDimensions.height, - }; - setCustomVideoStyles(defaultStyles); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Could not detect video size, using default 16:9 styles with 1.1x zoom`); - } - } - - // Mark player as ready for seeking - setIsPlayerReady(true); - - // Get audio and subtitle tracks from onLoad data - const audioTracksFromLoad = data.audioTracks || []; - const textTracksFromLoad = data.textTracks || []; - setVlcAudioTracks(audioTracksFromLoad); - setVlcTextTracks(textTracksFromLoad); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Video loaded with duration: ${data.duration / 1000}`); - logger.log(`[VideoPlayer] Screen dimensions: ${screenDimensions.width}x${screenDimensions.height}`); - logger.log(`[VideoPlayer] VLC Player custom styles applied`); - const methods = Object.keys(vlcRef.current || {}).filter( - key => typeof vlcRef.current[key] === 'function' - ); - logger.log('[VideoPlayer] Available VLC methods:', methods); - - // Log track-related methods specifically - const trackMethods = methods.filter(method => - method.toLowerCase().includes('track') || - method.toLowerCase().includes('audio') || - method.toLowerCase().includes('subtitle') || - method.toLowerCase().includes('text') - ); - logger.log('[VideoPlayer] Track-related VLC methods:', trackMethods); - - logger.log('[VideoPlayer] Available audio tracks:', audioTracksFromLoad); - logger.log('[VideoPlayer] Available subtitle tracks:', textTracksFromLoad); - } - - // Set default selected tracks - if (audioTracksFromLoad.length > 1) { // More than just "Disable" - const firstEnabledAudio = audioTracksFromLoad.find((t: any) => t.id !== -1); - if(firstEnabledAudio) { - setSelectedAudioTrack(firstEnabledAudio.id); - } - } else if (audioTracksFromLoad.length > 0) { - setSelectedAudioTrack(audioTracksFromLoad[0].id); - } - // Subtitles default to disabled (-1) - - // Auto-search for English subtitles if IMDb ID is available - if (imdbId && !customSubtitles.length) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-searching for English subtitles with IMDb ID: ${imdbId}`); - } - setTimeout(() => { - fetchAvailableSubtitles(imdbId, true); // true for autoSelectEnglish - }, 2000); // Delay to let video start playing first - } - - // If we have an initial position to seek to, do it now - if (initialPosition !== null && !isInitialSeekComplete) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Will seek to saved position: ${initialPosition}`); - } - - // Seek with a short delay to ensure video is ready - setTimeout(() => { - if (vlcRef.current && duration > 0 && isMounted.current) { - seekToTime(initialPosition); - setIsInitialSeekComplete(true); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Initial seek completed to position: ${initialPosition}s`); - } - } - }, 1000); - } - - // Mark video as loaded and complete opening animation - setIsVideoLoaded(true); - completeOpeningAnimation(); - }; - - const skip = (seconds: number) => { - if (vlcRef.current) { - const newTime = Math.max(0, Math.min(currentTime + seconds, duration)); - seekToTime(newTime); - } - }; - - const onAudioTracks = (data: { audioTracks: AudioTrack[] }) => { - const tracks = data.audioTracks || []; - setAudioTracks(tracks); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Available audio tracks:`, tracks); - } - }; - - const onTextTracks = (e: Readonly<{ textTracks: TextTrack[] }>) => { - const tracks = e.textTracks || []; - setTextTracks(tracks); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Available subtitle tracks:`, tracks); - } - }; - - // Custom aspect ratio control - now toggles between 1x and 1.1x zoom - const cycleAspectRatio = () => { - const newZoom = zoomScale === 1.1 ? 1 : 1.1; - - setZoomScale(newZoom); - setZoomTranslateX(0); - setZoomTranslateY(0); - setLastZoomScale(newZoom); - setLastTranslateX(0); - setLastTranslateY(0); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Toggled zoom to ${newZoom}x`); - } - }; - - // Enhanced immersive mode function - const enableImmersiveMode = () => { - // Force hide status bar immediately without animation - StatusBar.setHidden(true, 'none'); - - if (Platform.OS === 'android') { - // Use multiple methods to ensure complete immersion - try { - // Method 1: RNImmersiveMode - RNImmersiveMode.setBarMode('FullSticky'); - RNImmersiveMode.fullLayout(true); - - // Method 2: Additional native module call if available - if (NativeModules.StatusBarManager) { - NativeModules.StatusBarManager.setHidden(true); - } - } catch (error) { - console.log('Immersive mode error:', error); - } - } - - // For iOS, ensure status bar is hidden - if (Platform.OS === 'ios') { - StatusBar.setHidden(true, 'none'); - } - }; - - // Function to disable immersive mode - const disableImmersiveMode = () => { - StatusBar.setHidden(false); - - if (Platform.OS === 'android') { - // Restore normal mode using setBarMode - RNImmersiveMode.setBarMode('Normal'); - - // Alternative: disable fullLayout - RNImmersiveMode.fullLayout(false); - } - }; - - // Function to handle closing the video player - const handleClose = () => { - // First unlock the screen orientation - const unlockOrientation = async () => { - await ScreenOrientation.unlockAsync(); - }; - unlockOrientation(); - - // Disable immersive mode - disableImmersiveMode(); - - // Navigate back - navigation.goBack(); - }; - - // Add debug logs for modal visibility - useEffect(() => { - if (showAudioModal && DEBUG_MODE) { - logger.log("[VideoPlayer] Audio modal should be visible now"); - logger.log("[VideoPlayer] Available audio tracks:", audioTracks); - } - }, [showAudioModal, audioTracks]); - - useEffect(() => { - if (showSubtitleModal && DEBUG_MODE) { - logger.log("[VideoPlayer] Subtitle modal should be visible now"); - logger.log("[VideoPlayer] Available text tracks:", textTracks); - } - }, [showSubtitleModal, textTracks]); - - // Load resume preference on mount - useEffect(() => { - const loadResumePreference = async () => { - try { - const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); - if (pref) { - setResumePreference(pref); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Loaded resume preference: ${pref}`); - } - - // If user has a preference, apply it automatically - if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) { - setShowResumeOverlay(false); - setInitialPosition(resumePosition); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-resuming based on saved preference`); - } - } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { - setShowResumeOverlay(false); - setInitialPosition(0); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-starting from beginning based on saved preference`); - } - } - } - } catch (error) { - logger.error('[VideoPlayer] Error loading resume preference:', error); - } - }; - - loadResumePreference(); - }, [resumePosition]); - - // Reset resume preference - const resetResumePreference = async () => { - try { - await AsyncStorage.removeItem(RESUME_PREF_KEY); - setResumePreference(null); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Reset resume preference`); - } - } catch (error) { - logger.error('[VideoPlayer] Error resetting resume preference:', error); - } - }; - - // Handle resume from overlay - modified for VLC - const handleResume = async () => { - if (resumePosition !== null && vlcRef.current) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Resuming from ${resumePosition}`); - } - - // Save preference if remember choice is checked - if (rememberChoice) { - try { - await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Saved resume preference: ${RESUME_PREF.ALWAYS_RESUME}`); - } - } catch (error) { - logger.error('[VideoPlayer] Error saving resume preference:', error); - } - } - - // Set initial position to trigger seek - setInitialPosition(resumePosition); - // Hide overlay - setShowResumeOverlay(false); - - // Seek to position with VLC - setTimeout(() => { - if (vlcRef.current) { - seekToTime(resumePosition); - } - }, 500); - } - }; - - // Handle start from beginning - modified for VLC - const handleStartFromBeginning = async () => { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Starting from beginning`); - } - - // Save preference if remember choice is checked - if (rememberChoice) { - try { - await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_START_OVER); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Saved resume preference: ${RESUME_PREF.ALWAYS_START_OVER}`); - } - } catch (error) { - logger.error('[VideoPlayer] Error saving resume preference:', error); - } - } - - // Hide overlay - setShowResumeOverlay(false); - // Set initial position to 0 - setInitialPosition(0); - // Make sure we seek to beginning - if (vlcRef.current) { - seekToTime(0); - setCurrentTime(0); - progress.value = 0; - } - }; - - // Update the showControls logic to include animation - const toggleControls = () => { - // Start fade animation - Animated.timing(fadeAnim, { - toValue: showControls ? 0 : 1, - duration: 300, - useNativeDriver: true, - }).start(); - - // Update state - setShowControls(!showControls); - }; - - // Handle VLC errors - const handleError = (error: any) => { - logger.error('[VideoPlayer] Playback Error:', error); - // Optionally, you could show an error message to the user here - }; - - // Handle VLC buffering - const onBuffering = (event: any) => { - setIsBuffering(event.isBuffering); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Buffering: ${event.isBuffering}`); - } - }; - - // Handle VLC playback ended - const onEnd = () => { - // Your existing playback ended logic here - }; - - // Function to select audio track in VLC - const selectAudioTrack = (trackId: number) => { - setSelectedAudioTrack(trackId); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Selected audio track ID: ${trackId}`); - } - }; - - // Function to select subtitle track in VLC - const selectTextTrack = (trackId: number) => { - if (trackId === -999) { // Special ID for custom subtitles - setUseCustomSubtitles(true); - setSelectedTextTrack(-1); // Disable VLC subtitles - } else { - setUseCustomSubtitles(false); - setSelectedTextTrack(trackId); - } - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Selected subtitle track ID: ${trackId}, custom: ${trackId === -999}`); - } - }; - - // Update subtitle modal to use VLC subtitle tracks - const renderSubtitleModal = () => { - if (!showSubtitleModal) return null; - - return ( - <View style={styles.fullscreenOverlay}> - <View style={styles.modernModalContainer}> - <View style={styles.modernModalHeader}> - <Text style={styles.modernModalTitle}>Subtitle Settings</Text> - <TouchableOpacity - style={styles.modernCloseButton} - onPress={() => setShowSubtitleModal(false)} - > - <Ionicons name="close" size={24} color="white" /> - </TouchableOpacity> - </View> - - <ScrollView style={styles.modernTrackListScrollContainer} showsVerticalScrollIndicator={false}> - <View style={styles.modernTrackListContainer}> - - {/* External Subtitles Section - Priority */} - <View style={styles.sectionContainer}> - <Text style={styles.sectionTitle}>External Subtitles</Text> - <Text style={styles.sectionDescription}>High quality subtitles with size control</Text> - - {/* Custom subtitles option - show if loaded */} - {customSubtitles.length > 0 ? ( - <TouchableOpacity - style={[styles.modernTrackItem, useCustomSubtitles && styles.modernSelectedTrackItem]} - onPress={() => { - selectTextTrack(-999); - setShowSubtitleModal(false); - }} - > - <View style={styles.trackIconContainer}> - <Ionicons name="document-text" size={20} color="#4CAF50" /> - </View> - <View style={styles.modernTrackInfoContainer}> - <Text style={styles.modernTrackPrimaryText}>Custom Subtitles</Text> - <Text style={styles.modernTrackSecondaryText}> - {customSubtitles.length} cues • Size adjustable - </Text> - </View> - {useCustomSubtitles && ( - <View style={styles.modernSelectedIndicator}> - <Ionicons name="checkmark-circle" size={24} color="#4CAF50" /> - </View> - )} - </TouchableOpacity> - ) : null} - - {/* Search for external subtitles */} - <TouchableOpacity - style={styles.searchSubtitlesButton} - onPress={() => { - setShowSubtitleModal(false); - fetchAvailableSubtitles(); - }} - disabled={isLoadingSubtitleList} - > - <View style={styles.searchButtonContent}> - {isLoadingSubtitleList ? ( - <ActivityIndicator size="small" color="#2196F3" /> - ) : ( - <Ionicons name="search" size={20} color="#2196F3" /> - )} - <Text style={styles.searchSubtitlesText}> - {isLoadingSubtitleList ? 'Searching...' : 'Search Online Subtitles'} - </Text> - </View> - </TouchableOpacity> - </View> - - {/* Subtitle Size Controls - Only for custom subtitles */} - {useCustomSubtitles && ( - <View style={styles.sectionContainer}> - <Text style={styles.sectionTitle}>Size Control</Text> - <View style={styles.modernSubtitleSizeContainer}> - <TouchableOpacity - style={styles.modernSizeButton} - onPress={decreaseSubtitleSize} - > - <Ionicons name="remove" size={20} color="white" /> - </TouchableOpacity> - <View style={styles.sizeDisplayContainer}> - <Text style={styles.modernSubtitleSizeText}>{subtitleSize}px</Text> - <Text style={styles.sizeLabel}>Font Size</Text> - </View> - <TouchableOpacity - style={styles.modernSizeButton} - onPress={increaseSubtitleSize} - > - <Ionicons name="add" size={20} color="white" /> - </TouchableOpacity> - </View> - </View> - )} - - {/* Built-in Subtitles Section */} - <View style={styles.sectionContainer}> - <Text style={styles.sectionTitle}>Built-in Subtitles</Text> - <Text style={styles.sectionDescription}>System default sizing • No customization</Text> - - {/* Off option */} - <TouchableOpacity - style={[styles.modernTrackItem, (selectedTextTrack === -1 && !useCustomSubtitles) && styles.modernSelectedTrackItem]} - onPress={() => { - selectTextTrack(-1); - setShowSubtitleModal(false); - }} - > - <View style={styles.trackIconContainer}> - <Ionicons name="close-circle" size={20} color="#9E9E9E" /> - </View> - <View style={styles.modernTrackInfoContainer}> - <Text style={styles.modernTrackPrimaryText}>Disabled</Text> - <Text style={styles.modernTrackSecondaryText}>No subtitles</Text> - </View> - {(selectedTextTrack === -1 && !useCustomSubtitles) && ( - <View style={styles.modernSelectedIndicator}> - <Ionicons name="checkmark-circle" size={24} color="#9E9E9E" /> - </View> - )} - </TouchableOpacity> - - {/* Available built-in subtitle tracks */} - {vlcTextTracks.length > 0 ? vlcTextTracks.map(track => ( - <TouchableOpacity - key={track.id} - style={[styles.modernTrackItem, (selectedTextTrack === track.id && !useCustomSubtitles) && styles.modernSelectedTrackItem]} - onPress={() => { - selectTextTrack(track.id); - setShowSubtitleModal(false); - }} - > - <View style={styles.trackIconContainer}> - <Ionicons name="text" size={20} color="#FF9800" /> - </View> - <View style={styles.modernTrackInfoContainer}> - <Text style={styles.modernTrackPrimaryText}> - {getTrackDisplayName(track)} - </Text> - <Text style={styles.modernTrackSecondaryText}> - Built-in track • System font size - </Text> - </View> - {(selectedTextTrack === track.id && !useCustomSubtitles) && ( - <View style={styles.modernSelectedIndicator}> - <Ionicons name="checkmark-circle" size={24} color="#FF9800" /> - </View> - )} - </TouchableOpacity> - )) : ( - <View style={styles.modernEmptyStateContainer}> - <Ionicons name="information-circle-outline" size={24} color="#666" /> - <Text style={styles.modernEmptyStateText}>No built-in subtitles available</Text> - </View> - )} - </View> - </View> - </ScrollView> - </View> - </View> - ); - }; - - // Render subtitle language selection modal - const renderSubtitleLanguageModal = () => { - if (!showSubtitleLanguageModal) return null; - - return ( - <View style={styles.fullscreenOverlay}> - <View style={styles.enhancedModalContainer}> - <View style={styles.enhancedModalHeader}> - <Text style={styles.enhancedModalTitle}>Select Language</Text> - <TouchableOpacity - style={styles.enhancedCloseButton} - onPress={() => setShowSubtitleLanguageModal(false)} - > - <Ionicons name="close" size={24} color="white" /> - </TouchableOpacity> - </View> - - <ScrollView style={styles.trackListScrollContainer}> - <View style={styles.trackListContainer}> - {availableSubtitles.length > 0 ? availableSubtitles.map(subtitle => ( - <TouchableOpacity - key={subtitle.id} - style={styles.enhancedTrackItem} - onPress={() => loadWyzieSubtitle(subtitle)} - disabled={isLoadingSubtitles} - > - <View style={styles.subtitleLanguageItem}> - <Image - source={{ uri: subtitle.flagUrl }} - style={styles.flagIcon} - resizeMode="cover" - /> - <View style={styles.trackInfoContainer}> - <Text style={styles.trackPrimaryText}> - {formatLanguage(subtitle.language)} - </Text> - <Text style={styles.trackSecondaryText}> - {subtitle.display} - </Text> - </View> - </View> - {isLoadingSubtitles && ( - <ActivityIndicator size="small" color="#E50914" /> - )} - </TouchableOpacity> - )) : ( - <View style={styles.emptyStateContainer}> - <Ionicons name="alert-circle-outline" size={40} color="#888" /> - <Text style={styles.emptyStateText}> - No subtitles found for this content - </Text> - </View> - )} - </View> - </ScrollView> - </View> - </View> - ); - }; - - // Update the getInfo method for VLC - const getInfo = async () => { - if (vlcRef.current) { - try { - const position = await vlcRef.current.getPosition(); - const lengthResult = await vlcRef.current.getLength(); - return { - currentTime: position, - duration: lengthResult / 1000 // Convert to seconds - }; - } catch (e) { - logger.error('[VideoPlayer] Error getting playback info:', e); - return { - currentTime: currentTime, - duration: duration - }; - } - } - return { - currentTime: 0, - duration: 0 - }; - }; - - // VLC specific method to set playback speed - const changePlaybackSpeed = (speed: number) => { - if (vlcRef.current) { - if (typeof vlcRef.current.setRate === 'function') { - vlcRef.current.setRate(speed); - } else if (typeof vlcRef.current.setPlaybackRate === 'function') { - vlcRef.current.setPlaybackRate(speed); - } - setPlaybackSpeed(speed); - } - }; - - // VLC specific method for volume control - const setVolume = (volumeLevel: number) => { - if (vlcRef.current) { - // VLC volume is typically between 0-200 - if (typeof vlcRef.current.setVolume === 'function') { - vlcRef.current.setVolume(volumeLevel * 200); - } - } - }; - - // Added back the togglePlayback function - const togglePlayback = () => { - if (vlcRef.current) { - if (paused) { - // Check if resume function exists - if (typeof vlcRef.current.resume === 'function') { - vlcRef.current.resume(); - } else if (typeof vlcRef.current.play === 'function') { - vlcRef.current.play(); - } else { - // Fallback - use setPaused method or property if available - vlcRef.current.setPaused && vlcRef.current.setPaused(false); - } - } else { - // Check if pause function exists - if (typeof vlcRef.current.pause === 'function') { - vlcRef.current.pause(); - } else { - // Fallback - use setPaused method or property if available - vlcRef.current.setPaused && vlcRef.current.setPaused(true); - } - } - setPaused(!paused); - } - }; - - // Re-add the renderAudioModal function - const renderAudioModal = () => { - if (!showAudioModal) return null; - - return ( - <View style={styles.fullscreenOverlay}> - <View style={styles.enhancedModalContainer}> - <View style={styles.enhancedModalHeader}> - <Text style={styles.enhancedModalTitle}>Audio</Text> - <TouchableOpacity - style={styles.enhancedCloseButton} - onPress={() => setShowAudioModal(false)} - > - <Ionicons name="close" size={24} color="white" /> - </TouchableOpacity> - </View> - - <ScrollView style={styles.trackListScrollContainer}> - <View style={styles.trackListContainer}> - {vlcAudioTracks.length > 0 ? vlcAudioTracks.map(track => ( - <TouchableOpacity - key={track.id} - style={styles.enhancedTrackItem} - onPress={() => { - selectAudioTrack(track.id); - setShowAudioModal(false); - }} - > - <View style={styles.trackInfoContainer}> - <Text style={styles.trackPrimaryText}> - {getTrackDisplayName(track)} - </Text> - {(track.name && track.language) && ( - <Text style={styles.trackSecondaryText}>{track.name}</Text> - )} - </View> - {selectedAudioTrack === track.id && ( - <View style={styles.selectedIndicatorContainer}> - <Ionicons name="checkmark" size={22} color="#E50914" /> - </View> - )} - </TouchableOpacity> - )) : ( - <View style={styles.emptyStateContainer}> - <Ionicons name="alert-circle-outline" size={40} color="#888" /> - <Text style={styles.emptyStateText}>No audio tracks available</Text> - </View> - )} - </View> - </ScrollView> - </View> - </View> - ); - }; - - // Use a ref to track if we're mounted to prevent state updates after unmount - // This helps prevent potential memory leaks and strange behaviors with navigation - const isMounted = useRef(true); - - // Clean up when component unmounts - useEffect(() => { - return () => { - isMounted.current = false; - if (seekDebounceTimer.current) { - clearTimeout(seekDebounceTimer.current); - } - }; - }, []); - - // Wrap all setState calls with this check - const safeSetState = (setter: any) => { - if (isMounted.current) { - setter(); - } - }; - - // Add subtitle size management functions - const loadSubtitleSize = async () => { - try { - const savedSize = await AsyncStorage.getItem(SUBTITLE_SIZE_KEY); - if (savedSize) { - setSubtitleSize(parseInt(savedSize, 10)); - } - } catch (error) { - logger.error('[VideoPlayer] Error loading subtitle size:', error); - } - }; - - const saveSubtitleSize = async (size: number) => { - try { - await AsyncStorage.setItem(SUBTITLE_SIZE_KEY, size.toString()); - setSubtitleSize(size); - } catch (error) { - logger.error('[VideoPlayer] Error saving subtitle size:', error); - } - }; - - // Enhanced SRT parser function - more robust - const parseSRT = (srtContent: string): SubtitleCue[] => { - const cues: SubtitleCue[] = []; - - if (!srtContent || srtContent.trim().length === 0) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] SRT Parser: Empty content provided`); - } - return cues; - } - - // Normalize line endings and clean up the content - const normalizedContent = srtContent - .replace(/\r\n/g, '\n') // Convert Windows line endings - .replace(/\r/g, '\n') // Convert Mac line endings - .trim(); - - // Split by double newlines, but also handle cases with multiple empty lines - const blocks = normalizedContent.split(/\n\s*\n/).filter(block => block.trim().length > 0); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] SRT Parser: Found ${blocks.length} blocks after normalization`); - logger.log(`[VideoPlayer] SRT Parser: First few characters: "${normalizedContent.substring(0, 300)}"`); - } - - for (let i = 0; i < blocks.length; i++) { - const block = blocks[i].trim(); - const lines = block.split('\n').map(line => line.trim()).filter(line => line.length > 0); - - if (lines.length >= 3) { - // Find the timestamp line (could be line 1 or 2, depending on numbering) - let timeLineIndex = -1; - let timeMatch = null; - - for (let j = 0; j < Math.min(3, lines.length); j++) { - // More flexible time pattern matching - timeMatch = lines[j].match(/(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})/); - if (timeMatch) { - timeLineIndex = j; - break; - } - } - - if (timeMatch && timeLineIndex !== -1) { - try { - const startTime = - parseInt(timeMatch[1]) * 3600 + - parseInt(timeMatch[2]) * 60 + - parseInt(timeMatch[3]) + - parseInt(timeMatch[4]) / 1000; - - const endTime = - parseInt(timeMatch[5]) * 3600 + - parseInt(timeMatch[6]) * 60 + - parseInt(timeMatch[7]) + - parseInt(timeMatch[8]) / 1000; - - // Get text lines (everything after the timestamp line) - const textLines = lines.slice(timeLineIndex + 1); - if (textLines.length > 0) { - const text = textLines - .join('\n') - .replace(/<[^>]*>/g, '') // Remove HTML tags - .replace(/\{[^}]*\}/g, '') // Remove subtitle formatting tags like {italic} - .replace(/\\N/g, '\n') // Handle \N newlines - .trim(); - - if (text.length > 0) { - cues.push({ - start: startTime, - end: endTime, - text: text - }); - - if (DEBUG_MODE && (i < 5 || cues.length <= 10)) { - logger.log(`[VideoPlayer] SRT Parser: Cue ${cues.length}: ${startTime.toFixed(3)}s-${endTime.toFixed(3)}s: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); - } - } - } - } catch (error) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] SRT Parser: Error parsing times for block ${i + 1}: ${error}`); - } - } - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] SRT Parser: No valid timestamp found in block ${i + 1}. Lines: ${JSON.stringify(lines.slice(0, 3))}`); - } - } else if (DEBUG_MODE && block.length > 0) { - logger.log(`[VideoPlayer] SRT Parser: Block ${i + 1} has insufficient lines (${lines.length}): "${block.substring(0, 100)}"`); - } - } - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] SRT Parser: Successfully parsed ${cues.length} subtitle cues`); - if (cues.length > 0) { - logger.log(`[VideoPlayer] SRT Parser: Time range: ${cues[0].start.toFixed(1)}s to ${cues[cues.length-1].end.toFixed(1)}s`); - } - } - - return cues; - }; - - // Fetch available subtitles from Wyzie API - const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => { - const targetImdbId = imdbIdParam || imdbId; - if (!targetImdbId) { - logger.error('[VideoPlayer] No IMDb ID available for subtitle search'); - return; - } - - setIsLoadingSubtitleList(true); - try { - // Build search URL with season and episode parameters for TV shows - let searchUrl = `https://sub.wyzie.ru/search?id=${targetImdbId}&encoding=utf-8&source=all`; - - // Add season and episode parameters if available (for TV shows) - if (season && episode) { - searchUrl += `&season=${season}&episode=${episode}`; - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Searching for subtitles with IMDb ID: ${targetImdbId}, Season: ${season}, Episode: ${episode}`); - } - } else { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Searching for subtitles with IMDb ID: ${targetImdbId} (movie or no season/episode info)`); - } - } - - const response = await fetch(searchUrl); - const subtitles: WyzieSubtitle[] = await response.json(); - - // Filter out duplicates and sort by language - const uniqueSubtitles = subtitles.reduce((acc, current) => { - const exists = acc.find(item => item.language === current.language); - if (!exists) { - acc.push(current); - } - return acc; - }, [] as WyzieSubtitle[]); - - // Sort alphabetically by display name - uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display)); - - setAvailableSubtitles(uniqueSubtitles); - - if (autoSelectEnglish) { - // Try to find English subtitles - const englishSubtitle = uniqueSubtitles.find(sub => - sub.language.toLowerCase() === 'eng' || - sub.language.toLowerCase() === 'en' || - sub.display.toLowerCase().includes('english') - ); - - if (englishSubtitle) { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Auto-selecting English subtitle: ${englishSubtitle.display}`); - } - loadWyzieSubtitle(englishSubtitle); - return; - } else if (DEBUG_MODE) { - logger.log(`[VideoPlayer] No English subtitles found for auto-selection`); - } - } - - // Only show the modal if not auto-selecting or if no English subtitles found - if (!autoSelectEnglish) { - setShowSubtitleLanguageModal(true); - } - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Found ${uniqueSubtitles.length} unique subtitle languages`); - } - } catch (error) { - logger.error('[VideoPlayer] Error fetching subtitles from Wyzie API:', error); - } finally { - setIsLoadingSubtitleList(false); - } - }; - - // Load subtitle from selected Wyzie entry - const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => { - setShowSubtitleLanguageModal(false); - setIsLoadingSubtitles(true); - - try { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Loading subtitle: ${subtitle.display} from ${subtitle.url}`); - } - - const response = await fetch(subtitle.url); - const srtContent = await response.text(); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Downloaded subtitle content length: ${srtContent.length} characters`); - logger.log(`[VideoPlayer] First 200 characters of subtitle: ${srtContent.substring(0, 200)}`); - } - - const parsedCues = parseSRT(srtContent); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Parsed ${parsedCues.length} subtitle cues`); - if (parsedCues.length > 0) { - logger.log(`[VideoPlayer] First cue: ${parsedCues[0].start}s-${parsedCues[0].end}s: "${parsedCues[0].text}"`); - logger.log(`[VideoPlayer] Last cue: ${parsedCues[parsedCues.length-1].start}s-${parsedCues[parsedCues.length-1].end}s: "${parsedCues[parsedCues.length-1].text}"`); - } - } - - setCustomSubtitles(parsedCues); - setUseCustomSubtitles(true); - - // Disable VLC's built-in subtitles when using custom ones - setSelectedTextTrack(-1); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Successfully loaded subtitle: useCustomSubtitles=true, customSubtitles.length=${parsedCues.length}`); - } - } catch (error) { - logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error); - } finally { - setIsLoadingSubtitles(false); - } - }; - - // Load external subtitle file (keep for backwards compatibility) - const loadExternalSubtitles = async (subtitleUrl: string) => { - if (!subtitleUrl) return; - - setIsLoadingSubtitles(true); - try { - const response = await fetch(subtitleUrl); - const srtContent = await response.text(); - const parsedCues = parseSRT(srtContent); - setCustomSubtitles(parsedCues); - setUseCustomSubtitles(true); - - // Disable VLC's built-in subtitles when using custom ones - setSelectedTextTrack(-1); - - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Loaded ${parsedCues.length} subtitle cues from external file`); - } - } catch (error) { - logger.error('[VideoPlayer] Error loading external subtitles:', error); - } finally { - setIsLoadingSubtitles(false); - } - }; - - // Update current subtitle based on playback time - useEffect(() => { - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Subtitle useEffect - useCustomSubtitles: ${useCustomSubtitles}, customSubtitles.length: ${customSubtitles.length}, currentTime: ${currentTime.toFixed(3)}`); - - // Show detailed info about subtitle cues for debugging - if (useCustomSubtitles && customSubtitles.length > 0 && customSubtitles.length <= 5) { - logger.log(`[VideoPlayer] All ${customSubtitles.length} subtitle cues:`); - customSubtitles.forEach((cue, index) => { - const isActive = currentTime >= cue.start && currentTime <= cue.end; - logger.log(`[VideoPlayer] Cue ${index + 1}: ${cue.start.toFixed(3)}s-${cue.end.toFixed(3)}s ${isActive ? '(ACTIVE)' : ''}: "${cue.text.substring(0, 50)}${cue.text.length > 50 ? '...' : ''}"`); - }); - } else if (useCustomSubtitles && customSubtitles.length > 5) { - // For larger subtitle files, just show nearby cues - const nearbyCues = customSubtitles.filter(cue => - Math.abs(cue.start - currentTime) <= 10 || Math.abs(cue.end - currentTime) <= 10 - ); - if (nearbyCues.length > 0) { - logger.log(`[VideoPlayer] Nearby subtitle cues (within 10s):`); - nearbyCues.slice(0, 3).forEach((cue, index) => { - const isActive = currentTime >= cue.start && currentTime <= cue.end; - logger.log(`[VideoPlayer] Nearby cue: ${cue.start.toFixed(3)}s-${cue.end.toFixed(3)}s ${isActive ? '(ACTIVE)' : ''}: "${cue.text.substring(0, 50)}${cue.text.length > 50 ? '...' : ''}"`); - }); - } - } - } - - if (!useCustomSubtitles || customSubtitles.length === 0) { - if (currentSubtitle !== '') { - setCurrentSubtitle(''); - if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Cleared subtitle - useCustomSubtitles: ${useCustomSubtitles}, customSubtitles.length: ${customSubtitles.length}`); - } - } - return; - } - - const currentCue = customSubtitles.find(cue => - currentTime >= cue.start && currentTime <= cue.end - ); - - const newSubtitle = currentCue ? currentCue.text : ''; - - if (DEBUG_MODE && newSubtitle !== currentSubtitle) { - logger.log(`[VideoPlayer] Subtitle changed from "${currentSubtitle}" to "${newSubtitle}" at time ${currentTime.toFixed(3)}`); - if (currentCue) { - logger.log(`[VideoPlayer] Current cue: ${currentCue.start.toFixed(3)}s - ${currentCue.end.toFixed(3)}s: "${currentCue.text}"`); - } - } - - setCurrentSubtitle(newSubtitle); - }, [currentTime, customSubtitles, useCustomSubtitles]); - - // Load subtitle size preference on mount - useEffect(() => { - loadSubtitleSize(); - }, []); - - // Add subtitle size adjustment functions - const increaseSubtitleSize = () => { - const newSize = Math.min(subtitleSize + 2, 32); - saveSubtitleSize(newSize); - }; - - const decreaseSubtitleSize = () => { - const newSize = Math.max(subtitleSize - 2, 8); - saveSubtitleSize(newSize); - }; - - - - return ( - <View style={[styles.container, { - width: screenDimensions.width, - height: screenDimensions.height, - position: 'absolute', - top: 0, - left: 0, - }]}> - {/* Opening Animation Overlay - covers the entire screen during transition */} - <Animated.View - style={[ - styles.openingOverlay, - { - opacity: backgroundFadeAnim, - zIndex: isOpeningAnimationComplete ? -1 : 3000, - width: screenDimensions.width, - height: screenDimensions.height, - } - ]} - pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'} - > - <View style={styles.openingContent}> - <ActivityIndicator size="large" color="#E50914" /> - <Text style={styles.openingText}>Loading video...</Text> - </View> - </Animated.View> - - {/* Animated Video Player Container - ensure no transform issues */} - <Animated.View - style={[ - styles.videoPlayerContainer, - { - opacity: openingFadeAnim, - transform: isOpeningAnimationComplete ? [] : [{ scale: openingScaleAnim }], - width: screenDimensions.width, - height: screenDimensions.height, - } - ]} - > - <TouchableOpacity - style={[styles.videoContainer, { - width: screenDimensions.width, - height: screenDimensions.height, - }]} - onPress={toggleControls} - activeOpacity={1} - > - <PinchGestureHandler - ref={pinchRef} - onGestureEvent={onPinchGestureEvent} - onHandlerStateChange={onPinchHandlerStateChange} - > - <View style={{ - position: 'absolute', - top: 0, - left: 0, - width: screenDimensions.width, - height: screenDimensions.height, - backgroundColor: '#000', - }}> - <TouchableOpacity - style={{ flex: 1 }} - activeOpacity={1} - onPress={toggleControls} - onLongPress={resetZoom} - delayLongPress={300} - > - <VLCPlayer - ref={vlcRef} - style={{ - position: 'absolute', - top: 0, - left: 0, - width: screenDimensions.width, - height: screenDimensions.height, - backgroundColor: '#000', - transform: [ - { scale: zoomScale }, - ], - }} - source={{ - uri: uri, - initOptions: [ - '--rtsp-tcp', - '--network-caching=150', - '--rtsp-caching=150', - '--no-audio-time-stretch', - '--clock-jitter=0', - '--clock-synchro=0', - '--drop-late-frames', - '--skip-frames', - ], - }} - paused={paused} - autoplay={true} - autoAspectRatio={false} - resizeMode={'stretch' as any} - audioTrack={selectedAudioTrack || undefined} - textTrack={selectedTextTrack === -1 ? undefined : selectedTextTrack} - onLoad={onLoad} - onProgress={handleProgress} - onEnd={onEnd} - onError={handleError} - /> - </TouchableOpacity> - </View> - </PinchGestureHandler> - - {/* Progress bar with enhanced touch handling */} - <Animated.View style={[styles.sliderContainer, { opacity: fadeAnim }]}> - <View - style={styles.progressTouchArea} - onTouchStart={handleProgressBarDragStart} - onTouchMove={handleProgressBarDragMove} - onTouchEnd={handleProgressBarDragEnd} - > - <TouchableOpacity - activeOpacity={0.8} - onPress={handleProgressBarTouch} - style={{width: '100%'}} - > - <View - ref={progressBarRef} - style={styles.progressBarContainer} - > - {/* Buffered Progress */} - <View style={[styles.bufferProgress, { - width: `${(buffered / (duration || 1)) * 100}%` - }]} /> - {/* Animated Progress */} - <Animated.View - style={[ - styles.progressBarFill, - { - width: progressAnim.interpolate({ - inputRange: [0, 1], - outputRange: ['0%', '100%'] - }) - } - ]} - /> - </View> - </TouchableOpacity> - </View> - <View style={styles.timeDisplay}> - <Text style={styles.duration}>{formatTime(currentTime)}</Text> - <Text style={styles.duration}>{formatTime(duration)}</Text> - </View> - </Animated.View> - - {/* Controls Overlay - Using Animated.View */} - <Animated.View style={[styles.controlsContainer, { opacity: fadeAnim }]}> - {/* Top Gradient & Header */} - <LinearGradient - colors={['rgba(0,0,0,0.7)', 'transparent']} - style={styles.topGradient} - > - <View style={styles.header}> - {/* Title Section - Enhanced with metadata */} - <View style={styles.titleSection}> - <Text style={styles.title}>{title}</Text> - {/* Show season and episode for series */} - {season && episode && ( - <Text style={styles.episodeInfo}> - S{season}E{episode} {episodeTitle && `• ${episodeTitle}`} - </Text> - )} - {/* Show year, quality, and provider */} - <View style={styles.metadataRow}> - {year && <Text style={styles.metadataText}>{year}</Text>} - {quality && <View style={styles.qualityBadge}><Text style={styles.qualityText}>{quality}</Text></View>} - {streamProvider && <Text style={styles.providerText}>via {streamProvider}</Text>} - </View> - </View> - <TouchableOpacity style={styles.closeButton} onPress={handleClose}> - <Ionicons name="close" size={24} color="white" /> - </TouchableOpacity> - </View> - </LinearGradient> - - {/* Center Controls (Play/Pause, Skip) */} - <View style={styles.controls}> - <TouchableOpacity onPress={() => skip(-10)} style={styles.skipButton}> - <Ionicons name="play-back" size={24} color="white" /> - <Text style={styles.skipText}>10</Text> - </TouchableOpacity> - <TouchableOpacity onPress={togglePlayback} style={styles.playButton}> - <Ionicons name={paused ? "play" : "pause"} size={40} color="white" /> - </TouchableOpacity> - <TouchableOpacity onPress={() => skip(10)} style={styles.skipButton}> - <Ionicons name="play-forward" size={24} color="white" /> - <Text style={styles.skipText}>10</Text> - </TouchableOpacity> - </View> - - {/* Bottom Gradient */} - <LinearGradient - colors={['transparent', 'rgba(0,0,0,0.7)']} - style={styles.bottomGradient} - > - <View style={styles.bottomControls}> - {/* Bottom Buttons Row */} - <View style={styles.bottomButtons}> - {/* Speed Button */} - <TouchableOpacity style={styles.bottomButton}> - <Ionicons name="speedometer" size={20} color="white" /> - <Text style={styles.bottomButtonText}>Speed ({playbackSpeed}x)</Text> - </TouchableOpacity> - - {/* Fill/Cover Button - Updated to show fill/cover modes */} - <TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}> - <Ionicons name="resize" size={20} color="white" /> - <Text style={[styles.bottomButtonText, { fontSize: 14, textAlign: 'center' }]}> - {zoomScale === 1.1 ? 'Fill' : 'Cover'} - </Text> - </TouchableOpacity> - - {/* Audio Button - Updated to use vlcAudioTracks */} - <TouchableOpacity - style={styles.bottomButton} - onPress={() => setShowAudioModal(true)} - disabled={vlcAudioTracks.length <= 1} - > - <Ionicons name="volume-high" size={20} color={vlcAudioTracks.length <= 1 ? 'grey' : 'white'} /> - <Text style={[styles.bottomButtonText, vlcAudioTracks.length <= 1 && {color: 'grey'}]}> - {`Audio: ${getTrackDisplayName(vlcAudioTracks.find(t => t.id === selectedAudioTrack) || {id: -1, name: 'Default'})}`} - </Text> - </TouchableOpacity> - - {/* Subtitle Button - Always available for external subtitle search */} - <TouchableOpacity - style={styles.bottomButton} - onPress={() => setShowSubtitleModal(true)} - > - <Ionicons name="text" size={20} color="white" /> - <Text style={styles.bottomButtonText}> - {useCustomSubtitles - ? 'Subtitles: Custom' - : (selectedTextTrack === -1) - ? 'Subtitles' - : `Subtitles: ${getTrackDisplayName(vlcTextTracks.find(t => t.id === selectedTextTrack) || {id: -1, name: 'On'})}`} - </Text> - </TouchableOpacity> - </View> - </View> - </LinearGradient> - </Animated.View> - - {/* Custom Subtitle Overlay - Enhanced visibility and debugging */} - {(useCustomSubtitles && currentSubtitle) && ( - <View style={styles.customSubtitleContainer} pointerEvents="none"> - <View style={styles.customSubtitleWrapper}> - <Text style={[styles.customSubtitleText, { fontSize: subtitleSize }]}> - {currentSubtitle} - </Text> - </View> - </View> - )} - - {/* Resume Overlay */} - {showResumeOverlay && resumePosition !== null && ( - <View style={styles.resumeOverlay}> - <LinearGradient - colors={['rgba(0,0,0,0.9)', 'rgba(0,0,0,0.7)']} - style={styles.resumeContainer} - > - <View style={styles.resumeContent}> - <View style={styles.resumeIconContainer}> - <Ionicons name="play-circle" size={40} color="#E50914" /> - </View> - <View style={styles.resumeTextContainer}> - <Text style={styles.resumeTitle}>Continue Watching</Text> - <Text style={styles.resumeInfo}> - {title} - {season && episode && ` • S${season}E${episode}`} - </Text> - <View style={styles.resumeProgressContainer}> - <View style={styles.resumeProgressBar}> - <View - style={[ - styles.resumeProgressFill, - { width: `${duration > 0 ? (resumePosition / duration) * 100 : 0}%` } - ]} - /> - </View> - <Text style={styles.resumeTimeText}> - {formatTime(resumePosition)} {duration > 0 ? `/ ${formatTime(duration)}` : ''} - </Text> - </View> - </View> - </View> - - {/* Remember choice checkbox */} - <TouchableOpacity - style={styles.rememberChoiceContainer} - onPress={() => setRememberChoice(!rememberChoice)} - activeOpacity={0.7} - > - <View style={styles.checkboxContainer}> - <View style={[styles.checkbox, rememberChoice && styles.checkboxChecked]}> - {rememberChoice && <Ionicons name="checkmark" size={12} color="white" />} - </View> - <Text style={styles.rememberChoiceText}>Remember my choice</Text> - </View> - - {resumePreference && ( - <TouchableOpacity - onPress={resetResumePreference} - style={styles.resetPreferenceButton} - > - <Text style={styles.resetPreferenceText}>Reset</Text> - </TouchableOpacity> - )} - </TouchableOpacity> - - <View style={styles.resumeButtons}> - <TouchableOpacity - style={styles.resumeButton} - onPress={handleStartFromBeginning} - > - <Ionicons name="refresh" size={16} color="white" style={styles.buttonIcon} /> - <Text style={styles.resumeButtonText}>Start Over</Text> - </TouchableOpacity> - <TouchableOpacity - style={[styles.resumeButton, styles.resumeFromButton]} - onPress={handleResume} - > - <Ionicons name="play" size={16} color="white" style={styles.buttonIcon} /> - <Text style={styles.resumeButtonText}>Resume</Text> - </TouchableOpacity> - </View> - </LinearGradient> - </View> - )} - </TouchableOpacity> - </Animated.View> - - {/* Use the new modal rendering functions */} - {renderAudioModal()} - {renderSubtitleModal()} - {renderSubtitleLanguageModal()} - </View> - ); -}; - -const styles = StyleSheet.create({ - container: { - backgroundColor: '#000', - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - margin: 0, - padding: 0, - }, - videoContainer: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - margin: 0, - padding: 0, - }, - video: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - margin: 0, - padding: 0, - }, - controlsContainer: { - ...StyleSheet.absoluteFillObject, - justifyContent: 'space-between', - margin: 0, - padding: 0, - }, - topGradient: { - paddingTop: 20, - paddingHorizontal: 20, - paddingBottom: 10, - }, - bottomGradient: { - paddingBottom: 20, - paddingHorizontal: 20, - paddingTop: 20, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - }, - titleSection: { - flex: 1, - marginRight: 10, - }, - title: { - color: 'white', - fontSize: 18, - fontWeight: 'bold', - }, - episodeInfo: { - color: 'rgba(255, 255, 255, 0.9)', - fontSize: 14, - marginTop: 3, - }, - metadataRow: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 5, - flexWrap: 'wrap', - }, - metadataText: { - color: 'rgba(255, 255, 255, 0.7)', - fontSize: 12, - marginRight: 8, - }, - qualityBadge: { - backgroundColor: '#E50914', - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 4, - marginRight: 8, - }, - qualityText: { - color: 'white', - fontSize: 10, - fontWeight: 'bold', - }, - providerText: { - color: 'rgba(255, 255, 255, 0.7)', - fontSize: 12, - fontStyle: 'italic', - }, - closeButton: { - padding: 8, - }, - controls: { - position: 'absolute', - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - gap: 40, - left: 0, - right: 0, - top: '50%', - transform: [{ translateY: -30 }], - zIndex: 1000, - }, - playButton: { - justifyContent: 'center', - alignItems: 'center', - padding: 10, - }, - skipButton: { - alignItems: 'center', - justifyContent: 'center', - }, - skipText: { - color: 'white', - fontSize: 12, - marginTop: 2, - }, - bottomControls: { - gap: 12, - }, - sliderContainer: { - position: 'absolute', - bottom: 55, - left: 0, - right: 0, - paddingHorizontal: 20, - zIndex: 1000, - }, - progressTouchArea: { - height: 30, - justifyContent: 'center', - width: '100%', - }, - progressBarContainer: { - height: 4, - backgroundColor: 'rgba(255, 255, 255, 0.2)', - borderRadius: 2, - overflow: 'hidden', - marginHorizontal: 4, - position: 'relative', - }, - bufferProgress: { - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - backgroundColor: 'rgba(255, 255, 255, 0.4)', - }, - progressBarFill: { - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - backgroundColor: '#E50914', - height: '100%', - }, - timeDisplay: { - flexDirection: 'row', - justifyContent: 'space-between', - width: '100%', - paddingHorizontal: 4, - marginTop: 4, - marginBottom: 8, - }, - duration: { - color: 'white', - fontSize: 12, - fontWeight: '500', - }, - bottomButtons: { - flexDirection: 'row', - justifyContent: 'space-around', - alignItems: 'center', - }, - bottomButton: { - flexDirection: 'row', - alignItems: 'center', - gap: 5, - }, - bottomButtonText: { - color: 'white', - fontSize: 12, - }, - modalOverlay: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.7)', - }, - modalContent: { - width: '80%', - maxHeight: '70%', - backgroundColor: '#222', - borderRadius: 10, - overflow: 'hidden', - zIndex: 1000, - elevation: 5, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.8, - shadowRadius: 5, - }, - modalHeader: { - padding: 16, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - borderBottomWidth: 1, - borderBottomColor: '#333', - }, - modalTitle: { - color: 'white', - fontSize: 18, - fontWeight: 'bold', - }, - trackList: { - padding: 10, - }, - trackItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 15, - borderRadius: 5, - marginVertical: 5, - }, - selectedTrackItem: { - backgroundColor: 'rgba(229, 9, 20, 0.2)', - }, - trackLabel: { - color: 'white', - fontSize: 16, - }, - noTracksText: { - color: 'white', - fontSize: 16, - textAlign: 'center', - padding: 20, - }, - fullscreenOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0,0,0,0.85)', - justifyContent: 'center', - alignItems: 'center', - zIndex: 2000, - }, - enhancedModalContainer: { - width: 300, - maxHeight: '70%', - backgroundColor: '#181818', - borderRadius: 8, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.4, - shadowRadius: 10, - elevation: 8, - }, - enhancedModalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: '#333', - }, - enhancedModalTitle: { - color: 'white', - fontSize: 18, - fontWeight: 'bold', - }, - enhancedCloseButton: { - padding: 4, - }, - trackListScrollContainer: { - maxHeight: 350, - }, - trackListContainer: { - padding: 6, - }, - enhancedTrackItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 10, - marginVertical: 2, - borderRadius: 6, - backgroundColor: '#222', - }, - trackInfoContainer: { - flex: 1, - marginRight: 8, - }, - trackPrimaryText: { - color: 'white', - fontSize: 14, - fontWeight: '500', - }, - trackSecondaryText: { - color: '#aaa', - fontSize: 11, - marginTop: 2, - }, - selectedIndicatorContainer: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: 'rgba(229, 9, 20, 0.15)', - justifyContent: 'center', - alignItems: 'center', - }, - emptyStateContainer: { - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - emptyStateText: { - color: '#888', - fontSize: 14, - marginTop: 8, - textAlign: 'center', - }, - resumeOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.7)', - justifyContent: 'center', - alignItems: 'center', - zIndex: 1000, - }, - resumeContainer: { - width: '80%', - maxWidth: 500, - borderRadius: 12, - padding: 20, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 6, - elevation: 8, - }, - resumeContent: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 20, - }, - resumeIconContainer: { - marginRight: 16, - width: 50, - height: 50, - borderRadius: 25, - backgroundColor: 'rgba(229, 9, 20, 0.2)', - justifyContent: 'center', - alignItems: 'center', - }, - resumeTextContainer: { - flex: 1, - }, - resumeTitle: { - color: 'white', - fontSize: 20, - fontWeight: 'bold', - marginBottom: 4, - }, - resumeInfo: { - color: 'rgba(255, 255, 255, 0.9)', - fontSize: 14, - }, - resumeProgressContainer: { - marginTop: 12, - }, - resumeProgressBar: { - height: 4, - backgroundColor: 'rgba(255, 255, 255, 0.2)', - borderRadius: 2, - overflow: 'hidden', - marginBottom: 6, - }, - resumeProgressFill: { - height: '100%', - backgroundColor: '#E50914', - }, - resumeTimeText: { - color: 'rgba(255,255,255,0.7)', - fontSize: 12, - }, - resumeButtons: { - flexDirection: 'row', - justifyContent: 'flex-end', - width: '100%', - gap: 12, - }, - resumeButton: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 6, - backgroundColor: 'rgba(255, 255, 255, 0.15)', - minWidth: 110, - justifyContent: 'center', - }, - buttonIcon: { - marginRight: 6, - }, - resumeButtonText: { - color: 'white', - fontWeight: 'bold', - fontSize: 14, - }, - resumeFromButton: { - backgroundColor: '#E50914', - }, - rememberChoiceContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: 16, - paddingHorizontal: 2, - }, - checkboxContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - checkbox: { - width: 18, - height: 18, - borderRadius: 3, - borderWidth: 2, - borderColor: 'rgba(255, 255, 255, 0.5)', - marginRight: 8, - justifyContent: 'center', - alignItems: 'center', - }, - checkboxChecked: { - backgroundColor: '#E50914', - borderColor: '#E50914', - }, - rememberChoiceText: { - color: 'rgba(255, 255, 255, 0.8)', - fontSize: 14, - }, - resetPreferenceButton: { - padding: 4, - }, - resetPreferenceText: { - color: '#E50914', - fontSize: 12, - fontWeight: 'bold', - }, - openingOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0,0,0,0.85)', - justifyContent: 'center', - alignItems: 'center', - zIndex: 2000, - margin: 0, - padding: 0, - }, - openingContent: { - padding: 20, - backgroundColor: 'rgba(0,0,0,0.85)', - borderRadius: 10, - justifyContent: 'center', - alignItems: 'center', - }, - openingText: { - color: 'white', - fontSize: 18, - fontWeight: 'bold', - marginTop: 20, - }, - videoPlayerContainer: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - margin: 0, - padding: 0, - }, - subtitleSizeContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 10, - paddingVertical: 12, - marginBottom: 8, - backgroundColor: 'rgba(255, 255, 255, 0.05)', - borderRadius: 6, - }, - subtitleSizeLabel: { - color: 'white', - fontSize: 14, - fontWeight: 'bold', - }, - subtitleSizeControls: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - }, - sizeButton: { - width: 30, - height: 30, - borderRadius: 15, - backgroundColor: 'rgba(255, 255, 255, 0.2)', - justifyContent: 'center', - alignItems: 'center', - }, - subtitleSizeText: { - color: 'white', - fontSize: 14, - fontWeight: 'bold', - minWidth: 40, - textAlign: 'center', - }, - customSubtitleContainer: { - position: 'absolute', - bottom: 40, // Position above controls and progress bar - left: 20, - right: 20, - alignItems: 'center', - zIndex: 1500, // Higher z-index to appear above other elements - }, - customSubtitleWrapper: { - backgroundColor: 'rgba(0, 0, 0, 0.7)', - padding: 10, - borderRadius: 5, - }, - customSubtitleText: { - color: 'white', - textAlign: 'center', - textShadowColor: 'rgba(0, 0, 0, 0.9)', - textShadowOffset: { width: 2, height: 2 }, - textShadowRadius: 4, - lineHeight: undefined, // Let React Native calculate line height - fontWeight: '500', - }, - loadSubtitlesButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - padding: 12, - marginTop: 8, - borderRadius: 6, - backgroundColor: 'rgba(229, 9, 20, 0.2)', - borderWidth: 1, - borderColor: '#E50914', - }, - loadSubtitlesText: { - color: '#E50914', - fontSize: 14, - fontWeight: 'bold', - marginLeft: 8, - }, - disabledContainer: { - opacity: 0.5, - }, - disabledText: { - color: '#666', - }, - disabledButton: { - backgroundColor: '#666', - }, - noteContainer: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 10, - }, - noteText: { - color: '#aaa', - fontSize: 12, - marginLeft: 5, - }, - subtitleLanguageItem: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - }, - flagIcon: { - width: 24, - height: 18, - marginRight: 12, - borderRadius: 2, - }, - modernModalContainer: { - width: '90%', - maxWidth: 500, - backgroundColor: '#181818', - borderRadius: 10, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 6 }, - shadowOpacity: 0.4, - shadowRadius: 10, - elevation: 8, - }, - modernModalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: '#333', - }, - modernModalTitle: { - color: 'white', - fontSize: 18, - fontWeight: 'bold', - }, - modernCloseButton: { - padding: 4, - }, - modernTrackListScrollContainer: { - maxHeight: 350, - }, - modernTrackListContainer: { - padding: 6, - }, - sectionContainer: { - marginBottom: 20, - }, - sectionTitle: { - color: 'white', - fontSize: 16, - fontWeight: 'bold', - marginBottom: 8, - }, - sectionDescription: { - color: 'rgba(255, 255, 255, 0.7)', - fontSize: 12, - marginBottom: 12, - }, - trackIconContainer: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: 'rgba(255, 255, 255, 0.1)', - justifyContent: 'center', - alignItems: 'center', - }, - modernTrackInfoContainer: { - flex: 1, - marginLeft: 10, - }, - modernTrackPrimaryText: { - color: 'white', - fontSize: 14, - fontWeight: '500', - }, - modernTrackSecondaryText: { - color: '#aaa', - fontSize: 11, - marginTop: 2, - }, - modernSelectedIndicator: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: 'rgba(255, 255, 255, 0.15)', - justifyContent: 'center', - alignItems: 'center', - }, - modernEmptyStateContainer: { - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - modernEmptyStateText: { - color: '#888', - fontSize: 14, - marginTop: 8, - textAlign: 'center', - }, - searchSubtitlesButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - padding: 12, - marginTop: 8, - borderRadius: 6, - backgroundColor: 'rgba(229, 9, 20, 0.2)', - borderWidth: 1, - borderColor: '#E50914', - }, - searchButtonContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - }, - searchSubtitlesText: { - color: '#E50914', - fontSize: 14, - fontWeight: 'bold', - marginLeft: 8, - }, - modernSubtitleSizeContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - }, - modernSizeButton: { - width: 30, - height: 30, - borderRadius: 15, - backgroundColor: 'rgba(255, 255, 255, 0.2)', - justifyContent: 'center', - alignItems: 'center', - }, - modernTrackItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 12, - marginVertical: 4, - borderRadius: 8, - backgroundColor: '#222', - }, - modernSelectedTrackItem: { - backgroundColor: 'rgba(76, 175, 80, 0.15)', - borderWidth: 1, - borderColor: 'rgba(76, 175, 80, 0.3)', - }, - sizeDisplayContainer: { - alignItems: 'center', - flex: 1, - marginHorizontal: 20, - }, - modernSubtitleSizeText: { - color: 'white', - fontSize: 16, - fontWeight: 'bold', - }, - sizeLabel: { - color: 'rgba(255, 255, 255, 0.7)', - fontSize: 12, - marginTop: 2, - }, -}); - -export default VideoPlayer; \ No newline at end of file -- 2.45.2 From da6eb659f1b6faf1a86c3c846c8869bbce70390b Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Mon, 9 Jun 2025 00:53:48 +0530 Subject: [PATCH 21/88] Enhance HeroSection with improved image loading animations and error handling. Introduce state management for backdrop image transitions, including opacity and scale animations. Refactor image loading logic to provide a smoother user experience during content loading and error states, ensuring better visual feedback and responsiveness. --- src/components/metadata/HeroSection.tsx | 117 +++++++++++++++++++++--- 1 file changed, 104 insertions(+), 13 deletions(-) diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 3354fcf..103c8c9 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { View, Text, @@ -13,6 +13,9 @@ import Animated, { useAnimatedStyle, interpolate, Extrapolate, + useSharedValue, + withTiming, + withSpring, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; import { logger } from '../../utils/logger'; @@ -259,6 +262,48 @@ const HeroSection: React.FC<HeroSectionProps> = ({ setLogoLoadError, }) => { const { currentTheme } = useTheme(); + + // State for backdrop image loading + const [imageLoaded, setImageLoaded] = useState(false); + const [imageError, setImageError] = useState(false); + + // Animation values for smooth backdrop transitions + const backdropOpacity = useSharedValue(1); // Start visible + const backdropScale = useSharedValue(1); // Start at normal scale + + // Handle image load success + const handleImageLoad = () => { + setImageLoaded(true); + setImageError(false); + // Enhance the image with subtle animation + backdropOpacity.value = withTiming(1, { duration: 300 }); + backdropScale.value = withSpring(1, { + damping: 25, + stiffness: 120, + mass: 1 + }); + }; + + // Handle image load error + const handleImageError = () => { + logger.warn(`[HeroSection] Banner failed to load: ${bannerImage}`); + setImageError(true); + backdropOpacity.value = withTiming(0.7, { duration: 200 }); // Dim on error + if (bannerImage !== metadata.banner) { + setBannerImage(metadata.banner || metadata.poster); + } + }; + + // Reset animations when banner image changes + useEffect(() => { + if (bannerImage && !loadingBanner) { + setImageLoaded(false); + setImageError(false); + backdropOpacity.value = 0.8; // Start slightly dimmed + backdropScale.value = 0.98; // Start slightly smaller + } + }, [bannerImage, loadingBanner]); + // Enhanced animated styles with sophisticated micro-animations const heroAnimatedStyle = useAnimatedStyle(() => ({ width: '100%', @@ -317,7 +362,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ })); const watchProgressBarStyle = useAnimatedStyle(() => ({ - width: `${watchProgressWidth.value * 100}%`, transform: [ { scaleX: interpolate(watchProgressWidth.value, [0, 1], [0.8, 1]) } ] @@ -373,7 +417,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ [0, 150, 300], [1.08, 1.05, 1.02], Extrapolate.CLAMP - ) + ) * backdropScale.value }, { rotateZ: interpolate( @@ -386,6 +430,42 @@ const HeroSection: React.FC<HeroSectionProps> = ({ ], })); + // Backdrop image animated style for smooth transitions + const backdropImageStyle = useAnimatedStyle(() => ({ + opacity: backdropOpacity.value, + transform: [ + { + translateY: interpolate( + dampedScrollY.value, + [0, 100, 300], + [0, -35, -90], + Extrapolate.CLAMP + ) + }, + { + scale: interpolate( + dampedScrollY.value, + [0, 150, 300], + [1.08, 1.05, 1.02], + Extrapolate.CLAMP + ) * backdropScale.value + }, + { + rotateZ: interpolate( + dampedScrollY.value, + [0, 300], + [0, -0.1], + Extrapolate.CLAMP + ) + 'deg' + } + ], + })); + + // Loading skeleton animated style + const skeletonStyle = useAnimatedStyle(() => ({ + opacity: loadingBanner ? 0.2 : 0, + })); + // Render genres const renderGenres = () => { if (!metadata?.genres || !Array.isArray(metadata.genres) || metadata.genres.length === 0) { @@ -411,19 +491,22 @@ const HeroSection: React.FC<HeroSectionProps> = ({ return ( <Animated.View style={heroAnimatedStyle}> <View style={styles.heroSection}> - {loadingBanner ? ( - <View style={[styles.absoluteFill, { backgroundColor: currentTheme.colors.black }]} /> - ) : ( + {/* Fallback dark background */} + <View style={[styles.absoluteFill, { backgroundColor: currentTheme.colors.black }]} /> + + {/* Loading state with skeleton */} + {loadingBanner && ( + <Animated.View style={[styles.absoluteFill, styles.skeletonGradient, skeletonStyle]} /> + )} + + {/* Background image with smooth loading */} + {!loadingBanner && (bannerImage || metadata.banner || metadata.poster) && ( <Animated.Image source={{ uri: bannerImage || metadata.banner || metadata.poster }} - style={[styles.absoluteFill, parallaxImageStyle]} + style={[styles.absoluteFill, backdropImageStyle]} resizeMode="cover" - onError={() => { - logger.warn(`[HeroSection] Banner failed to load: ${bannerImage}`); - if (bannerImage !== metadata.banner) { - setBannerImage(metadata.banner || metadata.poster); - } - }} + onError={handleImageError} + onLoad={handleImageLoad} /> )} <LinearGradient @@ -643,6 +726,14 @@ const styles = StyleSheet.create({ opacity: 0.9, letterSpacing: 0.2 }, + skeletonGradient: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.5)', + }, }); export default React.memo(HeroSection); \ No newline at end of file -- 2.45.2 From 528c4842868b0fb3bac02bc99484b49a2e71c7ae Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Mon, 9 Jun 2025 01:09:55 +0530 Subject: [PATCH 22/88] Enhance MetadataScreen by disabling bounce and over-scroll behavior, and adjusting content container style for improved layout. These changes contribute to a more consistent scrolling experience and better content visibility. --- src/screens/MetadataScreen.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index d4e5fad..10b3894 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -334,6 +334,9 @@ const MetadataScreen = () => { showsVerticalScrollIndicator={false} onScroll={animations.scrollHandler} scrollEventThrottle={16} + bounces={false} + overScrollMode="never" + contentContainerStyle={{ flexGrow: 1 }} > {/* Hero Section */} <HeroSection -- 2.45.2 From 25b483f4faa0ea8ec6049f0c1fb9dcd422443685 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Mon, 9 Jun 2025 01:15:54 +0530 Subject: [PATCH 23/88] Refactor styling across multiple components to standardize border radius to 8. This change enhances visual consistency in the UI by unifying the border radius for content items, posters, and containers in various sections including Home, Library, and Catalog screens. --- src/components/discover/ContentItem.tsx | 2 +- src/components/home/ContentItem.tsx | 8 ++++---- src/components/home/ContinueWatchingSection.tsx | 6 +++--- src/components/home/ThisWeekSection.tsx | 2 +- src/screens/CatalogScreen.tsx | 6 +++--- src/screens/HomeScreen.tsx | 8 ++++---- src/screens/LibraryScreen.tsx | 2 +- src/screens/SearchScreen.tsx | 2 +- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/components/discover/ContentItem.tsx b/src/components/discover/ContentItem.tsx index 015db8c..de22a61 100644 --- a/src/components/discover/ContentItem.tsx +++ b/src/components/discover/ContentItem.tsx @@ -51,7 +51,7 @@ const styles = StyleSheet.create({ marginHorizontal: 0, }, posterContainer: { - borderRadius: 16, + borderRadius: 8, overflow: 'hidden', backgroundColor: 'rgba(255,255,255,0.03)', elevation: 5, diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index c116271..ac3b0db 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -132,7 +132,7 @@ const styles = StyleSheet.create({ width: POSTER_WIDTH, aspectRatio: 2/3, margin: 0, - borderRadius: 16, + borderRadius: 8, overflow: 'hidden', position: 'relative', elevation: 8, @@ -146,14 +146,14 @@ const styles = StyleSheet.create({ contentItemContainer: { width: '100%', height: '100%', - borderRadius: 16, + borderRadius: 8, overflow: 'hidden', position: 'relative', }, poster: { width: '100%', height: '100%', - borderRadius: 16, + borderRadius: 8, }, loadingOverlay: { position: 'absolute', @@ -163,7 +163,7 @@ const styles = StyleSheet.create({ bottom: 0, justifyContent: 'center', alignItems: 'center', - borderRadius: 16, + borderRadius: 8, }, watchedIndicator: { position: 'absolute', diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index d0d156f..5ef5002 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -320,7 +320,7 @@ const styles = StyleSheet.create({ width: POSTER_WIDTH, aspectRatio: 2/3, margin: 0, - borderRadius: 12, + borderRadius: 8, overflow: 'hidden', position: 'relative', elevation: 8, @@ -332,14 +332,14 @@ const styles = StyleSheet.create({ contentItemContainer: { width: '100%', height: '100%', - borderRadius: 12, + borderRadius: 8, overflow: 'hidden', position: 'relative', }, poster: { width: '100%', height: '100%', - borderRadius: 12, + borderRadius: 8, }, episodeInfoContainer: { position: 'absolute', diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx index d9eb511..1fe8cd5 100644 --- a/src/components/home/ThisWeekSection.tsx +++ b/src/components/home/ThisWeekSection.tsx @@ -329,7 +329,7 @@ const styles = StyleSheet.create({ episodeItem: { width: '100%', height: '100%', - borderRadius: 12, + borderRadius: 8, overflow: 'hidden', }, poster: { diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 597ce7c..59d5c39 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -85,7 +85,7 @@ const createStyles = (colors: any) => StyleSheet.create({ item: { width: ITEM_WIDTH, marginBottom: SPACING.lg, - borderRadius: 12, + borderRadius: 8, overflow: 'hidden', backgroundColor: colors.elevation2, shadowColor: '#000', @@ -97,8 +97,8 @@ const createStyles = (colors: any) => StyleSheet.create({ poster: { width: '100%', aspectRatio: 2/3, - borderTopLeftRadius: 12, - borderTopRightRadius: 12, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, backgroundColor: colors.elevation3, }, itemContent: { diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 958b773..f781b0d 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -895,7 +895,7 @@ const styles = StyleSheet.create<any>({ width: POSTER_WIDTH, aspectRatio: 2/3, margin: 0, - borderRadius: 16, + borderRadius: 8, overflow: 'hidden', position: 'relative', elevation: 8, @@ -909,7 +909,7 @@ const styles = StyleSheet.create<any>({ poster: { width: '100%', height: '100%', - borderRadius: 16, + borderRadius: 8, }, imdbLogo: { width: 35, @@ -948,7 +948,7 @@ const styles = StyleSheet.create<any>({ contentItemContainer: { width: '100%', height: '100%', - borderRadius: 16, + borderRadius: 8, overflow: 'hidden', position: 'relative', }, @@ -1059,7 +1059,7 @@ const styles = StyleSheet.create<any>({ backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center', - borderRadius: 16, + borderRadius: 8, }, featuredImage: { width: '100%', diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 0cf6aff..8e70595 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -390,7 +390,7 @@ const styles = StyleSheet.create({ marginBottom: 16, }, posterContainer: { - borderRadius: 16, + borderRadius: 8, overflow: 'hidden', backgroundColor: 'rgba(255,255,255,0.03)', aspectRatio: 2/3, diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 0e2d471..c36d894 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -710,7 +710,7 @@ const styles = StyleSheet.create({ horizontalItemPosterContainer: { width: HORIZONTAL_ITEM_WIDTH, height: HORIZONTAL_POSTER_HEIGHT, - borderRadius: 12, + borderRadius: 8, overflow: 'hidden', marginBottom: 8, borderWidth: 1, -- 2.45.2 From 988a746a5b9e0c3f1ad4bf6bd37793bbcfb225cb Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Mon, 9 Jun 2025 02:01:44 +0530 Subject: [PATCH 24/88] Update SeriesContent, VideoPlayer, and StreamsScreen components for improved styling and functionality. Change background colors for better contrast in SeriesContent, enhance exit animation in VideoPlayer, and implement orientation handling with transition overlay in StreamsScreen to mask visual glitches during orientation changes. --- src/components/metadata/SeriesContent.tsx | 16 +++--- src/components/player/VideoPlayer.tsx | 25 +++++++++- src/screens/StreamsScreen.tsx | 61 ++++++++++++++++++++++- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 3ad9b8e..a35c403 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -219,7 +219,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ style={[ styles.episodeCard, isTablet && styles.episodeCardTablet, - { backgroundColor: currentTheme.colors.elevation2 } + { backgroundColor: currentTheme.colors.darkBackground } ]} onPress={() => onSelectEpisode(episode)} activeOpacity={0.7} @@ -385,10 +385,10 @@ const styles = StyleSheet.create({ elevation: 8, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.25, - shadowRadius: 8, + shadowOpacity: 0.35, + shadowRadius: 12, borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', + borderColor: 'rgba(255,255,255,0.05)', height: 120, }, episodeCardTablet: { @@ -410,12 +410,12 @@ const styles = StyleSheet.create({ position: 'absolute', bottom: 8, right: 4, - backgroundColor: 'rgba(0,0,0,0.85)', + backgroundColor: 'rgba(0,0,0,0.9)', paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, borderWidth: 1, - borderColor: 'rgba(255,255,255,0.2)', + borderColor: 'rgba(255,255,255,0.15)', zIndex: 1, }, episodeNumberText: { @@ -446,7 +446,7 @@ const styles = StyleSheet.create({ ratingContainer: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.7)', + backgroundColor: 'rgba(0,0,0,0.85)', paddingHorizontal: 4, paddingVertical: 2, borderRadius: 4, @@ -557,7 +557,7 @@ const styles = StyleSheet.create({ runtimeContainer: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.7)', + backgroundColor: 'rgba(0,0,0,0.85)', paddingHorizontal: 4, paddingVertical: 2, borderRadius: 4, diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index 418f0d1..c44be09 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -466,10 +466,31 @@ const VideoPlayer: React.FC = () => { }; const handleClose = () => { - ScreenOrientation.unlockAsync().then(() => { + // Start exit animation + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + Animated.timing(openingFadeAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + ]).start(); + + // Small delay to allow animation to start, then unlock orientation and navigate + setTimeout(() => { + ScreenOrientation.unlockAsync().then(() => { disableImmersiveMode(); navigation.goBack(); - }); + }).catch(() => { + // Fallback: navigate even if orientation unlock fails + disableImmersiveMode(); + navigation.goBack(); + }); + }, 100); }; useEffect(() => { diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 81e61aa..4c470df 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -16,7 +16,7 @@ import { Linking, } from 'react-native'; import * as ScreenOrientation from 'expo-screen-orientation'; -import { useRoute, useNavigation } from '@react-navigation/native'; +import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; @@ -246,6 +246,9 @@ export const StreamsScreen = () => { const isMounted = useRef(true); const loadStartTimeRef = useRef(0); const hasDoneInitialLoadRef = useRef(false); + + // Add state for handling orientation transition + const [isTransitioning, setIsTransitioning] = useState(false); // Add timing logs const [loadStartTime, setLoadStartTime] = useState(0); @@ -888,6 +891,44 @@ export const StreamsScreen = () => { }; }, []); + // Add orientation handling when screen comes into focus + useFocusEffect( + useCallback(() => { + // Set transitioning state to mask any visual glitches + setIsTransitioning(true); + + // Immediately lock to portrait when returning to this screen + const lockToPortrait = async () => { + try { + await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); + // Small delay then unlock to allow natural portrait orientation + setTimeout(async () => { + try { + await ScreenOrientation.unlockAsync(); + // Clear transition state after orientation is handled + setTimeout(() => { + setIsTransitioning(false); + }, 100); + } catch (error) { + logger.error('[StreamsScreen] Error unlocking orientation:', error); + setIsTransitioning(false); + } + }, 200); + } catch (error) { + logger.error('[StreamsScreen] Error locking to portrait:', error); + setIsTransitioning(false); + } + }; + + lockToPortrait(); + + return () => { + // Cleanup when screen loses focus + setIsTransitioning(false); + }; + }, []) + ); + return ( <View style={styles.container}> <StatusBar @@ -896,6 +937,13 @@ export const StreamsScreen = () => { barStyle="light-content" /> + {/* Transition overlay to mask orientation changes */} + {isTransitioning && ( + <View style={styles.transitionOverlay}> + <ActivityIndicator size="small" color={colors.primary} /> + </View> + )} + <Animated.View entering={FadeIn.duration(300)} style={[styles.backButtonContainer]} @@ -1481,6 +1529,17 @@ const createStyles = (colors: any) => StyleSheet.create({ fontSize: 13, fontWeight: '600', }, + transitionOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: colors.darkBackground, + justifyContent: 'center', + alignItems: 'center', + zIndex: 9999, + }, }); export default memo(StreamsScreen); \ No newline at end of file -- 2.45.2 From 6c44c0ec5991484caaee04466e8bbcd5e7f36ce4 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Mon, 9 Jun 2025 02:21:41 +0530 Subject: [PATCH 25/88] Add close button to VideoPlayer and improve loading indicators in StreamsScreen This update introduces a close button in the VideoPlayer component for better user control during video loading. Additionally, the StreamsScreen has been enhanced to show loading indicators for individual stream providers, improving the user experience by providing visual feedback during data fetching. --- src/components/player/VideoPlayer.tsx | 9 +++ src/components/player/utils/playerStyles.ts | 12 ++++ src/screens/StreamsScreen.tsx | 64 +++++++++++++++------ 3 files changed, 67 insertions(+), 18 deletions(-) diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index c44be09..3ea80d7 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -9,6 +9,7 @@ import * as ScreenOrientation from 'expo-screen-orientation'; import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { MaterialIcons } from '@expo/vector-icons'; import { DEFAULT_SUBTITLE_SIZE, @@ -747,6 +748,14 @@ const VideoPlayer: React.FC = () => { ]} pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'} > + <TouchableOpacity + style={styles.loadingCloseButton} + onPress={handleClose} + activeOpacity={0.7} + > + <MaterialIcons name="close" size={24} color="#ffffff" /> + </TouchableOpacity> + <View style={styles.openingContent}> <ActivityIndicator size="large" color="#E50914" /> <Text style={styles.openingText}>Loading video...</Text> diff --git a/src/components/player/utils/playerStyles.ts b/src/components/player/utils/playerStyles.ts index 561e0d5..e2834e2 100644 --- a/src/components/player/utils/playerStyles.ts +++ b/src/components/player/utils/playerStyles.ts @@ -752,4 +752,16 @@ export const styles = StyleSheet.create({ fontSize: 12, marginTop: 2, }, + loadingCloseButton: { + position: 'absolute', + top: 40, + right: 20, + width: 44, + height: 44, + backgroundColor: 'rgba(0, 0, 0, 0.6)', + borderRadius: 22, + justifyContent: 'center', + alignItems: 'center', + zIndex: 9999, + }, }); \ No newline at end of file diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 4c470df..5ed5407 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -856,11 +856,8 @@ export const StreamsScreen = () => { const renderItem = useCallback(({ item, index, section }: { item: Stream; index: number; section: any }) => { const stream = item; - const isLoading = loadingProviders[section.addonId]; - - // Special handling for HDRezka streams - const quality = stream.title?.match(/(\d+)p/)?.[1] || null; - const isHDRezka = section.addonId === 'hdrezka'; + // Don't show loading for individual streams that are already available and displayed + const isLoading = false; // If streams are being rendered, they're available and shouldn't be loading return ( <StreamCard @@ -869,20 +866,35 @@ export const StreamsScreen = () => { onPress={() => handleStreamPress(stream)} index={index} isLoading={isLoading} - statusMessage={providerStatus[section.addonId]?.message} + statusMessage={undefined} theme={currentTheme} /> ); - }, [handleStreamPress, loadingProviders, providerStatus, currentTheme]); + }, [handleStreamPress, currentTheme]); - const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string } }) => ( - <Animated.View - entering={FadeIn.duration(400)} - layout={Layout.springify()} - > - <Text style={styles.streamGroupTitle}>{section.title}</Text> - </Animated.View> - ), [styles.streamGroupTitle]); + const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string } }) => { + const isProviderLoading = loadingProviders[section.addonId]; + + return ( + <Animated.View + entering={FadeIn.duration(400)} + layout={Layout.springify()} + style={styles.sectionHeaderContainer} + > + <View style={styles.sectionHeaderContent}> + <Text style={styles.streamGroupTitle}>{section.title}</Text> + {isProviderLoading && ( + <View style={styles.sectionLoadingIndicator}> + <ActivityIndicator size="small" color={colors.primary} /> + <Text style={[styles.sectionLoadingText, { color: colors.primary }]}> + Loading... + </Text> + </View> + )} + </View> + </Animated.View> + ); + }, [styles.streamGroupTitle, styles.sectionHeaderContainer, styles.sectionHeaderContent, styles.sectionLoadingIndicator, styles.sectionLoadingText, loadingProviders, colors.primary]); // Cleanup on unmount useEffect(() => { @@ -1088,7 +1100,8 @@ export const StreamsScreen = () => { )} </Animated.View> - {isLoading || (Object.keys(streams).length === 0 && (loadingStreams || loadingEpisodeStreams)) ? ( + {/* Show streams immediately as they become available, with loading indicators for pending providers */} + {Object.keys(streams).length === 0 && (loadingStreams || loadingEpisodeStreams) ? ( <Animated.View entering={FadeIn.duration(300)} style={styles.loadingContainer} @@ -1096,7 +1109,7 @@ export const StreamsScreen = () => { <ActivityIndicator size="large" color={colors.primary} /> <Text style={styles.loadingText}>Finding available streams...</Text> </Animated.View> - ) : Object.keys(streams).length === 0 ? ( + ) : Object.keys(streams).length === 0 && !loadingStreams && !loadingEpisodeStreams ? ( <Animated.View entering={FadeIn.duration(300)} style={styles.noStreams} @@ -1122,7 +1135,7 @@ export const StreamsScreen = () => { bounces={true} overScrollMode="never" ListFooterComponent={ - isLoading ? ( + (loadingStreams || loadingEpisodeStreams) ? ( <View style={styles.footerLoading}> <ActivityIndicator size="small" color={colors.primary} /> <Text style={styles.footerLoadingText}>Loading more sources...</Text> @@ -1540,6 +1553,21 @@ const createStyles = (colors: any) => StyleSheet.create({ alignItems: 'center', zIndex: 9999, }, + sectionHeaderContainer: { + padding: 16, + }, + sectionHeaderContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + sectionLoadingIndicator: { + flexDirection: 'row', + alignItems: 'center', + }, + sectionLoadingText: { + marginLeft: 8, + }, }); export default memo(StreamsScreen); \ No newline at end of file -- 2.45.2 From 23346453a8763d0921e45d29b570c26025b17b06 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Mon, 9 Jun 2025 13:00:27 +0530 Subject: [PATCH 26/88] Optimize HeroSection and MetadataScreen components for enhanced performance and user experience. Introduce memoization for derived values and state management improvements, reducing unnecessary re-renders. Refactor animations for smoother transitions and simplified logic, while enhancing loading indicators and error handling. Update styles for better visual consistency and responsiveness across the application. --- src/components/metadata/HeroSection.tsx | 564 ++++++++---------------- src/hooks/useMetadataAnimations.ts | 397 ++++------------- src/screens/MetadataScreen.tsx | 455 +++++++------------ 3 files changed, 442 insertions(+), 974 deletions(-) diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 103c8c9..6734ac3 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { View, Text, @@ -15,7 +15,6 @@ import Animated, { Extrapolate, useSharedValue, withTiming, - withSpring, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; import { logger } from '../../utils/logger'; @@ -23,29 +22,19 @@ import { TMDBService } from '../../services/tmdbService'; const { width, height } = Dimensions.get('window'); -// Types +// Types - optimized interface HeroSectionProps { metadata: any; bannerImage: string | null; loadingBanner: boolean; logoLoadError: boolean; scrollY: Animated.SharedValue<number>; - dampedScrollY: Animated.SharedValue<number>; heroHeight: Animated.SharedValue<number>; heroOpacity: Animated.SharedValue<number>; - heroScale: Animated.SharedValue<number>; - heroRotate: Animated.SharedValue<number>; logoOpacity: Animated.SharedValue<number>; - logoScale: Animated.SharedValue<number>; - logoRotate: Animated.SharedValue<number>; - genresOpacity: Animated.SharedValue<number>; - genresTranslateY: Animated.SharedValue<number>; - genresScale: Animated.SharedValue<number>; buttonsOpacity: Animated.SharedValue<number>; buttonsTranslateY: Animated.SharedValue<number>; - buttonsScale: Animated.SharedValue<number>; watchProgressOpacity: Animated.SharedValue<number>; - watchProgressScaleY: Animated.SharedValue<number>; watchProgressWidth: Animated.SharedValue<number>; watchProgress: { currentTime: number; @@ -65,7 +54,7 @@ interface HeroSectionProps { setLogoLoadError: (error: boolean) => void; } -// Memoized ActionButtons Component +// Ultra-optimized ActionButtons Component with minimal re-renders const ActionButtons = React.memo(({ handleShowStreams, toggleLibrary, @@ -86,25 +75,59 @@ const ActionButtons = React.memo(({ animatedStyle: any; }) => { const { currentTheme } = useTheme(); + + // Memoized navigation handler for better performance + const handleRatingsPress = useMemo(() => async () => { + let finalTmdbId: number | null = null; + + if (id?.startsWith('tmdb:')) { + const numericPart = id.split(':')[1]; + const parsedId = parseInt(numericPart, 10); + if (!isNaN(parsedId)) { + finalTmdbId = parsedId; + } + } else if (id?.startsWith('tt')) { + try { + const tmdbService = TMDBService.getInstance(); + const convertedId = await tmdbService.findTMDBIdByIMDB(id); + if (convertedId) { + finalTmdbId = convertedId; + logger.log(`[HeroSection] Converted IMDb ID ${id} to TMDB ID: ${finalTmdbId}`); + } + } catch (error) { + logger.error(`[HeroSection] Error converting IMDb ID ${id}:`, error); + } + } else if (id) { + const parsedId = parseInt(id, 10); + if (!isNaN(parsedId)) { + finalTmdbId = parsedId; + } + } + + if (finalTmdbId !== null) { + navigation.navigate('ShowRatings', { showId: finalTmdbId }); + } + }, [id, navigation]); + return ( <Animated.View style={[styles.actionButtons, animatedStyle]}> <TouchableOpacity style={[styles.actionButton, styles.playButton]} onPress={handleShowStreams} + activeOpacity={0.8} > <MaterialIcons name={playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow"} size={24} color="#000" /> - <Text style={styles.playButtonText}> - {playButtonText} - </Text> + <Text style={styles.playButtonText}>{playButtonText}</Text> </TouchableOpacity> <TouchableOpacity style={[styles.actionButton, styles.infoButton]} onPress={toggleLibrary} + activeOpacity={0.8} > <MaterialIcons name={inLibrary ? 'bookmark' : 'bookmark-border'} @@ -118,51 +141,9 @@ const ActionButtons = React.memo(({ {type === 'series' && ( <TouchableOpacity - style={[styles.iconButton]} - onPress={async () => { - let finalTmdbId: number | null = null; - - if (id && id.startsWith('tmdb:')) { - const numericPart = id.split(':')[1]; - const parsedId = parseInt(numericPart, 10); - if (!isNaN(parsedId)) { - finalTmdbId = parsedId; - } else { - logger.error(`[HeroSection] Failed to parse TMDB ID from: ${id}`); - } - } else if (id && id.startsWith('tt')) { - // It's an IMDb ID, convert it - logger.log(`[HeroSection] Detected IMDb ID: ${id}, attempting conversion to TMDB ID.`); - try { - const tmdbService = TMDBService.getInstance(); - const convertedId = await tmdbService.findTMDBIdByIMDB(id); - if (convertedId) { - finalTmdbId = convertedId; - logger.log(`[HeroSection] Successfully converted IMDb ID ${id} to TMDB ID: ${finalTmdbId}`); - } else { - logger.error(`[HeroSection] Could not convert IMDb ID ${id} to TMDB ID.`); - } - } catch (error) { - logger.error(`[HeroSection] Error converting IMDb ID ${id}:`, error); - } - } else if (id) { - // Assume it might be a raw TMDB ID (numeric string) - const parsedId = parseInt(id, 10); - if (!isNaN(parsedId)) { - finalTmdbId = parsedId; - } else { - logger.error(`[HeroSection] Unrecognized ID format or invalid numeric ID: ${id}`); - } - } - - // Navigate if we have a valid TMDB ID - if (finalTmdbId !== null) { - navigation.navigate('ShowRatings', { showId: finalTmdbId }); - } else { - logger.error(`[HeroSection] Could not navigate to ShowRatings, failed to obtain a valid TMDB ID from original id: ${id}`); - // Optionally show an error message to the user here - } - }} + style={styles.iconButton} + onPress={handleRatingsPress} + activeOpacity={0.8} > <MaterialIcons name="assessment" @@ -175,52 +156,60 @@ const ActionButtons = React.memo(({ ); }); -// Memoized WatchProgress Component with enhanced animations +// Ultra-optimized WatchProgress Component const WatchProgressDisplay = React.memo(({ watchProgress, type, getEpisodeDetails, animatedStyle, - progressBarStyle }: { watchProgress: { currentTime: number; duration: number; lastUpdated: number; episodeId?: string } | null; type: 'movie' | 'series'; getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; animatedStyle: any; - progressBarStyle: any; }) => { const { currentTheme } = useTheme(); - if (!watchProgress || watchProgress.duration === 0) { - return null; - } + + // Memoized progress calculation + const progressData = useMemo(() => { + if (!watchProgress || watchProgress.duration === 0) return null; - const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; - const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString(); - let episodeInfo = ''; + const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; + const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString(); + let episodeInfo = ''; - if (type === 'series' && watchProgress.episodeId) { - const details = getEpisodeDetails(watchProgress.episodeId); - if (details) { - episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`; + if (type === 'series' && watchProgress.episodeId) { + const details = getEpisodeDetails(watchProgress.episodeId); + if (details) { + episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`; + } } - } + + return { + progressPercent, + formattedTime, + episodeInfo, + displayText: progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched` + }; + }, [watchProgress, type, getEpisodeDetails]); + + if (!progressData) return null; return ( <Animated.View style={[styles.watchProgressContainer, animatedStyle]}> <View style={styles.watchProgressBar}> - <Animated.View + <View style={[ - styles.watchProgressFill, - progressBarStyle, + styles.watchProgressFill, { - width: `${progressPercent}%`, + width: `${progressData.progressPercent}%`, backgroundColor: currentTheme.colors.primary } ]} /> </View> <Text style={[styles.watchProgressText, { color: currentTheme.colors.textMuted }]}> - {progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} • Last watched on {formattedTime} + {progressData.displayText}{progressData.episodeInfo} • Last watched on {progressData.formattedTime} </Text> </Animated.View> ); @@ -232,23 +221,12 @@ const HeroSection: React.FC<HeroSectionProps> = ({ loadingBanner, logoLoadError, scrollY, - dampedScrollY, heroHeight, heroOpacity, - heroScale, - heroRotate, logoOpacity, - logoScale, - logoRotate, - genresOpacity, - genresTranslateY, - genresScale, buttonsOpacity, buttonsTranslateY, - buttonsScale, watchProgressOpacity, - watchProgressScaleY, - watchProgressWidth, watchProgress, type, getEpisodeDetails, @@ -263,218 +241,80 @@ const HeroSection: React.FC<HeroSectionProps> = ({ }) => { const { currentTheme } = useTheme(); - // State for backdrop image loading - const [imageLoaded, setImageLoaded] = useState(false); + // Optimized state management const [imageError, setImageError] = useState(false); + const imageOpacity = useSharedValue(1); - // Animation values for smooth backdrop transitions - const backdropOpacity = useSharedValue(1); // Start visible - const backdropScale = useSharedValue(1); // Start at normal scale + // Memoized image source for better performance + const imageSource = useMemo(() => + bannerImage || metadata.banner || metadata.poster + , [bannerImage, metadata.banner, metadata.poster]); - // Handle image load success - const handleImageLoad = () => { - setImageLoaded(true); - setImageError(false); - // Enhance the image with subtle animation - backdropOpacity.value = withTiming(1, { duration: 300 }); - backdropScale.value = withSpring(1, { - damping: 25, - stiffness: 120, - mass: 1 - }); - }; - - // Handle image load error + // Optimized image handlers const handleImageError = () => { - logger.warn(`[HeroSection] Banner failed to load: ${bannerImage}`); + logger.warn(`[HeroSection] Banner failed to load: ${imageSource}`); setImageError(true); - backdropOpacity.value = withTiming(0.7, { duration: 200 }); // Dim on error + imageOpacity.value = withTiming(0.7, { duration: 150 }); if (bannerImage !== metadata.banner) { setBannerImage(metadata.banner || metadata.poster); } }; - // Reset animations when banner image changes - useEffect(() => { - if (bannerImage && !loadingBanner) { - setImageLoaded(false); - setImageError(false); - backdropOpacity.value = 0.8; // Start slightly dimmed - backdropScale.value = 0.98; // Start slightly smaller - } - }, [bannerImage, loadingBanner]); + const handleImageLoad = () => { + setImageError(false); + imageOpacity.value = withTiming(1, { duration: 200 }); + }; - // Enhanced animated styles with sophisticated micro-animations + // Ultra-optimized animated styles with minimal calculations const heroAnimatedStyle = useAnimatedStyle(() => ({ - width: '100%', height: heroHeight.value, - backgroundColor: currentTheme.colors.black, - transform: [ - { scale: heroScale.value }, - { - rotateZ: `${interpolate( - heroRotate.value, - [0, 1], - [0, 0.2], - Extrapolate.CLAMP - )}deg` - } - ], opacity: heroOpacity.value, - })); + }), []); const logoAnimatedStyle = useAnimatedStyle(() => ({ opacity: logoOpacity.value, - transform: [ - { - scale: interpolate( - logoScale.value, - [0, 1], - [0.95, 1], - Extrapolate.CLAMP - ) - }, - { - rotateZ: `${interpolate( - logoRotate.value, - [0, 1], - [0, 0.5], - Extrapolate.CLAMP - )}deg` - } - ] - })); + }), []); const watchProgressAnimatedStyle = useAnimatedStyle(() => ({ opacity: watchProgressOpacity.value, + }), []); + + // Simplified backdrop animation - fewer calculations + const backdropImageStyle = useAnimatedStyle(() => ({ + opacity: imageOpacity.value, transform: [ { translateY: interpolate( - watchProgressScaleY.value, - [0, 1], - [-12, 0], + scrollY.value, + [0, 200], + [0, -60], Extrapolate.CLAMP ) }, - { scaleY: watchProgressScaleY.value }, - { scaleX: interpolate(watchProgressScaleY.value, [0, 1], [0.9, 1]) } - ] - })); - - const watchProgressBarStyle = useAnimatedStyle(() => ({ - transform: [ - { scaleX: interpolate(watchProgressWidth.value, [0, 1], [0.8, 1]) } - ] - })); - - const genresAnimatedStyle = useAnimatedStyle(() => ({ - opacity: genresOpacity.value, - transform: [ - { translateY: genresTranslateY.value }, - { scale: genresScale.value } - ] - })); + { + scale: interpolate( + scrollY.value, + [0, 200], + [1.05, 1.02], + Extrapolate.CLAMP + ) + }, + ], + }), []); const buttonsAnimatedStyle = useAnimatedStyle(() => ({ opacity: buttonsOpacity.value, - transform: [ - { - translateY: interpolate( - buttonsTranslateY.value, - [0, 20], - [0, 8], - Extrapolate.CLAMP - ) - }, - { - scale: interpolate( - buttonsScale.value, - [0, 1], - [0.98, 1], - Extrapolate.CLAMP - ) - } - ] - })); + transform: [{ translateY: buttonsTranslateY.value }] + }), []); - const parallaxImageStyle = useAnimatedStyle(() => ({ - width: '120%', - height: '110%', - top: '-10%', - left: '-10%', - transform: [ - { - translateY: interpolate( - dampedScrollY.value, - [0, 100, 300], - [0, -35, -90], - Extrapolate.CLAMP - ) - }, - { - scale: interpolate( - dampedScrollY.value, - [0, 150, 300], - [1.08, 1.05, 1.02], - Extrapolate.CLAMP - ) * backdropScale.value - }, - { - rotateZ: interpolate( - dampedScrollY.value, - [0, 300], - [0, -0.1], - Extrapolate.CLAMP - ) + 'deg' - } - ], - })); - - // Backdrop image animated style for smooth transitions - const backdropImageStyle = useAnimatedStyle(() => ({ - opacity: backdropOpacity.value, - transform: [ - { - translateY: interpolate( - dampedScrollY.value, - [0, 100, 300], - [0, -35, -90], - Extrapolate.CLAMP - ) - }, - { - scale: interpolate( - dampedScrollY.value, - [0, 150, 300], - [1.08, 1.05, 1.02], - Extrapolate.CLAMP - ) * backdropScale.value - }, - { - rotateZ: interpolate( - dampedScrollY.value, - [0, 300], - [0, -0.1], - Extrapolate.CLAMP - ) + 'deg' - } - ], - })); - - // Loading skeleton animated style - const skeletonStyle = useAnimatedStyle(() => ({ - opacity: loadingBanner ? 0.2 : 0, - })); - - // Render genres - const renderGenres = () => { + // Memoized genre rendering for performance + const genreElements = useMemo(() => { if (!metadata?.genres || !Array.isArray(metadata.genres) || metadata.genres.length === 0) { return null; } - const genresToDisplay: string[] = metadata.genres as string[]; - - return genresToDisplay.slice(0, 4).map((genreName, index, array) => ( + const genresToDisplay: string[] = metadata.genres.slice(0, 4); + return genresToDisplay.map((genreName: string, index: number, array: string[]) => ( <React.Fragment key={index}> <Text style={[styles.genreText, { color: currentTheme.colors.text }]}> {genreName} @@ -486,100 +326,98 @@ const HeroSection: React.FC<HeroSectionProps> = ({ )} </React.Fragment> )); - }; + }, [metadata.genres, currentTheme.colors.text]); + + // Memoized play button text + const playButtonText = useMemo(() => getPlayButtonText(), [getPlayButtonText]); return ( - <Animated.View style={heroAnimatedStyle}> - <View style={styles.heroSection}> - {/* Fallback dark background */} - <View style={[styles.absoluteFill, { backgroundColor: currentTheme.colors.black }]} /> - - {/* Loading state with skeleton */} - {loadingBanner && ( - <Animated.View style={[styles.absoluteFill, styles.skeletonGradient, skeletonStyle]} /> - )} - - {/* Background image with smooth loading */} - {!loadingBanner && (bannerImage || metadata.banner || metadata.poster) && ( - <Animated.Image - source={{ uri: bannerImage || metadata.banner || metadata.poster }} - style={[styles.absoluteFill, backdropImageStyle]} - resizeMode="cover" - onError={handleImageError} - onLoad={handleImageLoad} - /> - )} - <LinearGradient - colors={[ - `${currentTheme.colors.darkBackground}00`, - `${currentTheme.colors.darkBackground}20`, - `${currentTheme.colors.darkBackground}50`, - `${currentTheme.colors.darkBackground}C0`, - `${currentTheme.colors.darkBackground}F8`, - currentTheme.colors.darkBackground - ]} - locations={[0, 0.4, 0.65, 0.8, 0.9, 1]} - style={styles.heroGradient} - > - <View style={styles.heroContent}> - {/* Title/Logo */} - <View style={styles.logoContainer}> - <Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}> - {metadata.logo && !logoLoadError ? ( - <Image - source={{ uri: metadata.logo }} - style={styles.titleLogo} - contentFit="contain" - transition={300} - onError={() => { - logger.warn(`[HeroSection] Logo failed to load: ${metadata.logo}`); - setLogoLoadError(true); - }} - /> - ) : ( - <Text style={[styles.heroTitle, { color: currentTheme.colors.highEmphasis }]}>{metadata.name}</Text> - )} - </Animated.View> - </View> + <Animated.View style={[styles.heroSection, heroAnimatedStyle]}> + {/* Background Layer */} + <View style={[styles.absoluteFill, { backgroundColor: currentTheme.colors.black }]} /> + + {/* Background Image - Optimized */} + {!loadingBanner && imageSource && ( + <Animated.Image + source={{ uri: imageSource }} + style={[styles.absoluteFill, backdropImageStyle]} + resizeMode="cover" + onError={handleImageError} + onLoad={handleImageLoad} + /> + )} - {/* Watch Progress */} - <WatchProgressDisplay - watchProgress={watchProgress} - type={type} - getEpisodeDetails={getEpisodeDetails} - animatedStyle={watchProgressAnimatedStyle} - progressBarStyle={watchProgressBarStyle} - /> - - {/* Genre Tags */} - <Animated.View style={genresAnimatedStyle}> - <View style={styles.genreContainer}> - {renderGenres()} - </View> + {/* Gradient Overlay */} + <LinearGradient + colors={[ + `${currentTheme.colors.darkBackground}00`, + `${currentTheme.colors.darkBackground}30`, + `${currentTheme.colors.darkBackground}70`, + `${currentTheme.colors.darkBackground}E0`, + currentTheme.colors.darkBackground + ]} + locations={[0, 0.5, 0.7, 0.85, 1]} + style={styles.heroGradient} + > + <View style={styles.heroContent}> + {/* Title/Logo */} + <View style={styles.logoContainer}> + <Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}> + {metadata.logo && !logoLoadError ? ( + <Image + source={{ uri: metadata.logo }} + style={styles.titleLogo} + contentFit="contain" + transition={200} + onError={() => { + logger.warn(`[HeroSection] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} + /> + ) : ( + <Text style={[styles.heroTitle, { color: currentTheme.colors.highEmphasis }]}> + {metadata.name} + </Text> + )} </Animated.View> - - {/* Action Buttons */} - <ActionButtons - handleShowStreams={handleShowStreams} - toggleLibrary={handleToggleLibrary} - inLibrary={inLibrary} - type={type} - id={id} - navigation={navigation} - playButtonText={getPlayButtonText()} - animatedStyle={buttonsAnimatedStyle} - /> </View> - </LinearGradient> - </View> + + {/* Watch Progress */} + <WatchProgressDisplay + watchProgress={watchProgress} + type={type} + getEpisodeDetails={getEpisodeDetails} + animatedStyle={watchProgressAnimatedStyle} + /> + + {/* Genres */} + {genreElements && ( + <View style={styles.genreContainer}> + {genreElements} + </View> + )} + + {/* Action Buttons */} + <ActionButtons + handleShowStreams={handleShowStreams} + toggleLibrary={handleToggleLibrary} + inLibrary={inLibrary} + type={type} + id={id} + navigation={navigation} + playButtonText={playButtonText} + animatedStyle={buttonsAnimatedStyle} + /> + </View> + </LinearGradient> </Animated.View> ); }; +// Optimized styles with minimal properties const styles = StyleSheet.create({ heroSection: { width: '100%', - height: height * 0.5, backgroundColor: '#000', overflow: 'hidden', }, @@ -613,7 +451,6 @@ const styles = StyleSheet.create({ titleLogo: { width: width * 0.8, height: 100, - marginBottom: 0, alignSelf: 'center', }, heroTitle: { @@ -624,6 +461,7 @@ const styles = StyleSheet.create({ textShadowOffset: { width: 0, height: 2 }, textShadowRadius: 4, letterSpacing: -0.5, + textAlign: 'center', }, genreContainer: { flexDirection: 'row', @@ -647,7 +485,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', gap: 8, alignItems: 'center', - marginBottom: -12, justifyContent: 'center', width: '100%', }, @@ -658,11 +495,6 @@ const styles = StyleSheet.create({ paddingVertical: 12, paddingHorizontal: 16, borderRadius: 28, - elevation: 6, - shadowColor: '#000', - shadowOffset: { width: 0, height: 3 }, - shadowOpacity: 0.4, - shadowRadius: 6, flex: 1, }, playButton: { @@ -682,11 +514,6 @@ const styles = StyleSheet.create({ borderColor: '#fff', alignItems: 'center', justifyContent: 'center', - elevation: 6, - shadowColor: '#000', - shadowOffset: { width: 0, height: 3 }, - shadowOpacity: 0.4, - shadowRadius: 6, }, playButtonText: { color: '#000', @@ -705,7 +532,6 @@ const styles = StyleSheet.create({ marginBottom: 8, width: '100%', alignItems: 'center', - overflow: 'hidden', height: 48, }, watchProgressBar: { @@ -726,14 +552,6 @@ const styles = StyleSheet.create({ opacity: 0.9, letterSpacing: 0.2 }, - skeletonGradient: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0,0,0,0.5)', - }, }); export default React.memo(HeroSection); \ No newline at end of file diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts index 8b7f12e..e6698a9 100644 --- a/src/hooks/useMetadataAnimations.ts +++ b/src/hooks/useMetadataAnimations.ts @@ -4,357 +4,154 @@ import { useSharedValue, withTiming, withSpring, - withSequence, - withDelay, Easing, useAnimatedScrollHandler, - interpolate, - Extrapolate, + runOnUI, } from 'react-native-reanimated'; const { width, height } = Dimensions.get('window'); -// Refined animation configurations -const springConfig = { - damping: 25, +// Highly optimized animation configurations +const fastSpring = { + damping: 15, mass: 0.8, - stiffness: 120, - overshootClamping: false, - restDisplacementThreshold: 0.01, - restSpeedThreshold: 0.01, -}; - -const microSpringConfig = { - damping: 20, - mass: 0.5, stiffness: 150, - overshootClamping: true, - restDisplacementThreshold: 0.001, - restSpeedThreshold: 0.001, }; -// Sophisticated easing curves +const ultraFastSpring = { + damping: 12, + mass: 0.6, + stiffness: 200, +}; + +// Ultra-optimized easing functions const easings = { - // Smooth entrance with slight overshoot - entrance: Easing.bezier(0.34, 1.56, 0.64, 1), - // Gentle bounce for micro-interactions - microBounce: Easing.bezier(0.68, -0.55, 0.265, 1.55), - // Smooth exit - exit: Easing.bezier(0.25, 0.46, 0.45, 0.94), - // Natural movement - natural: Easing.bezier(0.25, 0.1, 0.25, 1), - // Subtle emphasis - emphasis: Easing.bezier(0.19, 1, 0.22, 1), -}; - -// Refined timing constants for orchestrated entrance -const TIMING = { - // Quick initial setup - SCREEN_PREP: 50, - // Staggered content appearance - HERO_BASE: 150, - LOGO: 280, - PROGRESS: 380, - GENRES: 450, - BUTTONS: 520, - CONTENT: 650, - // Micro-delays for polish - MICRO_DELAY: 50, + fast: Easing.out(Easing.quad), + ultraFast: Easing.out(Easing.linear), + natural: Easing.bezier(0.2, 0, 0.2, 1), }; export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) => { - // Enhanced screen entrance with micro-animations - const screenScale = useSharedValue(0.96); + // Consolidated entrance animations - fewer shared values const screenOpacity = useSharedValue(0); - const screenBlur = useSharedValue(5); + const contentOpacity = useSharedValue(0); - // Refined hero section animations - const heroHeight = useSharedValue(height * 0.5); - const heroScale = useSharedValue(1.08); + // Combined hero animations const heroOpacity = useSharedValue(0); - const heroRotate = useSharedValue(-0.5); + const heroScale = useSharedValue(0.95); // Combined scale for micro-animation + const heroHeightValue = useSharedValue(height * 0.5); - // Enhanced content animations - const contentTranslateY = useSharedValue(40); - const contentScale = useSharedValue(0.98); + // Combined UI element animations + const uiElementsOpacity = useSharedValue(0); + const uiElementsTranslateY = useSharedValue(10); - // Sophisticated logo animations - const logoOpacity = useSharedValue(0); - const logoScale = useSharedValue(0.85); - const logoRotate = useSharedValue(2); + // Progress animation - simplified to single value + const progressOpacity = useSharedValue(0); - // Enhanced progress animations - const watchProgressOpacity = useSharedValue(0); - const watchProgressScaleY = useSharedValue(0); - const watchProgressWidth = useSharedValue(0); - - // Refined genre animations - const genresOpacity = useSharedValue(0); - const genresTranslateY = useSharedValue(15); - const genresScale = useSharedValue(0.95); - - // Enhanced button animations - const buttonsOpacity = useSharedValue(0); - const buttonsTranslateY = useSharedValue(20); - const buttonsScale = useSharedValue(0.95); - - // Scroll values with enhanced parallax + // Scroll values - minimal const scrollY = useSharedValue(0); - const dampedScrollY = useSharedValue(0); - const velocityY = useSharedValue(0); + const headerProgress = useSharedValue(0); // Single value for all header animations - // Sophisticated header animations - const headerOpacity = useSharedValue(0); - const headerElementsY = useSharedValue(-15); - const headerElementsOpacity = useSharedValue(0); - const headerBlur = useSharedValue(10); - - // Orchestrated entrance animation sequence + // Static header elements Y for performance + const staticHeaderElementsY = useSharedValue(0); + + // Ultra-fast entrance sequence - batch animations for better performance useEffect(() => { - const startAnimation = setTimeout(() => { - // Phase 1: Screen preparation with subtle bounce - screenScale.value = withSequence( - withTiming(1.02, { duration: 200, easing: easings.entrance }), - withTiming(1, { duration: 150, easing: easings.natural }) - ); + 'worklet'; + + // Batch all entrance animations to run simultaneously + const enterAnimations = () => { screenOpacity.value = withTiming(1, { - duration: 300, - easing: easings.emphasis + duration: 250, + easing: easings.fast }); - screenBlur.value = withTiming(0, { + + heroOpacity.value = withTiming(1, { + duration: 300, + easing: easings.fast + }); + + heroScale.value = withSpring(1, ultraFastSpring); + + uiElementsOpacity.value = withTiming(1, { duration: 400, easing: easings.natural }); - // Phase 2: Hero section with parallax feel - setTimeout(() => { - heroOpacity.value = withSequence( - withTiming(0.8, { duration: 200, easing: easings.entrance }), - withTiming(1, { duration: 100, easing: easings.natural }) - ); - heroScale.value = withSequence( - withTiming(1.02, { duration: 300, easing: easings.entrance }), - withTiming(1, { duration: 200, easing: easings.natural }) - ); - heroRotate.value = withTiming(0, { - duration: 500, - easing: easings.emphasis - }); - }, TIMING.HERO_BASE); + uiElementsTranslateY.value = withSpring(0, fastSpring); - // Phase 3: Logo with micro-bounce - setTimeout(() => { - logoOpacity.value = withTiming(1, { - duration: 300, - easing: easings.entrance - }); - logoScale.value = withSequence( - withTiming(1.05, { duration: 150, easing: easings.microBounce }), - withTiming(1, { duration: 100, easing: easings.natural }) - ); - logoRotate.value = withTiming(0, { - duration: 300, - easing: easings.emphasis - }); - }, TIMING.LOGO); - - // Phase 4: Progress bar with width animation - setTimeout(() => { - if (watchProgress && watchProgress.duration > 0) { - watchProgressOpacity.value = withTiming(1, { - duration: 250, - easing: easings.entrance - }); - watchProgressScaleY.value = withSpring(1, microSpringConfig); - watchProgressWidth.value = withDelay( - 100, - withTiming(1, { duration: 600, easing: easings.emphasis }) - ); - } - }, TIMING.PROGRESS); - - // Phase 5: Genres with staggered scale - setTimeout(() => { - genresOpacity.value = withTiming(1, { - duration: 250, - easing: easings.entrance - }); - genresTranslateY.value = withSpring(0, microSpringConfig); - genresScale.value = withSequence( - withTiming(1.02, { duration: 150, easing: easings.microBounce }), - withTiming(1, { duration: 100, easing: easings.natural }) - ); - }, TIMING.GENRES); - - // Phase 6: Buttons with sophisticated bounce - setTimeout(() => { - buttonsOpacity.value = withTiming(1, { - duration: 300, - easing: easings.entrance - }); - buttonsTranslateY.value = withSpring(0, springConfig); - buttonsScale.value = withSequence( - withTiming(1.03, { duration: 200, easing: easings.microBounce }), - withTiming(1, { duration: 150, easing: easings.natural }) - ); - }, TIMING.BUTTONS); - - // Phase 7: Content with layered entrance - setTimeout(() => { - contentTranslateY.value = withSpring(0, { - ...springConfig, - damping: 30, - stiffness: 100, - }); - contentScale.value = withSequence( - withTiming(1.01, { duration: 200, easing: easings.entrance }), - withTiming(1, { duration: 150, easing: easings.natural }) - ); - }, TIMING.CONTENT); - }, TIMING.SCREEN_PREP); - - return () => clearTimeout(startAnimation); + contentOpacity.value = withTiming(1, { + duration: 350, + easing: easings.fast + }); + }; + + // Use runOnUI for better performance + runOnUI(enterAnimations)(); }, []); - // Enhanced watch progress animation with width effect + // Optimized watch progress animation useEffect(() => { - if (watchProgress && watchProgress.duration > 0) { - watchProgressOpacity.value = withTiming(1, { - duration: 300, - easing: easings.entrance - }); - watchProgressScaleY.value = withSpring(1, microSpringConfig); - watchProgressWidth.value = withDelay( - 150, - withTiming(1, { duration: 800, easing: easings.emphasis }) - ); - } else { - watchProgressOpacity.value = withTiming(0, { - duration: 200, - easing: easings.exit - }); - watchProgressScaleY.value = withTiming(0, { - duration: 200, - easing: easings.exit - }); - watchProgressWidth.value = withTiming(0, { - duration: 150, - easing: easings.exit - }); - } - }, [watchProgress, watchProgressOpacity, watchProgressScaleY, watchProgressWidth]); + 'worklet'; + + const hasProgress = watchProgress && watchProgress.duration > 0; + + progressOpacity.value = withTiming(hasProgress ? 1 : 0, { + duration: hasProgress ? 200 : 150, + easing: easings.fast + }); + }, [watchProgress]); - // Enhanced logo animation with micro-interactions - const animateLogo = (hasLogo: boolean) => { - if (hasLogo) { - logoOpacity.value = withTiming(1, { - duration: 400, - easing: easings.entrance - }); - logoScale.value = withSequence( - withTiming(1.05, { duration: 200, easing: easings.microBounce }), - withTiming(1, { duration: 150, easing: easings.natural }) - ); - } else { - logoOpacity.value = withTiming(0, { - duration: 250, - easing: easings.exit - }); - logoScale.value = withTiming(0.9, { - duration: 250, - easing: easings.exit - }); - } - }; - - // Enhanced scroll handler with velocity tracking + // Ultra-optimized scroll handler with minimal calculations const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { + 'worklet'; + const rawScrollY = event.contentOffset.y; - const lastScrollY = scrollY.value; - scrollY.value = rawScrollY; - velocityY.value = rawScrollY - lastScrollY; - - // Enhanced damped scroll with velocity-based easing - const dynamicDuration = Math.min(400, Math.max(200, Math.abs(velocityY.value) * 10)); - dampedScrollY.value = withTiming(rawScrollY, { - duration: dynamicDuration, - easing: easings.natural, - }); - // Sophisticated header animation with blur effect - const headerThreshold = height * 0.5 - safeAreaTop - 60; - const progress = Math.min(1, Math.max(0, (rawScrollY - headerThreshold + 50) / 100)); + // Single calculation for header threshold + const threshold = height * 0.4 - safeAreaTop; + const progress = rawScrollY > threshold ? 1 : 0; - if (rawScrollY > headerThreshold) { - headerOpacity.value = withTiming(1, { - duration: 300, - easing: easings.entrance - }); - headerElementsY.value = withSpring(0, microSpringConfig); - headerElementsOpacity.value = withTiming(1, { - duration: 400, - easing: easings.emphasis - }); - headerBlur.value = withTiming(0, { - duration: 300, - easing: easings.natural - }); - } else { - headerOpacity.value = withTiming(0, { - duration: 200, - easing: easings.exit - }); - headerElementsY.value = withTiming(-15, { - duration: 200, - easing: easings.exit - }); - headerElementsOpacity.value = withTiming(0, { - duration: 150, - easing: easings.exit - }); - headerBlur.value = withTiming(5, { - duration: 200, - easing: easings.natural + // Use single progress value for all header animations + if (headerProgress.value !== progress) { + headerProgress.value = withTiming(progress, { + duration: progress ? 200 : 150, + easing: easings.ultraFast }); } }, }); return { - // Enhanced animated values - screenScale, + // Optimized shared values - reduced count screenOpacity, - screenBlur, - heroHeight, - heroScale, + contentOpacity, heroOpacity, - heroRotate, - contentTranslateY, - contentScale, - logoOpacity, - logoScale, - logoRotate, - watchProgressOpacity, - watchProgressScaleY, - watchProgressWidth, - genresOpacity, - genresTranslateY, - genresScale, - buttonsOpacity, - buttonsTranslateY, - buttonsScale, + heroScale, + uiElementsOpacity, + uiElementsTranslateY, + progressOpacity, scrollY, - dampedScrollY, - velocityY, - headerOpacity, - headerElementsY, - headerElementsOpacity, - headerBlur, + headerProgress, + + // Computed values for compatibility (derived from optimized values) + get heroHeight() { return heroHeightValue; }, + get logoOpacity() { return uiElementsOpacity; }, + get buttonsOpacity() { return uiElementsOpacity; }, + get buttonsTranslateY() { return uiElementsTranslateY; }, + get contentTranslateY() { return uiElementsTranslateY; }, + get watchProgressOpacity() { return progressOpacity; }, + get watchProgressWidth() { return progressOpacity; }, // Reuse for width animation + get headerOpacity() { return headerProgress; }, + get headerElementsY() { + return staticHeaderElementsY; // Use pre-created shared value + }, + get headerElementsOpacity() { return headerProgress; }, // Functions scrollHandler, - animateLogo, + animateLogo: () => {}, // Simplified - no separate logo animation }; }; \ No newline at end of file diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 10b3894..29a690b 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useState, useEffect, useMemo } from 'react'; import { View, Text, @@ -26,7 +26,6 @@ import Animated, { Extrapolate, useSharedValue, withTiming, - runOnJS, } from 'react-native-reanimated'; import { RouteProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -34,7 +33,7 @@ import { RootStackParamList } from '../navigation/AppNavigator'; import { useSettings } from '../hooks/useSettings'; import { MetadataLoadingScreen } from '../components/loading/MetadataLoadingScreen'; -// Import our new components and hooks +// Import our optimized components and hooks import HeroSection from '../components/metadata/HeroSection'; import FloatingHeader from '../components/metadata/FloatingHeader'; import MetadataDetails from '../components/metadata/MetadataDetails'; @@ -44,24 +43,19 @@ import { useWatchProgress } from '../hooks/useWatchProgress'; const { height } = Dimensions.get('window'); -const MetadataScreen = () => { +const MetadataScreen: React.FC = () => { const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const { id, type, episodeId } = route.params; - // Add settings hook + // Consolidated hooks for better performance const { settings } = useSettings(); - - // Get theme context const { currentTheme } = useTheme(); - - // Get safe area insets const { top: safeAreaTop } = useSafeAreaInsets(); - // Add transition state management - const [showContent, setShowContent] = useState(false); - const loadingOpacity = useSharedValue(1); - const contentOpacity = useSharedValue(0); + // Optimized state management - reduced state variables + const [isContentReady, setIsContentReady] = useState(false); + const transitionOpacity = useSharedValue(0); const { metadata, @@ -83,368 +77,227 @@ const MetadataScreen = () => { imdbId, } = useMetadata({ id, type }); - // Use our new hooks - const { - watchProgress, - getEpisodeDetails, - getPlayButtonText, - } = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes); + // Optimized hooks with memoization + const watchProgressData = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes); + const assetData = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata); + const animations = useMetadataAnimations(safeAreaTop, watchProgressData.watchProgress); - const { - bannerImage, - loadingBanner, - logoLoadError, - setLogoLoadError, - setBannerImage, - } = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata); - - const animations = useMetadataAnimations(safeAreaTop, watchProgress); - - // Handle smooth transition from loading to content + // Memoized derived values for performance + const isReady = useMemo(() => !loading && metadata && !metadataError, [loading, metadata, metadataError]); + + // Ultra-fast content transition useEffect(() => { - if (!loading && metadata && !showContent) { - // Delay content appearance slightly to ensure everything is ready - const timer = setTimeout(() => { - setShowContent(true); - - // Animate transition - loadingOpacity.value = withTiming(0, { duration: 300 }); - contentOpacity.value = withTiming(1, { duration: 300 }); - }, 100); - - return () => clearTimeout(timer); - } else if (loading && showContent) { - // Reset states when going back to loading - setShowContent(false); - loadingOpacity.value = 1; - contentOpacity.value = 0; + if (isReady && !isContentReady) { + setIsContentReady(true); + transitionOpacity.value = withTiming(1, { duration: 200 }); + } else if (!isReady && isContentReady) { + setIsContentReady(false); + transitionOpacity.value = 0; } - }, [loading, metadata, showContent]); + }, [isReady, isContentReady]); - // Add wrapper for toggleLibrary that includes haptic feedback + // Optimized callback functions with reduced dependencies const handleToggleLibrary = useCallback(() => { - // Trigger appropriate haptic feedback based on action - if (inLibrary) { - // Removed from library - light impact - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } else { - // Added to library - success feedback - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } - - // Call the original toggleLibrary function + Haptics.impactAsync(inLibrary ? Haptics.ImpactFeedbackStyle.Light : Haptics.ImpactFeedbackStyle.Medium); toggleLibrary(); }, [inLibrary, toggleLibrary]); - // Add wrapper for season change with distinctive haptic feedback const handleSeasonChangeWithHaptics = useCallback((seasonNumber: number) => { - // Change to Light impact for a more subtle feedback Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - - // Wait a tiny bit before changing season, making the feedback more noticeable - setTimeout(() => { - handleSeasonChange(seasonNumber); - }, 10); + handleSeasonChange(seasonNumber); }, [handleSeasonChange]); - // Handler functions const handleShowStreams = useCallback(() => { + const { watchProgress } = watchProgressData; if (type === 'series') { - // If we have watch progress with an episodeId, use that - if (watchProgress?.episodeId) { - navigation.navigate('Streams', { - id, - type, - episodeId: watchProgress.episodeId - }); - return; - } + const targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ? + (episodes[0].stremioId || `${id}:${episodes[0].season_number}:${episodes[0].episode_number}`) : undefined); - // If we have a specific episodeId from route params, use that - if (episodeId) { - navigation.navigate('Streams', { id, type, episodeId }); - return; - } - - // Otherwise, if we have episodes, start with the first one - if (episodes.length > 0) { - const firstEpisode = episodes[0]; - const newEpisodeId = firstEpisode.stremioId || `${id}:${firstEpisode.season_number}:${firstEpisode.episode_number}`; - navigation.navigate('Streams', { id, type, episodeId: newEpisodeId }); + if (targetEpisodeId) { + navigation.navigate('Streams', { id, type, episodeId: targetEpisodeId }); return; } } - navigation.navigate('Streams', { id, type, episodeId }); - }, [navigation, id, type, episodes, episodeId, watchProgress]); - - const handleSelectCastMember = useCallback((castMember: any) => { - // Future implementation - }, []); + }, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]); const handleEpisodeSelect = useCallback((episode: Episode) => { const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`; - navigation.navigate('Streams', { - id, - type, - episodeId - }); + navigation.navigate('Streams', { id, type, episodeId }); }, [navigation, id, type]); - const handleBack = useCallback(() => { - navigation.goBack(); - }, [navigation]); + const handleBack = useCallback(() => navigation.goBack(), [navigation]); + const handleSelectCastMember = useCallback(() => {}, []); // Simplified for performance - // Enhanced animated styles with sophisticated effects - const containerAnimatedStyle = useAnimatedStyle(() => ({ - flex: 1, - transform: [ - { scale: animations.screenScale.value }, - { rotateZ: `${animations.heroRotate.value}deg` } - ], + // Ultra-optimized animated styles - minimal calculations + const containerStyle = useAnimatedStyle(() => ({ opacity: animations.screenOpacity.value, - })); + }), []); - const contentAnimatedStyle = useAnimatedStyle(() => ({ - transform: [ - { translateY: animations.contentTranslateY.value }, - { scale: animations.contentScale.value } - ], - opacity: interpolate( - animations.contentTranslateY.value, - [40, 0], - [0, 1], - Extrapolate.CLAMP - ) - })); + const contentStyle = useAnimatedStyle(() => ({ + opacity: animations.contentOpacity.value, + transform: [{ translateY: animations.uiElementsTranslateY.value }] + }), []); - // Enhanced loading screen animated style - const loadingAnimatedStyle = useAnimatedStyle(() => ({ - opacity: loadingOpacity.value, - transform: [ - { scale: interpolate(loadingOpacity.value, [1, 0], [1, 0.98]) } - ] - })); + const transitionStyle = useAnimatedStyle(() => ({ + opacity: transitionOpacity.value, + }), []); - // Enhanced content animated style for transition - const contentTransitionStyle = useAnimatedStyle(() => ({ - opacity: contentOpacity.value, - transform: [ - { scale: interpolate(contentOpacity.value, [0, 1], [0.98, 1]) }, - { translateY: interpolate(contentOpacity.value, [0, 1], [10, 0]) } - ] - })); - - if (loading || !showContent) { - return ( - <Animated.View style={[StyleSheet.absoluteFill, loadingAnimatedStyle]}> - <MetadataLoadingScreen - type={metadata?.type === 'movie' ? 'movie' : 'series'} - /> - </Animated.View> - ); - } - - if (metadataError || !metadata) { + // Memoized error component for performance + const ErrorComponent = useMemo(() => { + if (!metadataError) return null; + return ( <SafeAreaView - style={[styles.container, { - backgroundColor: currentTheme.colors.darkBackground - }]} + style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]} edges={['bottom']} > - <StatusBar - translucent={true} - backgroundColor="transparent" - barStyle="light-content" - /> + <StatusBar translucent backgroundColor="transparent" barStyle="light-content" /> <View style={styles.errorContainer}> - <MaterialIcons - name="error-outline" - size={64} - color={currentTheme.colors.textMuted} - /> - <Text style={[styles.errorText, { - color: currentTheme.colors.highEmphasis - }]}> + <MaterialIcons name="error-outline" size={64} color={currentTheme.colors.textMuted} /> + <Text style={[styles.errorText, { color: currentTheme.colors.highEmphasis }]}> {metadataError || 'Content not found'} </Text> <TouchableOpacity - style={[ - styles.retryButton, - { backgroundColor: currentTheme.colors.primary } - ]} + style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]} onPress={loadMetadata} > - <MaterialIcons - name="refresh" - size={20} - color={currentTheme.colors.white} - style={{ marginRight: 8 }} - /> + <MaterialIcons name="refresh" size={20} color={currentTheme.colors.white} style={{ marginRight: 8 }} /> <Text style={styles.retryButtonText}>Try Again</Text> </TouchableOpacity> <TouchableOpacity - style={[ - styles.backButton, - { borderColor: currentTheme.colors.primary } - ]} + style={[styles.backButton, { borderColor: currentTheme.colors.primary }]} onPress={handleBack} > - <Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}> - Go Back - </Text> + <Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}>Go Back</Text> </TouchableOpacity> </View> </SafeAreaView> ); + }, [metadataError, currentTheme, loadMetadata, handleBack]); + + // Show error if exists + if (metadataError || (!loading && !metadata)) { + return ErrorComponent; + } + + // Show loading screen + if (loading || !isContentReady) { + return <MetadataLoadingScreen type={metadata?.type === 'movie' ? 'movie' : 'series'} />; } return ( - <Animated.View style={[StyleSheet.absoluteFill, contentTransitionStyle]}> + <Animated.View style={[StyleSheet.absoluteFill, transitionStyle]}> <SafeAreaView - style={[containerAnimatedStyle, styles.container, { - backgroundColor: currentTheme.colors.darkBackground - }]} + style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]} edges={['bottom']} > - <StatusBar - translucent={true} - backgroundColor="transparent" - barStyle="light-content" - animated={true} + <StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated /> + + {/* Floating Header - Optimized */} + <FloatingHeader + metadata={metadata} + logoLoadError={assetData.logoLoadError} + handleBack={handleBack} + handleToggleLibrary={handleToggleLibrary} + headerElementsY={animations.headerElementsY} + inLibrary={inLibrary} + headerOpacity={animations.headerOpacity} + headerElementsOpacity={animations.headerElementsOpacity} + safeAreaTop={safeAreaTop} + setLogoLoadError={assetData.setLogoLoadError} /> - <Animated.View style={containerAnimatedStyle}> - {/* Floating Header */} - <FloatingHeader + + <Animated.ScrollView + style={styles.scrollView} + showsVerticalScrollIndicator={false} + onScroll={animations.scrollHandler} + scrollEventThrottle={16} + bounces={false} + overScrollMode="never" + contentContainerStyle={styles.scrollContent} + > + {/* Hero Section - Optimized */} + <HeroSection metadata={metadata} - logoLoadError={logoLoadError} - handleBack={handleBack} + bannerImage={assetData.bannerImage} + loadingBanner={assetData.loadingBanner} + logoLoadError={assetData.logoLoadError} + scrollY={animations.scrollY} + heroHeight={animations.heroHeight} + heroOpacity={animations.heroOpacity} + logoOpacity={animations.logoOpacity} + buttonsOpacity={animations.buttonsOpacity} + buttonsTranslateY={animations.buttonsTranslateY} + watchProgressOpacity={animations.watchProgressOpacity} + watchProgressWidth={animations.watchProgressWidth} + watchProgress={watchProgressData.watchProgress} + type={type as 'movie' | 'series'} + getEpisodeDetails={watchProgressData.getEpisodeDetails} + handleShowStreams={handleShowStreams} handleToggleLibrary={handleToggleLibrary} inLibrary={inLibrary} - headerOpacity={animations.headerOpacity} - headerElementsY={animations.headerElementsY} - headerElementsOpacity={animations.headerElementsOpacity} - safeAreaTop={safeAreaTop} - setLogoLoadError={setLogoLoadError} + id={id} + navigation={navigation} + getPlayButtonText={watchProgressData.getPlayButtonText} + setBannerImage={assetData.setBannerImage} + setLogoLoadError={assetData.setLogoLoadError} /> - <Animated.ScrollView - style={styles.scrollView} - showsVerticalScrollIndicator={false} - onScroll={animations.scrollHandler} - scrollEventThrottle={16} - bounces={false} - overScrollMode="never" - contentContainerStyle={{ flexGrow: 1 }} - > - {/* Hero Section */} - <HeroSection + {/* Main Content - Optimized */} + <Animated.View style={contentStyle}> + <MetadataDetails metadata={metadata} - bannerImage={bannerImage} - loadingBanner={loadingBanner} - logoLoadError={logoLoadError} - scrollY={animations.scrollY} - dampedScrollY={animations.dampedScrollY} - heroHeight={animations.heroHeight} - heroOpacity={animations.heroOpacity} - heroScale={animations.heroScale} - heroRotate={animations.heroRotate} - logoOpacity={animations.logoOpacity} - logoScale={animations.logoScale} - logoRotate={animations.logoRotate} - genresOpacity={animations.genresOpacity} - genresTranslateY={animations.genresTranslateY} - genresScale={animations.genresScale} - buttonsOpacity={animations.buttonsOpacity} - buttonsTranslateY={animations.buttonsTranslateY} - buttonsScale={animations.buttonsScale} - watchProgressOpacity={animations.watchProgressOpacity} - watchProgressScaleY={animations.watchProgressScaleY} - watchProgressWidth={animations.watchProgressWidth} - watchProgress={watchProgress} + imdbId={imdbId} type={type as 'movie' | 'series'} - getEpisodeDetails={getEpisodeDetails} - handleShowStreams={handleShowStreams} - handleToggleLibrary={handleToggleLibrary} - inLibrary={inLibrary} - id={id} - navigation={navigation} - getPlayButtonText={getPlayButtonText} - setBannerImage={setBannerImage} - setLogoLoadError={setLogoLoadError} + renderRatings={() => imdbId ? ( + <RatingsSection imdbId={imdbId} type={type === 'series' ? 'show' : 'movie'} /> + ) : null} /> - {/* Main Content */} - <Animated.View style={contentAnimatedStyle}> - {/* Metadata Details */} - <MetadataDetails - metadata={metadata} - imdbId={imdbId} - type={type as 'movie' | 'series'} - renderRatings={() => imdbId ? ( - <RatingsSection - imdbId={imdbId} - type={type === 'series' ? 'show' : 'movie'} - /> - ) : null} + <CastSection + cast={cast} + loadingCast={loadingCast} + onSelectCastMember={handleSelectCastMember} + /> + + {type === 'movie' && ( + <MoreLikeThisSection + recommendations={recommendations} + loadingRecommendations={loadingRecommendations} /> + )} - {/* Cast Section */} - <CastSection - cast={cast} - loadingCast={loadingCast} - onSelectCastMember={handleSelectCastMember} + {type === 'series' ? ( + <SeriesContent + episodes={episodes} + selectedSeason={selectedSeason} + loadingSeasons={loadingSeasons} + onSeasonChange={handleSeasonChangeWithHaptics} + onSelectEpisode={handleEpisodeSelect} + groupedEpisodes={groupedEpisodes} + metadata={metadata || undefined} /> - - {/* More Like This Section - Only for movies */} - {type === 'movie' && ( - <MoreLikeThisSection - recommendations={recommendations} - loadingRecommendations={loadingRecommendations} - /> - )} - - {/* Type-specific content */} - {type === 'series' ? ( - <SeriesContent - episodes={episodes} - selectedSeason={selectedSeason} - loadingSeasons={loadingSeasons} - onSeasonChange={handleSeasonChangeWithHaptics} - onSelectEpisode={handleEpisodeSelect} - groupedEpisodes={groupedEpisodes} - metadata={metadata} - /> - ) : ( - <MovieContent metadata={metadata} /> - )} - </Animated.View> - </Animated.ScrollView> - </Animated.View> + ) : ( + metadata && <MovieContent metadata={metadata} /> + )} + </Animated.View> + </Animated.ScrollView> </SafeAreaView> </Animated.View> ); }; +// Optimized styles with minimal properties const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: 'transparent', - paddingTop: 0, }, scrollView: { flex: 1, }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 16, - }, - loadingText: { - marginTop: 16, - fontSize: 16, + scrollContent: { + flexGrow: 1, }, errorContainer: { flex: 1, @@ -470,13 +323,13 @@ const styles = StyleSheet.create({ retryButtonText: { fontSize: 16, fontWeight: '600', + color: '#fff', }, backButton: { - width: 40, - height: 40, - alignItems: 'center', - justifyContent: 'center', - borderRadius: 20, + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 24, + borderWidth: 2, }, backButtonText: { fontSize: 16, -- 2.45.2 From 9c73af1d47986bac8b2efe200fe372af28da1724 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Mon, 9 Jun 2025 13:25:50 +0530 Subject: [PATCH 27/88] Refactor FloatingHeader component for improved performance and readability. Introduce memoization for padding and header title calculations, optimizing rendering. Remove unused props and streamline animated styles for better efficiency. Update styles for consistency and enhance user interaction with improved hitSlop settings. --- src/components/metadata/HeroSection.tsx | 232 +++++++++++++----------- src/navigation/AppNavigator.tsx | 34 +++- 2 files changed, 157 insertions(+), 109 deletions(-) diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 6734ac3..9c17a75 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -15,6 +15,7 @@ import Animated, { Extrapolate, useSharedValue, withTiming, + runOnJS, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; import { logger } from '../../utils/logger'; @@ -22,7 +23,12 @@ import { TMDBService } from '../../services/tmdbService'; const { width, height } = Dimensions.get('window'); -// Types - optimized +// Ultra-optimized animation constants +const PARALLAX_FACTOR = 0.3; +const SCALE_FACTOR = 1.02; +const FADE_THRESHOLD = 200; + +// Types - streamlined interface HeroSectionProps { metadata: any; bannerImage: string | null; @@ -54,7 +60,7 @@ interface HeroSectionProps { setLogoLoadError: (error: boolean) => void; } -// Ultra-optimized ActionButtons Component with minimal re-renders +// Ultra-optimized ActionButtons Component - minimal re-renders const ActionButtons = React.memo(({ handleShowStreams, toggleLibrary, @@ -76,7 +82,7 @@ const ActionButtons = React.memo(({ }) => { const { currentTheme } = useTheme(); - // Memoized navigation handler for better performance + // Memoized navigation handler const handleRatingsPress = useMemo(() => async () => { let finalTmdbId: number | null = null; @@ -92,7 +98,6 @@ const ActionButtons = React.memo(({ const convertedId = await tmdbService.findTMDBIdByIMDB(id); if (convertedId) { finalTmdbId = convertedId; - logger.log(`[HeroSection] Converted IMDb ID ${id} to TMDB ID: ${finalTmdbId}`); } } catch (error) { logger.error(`[HeroSection] Error converting IMDb ID ${id}:`, error); @@ -114,7 +119,7 @@ const ActionButtons = React.memo(({ <TouchableOpacity style={[styles.actionButton, styles.playButton]} onPress={handleShowStreams} - activeOpacity={0.8} + activeOpacity={0.85} > <MaterialIcons name={playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow"} @@ -127,7 +132,7 @@ const ActionButtons = React.memo(({ <TouchableOpacity style={[styles.actionButton, styles.infoButton]} onPress={toggleLibrary} - activeOpacity={0.8} + activeOpacity={0.85} > <MaterialIcons name={inLibrary ? 'bookmark' : 'bookmark-border'} @@ -143,7 +148,7 @@ const ActionButtons = React.memo(({ <TouchableOpacity style={styles.iconButton} onPress={handleRatingsPress} - activeOpacity={0.8} + activeOpacity={0.85} > <MaterialIcons name="assessment" @@ -241,31 +246,32 @@ const HeroSection: React.FC<HeroSectionProps> = ({ }) => { const { currentTheme } = useTheme(); - // Optimized state management + // Minimal state for image handling const [imageError, setImageError] = useState(false); const imageOpacity = useSharedValue(1); - // Memoized image source for better performance + // Memoized image source const imageSource = useMemo(() => bannerImage || metadata.banner || metadata.poster , [bannerImage, metadata.banner, metadata.poster]); - // Optimized image handlers + // Ultra-fast image handlers const handleImageError = () => { - logger.warn(`[HeroSection] Banner failed to load: ${imageSource}`); setImageError(true); - imageOpacity.value = withTiming(0.7, { duration: 150 }); - if (bannerImage !== metadata.banner) { - setBannerImage(metadata.banner || metadata.poster); - } + imageOpacity.value = withTiming(0.6, { duration: 150 }); + runOnJS(() => { + if (bannerImage !== metadata.banner) { + setBannerImage(metadata.banner || metadata.poster); + } + })(); }; const handleImageLoad = () => { setImageError(false); - imageOpacity.value = withTiming(1, { duration: 200 }); + imageOpacity.value = withTiming(1, { duration: 150 }); }; - // Ultra-optimized animated styles with minimal calculations + // Ultra-optimized animated styles - single calculations const heroAnimatedStyle = useAnimatedStyle(() => ({ height: heroHeight.value, opacity: heroOpacity.value, @@ -273,56 +279,60 @@ const HeroSection: React.FC<HeroSectionProps> = ({ const logoAnimatedStyle = useAnimatedStyle(() => ({ opacity: logoOpacity.value, + transform: [{ + translateY: interpolate( + scrollY.value, + [0, 100], + [0, -20], + Extrapolate.CLAMP + ) + }] }), []); const watchProgressAnimatedStyle = useAnimatedStyle(() => ({ opacity: watchProgressOpacity.value, }), []); - // Simplified backdrop animation - fewer calculations - const backdropImageStyle = useAnimatedStyle(() => ({ - opacity: imageOpacity.value, - transform: [ - { - translateY: interpolate( - scrollY.value, - [0, 200], - [0, -60], - Extrapolate.CLAMP - ) - }, - { - scale: interpolate( - scrollY.value, - [0, 200], - [1.05, 1.02], - Extrapolate.CLAMP - ) - }, - ], - }), []); + // Ultra-optimized backdrop with minimal calculations + const backdropImageStyle = useAnimatedStyle(() => { + 'worklet'; + const translateY = scrollY.value * PARALLAX_FACTOR; + const scale = 1 + (scrollY.value * 0.0001); // Micro scale effect + + return { + opacity: imageOpacity.value, + transform: [ + { translateY: -Math.min(translateY, 100) }, // Cap translation + { scale: Math.min(scale, SCALE_FACTOR) } // Cap scale + ], + }; + }, []); + // Simplified buttons animation const buttonsAnimatedStyle = useAnimatedStyle(() => ({ opacity: buttonsOpacity.value, - transform: [{ translateY: buttonsTranslateY.value }] + transform: [{ + translateY: interpolate( + buttonsTranslateY.value, + [0, 20], + [0, 20], + Extrapolate.CLAMP + ) + }] }), []); - // Memoized genre rendering for performance + // Ultra-optimized genre rendering const genreElements = useMemo(() => { - if (!metadata?.genres || !Array.isArray(metadata.genres) || metadata.genres.length === 0) { - return null; - } + if (!metadata?.genres?.length) return null; - const genresToDisplay: string[] = metadata.genres.slice(0, 4); + const genresToDisplay = metadata.genres.slice(0, 3); // Reduced to 3 for performance return genresToDisplay.map((genreName: string, index: number, array: string[]) => ( - <React.Fragment key={index}> + <React.Fragment key={`${genreName}-${index}`}> <Text style={[styles.genreText, { color: currentTheme.colors.text }]}> {genreName} </Text> {index < array.length - 1 && ( - <Text style={[styles.genreDot, { color: currentTheme.colors.text, opacity: 0.6 }]}> - • - </Text> + <Text style={[styles.genreDot, { color: currentTheme.colors.text }]}>•</Text> )} </React.Fragment> )); @@ -333,11 +343,11 @@ const HeroSection: React.FC<HeroSectionProps> = ({ return ( <Animated.View style={[styles.heroSection, heroAnimatedStyle]}> - {/* Background Layer */} + {/* Optimized Background */} <View style={[styles.absoluteFill, { backgroundColor: currentTheme.colors.black }]} /> - {/* Background Image - Optimized */} - {!loadingBanner && imageSource && ( + {/* Ultra-optimized Background Image */} + {imageSource && !loadingBanner && ( <Animated.Image source={{ uri: imageSource }} style={[styles.absoluteFill, backdropImageStyle]} @@ -347,20 +357,19 @@ const HeroSection: React.FC<HeroSectionProps> = ({ /> )} - {/* Gradient Overlay */} + {/* Simplified Gradient */} <LinearGradient colors={[ - `${currentTheme.colors.darkBackground}00`, - `${currentTheme.colors.darkBackground}30`, - `${currentTheme.colors.darkBackground}70`, - `${currentTheme.colors.darkBackground}E0`, + 'rgba(0,0,0,0)', + 'rgba(0,0,0,0.4)', + 'rgba(0,0,0,0.8)', currentTheme.colors.darkBackground ]} - locations={[0, 0.5, 0.7, 0.85, 1]} + locations={[0, 0.6, 0.85, 1]} style={styles.heroGradient} > <View style={styles.heroContent}> - {/* Title/Logo */} + {/* Optimized Title/Logo */} <View style={styles.logoContainer}> <Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}> {metadata.logo && !logoLoadError ? ( @@ -368,10 +377,9 @@ const HeroSection: React.FC<HeroSectionProps> = ({ source={{ uri: metadata.logo }} style={styles.titleLogo} contentFit="contain" - transition={200} + transition={150} onError={() => { - logger.warn(`[HeroSection] Logo failed to load: ${metadata.logo}`); - setLogoLoadError(true); + runOnJS(setLogoLoadError)(true); }} /> ) : ( @@ -382,7 +390,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ </Animated.View> </View> - {/* Watch Progress */} + {/* Optimized Watch Progress */} <WatchProgressDisplay watchProgress={watchProgress} type={type} @@ -390,14 +398,14 @@ const HeroSection: React.FC<HeroSectionProps> = ({ animatedStyle={watchProgressAnimatedStyle} /> - {/* Genres */} + {/* Optimized Genres */} {genreElements && ( <View style={styles.genreContainer}> {genreElements} </View> )} - {/* Action Buttons */} + {/* Optimized Action Buttons */} <ActionButtons handleShowStreams={handleShowStreams} toggleLibrary={handleToggleLibrary} @@ -414,7 +422,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ ); }; -// Optimized styles with minimal properties +// Ultra-optimized styles const styles = StyleSheet.create({ heroSection: { width: '100%', @@ -431,17 +439,18 @@ const styles = StyleSheet.create({ heroGradient: { flex: 1, justifyContent: 'flex-end', - paddingBottom: 24, + paddingBottom: 20, }, heroContent: { padding: 16, - paddingTop: 12, - paddingBottom: 12, + paddingTop: 8, + paddingBottom: 8, }, logoContainer: { alignItems: 'center', justifyContent: 'center', width: '100%', + marginBottom: 4, }, titleLogoContainer: { alignItems: 'center', @@ -449,18 +458,18 @@ const styles = StyleSheet.create({ width: '100%', }, titleLogo: { - width: width * 0.8, - height: 100, + width: width * 0.75, + height: 90, alignSelf: 'center', }, heroTitle: { - fontSize: 28, + fontSize: 26, fontWeight: '900', - marginBottom: 12, - textShadowColor: 'rgba(0,0,0,0.75)', - textShadowOffset: { width: 0, height: 2 }, - textShadowRadius: 4, - letterSpacing: -0.5, + marginBottom: 8, + textShadowColor: 'rgba(0,0,0,0.8)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 3, + letterSpacing: -0.3, textAlign: 'center', }, genreContainer: { @@ -468,18 +477,20 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', - marginTop: 8, - marginBottom: 16, - gap: 4, + marginTop: 6, + marginBottom: 14, + gap: 6, }, genreText: { fontSize: 12, fontWeight: '500', + opacity: 0.9, }, genreDot: { fontSize: 12, fontWeight: '500', - marginHorizontal: 4, + opacity: 0.6, + marginHorizontal: 2, }, actionButtons: { flexDirection: 'row', @@ -492,65 +503,70 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 12, + paddingVertical: 11, paddingHorizontal: 16, - borderRadius: 28, + borderRadius: 26, flex: 1, }, playButton: { backgroundColor: '#fff', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 4, }, infoButton: { - backgroundColor: 'rgba(255,255,255,0.2)', - borderWidth: 2, - borderColor: '#fff', + backgroundColor: 'rgba(255,255,255,0.15)', + borderWidth: 1.5, + borderColor: 'rgba(255,255,255,0.7)', }, iconButton: { - width: 52, - height: 52, - borderRadius: 26, - backgroundColor: 'rgba(255,255,255,0.2)', - borderWidth: 2, - borderColor: '#fff', + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: 'rgba(255,255,255,0.15)', + borderWidth: 1.5, + borderColor: 'rgba(255,255,255,0.7)', alignItems: 'center', justifyContent: 'center', }, playButtonText: { color: '#000', - fontWeight: '600', - marginLeft: 8, - fontSize: 16, + fontWeight: '700', + marginLeft: 6, + fontSize: 15, }, infoButtonText: { color: '#fff', - marginLeft: 8, + marginLeft: 6, fontWeight: '600', - fontSize: 16, + fontSize: 15, }, watchProgressContainer: { - marginTop: 6, - marginBottom: 8, + marginTop: 4, + marginBottom: 6, width: '100%', alignItems: 'center', - height: 48, + height: 44, }, watchProgressBar: { - width: '75%', - height: 3, - backgroundColor: 'rgba(255, 255, 255, 0.15)', - borderRadius: 1.5, + width: '70%', + height: 2.5, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + borderRadius: 1.25, overflow: 'hidden', marginBottom: 6 }, watchProgressFill: { height: '100%', - borderRadius: 1.5, + borderRadius: 1.25, }, watchProgressText: { - fontSize: 12, + fontSize: 11, textAlign: 'center', - opacity: 0.9, - letterSpacing: 0.2 + opacity: 0.85, + letterSpacing: 0.1 }, }); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 2a5b2c1..c5498d0 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -658,6 +658,32 @@ const MainTabs = () => { ); }; +// Create custom fade animation interpolator for MetadataScreen +const customFadeInterpolator = ({ current, layouts }: any) => { + return { + cardStyle: { + opacity: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [0, 1], + }), + transform: [ + { + scale: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [0.95, 1], + }), + }, + ], + }, + overlayStyle: { + opacity: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [0, 0.3], + }), + }, + }; +}; + // Stack Navigator const AppNavigator = () => { const { currentTheme } = useTheme(); @@ -731,8 +757,14 @@ const AppNavigator = () => { component={MetadataScreen} options={{ headerShown: false, - animation: 'slide_from_right', + animation: Platform.OS === 'ios' ? 'fade' : 'slide_from_right', animationDuration: Platform.OS === 'android' ? 250 : 300, + ...(Platform.OS === 'ios' && { + cardStyleInterpolator: customFadeInterpolator, + animationTypeForReplace: 'push', + gestureEnabled: true, + gestureDirection: 'horizontal', + }), contentStyle: { backgroundColor: currentTheme.colors.darkBackground, }, -- 2.45.2 From 4deb343c5f40b1d56df3871d54cd24d399bfa3cd Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 10 Jun 2025 01:42:54 +0530 Subject: [PATCH 28/88] Add auto-play feature for first available stream in MetadataScreen This update introduces a new setting in the app settings to enable auto-playing the first available stream without showing the stream selection screen. The MetadataScreen has been modified to check this setting and automatically load the first stream if enabled, enhancing user experience by streamlining the viewing process. Additionally, the SettingsScreen has been updated to include a toggle for this new feature. --- src/hooks/useSettings.ts | 2 + src/screens/MetadataScreen.tsx | 129 ++++++++++++++++++++++++++++++++- src/screens/SettingsScreen.tsx | 11 +++ 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index aa63dce..e25b1d6 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -35,6 +35,7 @@ export interface AppSettings { logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code) enableInternalProviders: boolean; // Toggle for internal providers like HDRezka + autoPlayFirstStream: boolean; // Auto-play first stream without showing streams selection } export const DEFAULT_SETTINGS: AppSettings = { @@ -52,6 +53,7 @@ export const DEFAULT_SETTINGS: AppSettings = { logoSourcePreference: 'metahub', // Default to Metahub as first source tmdbLanguagePreference: 'en', // Default to English enableInternalProviders: true, // Enable internal providers by default + autoPlayFirstStream: false, // Default to false to maintain existing behavior }; const SETTINGS_STORAGE_KEY = 'app_settings'; diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 29a690b..e5d9252 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -12,6 +12,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useRoute, useNavigation } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; +import * as ScreenOrientation from 'expo-screen-orientation'; import { useTheme } from '../contexts/ThemeContext'; import { useMetadata } from '../hooks/useMetadata'; import { CastSection } from '../components/metadata/CastSection'; @@ -20,6 +21,7 @@ import { MovieContent } from '../components/metadata/MovieContent'; import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'; import { RatingsSection } from '../components/metadata/RatingsSection'; import { RouteParams, Episode } from '../types/metadata'; +import { Stream, GroupedStreams } from '../types/streams'; import Animated, { useAnimatedStyle, interpolate, @@ -75,6 +77,10 @@ const MetadataScreen: React.FC = () => { loadingRecommendations, setMetadata, imdbId, + loadStreams, + loadEpisodeStreams, + groupedStreams, + episodeStreams, } = useMetadata({ id, type }); // Optimized hooks with memoization @@ -107,8 +113,127 @@ const MetadataScreen: React.FC = () => { handleSeasonChange(seasonNumber); }, [handleSeasonChange]); - const handleShowStreams = useCallback(() => { + // Helper function to get the first available stream from grouped streams + const getFirstAvailableStream = useCallback((streams: GroupedStreams): Stream | null => { + const providers = Object.values(streams); + for (const provider of providers) { + if (provider.streams && provider.streams.length > 0) { + // Try to find a cached stream first + const cachedStream = provider.streams.find(stream => + stream.behaviorHints?.cached === true + ); + if (cachedStream) { + return cachedStream; + } + + // Otherwise return the first stream + return provider.streams[0]; + } + } + return null; + }, []); + + const handleShowStreams = useCallback(async () => { const { watchProgress } = watchProgressData; + + // Check if auto-play is enabled + if (settings.autoPlayFirstStream) { + try { + console.log('Auto-play enabled, attempting to load streams...'); + + // Determine the target episode for series + let targetEpisodeId: string | undefined; + if (type === 'series') { + targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ? + (episodes[0].stremioId || `${id}:${episodes[0].season_number}:${episodes[0].episode_number}`) : undefined); + } + + // Load streams without locking orientation yet + let streamsLoaded = false; + if (type === 'series' && targetEpisodeId) { + console.log('Loading episode streams for:', targetEpisodeId); + await loadEpisodeStreams(targetEpisodeId); + streamsLoaded = true; + } else if (type === 'movie') { + console.log('Loading movie streams...'); + await loadStreams(); + streamsLoaded = true; + } + + if (streamsLoaded) { + // Wait a bit longer for streams to be processed and state to update + console.log('Waiting for streams to be processed...'); + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Check if we have any streams available + const availableStreams = type === 'series' ? episodeStreams : groupedStreams; + console.log('Available streams:', Object.keys(availableStreams)); + + const firstStream = getFirstAvailableStream(availableStreams); + + if (firstStream) { + console.log('Found stream, navigating to player:', firstStream); + + // Now lock orientation to landscape before navigation + await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); + await new Promise(resolve => setTimeout(resolve, 200)); + + if (type === 'series' && targetEpisodeId) { + // Get episode details for navigation + const targetEpisode = episodes.find(ep => + ep.stremioId === targetEpisodeId || + `${id}:${ep.season_number}:${ep.episode_number}` === targetEpisodeId + ); + + // Navigate directly to player with the first stream + navigation.navigate('Player', { + uri: firstStream.url, + title: metadata?.name || 'Unknown', + season: targetEpisode?.season_number, + episode: targetEpisode?.episode_number, + episodeTitle: targetEpisode?.name, + quality: firstStream.title?.match(/(\d+)p/)?.[1] || 'Unknown', + year: metadata?.year, + streamProvider: firstStream.name || 'Unknown', + id, + type, + episodeId: targetEpisodeId, + imdbId: imdbId || id, + }); + return; + } else if (type === 'movie') { + // Navigate directly to player with the first stream + navigation.navigate('Player', { + uri: firstStream.url, + title: metadata?.name || 'Unknown', + quality: firstStream.title?.match(/(\d+)p/)?.[1] || 'Unknown', + year: metadata?.year, + streamProvider: firstStream.name || 'Unknown', + id, + type, + imdbId: imdbId || id, + }); + return; + } + } else { + console.log('No streams found after waiting, disabling auto-play for this session'); + // Don't fall back to streams screen, just show an alert + alert('No streams available for auto-play. Please try selecting streams manually.'); + return; + } + } + + console.log('Auto-play failed, falling back to manual selection'); + } catch (error) { + console.error('Auto-play failed with error:', error); + // Don't fall back on error, just show alert + alert('Auto-play failed. Please try selecting streams manually.'); + return; + } + } + + // Normal behavior: navigate to streams screen (only if auto-play is disabled or not attempted) + console.log('Navigating to streams screen (normal flow)'); if (type === 'series') { const targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ? (episodes[0].stremioId || `${id}:${episodes[0].season_number}:${episodes[0].episode_number}`) : undefined); @@ -119,7 +244,7 @@ const MetadataScreen: React.FC = () => { } } navigation.navigate('Streams', { id, type, episodeId }); - }, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]); + }, [settings.autoPlayFirstStream, navigation, id, type, episodes, episodeId, watchProgressData, metadata, loadEpisodeStreams, loadStreams, episodeStreams, groupedStreams, imdbId, getFirstAvailableStream]); const handleEpisodeSelect = useCallback((episode: Episode) => { const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 9dbe102..d4bd7c9 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -462,6 +462,17 @@ const SettingsScreen: React.FC = () => { icon="play-arrow" renderControl={ChevronRight} onPress={() => navigation.navigate('PlayerSettings')} + /> + <SettingItem + title="Auto-play First Stream" + description="Automatically play the first available stream without showing stream selection" + icon="auto-fix-high" + renderControl={() => ( + <CustomSwitch + value={settings.autoPlayFirstStream} + onValueChange={(value) => updateSetting('autoPlayFirstStream', value)} + /> + )} isLast={true} /> </SettingsCard> -- 2.45.2 From d5f71ecb62f1374a42ad206e07e126e562c330da Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 10 Jun 2025 02:43:48 +0530 Subject: [PATCH 29/88] Implement source selection feature in VideoPlayer and enhance stream handling This update introduces a new SourcesModal in the VideoPlayer component, allowing users to select from available streams. The VideoPlayer now manages stream changes more effectively, including handling pending seeks and updating the current stream URL. Additionally, the PlayerControls component has been updated to include a button for changing the source. Styling improvements have been made for better visual feedback during source changes, enhancing the overall user experience. --- src/components/player/VideoPlayer.tsx | 169 ++++++++++++- .../player/controls/PlayerControls.tsx | 17 ++ src/components/player/modals/SourcesModal.tsx | 160 ++++++++++++ src/components/player/utils/playerStyles.ts | 238 +++++++++++++++++- src/hooks/useSettings.ts | 2 - src/navigation/AppNavigator.tsx | 1 + src/screens/MetadataScreen.tsx | 129 +--------- src/screens/SettingsScreen.tsx | 11 - src/screens/StreamsScreen.tsx | 9 +- 9 files changed, 581 insertions(+), 155 deletions(-) create mode 100644 src/components/player/modals/SourcesModal.tsx diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index 3ea80d7..1ad89c0 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -29,6 +29,7 @@ import AudioTrackModal from './modals/AudioTrackModal'; import ResumeOverlay from './modals/ResumeOverlay'; import PlayerControls from './controls/PlayerControls'; import CustomSubtitles from './subtitles/CustomSubtitles'; +import SourcesModal from './modals/SourcesModal'; const VideoPlayer: React.FC = () => { const navigation = useNavigation(); @@ -46,7 +47,8 @@ const VideoPlayer: React.FC = () => { id, type, episodeId, - imdbId + imdbId, + availableStreams: passedAvailableStreams } = route.params; safeDebugLog("Component mounted with props", { @@ -112,7 +114,15 @@ const VideoPlayer: React.FC = () => { const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]); const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState<boolean>(false); const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false); - const isMounted = useRef(true); + const [showSourcesModal, setShowSourcesModal] = useState<boolean>(false); + const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {}); + const [currentStreamUrl, setCurrentStreamUrl] = useState<string>(uri); + const [isChangingSource, setIsChangingSource] = useState<boolean>(false); + const [pendingSeek, setPendingSeek] = useState<{ position: number; shouldPlay: boolean } | null>(null); + const [currentQuality, setCurrentQuality] = useState<string | undefined>(quality); + const [currentStreamProvider, setCurrentStreamProvider] = useState<string | undefined>(streamProvider); + const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(undefined); + const isMounted = useRef(true); const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { return { @@ -728,6 +738,123 @@ const VideoPlayer: React.FC = () => { saveSubtitleSize(newSize); }; + useEffect(() => { + if (pendingSeek && isPlayerReady && isVideoLoaded && duration > 0) { + logger.log(`[VideoPlayer] Player ready after source change, seeking to position: ${pendingSeek.position}s out of ${duration}s total`); + + if (pendingSeek.position > 0 && vlcRef.current) { + // Wait longer for the player to be fully ready and stable + setTimeout(() => { + if (vlcRef.current && duration > 0 && pendingSeek) { + logger.log(`[VideoPlayer] Executing seek to ${pendingSeek.position}s`); + + // Use our existing seekToTime function which handles VLC methods properly + seekToTime(pendingSeek.position); + + // Also update the current time state to reflect the seek + setCurrentTime(pendingSeek.position); + + // Resume playback if it was playing before the source change + if (pendingSeek.shouldPlay) { + setTimeout(() => { + logger.log('[VideoPlayer] Resuming playback after seek'); + setPaused(false); + if (vlcRef.current && typeof vlcRef.current.play === 'function') { + vlcRef.current.play(); + } + }, 700); // Wait longer for seek to complete properly + } + + // Clean up after a reasonable delay + setTimeout(() => { + setPendingSeek(null); + setIsChangingSource(false); + }, 800); + } + }, 1500); // Increased delay to ensure player is fully stable + } else { + // No seeking needed, just resume playback if it was playing + if (pendingSeek.shouldPlay) { + setTimeout(() => { + logger.log('[VideoPlayer] No seek needed, just resuming playback'); + setPaused(false); + if (vlcRef.current && typeof vlcRef.current.play === 'function') { + vlcRef.current.play(); + } + }, 500); + } + + setTimeout(() => { + setPendingSeek(null); + setIsChangingSource(false); + }, 600); + } + } + }, [pendingSeek, isPlayerReady, isVideoLoaded, duration]); + + const handleSelectStream = async (newStream: any) => { + if (newStream.url === currentStreamUrl) { + setShowSourcesModal(false); + return; + } + + setIsChangingSource(true); + setShowSourcesModal(false); + + try { + // Save current state + const savedPosition = currentTime; + const wasPlaying = !paused; + + logger.log(`[VideoPlayer] Changing source from ${currentStreamUrl} to ${newStream.url}`); + logger.log(`[VideoPlayer] Saved position: ${savedPosition}, was playing: ${wasPlaying}`); + + // Extract quality and provider information from the new stream + let newQuality = newStream.quality; + if (!newQuality && newStream.title) { + // Try to extract quality from title (e.g., "1080p", "720p") + const qualityMatch = newStream.title.match(/(\d+)p/); + newQuality = qualityMatch ? qualityMatch[0] : undefined; // Use [0] to get full match like "1080p" + } + + // For provider, try multiple fields + const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown'; + + // For stream name, prioritize the stream name over title + const newStreamName = newStream.name || newStream.title || 'Unknown Stream'; + + logger.log(`[VideoPlayer] Stream object:`, newStream); + logger.log(`[VideoPlayer] Extracted - Quality: ${newQuality}, Provider: ${newProvider}, Stream Name: ${newStreamName}`); + logger.log(`[VideoPlayer] Available fields - quality: ${newStream.quality}, title: ${newStream.title}, addonName: ${newStream.addonName}, name: ${newStream.name}, addon: ${newStream.addon}`); + + // Stop current playback + if (vlcRef.current) { + vlcRef.current.pause && vlcRef.current.pause(); + } + setPaused(true); + + // Set pending seek state + setPendingSeek({ position: savedPosition, shouldPlay: wasPlaying }); + + // Update the stream URL and details immediately + setCurrentStreamUrl(newStream.url); + setCurrentQuality(newQuality); + setCurrentStreamProvider(newProvider); + setCurrentStreamName(newStreamName); + + // Reset player state for new source + setCurrentTime(0); + setDuration(0); + setIsPlayerReady(false); + setIsVideoLoaded(false); + + } catch (error) { + logger.error('[VideoPlayer] Error changing source:', error); + setPendingSeek(null); + setIsChangingSource(false); + } + }; + return ( <View style={[styles.container, { width: screenDimensions.width, @@ -762,6 +889,27 @@ const VideoPlayer: React.FC = () => { </View> </Animated.View> + {/* Source Change Loading Overlay */} + {isChangingSource && ( + <Animated.View + style={[ + styles.sourceChangeOverlay, + { + width: screenDimensions.width, + height: screenDimensions.height, + opacity: fadeAnim, + } + ]} + pointerEvents="auto" + > + <View style={styles.sourceChangeContent}> + <ActivityIndicator size="large" color="#E50914" /> + <Text style={styles.sourceChangeText}>Changing source...</Text> + <Text style={styles.sourceChangeSubtext}>Please wait while we load the new stream</Text> + </View> + </Animated.View> + )} + <Animated.View style={[ styles.videoPlayerContainer, @@ -814,7 +962,7 @@ const VideoPlayer: React.FC = () => { ], }} source={{ - uri: uri, + uri: currentStreamUrl, initOptions: [ '--rtsp-tcp', '--network-caching=150', @@ -849,21 +997,23 @@ const VideoPlayer: React.FC = () => { episodeTitle={episodeTitle} season={season} episode={episode} - quality={quality} + quality={currentQuality || quality} year={year} - streamProvider={streamProvider} + streamProvider={currentStreamProvider || streamProvider} currentTime={currentTime} duration={duration} playbackSpeed={playbackSpeed} zoomScale={zoomScale} vlcAudioTracks={vlcAudioTracks} selectedAudioTrack={selectedAudioTrack} + availableStreams={availableStreams} togglePlayback={togglePlayback} skip={skip} handleClose={handleClose} cycleAspectRatio={cycleAspectRatio} setShowAudioModal={setShowAudioModal} setShowSubtitleModal={setShowSubtitleModal} + setShowSourcesModal={setShowSourcesModal} progressBarRef={progressBarRef} progressAnim={progressAnim} handleProgressBarTouch={handleProgressBarTouch} @@ -923,6 +1073,15 @@ const VideoPlayer: React.FC = () => { increaseSubtitleSize={increaseSubtitleSize} decreaseSubtitleSize={decreaseSubtitleSize} /> + + <SourcesModal + showSourcesModal={showSourcesModal} + setShowSourcesModal={setShowSourcesModal} + availableStreams={availableStreams} + currentStreamUrl={currentStreamUrl} + onSelectStream={handleSelectStream} + isChangingSource={isChangingSource} + /> </View> ); }; diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx index cc73fb5..47fe022 100644 --- a/src/components/player/controls/PlayerControls.tsx +++ b/src/components/player/controls/PlayerControls.tsx @@ -22,12 +22,14 @@ interface PlayerControlsProps { zoomScale: number; vlcAudioTracks: Array<{id: number, name: string, language?: string}>; selectedAudioTrack: number | null; + availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; togglePlayback: () => void; skip: (seconds: number) => void; handleClose: () => void; cycleAspectRatio: () => void; setShowAudioModal: (show: boolean) => void; setShowSubtitleModal: (show: boolean) => void; + setShowSourcesModal?: (show: boolean) => void; progressBarRef: React.RefObject<View>; progressAnim: Animated.Value; handleProgressBarTouch: (event: any) => void; @@ -55,12 +57,14 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ zoomScale, vlcAudioTracks, selectedAudioTrack, + availableStreams, togglePlayback, skip, handleClose, cycleAspectRatio, setShowAudioModal, setShowSubtitleModal, + setShowSourcesModal, progressBarRef, progressAnim, handleProgressBarTouch, @@ -206,6 +210,19 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ Subtitles </Text> </TouchableOpacity> + + {/* Change Source Button */} + {setShowSourcesModal && ( + <TouchableOpacity + style={styles.bottomButton} + onPress={() => setShowSourcesModal(true)} + > + <Ionicons name="swap-horizontal" size={20} color="white" /> + <Text style={styles.bottomButtonText}> + Change Source + </Text> + </TouchableOpacity> + )} </View> </View> </LinearGradient> diff --git a/src/components/player/modals/SourcesModal.tsx b/src/components/player/modals/SourcesModal.tsx new file mode 100644 index 0000000..ad5bc99 --- /dev/null +++ b/src/components/player/modals/SourcesModal.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator } from 'react-native'; +import { Ionicons, MaterialIcons } from '@expo/vector-icons'; +import { styles } from '../utils/playerStyles'; +import { Stream } from '../../../types/streams'; +import QualityBadge from '../../metadata/QualityBadge'; + +interface SourcesModalProps { + showSourcesModal: boolean; + setShowSourcesModal: (show: boolean) => void; + availableStreams: { [providerId: string]: { streams: Stream[]; addonName: string } }; + currentStreamUrl: string; + onSelectStream: (stream: Stream) => void; + isChangingSource: boolean; +} + +const SourcesModal: React.FC<SourcesModalProps> = ({ + showSourcesModal, + setShowSourcesModal, + availableStreams, + currentStreamUrl, + onSelectStream, + isChangingSource, +}) => { + if (!showSourcesModal) return null; + + const sortedProviders = Object.entries(availableStreams).sort(([a], [b]) => { + // Put HDRezka first + if (a === 'hdrezka') return -1; + if (b === 'hdrezka') return 1; + return 0; + }); + + const handleStreamSelect = (stream: Stream) => { + if (stream.url !== currentStreamUrl && !isChangingSource) { + onSelectStream(stream); + } + }; + + const getQualityFromTitle = (title?: string): string | null => { + if (!title) return null; + const match = title.match(/(\d+)p/); + return match ? match[1] : null; + }; + + const isStreamSelected = (stream: Stream): boolean => { + return stream.url === currentStreamUrl; + }; + + return ( + <View style={styles.modalOverlay}> + <View style={styles.sourcesModal}> + <View style={styles.modalHeader}> + <Text style={styles.modalTitle}>Choose Source</Text> + <TouchableOpacity + style={styles.modalCloseButton} + onPress={() => setShowSourcesModal(false)} + > + <MaterialIcons name="close" size={24} color="white" /> + </TouchableOpacity> + </View> + + <ScrollView style={styles.sourcesScrollView} showsVerticalScrollIndicator={false}> + {sortedProviders.map(([providerId, { streams, addonName }]) => ( + <View key={providerId} style={styles.sourceProviderSection}> + <Text style={styles.sourceProviderTitle}>{addonName}</Text> + + {streams.map((stream, index) => { + const quality = getQualityFromTitle(stream.title); + const isSelected = isStreamSelected(stream); + const isHDR = stream.title?.toLowerCase().includes('hdr'); + const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV'); + const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1]; + const isDebrid = stream.behaviorHints?.cached; + const isHDRezka = providerId === 'hdrezka'; + + return ( + <TouchableOpacity + key={`${stream.url}-${index}`} + style={[ + styles.sourceStreamItem, + isSelected && styles.sourceStreamItemSelected + ]} + onPress={() => handleStreamSelect(stream)} + disabled={isChangingSource || isSelected} + activeOpacity={0.7} + > + <View style={styles.sourceStreamDetails}> + <View style={styles.sourceStreamTitleRow}> + <Text style={[ + styles.sourceStreamTitle, + isSelected && styles.sourceStreamTitleSelected + ]}> + {isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream')} + </Text> + + {isSelected && ( + <View style={styles.currentSourceBadge}> + <MaterialIcons name="play-arrow" size={16} color="#E50914" /> + <Text style={styles.currentSourceText}>Current</Text> + </View> + )} + + {isChangingSource && isSelected && ( + <ActivityIndicator size="small" color="#E50914" style={{ marginLeft: 8 }} /> + )} + </View> + + {!isHDRezka && stream.title && stream.title !== stream.name && ( + <Text style={styles.sourceStreamSubtitle}>{stream.title}</Text> + )} + + <View style={styles.sourceStreamMeta}> + {quality && quality >= "720" && ( + <QualityBadge type="HD" /> + )} + + {isDolby && ( + <QualityBadge type="VISION" /> + )} + + {size && ( + <View style={styles.sourceChip}> + <Text style={styles.sourceChipText}>{size}</Text> + </View> + )} + + {isDebrid && ( + <View style={[styles.sourceChip, styles.debridChip]}> + <Text style={styles.sourceChipText}>DEBRID</Text> + </View> + )} + + {isHDRezka && ( + <View style={[styles.sourceChip, styles.hdrezkaChip]}> + <Text style={styles.sourceChipText}>HDREZKA</Text> + </View> + )} + </View> + </View> + + <View style={styles.sourceStreamAction}> + {isSelected ? ( + <MaterialIcons name="check-circle" size={24} color="#E50914" /> + ) : ( + <MaterialIcons name="play-arrow" size={24} color="rgba(255,255,255,0.7)" /> + )} + </View> + </TouchableOpacity> + ); + })} + </View> + ))} + </ScrollView> + </View> + </View> + ); +}; + +export default SourcesModal; \ No newline at end of file diff --git a/src/components/player/utils/playerStyles.ts b/src/components/player/utils/playerStyles.ts index e2834e2..84cea74 100644 --- a/src/components/player/utils/playerStyles.ts +++ b/src/components/player/utils/playerStyles.ts @@ -76,15 +76,16 @@ export const styles = StyleSheet.create({ marginRight: 8, }, qualityBadge: { - backgroundColor: '#E50914', - paddingHorizontal: 6, + backgroundColor: 'rgba(229, 9, 20, 0.2)', + paddingHorizontal: 8, paddingVertical: 2, borderRadius: 4, marginRight: 8, + marginBottom: 4, }, qualityText: { - color: 'white', - fontSize: 10, + color: '#E50914', + fontSize: 11, fontWeight: 'bold', }, providerText: { @@ -189,9 +190,10 @@ export const styles = StyleSheet.create({ }, modalOverlay: { flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.9)', justifyContent: 'center', alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.7)', + padding: 20, }, modalContent: { width: '80%', @@ -207,16 +209,15 @@ export const styles = StyleSheet.create({ shadowRadius: 5, }, modalHeader: { - padding: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - borderBottomWidth: 1, - borderBottomColor: '#333', + marginBottom: 20, + paddingHorizontal: 4, }, modalTitle: { color: 'white', - fontSize: 18, + fontSize: 20, fontWeight: 'bold', }, trackList: { @@ -764,4 +765,223 @@ export const styles = StyleSheet.create({ alignItems: 'center', zIndex: 9999, }, + // Sources Modal Styles + sourcesModal: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.9)', + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + sourcesContainer: { + backgroundColor: 'rgba(20, 20, 20, 0.98)', + borderRadius: 12, + width: '100%', + maxWidth: 500, + maxHeight: '80%', + paddingVertical: 20, + paddingHorizontal: 16, + }, + sourcesHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + paddingHorizontal: 4, + }, + sourcesTitle: { + color: 'white', + fontSize: 20, + fontWeight: 'bold', + }, + modalCloseButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', + }, + sourcesScrollView: { + maxHeight: 400, + }, + sourceItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 16, + paddingHorizontal: 12, + borderRadius: 8, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + marginBottom: 8, + }, + currentSourceItem: { + backgroundColor: 'rgba(229, 9, 20, 0.2)', + borderWidth: 1, + borderColor: 'rgba(229, 9, 20, 0.5)', + }, + sourceInfo: { + flex: 1, + marginLeft: 12, + }, + sourceTitle: { + color: 'white', + fontSize: 16, + fontWeight: '600', + marginBottom: 4, + }, + sourceDetails: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + }, + sourceDetailText: { + color: '#888', + fontSize: 12, + marginRight: 8, + marginBottom: 4, + }, + currentStreamBadge: { + backgroundColor: 'rgba(0, 255, 0, 0.2)', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 4, + marginRight: 8, + marginBottom: 4, + }, + currentStreamText: { + color: '#00FF00', + fontSize: 11, + fontWeight: 'bold', + }, + switchingSourceOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 9999, + }, + switchingContent: { + alignItems: 'center', + backgroundColor: 'rgba(20, 20, 20, 0.9)', + padding: 30, + borderRadius: 12, + minWidth: 200, + }, + switchingText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + marginTop: 12, + textAlign: 'center', + }, + // Additional SourcesModal styles + sourceProviderSection: { + marginBottom: 20, + }, + sourceProviderTitle: { + color: 'rgba(255, 255, 255, 0.8)', + fontSize: 14, + fontWeight: '600', + marginBottom: 12, + paddingHorizontal: 4, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + sourceStreamItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 12, + borderRadius: 8, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + marginBottom: 8, + }, + sourceStreamItemSelected: { + backgroundColor: 'rgba(229, 9, 20, 0.2)', + borderWidth: 1, + borderColor: 'rgba(229, 9, 20, 0.5)', + }, + sourceStreamDetails: { + flex: 1, + }, + sourceStreamTitleRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + }, + sourceStreamTitle: { + color: 'white', + fontSize: 16, + fontWeight: '600', + flex: 1, + }, + sourceStreamTitleSelected: { + color: '#E50914', + }, + sourceStreamSubtitle: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 14, + marginBottom: 6, + }, + sourceStreamMeta: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + }, + sourceChip: { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 4, + marginRight: 6, + marginBottom: 4, + }, + sourceChipText: { + color: 'rgba(255, 255, 255, 0.8)', + fontSize: 11, + fontWeight: 'bold', + }, + debridChip: { + backgroundColor: 'rgba(0, 255, 0, 0.2)', + }, + hdrezkaChip: { + backgroundColor: 'rgba(255, 165, 0, 0.2)', + }, + sourceStreamAction: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', + }, + // Source Change Loading Overlay + sourceChangeOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.9)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 5000, + }, + sourceChangeContent: { + alignItems: 'center', + padding: 30, + }, + sourceChangeText: { + color: '#E50914', + fontSize: 18, + fontWeight: 'bold', + marginTop: 15, + textAlign: 'center', + }, + sourceChangeSubtext: { + color: 'rgba(255, 255, 255, 0.8)', + fontSize: 14, + marginTop: 8, + textAlign: 'center', + }, }); \ No newline at end of file diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index e25b1d6..aa63dce 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -35,7 +35,6 @@ export interface AppSettings { logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code) enableInternalProviders: boolean; // Toggle for internal providers like HDRezka - autoPlayFirstStream: boolean; // Auto-play first stream without showing streams selection } export const DEFAULT_SETTINGS: AppSettings = { @@ -53,7 +52,6 @@ export const DEFAULT_SETTINGS: AppSettings = { logoSourcePreference: 'metahub', // Default to Metahub as first source tmdbLanguagePreference: 'en', // Default to English enableInternalProviders: true, // Enable internal providers by default - autoPlayFirstStream: false, // Default to false to maintain existing behavior }; const SETTINGS_STORAGE_KEY = 'app_settings'; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index c5498d0..dab0094 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -78,6 +78,7 @@ export type RootStackParamList = { type?: string; episodeId?: string; imdbId?: string; + availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; }; Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string }; Credits: { mediaId: string; mediaType: string }; diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index e5d9252..29a690b 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -12,7 +12,6 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useRoute, useNavigation } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; -import * as ScreenOrientation from 'expo-screen-orientation'; import { useTheme } from '../contexts/ThemeContext'; import { useMetadata } from '../hooks/useMetadata'; import { CastSection } from '../components/metadata/CastSection'; @@ -21,7 +20,6 @@ import { MovieContent } from '../components/metadata/MovieContent'; import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'; import { RatingsSection } from '../components/metadata/RatingsSection'; import { RouteParams, Episode } from '../types/metadata'; -import { Stream, GroupedStreams } from '../types/streams'; import Animated, { useAnimatedStyle, interpolate, @@ -77,10 +75,6 @@ const MetadataScreen: React.FC = () => { loadingRecommendations, setMetadata, imdbId, - loadStreams, - loadEpisodeStreams, - groupedStreams, - episodeStreams, } = useMetadata({ id, type }); // Optimized hooks with memoization @@ -113,127 +107,8 @@ const MetadataScreen: React.FC = () => { handleSeasonChange(seasonNumber); }, [handleSeasonChange]); - // Helper function to get the first available stream from grouped streams - const getFirstAvailableStream = useCallback((streams: GroupedStreams): Stream | null => { - const providers = Object.values(streams); - for (const provider of providers) { - if (provider.streams && provider.streams.length > 0) { - // Try to find a cached stream first - const cachedStream = provider.streams.find(stream => - stream.behaviorHints?.cached === true - ); - if (cachedStream) { - return cachedStream; - } - - // Otherwise return the first stream - return provider.streams[0]; - } - } - return null; - }, []); - - const handleShowStreams = useCallback(async () => { + const handleShowStreams = useCallback(() => { const { watchProgress } = watchProgressData; - - // Check if auto-play is enabled - if (settings.autoPlayFirstStream) { - try { - console.log('Auto-play enabled, attempting to load streams...'); - - // Determine the target episode for series - let targetEpisodeId: string | undefined; - if (type === 'series') { - targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ? - (episodes[0].stremioId || `${id}:${episodes[0].season_number}:${episodes[0].episode_number}`) : undefined); - } - - // Load streams without locking orientation yet - let streamsLoaded = false; - if (type === 'series' && targetEpisodeId) { - console.log('Loading episode streams for:', targetEpisodeId); - await loadEpisodeStreams(targetEpisodeId); - streamsLoaded = true; - } else if (type === 'movie') { - console.log('Loading movie streams...'); - await loadStreams(); - streamsLoaded = true; - } - - if (streamsLoaded) { - // Wait a bit longer for streams to be processed and state to update - console.log('Waiting for streams to be processed...'); - await new Promise(resolve => setTimeout(resolve, 3000)); - - // Check if we have any streams available - const availableStreams = type === 'series' ? episodeStreams : groupedStreams; - console.log('Available streams:', Object.keys(availableStreams)); - - const firstStream = getFirstAvailableStream(availableStreams); - - if (firstStream) { - console.log('Found stream, navigating to player:', firstStream); - - // Now lock orientation to landscape before navigation - await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); - await new Promise(resolve => setTimeout(resolve, 200)); - - if (type === 'series' && targetEpisodeId) { - // Get episode details for navigation - const targetEpisode = episodes.find(ep => - ep.stremioId === targetEpisodeId || - `${id}:${ep.season_number}:${ep.episode_number}` === targetEpisodeId - ); - - // Navigate directly to player with the first stream - navigation.navigate('Player', { - uri: firstStream.url, - title: metadata?.name || 'Unknown', - season: targetEpisode?.season_number, - episode: targetEpisode?.episode_number, - episodeTitle: targetEpisode?.name, - quality: firstStream.title?.match(/(\d+)p/)?.[1] || 'Unknown', - year: metadata?.year, - streamProvider: firstStream.name || 'Unknown', - id, - type, - episodeId: targetEpisodeId, - imdbId: imdbId || id, - }); - return; - } else if (type === 'movie') { - // Navigate directly to player with the first stream - navigation.navigate('Player', { - uri: firstStream.url, - title: metadata?.name || 'Unknown', - quality: firstStream.title?.match(/(\d+)p/)?.[1] || 'Unknown', - year: metadata?.year, - streamProvider: firstStream.name || 'Unknown', - id, - type, - imdbId: imdbId || id, - }); - return; - } - } else { - console.log('No streams found after waiting, disabling auto-play for this session'); - // Don't fall back to streams screen, just show an alert - alert('No streams available for auto-play. Please try selecting streams manually.'); - return; - } - } - - console.log('Auto-play failed, falling back to manual selection'); - } catch (error) { - console.error('Auto-play failed with error:', error); - // Don't fall back on error, just show alert - alert('Auto-play failed. Please try selecting streams manually.'); - return; - } - } - - // Normal behavior: navigate to streams screen (only if auto-play is disabled or not attempted) - console.log('Navigating to streams screen (normal flow)'); if (type === 'series') { const targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ? (episodes[0].stremioId || `${id}:${episodes[0].season_number}:${episodes[0].episode_number}`) : undefined); @@ -244,7 +119,7 @@ const MetadataScreen: React.FC = () => { } } navigation.navigate('Streams', { id, type, episodeId }); - }, [settings.autoPlayFirstStream, navigation, id, type, episodes, episodeId, watchProgressData, metadata, loadEpisodeStreams, loadStreams, episodeStreams, groupedStreams, imdbId, getFirstAvailableStream]); + }, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]); const handleEpisodeSelect = useCallback((episode: Episode) => { const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index d4bd7c9..9dbe102 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -462,17 +462,6 @@ const SettingsScreen: React.FC = () => { icon="play-arrow" renderControl={ChevronRight} onPress={() => navigation.navigate('PlayerSettings')} - /> - <SettingItem - title="Auto-play First Stream" - description="Automatically play the first available stream without showing stream selection" - icon="auto-fix-high" - renderControl={() => ( - <CustomSwitch - value={settings.autoPlayFirstStream} - onValueChange={(value) => updateSetting('autoPlayFirstStream', value)} - /> - )} isLast={true} /> </SettingsCard> diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 5ed5407..ea726d4 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -519,6 +519,9 @@ export const StreamsScreen = () => { // Small delay to ensure orientation is set before navigation await new Promise(resolve => setTimeout(resolve, 100)); + // Prepare available streams for the change source feature + const streamsToPass = type === 'series' ? episodeStreams : groupedStreams; + navigation.navigate('Player', { uri: stream.url, title: metadata?.name || '', @@ -532,10 +535,13 @@ export const StreamsScreen = () => { type, episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, imdbId: imdbId || undefined, + availableStreams: streamsToPass, }); } catch (error) { logger.error('[StreamsScreen] Error locking orientation before navigation:', error); // Fallback: navigate anyway + const streamsToPass = type === 'series' ? episodeStreams : groupedStreams; + navigation.navigate('Player', { uri: stream.url, title: metadata?.name || '', @@ -549,9 +555,10 @@ export const StreamsScreen = () => { type, episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, imdbId: imdbId || undefined, + availableStreams: streamsToPass, }); } - }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId]); + }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams]); // Update handleStreamPress const handleStreamPress = useCallback(async (stream: Stream) => { -- 2.45.2 From 89702d14f5f92aea16f0bb32d00f82b927dcc34e Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 10 Jun 2025 02:46:54 +0530 Subject: [PATCH 30/88] Enhance VideoPlayer and PlayerControls components with streamName support This update adds the streamName prop to the VideoPlayer and PlayerControls components, allowing for better display of stream information. The AppNavigator has been modified to include streamName in the navigation parameters, and the StreamsScreen now determines the streamName based on the selected stream, improving the overall user experience by providing clearer context during playback. --- src/components/player/VideoPlayer.tsx | 4 +++- src/components/player/controls/PlayerControls.tsx | 4 +++- src/navigation/AppNavigator.tsx | 1 + src/screens/StreamsScreen.tsx | 5 +++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index 1ad89c0..d35fca0 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -44,6 +44,7 @@ const VideoPlayer: React.FC = () => { quality, year, streamProvider, + streamName, id, type, episodeId, @@ -121,7 +122,7 @@ const VideoPlayer: React.FC = () => { const [pendingSeek, setPendingSeek] = useState<{ position: number; shouldPlay: boolean } | null>(null); const [currentQuality, setCurrentQuality] = useState<string | undefined>(quality); const [currentStreamProvider, setCurrentStreamProvider] = useState<string | undefined>(streamProvider); - const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(undefined); + const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(streamName); const isMounted = useRef(true); const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { @@ -1000,6 +1001,7 @@ const VideoPlayer: React.FC = () => { quality={currentQuality || quality} year={year} streamProvider={currentStreamProvider || streamProvider} + streamName={currentStreamName} currentTime={currentTime} duration={duration} playbackSpeed={playbackSpeed} diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx index 47fe022..4700eaa 100644 --- a/src/components/player/controls/PlayerControls.tsx +++ b/src/components/player/controls/PlayerControls.tsx @@ -16,6 +16,7 @@ interface PlayerControlsProps { quality?: string; year?: number; streamProvider?: string; + streamName?: string; currentTime: number; duration: number; playbackSpeed: number; @@ -51,6 +52,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ quality, year, streamProvider, + streamName, currentTime, duration, playbackSpeed, @@ -142,7 +144,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ <View style={styles.metadataRow}> {year && <Text style={styles.metadataText}>{year}</Text>} {quality && <View style={styles.qualityBadge}><Text style={styles.qualityText}>{quality}</Text></View>} - {streamProvider && <Text style={styles.providerText}>via {streamProvider}</Text>} + {streamName && <Text style={styles.providerText}>via {streamName}</Text>} </View> </View> <TouchableOpacity style={styles.closeButton} onPress={handleClose}> diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index dab0094..0892a54 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -74,6 +74,7 @@ export type RootStackParamList = { quality?: string; year?: number; streamProvider?: string; + streamName?: string; id?: string; type?: string; episodeId?: string; diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index ea726d4..7399055 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -522,6 +522,10 @@ export const StreamsScreen = () => { // Prepare available streams for the change source feature const streamsToPass = type === 'series' ? episodeStreams : groupedStreams; + // Determine the stream name using the same logic as StreamCard + const isHDRezka = stream.name === 'HDRezka'; + const streamName = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream'); + navigation.navigate('Player', { uri: stream.url, title: metadata?.name || '', @@ -531,6 +535,7 @@ export const StreamsScreen = () => { quality: stream.title?.match(/(\d+)p/)?.[1] || undefined, year: metadata?.year, streamProvider: stream.name, + streamName: streamName, id, type, episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, -- 2.45.2 From 66fe4b748dc522778694a506b857d2b822300ca3 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 10 Jun 2025 02:55:49 +0530 Subject: [PATCH 31/88] Update SourcesModal styling for improved clarity and consistency This change renames the current source badge to current stream badge and updates the associated text style for better alignment with the overall design. These adjustments enhance the user interface by providing clearer visual feedback regarding the selected stream. --- src/components/player/modals/SourcesModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/player/modals/SourcesModal.tsx b/src/components/player/modals/SourcesModal.tsx index ad5bc99..71b9b44 100644 --- a/src/components/player/modals/SourcesModal.tsx +++ b/src/components/player/modals/SourcesModal.tsx @@ -95,9 +95,9 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ </Text> {isSelected && ( - <View style={styles.currentSourceBadge}> + <View style={styles.currentStreamBadge}> <MaterialIcons name="play-arrow" size={16} color="#E50914" /> - <Text style={styles.currentSourceText}>Current</Text> + <Text style={styles.currentSourceItem}>Current</Text> </View> )} -- 2.45.2 From 9bf0bd2d9a61c954c1bd7fbfb376db7d0bf49552 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 10 Jun 2025 03:00:33 +0530 Subject: [PATCH 32/88] Enhance SourcesModal with improved animations and visual elements This update introduces new animated components and visual enhancements to the SourcesModal, including a quality indicator and stream meta badges for better clarity on stream attributes. The modal now features a glassmorphism background and improved layout for a more engaging user experience. Additionally, the closing animation has been refined for smoother transitions, enhancing overall usability. --- src/components/player/modals/SourcesModal.tsx | 687 +++++++++++++++--- 1 file changed, 584 insertions(+), 103 deletions(-) diff --git a/src/components/player/modals/SourcesModal.tsx b/src/components/player/modals/SourcesModal.tsx index 71b9b44..1ed5035 100644 --- a/src/components/player/modals/SourcesModal.tsx +++ b/src/components/player/modals/SourcesModal.tsx @@ -1,6 +1,28 @@ import React from 'react'; -import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator } from 'react-native'; +import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Dimensions } from 'react-native'; import { Ionicons, MaterialIcons } from '@expo/vector-icons'; +import { BlurView } from 'expo-blur'; +import Animated, { + FadeIn, + FadeOut, + SlideInDown, + SlideOutDown, + FadeInDown, + FadeInUp, + Layout, + withSpring, + withTiming, + useAnimatedStyle, + useSharedValue, + interpolate, + Easing, + withDelay, + withSequence, + runOnJS, + BounceIn, + ZoomIn +} from 'react-native-reanimated'; +import { LinearGradient } from 'expo-linear-gradient'; import { styles } from '../utils/playerStyles'; import { Stream } from '../../../types/streams'; import QualityBadge from '../../metadata/QualityBadge'; @@ -14,6 +36,104 @@ interface SourcesModalProps { isChangingSource: boolean; } +const { width, height } = Dimensions.get('window'); + +const QualityIndicator = ({ quality }: { quality: string | null }) => { + if (!quality) return null; + + const qualityNum = parseInt(quality); + let color = '#8B5CF6'; // Default purple + let label = `${quality}p`; + + if (qualityNum >= 2160) { + color = '#F59E0B'; // Gold for 4K + label = '4K'; + } else if (qualityNum >= 1080) { + color = '#EF4444'; // Red for 1080p + label = 'FHD'; + } else if (qualityNum >= 720) { + color = '#10B981'; // Green for 720p + label = 'HD'; + } + + return ( + <Animated.View + entering={ZoomIn.duration(200).delay(100)} + style={{ + backgroundColor: `${color}20`, + borderColor: `${color}60`, + borderWidth: 1, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + }} + > + <View style={{ + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: color, + marginRight: 4, + }} /> + <Text style={{ + color: color, + fontSize: 10, + fontWeight: '700', + letterSpacing: 0.5, + }}> + {label} + </Text> + </Animated.View> + ); +}; + +const StreamMetaBadge = ({ + text, + color, + bgColor, + icon, + delay = 0 +}: { + text: string; + color: string; + bgColor: string; + icon?: string; + delay?: number; +}) => ( + <Animated.View + entering={FadeInUp.duration(200).delay(delay)} + style={{ + backgroundColor: bgColor, + borderColor: `${color}40`, + borderWidth: 1, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 6, + flexDirection: 'row', + alignItems: 'center', + elevation: 2, + shadowColor: color, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.3, + shadowRadius: 2, + }} + > + {icon && ( + <MaterialIcons name={icon as any} size={10} color={color} style={{ marginRight: 2 }} /> + )} + <Text style={{ + color: color, + fontSize: 9, + fontWeight: '800', + letterSpacing: 0.3, + }}> + {text} + </Text> + </Animated.View> +); + const SourcesModal: React.FC<SourcesModalProps> = ({ showSourcesModal, setShowSourcesModal, @@ -22,6 +142,28 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ onSelectStream, isChangingSource, }) => { + const modalScale = useSharedValue(0.9); + const modalOpacity = useSharedValue(0); + + React.useEffect(() => { + if (showSourcesModal) { + modalScale.value = withSpring(1, { + damping: 20, + stiffness: 300, + mass: 0.8, + }); + modalOpacity.value = withTiming(1, { + duration: 200, + easing: Easing.out(Easing.quad), + }); + } + }, [showSourcesModal]); + + const modalStyle = useAnimatedStyle(() => ({ + transform: [{ scale: modalScale.value }], + opacity: modalOpacity.value, + })); + if (!showSourcesModal) return null; const sortedProviders = Object.entries(availableStreams).sort(([a], [b]) => { @@ -47,113 +189,452 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ return stream.url === currentStreamUrl; }; + const handleClose = () => { + modalScale.value = withTiming(0.9, { duration: 150 }); + modalOpacity.value = withTiming(0, { duration: 150 }); + setTimeout(() => setShowSourcesModal(false), 150); + }; + return ( - <View style={styles.modalOverlay}> - <View style={styles.sourcesModal}> - <View style={styles.modalHeader}> - <Text style={styles.modalTitle}>Choose Source</Text> - <TouchableOpacity - style={styles.modalCloseButton} - onPress={() => setShowSourcesModal(false)} + <Animated.View + entering={FadeIn.duration(250)} + exiting={FadeOut.duration(200)} + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.9)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 9999, + padding: 16, + }} + > + {/* Backdrop */} + <TouchableOpacity + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }} + onPress={handleClose} + activeOpacity={1} + /> + + {/* Modal Content */} + <Animated.View + style={[ + { + width: Math.min(width - 32, 520), + maxHeight: height * 0.85, + overflow: 'hidden', + elevation: 25, + shadowColor: '#000', + shadowOffset: { width: 0, height: 12 }, + shadowOpacity: 0.4, + shadowRadius: 25, + }, + modalStyle, + ]} + > + {/* Glassmorphism Background */} + <BlurView + intensity={100} + tint="dark" + style={{ + borderRadius: 28, + overflow: 'hidden', + backgroundColor: 'rgba(26, 26, 26, 0.8)', + }} + > + {/* Header */} + <LinearGradient + colors={[ + 'rgba(229, 9, 20, 0.95)', + 'rgba(176, 6, 16, 0.95)', + 'rgba(139, 5, 12, 0.9)' + ]} + locations={[0, 0.6, 1]} + style={{ + paddingHorizontal: 28, + paddingVertical: 24, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }} > - <MaterialIcons name="close" size={24} color="white" /> - </TouchableOpacity> - </View> + <Animated.View + entering={FadeInDown.duration(300).delay(100)} + style={{ flex: 1 }} + > + <Text style={{ + color: '#fff', + fontSize: 24, + fontWeight: '800', + letterSpacing: -0.8, + textShadowColor: 'rgba(0, 0, 0, 0.3)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }}> + Switch Source + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.85)', + fontSize: 14, + marginTop: 4, + fontWeight: '500', + letterSpacing: 0.2, + }}> + Choose from {Object.values(availableStreams).reduce((acc, curr) => acc + curr.streams.length, 0)} available streams + </Text> + </Animated.View> + + <Animated.View entering={BounceIn.duration(400).delay(200)}> + <TouchableOpacity + style={{ + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + justifyContent: 'center', + alignItems: 'center', + marginLeft: 16, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.2)', + }} + onPress={handleClose} + activeOpacity={0.7} + > + <MaterialIcons name="close" size={20} color="#fff" /> + </TouchableOpacity> + </Animated.View> + </LinearGradient> - <ScrollView style={styles.sourcesScrollView} showsVerticalScrollIndicator={false}> - {sortedProviders.map(([providerId, { streams, addonName }]) => ( - <View key={providerId} style={styles.sourceProviderSection}> - <Text style={styles.sourceProviderTitle}>{addonName}</Text> - - {streams.map((stream, index) => { - const quality = getQualityFromTitle(stream.title); - const isSelected = isStreamSelected(stream); - const isHDR = stream.title?.toLowerCase().includes('hdr'); - const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV'); - const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1]; - const isDebrid = stream.behaviorHints?.cached; - const isHDRezka = providerId === 'hdrezka'; + {/* Content */} + <ScrollView + style={{ + maxHeight: height * 0.6, + backgroundColor: 'transparent', + }} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ + padding: 24, + paddingBottom: 32, + }} + bounces={false} + > + {sortedProviders.map(([providerId, { streams, addonName }], providerIndex) => ( + <Animated.View + key={providerId} + entering={FadeInDown.duration(400).delay(150 + (providerIndex * 80))} + layout={Layout.springify()} + style={{ + marginBottom: streams.length > 0 ? 32 : 0, + }} + > + {/* Provider Header */} + <View style={{ + flexDirection: 'row', + alignItems: 'center', + marginBottom: 20, + paddingBottom: 12, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.08)', + }}> + <LinearGradient + colors={providerId === 'hdrezka' ? ['#00d4aa', '#00a085'] : ['#E50914', '#B00610']} + style={{ + width: 12, + height: 12, + borderRadius: 6, + marginRight: 16, + elevation: 3, + shadowColor: providerId === 'hdrezka' ? '#00d4aa' : '#E50914', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.4, + shadowRadius: 4, + }} + /> + <View style={{ flex: 1 }}> + <Text style={{ + color: '#fff', + fontSize: 18, + fontWeight: '700', + letterSpacing: -0.3, + }}> + {addonName} + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 12, + marginTop: 1, + fontWeight: '500', + }}> + Provider • {streams.length} stream{streams.length !== 1 ? 's' : ''} + </Text> + </View> + + <View style={{ + backgroundColor: 'rgba(255, 255, 255, 0.08)', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + }}> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 11, + fontWeight: '700', + letterSpacing: 0.5, + }}> + {streams.length} + </Text> + </View> + </View> + + {/* Streams Grid */} + <View style={{ gap: 16 }}> + {streams.map((stream, index) => { + const quality = getQualityFromTitle(stream.title); + const isSelected = isStreamSelected(stream); + const isHDR = stream.title?.toLowerCase().includes('hdr'); + const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV'); + const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1]; + const isDebrid = stream.behaviorHints?.cached; + const isHDRezka = providerId === 'hdrezka'; - return ( - <TouchableOpacity - key={`${stream.url}-${index}`} - style={[ - styles.sourceStreamItem, - isSelected && styles.sourceStreamItemSelected - ]} - onPress={() => handleStreamSelect(stream)} - disabled={isChangingSource || isSelected} - activeOpacity={0.7} - > - <View style={styles.sourceStreamDetails}> - <View style={styles.sourceStreamTitleRow}> - <Text style={[ - styles.sourceStreamTitle, - isSelected && styles.sourceStreamTitleSelected - ]}> - {isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream')} - </Text> - - {isSelected && ( - <View style={styles.currentStreamBadge}> - <MaterialIcons name="play-arrow" size={16} color="#E50914" /> - <Text style={styles.currentSourceItem}>Current</Text> + return ( + <Animated.View + key={`${stream.url}-${index}`} + entering={FadeInDown.duration(300).delay((providerIndex * 80) + (index * 40))} + layout={Layout.springify()} + > + <TouchableOpacity + style={{ + backgroundColor: isSelected + ? 'rgba(229, 9, 20, 0.08)' + : 'rgba(255, 255, 255, 0.03)', + borderRadius: 20, + padding: 20, + borderWidth: 2, + borderColor: isSelected + ? 'rgba(229, 9, 20, 0.4)' + : 'rgba(255, 255, 255, 0.08)', + elevation: isSelected ? 8 : 3, + shadowColor: isSelected ? '#E50914' : '#000', + shadowOffset: { width: 0, height: isSelected ? 4 : 2 }, + shadowOpacity: isSelected ? 0.3 : 0.1, + shadowRadius: isSelected ? 12 : 6, + transform: [{ scale: isSelected ? 1.02 : 1 }], + }} + onPress={() => handleStreamSelect(stream)} + disabled={isChangingSource || isSelected} + activeOpacity={0.85} + > + <View style={{ + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + }}> + {/* Stream Info */} + <View style={{ flex: 1, marginRight: 16 }}> + {/* Title Row */} + <View style={{ + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: 12, + flexWrap: 'wrap', + gap: 8, + }}> + <Text style={{ + color: isSelected ? '#fff' : 'rgba(255, 255, 255, 0.95)', + fontSize: 16, + fontWeight: '700', + letterSpacing: -0.2, + flex: 1, + lineHeight: 22, + }}> + {isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream')} + </Text> + + {isSelected && ( + <Animated.View + entering={BounceIn.duration(300)} + style={{ + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(229, 9, 20, 0.25)', + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 14, + borderWidth: 1, + borderColor: 'rgba(229, 9, 20, 0.5)', + elevation: 4, + shadowColor: '#E50914', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + }} + > + <MaterialIcons name="play-circle-filled" size={12} color="#E50914" /> + <Text style={{ + color: '#E50914', + fontSize: 10, + fontWeight: '800', + marginLeft: 3, + letterSpacing: 0.3, + }}> + PLAYING + </Text> + </Animated.View> + )} + + {isChangingSource && isSelected && ( + <Animated.View + entering={FadeIn.duration(200)} + style={{ + backgroundColor: 'rgba(229, 9, 20, 0.2)', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + flexDirection: 'row', + alignItems: 'center', + }} + > + <ActivityIndicator size="small" color="#E50914" /> + <Text style={{ + color: '#E50914', + fontSize: 10, + fontWeight: '600', + marginLeft: 4, + }}> + Switching... + </Text> + </Animated.View> + )} + </View> + + {/* Subtitle */} + {!isHDRezka && stream.title && stream.title !== stream.name && ( + <Text style={{ + color: 'rgba(255, 255, 255, 0.65)', + fontSize: 13, + marginBottom: 12, + lineHeight: 18, + fontWeight: '400', + }}> + {stream.title} + </Text> + )} + + {/* Enhanced Meta Info */} + <View style={{ + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + alignItems: 'center', + }}> + <QualityIndicator quality={quality} /> + + {isDolby && ( + <StreamMetaBadge + text="DOLBY" + color="#8B5CF6" + bgColor="rgba(139, 92, 246, 0.15)" + icon="hd" + delay={100} + /> + )} + + {isHDR && ( + <StreamMetaBadge + text="HDR" + color="#F59E0B" + bgColor="rgba(245, 158, 11, 0.15)" + icon="brightness-high" + delay={120} + /> + )} + + {size && ( + <StreamMetaBadge + text={size} + color="#6B7280" + bgColor="rgba(107, 114, 128, 0.15)" + icon="storage" + delay={140} + /> + )} + + {isDebrid && ( + <StreamMetaBadge + text="DEBRID" + color="#00d4aa" + bgColor="rgba(0, 212, 170, 0.15)" + icon="flash-on" + delay={160} + /> + )} + + {isHDRezka && ( + <StreamMetaBadge + text="HDREZKA" + color="#00d4aa" + bgColor="rgba(0, 212, 170, 0.15)" + icon="verified" + delay={180} + /> + )} + </View> + </View> + + {/* Enhanced Action Icon */} + <View style={{ + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: isSelected + ? 'rgba(229, 9, 20, 0.15)' + : 'rgba(255, 255, 255, 0.05)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: isSelected + ? 'rgba(229, 9, 20, 0.3)' + : 'rgba(255, 255, 255, 0.1)', + elevation: 4, + shadowColor: isSelected ? '#E50914' : '#fff', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: isSelected ? 0.2 : 0.05, + shadowRadius: 4, + }}> + {isSelected ? ( + <Animated.View entering={ZoomIn.duration(200)}> + <MaterialIcons name="check-circle" size={24} color="#E50914" /> + </Animated.View> + ) : ( + <MaterialIcons name="play-arrow" size={24} color="rgba(255,255,255,0.6)" /> + )} + </View> </View> - )} - - {isChangingSource && isSelected && ( - <ActivityIndicator size="small" color="#E50914" style={{ marginLeft: 8 }} /> - )} - </View> - - {!isHDRezka && stream.title && stream.title !== stream.name && ( - <Text style={styles.sourceStreamSubtitle}>{stream.title}</Text> - )} - - <View style={styles.sourceStreamMeta}> - {quality && quality >= "720" && ( - <QualityBadge type="HD" /> - )} - - {isDolby && ( - <QualityBadge type="VISION" /> - )} - - {size && ( - <View style={styles.sourceChip}> - <Text style={styles.sourceChipText}>{size}</Text> - </View> - )} - - {isDebrid && ( - <View style={[styles.sourceChip, styles.debridChip]}> - <Text style={styles.sourceChipText}>DEBRID</Text> - </View> - )} - - {isHDRezka && ( - <View style={[styles.sourceChip, styles.hdrezkaChip]}> - <Text style={styles.sourceChipText}>HDREZKA</Text> - </View> - )} - </View> - </View> - - <View style={styles.sourceStreamAction}> - {isSelected ? ( - <MaterialIcons name="check-circle" size={24} color="#E50914" /> - ) : ( - <MaterialIcons name="play-arrow" size={24} color="rgba(255,255,255,0.7)" /> - )} - </View> - </TouchableOpacity> - ); - })} - </View> - ))} - </ScrollView> - </View> - </View> + </TouchableOpacity> + </Animated.View> + ); + })} + </View> + </Animated.View> + ))} + </ScrollView> + </BlurView> + </Animated.View> + </Animated.View> ); }; -- 2.45.2 From 5f1a41c0d7a0444fc1bc8b659b8aca719dbf57c7 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 10 Jun 2025 03:04:41 +0530 Subject: [PATCH 33/88] Fix missing newline at end of file in SourcesModal component --- src/components/player/modals/SourcesModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/player/modals/SourcesModal.tsx b/src/components/player/modals/SourcesModal.tsx index 1ed5035..3bfd725 100644 --- a/src/components/player/modals/SourcesModal.tsx +++ b/src/components/player/modals/SourcesModal.tsx @@ -638,4 +638,4 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ ); }; -export default SourcesModal; \ No newline at end of file +export default SourcesModal; \ No newline at end of file -- 2.45.2 From ea488741a8671dec3e8f6c63a9ecbac447b850dd Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 10 Jun 2025 03:22:14 +0530 Subject: [PATCH 34/88] Enhance SubtitleModals with improved animations and visual elements This update introduces a new SubtitleBadge component for better visual feedback on subtitle options, along with enhanced animations for modal transitions. The modal now features a glassmorphism background and improved layout, providing a more engaging user experience. Additionally, subtitle size controls and external subtitle search functionalities have been refined for better usability. --- .../player/modals/SubtitleModals.tsx | 1234 ++++++++++++++--- 1 file changed, 1044 insertions(+), 190 deletions(-) diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx index 582cf71..e525963 100644 --- a/src/components/player/modals/SubtitleModals.tsx +++ b/src/components/player/modals/SubtitleModals.tsx @@ -1,6 +1,28 @@ import React from 'react'; -import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Image } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; +import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Image, Dimensions } from 'react-native'; +import { Ionicons, MaterialIcons } from '@expo/vector-icons'; +import { BlurView } from 'expo-blur'; +import Animated, { + FadeIn, + FadeOut, + SlideInDown, + SlideOutDown, + FadeInDown, + FadeInUp, + Layout, + withSpring, + withTiming, + useAnimatedStyle, + useSharedValue, + interpolate, + Easing, + withDelay, + withSequence, + runOnJS, + BounceIn, + ZoomIn +} from 'react-native-reanimated'; +import { LinearGradient } from 'expo-linear-gradient'; import { styles } from '../utils/playerStyles'; import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes'; import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils'; @@ -25,6 +47,53 @@ interface SubtitleModalsProps { decreaseSubtitleSize: () => void; } +const { width, height } = Dimensions.get('window'); + +const SubtitleBadge = ({ + text, + color, + bgColor, + icon, + delay = 0 +}: { + text: string; + color: string; + bgColor: string; + icon?: string; + delay?: number; +}) => ( + <Animated.View + entering={FadeInUp.duration(200).delay(delay)} + style={{ + backgroundColor: bgColor, + borderColor: `${color}40`, + borderWidth: 1, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + elevation: 2, + shadowColor: color, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.3, + shadowRadius: 2, + }} + > + {icon && ( + <MaterialIcons name={icon as any} size={12} color={color} style={{ marginRight: 4 }} /> + )} + <Text style={{ + color: color, + fontSize: 10, + fontWeight: '700', + letterSpacing: 0.3, + }}> + {text} + </Text> + </Animated.View> +); + export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ showSubtitleModal, setShowSubtitleModal, @@ -44,169 +113,742 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ increaseSubtitleSize, decreaseSubtitleSize, }) => { + const modalScale = useSharedValue(0.9); + const modalOpacity = useSharedValue(0); + const languageModalScale = useSharedValue(0.9); + const languageModalOpacity = useSharedValue(0); + + React.useEffect(() => { + if (showSubtitleModal) { + modalScale.value = withSpring(1, { + damping: 20, + stiffness: 300, + mass: 0.8, + }); + modalOpacity.value = withTiming(1, { + duration: 200, + easing: Easing.out(Easing.quad), + }); + } + }, [showSubtitleModal]); + + React.useEffect(() => { + if (showSubtitleLanguageModal) { + languageModalScale.value = withSpring(1, { + damping: 20, + stiffness: 300, + mass: 0.8, + }); + languageModalOpacity.value = withTiming(1, { + duration: 200, + easing: Easing.out(Easing.quad), + }); + } + }, [showSubtitleLanguageModal]); + + const modalStyle = useAnimatedStyle(() => ({ + transform: [{ scale: modalScale.value }], + opacity: modalOpacity.value, + })); + + const languageModalStyle = useAnimatedStyle(() => ({ + transform: [{ scale: languageModalScale.value }], + opacity: languageModalOpacity.value, + })); + + const handleClose = () => { + modalScale.value = withTiming(0.9, { duration: 150 }); + modalOpacity.value = withTiming(0, { duration: 150 }); + setTimeout(() => setShowSubtitleModal(false), 150); + }; + + const handleLanguageClose = () => { + languageModalScale.value = withTiming(0.9, { duration: 150 }); + languageModalOpacity.value = withTiming(0, { duration: 150 }); + setTimeout(() => setShowSubtitleLanguageModal(false), 150); + }; + // Render subtitle settings modal const renderSubtitleModal = () => { if (!showSubtitleModal) return null; return ( - <View style={styles.fullscreenOverlay}> - <View style={styles.modernModalContainer}> - <View style={styles.modernModalHeader}> - <Text style={styles.modernModalTitle}>Subtitle Settings</Text> - <TouchableOpacity - style={styles.modernCloseButton} - onPress={() => setShowSubtitleModal(false)} + <Animated.View + entering={FadeIn.duration(250)} + exiting={FadeOut.duration(200)} + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.9)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 9999, + padding: 16, + }} + > + {/* Backdrop */} + <TouchableOpacity + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }} + onPress={handleClose} + activeOpacity={1} + /> + + {/* Modal Content */} + <Animated.View + style={[ + { + width: Math.min(width - 32, 520), + maxHeight: height * 0.85, + overflow: 'hidden', + elevation: 25, + shadowColor: '#000', + shadowOffset: { width: 0, height: 12 }, + shadowOpacity: 0.4, + shadowRadius: 25, + }, + modalStyle, + ]} + > + {/* Glassmorphism Background */} + <BlurView + intensity={100} + tint="dark" + style={{ + borderRadius: 28, + overflow: 'hidden', + backgroundColor: 'rgba(26, 26, 26, 0.8)', + }} + > + {/* Header */} + <LinearGradient + colors={[ + 'rgba(139, 92, 246, 0.95)', + 'rgba(124, 58, 237, 0.95)', + 'rgba(109, 40, 217, 0.9)' + ]} + locations={[0, 0.6, 1]} + style={{ + paddingHorizontal: 28, + paddingVertical: 24, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }} > - <Ionicons name="close" size={24} color="white" /> - </TouchableOpacity> - </View> - - <ScrollView style={styles.modernTrackListScrollContainer} showsVerticalScrollIndicator={false}> - <View style={styles.modernTrackListContainer}> + <Animated.View + entering={FadeInDown.duration(300).delay(100)} + style={{ flex: 1 }} + > + <Text style={{ + color: '#fff', + fontSize: 24, + fontWeight: '800', + letterSpacing: -0.8, + textShadowColor: 'rgba(0, 0, 0, 0.3)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }}> + Subtitle Settings + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.85)', + fontSize: 14, + marginTop: 4, + fontWeight: '500', + letterSpacing: 0.2, + }}> + Configure subtitles and language options + </Text> + </Animated.View> - {/* External Subtitles Section - Priority */} - <View style={styles.sectionContainer}> - <Text style={styles.sectionTitle}>External Subtitles</Text> - <Text style={styles.sectionDescription}>High quality subtitles with size control</Text> + <Animated.View entering={BounceIn.duration(400).delay(200)}> + <TouchableOpacity + style={{ + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + justifyContent: 'center', + alignItems: 'center', + marginLeft: 16, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.2)', + }} + onPress={handleClose} + activeOpacity={0.7} + > + <MaterialIcons name="close" size={20} color="#fff" /> + </TouchableOpacity> + </Animated.View> + </LinearGradient> + + {/* Content */} + <ScrollView + style={{ + maxHeight: height * 0.6, + backgroundColor: 'transparent', + }} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ + padding: 24, + paddingBottom: 32, + }} + bounces={false} + > + <View style={styles.modernTrackListContainer}> - {/* Custom subtitles option - show if loaded */} - {customSubtitles.length > 0 ? ( - <TouchableOpacity - style={[styles.modernTrackItem, useCustomSubtitles && styles.modernSelectedTrackItem]} - onPress={() => { - selectTextTrack(-999); - setShowSubtitleModal(false); - }} - > - <View style={styles.trackIconContainer}> - <Ionicons name="document-text" size={20} color="#4CAF50" /> - </View> - <View style={styles.modernTrackInfoContainer}> - <Text style={styles.modernTrackPrimaryText}>Custom Subtitles</Text> - <Text style={styles.modernTrackSecondaryText}> - {customSubtitles.length} cues • Size adjustable + {/* External Subtitles Section */} + <Animated.View + entering={FadeInDown.duration(400).delay(150)} + layout={Layout.springify()} + style={{ + marginBottom: 32, + }} + > + <View style={{ + flexDirection: 'row', + alignItems: 'center', + marginBottom: 20, + paddingBottom: 12, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.08)', + }}> + <LinearGradient + colors={['#4CAF50', '#388E3C']} + style={{ + width: 12, + height: 12, + borderRadius: 6, + marginRight: 16, + elevation: 3, + shadowColor: '#4CAF50', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.4, + shadowRadius: 4, + }} + /> + <View style={{ flex: 1 }}> + <Text style={{ + color: '#fff', + fontSize: 18, + fontWeight: '700', + letterSpacing: -0.3, + }}> + External Subtitles + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 12, + marginTop: 1, + fontWeight: '500', + }}> + High quality with size control </Text> </View> - {useCustomSubtitles && ( - <View style={styles.modernSelectedIndicator}> - <Ionicons name="checkmark-circle" size={24} color="#4CAF50" /> - </View> - )} - </TouchableOpacity> - ) : null} - - {/* Search for external subtitles */} - <TouchableOpacity - style={styles.searchSubtitlesButton} - onPress={() => { - setShowSubtitleModal(false); - fetchAvailableSubtitles(); - }} - disabled={isLoadingSubtitleList} - > - <View style={styles.searchButtonContent}> - {isLoadingSubtitleList ? ( - <ActivityIndicator size="small" color="#2196F3" /> - ) : ( - <Ionicons name="search" size={20} color="#2196F3" /> - )} - <Text style={styles.searchSubtitlesText}> - {isLoadingSubtitleList ? 'Searching...' : 'Search Online Subtitles'} - </Text> </View> - </TouchableOpacity> - </View> - {/* Subtitle Size Controls - Only for custom subtitles */} - {useCustomSubtitles && ( - <View style={styles.sectionContainer}> - <Text style={styles.sectionTitle}>Size Control</Text> - <View style={styles.modernSubtitleSizeContainer}> - <TouchableOpacity - style={styles.modernSizeButton} - onPress={decreaseSubtitleSize} + {/* Custom subtitles option */} + {customSubtitles.length > 0 && ( + <Animated.View + entering={FadeInDown.duration(300).delay(200)} + layout={Layout.springify()} + style={{ marginBottom: 16 }} > - <Ionicons name="remove" size={20} color="white" /> - </TouchableOpacity> - <View style={styles.sizeDisplayContainer}> - <Text style={styles.modernSubtitleSizeText}>{subtitleSize}px</Text> - <Text style={styles.sizeLabel}>Font Size</Text> - </View> - <TouchableOpacity - style={styles.modernSizeButton} - onPress={increaseSubtitleSize} - > - <Ionicons name="add" size={20} color="white" /> - </TouchableOpacity> - </View> - </View> - )} - - {/* Built-in Subtitles Section */} - <View style={styles.sectionContainer}> - <Text style={styles.sectionTitle}>Built-in Subtitles</Text> - <Text style={styles.sectionDescription}>System default sizing • No customization</Text> - - {/* Off option */} - <TouchableOpacity - style={[styles.modernTrackItem, (selectedTextTrack === -1 && !useCustomSubtitles) && styles.modernSelectedTrackItem]} - onPress={() => { - selectTextTrack(-1); - setShowSubtitleModal(false); - }} - > - <View style={styles.trackIconContainer}> - <Ionicons name="close-circle" size={20} color="#9E9E9E" /> - </View> - <View style={styles.modernTrackInfoContainer}> - <Text style={styles.modernTrackPrimaryText}>Disabled</Text> - <Text style={styles.modernTrackSecondaryText}>No subtitles</Text> - </View> - {(selectedTextTrack === -1 && !useCustomSubtitles) && ( - <View style={styles.modernSelectedIndicator}> - <Ionicons name="checkmark-circle" size={24} color="#9E9E9E" /> - </View> + <TouchableOpacity + style={{ + backgroundColor: useCustomSubtitles + ? 'rgba(76, 175, 80, 0.08)' + : 'rgba(255, 255, 255, 0.03)', + borderRadius: 20, + padding: 20, + borderWidth: 2, + borderColor: useCustomSubtitles + ? 'rgba(76, 175, 80, 0.4)' + : 'rgba(255, 255, 255, 0.08)', + elevation: useCustomSubtitles ? 8 : 3, + shadowColor: useCustomSubtitles ? '#4CAF50' : '#000', + shadowOffset: { width: 0, height: useCustomSubtitles ? 4 : 2 }, + shadowOpacity: useCustomSubtitles ? 0.3 : 0.1, + shadowRadius: useCustomSubtitles ? 12 : 6, + }} + onPress={() => { + selectTextTrack(-999); + setShowSubtitleModal(false); + }} + activeOpacity={0.85} + > + <View style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }}> + <View style={{ flex: 1, marginRight: 16 }}> + <View style={{ + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + gap: 12, + }}> + <Text style={{ + color: useCustomSubtitles ? '#fff' : 'rgba(255, 255, 255, 0.95)', + fontSize: 16, + fontWeight: '700', + letterSpacing: -0.2, + flex: 1, + }}> + Custom Subtitles + </Text> + + {useCustomSubtitles && ( + <Animated.View + entering={BounceIn.duration(300)} + style={{ + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(76, 175, 80, 0.25)', + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 14, + borderWidth: 1, + borderColor: 'rgba(76, 175, 80, 0.5)', + }} + > + <MaterialIcons name="subtitles" size={12} color="#4CAF50" /> + <Text style={{ + color: '#4CAF50', + fontSize: 10, + fontWeight: '800', + marginLeft: 3, + letterSpacing: 0.3, + }}> + ACTIVE + </Text> + </Animated.View> + )} + </View> + + <View style={{ + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + alignItems: 'center', + }}> + <SubtitleBadge + text={`${customSubtitles.length} CUES`} + color="#4CAF50" + bgColor="rgba(76, 175, 80, 0.15)" + icon="format-quote-close" + /> + <SubtitleBadge + text="SIZE CONTROL" + color="#8B5CF6" + bgColor="rgba(139, 92, 246, 0.15)" + icon="format-size" + delay={50} + /> + </View> + </View> + + <View style={{ + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: useCustomSubtitles + ? 'rgba(76, 175, 80, 0.15)' + : 'rgba(255, 255, 255, 0.05)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: useCustomSubtitles + ? 'rgba(76, 175, 80, 0.3)' + : 'rgba(255, 255, 255, 0.1)', + }}> + {useCustomSubtitles ? ( + <Animated.View entering={ZoomIn.duration(200)}> + <MaterialIcons name="check-circle" size={24} color="#4CAF50" /> + </Animated.View> + ) : ( + <MaterialIcons name="subtitles" size={24} color="rgba(255,255,255,0.6)" /> + )} + </View> + </View> + </TouchableOpacity> + </Animated.View> )} - </TouchableOpacity> + + {/* Search for external subtitles */} + <Animated.View + entering={FadeInDown.duration(300).delay(250)} + layout={Layout.springify()} + > + <TouchableOpacity + style={{ + backgroundColor: 'rgba(33, 150, 243, 0.08)', + borderRadius: 20, + padding: 20, + borderWidth: 2, + borderColor: 'rgba(33, 150, 243, 0.2)', + elevation: 3, + shadowColor: '#2196F3', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 6, + }} + onPress={() => { + handleClose(); + fetchAvailableSubtitles(); + }} + disabled={isLoadingSubtitleList} + activeOpacity={0.85} + > + <View style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }}> + {isLoadingSubtitleList ? ( + <ActivityIndicator size="small" color="#2196F3" style={{ marginRight: 12 }} /> + ) : ( + <MaterialIcons name="search" size={20} color="#2196F3" style={{ marginRight: 12 }} /> + )} + <Text style={{ + color: '#2196F3', + fontSize: 16, + fontWeight: '700', + letterSpacing: -0.2, + }}> + {isLoadingSubtitleList ? 'Searching...' : 'Search Online Subtitles'} + </Text> + </View> + </TouchableOpacity> + </Animated.View> + </Animated.View> + + {/* Subtitle Size Controls */} + {useCustomSubtitles && ( + <Animated.View + entering={FadeInDown.duration(400).delay(200)} + layout={Layout.springify()} + style={{ + marginBottom: 32, + }} + > + <View style={{ + flexDirection: 'row', + alignItems: 'center', + marginBottom: 20, + paddingBottom: 12, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.08)', + }}> + <LinearGradient + colors={['#8B5CF6', '#7C3AED']} + style={{ + width: 12, + height: 12, + borderRadius: 6, + marginRight: 16, + elevation: 3, + shadowColor: '#8B5CF6', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.4, + shadowRadius: 4, + }} + /> + <View style={{ flex: 1 }}> + <Text style={{ + color: '#fff', + fontSize: 18, + fontWeight: '700', + letterSpacing: -0.3, + }}> + Size Control + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 12, + marginTop: 1, + fontWeight: '500', + }}> + Adjust font size for better readability + </Text> + </View> + </View> + + <Animated.View + entering={FadeInDown.duration(300).delay(300)} + style={{ + backgroundColor: 'rgba(255, 255, 255, 0.03)', + borderRadius: 20, + padding: 24, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.08)', + elevation: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 6, + }} + > + <View style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }}> + <TouchableOpacity + style={{ + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: 'rgba(139, 92, 246, 0.15)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: 'rgba(139, 92, 246, 0.3)', + elevation: 4, + shadowColor: '#8B5CF6', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + }} + onPress={decreaseSubtitleSize} + activeOpacity={0.7} + > + <MaterialIcons name="remove" size={24} color="#8B5CF6" /> + </TouchableOpacity> + + <View style={{ + alignItems: 'center', + backgroundColor: 'rgba(139, 92, 246, 0.08)', + paddingHorizontal: 24, + paddingVertical: 16, + borderRadius: 16, + borderWidth: 1, + borderColor: 'rgba(139, 92, 246, 0.2)', + minWidth: 120, + }}> + <Text style={{ + color: '#8B5CF6', + fontSize: 24, + fontWeight: '800', + letterSpacing: -0.5, + }}> + {subtitleSize}px + </Text> + <Text style={{ + color: 'rgba(139, 92, 246, 0.7)', + fontSize: 12, + fontWeight: '600', + marginTop: 2, + letterSpacing: 0.3, + }}> + Font Size + </Text> + </View> + + <TouchableOpacity + style={{ + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: 'rgba(139, 92, 246, 0.15)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: 'rgba(139, 92, 246, 0.3)', + elevation: 4, + shadowColor: '#8B5CF6', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + }} + onPress={increaseSubtitleSize} + activeOpacity={0.7} + > + <MaterialIcons name="add" size={24} color="#8B5CF6" /> + </TouchableOpacity> + </View> + </Animated.View> + </Animated.View> + )} {/* Available built-in subtitle tracks */} - {vlcTextTracks.length > 0 ? vlcTextTracks.map(track => ( - <TouchableOpacity + {vlcTextTracks.length > 0 ? vlcTextTracks.map((track, index) => ( + <Animated.View key={track.id} - style={[styles.modernTrackItem, (selectedTextTrack === track.id && !useCustomSubtitles) && styles.modernSelectedTrackItem]} - onPress={() => { - selectTextTrack(track.id); - setShowSubtitleModal(false); + entering={FadeInDown.duration(300).delay(400 + (index * 50))} + layout={Layout.springify()} + style={{ marginBottom: 16 }} + > + <TouchableOpacity + style={{ + backgroundColor: (selectedTextTrack === track.id && !useCustomSubtitles) + ? 'rgba(255, 152, 0, 0.08)' + : 'rgba(255, 255, 255, 0.03)', + borderRadius: 20, + padding: 20, + borderWidth: 2, + borderColor: (selectedTextTrack === track.id && !useCustomSubtitles) + ? 'rgba(255, 152, 0, 0.4)' + : 'rgba(255, 255, 255, 0.08)', + elevation: (selectedTextTrack === track.id && !useCustomSubtitles) ? 8 : 3, + shadowColor: (selectedTextTrack === track.id && !useCustomSubtitles) ? '#FF9800' : '#000', + shadowOffset: { width: 0, height: (selectedTextTrack === track.id && !useCustomSubtitles) ? 4 : 2 }, + shadowOpacity: (selectedTextTrack === track.id && !useCustomSubtitles) ? 0.3 : 0.1, + shadowRadius: (selectedTextTrack === track.id && !useCustomSubtitles) ? 12 : 6, + }} + onPress={() => { + selectTextTrack(track.id); + handleClose(); + }} + activeOpacity={0.85} + > + <View style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }}> + <View style={{ flex: 1, marginRight: 16 }}> + <View style={{ + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + gap: 12, + }}> + <Text style={{ + color: (selectedTextTrack === track.id && !useCustomSubtitles) ? '#fff' : 'rgba(255, 255, 255, 0.95)', + fontSize: 16, + fontWeight: '700', + letterSpacing: -0.2, + flex: 1, + }}> + {getTrackDisplayName(track)} + </Text> + + {(selectedTextTrack === track.id && !useCustomSubtitles) && ( + <Animated.View + entering={BounceIn.duration(300)} + style={{ + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(255, 152, 0, 0.25)', + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 14, + borderWidth: 1, + borderColor: 'rgba(255, 152, 0, 0.5)', + }} + > + <MaterialIcons name="subtitles" size={12} color="#FF9800" /> + <Text style={{ + color: '#FF9800', + fontSize: 10, + fontWeight: '800', + marginLeft: 3, + letterSpacing: 0.3, + }}> + ACTIVE + </Text> + </Animated.View> + )} + </View> + + <View style={{ + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + alignItems: 'center', + }}> + <SubtitleBadge + text="BUILT-IN" + color="#FF9800" + bgColor="rgba(255, 152, 0, 0.15)" + icon="settings" + /> + <SubtitleBadge + text="SYSTEM SIZE" + color="#6B7280" + bgColor="rgba(107, 114, 128, 0.15)" + icon="format-size" + delay={50} + /> + </View> + </View> + + <View style={{ + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: (selectedTextTrack === track.id && !useCustomSubtitles) + ? 'rgba(255, 152, 0, 0.15)' + : 'rgba(255, 255, 255, 0.05)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: (selectedTextTrack === track.id && !useCustomSubtitles) + ? 'rgba(255, 152, 0, 0.3)' + : 'rgba(255, 255, 255, 0.1)', + }}> + {(selectedTextTrack === track.id && !useCustomSubtitles) ? ( + <Animated.View entering={ZoomIn.duration(200)}> + <MaterialIcons name="check-circle" size={24} color="#FF9800" /> + </Animated.View> + ) : ( + <MaterialIcons name="text-fields" size={24} color="rgba(255,255,255,0.6)" /> + )} + </View> + </View> + </TouchableOpacity> + </Animated.View> + )) : ( + <Animated.View + entering={FadeInDown.duration(300).delay(400)} + style={{ + backgroundColor: 'rgba(255, 255, 255, 0.02)', + borderRadius: 16, + padding: 32, + alignItems: 'center', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.05)', }} > - <View style={styles.trackIconContainer}> - <Ionicons name="text" size={20} color="#FF9800" /> - </View> - <View style={styles.modernTrackInfoContainer}> - <Text style={styles.modernTrackPrimaryText}> - {getTrackDisplayName(track)} - </Text> - <Text style={styles.modernTrackSecondaryText}> - Built-in track • System font size - </Text> - </View> - {(selectedTextTrack === track.id && !useCustomSubtitles) && ( - <View style={styles.modernSelectedIndicator}> - <Ionicons name="checkmark-circle" size={24} color="#FF9800" /> - </View> - )} - </TouchableOpacity> - )) : ( - <View style={styles.modernEmptyStateContainer}> - <Ionicons name="information-circle-outline" size={24} color="#666" /> - <Text style={styles.modernEmptyStateText}>No built-in subtitles available</Text> - </View> + <MaterialIcons name="info-outline" size={32} color="rgba(255, 255, 255, 0.4)" /> + <Text style={{ + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 16, + fontWeight: '600', + marginTop: 12, + textAlign: 'center', + letterSpacing: -0.2, + }}> + No built-in subtitles available + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.4)', + fontSize: 13, + marginTop: 4, + textAlign: 'center', + }}> + Try searching for external subtitles + </Text> + </Animated.View> )} </View> - </View> - </ScrollView> - </View> - </View> + </ScrollView> + </BlurView> + </Animated.View> + </Animated.View> ); }; @@ -215,58 +857,270 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ if (!showSubtitleLanguageModal) return null; return ( - <View style={styles.fullscreenOverlay}> - <View style={styles.enhancedModalContainer}> - <View style={styles.enhancedModalHeader}> - <Text style={styles.enhancedModalTitle}>Select Language</Text> - <TouchableOpacity - style={styles.enhancedCloseButton} - onPress={() => setShowSubtitleLanguageModal(false)} + <Animated.View + entering={FadeIn.duration(250)} + exiting={FadeOut.duration(200)} + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.9)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 9999, + padding: 16, + }} + > + {/* Backdrop */} + <TouchableOpacity + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }} + onPress={handleLanguageClose} + activeOpacity={1} + /> + + {/* Modal Content */} + <Animated.View + style={[ + { + width: Math.min(width - 32, 520), + maxHeight: height * 0.85, + overflow: 'hidden', + elevation: 25, + shadowColor: '#000', + shadowOffset: { width: 0, height: 12 }, + shadowOpacity: 0.4, + shadowRadius: 25, + }, + languageModalStyle, + ]} + > + {/* Glassmorphism Background */} + <BlurView + intensity={100} + tint="dark" + style={{ + borderRadius: 28, + overflow: 'hidden', + backgroundColor: 'rgba(26, 26, 26, 0.8)', + }} + > + {/* Header */} + <LinearGradient + colors={[ + 'rgba(33, 150, 243, 0.95)', + 'rgba(30, 136, 229, 0.95)', + 'rgba(25, 118, 210, 0.9)' + ]} + locations={[0, 0.6, 1]} + style={{ + paddingHorizontal: 28, + paddingVertical: 24, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }} > - <Ionicons name="close" size={24} color="white" /> - </TouchableOpacity> - </View> - - <ScrollView style={styles.trackListScrollContainer}> - <View style={styles.trackListContainer}> - {availableSubtitles.length > 0 ? availableSubtitles.map(subtitle => ( - <TouchableOpacity - key={subtitle.id} - style={styles.enhancedTrackItem} - onPress={() => loadWyzieSubtitle(subtitle)} - disabled={isLoadingSubtitles} + <Animated.View + entering={FadeInDown.duration(300).delay(100)} + style={{ flex: 1 }} + > + <Text style={{ + color: '#fff', + fontSize: 24, + fontWeight: '800', + letterSpacing: -0.8, + textShadowColor: 'rgba(0, 0, 0, 0.3)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }}> + Select Language + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.85)', + fontSize: 14, + marginTop: 4, + fontWeight: '500', + letterSpacing: 0.2, + }}> + Choose from {availableSubtitles.length} available languages + </Text> + </Animated.View> + + <Animated.View entering={BounceIn.duration(400).delay(200)}> + <TouchableOpacity + style={{ + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + justifyContent: 'center', + alignItems: 'center', + marginLeft: 16, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.2)', + }} + onPress={handleLanguageClose} + activeOpacity={0.7} > - <View style={styles.subtitleLanguageItem}> - <Image - source={{ uri: subtitle.flagUrl }} - style={styles.flagIcon} - resizeMode="cover" - /> - <View style={styles.trackInfoContainer}> - <Text style={styles.trackPrimaryText}> - {formatLanguage(subtitle.language)} - </Text> - <Text style={styles.trackSecondaryText}> - {subtitle.display} - </Text> - </View> - </View> - {isLoadingSubtitles && ( - <ActivityIndicator size="small" color="#E50914" /> - )} + <MaterialIcons name="close" size={20} color="#fff" /> </TouchableOpacity> + </Animated.View> + </LinearGradient> + + {/* Content */} + <ScrollView + style={{ + maxHeight: height * 0.6, + backgroundColor: 'transparent', + }} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ + padding: 24, + paddingBottom: 32, + }} + bounces={false} + > + {availableSubtitles.length > 0 ? availableSubtitles.map((subtitle, index) => ( + <Animated.View + key={subtitle.id} + entering={FadeInDown.duration(300).delay(150 + (index * 50))} + layout={Layout.springify()} + style={{ marginBottom: 16 }} + > + <TouchableOpacity + style={{ + backgroundColor: 'rgba(255, 255, 255, 0.03)', + borderRadius: 20, + padding: 20, + borderWidth: 2, + borderColor: 'rgba(255, 255, 255, 0.08)', + elevation: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 6, + }} + onPress={() => loadWyzieSubtitle(subtitle)} + disabled={isLoadingSubtitles} + activeOpacity={0.85} + > + <View style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }}> + <View style={{ + flexDirection: 'row', + alignItems: 'center', + flex: 1, + marginRight: 16, + }}> + <Image + source={{ uri: subtitle.flagUrl }} + style={{ + width: 32, + height: 24, + borderRadius: 4, + marginRight: 16, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.2, + shadowRadius: 2, + }} + resizeMode="cover" + /> + <View style={{ flex: 1 }}> + <Text style={{ + color: 'rgba(255, 255, 255, 0.95)', + fontSize: 16, + fontWeight: '700', + letterSpacing: -0.2, + marginBottom: 4, + }}> + {formatLanguage(subtitle.language)} + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 13, + fontWeight: '500', + }}> + {subtitle.display} + </Text> + </View> + </View> + + <View style={{ + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: isLoadingSubtitles + ? 'rgba(33, 150, 243, 0.15)' + : 'rgba(255, 255, 255, 0.05)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: isLoadingSubtitles + ? 'rgba(33, 150, 243, 0.3)' + : 'rgba(255, 255, 255, 0.1)', + }}> + {isLoadingSubtitles ? ( + <ActivityIndicator size="small" color="#2196F3" /> + ) : ( + <MaterialIcons name="download" size={24} color="rgba(255,255,255,0.6)" /> + )} + </View> + </View> + </TouchableOpacity> + </Animated.View> )) : ( - <View style={styles.emptyStateContainer}> - <Ionicons name="alert-circle-outline" size={40} color="#888" /> - <Text style={styles.emptyStateText}> - No subtitles found for this content + <Animated.View + entering={FadeInDown.duration(300).delay(150)} + style={{ + backgroundColor: 'rgba(255, 255, 255, 0.02)', + borderRadius: 20, + padding: 40, + alignItems: 'center', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.05)', + }} + > + <MaterialIcons name="translate" size={48} color="rgba(255, 255, 255, 0.3)" /> + <Text style={{ + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 18, + fontWeight: '700', + marginTop: 16, + textAlign: 'center', + letterSpacing: -0.3, + }}> + No subtitles found </Text> - </View> + <Text style={{ + color: 'rgba(255, 255, 255, 0.4)', + fontSize: 14, + marginTop: 8, + textAlign: 'center', + lineHeight: 20, + }}> + No subtitles are available for this content.{'\n'}Try searching again or check back later. + </Text> + </Animated.View> )} - </View> - </ScrollView> - </View> - </View> + </ScrollView> + </BlurView> + </Animated.View> + </Animated.View> ); }; -- 2.45.2 From 2ae2d4a828f6c1504760e6c150c9d94e2741ded7 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 10 Jun 2025 03:22:56 +0530 Subject: [PATCH 35/88] Remove shadow properties from SubtitleModals for cleaner styling --- src/components/player/modals/SubtitleModals.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx index e525963..a667189 100644 --- a/src/components/player/modals/SubtitleModals.tsx +++ b/src/components/player/modals/SubtitleModals.tsx @@ -1032,11 +1032,6 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ height: 24, borderRadius: 4, marginRight: 16, - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.2, - shadowRadius: 2, }} resizeMode="cover" /> -- 2.45.2 From 2d71a64af824bf7adcb61d9f48d3ebd4d0e971bd Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 10 Jun 2025 03:25:35 +0530 Subject: [PATCH 36/88] Enhance AudioTrackModal with improved animations and visual elements This update introduces a new AudioBadge component for better visual feedback on audio track options, along with enhanced animations for modal transitions. The modal now features a glassmorphism background and improved layout, providing a more engaging user experience. Additionally, the closing animation has been refined for smoother transitions, enhancing overall usability. --- .../player/modals/AudioTrackModal.tsx | 506 ++++++++++++++++-- 1 file changed, 462 insertions(+), 44 deletions(-) diff --git a/src/components/player/modals/AudioTrackModal.tsx b/src/components/player/modals/AudioTrackModal.tsx index d35e4b6..c8ec13f 100644 --- a/src/components/player/modals/AudioTrackModal.tsx +++ b/src/components/player/modals/AudioTrackModal.tsx @@ -1,6 +1,28 @@ import React from 'react'; -import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; +import { View, Text, TouchableOpacity, ScrollView, Dimensions } from 'react-native'; +import { Ionicons, MaterialIcons } from '@expo/vector-icons'; +import { BlurView } from 'expo-blur'; +import Animated, { + FadeIn, + FadeOut, + SlideInDown, + SlideOutDown, + FadeInDown, + FadeInUp, + Layout, + withSpring, + withTiming, + useAnimatedStyle, + useSharedValue, + interpolate, + Easing, + withDelay, + withSequence, + runOnJS, + BounceIn, + ZoomIn +} from 'react-native-reanimated'; +import { LinearGradient } from 'expo-linear-gradient'; import { styles } from '../utils/playerStyles'; import { getTrackDisplayName } from '../utils/playerUtils'; @@ -12,6 +34,53 @@ interface AudioTrackModalProps { selectAudioTrack: (trackId: number) => void; } +const { width, height } = Dimensions.get('window'); + +const AudioBadge = ({ + text, + color, + bgColor, + icon, + delay = 0 +}: { + text: string; + color: string; + bgColor: string; + icon?: string; + delay?: number; +}) => ( + <Animated.View + entering={FadeInUp.duration(200).delay(delay)} + style={{ + backgroundColor: bgColor, + borderColor: `${color}40`, + borderWidth: 1, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + elevation: 2, + shadowColor: color, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.3, + shadowRadius: 2, + }} + > + {icon && ( + <MaterialIcons name={icon as any} size={12} color={color} style={{ marginRight: 4 }} /> + )} + <Text style={{ + color: color, + fontSize: 10, + fontWeight: '700', + letterSpacing: 0.3, + }}> + {text} + </Text> + </Animated.View> +); + export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({ showAudioModal, setShowAudioModal, @@ -19,56 +88,405 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({ selectedAudioTrack, selectAudioTrack, }) => { + const modalScale = useSharedValue(0.9); + const modalOpacity = useSharedValue(0); + + React.useEffect(() => { + if (showAudioModal) { + modalScale.value = withSpring(1, { + damping: 20, + stiffness: 300, + mass: 0.8, + }); + modalOpacity.value = withTiming(1, { + duration: 200, + easing: Easing.out(Easing.quad), + }); + } + }, [showAudioModal]); + + const modalStyle = useAnimatedStyle(() => ({ + transform: [{ scale: modalScale.value }], + opacity: modalOpacity.value, + })); + + const handleClose = () => { + modalScale.value = withTiming(0.9, { duration: 150 }); + modalOpacity.value = withTiming(0, { duration: 150 }); + setTimeout(() => setShowAudioModal(false), 150); + }; + if (!showAudioModal) return null; return ( - <View style={styles.fullscreenOverlay}> - <View style={styles.enhancedModalContainer}> - <View style={styles.enhancedModalHeader}> - <Text style={styles.enhancedModalTitle}>Audio</Text> - <TouchableOpacity - style={styles.enhancedCloseButton} - onPress={() => setShowAudioModal(false)} + <Animated.View + entering={FadeIn.duration(250)} + exiting={FadeOut.duration(200)} + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.9)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 9999, + padding: 16, + }} + > + {/* Backdrop */} + <TouchableOpacity + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }} + onPress={handleClose} + activeOpacity={1} + /> + + {/* Modal Content */} + <Animated.View + style={[ + { + width: Math.min(width - 32, 520), + maxHeight: height * 0.85, + overflow: 'hidden', + elevation: 25, + shadowColor: '#000', + shadowOffset: { width: 0, height: 12 }, + shadowOpacity: 0.4, + shadowRadius: 25, + }, + modalStyle, + ]} + > + {/* Glassmorphism Background */} + <BlurView + intensity={100} + tint="dark" + style={{ + borderRadius: 28, + overflow: 'hidden', + backgroundColor: 'rgba(26, 26, 26, 0.8)', + }} + > + {/* Header */} + <LinearGradient + colors={[ + 'rgba(249, 115, 22, 0.95)', + 'rgba(234, 88, 12, 0.95)', + 'rgba(194, 65, 12, 0.9)' + ]} + locations={[0, 0.6, 1]} + style={{ + paddingHorizontal: 28, + paddingVertical: 24, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.1)', + }} > - <Ionicons name="close" size={24} color="white" /> - </TouchableOpacity> - </View> - - <ScrollView style={styles.trackListScrollContainer}> - <View style={styles.trackListContainer}> - {vlcAudioTracks.length > 0 ? vlcAudioTracks.map(track => ( - <TouchableOpacity - key={track.id} - style={styles.enhancedTrackItem} - onPress={() => { - selectAudioTrack(track.id); - setShowAudioModal(false); + <Animated.View + entering={FadeInDown.duration(300).delay(100)} + style={{ flex: 1 }} + > + <Text style={{ + color: '#fff', + fontSize: 24, + fontWeight: '800', + letterSpacing: -0.8, + textShadowColor: 'rgba(0, 0, 0, 0.3)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }}> + Audio Tracks + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.85)', + fontSize: 14, + marginTop: 4, + fontWeight: '500', + letterSpacing: 0.2, + }}> + Choose from {vlcAudioTracks.length} available track{vlcAudioTracks.length !== 1 ? 's' : ''} + </Text> + </Animated.View> + + <Animated.View entering={BounceIn.duration(400).delay(200)}> + <TouchableOpacity + style={{ + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + justifyContent: 'center', + alignItems: 'center', + marginLeft: 16, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.2)', }} + onPress={handleClose} + activeOpacity={0.7} > - <View style={styles.trackInfoContainer}> - <Text style={styles.trackPrimaryText}> - {getTrackDisplayName(track)} - </Text> - {(track.name && track.language) && ( - <Text style={styles.trackSecondaryText}>{track.name}</Text> - )} - </View> - {selectedAudioTrack === track.id && ( - <View style={styles.selectedIndicatorContainer}> - <Ionicons name="checkmark" size={22} color="#E50914" /> - </View> - )} + <MaterialIcons name="close" size={20} color="#fff" /> </TouchableOpacity> - )) : ( - <View style={styles.emptyStateContainer}> - <Ionicons name="alert-circle-outline" size={40} color="#888" /> - <Text style={styles.emptyStateText}>No audio tracks available</Text> + </Animated.View> + </LinearGradient> + + {/* Content */} + <ScrollView + style={{ + maxHeight: height * 0.6, + backgroundColor: 'transparent', + }} + showsVerticalScrollIndicator={false} + contentContainerStyle={{ + padding: 24, + paddingBottom: 32, + }} + bounces={false} + > + {/* Audio Tracks Section */} + <Animated.View + entering={FadeInDown.duration(400).delay(150)} + layout={Layout.springify()} + style={{ + marginBottom: 16, + }} + > + <View style={{ + flexDirection: 'row', + alignItems: 'center', + marginBottom: 20, + paddingBottom: 12, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.08)', + }}> + <LinearGradient + colors={['#F97316', '#EA580C']} + style={{ + width: 12, + height: 12, + borderRadius: 6, + marginRight: 16, + elevation: 3, + shadowColor: '#F97316', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.4, + shadowRadius: 4, + }} + /> + <View style={{ flex: 1 }}> + <Text style={{ + color: '#fff', + fontSize: 18, + fontWeight: '700', + letterSpacing: -0.3, + }}> + Available Audio Tracks + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 12, + marginTop: 1, + fontWeight: '500', + }}> + Select your preferred audio language + </Text> + </View> </View> - )} - </View> - </ScrollView> - </View> - </View> + + {/* Audio Tracks List */} + {vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track, index) => { + const isSelected = selectedAudioTrack === track.id; + + return ( + <Animated.View + key={track.id} + entering={FadeInDown.duration(300).delay(200 + (index * 50))} + layout={Layout.springify()} + style={{ marginBottom: 16 }} + > + <TouchableOpacity + style={{ + backgroundColor: isSelected + ? 'rgba(249, 115, 22, 0.08)' + : 'rgba(255, 255, 255, 0.03)', + borderRadius: 20, + padding: 20, + borderWidth: 2, + borderColor: isSelected + ? 'rgba(249, 115, 22, 0.4)' + : 'rgba(255, 255, 255, 0.08)', + elevation: isSelected ? 8 : 3, + shadowColor: isSelected ? '#F97316' : '#000', + shadowOffset: { width: 0, height: isSelected ? 4 : 2 }, + shadowOpacity: isSelected ? 0.3 : 0.1, + shadowRadius: isSelected ? 12 : 6, + }} + onPress={() => { + selectAudioTrack(track.id); + handleClose(); + }} + activeOpacity={0.85} + > + <View style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }}> + <View style={{ flex: 1, marginRight: 16 }}> + <View style={{ + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + gap: 12, + }}> + <Text style={{ + color: isSelected ? '#fff' : 'rgba(255, 255, 255, 0.95)', + fontSize: 16, + fontWeight: '700', + letterSpacing: -0.2, + flex: 1, + }}> + {getTrackDisplayName(track)} + </Text> + + {isSelected && ( + <Animated.View + entering={BounceIn.duration(300)} + style={{ + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(249, 115, 22, 0.25)', + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 14, + borderWidth: 1, + borderColor: 'rgba(249, 115, 22, 0.5)', + }} + > + <MaterialIcons name="volume-up" size={12} color="#F97316" /> + <Text style={{ + color: '#F97316', + fontSize: 10, + fontWeight: '800', + marginLeft: 3, + letterSpacing: 0.3, + }}> + ACTIVE + </Text> + </Animated.View> + )} + </View> + + {(track.name && track.language) && ( + <Text style={{ + color: 'rgba(255, 255, 255, 0.65)', + fontSize: 13, + marginBottom: 8, + lineHeight: 18, + fontWeight: '400', + }}> + {track.name} + </Text> + )} + + <View style={{ + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + alignItems: 'center', + }}> + <AudioBadge + text="AUDIO TRACK" + color="#F97316" + bgColor="rgba(249, 115, 22, 0.15)" + icon="audiotrack" + /> + {track.language && ( + <AudioBadge + text={track.language.toUpperCase()} + color="#6B7280" + bgColor="rgba(107, 114, 128, 0.15)" + icon="language" + delay={50} + /> + )} + </View> + </View> + + <View style={{ + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: isSelected + ? 'rgba(249, 115, 22, 0.15)' + : 'rgba(255, 255, 255, 0.05)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: isSelected + ? 'rgba(249, 115, 22, 0.3)' + : 'rgba(255, 255, 255, 0.1)', + }}> + {isSelected ? ( + <Animated.View entering={ZoomIn.duration(200)}> + <MaterialIcons name="check-circle" size={24} color="#F97316" /> + </Animated.View> + ) : ( + <MaterialIcons name="volume-up" size={24} color="rgba(255,255,255,0.6)" /> + )} + </View> + </View> + </TouchableOpacity> + </Animated.View> + ); + }) : ( + <Animated.View + entering={FadeInDown.duration(300).delay(200)} + style={{ + backgroundColor: 'rgba(255, 255, 255, 0.02)', + borderRadius: 20, + padding: 40, + alignItems: 'center', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.05)', + }} + > + <MaterialIcons name="volume-off" size={48} color="rgba(255, 255, 255, 0.3)" /> + <Text style={{ + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 18, + fontWeight: '700', + marginTop: 16, + textAlign: 'center', + letterSpacing: -0.3, + }}> + No audio tracks available + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.4)', + fontSize: 14, + marginTop: 8, + textAlign: 'center', + lineHeight: 20, + }}> + This content doesn't have multiple audio tracks.{'\n'}The default audio will be used. + </Text> + </Animated.View> + )} + </Animated.View> + </ScrollView> + </BlurView> + </Animated.View> + </Animated.View> ); }; -- 2.45.2 From 9e19628b46c2d009e238e297b3a4d2753ae6160e Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Tue, 10 Jun 2025 03:28:08 +0530 Subject: [PATCH 37/88] Remove playback speed state and related props from VideoPlayer and PlayerControls components for cleaner code and improved maintainability. --- src/components/player/VideoPlayer.tsx | 2 -- src/components/player/controls/PlayerControls.tsx | 8 -------- 2 files changed, 10 deletions(-) diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index d35fca0..3da4a29 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -64,7 +64,6 @@ const VideoPlayer: React.FC = () => { const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [showControls, setShowControls] = useState(true); - const [playbackSpeed, setPlaybackSpeed] = useState(1); const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]); const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null); const [textTracks, setTextTracks] = useState<TextTrack[]>([]); @@ -1004,7 +1003,6 @@ const VideoPlayer: React.FC = () => { streamName={currentStreamName} currentTime={currentTime} duration={duration} - playbackSpeed={playbackSpeed} zoomScale={zoomScale} vlcAudioTracks={vlcAudioTracks} selectedAudioTrack={selectedAudioTrack} diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx index 4700eaa..3cc6054 100644 --- a/src/components/player/controls/PlayerControls.tsx +++ b/src/components/player/controls/PlayerControls.tsx @@ -19,7 +19,6 @@ interface PlayerControlsProps { streamName?: string; currentTime: number; duration: number; - playbackSpeed: number; zoomScale: number; vlcAudioTracks: Array<{id: number, name: string, language?: string}>; selectedAudioTrack: number | null; @@ -55,7 +54,6 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ streamName, currentTime, duration, - playbackSpeed, zoomScale, vlcAudioTracks, selectedAudioTrack, @@ -176,12 +174,6 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ <View style={styles.bottomControls}> {/* Bottom Buttons Row */} <View style={styles.bottomButtons}> - {/* Speed Button */} - <TouchableOpacity style={styles.bottomButton}> - <Ionicons name="speedometer" size={20} color="white" /> - <Text style={styles.bottomButtonText}>Speed ({playbackSpeed}x)</Text> - </TouchableOpacity> - {/* Fill/Cover Button - Updated to show fill/cover modes */} <TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}> <Ionicons name="resize" size={20} color="white" /> -- 2.45.2 From 046c9e3f9787ac93cd45e1e6cf2a1edbfa6fd0fd Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 11 Jun 2025 02:10:10 +0530 Subject: [PATCH 38/88] Enhance modals with fixed dimensions and improved layout This update introduces fixed dimensions for the AudioTrackModal, SourcesModal, and SubtitleModals, ensuring consistent sizing across different screen sizes. The layout has been refined to improve visual clarity and usability, including adjustments to scroll view heights and modal styles. Additionally, the integration of a new XPRIME source in the metadata handling enhances the overall streaming experience by prioritizing this source in the selection process. --- .../player/modals/AudioTrackModal.tsx | 333 +++++------ src/components/player/modals/SourcesModal.tsx | 24 +- .../player/modals/SubtitleModals.tsx | 30 +- src/hooks/useMetadata.ts | 75 ++- src/navigation/AppNavigator.tsx | 17 + src/screens/InternalProvidersSettings.tsx | 515 ++++++++++++++++++ src/screens/SettingsScreen.tsx | 8 +- src/screens/StreamsScreen.tsx | 62 ++- src/services/hdrezkaService.ts | 20 +- src/services/xprimeService.ts | 380 +++++++++++++ 10 files changed, 1235 insertions(+), 229 deletions(-) create mode 100644 src/screens/InternalProvidersSettings.tsx create mode 100644 src/services/xprimeService.ts diff --git a/src/components/player/modals/AudioTrackModal.tsx b/src/components/player/modals/AudioTrackModal.tsx index c8ec13f..63805e1 100644 --- a/src/components/player/modals/AudioTrackModal.tsx +++ b/src/components/player/modals/AudioTrackModal.tsx @@ -36,6 +36,10 @@ interface AudioTrackModalProps { const { width, height } = Dimensions.get('window'); +// Fixed dimensions for the modal +const MODAL_WIDTH = Math.min(width - 32, 520); +const MODAL_MAX_HEIGHT = height * 0.85; + const AudioBadge = ({ text, color, @@ -152,14 +156,16 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({ <Animated.View style={[ { - width: Math.min(width - 32, 520), - maxHeight: height * 0.85, + width: MODAL_WIDTH, + maxHeight: MODAL_MAX_HEIGHT, + minHeight: height * 0.3, overflow: 'hidden', elevation: 25, shadowColor: '#000', shadowOffset: { width: 0, height: 12 }, shadowOpacity: 0.4, shadowRadius: 25, + alignSelf: 'center', }, modalStyle, ]} @@ -172,6 +178,8 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({ borderRadius: 28, overflow: 'hidden', backgroundColor: 'rgba(26, 26, 26, 0.8)', + width: '100%', + height: '100%', }} > {/* Header */} @@ -190,6 +198,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({ justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: 'rgba(255, 255, 255, 0.1)', + width: '100%', }} > <Animated.View @@ -242,215 +251,156 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({ {/* Content */} <ScrollView style={{ - maxHeight: height * 0.6, + maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height backgroundColor: 'transparent', + width: '100%', }} showsVerticalScrollIndicator={false} contentContainerStyle={{ padding: 24, paddingBottom: 32, + width: '100%', }} bounces={false} > - {/* Audio Tracks Section */} - <Animated.View - entering={FadeInDown.duration(400).delay(150)} - layout={Layout.springify()} - style={{ - marginBottom: 16, - }} - > - <View style={{ - flexDirection: 'row', - alignItems: 'center', - marginBottom: 20, - paddingBottom: 12, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255, 255, 255, 0.08)', - }}> - <LinearGradient - colors={['#F97316', '#EA580C']} - style={{ - width: 12, - height: 12, - borderRadius: 6, - marginRight: 16, - elevation: 3, - shadowColor: '#F97316', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.4, - shadowRadius: 4, + <View style={styles.modernTrackListContainer}> + {vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track, index) => ( + <Animated.View + key={track.id} + entering={FadeInDown.duration(300).delay(150 + (index * 50))} + layout={Layout.springify()} + style={{ + marginBottom: 16, + width: '100%', }} - /> - <View style={{ flex: 1 }}> - <Text style={{ - color: '#fff', - fontSize: 18, - fontWeight: '700', - letterSpacing: -0.3, - }}> - Available Audio Tracks - </Text> - <Text style={{ - color: 'rgba(255, 255, 255, 0.6)', - fontSize: 12, - marginTop: 1, - fontWeight: '500', - }}> - Select your preferred audio language - </Text> - </View> - </View> - - {/* Audio Tracks List */} - {vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track, index) => { - const isSelected = selectedAudioTrack === track.id; - - return ( - <Animated.View - key={track.id} - entering={FadeInDown.duration(300).delay(200 + (index * 50))} - layout={Layout.springify()} - style={{ marginBottom: 16 }} + > + <TouchableOpacity + style={{ + backgroundColor: selectedAudioTrack === track.id + ? 'rgba(249, 115, 22, 0.08)' + : 'rgba(255, 255, 255, 0.03)', + borderRadius: 20, + padding: 20, + borderWidth: 2, + borderColor: selectedAudioTrack === track.id + ? 'rgba(249, 115, 22, 0.4)' + : 'rgba(255, 255, 255, 0.08)', + elevation: selectedAudioTrack === track.id ? 8 : 3, + shadowColor: selectedAudioTrack === track.id ? '#F97316' : '#000', + shadowOffset: { width: 0, height: selectedAudioTrack === track.id ? 4 : 2 }, + shadowOpacity: selectedAudioTrack === track.id ? 0.3 : 0.1, + shadowRadius: selectedAudioTrack === track.id ? 12 : 6, + width: '100%', + }} + onPress={() => { + selectAudioTrack(track.id); + handleClose(); + }} + activeOpacity={0.85} > - <TouchableOpacity - style={{ - backgroundColor: isSelected - ? 'rgba(249, 115, 22, 0.08)' - : 'rgba(255, 255, 255, 0.03)', - borderRadius: 20, - padding: 20, - borderWidth: 2, - borderColor: isSelected - ? 'rgba(249, 115, 22, 0.4)' - : 'rgba(255, 255, 255, 0.08)', - elevation: isSelected ? 8 : 3, - shadowColor: isSelected ? '#F97316' : '#000', - shadowOffset: { width: 0, height: isSelected ? 4 : 2 }, - shadowOpacity: isSelected ? 0.3 : 0.1, - shadowRadius: isSelected ? 12 : 6, - }} - onPress={() => { - selectAudioTrack(track.id); - handleClose(); - }} - activeOpacity={0.85} - > - <View style={{ - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }}> - <View style={{ flex: 1, marginRight: 16 }}> - <View style={{ - flexDirection: 'row', - alignItems: 'center', - marginBottom: 8, - gap: 12, + <View style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + }}> + <View style={{ flex: 1, marginRight: 16 }}> + <View style={{ + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + gap: 12, + }}> + <Text style={{ + color: selectedAudioTrack === track.id ? '#fff' : 'rgba(255, 255, 255, 0.95)', + fontSize: 16, + fontWeight: '700', + letterSpacing: -0.2, + flex: 1, }}> - <Text style={{ - color: isSelected ? '#fff' : 'rgba(255, 255, 255, 0.95)', - fontSize: 16, - fontWeight: '700', - letterSpacing: -0.2, - flex: 1, - }}> - {getTrackDisplayName(track)} - </Text> - - {isSelected && ( - <Animated.View - entering={BounceIn.duration(300)} - style={{ - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(249, 115, 22, 0.25)', - paddingHorizontal: 10, - paddingVertical: 5, - borderRadius: 14, - borderWidth: 1, - borderColor: 'rgba(249, 115, 22, 0.5)', - }} - > - <MaterialIcons name="volume-up" size={12} color="#F97316" /> - <Text style={{ - color: '#F97316', - fontSize: 10, - fontWeight: '800', - marginLeft: 3, - letterSpacing: 0.3, - }}> - ACTIVE - </Text> - </Animated.View> - )} - </View> + {getTrackDisplayName(track)} + </Text> - {(track.name && track.language) && ( - <Text style={{ - color: 'rgba(255, 255, 255, 0.65)', - fontSize: 13, - marginBottom: 8, - lineHeight: 18, - fontWeight: '400', - }}> - {track.name} - </Text> + {selectedAudioTrack === track.id && ( + <Animated.View + entering={BounceIn.duration(300)} + style={{ + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(249, 115, 22, 0.25)', + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 14, + borderWidth: 1, + borderColor: 'rgba(249, 115, 22, 0.5)', + }} + > + <MaterialIcons name="volume-up" size={12} color="#F97316" /> + <Text style={{ + color: '#F97316', + fontSize: 10, + fontWeight: '800', + marginLeft: 3, + letterSpacing: 0.3, + }}> + ACTIVE + </Text> + </Animated.View> )} - - <View style={{ - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - alignItems: 'center', - }}> - <AudioBadge - text="AUDIO TRACK" - color="#F97316" - bgColor="rgba(249, 115, 22, 0.15)" - icon="audiotrack" - /> - {track.language && ( - <AudioBadge - text={track.language.toUpperCase()} - color="#6B7280" - bgColor="rgba(107, 114, 128, 0.15)" - icon="language" - delay={50} - /> - )} - </View> </View> <View style={{ - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: isSelected - ? 'rgba(249, 115, 22, 0.15)' - : 'rgba(255, 255, 255, 0.05)', - justifyContent: 'center', + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, alignItems: 'center', - borderWidth: 2, - borderColor: isSelected - ? 'rgba(249, 115, 22, 0.3)' - : 'rgba(255, 255, 255, 0.1)', }}> - {isSelected ? ( - <Animated.View entering={ZoomIn.duration(200)}> - <MaterialIcons name="check-circle" size={24} color="#F97316" /> - </Animated.View> - ) : ( - <MaterialIcons name="volume-up" size={24} color="rgba(255,255,255,0.6)" /> + <AudioBadge + text="AUDIO TRACK" + color="#F97316" + bgColor="rgba(249, 115, 22, 0.15)" + icon="audiotrack" + /> + {track.language && ( + <AudioBadge + text={track.language.toUpperCase()} + color="#6B7280" + bgColor="rgba(107, 114, 128, 0.15)" + icon="language" + delay={50} + /> )} </View> </View> - </TouchableOpacity> - </Animated.View> - ); - }) : ( + + <View style={{ + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: selectedAudioTrack === track.id + ? 'rgba(249, 115, 22, 0.15)' + : 'rgba(255, 255, 255, 0.05)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: selectedAudioTrack === track.id + ? 'rgba(249, 115, 22, 0.3)' + : 'rgba(255, 255, 255, 0.1)', + }}> + {selectedAudioTrack === track.id ? ( + <Animated.View entering={ZoomIn.duration(200)}> + <MaterialIcons name="check-circle" size={24} color="#F97316" /> + </Animated.View> + ) : ( + <MaterialIcons name="volume-up" size={24} color="rgba(255,255,255,0.6)" /> + )} + </View> + </View> + </TouchableOpacity> + </Animated.View> + )) : ( <Animated.View - entering={FadeInDown.duration(300).delay(200)} + entering={FadeInDown.duration(300).delay(150)} style={{ backgroundColor: 'rgba(255, 255, 255, 0.02)', borderRadius: 20, @@ -458,6 +408,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({ alignItems: 'center', borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.05)', + width: '100%', }} > <MaterialIcons name="volume-off" size={48} color="rgba(255, 255, 255, 0.3)" /> @@ -469,7 +420,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({ textAlign: 'center', letterSpacing: -0.3, }}> - No audio tracks available + No audio tracks found </Text> <Text style={{ color: 'rgba(255, 255, 255, 0.4)', @@ -478,11 +429,11 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({ textAlign: 'center', lineHeight: 20, }}> - This content doesn't have multiple audio tracks.{'\n'}The default audio will be used. + No audio tracks are available for this content.{'\n'}Try a different source or check your connection. </Text> </Animated.View> )} - </Animated.View> + </View> </ScrollView> </BlurView> </Animated.View> diff --git a/src/components/player/modals/SourcesModal.tsx b/src/components/player/modals/SourcesModal.tsx index 3bfd725..0c232e6 100644 --- a/src/components/player/modals/SourcesModal.tsx +++ b/src/components/player/modals/SourcesModal.tsx @@ -38,6 +38,10 @@ interface SourcesModalProps { const { width, height } = Dimensions.get('window'); +// Fixed dimensions for the modal +const MODAL_WIDTH = Math.min(width - 32, 520); +const MODAL_MAX_HEIGHT = height * 0.85; + const QualityIndicator = ({ quality }: { quality: string | null }) => { if (!quality) return null; @@ -229,14 +233,16 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ <Animated.View style={[ { - width: Math.min(width - 32, 520), - maxHeight: height * 0.85, + width: MODAL_WIDTH, + maxHeight: MODAL_MAX_HEIGHT, + minHeight: height * 0.3, overflow: 'hidden', elevation: 25, shadowColor: '#000', shadowOffset: { width: 0, height: 12 }, shadowOpacity: 0.4, shadowRadius: 25, + alignSelf: 'center', }, modalStyle, ]} @@ -249,6 +255,8 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ borderRadius: 28, overflow: 'hidden', backgroundColor: 'rgba(26, 26, 26, 0.8)', + width: '100%', + height: '100%', }} > {/* Header */} @@ -267,6 +275,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: 'rgba(255, 255, 255, 0.1)', + width: '100%', }} > <Animated.View @@ -319,13 +328,15 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ {/* Content */} <ScrollView style={{ - maxHeight: height * 0.6, + maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height backgroundColor: 'transparent', + width: '100%', }} showsVerticalScrollIndicator={false} contentContainerStyle={{ padding: 24, paddingBottom: 32, + width: '100%', }} bounces={false} > @@ -336,6 +347,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ layout={Layout.springify()} style={{ marginBottom: streams.length > 0 ? 32 : 0, + width: '100%', }} > {/* Provider Header */} @@ -346,6 +358,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: 'rgba(255, 255, 255, 0.08)', + width: '100%', }}> <LinearGradient colors={providerId === 'hdrezka' ? ['#00d4aa', '#00a085'] : ['#E50914', '#B00610']} @@ -400,7 +413,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ </View> {/* Streams Grid */} - <View style={{ gap: 16 }}> + <View style={{ gap: 16, width: '100%' }}> {streams.map((stream, index) => { const quality = getQualityFromTitle(stream.title); const isSelected = isStreamSelected(stream); @@ -415,6 +428,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ key={`${stream.url}-${index}`} entering={FadeInDown.duration(300).delay((providerIndex * 80) + (index * 40))} layout={Layout.springify()} + style={{ width: '100%' }} > <TouchableOpacity style={{ @@ -433,6 +447,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ shadowOpacity: isSelected ? 0.3 : 0.1, shadowRadius: isSelected ? 12 : 6, transform: [{ scale: isSelected ? 1.02 : 1 }], + width: '100%', }} onPress={() => handleStreamSelect(stream)} disabled={isChangingSource || isSelected} @@ -442,6 +457,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', + width: '100%', }}> {/* Stream Info */} <View style={{ flex: 1, marginRight: 16 }}> diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx index a667189..cf5e247 100644 --- a/src/components/player/modals/SubtitleModals.tsx +++ b/src/components/player/modals/SubtitleModals.tsx @@ -49,6 +49,10 @@ interface SubtitleModalsProps { const { width, height } = Dimensions.get('window'); +// Fixed dimensions for the modals +const MODAL_WIDTH = Math.min(width - 32, 520); +const MODAL_MAX_HEIGHT = height * 0.85; + const SubtitleBadge = ({ text, color, @@ -206,14 +210,16 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ <Animated.View style={[ { - width: Math.min(width - 32, 520), - maxHeight: height * 0.85, + width: MODAL_WIDTH, + maxHeight: MODAL_MAX_HEIGHT, + minHeight: height * 0.3, overflow: 'hidden', elevation: 25, shadowColor: '#000', shadowOffset: { width: 0, height: 12 }, shadowOpacity: 0.4, shadowRadius: 25, + alignSelf: 'center', }, modalStyle, ]} @@ -226,6 +232,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ borderRadius: 28, overflow: 'hidden', backgroundColor: 'rgba(26, 26, 26, 0.8)', + width: '100%', + height: '100%', }} > {/* Header */} @@ -244,6 +252,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: 'rgba(255, 255, 255, 0.1)', + width: '100%', }} > <Animated.View @@ -296,13 +305,15 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ {/* Content */} <ScrollView style={{ - maxHeight: height * 0.6, + maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height backgroundColor: 'transparent', + width: '100%', }} showsVerticalScrollIndicator={false} contentContainerStyle={{ padding: 24, paddingBottom: 32, + width: '100%', }} bounces={false} > @@ -890,14 +901,16 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ <Animated.View style={[ { - width: Math.min(width - 32, 520), - maxHeight: height * 0.85, + width: MODAL_WIDTH, + maxHeight: MODAL_MAX_HEIGHT, + minHeight: height * 0.3, overflow: 'hidden', elevation: 25, shadowColor: '#000', shadowOffset: { width: 0, height: 12 }, shadowOpacity: 0.4, shadowRadius: 25, + alignSelf: 'center', }, languageModalStyle, ]} @@ -910,6 +923,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ borderRadius: 28, overflow: 'hidden', backgroundColor: 'rgba(26, 26, 26, 0.8)', + width: '100%', + height: '100%', }} > {/* Header */} @@ -928,6 +943,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: 'rgba(255, 255, 255, 0.1)', + width: '100%', }} > <Animated.View @@ -980,13 +996,15 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ {/* Content */} <ScrollView style={{ - maxHeight: height * 0.6, + maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height backgroundColor: 'transparent', + width: '100%', }} showsVerticalScrollIndicator={false} contentContainerStyle={{ padding: 24, paddingBottom: 32, + width: '100%', }} bounces={false} > diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index ee0a895..c6941c1 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -4,6 +4,7 @@ import { catalogService } from '../services/catalogService'; import { stremioService } from '../services/stremioService'; import { tmdbService } from '../services/tmdbService'; import { hdrezkaService } from '../services/hdrezkaService'; +import { xprimeService } from '../services/xprimeService'; import { cacheService } from '../services/cacheService'; import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata'; import { TMDBService } from '../services/tmdbService'; @@ -215,6 +216,43 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = } }; + const processXprimeSource = async (type: string, id: string, season?: number, episode?: number, isEpisode = false) => { + const sourceStartTime = Date.now(); + const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams'; + const sourceName = 'xprime'; + + logger.log(`🔍 [${logPrefix}:${sourceName}] Starting fetch`); + + try { + const streams = await xprimeService.getStreams( + id, + type, + season, + episode + ); + + const processTime = Date.now() - sourceStartTime; + + if (streams && streams.length > 0) { + logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams after ${processTime}ms`); + + // Format response similar to Stremio format for the UI + return { + 'xprime': { + addonName: 'XPRIME', + streams + } + }; + } else { + logger.log(`⚠️ [${logPrefix}:${sourceName}] No streams found after ${processTime}ms`); + return {}; + } + } catch (error) { + logger.error(`❌ [${logPrefix}:${sourceName}] Error:`, error); + return {}; + } + }; + const processExternalSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => { const sourceStartTime = Date.now(); const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams'; @@ -230,7 +268,13 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = const updateState = (prevState: GroupedStreams) => { logger.log(`🔄 [${logPrefix}:${sourceType}] Updating state with ${Object.keys(result).length} providers`); - return { ...prevState, ...result }; + + // If this is XPRIME, put it first; otherwise append to the end + if (sourceType === 'xprime') { + return { ...result, ...prevState }; + } else { + return { ...prevState, ...result }; + } }; if (isEpisode) { @@ -641,18 +685,21 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = // Start Stremio request using the callback method processStremioSource(type, id, false); - // Add HDRezka source + // Add Xprime source (PRIMARY) + const xprimePromise = processExternalSource('xprime', processXprimeSource(type, id), false); + + // Add HDRezka source const hdrezkaPromise = processExternalSource('hdrezka', processHDRezkaSource(type, id), false); - // Include HDRezka in fetchPromises array - const fetchPromises: Promise<any>[] = [hdrezkaPromise]; + // Include Xprime and HDRezka in fetchPromises array (Xprime first) + const fetchPromises: Promise<any>[] = [xprimePromise, hdrezkaPromise]; // Wait only for external promises now const results = await Promise.allSettled(fetchPromises); const totalTime = Date.now() - startTime; console.log(`✅ [loadStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); - const sourceTypes: string[] = ['hdrezka']; + const sourceTypes: string[] = ['xprime', 'hdrezka']; results.forEach((result, index) => { const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; console.log(`📊 [loadStreams:${source}] Status: ${result.status}`); @@ -723,22 +770,26 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = // Start Stremio request using the callback method processStremioSource('series', episodeId, true); - // Add HDRezka source for episodes - const seasonNum = parseInt(season, 10); - const episodeNum = parseInt(episode, 10); - const hdrezkaPromise = processExternalSource('hdrezka', - processHDRezkaSource('series', id, seasonNum, episodeNum, true), + // Add Xprime source for episodes (PRIMARY) + const xprimeEpisodePromise = processExternalSource('xprime', + processXprimeSource('series', id, parseInt(season), parseInt(episode), true), true ); - const fetchPromises: Promise<any>[] = [hdrezkaPromise]; + // Add HDRezka source for episodes + const hdrezkaEpisodePromise = processExternalSource('hdrezka', + processHDRezkaSource('series', id, parseInt(season), parseInt(episode), true), + true + ); + + const fetchPromises: Promise<any>[] = [xprimeEpisodePromise, hdrezkaEpisodePromise]; // Wait only for external promises now const results = await Promise.allSettled(fetchPromises); const totalTime = Date.now() - startTime; console.log(`✅ [loadEpisodeStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); - const sourceTypes: string[] = ['hdrezka']; + const sourceTypes: string[] = ['xprime', 'hdrezka']; results.forEach((result, index) => { const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 0892a54..2407b9a 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -39,6 +39,7 @@ import PlayerSettingsScreen from '../screens/PlayerSettingsScreen'; import LogoSourceSettings from '../screens/LogoSourceSettings'; import ThemeScreen from '../screens/ThemeScreen'; import ProfilesScreen from '../screens/ProfilesScreen'; +import InternalProvidersSettings from '../screens/InternalProvidersSettings'; // Stack navigator types export type RootStackParamList = { @@ -100,6 +101,7 @@ export type RootStackParamList = { LogoSourceSettings: undefined; ThemeSettings: undefined; ProfilesSettings: undefined; + InternalProvidersSettings: undefined; }; export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>; @@ -1012,6 +1014,21 @@ const AppNavigator = () => { }, }} /> + <Stack.Screen + name="InternalProvidersSettings" + component={InternalProvidersSettings} + options={{ + animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', + animationDuration: Platform.OS === 'android' ? 250 : 200, + presentation: 'card', + gestureEnabled: true, + gestureDirection: 'horizontal', + headerShown: false, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> </Stack.Navigator> </View> </PaperProvider> diff --git a/src/screens/InternalProvidersSettings.tsx b/src/screens/InternalProvidersSettings.tsx new file mode 100644 index 0000000..69845b8 --- /dev/null +++ b/src/screens/InternalProvidersSettings.tsx @@ -0,0 +1,515 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + SafeAreaView, + Platform, + TouchableOpacity, + StatusBar, + Switch, + Alert, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSettings } from '../hooks/useSettings'; +import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; +import { useTheme } from '../contexts/ThemeContext'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +interface SettingItemProps { + title: string; + description?: string; + icon: string; + value: boolean; + onValueChange: (value: boolean) => void; + isLast?: boolean; + badge?: string; +} + +const SettingItem: React.FC<SettingItemProps> = ({ + title, + description, + icon, + value, + onValueChange, + isLast, + badge, +}) => { + const { currentTheme } = useTheme(); + + return ( + <View + style={[ + styles.settingItem, + !isLast && styles.settingItemBorder, + { borderBottomColor: 'rgba(255,255,255,0.08)' }, + ]} + > + <View style={styles.settingContent}> + <View style={[ + styles.settingIconContainer, + { backgroundColor: 'rgba(255,255,255,0.1)' } + ]}> + <MaterialIcons + name={icon} + size={20} + color={currentTheme.colors.primary} + /> + </View> + <View style={styles.settingText}> + <View style={styles.titleRow}> + <Text + style={[ + styles.settingTitle, + { color: currentTheme.colors.text }, + ]} + > + {title} + </Text> + {badge && ( + <View style={[styles.badge, { backgroundColor: currentTheme.colors.primary }]}> + <Text style={styles.badgeText}>{badge}</Text> + </View> + )} + </View> + {description && ( + <Text + style={[ + styles.settingDescription, + { color: currentTheme.colors.textMuted }, + ]} + > + {description} + </Text> + )} + </View> + <Switch + value={value} + onValueChange={onValueChange} + trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }} + thumbColor={Platform.OS === 'android' ? (value ? currentTheme.colors.white : currentTheme.colors.white) : ''} + ios_backgroundColor={'rgba(255,255,255,0.1)'} + /> + </View> + </View> + ); +}; + +const InternalProvidersSettings: React.FC = () => { + const { settings, updateSetting } = useSettings(); + const { currentTheme } = useTheme(); + const navigation = useNavigation(); + + // Individual provider states + const [xprimeEnabled, setXprimeEnabled] = useState(true); + const [hdrezkaEnabled, setHdrezkaEnabled] = useState(true); + + // Load individual provider settings + useEffect(() => { + const loadProviderSettings = async () => { + try { + const xprimeSettings = await AsyncStorage.getItem('xprime_settings'); + const hdrezkaSettings = await AsyncStorage.getItem('hdrezka_settings'); + + if (xprimeSettings) { + const parsed = JSON.parse(xprimeSettings); + setXprimeEnabled(parsed.enabled !== false); + } + + if (hdrezkaSettings) { + const parsed = JSON.parse(hdrezkaSettings); + setHdrezkaEnabled(parsed.enabled !== false); + } + } catch (error) { + console.error('Error loading provider settings:', error); + } + }; + + loadProviderSettings(); + }, []); + + const handleBack = () => { + navigation.goBack(); + }; + + const handleMasterToggle = useCallback((enabled: boolean) => { + if (!enabled) { + Alert.alert( + 'Disable Internal Providers', + 'This will disable all built-in streaming providers (XPRIME, HDRezka). You can still use external Stremio addons.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Disable', + style: 'destructive', + onPress: () => { + updateSetting('enableInternalProviders', false); + } + } + ] + ); + } else { + updateSetting('enableInternalProviders', true); + } + }, [updateSetting]); + + const handleXprimeToggle = useCallback(async (enabled: boolean) => { + setXprimeEnabled(enabled); + try { + await AsyncStorage.setItem('xprime_settings', JSON.stringify({ enabled })); + } catch (error) { + console.error('Error saving XPRIME settings:', error); + } + }, []); + + const handleHdrezkaToggle = useCallback(async (enabled: boolean) => { + setHdrezkaEnabled(enabled); + try { + await AsyncStorage.setItem('hdrezka_settings', JSON.stringify({ enabled })); + } catch (error) { + console.error('Error saving HDRezka settings:', error); + } + }, []); + + return ( + <SafeAreaView + style={[ + styles.container, + { backgroundColor: currentTheme.colors.darkBackground }, + ]} + > + <StatusBar + translucent + backgroundColor="transparent" + barStyle="light-content" + /> + + <View style={styles.header}> + <TouchableOpacity + onPress={handleBack} + style={styles.backButton} + activeOpacity={0.7} + > + <MaterialIcons + name="arrow-back" + size={24} + color={currentTheme.colors.text} + /> + </TouchableOpacity> + <Text + style={[ + styles.headerTitle, + { color: currentTheme.colors.text }, + ]} + > + Internal Providers + </Text> + </View> + + <ScrollView + style={styles.scrollView} + contentContainerStyle={styles.scrollContent} + > + {/* Master Toggle Section */} + <View style={styles.section}> + <Text + style={[ + styles.sectionTitle, + { color: currentTheme.colors.textMuted }, + ]} + > + MASTER CONTROL + </Text> + <View + style={[ + styles.card, + { backgroundColor: currentTheme.colors.elevation2 }, + ]} + > + <SettingItem + title="Enable Internal Providers" + description="Toggle all built-in streaming providers on/off" + icon="toggle-on" + value={settings.enableInternalProviders} + onValueChange={handleMasterToggle} + isLast={true} + /> + </View> + </View> + + {/* Individual Providers Section */} + {settings.enableInternalProviders && ( + <View style={styles.section}> + <Text + style={[ + styles.sectionTitle, + { color: currentTheme.colors.textMuted }, + ]} + > + INDIVIDUAL PROVIDERS + </Text> + <View + style={[ + styles.card, + { backgroundColor: currentTheme.colors.elevation2 }, + ]} + > + <SettingItem + title="XPRIME" + description="High-quality streams with various resolutions" + icon="star" + value={xprimeEnabled} + onValueChange={handleXprimeToggle} + badge="NEW" + /> + <SettingItem + title="HDRezka" + description="Popular streaming service with multiple quality options" + icon="hd" + value={hdrezkaEnabled} + onValueChange={handleHdrezkaToggle} + isLast={true} + /> + </View> + </View> + )} + + {/* Information Section */} + <View style={styles.section}> + <Text + style={[ + styles.sectionTitle, + { color: currentTheme.colors.textMuted }, + ]} + > + INFORMATION + </Text> + <View + style={[ + styles.infoCard, + { + backgroundColor: currentTheme.colors.elevation2, + borderColor: `${currentTheme.colors.primary}30` + }, + ]} + > + <MaterialIcons + name="info-outline" + size={24} + color={currentTheme.colors.primary} + style={styles.infoIcon} + /> + <View style={styles.infoContent}> + <Text + style={[ + styles.infoTitle, + { color: currentTheme.colors.text }, + ]} + > + About Internal Providers + </Text> + <Text + style={[ + styles.infoDescription, + { color: currentTheme.colors.textMuted }, + ]} + > + Internal providers are built directly into the app and don't require separate addon installation. They complement your Stremio addons by providing additional streaming sources. + </Text> + <View style={styles.featureList}> + <View style={styles.featureItem}> + <MaterialIcons + name="check-circle" + size={16} + color={currentTheme.colors.primary} + /> + <Text + style={[ + styles.featureText, + { color: currentTheme.colors.textMuted }, + ]} + > + No addon installation required + </Text> + </View> + <View style={styles.featureItem}> + <MaterialIcons + name="check-circle" + size={16} + color={currentTheme.colors.primary} + /> + <Text + style={[ + styles.featureText, + { color: currentTheme.colors.textMuted }, + ]} + > + Multiple quality options + </Text> + </View> + <View style={styles.featureItem}> + <MaterialIcons + name="check-circle" + size={16} + color={currentTheme.colors.primary} + /> + <Text + style={[ + styles.featureText, + { color: currentTheme.colors.textMuted }, + ]} + > + Fast and reliable streaming + </Text> + </View> + </View> + </View> + </View> + </View> + </ScrollView> + </SafeAreaView> + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16, + paddingBottom: 16, + }, + backButton: { + padding: 8, + marginRight: 8, + }, + headerTitle: { + fontSize: 24, + fontWeight: '700', + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: 16, + paddingBottom: 100, + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 13, + fontWeight: '600', + letterSpacing: 0.8, + marginBottom: 8, + paddingHorizontal: 4, + }, + card: { + borderRadius: 16, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + settingItem: { + padding: 16, + borderBottomWidth: 0.5, + }, + settingItemBorder: { + borderBottomWidth: 0.5, + }, + settingContent: { + flexDirection: 'row', + alignItems: 'center', + }, + settingIconContainer: { + marginRight: 16, + width: 36, + height: 36, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + settingText: { + flex: 1, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + }, + settingTitle: { + fontSize: 16, + fontWeight: '500', + }, + settingDescription: { + fontSize: 14, + opacity: 0.8, + lineHeight: 20, + }, + badge: { + height: 18, + minWidth: 18, + borderRadius: 9, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 6, + marginLeft: 8, + }, + badgeText: { + color: 'white', + fontSize: 10, + fontWeight: '600', + }, + infoCard: { + borderRadius: 16, + padding: 16, + flexDirection: 'row', + borderWidth: 1, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + infoIcon: { + marginRight: 12, + marginTop: 2, + }, + infoContent: { + flex: 1, + }, + infoTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 8, + }, + infoDescription: { + fontSize: 14, + lineHeight: 20, + marginBottom: 12, + }, + featureList: { + gap: 6, + }, + featureItem: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + featureText: { + fontSize: 14, + flex: 1, + }, +}); + +export default InternalProvidersSettings; \ No newline at end of file diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 9dbe102..bc7471c 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -410,12 +410,8 @@ const SettingsScreen: React.FC = () => { title="Internal Providers" description="Enable or disable built-in providers like HDRezka" icon="source" - renderControl={() => ( - <CustomSwitch - value={settings.enableInternalProviders} - onValueChange={(value) => updateSetting('enableInternalProviders', value)} - /> - )} + renderControl={ChevronRight} + onPress={() => navigation.navigate('InternalProvidersSettings')} /> <SettingItem title="Home Screen" diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 7399055..3ae76d7 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -749,11 +749,15 @@ export const StreamsScreen = () => { { id: 'all', name: 'All Providers' }, ...Array.from(allProviders) .sort((a, b) => { - // Always put HDRezka at the top + // Always put XPRIME at the top (primary source) + if (a === 'xprime') return -1; + if (b === 'xprime') return 1; + + // Then put HDRezka second if (a === 'hdrezka') return -1; if (b === 'hdrezka') return 1; - // Then sort Stremio addons by installation order + // Then sort by Stremio addon installation order const indexA = installedAddons.findIndex(addon => addon.id === a); const indexB = installedAddons.findIndex(addon => addon.id === b); @@ -789,8 +793,44 @@ export const StreamsScreen = () => { // Helper function to extract quality as a number for sorting const getQualityNumeric = (title: string | undefined): number => { if (!title) return 0; - const match = title.match(/(\d+)p/); - return match ? parseInt(match[1], 10) : 0; + + // First try to match quality with "p" (e.g., "1080p", "720p") + const matchWithP = title.match(/(\d+)p/i); + if (matchWithP) { + return parseInt(matchWithP[1], 10); + } + + // Then try to match standalone quality numbers at the end of the title + // This handles XPRIME format where quality is just "1080", "720", etc. + const matchAtEnd = title.match(/\b(\d{3,4})\s*$/); + if (matchAtEnd) { + const quality = parseInt(matchAtEnd[1], 10); + // Only return if it looks like a video quality (between 240 and 8000) + if (quality >= 240 && quality <= 8000) { + return quality; + } + } + + // Try to match quality patterns anywhere in the title with common formats + const qualityPatterns = [ + /\b(\d{3,4})p\b/i, // 1080p, 720p, etc. + /\b(\d{3,4})\s*$/, // 1080, 720 at end + /\s(\d{3,4})\s/, // 720 surrounded by spaces + /-\s*(\d{3,4})\s*$/, // -720 at end + /\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i // specific quality values + ]; + + for (const pattern of qualityPatterns) { + const match = title.match(pattern); + if (match) { + const quality = parseInt(match[1], 10); + if (quality >= 240 && quality <= 8000) { + return quality; + } + } + } + + return 0; }; // Filter streams by selected provider - only if not "all" @@ -804,7 +844,11 @@ export const StreamsScreen = () => { return addonId === selectedProvider; }) .sort(([addonIdA], [addonIdB]) => { - // Always put HDRezka at the top + // Always put XPRIME at the top (primary source) + if (addonIdA === 'xprime') return -1; + if (addonIdB === 'xprime') return 1; + + // Then put HDRezka second if (addonIdA === 'hdrezka') return -1; if (addonIdB === 'hdrezka') return 1; @@ -825,6 +869,14 @@ export const StreamsScreen = () => { const qualityB = getQualityNumeric(b.title); return qualityB - qualityA; // Sort descending (e.g., 1080p before 720p) }); + } else if (addonId === 'xprime') { + // Sort XPRIME streams by quality in descending order (highest quality first) + // For XPRIME, quality is in the 'name' field + sortedProviderStreams = [...providerStreams].sort((a, b) => { + const qualityA = getQualityNumeric(a.name); + const qualityB = getQualityNumeric(b.name); + return qualityB - qualityA; // Sort descending (e.g., 1080 before 720) + }); } return { title: addonName, diff --git a/src/services/hdrezkaService.ts b/src/services/hdrezkaService.ts index 98b39e3..0945924 100644 --- a/src/services/hdrezkaService.ts +++ b/src/services/hdrezkaService.ts @@ -420,15 +420,25 @@ class HDRezkaService { try { logger.log(`[HDRezka] Getting streams for ${mediaType} with ID: ${mediaId}`); - // First check if internal providers are enabled - const settingsJson = await AsyncStorage.getItem('app_settings'); - if (settingsJson) { - const settings = JSON.parse(settingsJson); - if (settings.enableInternalProviders === false) { + // Check if internal providers are enabled globally + const appSettingsJson = await AsyncStorage.getItem('app_settings'); + if (appSettingsJson) { + const appSettings = JSON.parse(appSettingsJson); + if (appSettings.enableInternalProviders === false) { logger.log('[HDRezka] Internal providers are disabled in settings, skipping HDRezka'); return []; } } + + // Check if HDRezka specifically is enabled + const hdrezkaSettingsJson = await AsyncStorage.getItem('hdrezka_settings'); + if (hdrezkaSettingsJson) { + const hdrezkaSettings = JSON.parse(hdrezkaSettingsJson); + if (hdrezkaSettings.enabled === false) { + logger.log('[HDRezka] HDRezka provider is disabled in settings, skipping HDRezka'); + return []; + } + } // First, extract the actual title from TMDB if this is an ID let title = mediaId; diff --git a/src/services/xprimeService.ts b/src/services/xprimeService.ts new file mode 100644 index 0000000..ab33583 --- /dev/null +++ b/src/services/xprimeService.ts @@ -0,0 +1,380 @@ +import { logger } from '../utils/logger'; +import { Stream } from '../types/metadata'; +import { tmdbService } from './tmdbService'; +import axios from 'axios'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as FileSystem from 'expo-file-system'; +import * as Crypto from 'expo-crypto'; + +// Use node-fetch if available, otherwise fallback to global fetch +let fetchImpl: typeof fetch; +try { + // @ts-ignore + fetchImpl = require('node-fetch'); +} catch { + fetchImpl = fetch; +} + +// Constants +const MAX_RETRIES_XPRIME = 3; +const RETRY_DELAY_MS_XPRIME = 1000; + +// Use app's cache directory for React Native +const CACHE_DIR = `${FileSystem.cacheDirectory}xprime/`; + +const BROWSER_HEADERS_XPRIME = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache', + 'Sec-Ch-Ua': '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"', + 'Sec-Ch-Ua-Mobile': '?0', + 'Sec-Ch-Ua-Platform': '"Windows"', + 'Connection': 'keep-alive' +}; + +interface XprimeStream { + url: string; + quality: string; + title: string; + provider: string; + codecs: string[]; + size: string; +} + +class XprimeService { + private MAX_RETRIES = 3; + private RETRY_DELAY = 1000; // 1 second + + // Ensure cache directories exist + private async ensureCacheDir(dirPath: string) { + try { + const dirInfo = await FileSystem.getInfoAsync(dirPath); + if (!dirInfo.exists) { + await FileSystem.makeDirectoryAsync(dirPath, { intermediates: true }); + } + } catch (error) { + logger.error(`[XPRIME] Warning: Could not create cache directory ${dirPath}:`, error); + } + } + + // Cache helpers + private async getFromCache(cacheKey: string, subDir: string = ''): Promise<any> { + try { + const fullPath = `${CACHE_DIR}${subDir}/${cacheKey}`; + const fileInfo = await FileSystem.getInfoAsync(fullPath); + + if (fileInfo.exists) { + const data = await FileSystem.readAsStringAsync(fullPath); + logger.log(`[XPRIME] CACHE HIT for: ${subDir}/${cacheKey}`); + try { + return JSON.parse(data); + } catch (e) { + return data; + } + } + return null; + } catch (error) { + logger.error(`[XPRIME] CACHE READ ERROR for ${cacheKey}:`, error); + return null; + } + } + + private async saveToCache(cacheKey: string, content: any, subDir: string = '') { + try { + const fullSubDir = `${CACHE_DIR}${subDir}/`; + await this.ensureCacheDir(fullSubDir); + + const fullPath = `${fullSubDir}${cacheKey}`; + const dataToSave = typeof content === 'string' ? content : JSON.stringify(content, null, 2); + + await FileSystem.writeAsStringAsync(fullPath, dataToSave); + logger.log(`[XPRIME] SAVED TO CACHE: ${subDir}/${cacheKey}`); + } catch (error) { + logger.error(`[XPRIME] CACHE WRITE ERROR for ${cacheKey}:`, error); + } + } + + // Helper function to fetch stream size using a HEAD request + private async fetchStreamSize(url: string): Promise<string> { + const cacheSubDir = 'xprime_stream_sizes'; + + // Create a hash of the URL to use as the cache key + const urlHash = await Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.MD5, + url, + { encoding: Crypto.CryptoEncoding.HEX } + ); + const urlCacheKey = `${urlHash}.txt`; + + const cachedSize = await this.getFromCache(urlCacheKey, cacheSubDir); + if (cachedSize !== null) { + return cachedSize; + } + + try { + // For m3u8, Content-Length is for the playlist file, not the stream segments + if (url.toLowerCase().includes('.m3u8')) { + await this.saveToCache(urlCacheKey, 'Playlist (size N/A)', cacheSubDir); + return 'Playlist (size N/A)'; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5-second timeout + + try { + const response = await fetchImpl(url, { + method: 'HEAD', + signal: controller.signal + }); + clearTimeout(timeoutId); + + const contentLength = response.headers.get('content-length'); + if (contentLength) { + const sizeInBytes = parseInt(contentLength, 10); + if (!isNaN(sizeInBytes)) { + let formattedSize; + if (sizeInBytes < 1024) formattedSize = `${sizeInBytes} B`; + else if (sizeInBytes < 1024 * 1024) formattedSize = `${(sizeInBytes / 1024).toFixed(2)} KB`; + else if (sizeInBytes < 1024 * 1024 * 1024) formattedSize = `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`; + else formattedSize = `${(sizeInBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + + await this.saveToCache(urlCacheKey, formattedSize, cacheSubDir); + return formattedSize; + } + } + await this.saveToCache(urlCacheKey, 'Unknown size', cacheSubDir); + return 'Unknown size'; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + logger.error(`[XPRIME] Could not fetch size for ${url.substring(0, 50)}...`, error); + await this.saveToCache(urlCacheKey, 'Unknown size', cacheSubDir); + return 'Unknown size'; + } + } + + private async fetchWithRetry(url: string, options: any, maxRetries: number = MAX_RETRIES_XPRIME) { + let lastError; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await fetchImpl(url, options); + if (!response.ok) { + let errorBody = ''; + try { + errorBody = await response.text(); + } catch (e) { + // ignore + } + + const httpError = new Error(`HTTP error! Status: ${response.status} ${response.statusText}. Body: ${errorBody.substring(0, 200)}`); + (httpError as any).status = response.status; + throw httpError; + } + return response; + } catch (error: any) { + lastError = error; + logger.error(`[XPRIME] Fetch attempt ${attempt}/${maxRetries} failed for ${url}:`, error); + + // If it's a 403 error, stop retrying immediately + if (error.status === 403) { + logger.log(`[XPRIME] Encountered 403 Forbidden for ${url}. Halting retries.`); + throw lastError; + } + + if (attempt < maxRetries) { + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS_XPRIME * Math.pow(2, attempt - 1))); + } + } + } + + logger.error(`[XPRIME] All fetch attempts failed for ${url}. Last error:`, lastError); + if (lastError) throw lastError; + else throw new Error(`[XPRIME] All fetch attempts failed for ${url} without a specific error captured.`); + } + + async getStreams(mediaId: string, mediaType: string, season?: number, episode?: number): Promise<Stream[]> { + try { + logger.log(`[XPRIME] Getting streams for ${mediaType} with ID: ${mediaId}`); + + // First check if internal providers are enabled + const settingsJson = await AsyncStorage.getItem('app_settings'); + if (settingsJson) { + const settings = JSON.parse(settingsJson); + if (settings.enableInternalProviders === false) { + logger.log('[XPRIME] Internal providers are disabled in settings, skipping Xprime.tv'); + return []; + } + } + + // Check individual XPRIME provider setting + const xprimeSettingsJson = await AsyncStorage.getItem('xprime_settings'); + if (xprimeSettingsJson) { + const xprimeSettings = JSON.parse(xprimeSettingsJson); + if (xprimeSettings.enabled === false) { + logger.log('[XPRIME] XPRIME provider is disabled in settings, skipping Xprime.tv'); + return []; + } + } + + // Extract the actual title from TMDB if this is an ID + let title = mediaId; + let year: number | undefined = undefined; + + if (mediaId.startsWith('tt') || mediaId.startsWith('tmdb:')) { + let tmdbId: number | null = null; + + // Handle IMDB IDs + if (mediaId.startsWith('tt')) { + logger.log(`[XPRIME] Converting IMDB ID to TMDB ID: ${mediaId}`); + tmdbId = await tmdbService.findTMDBIdByIMDB(mediaId); + } + // Handle TMDB IDs + else if (mediaId.startsWith('tmdb:')) { + tmdbId = parseInt(mediaId.split(':')[1], 10); + } + + if (tmdbId) { + // Fetch metadata from TMDB API + if (mediaType === 'movie') { + logger.log(`[XPRIME] Fetching movie details from TMDB for ID: ${tmdbId}`); + const movieDetails = await tmdbService.getMovieDetails(tmdbId.toString()); + if (movieDetails) { + title = movieDetails.title; + year = movieDetails.release_date ? parseInt(movieDetails.release_date.substring(0, 4), 10) : undefined; + logger.log(`[XPRIME] Using movie title "${title}" (${year}) for search`); + } + } else { + logger.log(`[XPRIME] Fetching TV show details from TMDB for ID: ${tmdbId}`); + const showDetails = await tmdbService.getTVShowDetails(tmdbId); + if (showDetails) { + title = showDetails.name; + year = showDetails.first_air_date ? parseInt(showDetails.first_air_date.substring(0, 4), 10) : undefined; + logger.log(`[XPRIME] Using TV show title "${title}" (${year}) for search`); + } + } + } + } + + if (!title || !year) { + logger.log('[XPRIME] Skipping fetch: title or year is missing.'); + return []; + } + + const rawXprimeStreams = await this.getXprimeStreams(title, year, mediaType, season, episode); + + // Convert to Stream format + const streams: Stream[] = rawXprimeStreams.map(xprimeStream => ({ + name: `XPRIME ${xprimeStream.quality.toUpperCase()}`, + title: xprimeStream.size !== 'Unknown size' ? xprimeStream.size : '', + url: xprimeStream.url, + behaviorHints: { + notWebReady: false + } + })); + + logger.log(`[XPRIME] Found ${streams.length} streams`); + return streams; + } catch (error) { + logger.error(`[XPRIME] Error getting streams:`, error); + return []; + } + } + + private async getXprimeStreams(title: string, year: number, type: string, seasonNum?: number, episodeNum?: number): Promise<XprimeStream[]> { + let rawXprimeStreams: XprimeStream[] = []; + + try { + logger.log(`[XPRIME] Fetch attempt for '${title}' (${year}). Type: ${type}, S: ${seasonNum}, E: ${episodeNum}`); + + const xprimeName = encodeURIComponent(title); + let xprimeApiUrl: string; + + // type here is tmdbTypeFromId which is 'movie' or 'tv'/'series' + if (type === 'movie') { + xprimeApiUrl = `https://backend.xprime.tv/primebox?name=${xprimeName}&year=${year}&fallback_year=${year}`; + } else if (type === 'tv' || type === 'series') { // Accept both 'tv' and 'series' for compatibility + if (seasonNum !== null && seasonNum !== undefined && episodeNum !== null && episodeNum !== undefined) { + xprimeApiUrl = `https://backend.xprime.tv/primebox?name=${xprimeName}&year=${year}&fallback_year=${year}&season=${seasonNum}&episode=${episodeNum}`; + } else { + logger.log('[XPRIME] Skipping series request: missing season/episode numbers.'); + return []; + } + } else { + logger.log(`[XPRIME] Skipping request: unknown type '${type}'.`); + return []; + } + + let xprimeResult: any; + + // Direct fetch only + logger.log(`[XPRIME] Fetching directly: ${xprimeApiUrl}`); + const xprimeResponse = await this.fetchWithRetry(xprimeApiUrl, { + headers: { + ...BROWSER_HEADERS_XPRIME, + 'Origin': 'https://pstream.org', + 'Referer': 'https://pstream.org/', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'cross-site', + 'Sec-Fetch-Dest': 'empty' + } + }); + xprimeResult = await xprimeResponse.json(); + + // Process the result + this.processXprimeResult(xprimeResult, rawXprimeStreams, title, type, seasonNum, episodeNum); + + // Fetch stream sizes concurrently for all Xprime streams + if (rawXprimeStreams.length > 0) { + logger.log('[XPRIME] Fetching stream sizes...'); + const sizePromises = rawXprimeStreams.map(async (stream) => { + stream.size = await this.fetchStreamSize(stream.url); + return stream; + }); + await Promise.all(sizePromises); + logger.log(`[XPRIME] Found ${rawXprimeStreams.length} streams with sizes.`); + } + + return rawXprimeStreams; + + } catch (xprimeError) { + logger.error('[XPRIME] Error fetching or processing streams:', xprimeError); + return []; + } + } + + // Helper function to process Xprime API response + private processXprimeResult(xprimeResult: any, rawXprimeStreams: XprimeStream[], title: string, type: string, seasonNum?: number, episodeNum?: number) { + const processXprimeItem = (item: any) => { + if (item && typeof item === 'object' && !item.error && item.streams && typeof item.streams === 'object') { + Object.entries(item.streams).forEach(([quality, fileUrl]) => { + if (fileUrl && typeof fileUrl === 'string') { + rawXprimeStreams.push({ + url: fileUrl, + quality: quality || 'Unknown', + title: `${title} - ${(type === 'tv' || type === 'series') ? `S${String(seasonNum).padStart(2,'0')}E${String(episodeNum).padStart(2,'0')} ` : ''}${quality}`, + provider: 'XPRIME', + codecs: [], + size: 'Unknown size' + }); + } + }); + } else { + logger.log('[XPRIME] Skipping item due to missing/invalid streams or an error was reported by Xprime API:', item && item.error); + } + }; + + if (Array.isArray(xprimeResult)) { + xprimeResult.forEach(processXprimeItem); + } else if (xprimeResult) { + processXprimeItem(xprimeResult); + } else { + logger.log('[XPRIME] No result from Xprime API to process.'); + } + } +} + +export const xprimeService = new XprimeService(); \ No newline at end of file -- 2.45.2 From 9e03619db77a84c6697f78a3f604152a8630ecd2 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 09:02:48 +0530 Subject: [PATCH 39/88] Refactor internal provider settings and enhance streaming experience This update removes the XPRIME provider from internal settings, streamlining the provider management process. The HDRezka provider is now prioritized in the UI, improving user experience. Additionally, various components have been optimized for better performance, including enhancements to the VideoPlayer for improved buffering and seeking behavior on Android devices. The app's theme has been updated to a dark mode, and several dependencies have been upgraded for better stability and performance. --- App.tsx | 12 +- app.json | 11 +- eas.json | 10 +- metro.config.js | 47 +++---- package-lock.json | 67 ++++++---- package.json | 2 +- scripts/test-hdrezka.js | 2 +- src/components/SplashScreen.tsx | 59 +++++++++ src/components/player/VideoPlayer.tsx | 147 +++++++++++++++++----- src/hooks/useMetadata.ts | 108 +++++----------- src/screens/InternalProvidersSettings.tsx | 26 +--- src/screens/StreamsScreen.tsx | 24 ++-- src/screens/TMDBSettingsScreen.tsx | 9 +- src/services/xprimeService.ts | 88 +------------ 14 files changed, 321 insertions(+), 291 deletions(-) create mode 100644 src/components/SplashScreen.tsx diff --git a/App.tsx b/App.tsx index df02cd8..bf19654 100644 --- a/App.tsx +++ b/App.tsx @@ -5,7 +5,7 @@ * @format */ -import React from 'react'; +import React, { useState } from 'react'; import { View, StyleSheet @@ -24,6 +24,7 @@ import { CatalogProvider } from './src/contexts/CatalogContext'; import { GenreProvider } from './src/contexts/GenreContext'; import { TraktProvider } from './src/contexts/TraktContext'; import { ThemeProvider, useTheme } from './src/contexts/ThemeContext'; +import SplashScreen from './src/components/SplashScreen'; // This fixes many navigation layout issues by using native screen containers enableScreens(true); @@ -31,6 +32,7 @@ enableScreens(true); // Inner app component that uses the theme context const ThemedApp = () => { const { currentTheme } = useTheme(); + const [isAppReady, setIsAppReady] = useState(false); // Create custom themes based on current theme const customDarkTheme = { @@ -50,6 +52,11 @@ const ThemedApp = () => { background: currentTheme.colors.darkBackground, } }; + + // Handler for splash screen completion + const handleSplashComplete = () => { + setIsAppReady(true); + }; return ( <PaperProvider theme={customDarkTheme}> @@ -62,7 +69,8 @@ const ThemedApp = () => { <StatusBar style="light" /> - <AppNavigator /> + {!isAppReady && <SplashScreen onFinish={handleSplashComplete} />} + {isAppReady && <AppNavigator />} </View> </NavigationContainer> </PaperProvider> diff --git a/app.json b/app.json index 07c328d..453ab6c 100644 --- a/app.json +++ b/app.json @@ -5,13 +5,13 @@ "version": "1.0.0", "orientation": "default", "icon": "./assets/icon.png", - "userInterfaceStyle": "light", + "userInterfaceStyle": "dark", "scheme": "stremioexpo", "newArchEnabled": true, "splash": { "image": "./assets/splash-icon.png", "resizeMode": "contain", - "backgroundColor": "#ffffff" + "backgroundColor": "#020404" }, "ios": { "supportsTablet": true, @@ -49,7 +49,12 @@ "WAKE_LOCK" ], "package": "com.nuvio.app", - "enableSplitAPKs": true + "enableSplitAPKs": true, + "versionCode": 1, + "enableProguardInReleaseBuilds": true, + "enableHermes": true, + "enableSeparateBuildPerCPUArchitecture": true, + "enableVectorDrawables": true }, "web": { "favicon": "./assets/favicon.png" diff --git a/eas.json b/eas.json index afd500a..b208a76 100644 --- a/eas.json +++ b/eas.json @@ -13,7 +13,12 @@ }, "production": { "autoIncrement": true, - "extends": "apk" + "extends": "apk", + "android": { + "buildType": "apk", + "gradleCommand": ":app:assembleRelease", + "image": "latest" + } }, "release": { "distribution": "store", @@ -23,7 +28,8 @@ }, "apk": { "android": { - "buildType": "apk" + "buildType": "apk", + "gradleCommand": ":app:assembleRelease" } } }, diff --git a/metro.config.js b/metro.config.js index 6218690..79fe23f 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,28 +1,31 @@ const { getDefaultConfig } = require('expo/metro-config'); -module.exports = (() => { - const config = getDefaultConfig(__dirname); +const config = getDefaultConfig(__dirname); - const { transformer, resolver } = config; - - config.transformer = { - ...transformer, - babelTransformerPath: require.resolve('react-native-svg-transformer'), - minifierConfig: { - compress: { - // Remove console.* statements in release builds - drop_console: true, - // Keep error logging for critical issues - pure_funcs: ['console.info', 'console.log', 'console.debug', 'console.warn'], - }, +// Enable tree shaking and better minification +config.transformer = { + ...config.transformer, + babelTransformerPath: require.resolve('react-native-svg-transformer'), + minifierConfig: { + ecma: 8, + keep_fnames: true, + mangle: { + keep_fnames: true, }, - }; + compress: { + drop_console: true, + drop_debugger: true, + pure_funcs: ['console.log', 'console.info', 'console.debug'], + }, + }, +}; - config.resolver = { - ...resolver, - assetExts: resolver.assetExts.filter((ext) => ext !== 'svg'), - sourceExts: [...resolver.sourceExts, 'svg'], - }; +// Optimize resolver for better tree shaking and SVG support +config.resolver = { + ...config.resolver, + assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'), + sourceExts: [...config.resolver.sourceExts, 'svg'], + resolverMainFields: ['react-native', 'browser', 'main'], +}; - return config; -})(); \ No newline at end of file +module.exports = config; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 60caca1..a8a42dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@shopify/flash-list": "1.7.3", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", - "axios": "^1.8.4", + "axios": "^1.10.0", "base64-js": "^1.5.1", "date-fns": "^4.1.0", "eventemitter3": "^5.0.1", @@ -4927,9 +4927,9 @@ } }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -5554,21 +5554,21 @@ } }, "node_modules/cheerio": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", - "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.0.tgz", + "integrity": "sha512-+0hMx9eYhJvWbgpKV9hN7jg0JcwydpopZE4hgi+KvQtByZXPp04NiCWU0LzcAbP63abZckIHkTQaXVF52mX3xQ==", "license": "MIT", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", - "domutils": "^3.1.0", + "domutils": "^3.2.2", "encoding-sniffer": "^0.2.0", - "htmlparser2": "^9.1.0", - "parse5": "^7.1.2", - "parse5-htmlparser2-tree-adapter": "^7.0.0", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", - "undici": "^6.19.5", + "undici": "^7.10.0", "whatwg-mimetype": "^4.0.0" }, "engines": { @@ -5595,6 +5595,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cheerio/node_modules/undici": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz", + "integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -6143,9 +6152,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -7880,9 +7889,9 @@ "license": "ISC" }, "node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -7894,8 +7903,20 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/http-errors": { @@ -13696,9 +13717,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 7d70e09..2fef212 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@shopify/flash-list": "1.7.3", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", - "axios": "^1.8.4", + "axios": "^1.10.0", "base64-js": "^1.5.1", "date-fns": "^4.1.0", "eventemitter3": "^5.0.1", diff --git a/scripts/test-hdrezka.js b/scripts/test-hdrezka.js index d1e8ab1..1d190aa 100644 --- a/scripts/test-hdrezka.js +++ b/scripts/test-hdrezka.js @@ -1,4 +1,4 @@ -// Test script for HDRezka service +d// Test script for HDRezka service // Run with: node scripts/test-hdrezka.js const fetch = require('node-fetch'); diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx new file mode 100644 index 0000000..fabe7b8 --- /dev/null +++ b/src/components/SplashScreen.tsx @@ -0,0 +1,59 @@ +import React, { useEffect } from 'react'; +import { View, Image, StyleSheet, Animated } from 'react-native'; +import { colors } from '../styles/colors'; + +interface SplashScreenProps { + onFinish: () => void; +} + +const SplashScreen = ({ onFinish }: SplashScreenProps) => { + // Animation value for opacity + const fadeAnim = new Animated.Value(1); + + useEffect(() => { + // Wait for a short period then start fade out animation + const timer = setTimeout(() => { + Animated.timing(fadeAnim, { + toValue: 0, + duration: 800, + useNativeDriver: true, + }).start(() => { + // Call onFinish when animation completes + onFinish(); + }); + }, 1500); // Show splash for 1.5 seconds + + return () => clearTimeout(timer); + }, [fadeAnim, onFinish]); + + return ( + <Animated.View style={[styles.container, { opacity: fadeAnim }]}> + <Image + source={require('../../assets/splash-icon.png')} + style={styles.image} + resizeMode="contain" + /> + </Animated.View> + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.darkBackground, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 10, + }, + image: { + width: '70%', + height: '70%', + }, +}); + +export default SplashScreen; \ No newline at end of file diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index 3da4a29..2655caa 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -297,16 +297,56 @@ const VideoPlayer: React.FC = () => { const seekToTime = (timeInSeconds: number) => { if (!isPlayerReady || duration <= 0 || !vlcRef.current) return; const normalizedPosition = Math.max(0, Math.min(timeInSeconds / duration, 1)); + try { - if (typeof vlcRef.current.setPosition === 'function') { + if (Platform.OS === 'android') { + // On Android, we need to handle seeking differently to prevent black screens + setIsBuffering(true); + + // Set a small timeout to prevent overwhelming the player + const now = Date.now(); + if (now - lastSeekTime.current < 300) { + // Throttle seeks that are too close together + if (seekDebounceTimer.current) { + clearTimeout(seekDebounceTimer.current); + } + + seekDebounceTimer.current = setTimeout(() => { + if (vlcRef.current) { + // Set position instead of using seek on Android + vlcRef.current.setPosition(normalizedPosition); + lastSeekTime.current = Date.now(); + + // Give the player some time to recover + setTimeout(() => { + setIsBuffering(false); + }, 500); + } + }, 300); + return; + } + + // Directly set position vlcRef.current.setPosition(normalizedPosition); - } else if (typeof vlcRef.current.seek === 'function') { - vlcRef.current.seek(normalizedPosition); + lastSeekTime.current = now; + + // Reset buffering state after a delay + setTimeout(() => { + setIsBuffering(false); + }, 500); } else { - logger.error('[VideoPlayer] No seek method available on VLC player'); + // For iOS, keep the original behavior + if (typeof vlcRef.current.setPosition === 'function') { + vlcRef.current.setPosition(normalizedPosition); + } else if (typeof vlcRef.current.seek === 'function') { + vlcRef.current.seek(normalizedPosition); + } else { + logger.error('[VideoPlayer] No seek method available on VLC player'); + } } } catch (error) { logger.error('[VideoPlayer] Error during seek operation:', error); + setIsBuffering(false); } }; @@ -329,8 +369,18 @@ const VideoPlayer: React.FC = () => { const handleProgressBarDragEnd = () => { setIsDragging(false); if (pendingSeekValue.current !== null) { - seekToTime(pendingSeekValue.current); - pendingSeekValue.current = null; + // For Android, add a small delay to ensure UI updates before the seek happens + if (Platform.OS === 'android') { + setTimeout(() => { + if (pendingSeekValue.current !== null) { + seekToTime(pendingSeekValue.current); + pendingSeekValue.current = null; + } + }, 150); + } else { + seekToTime(pendingSeekValue.current); + pendingSeekValue.current = null; + } } }; @@ -743,35 +793,63 @@ const VideoPlayer: React.FC = () => { logger.log(`[VideoPlayer] Player ready after source change, seeking to position: ${pendingSeek.position}s out of ${duration}s total`); if (pendingSeek.position > 0 && vlcRef.current) { - // Wait longer for the player to be fully ready and stable + // Longer delay for Android to ensure player is stable + const delayTime = Platform.OS === 'android' ? 2500 : 1500; + setTimeout(() => { if (vlcRef.current && duration > 0 && pendingSeek) { logger.log(`[VideoPlayer] Executing seek to ${pendingSeek.position}s`); - // Use our existing seekToTime function which handles VLC methods properly - seekToTime(pendingSeek.position); - - // Also update the current time state to reflect the seek - setCurrentTime(pendingSeek.position); - - // Resume playback if it was playing before the source change - if (pendingSeek.shouldPlay) { + if (Platform.OS === 'android') { + // On Android, wait longer and set isBuffering to improve visual feedback + setIsBuffering(true); + + // For Android, use setPosition directly with normalized value + const normalizedPosition = Math.max(0, Math.min(pendingSeek.position / duration, 1)); + vlcRef.current.setPosition(normalizedPosition); + + // Update the current time + setCurrentTime(pendingSeek.position); + + // Give the player time to recover from the seek setTimeout(() => { - logger.log('[VideoPlayer] Resuming playback after seek'); - setPaused(false); - if (vlcRef.current && typeof vlcRef.current.play === 'function') { - vlcRef.current.play(); + setIsBuffering(false); + + // Resume playback after a delay if needed + if (pendingSeek.shouldPlay) { + setPaused(false); } - }, 700); // Wait longer for seek to complete properly + + // Clean up + setPendingSeek(null); + setIsChangingSource(false); + }, 800); + } else { + // iOS - use the normal seekToTime function + seekToTime(pendingSeek.position); + + // Also update the current time state + setCurrentTime(pendingSeek.position); + + // Resume playback if needed + if (pendingSeek.shouldPlay) { + setTimeout(() => { + logger.log('[VideoPlayer] Resuming playback after seek'); + setPaused(false); + if (vlcRef.current && typeof vlcRef.current.play === 'function') { + vlcRef.current.play(); + } + }, 700); + } + + // Clean up + setTimeout(() => { + setPendingSeek(null); + setIsChangingSource(false); + }, 800); } - - // Clean up after a reasonable delay - setTimeout(() => { - setPendingSeek(null); - setIsChangingSource(false); - }, 800); } - }, 1500); // Increased delay to ensure player is fully stable + }, delayTime); } else { // No seeking needed, just resume playback if it was playing if (pendingSeek.shouldPlay) { @@ -963,7 +1041,19 @@ const VideoPlayer: React.FC = () => { }} source={{ uri: currentStreamUrl, - initOptions: [ + initOptions: Platform.OS === 'android' ? [ + '--rtsp-tcp', + '--network-caching=1500', + '--rtsp-caching=1500', + '--no-audio-time-stretch', + '--clock-jitter=0', + '--clock-synchro=0', + '--drop-late-frames', + '--skip-frames', + '--aout=opensles', + '--file-caching=1500', + '--sout-mux-caching=1500', + ] : [ '--rtsp-tcp', '--network-caching=150', '--rtsp-caching=150', @@ -984,6 +1074,7 @@ const VideoPlayer: React.FC = () => { onProgress={handleProgress} onEnd={onEnd} onError={handleError} + onBuffering={onBuffering} /> </TouchableOpacity> </View> diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index c6941c1..fe71136 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -4,12 +4,13 @@ import { catalogService } from '../services/catalogService'; import { stremioService } from '../services/stremioService'; import { tmdbService } from '../services/tmdbService'; import { hdrezkaService } from '../services/hdrezkaService'; -import { xprimeService } from '../services/xprimeService'; import { cacheService } from '../services/cacheService'; import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata'; import { TMDBService } from '../services/tmdbService'; import { logger } from '../utils/logger'; import { usePersistentSeasons } from './usePersistentSeasons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Stream } from '../types/metadata'; // Constants for timeouts and retries const API_TIMEOUT = 10000; // 10 seconds @@ -115,6 +116,8 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = const [recommendations, setRecommendations] = useState<StreamingContent[]>([]); const [loadingRecommendations, setLoadingRecommendations] = useState(false); const [imdbId, setImdbId] = useState<string | null>(null); + const [isLoading, setIsLoading] = useState(false); + const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({}); // Add hook for persistent seasons const { getSeason, saveSeason } = usePersistentSeasons(); @@ -216,78 +219,43 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = } }; - const processXprimeSource = async (type: string, id: string, season?: number, episode?: number, isEpisode = false) => { - const sourceStartTime = Date.now(); - const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams'; - const sourceName = 'xprime'; - - logger.log(`🔍 [${logPrefix}:${sourceName}] Starting fetch`); - - try { - const streams = await xprimeService.getStreams( - id, - type, - season, - episode - ); - - const processTime = Date.now() - sourceStartTime; - - if (streams && streams.length > 0) { - logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams after ${processTime}ms`); - - // Format response similar to Stremio format for the UI - return { - 'xprime': { - addonName: 'XPRIME', - streams - } - }; - } else { - logger.log(`⚠️ [${logPrefix}:${sourceName}] No streams found after ${processTime}ms`); - return {}; - } - } catch (error) { - logger.error(`❌ [${logPrefix}:${sourceName}] Error:`, error); - return {}; - } - }; - const processExternalSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => { - const sourceStartTime = Date.now(); - const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams'; - try { - logger.log(`🔍 [${logPrefix}:${sourceType}] Starting fetch`); + const startTime = Date.now(); const result = await promise; - logger.log(`✅ [${logPrefix}:${sourceType}] Completed in ${Date.now() - sourceStartTime}ms`); + const processingTime = Date.now() - startTime; - if (Object.keys(result).length > 0) { - const totalStreams = Object.values(result).reduce((acc, group: any) => acc + (group.streams?.length || 0), 0); - logger.log(`📦 [${logPrefix}:${sourceType}] Found ${totalStreams} streams`); - + if (result && Object.keys(result).length > 0) { + // Update the appropriate state based on whether this is for an episode or not const updateState = (prevState: GroupedStreams) => { - logger.log(`🔄 [${logPrefix}:${sourceType}] Updating state with ${Object.keys(result).length} providers`); + const newState = { ...prevState }; - // If this is XPRIME, put it first; otherwise append to the end - if (sourceType === 'xprime') { - return { ...result, ...prevState }; - } else { - return { ...prevState, ...result }; - } + // Merge in the new streams + Object.entries(result).forEach(([provider, data]: [string, any]) => { + newState[provider] = data; + }); + + return newState; }; - + if (isEpisode) { setEpisodeStreams(updateState); } else { setGroupedStreams(updateState); } + + console.log(`✅ [processExternalSource:${sourceType}] Processed in ${processingTime}ms, found streams:`, + Object.values(result).reduce((acc: number, curr: any) => acc + (curr.streams?.length || 0), 0) + ); + + // Return the result for the promise chain + return result; } else { - logger.log(`⚠️ [${logPrefix}:${sourceType}] No streams found`); + console.log(`⚠️ [processExternalSource:${sourceType}] No streams found after ${processingTime}ms`); + return {}; } - return result; } catch (error) { - logger.error(`❌ [${logPrefix}:${sourceType}] Error:`, error); + console.error(`❌ [processExternalSource:${sourceType}] Error:`, error); return {}; } }; @@ -661,9 +629,6 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = console.log('🚀 [loadStreams] START - Loading streams for:', id); updateLoadingState(); - // Always clear streams first to ensure we don't show stale data - setGroupedStreams({}); - // Get TMDB ID for external sources first before starting parallel requests console.log('🔍 [loadStreams] Getting TMDB ID for:', id); let tmdbId; @@ -679,27 +644,22 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = tmdbId = id; console.log('ℹ️ [loadStreams] Using ID as TMDB ID:', tmdbId); } - - console.log('🔄 [loadStreams] Starting stream requests'); // Start Stremio request using the callback method processStremioSource(type, id, false); - - // Add Xprime source (PRIMARY) - const xprimePromise = processExternalSource('xprime', processXprimeSource(type, id), false); // Add HDRezka source const hdrezkaPromise = processExternalSource('hdrezka', processHDRezkaSource(type, id), false); - // Include Xprime and HDRezka in fetchPromises array (Xprime first) - const fetchPromises: Promise<any>[] = [xprimePromise, hdrezkaPromise]; + // Include HDRezka in fetchPromises array + const fetchPromises: Promise<any>[] = [hdrezkaPromise]; // Wait only for external promises now const results = await Promise.allSettled(fetchPromises); const totalTime = Date.now() - startTime; console.log(`✅ [loadStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); - const sourceTypes: string[] = ['xprime', 'hdrezka']; + const sourceTypes: string[] = ['hdrezka']; results.forEach((result, index) => { const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; console.log(`📊 [loadStreams:${source}] Status: ${result.status}`); @@ -770,26 +730,20 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = // Start Stremio request using the callback method processStremioSource('series', episodeId, true); - // Add Xprime source for episodes (PRIMARY) - const xprimeEpisodePromise = processExternalSource('xprime', - processXprimeSource('series', id, parseInt(season), parseInt(episode), true), - true - ); - // Add HDRezka source for episodes const hdrezkaEpisodePromise = processExternalSource('hdrezka', processHDRezkaSource('series', id, parseInt(season), parseInt(episode), true), true ); - const fetchPromises: Promise<any>[] = [xprimeEpisodePromise, hdrezkaEpisodePromise]; + const fetchPromises: Promise<any>[] = [hdrezkaEpisodePromise]; // Wait only for external promises now const results = await Promise.allSettled(fetchPromises); const totalTime = Date.now() - startTime; console.log(`✅ [loadEpisodeStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); - const sourceTypes: string[] = ['xprime', 'hdrezka']; + const sourceTypes: string[] = ['hdrezka']; results.forEach((result, index) => { const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`); diff --git a/src/screens/InternalProvidersSettings.tsx b/src/screens/InternalProvidersSettings.tsx index 69845b8..996d3d1 100644 --- a/src/screens/InternalProvidersSettings.tsx +++ b/src/screens/InternalProvidersSettings.tsx @@ -104,21 +104,14 @@ const InternalProvidersSettings: React.FC = () => { const navigation = useNavigation(); // Individual provider states - const [xprimeEnabled, setXprimeEnabled] = useState(true); const [hdrezkaEnabled, setHdrezkaEnabled] = useState(true); // Load individual provider settings useEffect(() => { const loadProviderSettings = async () => { try { - const xprimeSettings = await AsyncStorage.getItem('xprime_settings'); const hdrezkaSettings = await AsyncStorage.getItem('hdrezka_settings'); - if (xprimeSettings) { - const parsed = JSON.parse(xprimeSettings); - setXprimeEnabled(parsed.enabled !== false); - } - if (hdrezkaSettings) { const parsed = JSON.parse(hdrezkaSettings); setHdrezkaEnabled(parsed.enabled !== false); @@ -139,7 +132,7 @@ const InternalProvidersSettings: React.FC = () => { if (!enabled) { Alert.alert( 'Disable Internal Providers', - 'This will disable all built-in streaming providers (XPRIME, HDRezka). You can still use external Stremio addons.', + 'This will disable all built-in streaming providers (HDRezka). You can still use external Stremio addons.', [ { text: 'Cancel', style: 'cancel' }, { @@ -156,15 +149,6 @@ const InternalProvidersSettings: React.FC = () => { } }, [updateSetting]); - const handleXprimeToggle = useCallback(async (enabled: boolean) => { - setXprimeEnabled(enabled); - try { - await AsyncStorage.setItem('xprime_settings', JSON.stringify({ enabled })); - } catch (error) { - console.error('Error saving XPRIME settings:', error); - } - }, []); - const handleHdrezkaToggle = useCallback(async (enabled: boolean) => { setHdrezkaEnabled(enabled); try { @@ -257,14 +241,6 @@ const InternalProvidersSettings: React.FC = () => { { backgroundColor: currentTheme.colors.elevation2 }, ]} > - <SettingItem - title="XPRIME" - description="High-quality streams with various resolutions" - icon="star" - value={xprimeEnabled} - onValueChange={handleXprimeToggle} - badge="NEW" - /> <SettingItem title="HDRezka" description="Popular streaming service with multiple quality options" diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 3ae76d7..44f36e5 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -749,11 +749,7 @@ export const StreamsScreen = () => { { id: 'all', name: 'All Providers' }, ...Array.from(allProviders) .sort((a, b) => { - // Always put XPRIME at the top (primary source) - if (a === 'xprime') return -1; - if (b === 'xprime') return 1; - - // Then put HDRezka second + // Put HDRezka first if (a === 'hdrezka') return -1; if (b === 'hdrezka') return 1; @@ -801,7 +797,6 @@ export const StreamsScreen = () => { } // Then try to match standalone quality numbers at the end of the title - // This handles XPRIME format where quality is just "1080", "720", etc. const matchAtEnd = title.match(/\b(\d{3,4})\s*$/); if (matchAtEnd) { const quality = parseInt(matchAtEnd[1], 10); @@ -844,11 +839,7 @@ export const StreamsScreen = () => { return addonId === selectedProvider; }) .sort(([addonIdA], [addonIdB]) => { - // Always put XPRIME at the top (primary source) - if (addonIdA === 'xprime') return -1; - if (addonIdB === 'xprime') return 1; - - // Then put HDRezka second + // Put HDRezka first if (addonIdA === 'hdrezka') return -1; if (addonIdB === 'hdrezka') return 1; @@ -869,13 +860,12 @@ export const StreamsScreen = () => { const qualityB = getQualityNumeric(b.title); return qualityB - qualityA; // Sort descending (e.g., 1080p before 720p) }); - } else if (addonId === 'xprime') { - // Sort XPRIME streams by quality in descending order (highest quality first) - // For XPRIME, quality is in the 'name' field + } else { + // Sort other streams by quality if possible sortedProviderStreams = [...providerStreams].sort((a, b) => { - const qualityA = getQualityNumeric(a.name); - const qualityB = getQualityNumeric(b.name); - return qualityB - qualityA; // Sort descending (e.g., 1080 before 720) + const qualityA = getQualityNumeric(a.name || a.title); + const qualityB = getQualityNumeric(b.name || b.title); + return qualityB - qualityA; // Sort descending }); } return { diff --git a/src/screens/TMDBSettingsScreen.tsx b/src/screens/TMDBSettingsScreen.tsx index 2503999..f655405 100644 --- a/src/screens/TMDBSettingsScreen.tsx +++ b/src/screens/TMDBSettingsScreen.tsx @@ -115,13 +115,12 @@ const TMDBSettingsScreen = () => { const testApiKey = async (key: string): Promise<boolean> => { try { - // Simple API call to test the key + // Simple API call to test the key using the API key parameter method const response = await fetch( - 'https://api.themoviedb.org/3/configuration', + `https://api.themoviedb.org/3/configuration?api_key=${key}`, { method: 'GET', headers: { - 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json', } } @@ -523,7 +522,7 @@ const TMDBSettingsScreen = () => { setApiKey(text); if (testResult) setTestResult(null); }} - placeholder="Paste your TMDb API key (v4 auth)" + placeholder="Paste your TMDb API key (v3)" placeholderTextColor={currentTheme.colors.mediumGray} autoCapitalize="none" autoCorrect={false} @@ -591,7 +590,7 @@ const TMDBSettingsScreen = () => { <View style={styles.infoCard}> <MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} /> <Text style={styles.infoText}> - To get your own TMDb API key (v4 auth token), you need to create a TMDb account and request an API key from their website. + To get your own TMDb API key (v3), you need to create a TMDb account and request an API key from their website. Using your own API key gives you dedicated quota and may improve app performance. </Text> </View> diff --git a/src/services/xprimeService.ts b/src/services/xprimeService.ts index ab33583..704982f 100644 --- a/src/services/xprimeService.ts +++ b/src/services/xprimeService.ts @@ -197,91 +197,9 @@ class XprimeService { } async getStreams(mediaId: string, mediaType: string, season?: number, episode?: number): Promise<Stream[]> { - try { - logger.log(`[XPRIME] Getting streams for ${mediaType} with ID: ${mediaId}`); - - // First check if internal providers are enabled - const settingsJson = await AsyncStorage.getItem('app_settings'); - if (settingsJson) { - const settings = JSON.parse(settingsJson); - if (settings.enableInternalProviders === false) { - logger.log('[XPRIME] Internal providers are disabled in settings, skipping Xprime.tv'); - return []; - } - } - - // Check individual XPRIME provider setting - const xprimeSettingsJson = await AsyncStorage.getItem('xprime_settings'); - if (xprimeSettingsJson) { - const xprimeSettings = JSON.parse(xprimeSettingsJson); - if (xprimeSettings.enabled === false) { - logger.log('[XPRIME] XPRIME provider is disabled in settings, skipping Xprime.tv'); - return []; - } - } - - // Extract the actual title from TMDB if this is an ID - let title = mediaId; - let year: number | undefined = undefined; - - if (mediaId.startsWith('tt') || mediaId.startsWith('tmdb:')) { - let tmdbId: number | null = null; - - // Handle IMDB IDs - if (mediaId.startsWith('tt')) { - logger.log(`[XPRIME] Converting IMDB ID to TMDB ID: ${mediaId}`); - tmdbId = await tmdbService.findTMDBIdByIMDB(mediaId); - } - // Handle TMDB IDs - else if (mediaId.startsWith('tmdb:')) { - tmdbId = parseInt(mediaId.split(':')[1], 10); - } - - if (tmdbId) { - // Fetch metadata from TMDB API - if (mediaType === 'movie') { - logger.log(`[XPRIME] Fetching movie details from TMDB for ID: ${tmdbId}`); - const movieDetails = await tmdbService.getMovieDetails(tmdbId.toString()); - if (movieDetails) { - title = movieDetails.title; - year = movieDetails.release_date ? parseInt(movieDetails.release_date.substring(0, 4), 10) : undefined; - logger.log(`[XPRIME] Using movie title "${title}" (${year}) for search`); - } - } else { - logger.log(`[XPRIME] Fetching TV show details from TMDB for ID: ${tmdbId}`); - const showDetails = await tmdbService.getTVShowDetails(tmdbId); - if (showDetails) { - title = showDetails.name; - year = showDetails.first_air_date ? parseInt(showDetails.first_air_date.substring(0, 4), 10) : undefined; - logger.log(`[XPRIME] Using TV show title "${title}" (${year}) for search`); - } - } - } - } - - if (!title || !year) { - logger.log('[XPRIME] Skipping fetch: title or year is missing.'); - return []; - } - - const rawXprimeStreams = await this.getXprimeStreams(title, year, mediaType, season, episode); - - // Convert to Stream format - const streams: Stream[] = rawXprimeStreams.map(xprimeStream => ({ - name: `XPRIME ${xprimeStream.quality.toUpperCase()}`, - title: xprimeStream.size !== 'Unknown size' ? xprimeStream.size : '', - url: xprimeStream.url, - behaviorHints: { - notWebReady: false - } - })); - - logger.log(`[XPRIME] Found ${streams.length} streams`); - return streams; - } catch (error) { - logger.error(`[XPRIME] Error getting streams:`, error); - return []; - } + // XPRIME service has been removed from internal providers + logger.log('[XPRIME] Service has been removed from internal providers'); + return []; } private async getXprimeStreams(title: string, year: number, type: string, seasonNum?: number, episodeNum?: number): Promise<XprimeStream[]> { -- 2.45.2 From d62874d20d1b5fa3cc3d9b05406e4a538978fe97 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 10:27:02 +0530 Subject: [PATCH 40/88] Add new dependencies and enhance VideoPlayer functionality This update introduces new dependencies including cheerio, cors, express, and puppeteer to support additional features. The VideoPlayer component has been enhanced to improve seeking behavior on Android, with a new AndroidVideoPlayer component for better performance. Additionally, state management for seeking has been refined, ensuring smoother playback and user experience across platforms. --- components/AndroidVideoPlayer.tsx | 119 ++ package.json | 4 + src/components/player/AndroidVideoPlayer.tsx | 1067 ++++++++++++++++++ src/components/player/VideoPlayer.tsx | 332 ++---- 4 files changed, 1302 insertions(+), 220 deletions(-) create mode 100644 components/AndroidVideoPlayer.tsx create mode 100644 src/components/player/AndroidVideoPlayer.tsx diff --git a/components/AndroidVideoPlayer.tsx b/components/AndroidVideoPlayer.tsx new file mode 100644 index 0000000..8865071 --- /dev/null +++ b/components/AndroidVideoPlayer.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Platform } from 'react-native'; +import Video, { VideoRef, SelectedTrack, BufferingStrategyType } from 'react-native-video'; + +interface VideoPlayerProps { + src: string; + paused: boolean; + volume: number; + currentTime: number; + selectedAudioTrack?: SelectedTrack; + selectedTextTrack?: SelectedTrack; + onProgress?: (data: { currentTime: number; playableDuration: number }) => void; + onLoad?: (data: { duration: number }) => void; + onError?: (error: any) => void; + onBuffer?: (data: { isBuffering: boolean }) => void; + onSeek?: (data: { currentTime: number; seekTime: number }) => void; + onEnd?: () => void; +} + +export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({ + src, + paused, + volume, + currentTime, + selectedAudioTrack, + selectedTextTrack, + onProgress, + onLoad, + onError, + onBuffer, + onSeek, + onEnd, +}) => { + const videoRef = useRef<VideoRef>(null); + const [isLoaded, setIsLoaded] = useState(false); + const [isSeeking, setIsSeeking] = useState(false); + const [lastSeekTime, setLastSeekTime] = useState<number>(0); + + // Only render on Android + if (Platform.OS !== 'android') { + return null; + } + + useEffect(() => { + if (isLoaded && !isSeeking && Math.abs(currentTime - lastSeekTime) > 1) { + setIsSeeking(true); + videoRef.current?.seek(currentTime); + setLastSeekTime(currentTime); + } + }, [currentTime, isLoaded, isSeeking, lastSeekTime]); + + const handleLoad = (data: any) => { + setIsLoaded(true); + onLoad?.(data); + }; + + const handleProgress = (data: any) => { + if (!isSeeking) { + onProgress?.(data); + } + }; + + const handleSeek = (data: any) => { + setIsSeeking(false); + onSeek?.(data); + }; + + const handleBuffer = (data: any) => { + onBuffer?.(data); + }; + + const handleError = (error: any) => { + console.error('Video playback error:', error); + onError?.(error); + }; + + const handleEnd = () => { + onEnd?.(); + }; + + return ( + <Video + ref={videoRef} + source={{ uri: src }} + style={{ flex: 1 }} + paused={paused} + volume={volume} + selectedAudioTrack={selectedAudioTrack} + selectedTextTrack={selectedTextTrack} + onLoad={handleLoad} + onProgress={handleProgress} + onSeek={handleSeek} + onBuffer={handleBuffer} + onError={handleError} + onEnd={handleEnd} + resizeMode="contain" + controls={false} + playInBackground={false} + playWhenInactive={false} + progressUpdateInterval={250} + allowsExternalPlayback={false} + bufferingStrategy={BufferingStrategyType.DEFAULT} + ignoreSilentSwitch="ignore" + mixWithOthers="inherit" + rate={1.0} + repeat={false} + reportBandwidth={true} + textTracks={[]} + useTextureView={false} + disableFocus={false} + minLoadRetryCount={3} + automaticallyWaitsToMinimizeStalling={true} + hideShutterView={false} + shutterColor="#000000" + /> + ); +}; + +export default AndroidVideoPlayer; \ No newline at end of file diff --git a/package.json b/package.json index 2fef212..6aae894 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "@types/react-native-video": "^5.0.20", "axios": "^1.10.0", "base64-js": "^1.5.1", + "cheerio": "^1.1.0", + "cors": "^2.8.5", "date-fns": "^4.1.0", "eventemitter3": "^5.0.1", "expo": "~52.0.43", @@ -44,8 +46,10 @@ "expo-status-bar": "~2.0.1", "expo-system-ui": "^4.0.9", "expo-web-browser": "~14.0.2", + "express": "^5.1.0", "lodash": "^4.17.21", "node-fetch": "^2.6.7", + "puppeteer": "^24.10.1", "react": "18.3.1", "react-native": "0.76.9", "react-native-awesome-slider": "^2.9.0", diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx new file mode 100644 index 0000000..b1d89e5 --- /dev/null +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -0,0 +1,1067 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text } from 'react-native'; +import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType } from 'react-native-video'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'; +import RNImmersiveMode from 'react-native-immersive-mode'; +import * as ScreenOrientation from 'expo-screen-orientation'; +import { storageService } from '../../services/storageService'; +import { logger } from '../../utils/logger'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { MaterialIcons } from '@expo/vector-icons'; + +import { + DEFAULT_SUBTITLE_SIZE, + AudioTrack, + TextTrack, + ResizeModeType, + WyzieSubtitle, + SubtitleCue, + RESUME_PREF_KEY, + RESUME_PREF, + SUBTITLE_SIZE_KEY +} from './utils/playerTypes'; +import { safeDebugLog, parseSRT, DEBUG_MODE, formatTime } from './utils/playerUtils'; +import { styles } from './utils/playerStyles'; +import SubtitleModals from './modals/SubtitleModals'; +import AudioTrackModal from './modals/AudioTrackModal'; +import ResumeOverlay from './modals/ResumeOverlay'; +import PlayerControls from './controls/PlayerControls'; +import CustomSubtitles from './subtitles/CustomSubtitles'; +import SourcesModal from './modals/SourcesModal'; + +// Map VLC resize modes to react-native-video resize modes +const getVideoResizeMode = (resizeMode: ResizeModeType) => { + switch (resizeMode) { + case 'contain': return 'contain'; + case 'cover': return 'cover'; + case 'stretch': return 'stretch'; + case 'none': return 'contain'; + default: return 'contain'; + } +}; + +const AndroidVideoPlayer: React.FC = () => { + const navigation = useNavigation(); + const route = useRoute<RouteProp<RootStackParamList, 'Player'>>(); + + const { + uri, + title = 'Episode Name', + season, + episode, + episodeTitle, + quality, + year, + streamProvider, + streamName, + id, + type, + episodeId, + imdbId, + availableStreams: passedAvailableStreams + } = route.params; + + safeDebugLog("Android Component mounted with props", { + uri, title, season, episode, episodeTitle, quality, year, + streamProvider, id, type, episodeId, imdbId + }); + + const screenData = Dimensions.get('screen'); + const [screenDimensions, setScreenDimensions] = useState(screenData); + + const [paused, setPaused] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [showControls, setShowControls] = useState(true); + const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]); + const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null); + const [textTracks, setTextTracks] = useState<TextTrack[]>([]); + const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1); + const [resizeMode, setResizeMode] = useState<ResizeModeType>('stretch'); + const [buffered, setBuffered] = useState(0); + const [seekTime, setSeekTime] = useState<number | null>(null); + const videoRef = useRef<VideoRef>(null); + const [showAudioModal, setShowAudioModal] = useState(false); + const [showSubtitleModal, setShowSubtitleModal] = useState(false); + const [initialPosition, setInitialPosition] = useState<number | null>(null); + const [progressSaveInterval, setProgressSaveInterval] = useState<NodeJS.Timeout | null>(null); + const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false); + const [showResumeOverlay, setShowResumeOverlay] = useState(false); + const [resumePosition, setResumePosition] = useState<number | null>(null); + const [rememberChoice, setRememberChoice] = useState(false); + const [resumePreference, setResumePreference] = useState<string | null>(null); + const fadeAnim = useRef(new Animated.Value(1)).current; + const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); + const openingFadeAnim = useRef(new Animated.Value(0)).current; + const openingScaleAnim = useRef(new Animated.Value(0.8)).current; + const backgroundFadeAnim = useRef(new Animated.Value(1)).current; + const [isBuffering, setIsBuffering] = useState(false); + const [rnVideoAudioTracks, setRnVideoAudioTracks] = useState<Array<{id: number, name: string, language?: string}>>([]); + const [rnVideoTextTracks, setRnVideoTextTracks] = useState<Array<{id: number, name: string, language?: string}>>([]); + const [isPlayerReady, setIsPlayerReady] = useState(false); + const progressAnim = useRef(new Animated.Value(0)).current; + const progressBarRef = useRef<View>(null); + const [isDragging, setIsDragging] = useState(false); + const isSeeking = useRef(false); + const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null); + const pendingSeekValue = useRef<number | null>(null); + const lastSeekTime = useRef<number>(0); + const [isVideoLoaded, setIsVideoLoaded] = useState(false); + const [videoAspectRatio, setVideoAspectRatio] = useState<number | null>(null); + const [is16by9Content, setIs16by9Content] = useState(false); + const [customVideoStyles, setCustomVideoStyles] = useState<any>({}); + const [zoomScale, setZoomScale] = useState(1); + const [zoomTranslateX, setZoomTranslateX] = useState(0); + const [zoomTranslateY, setZoomTranslateY] = useState(0); + const [lastZoomScale, setLastZoomScale] = useState(1); + const [lastTranslateX, setLastTranslateX] = useState(0); + const [lastTranslateY, setLastTranslateY] = useState(0); + const pinchRef = useRef<PinchGestureHandler>(null); + const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]); + const [currentSubtitle, setCurrentSubtitle] = useState<string>(''); + const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE); + const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false); + const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false); + const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]); + const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState<boolean>(false); + const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState<boolean>(false); + const [showSourcesModal, setShowSourcesModal] = useState<boolean>(false); + const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {}); + const [currentStreamUrl, setCurrentStreamUrl] = useState<string>(uri); + const [isChangingSource, setIsChangingSource] = useState<boolean>(false); + const [pendingSeek, setPendingSeek] = useState<{ position: number; shouldPlay: boolean } | null>(null); + const [currentQuality, setCurrentQuality] = useState<string | undefined>(quality); + const [currentStreamProvider, setCurrentStreamProvider] = useState<string | undefined>(streamProvider); + const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(streamName); + const isMounted = useRef(true); + + const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { + return { + position: 'absolute', + top: 0, + left: 0, + width: screenWidth, + height: screenHeight, + }; + }; + + const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => { + const { scale } = event.nativeEvent; + const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1)); + setZoomScale(newScale); + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`); + } + }; + + const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => { + if (event.nativeEvent.state === State.END) { + setLastZoomScale(zoomScale); + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`); + } + } + }; + + const resetZoom = () => { + const targetZoom = is16by9Content ? 1.1 : 1; + setZoomScale(targetZoom); + setLastZoomScale(targetZoom); + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`); + } + }; + + useEffect(() => { + if (videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) { + const styles = calculateVideoStyles( + videoAspectRatio * 1000, + 1000, + screenDimensions.width, + screenDimensions.height + ); + setCustomVideoStyles(styles); + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Screen dimensions changed, recalculated styles:`, styles); + } + } + }, [screenDimensions, videoAspectRatio]); + + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ screen }) => { + setScreenDimensions(screen); + }); + const initializePlayer = () => { + StatusBar.setHidden(true, 'none'); + enableImmersiveMode(); + startOpeningAnimation(); + }; + initializePlayer(); + return () => { + subscription?.remove(); + const unlockOrientation = async () => { + await ScreenOrientation.unlockAsync(); + }; + unlockOrientation(); + disableImmersiveMode(); + }; + }, []); + + const startOpeningAnimation = () => { + // Animation logic here + }; + + const completeOpeningAnimation = () => { + Animated.parallel([ + Animated.timing(openingFadeAnim, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }), + Animated.timing(openingScaleAnim, { + toValue: 1, + duration: 700, + useNativeDriver: true, + }), + Animated.timing(backgroundFadeAnim, { + toValue: 0, + duration: 800, + useNativeDriver: true, + }), + ]).start(() => { + openingScaleAnim.setValue(1); + openingFadeAnim.setValue(1); + setIsOpeningAnimationComplete(true); + setTimeout(() => { + backgroundFadeAnim.setValue(0); + }, 100); + }); + }; + + useEffect(() => { + const loadWatchProgress = async () => { + if (id && type) { + try { + const savedProgress = await storageService.getWatchProgress(id, type, episodeId); + if (savedProgress) { + const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; + if (progressPercent < 95) { + setResumePosition(savedProgress.currentTime); + const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); + if (pref === RESUME_PREF.ALWAYS_RESUME) { + setInitialPosition(savedProgress.currentTime); + } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { + setInitialPosition(0); + } else { + setShowResumeOverlay(true); + } + } + } + } catch (error) { + logger.error('[AndroidVideoPlayer] Error loading watch progress:', error); + } + } + }; + loadWatchProgress(); + }, [id, type, episodeId]); + + const saveWatchProgress = async () => { + if (id && type && currentTime > 0 && duration > 0) { + const progress = { + currentTime, + duration, + lastUpdated: Date.now() + }; + try { + await storageService.setWatchProgress(id, type, progress, episodeId); + } catch (error) { + logger.error('[AndroidVideoPlayer] Error saving watch progress:', error); + } + } + }; + + useEffect(() => { + if (id && type && !paused && duration > 0) { + if (progressSaveInterval) { + clearInterval(progressSaveInterval); + } + const interval = setInterval(() => { + saveWatchProgress(); + }, 5000); + setProgressSaveInterval(interval); + return () => { + clearInterval(interval); + setProgressSaveInterval(null); + }; + } + }, [id, type, paused, currentTime, duration]); + + useEffect(() => { + return () => { + if (id && type && duration > 0) { + saveWatchProgress(); + } + }; + }, [id, type, currentTime, duration]); + + const seekToTime = (timeInSeconds: number) => { + if (videoRef.current && duration > 0 && !isSeeking.current) { + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s`); + } + + isSeeking.current = true; + setSeekTime(timeInSeconds); + + // Clear seek state after seek + setTimeout(() => { + if (isMounted.current) { + setSeekTime(null); + isSeeking.current = false; + } + }, 100); + } else { + if (DEBUG_MODE) { + logger.error('[AndroidVideoPlayer] Seek failed: Player not ready, duration is zero, or already seeking.'); + } + } + }; + + // Handle seeking when seekTime changes + useEffect(() => { + if (seekTime !== null && videoRef.current && duration > 0) { + videoRef.current.seek(seekTime); + } + }, [seekTime, duration]); + + const handleProgressBarTouch = (event: any) => { + if (duration > 0) { + const { locationX } = event.nativeEvent; + processProgressTouch(locationX); + } + }; + + const handleProgressBarDragStart = () => { + setIsDragging(true); + }; + + const handleProgressBarDragMove = (event: any) => { + if (!isDragging || !duration || duration <= 0) return; + const { locationX } = event.nativeEvent; + processProgressTouch(locationX, true); + }; + + const handleProgressBarDragEnd = () => { + setIsDragging(false); + if (pendingSeekValue.current !== null) { + seekToTime(pendingSeekValue.current); + pendingSeekValue.current = null; + } + }; + + const processProgressTouch = (locationX: number, isDragging = false) => { + progressBarRef.current?.measure((x, y, width, height, pageX, pageY) => { + const percentage = Math.max(0, Math.min(locationX / width, 1)); + const seekTime = percentage * duration; + progressAnim.setValue(percentage); + if (isDragging) { + pendingSeekValue.current = seekTime; + setCurrentTime(seekTime); + } else { + seekToTime(seekTime); + } + }); + }; + + const handleProgress = (data: any) => { + if (isDragging || isSeeking.current) return; + + const currentTimeInSeconds = data.currentTime; + + // Only update if there's a significant change to avoid unnecessary updates + if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { + safeSetState(() => setCurrentTime(currentTimeInSeconds)); + const progressPercent = duration > 0 ? currentTimeInSeconds / duration : 0; + Animated.timing(progressAnim, { + toValue: progressPercent, + duration: 250, + useNativeDriver: false, + }).start(); + const bufferedTime = data.playableDuration || currentTimeInSeconds; + safeSetState(() => setBuffered(bufferedTime)); + } + }; + + const onLoad = (data: any) => { + if (DEBUG_MODE) { + logger.log('[AndroidVideoPlayer] Video loaded:', data); + } + if (isMounted.current) { + if (data.duration > 0) { + setDuration(data.duration); + } + + // Set aspect ratio from video dimensions + if (data.naturalSize && data.naturalSize.width && data.naturalSize.height) { + setVideoAspectRatio(data.naturalSize.width / data.naturalSize.height); + } + + // Handle audio tracks + if (data.audioTracks && data.audioTracks.length > 0) { + const formattedAudioTracks = data.audioTracks.map((track: any, index: number) => ({ + id: track.index || index, + name: track.title || track.language || `Audio ${index + 1}`, + language: track.language, + })); + setRnVideoAudioTracks(formattedAudioTracks); + } + + // Handle text tracks + if (data.textTracks && data.textTracks.length > 0) { + const formattedTextTracks = data.textTracks.map((track: any, index: number) => ({ + id: track.index || index, + name: track.title || track.language || `Subtitle ${index + 1}`, + language: track.language, + })); + setRnVideoTextTracks(formattedTextTracks); + } + + setIsVideoLoaded(true); + setIsPlayerReady(true); + + if (initialPosition && !isInitialSeekComplete) { + setTimeout(() => { + if (videoRef.current && duration > 0 && isMounted.current) { + seekToTime(initialPosition); + setIsInitialSeekComplete(true); + } + }, 1000); + } + completeOpeningAnimation(); + } + }; + + const skip = (seconds: number) => { + if (videoRef.current) { + const newTime = Math.max(0, Math.min(currentTime + seconds, duration)); + seekToTime(newTime); + } + }; + + const cycleAspectRatio = () => { + const newZoom = zoomScale === 1.1 ? 1 : 1.1; + setZoomScale(newZoom); + setZoomTranslateX(0); + setZoomTranslateY(0); + setLastZoomScale(newZoom); + setLastTranslateX(0); + setLastTranslateY(0); + }; + + const enableImmersiveMode = () => { + StatusBar.setHidden(true, 'none'); + if (Platform.OS === 'android') { + try { + RNImmersiveMode.setBarMode('FullSticky'); + RNImmersiveMode.fullLayout(true); + if (NativeModules.StatusBarManager) { + NativeModules.StatusBarManager.setHidden(true); + } + } catch (error) { + console.log('Immersive mode error:', error); + } + } + }; + + const disableImmersiveMode = () => { + StatusBar.setHidden(false); + if (Platform.OS === 'android') { + RNImmersiveMode.setBarMode('Normal'); + RNImmersiveMode.fullLayout(false); + } + }; + + const handleClose = () => { + // Start exit animation + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + Animated.timing(openingFadeAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + ]).start(); + + // Small delay to allow animation to start, then unlock orientation and navigate + setTimeout(() => { + ScreenOrientation.unlockAsync().then(() => { + disableImmersiveMode(); + navigation.goBack(); + }).catch(() => { + // Fallback: navigate even if orientation unlock fails + disableImmersiveMode(); + navigation.goBack(); + }); + }, 100); + }; + + useEffect(() => { + const loadResumePreference = async () => { + try { + const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); + if (pref) { + setResumePreference(pref); + if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) { + setShowResumeOverlay(false); + setInitialPosition(resumePosition); + } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { + setShowResumeOverlay(false); + setInitialPosition(0); + } + } + } catch (error) { + logger.error('[AndroidVideoPlayer] Error loading resume preference:', error); + } + }; + loadResumePreference(); + }, [resumePosition]); + + const resetResumePreference = async () => { + try { + await AsyncStorage.removeItem(RESUME_PREF_KEY); + setResumePreference(null); + } catch (error) { + logger.error('[AndroidVideoPlayer] Error resetting resume preference:', error); + } + }; + + const handleResume = async () => { + if (resumePosition !== null && videoRef.current) { + if (rememberChoice) { + try { + await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME); + } catch (error) { + logger.error('[AndroidVideoPlayer] Error saving resume preference:', error); + } + } + setInitialPosition(resumePosition); + setShowResumeOverlay(false); + setTimeout(() => { + if (videoRef.current) { + seekToTime(resumePosition); + } + }, 500); + } + }; + + const handleStartFromBeginning = async () => { + if (rememberChoice) { + try { + await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_START_OVER); + } catch (error) { + logger.error('[AndroidVideoPlayer] Error saving resume preference:', error); + } + } + setShowResumeOverlay(false); + setInitialPosition(0); + if (videoRef.current) { + seekToTime(0); + setCurrentTime(0); + } + }; + + const toggleControls = () => { + setShowControls(previousState => !previousState); + }; + + useEffect(() => { + Animated.timing(fadeAnim, { + toValue: showControls ? 1 : 0, + duration: 300, + useNativeDriver: true, + }).start(); + }, [showControls]); + + const handleError = (error: any) => { + logger.error('[AndroidVideoPlayer] Playback Error:', error); + }; + + const onBuffer = (data: any) => { + setIsBuffering(data.isBuffering); + }; + + const onEnd = () => { + // End logic here + }; + + const selectAudioTrack = (trackId: number) => { + setSelectedAudioTrack(trackId); + }; + + const selectTextTrack = (trackId: number) => { + if (trackId === -999) { + setUseCustomSubtitles(true); + setSelectedTextTrack(-1); + } else { + setUseCustomSubtitles(false); + setSelectedTextTrack(trackId); + } + }; + + const loadSubtitleSize = async () => { + try { + const savedSize = await AsyncStorage.getItem(SUBTITLE_SIZE_KEY); + if (savedSize) { + setSubtitleSize(parseInt(savedSize, 10)); + } + } catch (error) { + logger.error('[AndroidVideoPlayer] Error loading subtitle size:', error); + } + }; + + const saveSubtitleSize = async (size: number) => { + try { + await AsyncStorage.setItem(SUBTITLE_SIZE_KEY, size.toString()); + setSubtitleSize(size); + } catch (error) { + logger.error('[AndroidVideoPlayer] Error saving subtitle size:', error); + } + }; + + const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => { + const targetImdbId = imdbIdParam || imdbId; + if (!targetImdbId) { + logger.error('[AndroidVideoPlayer] No IMDb ID available for subtitle search'); + return; + } + setIsLoadingSubtitleList(true); + try { + let searchUrl = `https://sub.wyzie.ru/search?id=${targetImdbId}&encoding=utf-8&source=all`; + if (season && episode) { + searchUrl += `&season=${season}&episode=${episode}`; + } + const response = await fetch(searchUrl); + const subtitles: WyzieSubtitle[] = await response.json(); + const uniqueSubtitles = subtitles.reduce((acc, current) => { + const exists = acc.find(item => item.language === current.language); + if (!exists) { + acc.push(current); + } + return acc; + }, [] as WyzieSubtitle[]); + uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display)); + setAvailableSubtitles(uniqueSubtitles); + if (autoSelectEnglish) { + const englishSubtitle = uniqueSubtitles.find(sub => + sub.language.toLowerCase() === 'eng' || + sub.language.toLowerCase() === 'en' || + sub.display.toLowerCase().includes('english') + ); + if (englishSubtitle) { + loadWyzieSubtitle(englishSubtitle); + return; + } + } + if (!autoSelectEnglish) { + setShowSubtitleLanguageModal(true); + } + } catch (error) { + logger.error('[AndroidVideoPlayer] Error fetching subtitles from Wyzie API:', error); + } finally { + setIsLoadingSubtitleList(false); + } + }; + + const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => { + setShowSubtitleLanguageModal(false); + setIsLoadingSubtitles(true); + try { + const response = await fetch(subtitle.url); + const srtContent = await response.text(); + const parsedCues = parseSRT(srtContent); + setCustomSubtitles(parsedCues); + setUseCustomSubtitles(true); + setSelectedTextTrack(-1); + } catch (error) { + logger.error('[AndroidVideoPlayer] Error loading Wyzie subtitle:', error); + } finally { + setIsLoadingSubtitles(false); + } + }; + + const togglePlayback = () => { + if (videoRef.current) { + setPaused(!paused); + } + }; + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + if (seekDebounceTimer.current) { + clearTimeout(seekDebounceTimer.current); + } + }; + }, []); + + const safeSetState = (setter: any) => { + if (isMounted.current) { + setter(); + } + }; + + useEffect(() => { + if (!useCustomSubtitles || customSubtitles.length === 0) { + if (currentSubtitle !== '') { + setCurrentSubtitle(''); + } + return; + } + const currentCue = customSubtitles.find(cue => + currentTime >= cue.start && currentTime <= cue.end + ); + const newSubtitle = currentCue ? currentCue.text : ''; + setCurrentSubtitle(newSubtitle); + }, [currentTime, customSubtitles, useCustomSubtitles]); + + useEffect(() => { + loadSubtitleSize(); + }, []); + + const increaseSubtitleSize = () => { + const newSize = Math.min(subtitleSize + 2, 32); + saveSubtitleSize(newSize); + }; + + const decreaseSubtitleSize = () => { + const newSize = Math.max(subtitleSize - 2, 8); + saveSubtitleSize(newSize); + }; + + useEffect(() => { + if (pendingSeek && isPlayerReady && isVideoLoaded && duration > 0) { + logger.log(`[AndroidVideoPlayer] Player ready after source change, seeking to position: ${pendingSeek.position}s out of ${duration}s total`); + + if (pendingSeek.position > 0 && videoRef.current) { + const delayTime = 800; // Shorter delay for react-native-video + + setTimeout(() => { + if (videoRef.current && duration > 0 && pendingSeek) { + logger.log(`[AndroidVideoPlayer] Executing seek to ${pendingSeek.position}s`); + + seekToTime(pendingSeek.position); + + if (pendingSeek.shouldPlay) { + setTimeout(() => { + logger.log('[AndroidVideoPlayer] Resuming playback after source change seek'); + setPaused(false); + }, 300); + } + + setTimeout(() => { + setPendingSeek(null); + setIsChangingSource(false); + }, 400); + } + }, delayTime); + } else { + // No seeking needed, just resume playback if it was playing + if (pendingSeek.shouldPlay) { + setTimeout(() => { + logger.log('[AndroidVideoPlayer] No seek needed, just resuming playback'); + setPaused(false); + }, 300); + } + + setTimeout(() => { + setPendingSeek(null); + setIsChangingSource(false); + }, 400); + } + } + }, [pendingSeek, isPlayerReady, isVideoLoaded, duration]); + + const handleSelectStream = async (newStream: any) => { + if (newStream.url === currentStreamUrl) { + setShowSourcesModal(false); + return; + } + + setIsChangingSource(true); + setShowSourcesModal(false); + + try { + // Save current state + const savedPosition = currentTime; + const wasPlaying = !paused; + + logger.log(`[AndroidVideoPlayer] Changing source from ${currentStreamUrl} to ${newStream.url}`); + logger.log(`[AndroidVideoPlayer] Saved position: ${savedPosition}, was playing: ${wasPlaying}`); + + // Extract quality and provider information from the new stream + let newQuality = newStream.quality; + if (!newQuality && newStream.title) { + // Try to extract quality from title (e.g., "1080p", "720p") + const qualityMatch = newStream.title.match(/(\d+)p/); + newQuality = qualityMatch ? qualityMatch[0] : undefined; + } + + // For provider, try multiple fields + const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown'; + + // For stream name, prioritize the stream name over title + const newStreamName = newStream.name || newStream.title || 'Unknown Stream'; + + logger.log(`[AndroidVideoPlayer] Stream object:`, newStream); + logger.log(`[AndroidVideoPlayer] Extracted - Quality: ${newQuality}, Provider: ${newProvider}, Stream Name: ${newStreamName}`); + + // Stop current playback + setPaused(true); + + // Set pending seek state + setPendingSeek({ position: savedPosition, shouldPlay: wasPlaying }); + + // Update the stream URL and details immediately + setCurrentStreamUrl(newStream.url); + setCurrentQuality(newQuality); + setCurrentStreamProvider(newProvider); + setCurrentStreamName(newStreamName); + + // Reset player state for new source + setCurrentTime(0); + setDuration(0); + setIsPlayerReady(false); + setIsVideoLoaded(false); + + } catch (error) { + logger.error('[AndroidVideoPlayer] Error changing source:', error); + setPendingSeek(null); + setIsChangingSource(false); + } + }; + + return ( + <View style={[styles.container, { + width: screenDimensions.width, + height: screenDimensions.height, + position: 'absolute', + top: 0, + left: 0, + }]}> + <Animated.View + style={[ + styles.openingOverlay, + { + opacity: backgroundFadeAnim, + zIndex: isOpeningAnimationComplete ? -1 : 3000, + width: screenDimensions.width, + height: screenDimensions.height, + } + ]} + pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'} + > + <TouchableOpacity + style={styles.loadingCloseButton} + onPress={handleClose} + activeOpacity={0.7} + > + <MaterialIcons name="close" size={24} color="#ffffff" /> + </TouchableOpacity> + + <View style={styles.openingContent}> + <ActivityIndicator size="large" color="#E50914" /> + <Text style={styles.openingText}>Loading video...</Text> + </View> + </Animated.View> + + {/* Source Change Loading Overlay */} + {isChangingSource && ( + <Animated.View + style={[ + styles.sourceChangeOverlay, + { + width: screenDimensions.width, + height: screenDimensions.height, + opacity: fadeAnim, + } + ]} + pointerEvents="auto" + > + <View style={styles.sourceChangeContent}> + <ActivityIndicator size="large" color="#E50914" /> + <Text style={styles.sourceChangeText}>Changing source...</Text> + <Text style={styles.sourceChangeSubtext}>Please wait while we load the new stream</Text> + </View> + </Animated.View> + )} + + <Animated.View + style={[ + styles.videoPlayerContainer, + { + opacity: openingFadeAnim, + transform: isOpeningAnimationComplete ? [] : [{ scale: openingScaleAnim }], + width: screenDimensions.width, + height: screenDimensions.height, + } + ]} + > + <TouchableOpacity + style={[styles.videoContainer, { + width: screenDimensions.width, + height: screenDimensions.height, + }]} + onPress={toggleControls} + activeOpacity={1} + > + <PinchGestureHandler + ref={pinchRef} + onGestureEvent={onPinchGestureEvent} + onHandlerStateChange={onPinchHandlerStateChange} + > + <View style={{ + position: 'absolute', + top: 0, + left: 0, + width: screenDimensions.width, + height: screenDimensions.height, + }}> + <TouchableOpacity + style={{ flex: 1 }} + activeOpacity={1} + onPress={toggleControls} + onLongPress={resetZoom} + delayLongPress={300} + > + <Video + ref={videoRef} + style={[styles.video, customVideoStyles, { transform: [{ scale: zoomScale }] }]} + source={{ uri: currentStreamUrl }} + paused={paused} + onProgress={handleProgress} + onLoad={onLoad} + onEnd={onEnd} + onError={handleError} + onBuffer={onBuffer} + resizeMode={getVideoResizeMode(resizeMode)} + selectedAudioTrack={selectedAudioTrack !== null ? { type: SelectedTrackType.INDEX, value: selectedAudioTrack } : undefined} + selectedTextTrack={useCustomSubtitles ? { type: SelectedTrackType.DISABLED } : (selectedTextTrack >= 0 ? { type: SelectedTrackType.INDEX, value: selectedTextTrack } : undefined)} + rate={1.0} + volume={1.0} + muted={false} + repeat={false} + playInBackground={false} + playWhenInactive={false} + ignoreSilentSwitch="ignore" + mixWithOthers="inherit" + progressUpdateInterval={1000} + /> + </TouchableOpacity> + </View> + </PinchGestureHandler> + + <PlayerControls + showControls={showControls} + fadeAnim={fadeAnim} + paused={paused} + title={title} + episodeTitle={episodeTitle} + season={season} + episode={episode} + quality={currentQuality || quality} + year={year} + streamProvider={currentStreamProvider || streamProvider} + streamName={currentStreamName} + currentTime={currentTime} + duration={duration} + zoomScale={zoomScale} + vlcAudioTracks={rnVideoAudioTracks} + selectedAudioTrack={selectedAudioTrack} + availableStreams={availableStreams} + togglePlayback={togglePlayback} + skip={skip} + handleClose={handleClose} + cycleAspectRatio={cycleAspectRatio} + setShowAudioModal={setShowAudioModal} + setShowSubtitleModal={setShowSubtitleModal} + setShowSourcesModal={setShowSourcesModal} + progressBarRef={progressBarRef} + progressAnim={progressAnim} + handleProgressBarTouch={handleProgressBarTouch} + handleProgressBarDragStart={handleProgressBarDragStart} + handleProgressBarDragMove={handleProgressBarDragMove} + handleProgressBarDragEnd={handleProgressBarDragEnd} + buffered={buffered} + formatTime={formatTime} + /> + + <CustomSubtitles + useCustomSubtitles={useCustomSubtitles} + currentSubtitle={currentSubtitle} + subtitleSize={subtitleSize} + /> + + <ResumeOverlay + showResumeOverlay={showResumeOverlay} + resumePosition={resumePosition} + duration={duration} + title={title} + season={season} + episode={episode} + rememberChoice={rememberChoice} + setRememberChoice={setRememberChoice} + resumePreference={resumePreference} + resetResumePreference={resetResumePreference} + handleResume={handleResume} + handleStartFromBeginning={handleStartFromBeginning} + /> + </TouchableOpacity> + </Animated.View> + + <AudioTrackModal + showAudioModal={showAudioModal} + setShowAudioModal={setShowAudioModal} + vlcAudioTracks={rnVideoAudioTracks} + selectedAudioTrack={selectedAudioTrack} + selectAudioTrack={selectAudioTrack} + /> + <SubtitleModals + showSubtitleModal={showSubtitleModal} + setShowSubtitleModal={setShowSubtitleModal} + showSubtitleLanguageModal={showSubtitleLanguageModal} + setShowSubtitleLanguageModal={setShowSubtitleLanguageModal} + isLoadingSubtitleList={isLoadingSubtitleList} + isLoadingSubtitles={isLoadingSubtitles} + customSubtitles={customSubtitles} + availableSubtitles={availableSubtitles} + vlcTextTracks={rnVideoTextTracks} + selectedTextTrack={selectedTextTrack} + useCustomSubtitles={useCustomSubtitles} + subtitleSize={subtitleSize} + fetchAvailableSubtitles={fetchAvailableSubtitles} + loadWyzieSubtitle={loadWyzieSubtitle} + selectTextTrack={selectTextTrack} + increaseSubtitleSize={increaseSubtitleSize} + decreaseSubtitleSize={decreaseSubtitleSize} + /> + + <SourcesModal + showSourcesModal={showSourcesModal} + setShowSourcesModal={setShowSourcesModal} + availableStreams={availableStreams} + currentStreamUrl={currentStreamUrl} + onSelectStream={handleSelectStream} + isChangingSource={isChangingSource} + /> + </View> + ); +}; + +export default AndroidVideoPlayer; \ No newline at end of file diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index 2655caa..f4d29eb 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -10,6 +10,7 @@ import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; +import AndroidVideoPlayer from './AndroidVideoPlayer'; import { DEFAULT_SUBTITLE_SIZE, @@ -32,6 +33,11 @@ import CustomSubtitles from './subtitles/CustomSubtitles'; import SourcesModal from './modals/SourcesModal'; const VideoPlayer: React.FC = () => { + // If on Android, use the AndroidVideoPlayer component + if (Platform.OS === 'android') { + return <AndroidVideoPlayer />; + } + const navigation = useNavigation(); const route = useRoute<RouteProp<RootStackParamList, 'Player'>>(); @@ -70,6 +76,7 @@ const VideoPlayer: React.FC = () => { const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1); const [resizeMode, setResizeMode] = useState<ResizeModeType>('stretch'); const [buffered, setBuffered] = useState(0); + const [seekPosition, setSeekPosition] = useState<number | null>(null); const vlcRef = useRef<any>(null); const [showAudioModal, setShowAudioModal] = useState(false); const [showSubtitleModal, setShowSubtitleModal] = useState(false); @@ -92,6 +99,7 @@ const VideoPlayer: React.FC = () => { const progressAnim = useRef(new Animated.Value(0)).current; const progressBarRef = useRef<View>(null); const [isDragging, setIsDragging] = useState(false); + const isSeeking = useRef(false); const seekDebounceTimer = useRef<NodeJS.Timeout | null>(null); const pendingSeekValue = useRef<number | null>(null); const lastSeekTime = useRef<number>(0); @@ -131,7 +139,6 @@ const VideoPlayer: React.FC = () => { left: 0, width: screenWidth, height: screenHeight, - backgroundColor: '#000', }; }; @@ -294,66 +301,62 @@ const VideoPlayer: React.FC = () => { }; }, [id, type, currentTime, duration]); + const onPlaying = () => { + if (isMounted.current && !isSeeking.current) { + setPaused(false); + } + }; + + const onPaused = () => { + if (isMounted.current) { + setPaused(true); + } + }; + const seekToTime = (timeInSeconds: number) => { - if (!isPlayerReady || duration <= 0 || !vlcRef.current) return; - const normalizedPosition = Math.max(0, Math.min(timeInSeconds / duration, 1)); - - try { - if (Platform.OS === 'android') { - // On Android, we need to handle seeking differently to prevent black screens - setIsBuffering(true); - - // Set a small timeout to prevent overwhelming the player - const now = Date.now(); - if (now - lastSeekTime.current < 300) { - // Throttle seeks that are too close together - if (seekDebounceTimer.current) { - clearTimeout(seekDebounceTimer.current); - } - - seekDebounceTimer.current = setTimeout(() => { - if (vlcRef.current) { - // Set position instead of using seek on Android - vlcRef.current.setPosition(normalizedPosition); - lastSeekTime.current = Date.now(); - - // Give the player some time to recover - setTimeout(() => { - setIsBuffering(false); - }, 500); - } - }, 300); - return; - } - - // Directly set position - vlcRef.current.setPosition(normalizedPosition); - lastSeekTime.current = now; - - // Reset buffering state after a delay - setTimeout(() => { - setIsBuffering(false); - }, 500); - } else { - // For iOS, keep the original behavior - if (typeof vlcRef.current.setPosition === 'function') { - vlcRef.current.setPosition(normalizedPosition); - } else if (typeof vlcRef.current.seek === 'function') { - vlcRef.current.seek(normalizedPosition); - } else { - logger.error('[VideoPlayer] No seek method available on VLC player'); - } + if (vlcRef.current && duration > 0 && !isSeeking.current) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s`); + } + + isSeeking.current = true; + + // For Android, use direct seeking on VLC player ref instead of seek prop + if (Platform.OS === 'android' && vlcRef.current.seek) { + // Calculate position as fraction + const position = timeInSeconds / duration; + vlcRef.current.seek(position); + + // Clear seek state after Android seek + setTimeout(() => { + if (isMounted.current) { + isSeeking.current = false; + } + }, 300); + } else { + // iOS fallback - use seek prop + const position = timeInSeconds / duration; + setSeekPosition(position); + + setTimeout(() => { + if (isMounted.current) { + setSeekPosition(null); + isSeeking.current = false; + } + }, 500); + } + } else { + if (DEBUG_MODE) { + logger.error('[VideoPlayer] Seek failed: Player not ready, duration is zero, or already seeking.'); } - } catch (error) { - logger.error('[VideoPlayer] Error during seek operation:', error); - setIsBuffering(false); } }; const handleProgressBarTouch = (event: any) => { - if (!duration || duration <= 0) return; - const { locationX } = event.nativeEvent; - processProgressTouch(locationX); + if (duration > 0) { + const { locationX } = event.nativeEvent; + processProgressTouch(locationX); + } }; const handleProgressBarDragStart = () => { @@ -369,18 +372,8 @@ const VideoPlayer: React.FC = () => { const handleProgressBarDragEnd = () => { setIsDragging(false); if (pendingSeekValue.current !== null) { - // For Android, add a small delay to ensure UI updates before the seek happens - if (Platform.OS === 'android') { - setTimeout(() => { - if (pendingSeekValue.current !== null) { - seekToTime(pendingSeekValue.current); - pendingSeekValue.current = null; - } - }, 150); - } else { - seekToTime(pendingSeekValue.current); - pendingSeekValue.current = null; - } + seekToTime(pendingSeekValue.current); + pendingSeekValue.current = null; } }; @@ -399,8 +392,11 @@ const VideoPlayer: React.FC = () => { }; const handleProgress = (event: any) => { - if (isDragging) return; + if (isDragging || isSeeking.current) return; + const currentTimeInSeconds = event.currentTime / 1000; + + // Only update if there's a significant change to avoid unnecessary updates if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { safeSetState(() => setCurrentTime(currentTimeInSeconds)); const progressPercent = duration > 0 ? currentTimeInSeconds / duration : 0; @@ -415,67 +411,34 @@ const VideoPlayer: React.FC = () => { }; const onLoad = (data: any) => { - setDuration(data.duration / 1000); - if (data.videoSize && data.videoSize.width && data.videoSize.height) { - const aspectRatio = data.videoSize.width / data.videoSize.height; - setVideoAspectRatio(aspectRatio); - const is16x9 = Math.abs(aspectRatio - (16/9)) < 0.1; - setIs16by9Content(is16x9); - if (is16x9) { - setZoomScale(1.1); - setLastZoomScale(1.1); - } else { - setZoomScale(1); - setLastZoomScale(1); + if (DEBUG_MODE) { + logger.log('[VideoPlayer] Video loaded:', data); + } + if (isMounted.current) { + if (data.duration > 0) { + setDuration(data.duration / 1000); } - const styles = calculateVideoStyles( - data.videoSize.width, - data.videoSize.height, - screenDimensions.width, - screenDimensions.height - ); - setCustomVideoStyles(styles); - } else { - setIs16by9Content(true); - setZoomScale(1.1); - setLastZoomScale(1.1); - const defaultStyles = { - position: 'absolute', - top: 0, - left: 0, - width: screenDimensions.width, - height: screenDimensions.height, - }; - setCustomVideoStyles(defaultStyles); + setVideoAspectRatio(data.videoSize.width / data.videoSize.height); + + if (data.audioTracks && data.audioTracks.length > 0) { + setVlcAudioTracks(data.audioTracks); + } + if (data.textTracks && data.textTracks.length > 0) { + setVlcTextTracks(data.textTracks); + } + + setIsVideoLoaded(true); + setIsPlayerReady(true); + if (initialPosition && !isInitialSeekComplete) { + setTimeout(() => { + if (vlcRef.current && duration > 0 && isMounted.current) { + seekToTime(initialPosition); + setIsInitialSeekComplete(true); + } + }, 1000); + } + completeOpeningAnimation(); } - setIsPlayerReady(true); - const audioTracksFromLoad = data.audioTracks || []; - const textTracksFromLoad = data.textTracks || []; - setVlcAudioTracks(audioTracksFromLoad); - setVlcTextTracks(textTracksFromLoad); - if (audioTracksFromLoad.length > 1) { - const firstEnabledAudio = audioTracksFromLoad.find((t: any) => t.id !== -1); - if(firstEnabledAudio) { - setSelectedAudioTrack(firstEnabledAudio.id); - } - } else if (audioTracksFromLoad.length > 0) { - setSelectedAudioTrack(audioTracksFromLoad[0].id); - } - if (imdbId && !customSubtitles.length) { - setTimeout(() => { - fetchAvailableSubtitles(imdbId, true); - }, 2000); - } - if (initialPosition !== null && !isInitialSeekComplete) { - setTimeout(() => { - if (vlcRef.current && duration > 0 && isMounted.current) { - seekToTime(initialPosition); - setIsInitialSeekComplete(true); - } - }, 1000); - } - setIsVideoLoaded(true); - completeOpeningAnimation(); }; const skip = (seconds: number) => { @@ -793,61 +756,25 @@ const VideoPlayer: React.FC = () => { logger.log(`[VideoPlayer] Player ready after source change, seeking to position: ${pendingSeek.position}s out of ${duration}s total`); if (pendingSeek.position > 0 && vlcRef.current) { - // Longer delay for Android to ensure player is stable - const delayTime = Platform.OS === 'android' ? 2500 : 1500; + const delayTime = Platform.OS === 'android' ? 1500 : 1000; setTimeout(() => { if (vlcRef.current && duration > 0 && pendingSeek) { logger.log(`[VideoPlayer] Executing seek to ${pendingSeek.position}s`); - if (Platform.OS === 'android') { - // On Android, wait longer and set isBuffering to improve visual feedback - setIsBuffering(true); - - // For Android, use setPosition directly with normalized value - const normalizedPosition = Math.max(0, Math.min(pendingSeek.position / duration, 1)); - vlcRef.current.setPosition(normalizedPosition); - - // Update the current time - setCurrentTime(pendingSeek.position); - - // Give the player time to recover from the seek + seekToTime(pendingSeek.position); + + if (pendingSeek.shouldPlay) { setTimeout(() => { - setIsBuffering(false); - - // Resume playback after a delay if needed - if (pendingSeek.shouldPlay) { - setPaused(false); - } - - // Clean up - setPendingSeek(null); - setIsChangingSource(false); - }, 800); - } else { - // iOS - use the normal seekToTime function - seekToTime(pendingSeek.position); - - // Also update the current time state - setCurrentTime(pendingSeek.position); - - // Resume playback if needed - if (pendingSeek.shouldPlay) { - setTimeout(() => { - logger.log('[VideoPlayer] Resuming playback after seek'); - setPaused(false); - if (vlcRef.current && typeof vlcRef.current.play === 'function') { - vlcRef.current.play(); - } - }, 700); - } - - // Clean up - setTimeout(() => { - setPendingSeek(null); - setIsChangingSource(false); - }, 800); + logger.log('[VideoPlayer] Resuming playback after source change seek'); + setPaused(false); + }, 850); // Delay should be slightly more than seekToTime's internal timeout } + + setTimeout(() => { + setPendingSeek(null); + setIsChangingSource(false); + }, 900); } }, delayTime); } else { @@ -856,9 +783,6 @@ const VideoPlayer: React.FC = () => { setTimeout(() => { logger.log('[VideoPlayer] No seek needed, just resuming playback'); setPaused(false); - if (vlcRef.current && typeof vlcRef.current.play === 'function') { - vlcRef.current.play(); - } }, 500); } @@ -1018,7 +942,6 @@ const VideoPlayer: React.FC = () => { left: 0, width: screenDimensions.width, height: screenDimensions.height, - backgroundColor: '#000', }}> <TouchableOpacity style={{ flex: 1 }} @@ -1029,52 +952,21 @@ const VideoPlayer: React.FC = () => { > <VLCPlayer ref={vlcRef} - style={{ - position: 'absolute', - top: 0, - left: 0, - width: screenDimensions.width, - height: screenDimensions.height, - transform: [ - { scale: zoomScale }, - ], - }} - source={{ - uri: currentStreamUrl, - initOptions: Platform.OS === 'android' ? [ - '--rtsp-tcp', - '--network-caching=1500', - '--rtsp-caching=1500', - '--no-audio-time-stretch', - '--clock-jitter=0', - '--clock-synchro=0', - '--drop-late-frames', - '--skip-frames', - '--aout=opensles', - '--file-caching=1500', - '--sout-mux-caching=1500', - ] : [ - '--rtsp-tcp', - '--network-caching=150', - '--rtsp-caching=150', - '--no-audio-time-stretch', - '--clock-jitter=0', - '--clock-synchro=0', - '--drop-late-frames', - '--skip-frames', - ], - }} + style={[styles.video, customVideoStyles, { transform: [{ scale: zoomScale }] }]} + source={{ uri: currentStreamUrl }} paused={paused} - autoplay={true} - autoAspectRatio={false} - resizeMode={'stretch' as any} - audioTrack={selectedAudioTrack || undefined} - textTrack={selectedTextTrack === -1 ? undefined : selectedTextTrack} - onLoad={onLoad} onProgress={handleProgress} + onLoad={onLoad} onEnd={onEnd} onError={handleError} onBuffering={onBuffering} + onPlaying={onPlaying} + onPaused={onPaused} + resizeMode={resizeMode as any} + audioTrack={selectedAudioTrack ?? undefined} + textTrack={useCustomSubtitles ? -1 : (selectedTextTrack ?? undefined)} + seek={Platform.OS === 'ios' ? (seekPosition ?? undefined) : undefined} + autoAspectRatio /> </TouchableOpacity> </View> -- 2.45.2 From 81897b7242474562704e69e3b3c4552707473191 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 10:47:27 +0530 Subject: [PATCH 41/88] Refactor loading and image handling in Metadata components for smoother transitions This update removes the fade animation from the MetadataLoadingScreen, allowing the parent component to manage transitions. In the HeroSection, enhancements have been made to the image loading state, introducing shimmer animations for a better user experience during loading. The MetadataScreen now features a skeleton loading screen with a fade-out transition, improving the overall content loading experience. Additionally, state management for image loading has been optimized to prevent unnecessary re-renders. --- .../loading/MetadataLoadingScreen.tsx | 17 +- src/components/metadata/HeroSection.tsx | 56 ++++- src/hooks/useMetadataAssets.ts | 36 ++- src/screens/MetadataScreen.tsx | 229 ++++++++++-------- 4 files changed, 212 insertions(+), 126 deletions(-) diff --git a/src/components/loading/MetadataLoadingScreen.tsx b/src/components/loading/MetadataLoadingScreen.tsx index 39388bb..c0a8ebf 100644 --- a/src/components/loading/MetadataLoadingScreen.tsx +++ b/src/components/loading/MetadataLoadingScreen.tsx @@ -22,19 +22,11 @@ export const MetadataLoadingScreen: React.FC<MetadataLoadingScreenProps> = ({ }) => { const { currentTheme } = useTheme(); - // Animation values - const fadeAnim = useRef(new Animated.Value(0)).current; + // Animation values - removed fadeAnim since parent handles transitions const pulseAnim = useRef(new Animated.Value(0.3)).current; const shimmerAnim = useRef(new Animated.Value(0)).current; useEffect(() => { - // Start entrance animation - Animated.timing(fadeAnim, { - toValue: 1, - duration: 800, - useNativeDriver: true, - }).start(); - // Continuous pulse animation for skeleton elements const pulseAnimation = Animated.loop( Animated.sequence([ @@ -138,10 +130,7 @@ export const MetadataLoadingScreen: React.FC<MetadataLoadingScreenProps> = ({ barStyle="light-content" /> - <Animated.View style={[ - styles.content, - { opacity: fadeAnim } - ]}> + <View style={styles.content}> {/* Hero Skeleton */} <View style={styles.heroSection}> <SkeletonElement @@ -230,7 +219,7 @@ export const MetadataLoadingScreen: React.FC<MetadataLoadingScreenProps> = ({ </View> )} </View> - </Animated.View> + </View> </SafeAreaView> ); }; diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 9c17a75..2575618 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -16,6 +16,7 @@ import Animated, { useSharedValue, withTiming, runOnJS, + withRepeat, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; import { logger } from '../../utils/logger'; @@ -246,19 +247,47 @@ const HeroSection: React.FC<HeroSectionProps> = ({ }) => { const { currentTheme } = useTheme(); - // Minimal state for image handling + // Enhanced state for smooth image loading const [imageError, setImageError] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); const imageOpacity = useSharedValue(1); + const imageLoadOpacity = useSharedValue(0); + const shimmerOpacity = useSharedValue(0.3); // Memoized image source const imageSource = useMemo(() => bannerImage || metadata.banner || metadata.poster , [bannerImage, metadata.banner, metadata.poster]); - // Ultra-fast image handlers + // Start shimmer animation for loading state + useEffect(() => { + if (!imageLoaded && imageSource) { + // Start shimmer animation + shimmerOpacity.value = withRepeat( + withTiming(0.8, { duration: 1200 }), + -1, + true + ); + } else { + // Stop shimmer when loaded + shimmerOpacity.value = withTiming(0.3, { duration: 300 }); + } + }, [imageLoaded, imageSource]); + + // Reset loading state when image source changes + useEffect(() => { + if (imageSource) { + setImageLoaded(false); + imageLoadOpacity.value = 0; + } + }, [imageSource]); + + // Enhanced image handlers with smooth transitions const handleImageError = () => { setImageError(true); + setImageLoaded(false); imageOpacity.value = withTiming(0.6, { duration: 150 }); + imageLoadOpacity.value = withTiming(0, { duration: 150 }); runOnJS(() => { if (bannerImage !== metadata.banner) { setBannerImage(metadata.banner || metadata.poster); @@ -268,7 +297,10 @@ const HeroSection: React.FC<HeroSectionProps> = ({ const handleImageLoad = () => { setImageError(false); + setImageLoaded(true); imageOpacity.value = withTiming(1, { duration: 150 }); + // Smooth fade-in for the loaded image + imageLoadOpacity.value = withTiming(1, { duration: 400 }); }; // Ultra-optimized animated styles - single calculations @@ -293,14 +325,14 @@ const HeroSection: React.FC<HeroSectionProps> = ({ opacity: watchProgressOpacity.value, }), []); - // Ultra-optimized backdrop with minimal calculations + // Enhanced backdrop with smooth loading animation const backdropImageStyle = useAnimatedStyle(() => { 'worklet'; const translateY = scrollY.value * PARALLAX_FACTOR; const scale = 1 + (scrollY.value * 0.0001); // Micro scale effect return { - opacity: imageOpacity.value, + opacity: imageOpacity.value * imageLoadOpacity.value, transform: [ { translateY: -Math.min(translateY, 100) }, // Cap translation { scale: Math.min(scale, SCALE_FACTOR) } // Cap scale @@ -346,7 +378,21 @@ const HeroSection: React.FC<HeroSectionProps> = ({ {/* Optimized Background */} <View style={[styles.absoluteFill, { backgroundColor: currentTheme.colors.black }]} /> - {/* Ultra-optimized Background Image */} + {/* Loading placeholder for smooth transition */} + {((imageSource && !imageLoaded) || loadingBanner) && ( + <Animated.View style={[styles.absoluteFill, { + opacity: shimmerOpacity, + }]}> + <LinearGradient + colors={['#111', '#222', '#111']} + style={styles.absoluteFill} + start={{ x: 0, y: 0 }} + end={{ x: 1, y: 1 }} + /> + </Animated.View> + )} + + {/* Enhanced Background Image with smooth loading */} {imageSource && !loadingBanner && ( <Animated.Image source={{ uri: imageSource }} diff --git a/src/hooks/useMetadataAssets.ts b/src/hooks/useMetadataAssets.ts index 5bd17f4..b52cc65 100644 --- a/src/hooks/useMetadataAssets.ts +++ b/src/hooks/useMetadataAssets.ts @@ -225,9 +225,15 @@ export const useMetadataAssets = ( const fetchBanner = async () => { logger.log(`[useMetadataAssets:Banner] Starting banner fetch.`); - setLoadingBanner(true); - setBannerImage(null); // Clear existing banner to prevent mixed sources - setBannerSource(null); // Clear source tracking + setLoadingBanner(true); + + // Show fallback banner immediately to prevent blank state + const fallbackBanner = metadata?.banner || metadata?.poster || null; + if (fallbackBanner && !bannerImage) { + setBannerImage(fallbackBanner); + setBannerSource('default'); + logger.log(`[useMetadataAssets:Banner] Setting immediate fallback banner: ${fallbackBanner}`); + } let finalBanner: string | null = null; let bannerSourceType: 'tmdb' | 'metahub' | 'default' = 'default'; @@ -419,17 +425,31 @@ export const useMetadataAssets = ( // Set the final state logger.log(`[useMetadataAssets:Banner] Final decision: Setting banner to ${finalBanner} (Source: ${bannerSourceType})`); - setBannerImage(finalBanner); - setBannerSource(bannerSourceType); // Track the source of the final image + + // Only update if the banner actually changed to avoid unnecessary re-renders + if (finalBanner !== bannerImage || bannerSourceType !== bannerSource) { + setBannerImage(finalBanner); + setBannerSource(bannerSourceType); // Track the source of the final image + logger.log(`[useMetadataAssets:Banner] Banner updated from ${bannerImage} to ${finalBanner}`); + } else { + logger.log(`[useMetadataAssets:Banner] Banner unchanged, skipping update`); + } + forcedBannerRefreshDone.current = true; // Mark this cycle as complete } catch (error) { logger.error(`[useMetadataAssets:Banner] Error in outer fetchBanner try block:`, error); // Ensure fallback to default even on outer error const defaultBanner = metadata?.banner || metadata?.poster || null; - setBannerImage(defaultBanner); - setBannerSource('default'); - logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`); + + // Only set if it's different from current banner + if (defaultBanner !== bannerImage) { + setBannerImage(defaultBanner); + setBannerSource('default'); + logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`); + } else { + logger.log(`[useMetadataAssets:Banner] Default banner already set, skipping update`); + } } finally { logger.log(`[useMetadataAssets:Banner] Finished banner fetch attempt.`); setLoadingBanner(false); diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 29a690b..d6caeb6 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -55,7 +55,9 @@ const MetadataScreen: React.FC = () => { // Optimized state management - reduced state variables const [isContentReady, setIsContentReady] = useState(false); + const [showSkeleton, setShowSkeleton] = useState(true); const transitionOpacity = useSharedValue(0); + const skeletonOpacity = useSharedValue(1); const { metadata, @@ -85,14 +87,26 @@ const MetadataScreen: React.FC = () => { // Memoized derived values for performance const isReady = useMemo(() => !loading && metadata && !metadataError, [loading, metadata, metadataError]); - // Ultra-fast content transition + // Smooth skeleton to content transition useEffect(() => { if (isReady && !isContentReady) { - setIsContentReady(true); - transitionOpacity.value = withTiming(1, { duration: 200 }); + // Small delay to ensure skeleton is rendered before starting transition + setTimeout(() => { + // Start fade out skeleton and fade in content simultaneously + skeletonOpacity.value = withTiming(0, { duration: 300 }); + transitionOpacity.value = withTiming(1, { duration: 400 }); + + // Hide skeleton after fade out completes + setTimeout(() => { + setShowSkeleton(false); + setIsContentReady(true); + }, 300); + }, 100); } else if (!isReady && isContentReady) { setIsContentReady(false); + setShowSkeleton(true); transitionOpacity.value = 0; + skeletonOpacity.value = 1; } }, [isReady, isContentReady]); @@ -143,6 +157,10 @@ const MetadataScreen: React.FC = () => { opacity: transitionOpacity.value, }), []); + const skeletonStyle = useAnimatedStyle(() => ({ + opacity: skeletonOpacity.value, + }), []); + // Memoized error component for performance const ErrorComponent = useMemo(() => { if (!metadataError) return null; @@ -181,110 +199,123 @@ const MetadataScreen: React.FC = () => { return ErrorComponent; } - // Show loading screen - if (loading || !isContentReady) { - return <MetadataLoadingScreen type={metadata?.type === 'movie' ? 'movie' : 'series'} />; - } - return ( - <Animated.View style={[StyleSheet.absoluteFill, transitionStyle]}> - <SafeAreaView - style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]} - edges={['bottom']} - > - <StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated /> - - {/* Floating Header - Optimized */} - <FloatingHeader - metadata={metadata} - logoLoadError={assetData.logoLoadError} - handleBack={handleBack} - handleToggleLibrary={handleToggleLibrary} - headerElementsY={animations.headerElementsY} - inLibrary={inLibrary} - headerOpacity={animations.headerOpacity} - headerElementsOpacity={animations.headerElementsOpacity} - safeAreaTop={safeAreaTop} - setLogoLoadError={assetData.setLogoLoadError} - /> - - <Animated.ScrollView - style={styles.scrollView} - showsVerticalScrollIndicator={false} - onScroll={animations.scrollHandler} - scrollEventThrottle={16} - bounces={false} - overScrollMode="never" - contentContainerStyle={styles.scrollContent} + <View style={StyleSheet.absoluteFill}> + {/* Skeleton Loading Screen - with fade out transition */} + {showSkeleton && ( + <Animated.View + style={[StyleSheet.absoluteFill, skeletonStyle]} + pointerEvents={isContentReady ? 'none' : 'auto'} > - {/* Hero Section - Optimized */} - <HeroSection - metadata={metadata} - bannerImage={assetData.bannerImage} - loadingBanner={assetData.loadingBanner} - logoLoadError={assetData.logoLoadError} - scrollY={animations.scrollY} - heroHeight={animations.heroHeight} - heroOpacity={animations.heroOpacity} - logoOpacity={animations.logoOpacity} - buttonsOpacity={animations.buttonsOpacity} - buttonsTranslateY={animations.buttonsTranslateY} - watchProgressOpacity={animations.watchProgressOpacity} - watchProgressWidth={animations.watchProgressWidth} - watchProgress={watchProgressData.watchProgress} - type={type as 'movie' | 'series'} - getEpisodeDetails={watchProgressData.getEpisodeDetails} - handleShowStreams={handleShowStreams} - handleToggleLibrary={handleToggleLibrary} - inLibrary={inLibrary} - id={id} - navigation={navigation} - getPlayButtonText={watchProgressData.getPlayButtonText} - setBannerImage={assetData.setBannerImage} - setLogoLoadError={assetData.setLogoLoadError} - /> + <MetadataLoadingScreen type={metadata?.type === 'movie' ? 'movie' : 'series'} /> + </Animated.View> + )} - {/* Main Content - Optimized */} - <Animated.View style={contentStyle}> - <MetadataDetails + {/* Main Content - with fade in transition */} + {metadata && ( + <Animated.View + style={[StyleSheet.absoluteFill, transitionStyle]} + pointerEvents={isContentReady ? 'auto' : 'none'} + > + <SafeAreaView + style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]} + edges={['bottom']} + > + <StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated /> + + {/* Floating Header - Optimized */} + <FloatingHeader metadata={metadata} - imdbId={imdbId} - type={type as 'movie' | 'series'} - renderRatings={() => imdbId ? ( - <RatingsSection imdbId={imdbId} type={type === 'series' ? 'show' : 'movie'} /> - ) : null} + logoLoadError={assetData.logoLoadError} + handleBack={handleBack} + handleToggleLibrary={handleToggleLibrary} + headerElementsY={animations.headerElementsY} + inLibrary={inLibrary} + headerOpacity={animations.headerOpacity} + headerElementsOpacity={animations.headerElementsOpacity} + safeAreaTop={safeAreaTop} + setLogoLoadError={assetData.setLogoLoadError} /> - <CastSection - cast={cast} - loadingCast={loadingCast} - onSelectCastMember={handleSelectCastMember} - /> - - {type === 'movie' && ( - <MoreLikeThisSection - recommendations={recommendations} - loadingRecommendations={loadingRecommendations} + <Animated.ScrollView + style={styles.scrollView} + showsVerticalScrollIndicator={false} + onScroll={animations.scrollHandler} + scrollEventThrottle={16} + bounces={false} + overScrollMode="never" + contentContainerStyle={styles.scrollContent} + > + {/* Hero Section - Optimized */} + <HeroSection + metadata={metadata} + bannerImage={assetData.bannerImage} + loadingBanner={assetData.loadingBanner} + logoLoadError={assetData.logoLoadError} + scrollY={animations.scrollY} + heroHeight={animations.heroHeight} + heroOpacity={animations.heroOpacity} + logoOpacity={animations.logoOpacity} + buttonsOpacity={animations.buttonsOpacity} + buttonsTranslateY={animations.buttonsTranslateY} + watchProgressOpacity={animations.watchProgressOpacity} + watchProgressWidth={animations.watchProgressWidth} + watchProgress={watchProgressData.watchProgress} + type={type as 'movie' | 'series'} + getEpisodeDetails={watchProgressData.getEpisodeDetails} + handleShowStreams={handleShowStreams} + handleToggleLibrary={handleToggleLibrary} + inLibrary={inLibrary} + id={id} + navigation={navigation} + getPlayButtonText={watchProgressData.getPlayButtonText} + setBannerImage={assetData.setBannerImage} + setLogoLoadError={assetData.setLogoLoadError} /> - )} - {type === 'series' ? ( - <SeriesContent - episodes={episodes} - selectedSeason={selectedSeason} - loadingSeasons={loadingSeasons} - onSeasonChange={handleSeasonChangeWithHaptics} - onSelectEpisode={handleEpisodeSelect} - groupedEpisodes={groupedEpisodes} - metadata={metadata || undefined} - /> - ) : ( - metadata && <MovieContent metadata={metadata} /> - )} - </Animated.View> - </Animated.ScrollView> - </SafeAreaView> - </Animated.View> + {/* Main Content - Optimized */} + <Animated.View style={contentStyle}> + <MetadataDetails + metadata={metadata} + imdbId={imdbId} + type={type as 'movie' | 'series'} + renderRatings={() => imdbId ? ( + <RatingsSection imdbId={imdbId} type={type === 'series' ? 'show' : 'movie'} /> + ) : null} + /> + + <CastSection + cast={cast} + loadingCast={loadingCast} + onSelectCastMember={handleSelectCastMember} + /> + + {type === 'movie' && ( + <MoreLikeThisSection + recommendations={recommendations} + loadingRecommendations={loadingRecommendations} + /> + )} + + {type === 'series' ? ( + <SeriesContent + episodes={episodes} + selectedSeason={selectedSeason} + loadingSeasons={loadingSeasons} + onSeasonChange={handleSeasonChangeWithHaptics} + onSelectEpisode={handleEpisodeSelect} + groupedEpisodes={groupedEpisodes} + metadata={metadata || undefined} + /> + ) : ( + metadata && <MovieContent metadata={metadata} /> + )} + </Animated.View> + </Animated.ScrollView> + </SafeAreaView> + </Animated.View> + )} + </View> ); }; -- 2.45.2 From 2e5de7216b2bbb5a8551bd10deb90a3b3b7bd6b5 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 11:25:54 +0530 Subject: [PATCH 42/88] Implement episode layout settings and enhance SeriesContent display This update introduces a new episode layout setting, allowing users to choose between vertical and horizontal card styles for episode displays. The SeriesContent component has been refactored to support both layouts, improving the user experience with a more dynamic presentation of episodes. Additionally, new styles and components have been added for the horizontal layout, including gradient overlays and progress indicators, enhancing visual appeal and functionality. The settings screen has also been updated to allow users to toggle between layout styles seamlessly. --- src/components/metadata/SeriesContent.tsx | 451 ++++++++++++++---- src/hooks/useSettings.ts | 2 + src/screens/AddonsScreen.tsx | 55 ++- src/screens/SettingsScreen.tsx | 45 ++ src/screens/TMDBSettingsScreen.tsx | 528 +++++++++++----------- 5 files changed, 728 insertions(+), 353 deletions(-) diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index a35c403..8868b28 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -2,7 +2,9 @@ import React, { useEffect, useState, useRef } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native'; import { Image } from 'expo-image'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; +import { LinearGradient } from 'expo-linear-gradient'; import { useTheme } from '../../contexts/ThemeContext'; +import { useSettings } from '../../hooks/useSettings'; import { Episode } from '../../types/metadata'; import { tmdbService } from '../../services/tmdbService'; import { storageService } from '../../services/storageService'; @@ -34,6 +36,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ metadata }) => { const { currentTheme } = useTheme(); + const { settings } = useSettings(); const { width } = useWindowDimensions(); const isTablet = width > 768; const isDarkMode = useColorScheme() === 'dark'; @@ -159,6 +162,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ </View> <Text style={[ + styles.seasonButtonText, { color: currentTheme.colors.mediumEmphasis }, selectedSeason === season && [styles.selectedSeasonButtonText, { color: currentTheme.colors.primary }] ]} @@ -173,7 +177,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ ); }; - const renderEpisodeCard = (episode: Episode) => { + // Vertical layout episode card (traditional) + const renderVerticalEpisodeCard = (episode: Episode) => { let episodeImage = EPISODE_PLACEHOLDER; if (episode.still_path) { const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500'); @@ -217,9 +222,9 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ <TouchableOpacity key={episode.id} style={[ - styles.episodeCard, - isTablet && styles.episodeCardTablet, - { backgroundColor: currentTheme.colors.darkBackground } + styles.episodeCardVertical, + isTablet && styles.episodeCardVerticalTablet, + { backgroundColor: currentTheme.colors.elevation2 } ]} onPress={() => onSelectEpisode(episode)} activeOpacity={0.7} @@ -291,6 +296,127 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ ); }; + // Horizontal layout episode card (Netflix-style) + const renderHorizontalEpisodeCard = (episode: Episode) => { + let episodeImage = EPISODE_PLACEHOLDER; + if (episode.still_path) { + const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500'); + if (tmdbUrl) episodeImage = tmdbUrl; + } else if (metadata?.poster) { + episodeImage = metadata.poster; + } + + 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); + const minutes = runtime % 60; + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; + }; + + // Get episode progress + const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`; + const progress = episodeProgress[episodeId]; + const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0; + + // Don't show progress bar if episode is complete (>= 95%) + const showProgress = progress && progressPercent < 95; + + return ( + <TouchableOpacity + key={episode.id} + style={[ + styles.episodeCardHorizontal, + isTablet && styles.episodeCardHorizontalTablet + ]} + onPress={() => onSelectEpisode(episode)} + activeOpacity={0.85} + > + {/* Background Image */} + <Image + source={{ uri: episodeImage }} + style={styles.episodeBackgroundImage} + contentFit="cover" + /> + + {/* Gradient Overlay */} + <LinearGradient + colors={[ + 'rgba(0,0,0,0.1)', + 'rgba(0,0,0,0.3)', + 'rgba(0,0,0,0.8)', + 'rgba(0,0,0,0.95)' + ]} + locations={[0, 0.3, 0.7, 1]} + style={styles.episodeGradient} + > + {/* Content Container */} + <View style={styles.episodeContent}> + {/* Episode Number Badge */} + <Text style={styles.episodeNumberHorizontal}>{episodeString}</Text> + + {/* Episode Title */} + <Text style={styles.episodeTitleHorizontal} numberOfLines={2}> + {episode.name} + </Text> + + {/* Episode Description */} + <Text style={styles.episodeDescriptionHorizontal} numberOfLines={3}> + {episode.overview || 'No description available'} + </Text> + + {/* Metadata Row */} + <View style={styles.episodeMetadataRowHorizontal}> + {episode.runtime && ( + <Text style={styles.runtimeTextHorizontal}> + {formatRuntime(episode.runtime)} + </Text> + )} + {episode.vote_average > 0 && ( + <View style={styles.ratingContainerHorizontal}> + <MaterialIcons name="star" size={14} color="#FFD700" /> + <Text style={styles.ratingTextHorizontal}> + {episode.vote_average.toFixed(1)} + </Text> + </View> + )} + </View> + </View> + + {/* Progress Bar */} + {showProgress && ( + <View style={styles.progressBarContainerHorizontal}> + <View + style={[ + styles.progressBarHorizontal, + { width: `${progressPercent}%`, backgroundColor: currentTheme.colors.primary } + ]} + /> + </View> + )} + + {/* Completed Badge */} + {progressPercent >= 95 && ( + <View style={[styles.completedBadgeHorizontal, { backgroundColor: currentTheme.colors.primary }]}> + <MaterialIcons name="check" size={16} color="#fff" /> + </View> + )} + + {/* More Options */} + <TouchableOpacity style={styles.moreButton} activeOpacity={0.7}> + <MaterialIcons name="more-horiz" size={24} color="rgba(255,255,255,0.8)" /> + </TouchableOpacity> + </LinearGradient> + </TouchableOpacity> + ); + }; + const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || []; return ( @@ -308,35 +434,62 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ {episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'} </Text> - <ScrollView - style={styles.episodeList} - contentContainerStyle={[ - styles.episodeListContent, - isTablet && styles.episodeListContentTablet - ]} - > - {isTablet ? ( - <View style={styles.episodeGrid}> - {currentSeasonEpisodes.map((episode, index) => ( + {settings.episodeLayoutStyle === 'horizontal' ? ( + // Horizontal Layout (Netflix-style) + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + style={styles.episodeList} + contentContainerStyle={styles.episodeListContentHorizontal} + decelerationRate="fast" + snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16} + snapToAlignment="start" + > + {currentSeasonEpisodes.map((episode, index) => ( + <Animated.View + key={episode.id} + entering={FadeIn.duration(400).delay(300 + index * 50)} + style={[ + styles.episodeCardWrapperHorizontal, + isTablet && styles.episodeCardWrapperHorizontalTablet + ]} + > + {renderHorizontalEpisodeCard(episode)} + </Animated.View> + ))} + </ScrollView> + ) : ( + // Vertical Layout (Traditional) + <ScrollView + style={styles.episodeList} + contentContainerStyle={[ + styles.episodeListContentVertical, + isTablet && styles.episodeListContentVerticalTablet + ]} + > + {isTablet ? ( + <View style={styles.episodeGridVertical}> + {currentSeasonEpisodes.map((episode, index) => ( + <Animated.View + key={episode.id} + entering={FadeIn.duration(400).delay(300 + index * 50)} + > + {renderVerticalEpisodeCard(episode)} + </Animated.View> + ))} + </View> + ) : ( + currentSeasonEpisodes.map((episode, index) => ( <Animated.View key={episode.id} entering={FadeIn.duration(400).delay(300 + index * 50)} > - {renderEpisodeCard(episode)} + {renderVerticalEpisodeCard(episode)} </Animated.View> - ))} - </View> - ) : ( - currentSeasonEpisodes.map((episode, index) => ( - <Animated.View - key={episode.id} - entering={FadeIn.duration(400).delay(300 + index * 50)} - > - {renderEpisodeCard(episode)} - </Animated.View> - )) - )} - </ScrollView> + )) + )} + </ScrollView> + )} </Animated.View> </View> ); @@ -366,18 +519,20 @@ const styles = StyleSheet.create({ episodeList: { flex: 1, }, - episodeListContent: { + + // Vertical Layout Styles + episodeListContentVertical: { paddingBottom: 20, }, - episodeListContentTablet: { + episodeListContentVerticalTablet: { paddingHorizontal: 8, }, - episodeGrid: { + episodeGridVertical: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', }, - episodeCard: { + episodeCardVertical: { flexDirection: 'row', borderRadius: 16, marginBottom: 16, @@ -385,13 +540,13 @@ const styles = StyleSheet.create({ elevation: 8, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.35, - shadowRadius: 12, + shadowOpacity: 0.25, + shadowRadius: 8, borderWidth: 1, - borderColor: 'rgba(255,255,255,0.05)', + borderColor: 'rgba(255,255,255,0.1)', height: 120, }, - episodeCardTablet: { + episodeCardVerticalTablet: { width: '48%', flexDirection: 'column', height: 120, @@ -410,12 +565,12 @@ const styles = StyleSheet.create({ position: 'absolute', bottom: 8, right: 4, - backgroundColor: 'rgba(0,0,0,0.9)', + backgroundColor: 'rgba(0,0,0,0.85)', paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, borderWidth: 1, - borderColor: 'rgba(255,255,255,0.15)', + borderColor: 'rgba(255,255,255,0.2)', zIndex: 1, }, episodeNumberText: { @@ -446,7 +601,7 @@ const styles = StyleSheet.create({ ratingContainer: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.85)', + backgroundColor: 'rgba(0,0,0,0.7)', paddingHorizontal: 4, paddingVertical: 2, borderRadius: 4, @@ -461,6 +616,19 @@ const styles = StyleSheet.create({ fontWeight: '700', marginLeft: 4, }, + runtimeContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.7)', + paddingHorizontal: 4, + paddingVertical: 2, + borderRadius: 4, + }, + runtimeText: { + fontSize: 13, + fontWeight: '600', + marginLeft: 4, + }, airDateText: { fontSize: 12, opacity: 0.8, @@ -469,6 +637,161 @@ const styles = StyleSheet.create({ fontSize: 13, lineHeight: 18, }, + progressBarContainer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 3, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + progressBar: { + height: '100%', + }, + completedBadge: { + position: 'absolute', + bottom: 8, + right: 8, + width: 20, + height: 20, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.3)', + }, + + // Horizontal Layout Styles + episodeListContentHorizontal: { + paddingLeft: 0, + paddingRight: 16, + }, + episodeCardWrapperHorizontal: { + width: Dimensions.get('window').width * 0.85, + marginRight: 16, + }, + episodeCardWrapperHorizontalTablet: { + width: Dimensions.get('window').width * 0.4, + }, + episodeCardHorizontal: { + borderRadius: 16, + overflow: 'hidden', + elevation: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.35, + shadowRadius: 12, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.05)', + height: 200, + position: 'relative', + width: '100%', + }, + episodeCardHorizontalTablet: { + height: 180, + }, + episodeBackgroundImage: { + width: '100%', + height: '100%', + borderRadius: 16, + }, + episodeGradient: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 16, + justifyContent: 'flex-end', + }, + episodeContent: { + padding: 16, + paddingBottom: 20, + }, + episodeNumberHorizontal: { + color: 'rgba(255,255,255,0.8)', + fontSize: 11, + fontWeight: '600', + letterSpacing: 1, + textTransform: 'uppercase', + marginBottom: 4, + }, + episodeTitleHorizontal: { + color: '#fff', + fontSize: 18, + fontWeight: '700', + letterSpacing: -0.3, + marginBottom: 8, + lineHeight: 22, + }, + episodeDescriptionHorizontal: { + color: 'rgba(255,255,255,0.85)', + fontSize: 13, + lineHeight: 18, + marginBottom: 12, + opacity: 0.9, + }, + episodeMetadataRowHorizontal: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + runtimeTextHorizontal: { + color: 'rgba(255,255,255,0.8)', + fontSize: 12, + fontWeight: '500', + }, + ratingContainerHorizontal: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.4)', + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 4, + gap: 3, + }, + ratingTextHorizontal: { + color: '#FFD700', + fontSize: 12, + fontWeight: '600', + }, + progressBarContainerHorizontal: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 3, + backgroundColor: 'rgba(255,255,255,0.2)', + }, + progressBarHorizontal: { + height: '100%', + borderRadius: 2, + }, + completedBadgeHorizontal: { + position: 'absolute', + bottom: 12, + right: 12, + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: '#fff', + }, + moreButton: { + position: 'absolute', + top: 12, + right: 12, + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.3)', + }, + + // Season Selector Styles seasonSelectorWrapper: { marginBottom: 20, }, @@ -517,54 +840,4 @@ const styles = StyleSheet.create({ selectedSeasonButtonText: { fontWeight: '700', }, - progressBarContainer: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - height: 3, - backgroundColor: 'rgba(0,0,0,0.5)', - }, - progressBar: { - height: '100%', - }, - progressTextContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.7)', - paddingHorizontal: 6, - paddingVertical: 3, - borderRadius: 4, - marginRight: 8, - }, - progressText: { - fontSize: 12, - fontWeight: '600', - marginLeft: 4, - }, - completedBadge: { - position: 'absolute', - bottom: 8, - right: 8, - width: 20, - height: 20, - borderRadius: 10, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.3)', - }, - runtimeContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(0,0,0,0.85)', - paddingHorizontal: 4, - paddingVertical: 2, - borderRadius: 4, - }, - runtimeText: { - fontSize: 13, - fontWeight: '600', - marginLeft: 4, - }, }); \ No newline at end of file diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index aa63dce..bb13546 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -35,6 +35,7 @@ export interface AppSettings { logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code) enableInternalProviders: boolean; // Toggle for internal providers like HDRezka + episodeLayoutStyle: 'vertical' | 'horizontal'; // Layout style for episode cards } export const DEFAULT_SETTINGS: AppSettings = { @@ -52,6 +53,7 @@ export const DEFAULT_SETTINGS: AppSettings = { logoSourcePreference: 'metahub', // Default to Metahub as first source tmdbLanguagePreference: 'en', // Default to English enableInternalProviders: true, // Enable internal providers by default + episodeLayoutStyle: 'horizontal', // Default to the new horizontal layout }; const SETTINGS_STORAGE_KEY = 'app_settings'; diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index 1761a9a..c9b39bb 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -29,7 +29,9 @@ import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { BlurView } from 'expo-blur'; +import { BlurView as ExpoBlurView } from 'expo-blur'; +import { BlurView as CommunityBlurView } from '@react-native-community/blur'; +import Constants, { ExecutionEnvironment } from 'expo-constants'; import axios from 'axios'; import { useTheme } from '../contexts/ThemeContext'; @@ -552,6 +554,36 @@ const createStyles = (colors: any) => StyleSheet.create({ flexDirection: 'row', alignItems: 'center', }, + blurOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.4)', + }, + androidBlurContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + androidBlur: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + androidFallbackBlur: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'black', + }, }); const AddonsScreen = () => { @@ -1233,7 +1265,24 @@ const AddonsScreen = () => { setAddonDetails(null); }} > - <BlurView intensity={80} style={styles.modalContainer} tint="dark"> + <View style={styles.modalContainer}> + {Platform.OS === 'ios' ? ( + <ExpoBlurView intensity={80} style={styles.blurOverlay} tint="dark" /> + ) : ( + Constants.executionEnvironment === ExecutionEnvironment.StoreClient ? ( + <View style={[styles.androidBlurContainer, styles.androidFallbackBlur]} /> + ) : ( + <View style={styles.androidBlurContainer}> + <CommunityBlurView + style={styles.androidBlur} + blurType="dark" + blurAmount={8} + overlayColor="rgba(0,0,0,0.4)" + reducedTransparencyFallbackColor="black" + /> + </View> + ) + )} <View style={styles.modalContent}> {addonDetails && ( <> @@ -1332,7 +1381,7 @@ const AddonsScreen = () => { </> )} </View> - </BlurView> + </View> </Modal> </SafeAreaView> ); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index bc7471c..9b1dcf1 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -367,6 +367,51 @@ const SettingsScreen: React.FC = () => { icon="palette" renderControl={ChevronRight} onPress={() => navigation.navigate('ThemeSettings')} + /> + <SettingItem + title="Episode Layout" + description={settings.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'} + icon="view-module" + renderControl={() => ( + <View style={styles.selectorContainer}> + <TouchableOpacity + style={[ + styles.selectorButton, + settings.episodeLayoutStyle === 'vertical' && { + backgroundColor: currentTheme.colors.primary + } + ]} + onPress={() => updateSetting('episodeLayoutStyle', 'vertical')} + > + <Text style={[ + styles.selectorText, + { color: currentTheme.colors.mediumEmphasis }, + settings.episodeLayoutStyle === 'vertical' && { + color: currentTheme.colors.white, + fontWeight: '600' + } + ]}>Vertical</Text> + </TouchableOpacity> + <TouchableOpacity + style={[ + styles.selectorButton, + settings.episodeLayoutStyle === 'horizontal' && { + backgroundColor: currentTheme.colors.primary + } + ]} + onPress={() => updateSetting('episodeLayoutStyle', 'horizontal')} + > + <Text style={[ + styles.selectorText, + { color: currentTheme.colors.mediumEmphasis }, + settings.episodeLayoutStyle === 'horizontal' && { + color: currentTheme.colors.white, + fontWeight: '600' + } + ]}>Horizontal</Text> + </TouchableOpacity> + </View> + )} isLast={true} /> </SettingsCard> diff --git a/src/screens/TMDBSettingsScreen.tsx b/src/screens/TMDBSettingsScreen.tsx index f655405..660ecd3 100644 --- a/src/screens/TMDBSettingsScreen.tsx +++ b/src/screens/TMDBSettingsScreen.tsx @@ -26,10 +26,10 @@ import { tmdbService } from '../services/tmdbService'; import { useSettings } from '../hooks/useSettings'; import { logger } from '../utils/logger'; import { useTheme } from '../contexts/ThemeContext'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key'; const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key'; -const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const TMDBSettingsScreen = () => { const navigation = useNavigation(); @@ -41,6 +41,7 @@ const TMDBSettingsScreen = () => { const [isInputFocused, setIsInputFocused] = useState(false); const apiKeyInputRef = useRef<TextInput>(null); const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); useEffect(() => { logger.log('[TMDBSettingsScreen] Component mounted'); @@ -222,277 +223,66 @@ const TMDBSettingsScreen = () => { }); }; - const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: currentTheme.colors.darkBackground, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - loadingText: { - marginTop: 12, - fontSize: 16, - color: currentTheme.colors.white, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, - paddingHorizontal: 16, - paddingBottom: 16, - }, - backButton: { - flexDirection: 'row', - alignItems: 'center', - }, - backText: { - color: currentTheme.colors.primary, - fontSize: 16, - fontWeight: '500', - }, - scrollView: { - flex: 1, - }, - scrollContent: { - paddingBottom: 40, - }, - titleContainer: { - paddingTop: 8, - }, - title: { - fontSize: 28, - fontWeight: 'bold', - color: currentTheme.colors.white, - marginHorizontal: 16, - marginBottom: 16, - }, - switchCard: { - backgroundColor: currentTheme.colors.elevation2, - borderRadius: 12, - marginHorizontal: 16, - marginBottom: 16, - padding: 16, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - switchTextContainer: { - flex: 1, - marginRight: 12, - }, - switchTitle: { - fontSize: 16, - fontWeight: '500', - color: currentTheme.colors.white, - }, - switchDescription: { - fontSize: 14, - color: currentTheme.colors.mediumEmphasis, - lineHeight: 20, - }, - statusCard: { - flexDirection: 'row', - backgroundColor: currentTheme.colors.elevation2, - borderRadius: 12, - marginHorizontal: 16, - marginBottom: 16, - padding: 16, - }, - statusIconContainer: { - marginRight: 12, - }, - statusTextContainer: { - flex: 1, - }, - statusTitle: { - fontSize: 16, - fontWeight: '500', - color: currentTheme.colors.white, - marginBottom: 4, - }, - statusDescription: { - fontSize: 14, - color: currentTheme.colors.mediumEmphasis, - }, - card: { - backgroundColor: currentTheme.colors.elevation2, - borderRadius: 12, - marginHorizontal: 16, - marginBottom: 16, - padding: 16, - }, - cardTitle: { - fontSize: 16, - fontWeight: '500', - color: currentTheme.colors.white, - marginBottom: 16, - }, - inputContainer: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 16, - }, - input: { - flex: 1, - backgroundColor: currentTheme.colors.elevation1, - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - color: currentTheme.colors.white, - fontSize: 15, - borderWidth: 1, - borderColor: 'transparent', - }, - inputFocused: { - borderColor: currentTheme.colors.primary, - }, - pasteButton: { - position: 'absolute', - right: 8, - padding: 4, - }, - buttonRow: { - flexDirection: 'row', - justifyContent: 'space-between', - }, - button: { - backgroundColor: currentTheme.colors.primary, - borderRadius: 8, - paddingVertical: 12, - paddingHorizontal: 16, - alignItems: 'center', - flex: 1, - marginRight: 8, - }, - clearButton: { - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: currentTheme.colors.error, - marginRight: 0, - marginLeft: 8, - flex: 0, - }, - buttonText: { - color: currentTheme.colors.white, - fontWeight: '500', - fontSize: 15, - }, - clearButtonText: { - color: currentTheme.colors.error, - }, - resultMessage: { - borderRadius: 8, - padding: 12, - marginTop: 16, - flexDirection: 'row', - alignItems: 'center', - }, - successMessage: { - backgroundColor: currentTheme.colors.success + '1A', // 10% opacity - }, - errorMessage: { - backgroundColor: currentTheme.colors.error + '1A', // 10% opacity - }, - resultIcon: { - marginRight: 8, - }, - resultText: { - flex: 1, - }, - successText: { - color: currentTheme.colors.success, - }, - errorText: { - color: currentTheme.colors.error, - }, - helpLink: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 8, - }, - helpIcon: { - marginRight: 4, - }, - helpText: { - color: currentTheme.colors.primary, - fontSize: 14, - }, - infoCard: { - backgroundColor: currentTheme.colors.elevation1, - borderRadius: 12, - marginHorizontal: 16, - marginBottom: 16, - padding: 16, - flexDirection: 'row', - alignItems: 'flex-start', - }, - infoIcon: { - marginRight: 8, - marginTop: 2, - }, - infoText: { - color: currentTheme.colors.mediumEmphasis, - fontSize: 14, - flex: 1, - lineHeight: 20, - }, - }); + const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; + const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; + const headerHeight = headerBaseHeight + topSpacing; if (isLoading) { return ( - <SafeAreaView style={styles.container}> + <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <StatusBar barStyle="light-content" /> <View style={styles.loadingContainer}> <ActivityIndicator size="large" color={currentTheme.colors.primary} /> - <Text style={styles.loadingText}>Loading Settings...</Text> + <Text style={[styles.loadingText, { color: currentTheme.colors.text }]}>Loading Settings...</Text> </View> - </SafeAreaView> + </View> ); } return ( - <SafeAreaView style={styles.container}> + <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <StatusBar barStyle="light-content" /> - <View style={styles.header}> - <TouchableOpacity - style={styles.backButton} - onPress={() => navigation.goBack()} - > - <MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} /> - <Text style={styles.backText}>Settings</Text> - </TouchableOpacity> + <View style={[styles.headerContainer, { paddingTop: topSpacing }]}> + <View style={styles.header}> + <TouchableOpacity + style={styles.backButton} + onPress={() => navigation.goBack()} + > + <MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} /> + <Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text> + </TouchableOpacity> + </View> + <Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}> + TMDb Settings + </Text> </View> - <Text style={styles.title}>TMDb Settings</Text> <ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator={false} > - <View style={styles.switchCard}> + <View style={[styles.switchCard, { backgroundColor: currentTheme.colors.elevation2 }]}> <View style={styles.switchTextContainer}> - <Text style={styles.switchTitle}>Use Custom TMDb API Key</Text> + <Text style={[styles.switchTitle, { color: currentTheme.colors.text }]}>Use Custom TMDb API Key</Text> + <Text style={[styles.switchDescription, { color: currentTheme.colors.mediumEmphasis }]}> + Enable to use your own TMDb API key instead of the built-in one. + Using your own API key may provide better performance and higher rate limits. + </Text> </View> <Switch value={useCustomKey} onValueChange={toggleUseCustomKey} - trackColor={{ false: currentTheme.colors.lightGray, true: currentTheme.colors.accentLight }} - thumbColor={Platform.OS === 'android' ? currentTheme.colors.primary : ''} - ios_backgroundColor={currentTheme.colors.lightGray} + trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }} + thumbColor={Platform.OS === 'android' ? (useCustomKey ? currentTheme.colors.white : currentTheme.colors.white) : ''} + ios_backgroundColor={'rgba(255,255,255,0.1)'} /> </View> - <Text style={styles.switchDescription}> - Enable to use your own TMDb API key instead of the built-in one. - Using your own API key may provide better performance and higher rate limits. - </Text> - {useCustomKey && ( <> - <View style={styles.statusCard}> + <View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation2 }]}> <MaterialIcons name={isKeySet ? "check-circle" : "error-outline"} size={28} @@ -500,10 +290,10 @@ const TMDBSettingsScreen = () => { style={styles.statusIconContainer} /> <View style={styles.statusTextContainer}> - <Text style={styles.statusTitle}> + <Text style={[styles.statusTitle, { color: currentTheme.colors.text }]}> {isKeySet ? "API Key Active" : "API Key Required"} </Text> - <Text style={styles.statusDescription}> + <Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}> {isKeySet ? "Your custom TMDb API key is set and active." : "Add your TMDb API key below."} @@ -511,19 +301,26 @@ const TMDBSettingsScreen = () => { </View> </View> - <View style={styles.card}> - <Text style={styles.cardTitle}>API Key</Text> + <View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}> + <Text style={[styles.cardTitle, { color: currentTheme.colors.text }]}>API Key</Text> <View style={styles.inputContainer}> <TextInput ref={apiKeyInputRef} - style={[styles.input, isInputFocused && styles.inputFocused]} + style={[ + styles.input, + { + backgroundColor: currentTheme.colors.elevation1, + color: currentTheme.colors.text, + borderColor: isInputFocused ? currentTheme.colors.primary : 'transparent' + } + ]} value={apiKey} onChangeText={(text) => { setApiKey(text); if (testResult) setTestResult(null); }} placeholder="Paste your TMDb API key (v3)" - placeholderTextColor={currentTheme.colors.mediumGray} + placeholderTextColor={currentTheme.colors.mediumEmphasis} autoCapitalize="none" autoCorrect={false} spellCheck={false} @@ -540,18 +337,18 @@ const TMDBSettingsScreen = () => { <View style={styles.buttonRow}> <TouchableOpacity - style={styles.button} + style={[styles.button, { backgroundColor: currentTheme.colors.primary }]} onPress={saveApiKey} > - <Text style={styles.buttonText}>Save API Key</Text> + <Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save API Key</Text> </TouchableOpacity> {isKeySet && ( <TouchableOpacity - style={[styles.button, styles.clearButton]} + style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]} onPress={clearApiKey} > - <Text style={[styles.buttonText, styles.clearButtonText]}>Clear</Text> + <Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text> </TouchableOpacity> )} </View> @@ -559,7 +356,7 @@ const TMDBSettingsScreen = () => { {testResult && ( <View style={[ styles.resultMessage, - testResult.success ? styles.successMessage : styles.errorMessage + { backgroundColor: testResult.success ? currentTheme.colors.success + '1A' : currentTheme.colors.error + '1A' } ]}> <MaterialIcons name={testResult.success ? "check-circle" : "error"} @@ -569,7 +366,7 @@ const TMDBSettingsScreen = () => { /> <Text style={[ styles.resultText, - testResult.success ? styles.successText : styles.errorText + { color: testResult.success ? currentTheme.colors.success : currentTheme.colors.error } ]}> {testResult.message} </Text> @@ -581,15 +378,15 @@ const TMDBSettingsScreen = () => { onPress={openTMDBWebsite} > <MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} /> - <Text style={styles.helpText}> + <Text style={[styles.helpText, { color: currentTheme.colors.primary }]}> How to get a TMDb API key? </Text> </TouchableOpacity> </View> - <View style={styles.infoCard}> + <View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}> <MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} /> - <Text style={styles.infoText}> + <Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}> To get your own TMDb API key (v3), you need to create a TMDb account and request an API key from their website. Using your own API key gives you dedicated quota and may improve app performance. </Text> @@ -598,17 +395,226 @@ const TMDBSettingsScreen = () => { )} {!useCustomKey && ( - <View style={styles.infoCard}> + <View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}> <MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} /> - <Text style={styles.infoText}> + <Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}> Currently using the built-in TMDb API key. This key is shared among all users. For better performance and reliability, consider using your own API key. </Text> </View> )} </ScrollView> - </SafeAreaView> + </View> ); }; +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 12, + fontSize: 16, + }, + headerContainer: { + paddingHorizontal: 20, + paddingBottom: 8, + backgroundColor: 'transparent', + zIndex: 2, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + }, + backText: { + fontSize: 16, + fontWeight: '500', + marginLeft: 4, + }, + headerTitle: { + fontSize: 32, + fontWeight: '800', + letterSpacing: 0.3, + paddingLeft: 4, + }, + scrollView: { + flex: 1, + zIndex: 1, + }, + scrollContent: { + paddingHorizontal: 16, + paddingBottom: 40, + }, + switchCard: { + borderRadius: 16, + marginBottom: 16, + padding: 16, + flexDirection: 'row', + alignItems: 'flex-start', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + switchTextContainer: { + flex: 1, + marginRight: 16, + }, + switchTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 4, + }, + switchDescription: { + fontSize: 14, + lineHeight: 20, + opacity: 0.8, + }, + statusCard: { + flexDirection: 'row', + borderRadius: 16, + marginBottom: 16, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + statusIconContainer: { + marginRight: 12, + }, + statusTextContainer: { + flex: 1, + }, + statusTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 4, + }, + statusDescription: { + fontSize: 14, + opacity: 0.8, + }, + card: { + borderRadius: 16, + marginBottom: 16, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + cardTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 16, + }, + inputContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + input: { + flex: 1, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + fontSize: 15, + borderWidth: 2, + }, + pasteButton: { + position: 'absolute', + right: 12, + padding: 8, + }, + buttonRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + button: { + borderRadius: 12, + paddingVertical: 14, + paddingHorizontal: 20, + alignItems: 'center', + flex: 1, + marginRight: 8, + }, + clearButton: { + backgroundColor: 'transparent', + borderWidth: 2, + marginRight: 0, + marginLeft: 8, + flex: 0, + paddingHorizontal: 16, + }, + buttonText: { + fontWeight: '600', + fontSize: 15, + }, + resultMessage: { + borderRadius: 12, + padding: 16, + marginTop: 16, + flexDirection: 'row', + alignItems: 'center', + }, + resultIcon: { + marginRight: 12, + }, + resultText: { + flex: 1, + fontSize: 14, + fontWeight: '500', + }, + helpLink: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 16, + paddingVertical: 8, + }, + helpIcon: { + marginRight: 8, + }, + helpText: { + fontSize: 14, + fontWeight: '500', + }, + infoCard: { + borderRadius: 16, + marginBottom: 16, + padding: 16, + flexDirection: 'row', + alignItems: 'flex-start', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + infoIcon: { + marginRight: 12, + marginTop: 2, + }, + infoText: { + fontSize: 14, + flex: 1, + lineHeight: 20, + opacity: 0.8, + }, +}); + export default TMDBSettingsScreen; \ No newline at end of file -- 2.45.2 From 666e6edf5778d3182bc0b53325277385802b94b1 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 11:31:30 +0530 Subject: [PATCH 43/88] Enhance HeroSection with platform-specific blur effects and improved button styles This update introduces platform-specific blur effects in the HeroSection component, utilizing ExpoBlurView for iOS and CommunityBlurView for Android, along with a fallback for StoreClient environments. Additionally, button styles have been refined with overflow properties for better visual consistency. New styles for blur backgrounds have been added to enhance the overall aesthetic of the action buttons. --- src/components/metadata/HeroSection.tsx | 72 ++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 2575618..457d56f 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -5,10 +5,14 @@ import { StyleSheet, Dimensions, TouchableOpacity, + Platform, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { Image } from 'expo-image'; +import { BlurView as ExpoBlurView } from 'expo-blur'; +import { BlurView as CommunityBlurView } from '@react-native-community/blur'; +import Constants, { ExecutionEnvironment } from 'expo-constants'; import Animated, { useAnimatedStyle, interpolate, @@ -135,6 +139,21 @@ const ActionButtons = React.memo(({ onPress={toggleLibrary} activeOpacity={0.85} > + {Platform.OS === 'ios' ? ( + <ExpoBlurView intensity={80} style={styles.blurBackground} tint="dark" /> + ) : ( + Constants.executionEnvironment === ExecutionEnvironment.StoreClient ? ( + <View style={styles.androidFallbackBlur} /> + ) : ( + <CommunityBlurView + style={styles.blurBackground} + blurType="dark" + blurAmount={8} + overlayColor="rgba(255,255,255,0.1)" + reducedTransparencyFallbackColor="rgba(255,255,255,0.15)" + /> + ) + )} <MaterialIcons name={inLibrary ? 'bookmark' : 'bookmark-border'} size={24} @@ -151,6 +170,21 @@ const ActionButtons = React.memo(({ onPress={handleRatingsPress} activeOpacity={0.85} > + {Platform.OS === 'ios' ? ( + <ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" /> + ) : ( + Constants.executionEnvironment === ExecutionEnvironment.StoreClient ? ( + <View style={styles.androidFallbackBlurRound} /> + ) : ( + <CommunityBlurView + style={styles.blurBackgroundRound} + blurType="dark" + blurAmount={8} + overlayColor="rgba(255,255,255,0.1)" + reducedTransparencyFallbackColor="rgba(255,255,255,0.15)" + /> + ) + )} <MaterialIcons name="assessment" size={24} @@ -563,19 +597,19 @@ const styles = StyleSheet.create({ elevation: 4, }, infoButton: { - backgroundColor: 'rgba(255,255,255,0.15)', borderWidth: 1.5, borderColor: 'rgba(255,255,255,0.7)', + overflow: 'hidden', }, iconButton: { width: 50, height: 50, borderRadius: 25, - backgroundColor: 'rgba(255,255,255,0.15)', borderWidth: 1.5, borderColor: 'rgba(255,255,255,0.7)', alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', }, playButtonText: { color: '#000', @@ -614,6 +648,40 @@ const styles = StyleSheet.create({ opacity: 0.85, letterSpacing: 0.1 }, + blurBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 20, + }, + androidFallbackBlur: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.15)', + }, + blurBackgroundRound: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 25, + }, + androidFallbackBlurRound: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 25, + backgroundColor: 'rgba(255,255,255,0.15)', + }, }); export default React.memo(HeroSection); \ No newline at end of file -- 2.45.2 From 7e77f6ec423159ec2ea217f17af6c5f6923f2ce5 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 11:57:04 +0530 Subject: [PATCH 44/88] Refactor SeriesContent styles for improved layout and readability This update modifies the styling of the SeriesContent component, adjusting padding, font sizes, and margins to enhance the overall layout and readability. Key changes include reduced padding in episode content, smaller font sizes for episode numbers and titles, and adjustments to line heights and gaps for a more cohesive design. These refinements aim to improve the user experience by creating a cleaner and more visually appealing presentation of episode information. --- src/components/metadata/SeriesContent.tsx | 36 +++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 8868b28..e1f98c3 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -705,54 +705,54 @@ const styles = StyleSheet.create({ justifyContent: 'flex-end', }, episodeContent: { - padding: 16, - paddingBottom: 20, + padding: 12, + paddingBottom: 16, }, episodeNumberHorizontal: { color: 'rgba(255,255,255,0.8)', - fontSize: 11, + fontSize: 10, fontWeight: '600', - letterSpacing: 1, + letterSpacing: 0.8, textTransform: 'uppercase', - marginBottom: 4, + marginBottom: 2, }, episodeTitleHorizontal: { color: '#fff', - fontSize: 18, + fontSize: 15, fontWeight: '700', letterSpacing: -0.3, - marginBottom: 8, - lineHeight: 22, + marginBottom: 4, + lineHeight: 18, }, episodeDescriptionHorizontal: { color: 'rgba(255,255,255,0.85)', - fontSize: 13, - lineHeight: 18, - marginBottom: 12, + fontSize: 12, + lineHeight: 16, + marginBottom: 8, opacity: 0.9, }, episodeMetadataRowHorizontal: { flexDirection: 'row', alignItems: 'center', - gap: 12, + gap: 8, }, runtimeTextHorizontal: { color: 'rgba(255,255,255,0.8)', - fontSize: 12, + fontSize: 11, fontWeight: '500', }, ratingContainerHorizontal: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.4)', - paddingHorizontal: 6, - paddingVertical: 3, - borderRadius: 4, - gap: 3, + paddingHorizontal: 5, + paddingVertical: 2, + borderRadius: 3, + gap: 2, }, ratingTextHorizontal: { color: '#FFD700', - fontSize: 12, + fontSize: 11, fontWeight: '600', }, progressBarContainerHorizontal: { -- 2.45.2 From 3f2e6b08fc0a88f214c82fb784787221e66deed0 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 18:03:52 +0530 Subject: [PATCH 45/88] some fixes --- src/navigation/AppNavigator.tsx | 2 +- src/screens/StreamsScreen.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 2407b9a..8102042 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -761,7 +761,7 @@ const AppNavigator = () => { component={MetadataScreen} options={{ headerShown: false, - animation: Platform.OS === 'ios' ? 'fade' : 'slide_from_right', + animation: 'fade', animationDuration: Platform.OS === 'android' ? 250 : 300, ...(Platform.OS === 'ios' && { cardStyleInterpolator: customFadeInterpolator, diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 44f36e5..e1b14e3 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -1223,7 +1223,7 @@ const createStyles = (colors: any) => StyleSheet.create({ alignItems: 'center', gap: 8, padding: 14, - paddingTop: Platform.OS === 'android' ? 20 : 15, + paddingTop: Platform.OS === 'android' ? 45 : 15, }, backButtonText: { color: colors.highEmphasis, -- 2.45.2 From 7ae46313a5201fe1d0797ddc7c9cd8fdd7adbdb4 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 18:21:04 +0530 Subject: [PATCH 46/88] Refactor useMetadataAnimations for Android compatibility and optimize animation values This update modifies the entrance animations in the useMetadataAnimations hook to start with visible values for better compatibility on Android devices. The opacity and scale values for various animations have been adjusted to enhance performance and visual consistency. Additionally, the progress animation logic has been simplified for improved efficiency. --- src/hooks/useMetadataAnimations.ts | 34 ++++++++++++---------- src/screens/MDBListSettingsScreen.tsx | 2 +- src/screens/SettingsScreen.tsx | 7 +++-- src/screens/StreamsScreen.tsx | 41 +++++++-------------------- 4 files changed, 34 insertions(+), 50 deletions(-) diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts index e6698a9..eef7672 100644 --- a/src/hooks/useMetadataAnimations.ts +++ b/src/hooks/useMetadataAnimations.ts @@ -32,18 +32,18 @@ const easings = { }; export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) => { - // Consolidated entrance animations - fewer shared values - const screenOpacity = useSharedValue(0); - const contentOpacity = useSharedValue(0); + // Consolidated entrance animations - start with visible values for Android compatibility + const screenOpacity = useSharedValue(1); + const contentOpacity = useSharedValue(1); // Combined hero animations - const heroOpacity = useSharedValue(0); - const heroScale = useSharedValue(0.95); // Combined scale for micro-animation + const heroOpacity = useSharedValue(1); + const heroScale = useSharedValue(1); // Start at 1 for Android compatibility const heroHeightValue = useSharedValue(height * 0.5); // Combined UI element animations - const uiElementsOpacity = useSharedValue(0); - const uiElementsTranslateY = useSharedValue(10); + const uiElementsOpacity = useSharedValue(1); + const uiElementsTranslateY = useSharedValue(0); // Progress animation - simplified to single value const progressOpacity = useSharedValue(0); @@ -57,10 +57,11 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) = // Ultra-fast entrance sequence - batch animations for better performance useEffect(() => { - 'worklet'; - // Batch all entrance animations to run simultaneously const enterAnimations = () => { + 'worklet'; + + // Start with slightly reduced values and animate to full visibility screenOpacity.value = withTiming(1, { duration: 250, easing: easings.fast @@ -92,14 +93,17 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) = // Optimized watch progress animation useEffect(() => { - 'worklet'; - const hasProgress = watchProgress && watchProgress.duration > 0; - progressOpacity.value = withTiming(hasProgress ? 1 : 0, { - duration: hasProgress ? 200 : 150, - easing: easings.fast - }); + const updateProgress = () => { + 'worklet'; + progressOpacity.value = withTiming(hasProgress ? 1 : 0, { + duration: hasProgress ? 200 : 150, + easing: easings.fast + }); + }; + + runOnUI(updateProgress)(); }, [watchProgress]); // Ultra-optimized scroll handler with minimal calculations diff --git a/src/screens/MDBListSettingsScreen.tsx b/src/screens/MDBListSettingsScreen.tsx index 7dd3dda..93126f5 100644 --- a/src/screens/MDBListSettingsScreen.tsx +++ b/src/screens/MDBListSettingsScreen.tsx @@ -540,7 +540,7 @@ const MDBListSettingsScreen = () => { const openMDBListWebsite = () => { logger.log('[MDBListSettingsScreen] Opening MDBList website'); - Linking.openURL('https://mdblist.com/settings').catch(error => { + Linking.openURL('https://mdblist.com/preferences').catch(error => { logger.error('[MDBListSettingsScreen] Error opening website:', error); }); }; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 9b1dcf1..7f44226 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -697,19 +697,20 @@ const styles = StyleSheet.create({ borderRadius: 8, overflow: 'hidden', height: 36, - width: 160, + width: 180, marginRight: 8, }, selectorButton: { flex: 1, justifyContent: 'center', alignItems: 'center', - paddingHorizontal: 12, + paddingHorizontal: 8, backgroundColor: 'rgba(255,255,255,0.08)', }, selectorText: { - fontSize: 14, + fontSize: 13, fontWeight: '500', + textAlign: 'center', }, profileLockContainer: { padding: 16, diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index e1b14e3..941500d 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -679,39 +679,18 @@ export const StreamsScreen = () => { navigateToPlayer(stream); }); } else { - // For direct video URLs, use the S.Browser.ACTION_VIEW approach - // This is a more reliable way to force Android to show all video apps + // For direct video URLs, use the VideoPlayerService to show the Android app chooser + const success = await VideoPlayerService.playVideo(stream.url, { + useExternalPlayer: true, + title: metadata?.name || 'Video', + episodeTitle: type === 'series' ? currentEpisode?.name : undefined, + episodeNumber: type === 'series' && currentEpisode ? `S${currentEpisode.season_number}E${currentEpisode.episode_number}` : undefined, + }); - // Strip query parameters if they exist as they can cause issues with some apps - let cleanUrl = stream.url; - if (cleanUrl.includes('?')) { - cleanUrl = cleanUrl.split('?')[0]; + if (!success) { + console.log('VideoPlayerService failed, falling back to built-in player'); + navigateToPlayer(stream); } - - // Create an Android intent URL that forces the chooser - // Set component=null to ensure chooser is shown - // Set action=android.intent.action.VIEW to open the content - const intentUrl = `intent:${cleanUrl}#Intent;action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;component=;type=video/*;launchFlags=0x10000000;end`; - - console.log(`Using intent URL: ${intentUrl}`); - - Linking.openURL(intentUrl) - .then(() => console.log('Successfully opened with intent URL')) - .catch(err => { - console.error('Failed to open with intent URL:', err); - - // First fallback: Try direct URL with regular Linking API - console.log('Trying plain URL as fallback'); - Linking.openURL(stream.url) - .then(() => console.log('Opened with direct URL')) - .catch(directErr => { - console.error('Failed to open direct URL:', directErr); - - // Final fallback: Use built-in player - console.log('All external player attempts failed, using built-in player'); - navigateToPlayer(stream); - }); - }); } } catch (error) { console.error('Error with external player:', error); -- 2.45.2 From d5cfe8a563e28cdfcf8c0c088c2f865c9399f91a Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 18:25:19 +0530 Subject: [PATCH 47/88] Implement dynamic poster layout calculation across multiple components for improved responsiveness This update introduces a new function to calculate poster layout dynamically based on screen width in several components, including CatalogSection, ContentItem, ContinueWatchingSection, MoreLikeThisSection, CatalogScreen, HomeScreen, and homeStyles. The function ensures that poster widths and the number of columns adapt to different screen sizes, enhancing the overall user experience and visual consistency across the application. --- src/components/home/CatalogSection.tsx | 27 +++++- src/components/home/ContentItem.tsx | 27 +++++- .../home/ContinueWatchingSection.tsx | 26 +++++- .../metadata/MoreLikeThisSection.tsx | 27 +++++- src/screens/CatalogScreen.tsx | 30 ++++++- src/screens/HomeScreen.tsx | 27 +++++- src/styles/homeStyles.ts | 27 +++++- src/utils/posterUtils.ts | 82 +++++++++++++++++++ 8 files changed, 265 insertions(+), 8 deletions(-) create mode 100644 src/utils/posterUtils.ts diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx index 8f4ed81..d4cdd9c 100644 --- a/src/components/home/CatalogSection.tsx +++ b/src/components/home/CatalogSection.tsx @@ -14,7 +14,32 @@ interface CatalogSectionProps { } const { width } = Dimensions.get('window'); -const POSTER_WIDTH = (width - 50) / 3; + +// Dynamic poster calculation based on screen width +const calculatePosterLayout = (screenWidth: number) => { + const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability + const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters + const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins + + // Calculate how many posters can fit + const availableWidth = screenWidth - HORIZONTAL_PADDING; + const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); + + // Limit to reasonable number of columns (3-6) + const numColumns = Math.min(Math.max(maxColumns, 3), 6); + + // Calculate actual poster width + const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); + + return { + numColumns, + posterWidth, + spacing: 12 // Space between posters + }; +}; + +const posterLayout = calculatePosterLayout(width); +const POSTER_WIDTH = posterLayout.posterWidth; const CatalogSection = ({ catalog }: CatalogSectionProps) => { const navigation = useNavigation<NavigationProp<RootStackParamList>>(); diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index ac3b0db..f5061c5 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -12,7 +12,32 @@ interface ContentItemProps { } const { width } = Dimensions.get('window'); -const POSTER_WIDTH = (width - 50) / 3; + +// Dynamic poster calculation based on screen width +const calculatePosterLayout = (screenWidth: number) => { + const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability + const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters + const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins + + // Calculate how many posters can fit + const availableWidth = screenWidth - HORIZONTAL_PADDING; + const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); + + // Limit to reasonable number of columns (3-6) + const numColumns = Math.min(Math.max(maxColumns, 3), 6); + + // Calculate actual poster width + const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); + + return { + numColumns, + posterWidth, + spacing: 12 // Space between posters + }; +}; + +const posterLayout = calculatePosterLayout(width); +const POSTER_WIDTH = posterLayout.posterWidth; const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { const [menuVisible, setMenuVisible] = useState(false); diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 5ef5002..17e64d2 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -33,8 +33,32 @@ interface ContinueWatchingRef { refresh: () => Promise<boolean>; } +// Dynamic poster calculation based on screen width for Continue Watching section +const calculatePosterLayout = (screenWidth: number) => { + const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items + const MAX_POSTER_WIDTH = 160; // Maximum poster width for this section + const HORIZONTAL_PADDING = 40; // Total horizontal padding/margins + + // Calculate how many posters can fit (fewer items for continue watching) + const availableWidth = screenWidth - HORIZONTAL_PADDING; + const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); + + // Limit to reasonable number of columns (2-5 for continue watching) + const numColumns = Math.min(Math.max(maxColumns, 2), 5); + + // Calculate actual poster width + const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); + + return { + numColumns, + posterWidth, + spacing: 12 // Space between posters + }; +}; + const { width } = Dimensions.get('window'); -const POSTER_WIDTH = (width - 40) / 2.7; +const posterLayout = calculatePosterLayout(width); +const POSTER_WIDTH = posterLayout.posterWidth; // Create a proper imperative handle with React.forwardRef and updated type const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => { diff --git a/src/components/metadata/MoreLikeThisSection.tsx b/src/components/metadata/MoreLikeThisSection.tsx index f69cc69..f2df2e4 100644 --- a/src/components/metadata/MoreLikeThisSection.tsx +++ b/src/components/metadata/MoreLikeThisSection.tsx @@ -19,7 +19,32 @@ import { TMDBService } from '../../services/tmdbService'; import { catalogService } from '../../services/catalogService'; const { width } = Dimensions.get('window'); -const POSTER_WIDTH = (width - 48) / 3.5; // Adjust number for desired items visible + +// Dynamic poster calculation based on screen width for More Like This section +const calculatePosterLayout = (screenWidth: number) => { + const MIN_POSTER_WIDTH = 100; // Slightly smaller for more items in this section + const MAX_POSTER_WIDTH = 130; // Maximum poster width + const HORIZONTAL_PADDING = 48; // Total horizontal padding/margins + + // Calculate how many posters can fit (aim for slightly more items than main sections) + const availableWidth = screenWidth - HORIZONTAL_PADDING; + const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); + + // Limit to reasonable number of columns (3-7 for this section) + const numColumns = Math.min(Math.max(maxColumns, 3), 7); + + // Calculate actual poster width + const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); + + return { + numColumns, + posterWidth, + spacing: 12 // Space between posters + }; +}; + +const posterLayout = calculatePosterLayout(width); +const POSTER_WIDTH = posterLayout.posterWidth; const POSTER_HEIGHT = POSTER_WIDTH * 1.5; interface MoreLikeThisSectionProps { diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 59d5c39..83de2ab 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -41,9 +41,35 @@ const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; // Screen dimensions and grid layout const { width } = Dimensions.get('window'); -const NUM_COLUMNS = 3; + +// Dynamic column calculation based on screen width +const calculateCatalogLayout = (screenWidth: number) => { + const MIN_ITEM_WIDTH = 110; // Minimum item width for readability + const MAX_ITEM_WIDTH = 150; // Maximum item width + const HORIZONTAL_PADDING = SPACING.lg * 2; // Total horizontal padding + const ITEM_MARGIN_TOTAL = SPACING.sm * 2; // Total margin per item + + // Calculate how many columns can fit + const availableWidth = screenWidth - HORIZONTAL_PADDING; + const maxColumns = Math.floor(availableWidth / (MIN_ITEM_WIDTH + ITEM_MARGIN_TOTAL)); + + // Limit to reasonable number of columns (2-6) + const numColumns = Math.min(Math.max(maxColumns, 2), 6); + + // Calculate actual item width + const totalMargins = ITEM_MARGIN_TOTAL * numColumns; + const itemWidth = Math.min((availableWidth - totalMargins) / numColumns, MAX_ITEM_WIDTH); + + return { + numColumns, + itemWidth + }; +}; + +const catalogLayout = calculateCatalogLayout(width); +const NUM_COLUMNS = catalogLayout.numColumns; const ITEM_MARGIN = SPACING.sm; -const ITEM_WIDTH = (width - (SPACING.lg * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS; +const ITEM_WIDTH = catalogLayout.itemWidth; // Create a styles creator function that accepts the theme colors const createStyles = (colors: any) => StyleSheet.create({ diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index f781b0d..8da022b 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -679,7 +679,32 @@ const HomeScreen = () => { }; const { width, height } = Dimensions.get('window'); -const POSTER_WIDTH = (width - 50) / 3; + +// Dynamic poster calculation based on screen width +const calculatePosterLayout = (screenWidth: number) => { + const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability + const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters + const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins + + // Calculate how many posters can fit + const availableWidth = screenWidth - HORIZONTAL_PADDING; + const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); + + // Limit to reasonable number of columns (3-6) + const numColumns = Math.min(Math.max(maxColumns, 3), 6); + + // Calculate actual poster width + const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); + + return { + numColumns, + posterWidth, + spacing: 12 // Space between posters + }; +}; + +const posterLayout = calculatePosterLayout(width); +const POSTER_WIDTH = posterLayout.posterWidth; const styles = StyleSheet.create<any>({ container: { diff --git a/src/styles/homeStyles.ts b/src/styles/homeStyles.ts index dff5863..c74c122 100644 --- a/src/styles/homeStyles.ts +++ b/src/styles/homeStyles.ts @@ -1,7 +1,32 @@ import { StyleSheet, Dimensions, Platform } from 'react-native'; const { width, height } = Dimensions.get('window'); -export const POSTER_WIDTH = (width - 50) / 3; + +// Dynamic poster calculation based on screen width +const calculatePosterLayout = (screenWidth: number) => { + const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability + const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters + const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins + + // Calculate how many posters can fit + const availableWidth = screenWidth - HORIZONTAL_PADDING; + const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); + + // Limit to reasonable number of columns (3-6) + const numColumns = Math.min(Math.max(maxColumns, 3), 6); + + // Calculate actual poster width + const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); + + return { + numColumns, + posterWidth, + spacing: 12 // Space between posters + }; +}; + +const posterLayout = calculatePosterLayout(width); +export const POSTER_WIDTH = posterLayout.posterWidth; export const POSTER_HEIGHT = POSTER_WIDTH * 1.5; export const HORIZONTAL_PADDING = 16; diff --git a/src/utils/posterUtils.ts b/src/utils/posterUtils.ts new file mode 100644 index 0000000..4fa4c1d --- /dev/null +++ b/src/utils/posterUtils.ts @@ -0,0 +1,82 @@ +import { Dimensions } from 'react-native'; + +export interface PosterLayoutConfig { + minPosterWidth: number; + maxPosterWidth: number; + horizontalPadding: number; + minColumns: number; + maxColumns: number; + spacing: number; +} + +export interface PosterLayout { + numColumns: number; + posterWidth: number; + spacing: number; +} + +// Default configuration for main home sections +export const DEFAULT_POSTER_CONFIG: PosterLayoutConfig = { + minPosterWidth: 110, + maxPosterWidth: 140, + horizontalPadding: 50, + minColumns: 3, + maxColumns: 6, + spacing: 12 +}; + +// Configuration for More Like This section (smaller posters, more items) +export const MORE_LIKE_THIS_CONFIG: PosterLayoutConfig = { + minPosterWidth: 100, + maxPosterWidth: 130, + horizontalPadding: 48, + minColumns: 3, + maxColumns: 7, + spacing: 12 +}; + +// Configuration for Continue Watching section (larger posters, fewer items) +export const CONTINUE_WATCHING_CONFIG: PosterLayoutConfig = { + minPosterWidth: 120, + maxPosterWidth: 160, + horizontalPadding: 40, + minColumns: 2, + maxColumns: 5, + spacing: 12 +}; + +export const calculatePosterLayout = ( + screenWidth: number, + config: PosterLayoutConfig = DEFAULT_POSTER_CONFIG +): PosterLayout => { + const { + minPosterWidth, + maxPosterWidth, + horizontalPadding, + minColumns, + maxColumns, + spacing + } = config; + + // Calculate how many posters can fit + const availableWidth = screenWidth - horizontalPadding; + const maxColumnsBasedOnWidth = Math.floor(availableWidth / minPosterWidth); + + // Limit to reasonable number of columns + const numColumns = Math.min(Math.max(maxColumnsBasedOnWidth, minColumns), maxColumns); + + // Calculate actual poster width + const posterWidth = Math.min(availableWidth / numColumns, maxPosterWidth); + + return { + numColumns, + posterWidth, + spacing + }; +}; + +// Helper function to get current screen dimensions +export const getCurrentPosterLayout = (config?: PosterLayoutConfig): PosterLayout => { + const { width } = Dimensions.get('window'); + return calculatePosterLayout(width, config); +}; \ No newline at end of file -- 2.45.2 From f41bba740d47e0e22c185225ccb75e16cfe98b1e Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 18:30:37 +0530 Subject: [PATCH 48/88] Enhance SeriesContent component with improved styling and layout adjustments This update introduces several enhancements to the SeriesContent component, including the addition of a subtle highlight border for episode cards, refined gradient overlay colors, and new badge styles for episode numbers and runtime. The layout has been optimized for better readability and visual appeal, ensuring a more cohesive presentation of episode information. These changes aim to enhance the overall user experience by providing clearer and more attractive content displays. --- src/components/metadata/SeriesContent.tsx | 59 +++++++++++++++++------ 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index e1f98c3..ec86b3c 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -333,7 +333,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ key={episode.id} style={[ styles.episodeCardHorizontal, - isTablet && styles.episodeCardHorizontalTablet + isTablet && styles.episodeCardHorizontalTablet, + // Add subtle highlight border only + { + borderWidth: 2, + borderColor: currentTheme.colors.primary + '40', // 40% opacity for subtle effect + } ]} onPress={() => onSelectEpisode(episode)} activeOpacity={0.85} @@ -345,21 +350,24 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ contentFit="cover" /> - {/* Gradient Overlay */} + {/* Standard Gradient Overlay */} <LinearGradient colors={[ - 'rgba(0,0,0,0.1)', - 'rgba(0,0,0,0.3)', - 'rgba(0,0,0,0.8)', + 'rgba(0,0,0,0.05)', + 'rgba(0,0,0,0.2)', + 'rgba(0,0,0,0.6)', + 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.95)' ]} - locations={[0, 0.3, 0.7, 1]} + locations={[0, 0.2, 0.5, 0.8, 1]} style={styles.episodeGradient} > {/* Content Container */} <View style={styles.episodeContent}> {/* Episode Number Badge */} - <Text style={styles.episodeNumberHorizontal}>{episodeString}</Text> + <View style={styles.episodeNumberBadgeHorizontal}> + <Text style={styles.episodeNumberHorizontal}>{episodeString}</Text> + </View> {/* Episode Title */} <Text style={styles.episodeTitleHorizontal} numberOfLines={2}> @@ -374,9 +382,11 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ {/* Metadata Row */} <View style={styles.episodeMetadataRowHorizontal}> {episode.runtime && ( - <Text style={styles.runtimeTextHorizontal}> - {formatRuntime(episode.runtime)} - </Text> + <View style={styles.runtimeContainerHorizontal}> + <Text style={styles.runtimeTextHorizontal}> + {formatRuntime(episode.runtime)} + </Text> + </View> )} {episode.vote_average > 0 && ( <View style={styles.ratingContainerHorizontal}> @@ -395,7 +405,10 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ <View style={[ styles.progressBarHorizontal, - { width: `${progressPercent}%`, backgroundColor: currentTheme.colors.primary } + { + width: `${progressPercent}%`, + backgroundColor: currentTheme.colors.primary, + } ]} /> </View> @@ -403,14 +416,16 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ {/* Completed Badge */} {progressPercent >= 95 && ( - <View style={[styles.completedBadgeHorizontal, { backgroundColor: currentTheme.colors.primary }]}> + <View style={[styles.completedBadgeHorizontal, { + backgroundColor: currentTheme.colors.primary, + }]}> <MaterialIcons name="check" size={16} color="#fff" /> </View> )} - {/* More Options */} + {/* More Options Button */} <TouchableOpacity style={styles.moreButton} activeOpacity={0.7}> - <MaterialIcons name="more-horiz" size={24} color="rgba(255,255,255,0.8)" /> + <MaterialIcons name="more-horiz" size={24} color="rgba(255,255,255,0.9)" /> </TouchableOpacity> </LinearGradient> </TouchableOpacity> @@ -708,6 +723,14 @@ const styles = StyleSheet.create({ padding: 12, paddingBottom: 16, }, + episodeNumberBadgeHorizontal: { + backgroundColor: 'rgba(0,0,0,0.4)', + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 4, + marginBottom: 6, + alignSelf: 'flex-start', + }, episodeNumberHorizontal: { color: 'rgba(255,255,255,0.8)', fontSize: 10, @@ -736,6 +759,14 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: 8, }, + runtimeContainerHorizontal: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.4)', + paddingHorizontal: 5, + paddingVertical: 2, + borderRadius: 3, + }, runtimeTextHorizontal: { color: 'rgba(255,255,255,0.8)', fontSize: 11, -- 2.45.2 From e38d5a271fd2052704d624398ecbda82e4862d01 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 18:32:22 +0530 Subject: [PATCH 49/88] Enhance SeriesContent component with gradient border effect and layout improvements This update introduces a gradient border effect for episode cards, enhancing visual appeal and depth. The base border has been made transparent, and new shadow properties have been added for a more dynamic look. Additionally, the layout has been refined by removing the more options button, contributing to a cleaner design. These changes aim to improve the overall user experience by providing a more attractive and cohesive presentation of episode information. --- src/components/metadata/SeriesContent.tsx | 53 +++++++++++++++-------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index ec86b3c..d93b529 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -334,15 +334,48 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ style={[ styles.episodeCardHorizontal, isTablet && styles.episodeCardHorizontalTablet, - // Add subtle highlight border only + // Enhanced border styling { borderWidth: 2, - borderColor: currentTheme.colors.primary + '40', // 40% opacity for subtle effect + borderColor: 'transparent', // Make base border transparent + // Add gradient border effect using shadow + shadowColor: currentTheme.colors.primary, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.6, + shadowRadius: 8, + elevation: 12, } ]} onPress={() => onSelectEpisode(episode)} activeOpacity={0.85} > + {/* Gradient Border Container */} + <View style={{ + position: 'absolute', + top: -2, + left: -2, + right: -2, + bottom: -2, + borderRadius: 18, + zIndex: -1, + }}> + <LinearGradient + colors={[ + currentTheme.colors.primary + '80', // 80% opacity + currentTheme.colors.primary + '40', // 40% opacity + currentTheme.colors.primary + '20', // 20% opacity + currentTheme.colors.primary + '40', // 40% opacity + currentTheme.colors.primary + '80', // 80% opacity + ]} + start={{ x: 0, y: 0 }} + end={{ x: 1, y: 1 }} + style={{ + flex: 1, + borderRadius: 18, + }} + /> + </View> + {/* Background Image */} <Image source={{ uri: episodeImage }} @@ -423,10 +456,6 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ </View> )} - {/* More Options Button */} - <TouchableOpacity style={styles.moreButton} activeOpacity={0.7}> - <MaterialIcons name="more-horiz" size={24} color="rgba(255,255,255,0.9)" /> - </TouchableOpacity> </LinearGradient> </TouchableOpacity> ); @@ -701,6 +730,7 @@ const styles = StyleSheet.create({ height: 200, position: 'relative', width: '100%', + backgroundColor: 'transparent', }, episodeCardHorizontalTablet: { height: 180, @@ -810,17 +840,6 @@ const styles = StyleSheet.create({ borderWidth: 2, borderColor: '#fff', }, - moreButton: { - position: 'absolute', - top: 12, - right: 12, - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(0,0,0,0.3)', - }, // Season Selector Styles seasonSelectorWrapper: { -- 2.45.2 From 7239997acc64ef90d5e50e0e5811c4a97fe28050 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 18:36:04 +0530 Subject: [PATCH 50/88] catalog fix --- src/screens/CatalogScreen.tsx | 47 +++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 83de2ab..ba3a97d 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -44,25 +44,28 @@ const { width } = Dimensions.get('window'); // Dynamic column calculation based on screen width const calculateCatalogLayout = (screenWidth: number) => { - const MIN_ITEM_WIDTH = 110; // Minimum item width for readability - const MAX_ITEM_WIDTH = 150; // Maximum item width + const MIN_ITEM_WIDTH = 120; // Increased minimum for better readability + const MAX_ITEM_WIDTH = 160; // Adjusted maximum const HORIZONTAL_PADDING = SPACING.lg * 2; // Total horizontal padding - const ITEM_MARGIN_TOTAL = SPACING.sm * 2; // Total margin per item + const ITEM_SPACING = SPACING.sm; // Space between items // Calculate how many columns can fit const availableWidth = screenWidth - HORIZONTAL_PADDING; - const maxColumns = Math.floor(availableWidth / (MIN_ITEM_WIDTH + ITEM_MARGIN_TOTAL)); + const maxColumns = Math.floor(availableWidth / (MIN_ITEM_WIDTH + ITEM_SPACING)); - // Limit to reasonable number of columns (2-6) - const numColumns = Math.min(Math.max(maxColumns, 2), 6); + // Limit to reasonable number of columns (2-4 for better UX) + const numColumns = Math.min(Math.max(maxColumns, 2), 4); - // Calculate actual item width - const totalMargins = ITEM_MARGIN_TOTAL * numColumns; - const itemWidth = Math.min((availableWidth - totalMargins) / numColumns, MAX_ITEM_WIDTH); + // Calculate actual item width with proper spacing + const totalSpacing = ITEM_SPACING * (numColumns - 1); + const itemWidth = (availableWidth - totalSpacing) / numColumns; + + // For 2 columns, ensure we use the full available width + const finalItemWidth = numColumns === 2 ? itemWidth : Math.min(itemWidth, MAX_ITEM_WIDTH); return { numColumns, - itemWidth + itemWidth: finalItemWidth }; }; @@ -105,11 +108,7 @@ const createStyles = (colors: any) => StyleSheet.create({ padding: SPACING.lg, paddingTop: SPACING.sm, }, - columnWrapper: { - justifyContent: 'space-between', - }, item: { - width: ITEM_WIDTH, marginBottom: SPACING.lg, borderRadius: 8, overflow: 'hidden', @@ -441,10 +440,22 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { } }, [loading, hasMore, page, loadItems]); - const renderItem = useCallback(({ item }: { item: Meta }) => { + const renderItem = useCallback(({ item, index }: { item: Meta; index: number }) => { + // Calculate if this is the last item in a row + const isLastInRow = (index + 1) % NUM_COLUMNS === 0; + // For 2-column layout, ensure proper spacing + const rightMargin = isLastInRow ? 0 : SPACING.sm; + return ( <TouchableOpacity - style={styles.item} + style={[ + styles.item, + { + marginRight: rightMargin, + // For 2 columns, ensure items fill the available space properly + width: NUM_COLUMNS === 2 ? ITEM_WIDTH : ITEM_WIDTH + } + ]} onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })} activeOpacity={0.7} > @@ -469,7 +480,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { </View> </TouchableOpacity> ); - }, [navigation, styles]); + }, [navigation, styles, NUM_COLUMNS, ITEM_WIDTH]); const renderEmptyState = () => ( <View style={styles.centered}> @@ -568,6 +579,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { renderItem={renderItem} keyExtractor={(item) => `${item.id}-${item.type}`} numColumns={NUM_COLUMNS} + key={NUM_COLUMNS} refreshControl={ <RefreshControl refreshing={refreshing} @@ -586,7 +598,6 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { ) : null } contentContainerStyle={styles.list} - columnWrapperStyle={styles.columnWrapper} showsVerticalScrollIndicator={false} /> ) : renderEmptyState()} -- 2.45.2 From e114bfb15fc53a4be3890cb2bf7e5595fd540902 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 18:40:33 +0530 Subject: [PATCH 51/88] fix catatlog names --- src/screens/CatalogScreen.tsx | 49 ++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index ba3a97d..8401e79 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -193,13 +193,60 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { const [hasMore, setHasMore] = useState(true); const [error, setError] = useState<string | null>(null); const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS); + const [actualCatalogName, setActualCatalogName] = useState<string | null>(null); const { currentTheme } = useTheme(); const colors = currentTheme.colors; const styles = createStyles(colors); const isDarkMode = true; const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames(); - const displayName = getCustomName(addonId || '', type || '', id || '', originalName || ''); + + // Create display name with proper type suffix + const createDisplayName = (catalogName: string) => { + if (!catalogName) return ''; + + // Check if the name already includes content type indicators + const lowerName = catalogName.toLowerCase(); + const contentType = type === 'movie' ? 'Movies' : type === 'series' ? 'TV Shows' : `${type.charAt(0).toUpperCase() + type.slice(1)}s`; + + // If the name already contains type information, return as is + if (lowerName.includes('movie') || lowerName.includes('tv') || lowerName.includes('show') || lowerName.includes('series')) { + return catalogName; + } + + // Otherwise append the content type + return `${catalogName} ${contentType}`; + }; + + // Use actual catalog name if available, otherwise fallback to custom name or original name + const displayName = actualCatalogName + ? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName)) + : getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') || + (genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` : + `${type.charAt(0).toUpperCase() + type.slice(1)}s`); + + // Add effect to get the actual catalog name from addon manifest + useEffect(() => { + const getActualCatalogName = async () => { + if (addonId && type && id) { + try { + const manifests = await stremioService.getInstalledAddonsAsync(); + const addon = manifests.find(a => a.id === addonId); + + if (addon && addon.catalogs) { + const catalog = addon.catalogs.find(c => c.type === type && c.id === id); + if (catalog && catalog.name) { + setActualCatalogName(catalog.name); + } + } + } catch (error) { + logger.error('Failed to get actual catalog name:', error); + } + } + }; + + getActualCatalogName(); + }, [addonId, type, id]); // Add effect to get data source preference when component mounts useEffect(() => { -- 2.45.2 From 876e3fa71b9c359cd2880454f0c966d33be4de31 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 18:42:38 +0530 Subject: [PATCH 52/88] Enhance SearchScreen functionality with improved search handling and user feedback This update introduces several enhancements to the SearchScreen component, including logging for search actions, debouncing adjustments, and improved user feedback for search input. The search now requires at least 2 characters to initiate, with visual prompts for insufficient input. Additionally, a cleanup function has been added to cancel pending searches, ensuring a smoother user experience. --- src/screens/SearchScreen.tsx | 39 +++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index c36d894..ff8ca01 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -320,34 +320,50 @@ const SearchScreen = () => { } try { + logger.info('Performing search for:', searchQuery); const searchResults = await catalogService.searchContentCinemeta(searchQuery); setResults(searchResults); if (searchResults.length > 0) { await saveRecentSearch(searchQuery); } + logger.info('Search completed, found', searchResults.length, 'results'); } catch (error) { logger.error('Search failed:', error); setResults([]); } finally { setSearching(false); } - }, 200), + }, 800), [recentSearches] ); useEffect(() => { - if (query.trim()) { + if (query.trim() && query.trim().length >= 2) { setSearching(true); setSearched(true); setShowRecent(false); debouncedSearch(query); + } else if (query.trim().length < 2 && query.trim().length > 0) { + // Show that we're waiting for more characters + setSearching(false); + setSearched(false); + setShowRecent(false); + setResults([]); } else { + // Cancel any pending search when query is cleared + debouncedSearch.cancel(); setResults([]); setSearched(false); + setSearching(false); setShowRecent(true); loadRecentSearches(); } - }, [query]); + + // Cleanup function to cancel pending searches + return () => { + debouncedSearch.cancel(); + }; + }, [query, debouncedSearch]); const handleClearSearch = () => { setQuery(''); @@ -544,6 +560,23 @@ const SearchScreen = () => { <View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}> {searching ? ( <SimpleSearchAnimation /> + ) : query.trim().length === 1 ? ( + <Animated.View + style={styles.emptyContainer} + entering={FadeIn.duration(300)} + > + <MaterialIcons + name="search" + size={64} + color={currentTheme.colors.lightGray} + /> + <Text style={[styles.emptyText, { color: currentTheme.colors.white }]}> + Keep typing... + </Text> + <Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}> + Type at least 2 characters to search + </Text> + </Animated.View> ) : searched && !hasResultsToShow ? ( <Animated.View style={styles.emptyContainer} -- 2.45.2 From ac8d7ff4adf30545a97a8496b7babd90a87d1ecf Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 18:43:59 +0530 Subject: [PATCH 53/88] added animation for search page --- src/navigation/AppNavigator.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 8102042..ee73cb2 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -824,8 +824,8 @@ const AppNavigator = () => { name="Search" component={SearchScreen as any} options={{ - animation: 'slide_from_right', - animationDuration: Platform.OS === 'android' ? 250 : 300, + animation: 'fade', + animationDuration: Platform.OS === 'android' ? 300 : 350, contentStyle: { backgroundColor: currentTheme.colors.darkBackground, }, -- 2.45.2 From 7369f7b5c2951791d5fe506dffc09b6b3cc52953 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 18:44:54 +0530 Subject: [PATCH 54/88] adjusted header padding --- src/components/NuvioHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/NuvioHeader.tsx b/src/components/NuvioHeader.tsx index d6c10c3..77ea5ff 100644 --- a/src/components/NuvioHeader.tsx +++ b/src/components/NuvioHeader.tsx @@ -81,7 +81,7 @@ const styles = StyleSheet.create({ }, headerContainer: { height: Platform.OS === 'ios' ? 100 : 90, - paddingTop: Platform.OS === 'ios' ? 35 : 20, + paddingTop: Platform.OS === 'ios' ? 35 : 35, backgroundColor: 'rgba(0,0,0,0.3)', }, blurOverlay: { -- 2.45.2 From f8f14a352a9ade9c5f3f57d4c52b5cec9dbb4abb Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 18:47:04 +0530 Subject: [PATCH 55/88] removed unnecessary refresh --- src/screens/HomeScreen.tsx | 43 +++++--------------------------------- 1 file changed, 5 insertions(+), 38 deletions(-) diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 8da022b..2159ed6 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -6,7 +6,6 @@ import { FlatList, TouchableOpacity, ActivityIndicator, - RefreshControl, SafeAreaView, StatusBar, useColorScheme, @@ -395,7 +394,6 @@ const HomeScreen = () => { const { catalogs, loading: catalogsLoading, - refreshing: catalogsRefreshing, refreshCatalogs } = useHomeCatalogs(); @@ -412,8 +410,6 @@ const HomeScreen = () => { (showHeroSection ? featuredLoading : false) || catalogsLoading, [showHeroSection, featuredLoading, catalogsLoading] ); - - const isRefreshing = catalogsRefreshing; // React to settings changes useEffect(() => { @@ -496,25 +492,6 @@ const HomeScreen = () => { } }, []); - const handleRefresh = useCallback(async () => { - try { - const refreshTasks = [ - refreshCatalogs(), - continueWatchingRef.current?.refresh(), - ]; - - // Only refresh featured content if hero section is enabled, - // and force refresh to bypass the cache - if (showHeroSection) { - refreshTasks.push(refreshFeatured()); - } - - await Promise.all(refreshTasks); - } catch (error) { - logger.error('Error during refresh:', error); - } - }, [refreshFeatured, refreshCatalogs, showHeroSection]); - const handleContentPress = useCallback((id: string, type: string) => { navigation.navigate('Metadata', { id, type }); }, [navigation]); @@ -569,7 +546,7 @@ const HomeScreen = () => { // Memoize the loading screen to prevent unnecessary re-renders const renderLoadingScreen = useMemo(() => { - if (isLoading && !isRefreshing) { + if (isLoading) { return ( <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <StatusBar @@ -585,11 +562,11 @@ const HomeScreen = () => { ); } return null; - }, [isLoading, isRefreshing, currentTheme.colors]); + }, [isLoading, currentTheme.colors]); // Memoize the main content section const renderMainContent = useMemo(() => { - if (isLoading && !isRefreshing) return null; + if (isLoading) return null; return ( <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> @@ -599,14 +576,6 @@ const HomeScreen = () => { translucent /> <ScrollView - refreshControl={ - <RefreshControl - refreshing={isRefreshing} - onRefresh={handleRefresh} - tintColor={currentTheme.colors.primary} - colors={[currentTheme.colors.primary, currentTheme.colors.secondary]} - /> - } contentContainerStyle={[ styles.scrollContent, { paddingTop: Platform.OS === 'ios' ? 100 : 90 } @@ -661,7 +630,6 @@ const HomeScreen = () => { ); }, [ isLoading, - isRefreshing, currentTheme.colors, showHeroSection, featuredContent, @@ -669,13 +637,12 @@ const HomeScreen = () => { handleSaveToLibrary, hasContinueWatching, catalogs, - catalogsLoading, - handleRefresh, + catalogsLoading, navigation, featuredContentSource ]); - return isLoading && !isRefreshing ? renderLoadingScreen : renderMainContent; + return isLoading ? renderLoadingScreen : renderMainContent; }; const { width, height } = Dimensions.get('window'); -- 2.45.2 From 70586e2b64488a9776a26375e52dc8a07573d75b Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 18:57:30 +0530 Subject: [PATCH 56/88] adjusted default theme --- src/contexts/ThemeContext.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index 5eb8c96..54c550b 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -45,9 +45,9 @@ export const DEFAULT_THEMES: Theme[] = [ name: 'Moonlight', colors: { ...defaultColors, - primary: '#a786df', - secondary: '#5e72e4', - darkBackground: '#0f0f1a', + primary: '#c084fc', + secondary: '#60a5fa', + darkBackground: '#060609', }, isEditable: false, }, -- 2.45.2 From 6acf84677f00b2fd0422a0cf08d721d271778dad Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Wed, 18 Jun 2025 21:47:19 +0530 Subject: [PATCH 57/88] added trakt watch history. --- .gitignore | 2 + app.json | 5 +- src/screens/LibraryScreen.tsx | 581 +++++++++++++++++++++++++++++++--- src/screens/SearchScreen.tsx | 25 +- 4 files changed, 554 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index f7c2b63..bf6e7ea 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ yarn-error.* # typescript *.tsbuildinfo +plan.md +release_announcement.md \ No newline at end of file diff --git a/app.json b/app.json index 453ab6c..5cb6c50 100644 --- a/app.json +++ b/app.json @@ -41,8 +41,9 @@ }, "android": { "adaptiveIcon": { - "foregroundImage": "./assets/adaptive-icon.png", - "backgroundColor": "#ffffff" + "foregroundImage": "./assets/icon.png", + "backgroundColor": "#020404", + "monochromeImage": "./assets/icon.png" }, "permissions": [ "INTERNET", diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 8e70595..b4cef61 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { View, Text, @@ -12,6 +12,7 @@ import { Animated as RNAnimated, ActivityIndicator, Platform, + ScrollView, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -25,13 +26,28 @@ import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; +import { useTraktContext } from '../contexts/TraktContext'; +import TraktIcon from '../../assets/rating-icons/trakt.svg'; +import { TMDBService } from '../services/tmdbService'; -// Types +// Define interfaces for proper typing interface LibraryItem extends StreamingContent { progress?: number; lastWatched?: string; } +interface TraktDisplayItem { + id: string; + name: string; + type: 'movie' | 'series'; + poster: string; + year?: number; + lastWatched: string; + plays: number; + imdbId?: string; + traktId: number; +} + const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const SkeletonLoader = () => { @@ -99,8 +115,18 @@ const LibraryScreen = () => { const [loading, setLoading] = useState(true); const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]); const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all'); + const [showTraktContent, setShowTraktContent] = useState(false); const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); + + // Trakt integration + const { + isAuthenticated: traktAuthenticated, + isLoading: traktLoading, + watchedMovies, + watchedShows, + loadWatchedItems + } = useTraktContext(); // Force consistent status bar settings useEffect(() => { @@ -151,6 +177,120 @@ const LibraryScreen = () => { return true; }); + // Prepare Trakt items with proper poster URLs + const traktItems = useMemo(() => { + if (!traktAuthenticated || (!watchedMovies?.length && !watchedShows?.length)) { + return []; + } + + const items: TraktDisplayItem[] = []; + + // Process watched movies + if (watchedMovies) { + for (const watchedMovie of watchedMovies) { + const movie = watchedMovie.movie; + if (movie) { + items.push({ + id: String(movie.ids.trakt), + name: movie.title, + type: 'movie', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: movie.year, + lastWatched: new Date(watchedMovie.last_watched_at).toLocaleDateString(), + plays: watchedMovie.plays, + imdbId: movie.ids.imdb, + traktId: movie.ids.trakt, + }); + } + } + } + + // Process watched shows + if (watchedShows) { + for (const watchedShow of watchedShows) { + const show = watchedShow.show; + if (show) { + items.push({ + id: String(show.ids.trakt), + name: show.title, + type: 'series', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: show.year, + lastWatched: new Date(watchedShow.last_watched_at).toLocaleDateString(), + plays: watchedShow.plays, + imdbId: show.ids.imdb, + traktId: show.ids.trakt, + }); + } + } + } + + // Sort by last watched date (most recent first) + return items.sort((a, b) => new Date(b.lastWatched).getTime() - new Date(a.lastWatched).getTime()); + }, [traktAuthenticated, watchedMovies, watchedShows]); + + // State for tracking poster URLs + const [traktPostersMap, setTraktPostersMap] = useState<Map<string, string>>(new Map()); + + // Effect to fetch poster URLs for Trakt items + useEffect(() => { + const fetchTraktPosters = async () => { + if (!traktAuthenticated || traktItems.length === 0) return; + + const tmdbService = TMDBService.getInstance(); + + // Process items individually and update state as each poster is fetched + for (const item of traktItems) { + try { + // Get TMDB ID from the original Trakt data + let tmdbId: number | null = null; + + if (item.type === 'movie' && watchedMovies) { + const watchedMovie = watchedMovies.find(wm => wm.movie?.ids.trakt === item.traktId); + tmdbId = watchedMovie?.movie?.ids.tmdb || null; + } else if (item.type === 'series' && watchedShows) { + const watchedShow = watchedShows.find(ws => ws.show?.ids.trakt === item.traktId); + tmdbId = watchedShow?.show?.ids.tmdb || null; + } + + if (tmdbId) { + // Fetch details from TMDB to get poster path + let posterPath: string | null = null; + + if (item.type === 'movie') { + const movieDetails = await tmdbService.getMovieDetails(String(tmdbId)); + posterPath = movieDetails?.poster_path || null; + } else { + const showDetails = await tmdbService.getTVShowDetails(tmdbId); + posterPath = showDetails?.poster_path || null; + } + + if (posterPath) { + const fullPosterUrl = tmdbService.getImageUrl(posterPath, 'w500'); + if (fullPosterUrl) { + // Update state immediately for this item + setTraktPostersMap(prevMap => { + const newMap = new Map(prevMap); + newMap.set(item.id, fullPosterUrl); + return newMap; + }); + } + } + } + } catch (error) { + logger.error(`Failed to fetch poster for Trakt item ${item.id}:`, error); + } + } + }; + + fetchTraktPosters(); + }, [traktItems, traktAuthenticated, watchedMovies, watchedShows]); + + // Log when posters map updates + useEffect(() => { + // Removed debugging logs + }, [traktPostersMap]); + const itemWidth = (width - 48) / 2; // 2 items per row with padding const renderItem = ({ item }: { item: LibraryItem }) => ( @@ -208,8 +348,203 @@ const LibraryScreen = () => { </TouchableOpacity> ); + const renderTraktFolder = () => ( + <TouchableOpacity + style={[styles.itemContainer, { width: itemWidth }]} + onPress={() => { + if (!traktAuthenticated) { + navigation.navigate('TraktSettings'); + } else { + setShowTraktContent(true); + } + }} + activeOpacity={0.7} + > + <View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black }]}> + <LinearGradient + colors={['#E8254B', '#C41E3A']} + style={styles.folderGradient} + > + <TraktIcon width={60} height={60} style={{ marginBottom: 12 }} /> + <Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}> + Trakt Collection + </Text> + {traktAuthenticated && traktItems.length > 0 && ( + <Text style={styles.folderCount}> + {traktItems.length} items + </Text> + )} + {!traktAuthenticated && ( + <Text style={styles.folderSubtitle}> + Tap to connect + </Text> + )} + </LinearGradient> + + {/* Trakt badge */} + <View style={[styles.badgeContainer, { backgroundColor: 'rgba(255,255,255,0.2)' }]}> + <TraktIcon width={12} height={12} style={{ marginRight: 4 }} /> + <Text style={[styles.badgeText, { color: currentTheme.colors.white }]}> + Trakt + </Text> + </View> + </View> + </TouchableOpacity> + ); + + const renderTraktItem = ({ item, customWidth }: { item: TraktDisplayItem; customWidth?: number }) => { + const posterUrl = traktPostersMap.get(item.id) || 'https://via.placeholder.com/300x450/ff0000/ffffff?text=No+Poster'; + const width = customWidth || itemWidth; + + return ( + <TouchableOpacity + style={[styles.itemContainer, { width }]} + onPress={() => { + // Navigate using IMDB ID for Trakt items + if (item.imdbId) { + navigation.navigate('Metadata', { id: item.imdbId, type: item.type }); + } + }} + activeOpacity={0.7} + > + <View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}> + <Image + source={{ uri: posterUrl }} + style={styles.poster} + contentFit="cover" + transition={300} + /> + <LinearGradient + colors={['transparent', 'rgba(0,0,0,0.85)']} + style={styles.posterGradient} + > + <Text + style={[styles.itemTitle, { color: currentTheme.colors.white }]} + numberOfLines={2} + > + {item.name} + </Text> + <Text style={styles.lastWatched}> + Last watched: {item.lastWatched} + </Text> + {item.plays > 1 && ( + <Text style={styles.playsCount}> + {item.plays} plays + </Text> + )} + </LinearGradient> + + {/* Trakt badge */} + <View style={[styles.badgeContainer, { backgroundColor: 'rgba(232,37,75,0.9)' }]}> + <TraktIcon width={12} height={12} style={{ marginRight: 4 }} /> + <Text style={[styles.badgeText, { color: currentTheme.colors.white }]}> + {item.type === 'movie' ? 'Movie' : 'Series'} + </Text> + </View> + </View> + </TouchableOpacity> + ); + }; + + const renderTraktContent = () => { + if (traktLoading) { + return <SkeletonLoader />; + } + + if (traktItems.length === 0) { + return ( + <View style={styles.emptyContainer}> + <TraktIcon width={80} height={80} style={{ opacity: 0.7, marginBottom: 16 }} /> + <Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>No watched content</Text> + <Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}> + Your Trakt watched history will appear here + </Text> + <TouchableOpacity + style={[styles.exploreButton, { + backgroundColor: currentTheme.colors.primary, + shadowColor: currentTheme.colors.black + }]} + onPress={() => { + loadWatchedItems(); + }} + activeOpacity={0.7} + > + <Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Refresh</Text> + </TouchableOpacity> + </View> + ); + } + + // Separate movies and shows + const movies = traktItems.filter(item => item.type === 'movie'); + const shows = traktItems.filter(item => item.type === 'series'); + + return ( + <ScrollView + style={styles.sectionsContainer} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.sectionsContent} + > + {movies.length > 0 && ( + <View style={styles.section}> + <View style={styles.sectionHeader}> + <MaterialIcons + name="movie" + size={24} + color={currentTheme.colors.white} + style={styles.sectionIcon} + /> + <Text style={[styles.sectionTitle, { color: currentTheme.colors.white }]}> + Movies ({movies.length}) + </Text> + </View> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalScrollContent} + > + {movies.map((item) => ( + <View key={item.id} style={{ width: itemWidth * 0.8, marginRight: 12 }}> + {renderTraktItem({ item, customWidth: itemWidth * 0.8 })} + </View> + ))} + </ScrollView> + </View> + )} + + {shows.length > 0 && ( + <View style={styles.section}> + <View style={styles.sectionHeader}> + <MaterialIcons + name="live-tv" + size={24} + color={currentTheme.colors.white} + style={styles.sectionIcon} + /> + <Text style={[styles.sectionTitle, { color: currentTheme.colors.white }]}> + TV Shows ({shows.length}) + </Text> + </View> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalScrollContent} + > + {shows.map((item) => ( + <View key={item.id} style={{ width: itemWidth * 0.8, marginRight: 12 }}> + {renderTraktItem({ item, customWidth: itemWidth * 0.8 })} + </View> + ))} + </ScrollView> + </View> + )} + </ScrollView> + ); + }; + const renderFilter = (filterType: 'all' | 'movies' | 'series', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => { const isActive = filter === filterType; + return ( <TouchableOpacity style={[ @@ -239,6 +574,71 @@ const LibraryScreen = () => { ); }; + const renderContent = () => { + if (loading) { + return <SkeletonLoader />; + } + + // Combine regular library items with Trakt folder + const allItems = []; + + // Add Trakt folder if authenticated or as connection prompt + if (traktAuthenticated || !traktAuthenticated) { + allItems.push({ type: 'trakt-folder', id: 'trakt-folder' }); + } + + // Add filtered library items + allItems.push(...filteredItems); + + if (allItems.length === 0) { + return ( + <View style={styles.emptyContainer}> + <MaterialIcons + name="video-library" + size={80} + color={currentTheme.colors.mediumGray} + style={{ opacity: 0.7 }} + /> + <Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>Your library is empty</Text> + <Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}> + Add content to your library to keep track of what you're watching + </Text> + <TouchableOpacity + style={[styles.exploreButton, { + backgroundColor: currentTheme.colors.primary, + shadowColor: currentTheme.colors.black + }]} + onPress={() => navigation.navigate('Discover')} + activeOpacity={0.7} + > + <Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Explore Content</Text> + </TouchableOpacity> + </View> + ); + } + + return ( + <FlatList + data={allItems} + renderItem={({ item }) => { + if (item.type === 'trakt-folder') { + return renderTraktFolder(); + } + return renderItem({ item: item as LibraryItem }); + }} + keyExtractor={item => item.id} + numColumns={2} + contentContainerStyle={styles.listContainer} + showsVerticalScrollIndicator={false} + columnWrapperStyle={styles.columnWrapper} + initialNumToRender={6} + maxToRenderPerBatch={6} + windowSize={5} + removeClippedSubviews={Platform.OS === 'android'} + /> + ); + }; + const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; const headerHeight = headerBaseHeight + topSpacing; @@ -252,58 +652,48 @@ const LibraryScreen = () => { {/* Header Section with proper top spacing */} <View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}> <View style={styles.headerContent}> - <Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Library</Text> + {showTraktContent ? ( + <> + <TouchableOpacity + style={styles.backButton} + onPress={() => setShowTraktContent(false)} + activeOpacity={0.7} + > + <MaterialIcons + name="arrow-back" + size={28} + color={currentTheme.colors.white} + /> + </TouchableOpacity> + <View style={styles.headerTitleContainer}> + <Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}> + Trakt Collection + </Text> + </View> + <View style={styles.headerSpacer} /> + </> + ) : ( + <Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Library</Text> + )} </View> </View> {/* Content Container */} <View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}> - <View style={styles.filtersContainer}> - {renderFilter('all', 'All', 'apps')} - {renderFilter('movies', 'Movies', 'movie')} - {renderFilter('series', 'TV Shows', 'live-tv')} - </View> - - {loading ? ( - <SkeletonLoader /> - ) : filteredItems.length === 0 ? ( - <View style={styles.emptyContainer}> - <MaterialIcons - name="video-library" - size={80} - color={currentTheme.colors.mediumGray} - style={{ opacity: 0.7 }} - /> - <Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>Your library is empty</Text> - <Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}> - Add content to your library to keep track of what you're watching - </Text> - <TouchableOpacity - style={[styles.exploreButton, { - backgroundColor: currentTheme.colors.primary, - shadowColor: currentTheme.colors.black - }]} - onPress={() => navigation.navigate('Discover')} - activeOpacity={0.7} - > - <Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Explore Content</Text> - </TouchableOpacity> - </View> - ) : ( - <FlatList - data={filteredItems} - renderItem={renderItem} - keyExtractor={item => item.id} - numColumns={2} - contentContainerStyle={styles.listContainer} - showsVerticalScrollIndicator={false} - columnWrapperStyle={styles.columnWrapper} - initialNumToRender={6} - maxToRenderPerBatch={6} - windowSize={5} - removeClippedSubviews={Platform.OS === 'android'} - /> + {!showTraktContent && ( + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.filtersContainer} + style={styles.filtersScrollView} + > + {renderFilter('all', 'All', 'apps')} + {renderFilter('movies', 'Movies', 'movie')} + {renderFilter('series', 'TV Shows', 'live-tv')} + </ScrollView> )} + + {showTraktContent ? renderTraktContent() : renderContent()} </View> </View> </View> @@ -489,7 +879,100 @@ const styles = StyleSheet.create({ exploreButtonText: { fontSize: 16, fontWeight: '600', - } + }, + playsCount: { + fontSize: 11, + color: 'rgba(255,255,255,0.6)', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + marginTop: 2, + }, + filtersScrollView: { + flexGrow: 0, + }, + folderContainer: { + borderRadius: 8, + overflow: 'hidden', + backgroundColor: 'rgba(255,255,255,0.03)', + aspectRatio: 2/3, + elevation: 5, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + }, + folderGradient: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + padding: 16, + justifyContent: 'center', + alignItems: 'center', + height: '100%', + }, + folderTitle: { + fontSize: 18, + fontWeight: '700', + marginBottom: 4, + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + letterSpacing: 0.3, + }, + folderCount: { + fontSize: 12, + color: 'rgba(255,255,255,0.7)', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + folderSubtitle: { + fontSize: 12, + color: 'rgba(255,255,255,0.7)', + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 2, + }, + backButton: { + padding: 8, + }, + sectionsContainer: { + flex: 1, + }, + sectionsContent: { + paddingBottom: 90, + }, + section: { + marginBottom: 24, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + paddingHorizontal: 16, + }, + sectionIcon: { + marginRight: 8, + }, + sectionTitle: { + fontSize: 20, + fontWeight: '700', + letterSpacing: 0.3, + }, + horizontalScrollContent: { + paddingLeft: 16, + paddingRight: 4, + }, + headerTitleContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + headerSpacer: { + width: 44, // Match the back button width + }, }); export default LibraryScreen; \ No newline at end of file diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index ff8ca01..712dea1 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -235,6 +235,11 @@ const SearchScreen = () => { useEffect(() => { loadRecentSearches(); + + // Cleanup function to cancel pending searches on unmount + return () => { + debouncedSearch.cancel(); + }; }, []); const animatedSearchBarStyle = useAnimatedStyle(() => { @@ -299,13 +304,17 @@ const SearchScreen = () => { const saveRecentSearch = async (searchQuery: string) => { try { - const newRecentSearches = [ - searchQuery, - ...recentSearches.filter(s => s !== searchQuery) - ].slice(0, MAX_RECENT_SEARCHES); - - setRecentSearches(newRecentSearches); - await AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); + setRecentSearches(prevSearches => { + const newRecentSearches = [ + searchQuery, + ...prevSearches.filter(s => s !== searchQuery) + ].slice(0, MAX_RECENT_SEARCHES); + + // Save to AsyncStorage + AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); + + return newRecentSearches; + }); } catch (error) { logger.error('Failed to save recent search:', error); } @@ -334,7 +343,7 @@ const SearchScreen = () => { setSearching(false); } }, 800), - [recentSearches] + [] ); useEffect(() => { -- 2.45.2 From 235a7eff24fccd5e2c7a5b6a6180cfd7e348c204 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Thu, 19 Jun 2025 21:39:47 +0530 Subject: [PATCH 58/88] Implement Trakt autosync functionality across video player components This update integrates Trakt autosync capabilities into the AndroidVideoPlayer and VideoPlayer components, allowing for automatic syncing of watch progress and playback events. Key features include starting a watching session, updating progress during playback, and handling playback end events to ensure accurate tracking. Additionally, the useTraktIntegration and useTraktAutosync hooks have been enhanced to support these functionalities, improving the overall user experience by maintaining consistent watch history across devices. --- src/components/metadata/SeriesContent.tsx | 8 +- src/components/player/AndroidVideoPlayer.tsx | 33 +- src/components/player/VideoPlayer.tsx | 35 +- src/hooks/useMetadataAnimations.ts | 8 +- src/hooks/useTraktAutosync.ts | 258 ++++++++ src/hooks/useTraktAutosyncSettings.ts | 159 +++++ src/hooks/useTraktIntegration.ts | 193 +++++- src/screens/HomeScreen.tsx | 2 +- src/screens/LibraryScreen.tsx | 66 +- src/screens/SearchScreen.tsx | 8 +- src/screens/StreamsScreen.tsx | 2 +- src/screens/TraktSettingsScreen.tsx | 137 +++- src/services/storageService.ts | 127 ++++ src/services/traktService.ts | 657 ++++++++++++++++++- 14 files changed, 1623 insertions(+), 70 deletions(-) create mode 100644 src/hooks/useTraktAutosync.ts create mode 100644 src/hooks/useTraktAutosyncSettings.ts diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index d93b529..a8ee469 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -399,7 +399,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ <View style={styles.episodeContent}> {/* Episode Number Badge */} <View style={styles.episodeNumberBadgeHorizontal}> - <Text style={styles.episodeNumberHorizontal}>{episodeString}</Text> + <Text style={styles.episodeNumberHorizontal}>{episodeString}</Text> </View> {/* Episode Title */} @@ -416,9 +416,9 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ <View style={styles.episodeMetadataRowHorizontal}> {episode.runtime && ( <View style={styles.runtimeContainerHorizontal}> - <Text style={styles.runtimeTextHorizontal}> - {formatRuntime(episode.runtime)} - </Text> + <Text style={styles.runtimeTextHorizontal}> + {formatRuntime(episode.runtime)} + </Text> </View> )} {episode.vote_average > 0 && ( diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index b1d89e5..dc3f16d 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -10,6 +10,7 @@ import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; +import { useTraktAutosync } from '../../hooks/useTraktAutosync'; import { DEFAULT_SUBTITLE_SIZE, @@ -63,6 +64,21 @@ const AndroidVideoPlayer: React.FC = () => { availableStreams: passedAvailableStreams } = route.params; + // Initialize Trakt autosync + const traktAutosync = useTraktAutosync({ + id: id || '', + type: type === 'series' ? 'series' : 'movie', + title: episodeTitle || title, + year: year || 0, + imdbId: imdbId || '', + season: season, + episode: episode, + showTitle: title, + showYear: year, + showImdbId: imdbId, + episodeId: episodeId + }); + safeDebugLog("Android Component mounted with props", { uri, title, season, episode, episodeTitle, quality, year, streamProvider, id, type, episodeId, imdbId @@ -276,6 +292,9 @@ const AndroidVideoPlayer: React.FC = () => { }; try { await storageService.setWatchProgress(id, type, progress, episodeId); + + // Sync to Trakt if authenticated + await traktAutosync.handleProgressUpdate(currentTime, duration); } catch (error) { logger.error('[AndroidVideoPlayer] Error saving watch progress:', error); } @@ -302,6 +321,8 @@ const AndroidVideoPlayer: React.FC = () => { return () => { if (id && type && duration > 0) { saveWatchProgress(); + // Final Trakt sync on component unmount + traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount'); } }; }, [id, type, currentTime, duration]); @@ -431,6 +452,9 @@ const AndroidVideoPlayer: React.FC = () => { setIsVideoLoaded(true); setIsPlayerReady(true); + // Start Trakt watching session when video loads + traktAutosync.handlePlaybackStart(currentTime, data.duration || duration); + if (initialPosition && !isInitialSeekComplete) { setTimeout(() => { if (videoRef.current && duration > 0 && isMounted.current) { @@ -484,6 +508,12 @@ const AndroidVideoPlayer: React.FC = () => { }; const handleClose = () => { + logger.log('[AndroidVideoPlayer] Close button pressed - syncing to Trakt before closing'); + logger.log(`[AndroidVideoPlayer] Current progress: ${currentTime}/${duration} (${duration > 0 ? ((currentTime / duration) * 100).toFixed(1) : 0}%)`); + + // Sync progress to Trakt before closing + traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount'); + // Start exit animation Animated.parallel([ Animated.timing(fadeAnim, { @@ -597,7 +627,8 @@ const AndroidVideoPlayer: React.FC = () => { }; const onEnd = () => { - // End logic here + // Sync final progress to Trakt + traktAutosync.handlePlaybackEnd(currentTime, duration, 'ended'); }; const selectAudioTrack = (trackId: number) => { diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index f4d29eb..feeb559 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -11,6 +11,7 @@ import { logger } from '../../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; import AndroidVideoPlayer from './AndroidVideoPlayer'; +import { useTraktAutosync } from '../../hooks/useTraktAutosync'; import { DEFAULT_SUBTITLE_SIZE, @@ -58,6 +59,21 @@ const VideoPlayer: React.FC = () => { availableStreams: passedAvailableStreams } = route.params; + // Initialize Trakt autosync + const traktAutosync = useTraktAutosync({ + id: id || '', + type: type === 'series' ? 'series' : 'movie', + title: episodeTitle || title, + year: year || 0, + imdbId: imdbId || '', + season: season, + episode: episode, + showTitle: title, + showYear: year, + showImdbId: imdbId, + episodeId: episodeId + }); + safeDebugLog("Component mounted with props", { uri, title, season, episode, episodeTitle, quality, year, streamProvider, id, type, episodeId, imdbId @@ -271,6 +287,9 @@ const VideoPlayer: React.FC = () => { }; try { await storageService.setWatchProgress(id, type, progress, episodeId); + + // Sync to Trakt if authenticated + await traktAutosync.handleProgressUpdate(currentTime, duration); } catch (error) { logger.error('[VideoPlayer] Error saving watch progress:', error); } @@ -297,6 +316,8 @@ const VideoPlayer: React.FC = () => { return () => { if (id && type && duration > 0) { saveWatchProgress(); + // Final Trakt sync on component unmount + traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount'); } }; }, [id, type, currentTime, duration]); @@ -304,6 +325,11 @@ const VideoPlayer: React.FC = () => { const onPlaying = () => { if (isMounted.current && !isSeeking.current) { setPaused(false); + + // Start Trakt watching session only if duration is loaded + if (duration > 0) { + traktAutosync.handlePlaybackStart(currentTime, duration); + } } }; @@ -490,6 +516,12 @@ const VideoPlayer: React.FC = () => { }; const handleClose = () => { + logger.log('[VideoPlayer] Close button pressed - syncing to Trakt before closing'); + logger.log(`[VideoPlayer] Current progress: ${currentTime}/${duration} (${duration > 0 ? ((currentTime / duration) * 100).toFixed(1) : 0}%)`); + + // Sync progress to Trakt before closing + traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount'); + // Start exit animation Animated.parallel([ Animated.timing(fadeAnim, { @@ -603,7 +635,8 @@ const VideoPlayer: React.FC = () => { }; const onEnd = () => { - // End logic here + // Sync final progress to Trakt + traktAutosync.handlePlaybackEnd(currentTime, duration, 'ended'); }; const selectAudioTrack = (trackId: number) => { diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts index eef7672..89f0d6d 100644 --- a/src/hooks/useMetadataAnimations.ts +++ b/src/hooks/useMetadataAnimations.ts @@ -97,10 +97,10 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) = const updateProgress = () => { 'worklet'; - progressOpacity.value = withTiming(hasProgress ? 1 : 0, { - duration: hasProgress ? 200 : 150, - easing: easings.fast - }); + progressOpacity.value = withTiming(hasProgress ? 1 : 0, { + duration: hasProgress ? 200 : 150, + easing: easings.fast + }); }; runOnUI(updateProgress)(); diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts new file mode 100644 index 0000000..a389359 --- /dev/null +++ b/src/hooks/useTraktAutosync.ts @@ -0,0 +1,258 @@ +import { useCallback, useRef, useEffect } from 'react'; +import { useTraktIntegration } from './useTraktIntegration'; +import { useTraktAutosyncSettings } from './useTraktAutosyncSettings'; +import { TraktContentData } from '../services/traktService'; +import { storageService } from '../services/storageService'; +import { logger } from '../utils/logger'; + +interface TraktAutosyncOptions { + id: string; + type: 'movie' | 'series'; + title: string; + year: number | string; // Allow both for compatibility + imdbId: string; + // For episodes + season?: number; + episode?: number; + showTitle?: string; + showYear?: number | string; // Allow both for compatibility + showImdbId?: string; + episodeId?: string; +} + +export function useTraktAutosync(options: TraktAutosyncOptions) { + const { + isAuthenticated, + startWatching, + updateProgress, + stopWatching + } = useTraktIntegration(); + + const { settings: autosyncSettings } = useTraktAutosyncSettings(); + + const hasStartedWatching = useRef(false); + const lastSyncTime = useRef(0); + const lastSyncProgress = useRef(0); + const sessionKey = useRef<string | null>(null); + const unmountCount = useRef(0); + + // Generate a unique session key for this content instance + useEffect(() => { + const contentKey = options.type === 'movie' + ? `movie:${options.imdbId}` + : `episode:${options.imdbId}:${options.season}:${options.episode}`; + sessionKey.current = `${contentKey}:${Date.now()}`; + + logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`); + + return () => { + unmountCount.current++; + logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`); + }; + }, [options.imdbId, options.season, options.episode, options.type]); + + // Build Trakt content data from options + const buildContentData = useCallback((): TraktContentData => { + // Ensure year is a number and valid + const parseYear = (year: number | string | undefined): number => { + if (!year) return 0; + if (typeof year === 'number') return year; + const parsed = parseInt(year.toString(), 10); + return isNaN(parsed) ? 0 : parsed; + }; + + const numericYear = parseYear(options.year); + const numericShowYear = parseYear(options.showYear); + + // Validate required fields + if (!options.title || !options.imdbId) { + logger.warn('[TraktAutosync] Missing required fields:', { title: options.title, imdbId: options.imdbId }); + } + + if (options.type === 'movie') { + return { + type: 'movie', + imdbId: options.imdbId, + title: options.title, + year: numericYear + }; + } else { + return { + type: 'episode', + imdbId: options.imdbId, + title: options.title, + year: numericYear, + season: options.season, + episode: options.episode, + showTitle: options.showTitle || options.title, + showYear: numericShowYear || numericYear, + showImdbId: options.showImdbId || options.imdbId + }; + } + }, [options]); + + // Start watching (scrobble start) + const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => { + logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, session=${sessionKey.current}`); + + if (!isAuthenticated || !autosyncSettings.enabled || hasStartedWatching.current) { + logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}`); + return; + } + + if (duration <= 0) { + logger.log(`[TraktAutosync] Skipping handlePlaybackStart: invalid duration (${duration})`); + return; + } + + try { + const progressPercent = (currentTime / duration) * 100; + const contentData = buildContentData(); + + const success = await startWatching(contentData, progressPercent); + if (success) { + hasStartedWatching.current = true; + logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`); + } + } catch (error) { + logger.error('[TraktAutosync] Error starting watch:', error); + } + }, [isAuthenticated, autosyncSettings.enabled, startWatching, buildContentData]); + + // Sync progress during playback + const handleProgressUpdate = useCallback(async ( + currentTime: number, + duration: number, + force: boolean = false + ) => { + if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) { + return; + } + + try { + const progressPercent = (currentTime / duration) * 100; + const now = Date.now(); + + // Use the user's configured sync frequency + const timeSinceLastSync = now - lastSyncTime.current; + const progressDiff = Math.abs(progressPercent - lastSyncProgress.current); + + if (!force && timeSinceLastSync < autosyncSettings.syncFrequency && progressDiff < 5) { + return; + } + + const contentData = buildContentData(); + const success = await updateProgress(contentData, progressPercent, force); + + if (success) { + lastSyncTime.current = now; + lastSyncProgress.current = progressPercent; + + // Update local storage sync status + await storageService.updateTraktSyncStatus( + options.id, + options.type, + true, + progressPercent, + options.episodeId + ); + + logger.log(`[TraktAutosync] Synced progress ${progressPercent.toFixed(1)}%: ${contentData.title}`); + } + } catch (error) { + logger.error('[TraktAutosync] Error syncing progress:', error); + } + }, [isAuthenticated, autosyncSettings.enabled, autosyncSettings.syncFrequency, updateProgress, buildContentData, options]); + + // Handle playback end/pause + const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' = 'ended') => { + logger.log(`[TraktAutosync] handlePlaybackEnd called: reason=${reason}, time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, started=${hasStartedWatching.current}, session=${sessionKey.current}, unmountCount=${unmountCount.current}`); + + if (!isAuthenticated || !autosyncSettings.enabled) { + logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`); + return; + } + + // Skip rapid unmount calls (likely from React strict mode or component remounts) + if (reason === 'unmount' && unmountCount.current > 1) { + logger.log(`[TraktAutosync] Skipping duplicate unmount call #${unmountCount.current}`); + return; + } + + try { + let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; + logger.log(`[TraktAutosync] Initial progress calculation: ${progressPercent.toFixed(1)}%`); + + // If progress is 0 during unmount, use the last synced progress instead + // This happens when video player state is reset before component unmount + if (reason === 'unmount' && progressPercent < 1 && lastSyncProgress.current > 0) { + progressPercent = lastSyncProgress.current; + logger.log(`[TraktAutosync] Using last synced progress for unmount: ${progressPercent.toFixed(1)}%`); + } + + // If we have valid progress but no started session, force start one first + if (!hasStartedWatching.current && progressPercent > 1) { + logger.log(`[TraktAutosync] Force starting session for progress: ${progressPercent.toFixed(1)}%`); + const contentData = buildContentData(); + const success = await startWatching(contentData, progressPercent); + if (success) { + hasStartedWatching.current = true; + logger.log(`[TraktAutosync] Force started watching: ${contentData.title}`); + } + } + + // Only stop if we have meaningful progress (>= 1%) or it's a natural video end + // Skip unmount calls with very low progress unless video actually ended + if (reason === 'unmount' && progressPercent < 1) { + logger.log(`[TraktAutosync] Skipping unmount stop for ${options.title} - too early (${progressPercent.toFixed(1)}%)`); + return; + } + + const contentData = buildContentData(); + + // Use stopWatching for proper scrobble stop + const success = await stopWatching(contentData, progressPercent); + + if (success) { + // Update local storage sync status + await storageService.updateTraktSyncStatus( + options.id, + options.type, + true, + progressPercent, + options.episodeId + ); + } + + // Reset state only for natural end or very high progress unmounts + if (reason === 'ended' || progressPercent >= 80) { + hasStartedWatching.current = false; + lastSyncTime.current = 0; + lastSyncProgress.current = 0; + logger.log(`[TraktAutosync] Reset session state for ${reason} at ${progressPercent.toFixed(1)}%`); + } + + logger.log(`[TraktAutosync] Ended watching: ${options.title} (${reason})`); + } catch (error) { + logger.error('[TraktAutosync] Error ending watch:', error); + } + }, [isAuthenticated, autosyncSettings.enabled, stopWatching, buildContentData, options]); + + // Reset state (useful when switching content) + const resetState = useCallback(() => { + hasStartedWatching.current = false; + lastSyncTime.current = 0; + lastSyncProgress.current = 0; + unmountCount.current = 0; + sessionKey.current = null; + logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`); + }, [options.title]); + + return { + isAuthenticated, + handlePlaybackStart, + handleProgressUpdate, + handlePlaybackEnd, + resetState + }; +} \ No newline at end of file diff --git a/src/hooks/useTraktAutosyncSettings.ts b/src/hooks/useTraktAutosyncSettings.ts new file mode 100644 index 0000000..9bcac08 --- /dev/null +++ b/src/hooks/useTraktAutosyncSettings.ts @@ -0,0 +1,159 @@ +import { useState, useEffect, useCallback } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useTraktIntegration } from './useTraktIntegration'; +import { logger } from '../utils/logger'; + +const TRAKT_AUTOSYNC_ENABLED_KEY = '@trakt_autosync_enabled'; +const TRAKT_SYNC_FREQUENCY_KEY = '@trakt_sync_frequency'; +const TRAKT_COMPLETION_THRESHOLD_KEY = '@trakt_completion_threshold'; + +export interface TraktAutosyncSettings { + enabled: boolean; + syncFrequency: number; // in milliseconds + completionThreshold: number; // percentage (80-95) +} + +const DEFAULT_SETTINGS: TraktAutosyncSettings = { + enabled: true, + syncFrequency: 60000, // 60 seconds + completionThreshold: 95, // 95% +}; + +export function useTraktAutosyncSettings() { + const { + isAuthenticated, + syncAllProgress, + fetchAndMergeTraktProgress + } = useTraktIntegration(); + + const [settings, setSettings] = useState<TraktAutosyncSettings>(DEFAULT_SETTINGS); + const [isLoading, setIsLoading] = useState(true); + const [isSyncing, setIsSyncing] = useState(false); + + // Load settings from storage + const loadSettings = useCallback(async () => { + try { + setIsLoading(true); + const [enabled, frequency, threshold] = await Promise.all([ + AsyncStorage.getItem(TRAKT_AUTOSYNC_ENABLED_KEY), + AsyncStorage.getItem(TRAKT_SYNC_FREQUENCY_KEY), + AsyncStorage.getItem(TRAKT_COMPLETION_THRESHOLD_KEY) + ]); + + setSettings({ + enabled: enabled !== null ? JSON.parse(enabled) : DEFAULT_SETTINGS.enabled, + syncFrequency: frequency ? parseInt(frequency, 10) : DEFAULT_SETTINGS.syncFrequency, + completionThreshold: threshold ? parseInt(threshold, 10) : DEFAULT_SETTINGS.completionThreshold, + }); + } catch (error) { + logger.error('[useTraktAutosyncSettings] Error loading settings:', error); + setSettings(DEFAULT_SETTINGS); + } finally { + setIsLoading(false); + } + }, []); + + // Save individual setting + const saveSetting = useCallback(async (key: string, value: any) => { + try { + await AsyncStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + logger.error('[useTraktAutosyncSettings] Error saving setting:', error); + } + }, []); + + // Update autosync enabled status + const setAutosyncEnabled = useCallback(async (enabled: boolean) => { + try { + await saveSetting(TRAKT_AUTOSYNC_ENABLED_KEY, enabled); + setSettings(prev => ({ ...prev, enabled })); + logger.log(`[useTraktAutosyncSettings] Autosync ${enabled ? 'enabled' : 'disabled'}`); + } catch (error) { + logger.error('[useTraktAutosyncSettings] Error updating autosync enabled:', error); + } + }, [saveSetting]); + + // Update sync frequency + const setSyncFrequency = useCallback(async (frequency: number) => { + try { + await saveSetting(TRAKT_SYNC_FREQUENCY_KEY, frequency); + setSettings(prev => ({ ...prev, syncFrequency: frequency })); + logger.log(`[useTraktAutosyncSettings] Sync frequency updated to ${frequency}ms`); + } catch (error) { + logger.error('[useTraktAutosyncSettings] Error updating sync frequency:', error); + } + }, [saveSetting]); + + // Update completion threshold + const setCompletionThreshold = useCallback(async (threshold: number) => { + try { + await saveSetting(TRAKT_COMPLETION_THRESHOLD_KEY, threshold); + setSettings(prev => ({ ...prev, completionThreshold: threshold })); + logger.log(`[useTraktAutosyncSettings] Completion threshold updated to ${threshold}%`); + } catch (error) { + logger.error('[useTraktAutosyncSettings] Error updating completion threshold:', error); + } + }, [saveSetting]); + + // Manual sync all progress + const performManualSync = useCallback(async (): Promise<boolean> => { + if (!isAuthenticated) { + logger.warn('[useTraktAutosyncSettings] Cannot sync: not authenticated'); + return false; + } + + try { + setIsSyncing(true); + logger.log('[useTraktAutosyncSettings] Starting manual sync...'); + + // First, fetch and merge Trakt progress with local + await fetchAndMergeTraktProgress(); + + // Then, sync any unsynced local progress to Trakt + const success = await syncAllProgress(); + + logger.log(`[useTraktAutosyncSettings] Manual sync ${success ? 'completed' : 'failed'}`); + return success; + } catch (error) { + logger.error('[useTraktAutosyncSettings] Error during manual sync:', error); + return false; + } finally { + setIsSyncing(false); + } + }, [isAuthenticated, syncAllProgress, fetchAndMergeTraktProgress]); + + // Get formatted sync frequency options + const getSyncFrequencyOptions = useCallback(() => [ + { label: 'Every 30 seconds', value: 30000 }, + { label: 'Every minute', value: 60000 }, + { label: 'Every 2 minutes', value: 120000 }, + { label: 'Every 5 minutes', value: 300000 }, + ], []); + + // Get formatted completion threshold options + const getCompletionThresholdOptions = useCallback(() => [ + { label: '80% complete', value: 80 }, + { label: '85% complete', value: 85 }, + { label: '90% complete', value: 90 }, + { label: '95% complete', value: 95 }, + ], []); + + // Load settings on mount + useEffect(() => { + loadSettings(); + }, [loadSettings]); + + return { + settings, + isLoading, + isSyncing, + isAuthenticated, + setAutosyncEnabled, + setSyncFrequency, + setCompletionThreshold, + performManualSync, + getSyncFrequencyOptions, + getCompletionThresholdOptions, + loadSettings + }; +} \ No newline at end of file diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index 692cdaa..027205c 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; -import { traktService, TraktUser, TraktWatchedItem } from '../services/traktService'; +import { traktService, TraktUser, TraktWatchedItem, TraktContentData, TraktPlaybackItem } from '../services/traktService'; +import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; export function useTraktIntegration() { @@ -128,6 +129,179 @@ export function useTraktIntegration() { } }, [isAuthenticated, loadWatchedItems]); + // Start watching content (scrobble start) + const startWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise<boolean> => { + if (!isAuthenticated) return false; + + try { + return await traktService.scrobbleStart(contentData, progress); + } catch (error) { + logger.error('[useTraktIntegration] Error starting watch:', error); + return false; + } + }, [isAuthenticated]); + + // Update progress while watching (scrobble pause) + const updateProgress = useCallback(async ( + contentData: TraktContentData, + progress: number, + force: boolean = false + ): Promise<boolean> => { + if (!isAuthenticated) return false; + + try { + return await traktService.scrobblePause(contentData, progress, force); + } catch (error) { + logger.error('[useTraktIntegration] Error updating progress:', error); + return false; + } + }, [isAuthenticated]); + + // Stop watching content (scrobble stop) + const stopWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise<boolean> => { + if (!isAuthenticated) return false; + + try { + return await traktService.scrobbleStop(contentData, progress); + } catch (error) { + logger.error('[useTraktIntegration] Error stopping watch:', error); + return false; + } + }, [isAuthenticated]); + + // Sync progress to Trakt (legacy method) + const syncProgress = useCallback(async ( + contentData: TraktContentData, + progress: number, + force: boolean = false + ): Promise<boolean> => { + if (!isAuthenticated) return false; + + try { + return await traktService.syncProgressToTrakt(contentData, progress, force); + } catch (error) { + logger.error('[useTraktIntegration] Error syncing progress:', error); + return false; + } + }, [isAuthenticated]); + + // Get playback progress from Trakt + const getTraktPlaybackProgress = useCallback(async (type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> => { + if (!isAuthenticated) return []; + + try { + return await traktService.getPlaybackProgress(type); + } catch (error) { + logger.error('[useTraktIntegration] Error getting playback progress:', error); + return []; + } + }, [isAuthenticated]); + + // Sync all local progress to Trakt + const syncAllProgress = useCallback(async (): Promise<boolean> => { + if (!isAuthenticated) return false; + + try { + const unsyncedProgress = await storageService.getUnsyncedProgress(); + logger.log(`[useTraktIntegration] Found ${unsyncedProgress.length} unsynced progress entries`); + + let syncedCount = 0; + const batchSize = 5; // Process in smaller batches + const delayBetweenBatches = 2000; // 2 seconds between batches + + // Process items in batches to avoid overwhelming the API + for (let i = 0; i < unsyncedProgress.length; i += batchSize) { + const batch = unsyncedProgress.slice(i, i + batchSize); + + // Process batch items with individual error handling + const batchPromises = batch.map(async (item) => { + try { + // Build content data from stored progress + const contentData: TraktContentData = { + type: item.type as 'movie' | 'episode', + imdbId: item.id, + title: 'Unknown', // We don't store title in progress, this would need metadata lookup + year: 0, + season: item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined, + episode: item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined + }; + + const progressPercent = (item.progress.currentTime / item.progress.duration) * 100; + + const success = await traktService.syncProgressToTrakt(contentData, progressPercent, true); + if (success) { + await storageService.updateTraktSyncStatus(item.id, item.type, true, progressPercent, item.episodeId); + return true; + } + return false; + } catch (error) { + logger.error('[useTraktIntegration] Error syncing individual progress:', error); + return false; + } + }); + + // Wait for batch to complete + const batchResults = await Promise.all(batchPromises); + syncedCount += batchResults.filter(result => result).length; + + // Delay between batches to avoid rate limiting + if (i + batchSize < unsyncedProgress.length) { + await new Promise(resolve => setTimeout(resolve, delayBetweenBatches)); + } + } + + logger.log(`[useTraktIntegration] Synced ${syncedCount}/${unsyncedProgress.length} progress entries`); + return syncedCount > 0; + } catch (error) { + logger.error('[useTraktIntegration] Error syncing all progress:', error); + return false; + } + }, [isAuthenticated]); + + // Fetch and merge Trakt progress with local progress + const fetchAndMergeTraktProgress = useCallback(async (): Promise<boolean> => { + if (!isAuthenticated) return false; + + try { + const traktProgress = await getTraktPlaybackProgress(); + + for (const item of traktProgress) { + try { + let id: string; + let type: string; + let episodeId: string | undefined; + + if (item.type === 'movie' && item.movie) { + id = item.movie.ids.imdb; + type = 'movie'; + } else if (item.type === 'episode' && item.show && item.episode) { + id = item.show.ids.imdb; + type = 'series'; + episodeId = `S${item.episode.season}E${item.episode.number}`; + } else { + continue; + } + + await storageService.mergeWithTraktProgress( + id, + type, + item.progress, + item.paused_at, + episodeId + ); + } catch (error) { + logger.error('[useTraktIntegration] Error merging individual Trakt progress:', error); + } + } + + logger.log(`[useTraktIntegration] Merged ${traktProgress.length} Trakt progress entries`); + return true; + } catch (error) { + logger.error('[useTraktIntegration] Error fetching and merging Trakt progress:', error); + return false; + } + }, [isAuthenticated, getTraktPlaybackProgress]); + // Initialize and check auth status useEffect(() => { checkAuthStatus(); @@ -140,6 +314,14 @@ export function useTraktIntegration() { } }, [isAuthenticated, loadWatchedItems]); + // Auto-sync when authenticated changes + useEffect(() => { + if (isAuthenticated) { + // Fetch Trakt progress and merge with local + fetchAndMergeTraktProgress(); + } + }, [isAuthenticated, fetchAndMergeTraktProgress]); + return { isAuthenticated, isLoading, @@ -152,6 +334,13 @@ export function useTraktIntegration() { isEpisodeWatched, markMovieAsWatched, markEpisodeAsWatched, - refreshAuthStatus + refreshAuthStatus, + startWatching, + updateProgress, + stopWatching, + syncProgress, // legacy + getTraktPlaybackProgress, + syncAllProgress, + fetchAndMergeTraktProgress }; } \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 2159ed6..4c63eb4 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -637,7 +637,7 @@ const HomeScreen = () => { handleSaveToLibrary, hasContinueWatching, catalogs, - catalogsLoading, + catalogsLoading, navigation, featuredContentSource ]); diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index b4cef61..50eb5aa 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -592,33 +592,33 @@ const LibraryScreen = () => { if (allItems.length === 0) { return ( - <View style={styles.emptyContainer}> - <MaterialIcons - name="video-library" - size={80} - color={currentTheme.colors.mediumGray} - style={{ opacity: 0.7 }} - /> - <Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>Your library is empty</Text> - <Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}> - Add content to your library to keep track of what you're watching - </Text> - <TouchableOpacity - style={[styles.exploreButton, { - backgroundColor: currentTheme.colors.primary, - shadowColor: currentTheme.colors.black - }]} - onPress={() => navigation.navigate('Discover')} - activeOpacity={0.7} - > - <Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Explore Content</Text> - </TouchableOpacity> - </View> + <View style={styles.emptyContainer}> + <MaterialIcons + name="video-library" + size={80} + color={currentTheme.colors.mediumGray} + style={{ opacity: 0.7 }} + /> + <Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>Your library is empty</Text> + <Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}> + Add content to your library to keep track of what you're watching + </Text> + <TouchableOpacity + style={[styles.exploreButton, { + backgroundColor: currentTheme.colors.primary, + shadowColor: currentTheme.colors.black + }]} + onPress={() => navigation.navigate('Discover')} + activeOpacity={0.7} + > + <Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Explore Content</Text> + </TouchableOpacity> + </View> ); } return ( - <FlatList + <FlatList data={allItems} renderItem={({ item }) => { if (item.type === 'trakt-folder') { @@ -626,16 +626,16 @@ const LibraryScreen = () => { } return renderItem({ item: item as LibraryItem }); }} - keyExtractor={item => item.id} - numColumns={2} - contentContainerStyle={styles.listContainer} - showsVerticalScrollIndicator={false} - columnWrapperStyle={styles.columnWrapper} - initialNumToRender={6} - maxToRenderPerBatch={6} - windowSize={5} - removeClippedSubviews={Platform.OS === 'android'} - /> + keyExtractor={item => item.id} + numColumns={2} + contentContainerStyle={styles.listContainer} + showsVerticalScrollIndicator={false} + columnWrapperStyle={styles.columnWrapper} + initialNumToRender={6} + maxToRenderPerBatch={6} + windowSize={5} + removeClippedSubviews={Platform.OS === 'android'} + /> ); }; diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 712dea1..2d57e65 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -305,11 +305,11 @@ 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 AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 941500d..70d7475 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -689,7 +689,7 @@ export const StreamsScreen = () => { if (!success) { console.log('VideoPlayerService failed, falling back to built-in player'); - navigateToPlayer(stream); + navigateToPlayer(stream); } } } catch (error) { diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index ad214ee..d9f9e47 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -11,6 +11,8 @@ import { ScrollView, StatusBar, Platform, + Linking, + Switch, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session'; @@ -20,6 +22,9 @@ import { useSettings } from '../hooks/useSettings'; import { logger } from '../utils/logger'; import TraktIcon from '../../assets/rating-icons/trakt.svg'; import { useTheme } from '../contexts/ThemeContext'; +import { useTraktIntegration } from '../hooks/useTraktIntegration'; +import { useTraktAutosyncSettings } from '../hooks/useTraktAutosyncSettings'; +import { colors } from '../styles'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; @@ -44,6 +49,21 @@ const TraktSettingsScreen: React.FC = () => { const [isAuthenticated, setIsAuthenticated] = useState(false); const [userProfile, setUserProfile] = useState<TraktUser | null>(null); const { currentTheme } = useTheme(); + + const { + settings: autosyncSettings, + isSyncing, + setAutosyncEnabled, + performManualSync + } = useTraktAutosyncSettings(); + + const { + isLoading: traktLoading, + refreshAuthStatus + } = useTraktIntegration(); + + const [showSyncFrequencyModal, setShowSyncFrequencyModal] = useState(false); + const [showThresholdModal, setShowThresholdModal] = useState(false); const checkAuthStatus = useCallback(async () => { setIsLoading(true); @@ -180,7 +200,7 @@ const TraktSettingsScreen: React.FC = () => { </TouchableOpacity> <Text style={[ styles.headerTitle, - { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark } + { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark} ]}> Trakt Settings </Text> @@ -308,18 +328,31 @@ const TraktSettingsScreen: React.FC = () => { Sync Settings </Text> <View style={styles.settingItem}> - <Text style={[ - styles.settingLabel, - { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark } - ]}> - Auto-sync playback progress - </Text> - <Text style={[ - styles.settingDescription, - { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark } - ]}> - Coming soon - </Text> + <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}> + <View style={{ flex: 1 }}> + <Text style={[ + styles.settingLabel, + { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark } + ]}> + Auto-sync playback progress + </Text> + <Text style={[ + styles.settingDescription, + { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark } + ]}> + Automatically sync watch progress to Trakt + </Text> + </View> + <Switch + value={autosyncSettings.enabled} + onValueChange={setAutosyncEnabled} + trackColor={{ + false: isDarkMode ? 'rgba(120,120,128,0.3)' : 'rgba(120,120,128,0.2)', + true: currentTheme.colors.primary + '80' + }} + thumbColor={autosyncSettings.enabled ? currentTheme.colors.primary : (isDarkMode ? '#ffffff' : '#f4f3f4')} + /> + </View> </View> <View style={styles.settingItem}> <Text style={[ @@ -338,15 +371,85 @@ const TraktSettingsScreen: React.FC = () => { <TouchableOpacity style={[ styles.button, - { backgroundColor: isDarkMode ? 'rgba(120,120,128,0.2)' : 'rgba(120,120,128,0.1)' } + { + backgroundColor: isDarkMode ? currentTheme.colors.primary + '40' : currentTheme.colors.primary + '20', + opacity: isSyncing ? 0.6 : 1 + } ]} - disabled={true} + disabled={isSyncing} + onPress={async () => { + const success = await performManualSync(); + Alert.alert( + 'Sync Complete', + success ? 'Successfully synced your watch progress with Trakt.' : 'Sync failed. Please try again.', + [{ text: 'OK' }] + ); + }} + > + {isSyncing ? ( + <ActivityIndicator + size="small" + color={isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary} + /> + ) : ( + <Text style={[ + styles.buttonText, + { color: isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary } + ]}> + Sync Now + </Text> + )} + </TouchableOpacity> + + <TouchableOpacity + style={[ + styles.button, + { + backgroundColor: isDarkMode ? '#FF6B35' + '40' : '#FF6B35' + '20', + marginTop: 8 + } + ]} + onPress={async () => { + await traktService.debugPlaybackProgress(); + Alert.alert( + 'Debug Complete', + 'Check the app logs for current Trakt playback progress. Look for lines starting with "[TraktService] DEBUG".', + [{ text: 'OK' }] + ); + }} > <Text style={[ styles.buttonText, - { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark } + { color: '#FF6B35' } ]}> - Sync Now (Coming Soon) + Debug Trakt Progress + </Text> + </TouchableOpacity> + + <TouchableOpacity + style={[ + styles.button, + { + backgroundColor: isDarkMode ? '#9B59B6' + '40' : '#9B59B6' + '20', + marginTop: 8 + } + ]} + onPress={async () => { + const result = await traktService.debugTraktConnection(); + Alert.alert( + 'Connection Test', + result.authenticated + ? `Connection successful! User: ${result.user?.username || 'Unknown'}` + : `Connection failed: ${result.error}`, + [{ text: 'OK' }] + ); + }} + > + <Text style={[ + styles.buttonText, + { color: '#9B59B6' } + ]}> + Test API Connection </Text> </TouchableOpacity> </View> diff --git a/src/services/storageService.ts b/src/services/storageService.ts index cab6882..3bad143 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -5,6 +5,9 @@ interface WatchProgress { currentTime: number; duration: number; lastUpdated: number; + traktSynced?: boolean; + traktLastSynced?: number; + traktProgress?: number; } class StorageService { @@ -103,6 +106,130 @@ class StorageService { return {}; } } + + /** + * Update Trakt sync status for a watch progress entry + */ + public async updateTraktSyncStatus( + id: string, + type: string, + traktSynced: boolean, + traktProgress?: number, + episodeId?: string + ): Promise<void> { + try { + const existingProgress = await this.getWatchProgress(id, type, episodeId); + if (existingProgress) { + const updatedProgress: WatchProgress = { + ...existingProgress, + traktSynced, + traktLastSynced: traktSynced ? Date.now() : existingProgress.traktLastSynced, + traktProgress: traktProgress !== undefined ? traktProgress : existingProgress.traktProgress + }; + await this.setWatchProgress(id, type, updatedProgress, episodeId); + } + } catch (error) { + logger.error('Error updating Trakt sync status:', error); + } + } + + /** + * Get all watch progress entries that need Trakt sync + */ + public async getUnsyncedProgress(): Promise<Array<{ + key: string; + id: string; + type: string; + episodeId?: string; + progress: WatchProgress; + }>> { + try { + const allProgress = await this.getAllWatchProgress(); + const unsynced: Array<{ + key: string; + id: string; + type: string; + episodeId?: string; + progress: WatchProgress; + }> = []; + + for (const [key, progress] of Object.entries(allProgress)) { + // Check if needs sync (either never synced or local progress is newer) + const needsSync = !progress.traktSynced || + (progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced); + + if (needsSync) { + const parts = key.split(':'); + const type = parts[0]; + const id = parts[1]; + const episodeId = parts[2] || undefined; + + unsynced.push({ + key, + id, + type, + episodeId, + progress + }); + } + } + + return unsynced; + } catch (error) { + logger.error('Error getting unsynced progress:', error); + return []; + } + } + + /** + * Merge Trakt progress with local progress + */ + public async mergeWithTraktProgress( + id: string, + type: string, + traktProgress: number, + traktPausedAt: string, + episodeId?: string + ): Promise<void> { + try { + const localProgress = await this.getWatchProgress(id, type, episodeId); + const traktTimestamp = new Date(traktPausedAt).getTime(); + + if (!localProgress) { + // No local progress, use Trakt data (estimate duration) + const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600; // Default 1 hour + const newProgress: WatchProgress = { + currentTime: (traktProgress / 100) * estimatedDuration, + duration: estimatedDuration, + lastUpdated: traktTimestamp, + traktSynced: true, + traktLastSynced: Date.now(), + traktProgress + }; + await this.setWatchProgress(id, type, newProgress, episodeId); + } else { + // Merge with existing local progress + const shouldUseTraktProgress = traktTimestamp > localProgress.lastUpdated; + + if (shouldUseTraktProgress && localProgress.duration > 0) { + const updatedProgress: WatchProgress = { + ...localProgress, + currentTime: (traktProgress / 100) * localProgress.duration, + lastUpdated: traktTimestamp, + traktSynced: true, + traktLastSynced: Date.now(), + traktProgress + }; + await this.setWatchProgress(id, type, updatedProgress, episodeId); + } else { + // Local is newer, just mark as needing sync + await this.updateTraktSyncStatus(id, type, false, undefined, episodeId); + } + } + } catch (error) { + logger.error('Error merging with Trakt progress:', error); + } + } } export const storageService = StorageService.getInstance(); \ No newline at end of file diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 64a7f6a..ffccb9a 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -47,12 +47,121 @@ export interface TraktWatchedItem { last_watched_at: string; } +// New types for scrobbling +export interface TraktPlaybackItem { + progress: number; + paused_at: string; + id: number; + type: 'movie' | 'episode'; + movie?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + imdb: string; + tmdb: number; + }; + }; + episode?: { + season: number; + number: number; + title: string; + ids: { + trakt: number; + tvdb?: number; + imdb?: string; + tmdb?: number; + }; + }; + show?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + tvdb?: number; + imdb: string; + tmdb: number; + }; + }; +} + +export interface TraktScrobbleResponse { + id: number; + action: 'start' | 'pause' | 'scrobble' | 'conflict'; + progress: number; + sharing?: { + twitter?: boolean; + mastodon?: boolean; + tumblr?: boolean; + facebook?: boolean; + }; + movie?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + imdb: string; + tmdb: number; + }; + }; + episode?: { + season: number; + number: number; + title: string; + ids: { + trakt: number; + tvdb?: number; + imdb?: string; + tmdb?: number; + }; + }; + show?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + tvdb?: number; + imdb: string; + tmdb: number; + }; + }; + // Additional field for 409 handling + alreadyScrobbled?: boolean; +} + +export interface TraktContentData { + type: 'movie' | 'episode'; + imdbId: string; + title: string; + year: number; + season?: number; + episode?: number; + showTitle?: string; + showYear?: number; + showImdbId?: string; +} + export class TraktService { private static instance: TraktService; private accessToken: string | null = null; private refreshToken: string | null = null; private tokenExpiry: number = 0; private isInitialized: boolean = false; + + // Rate limiting + private lastApiCall: number = 0; + private readonly MIN_API_INTERVAL = 1000; // Minimum 1 second between API calls + private requestQueue: Array<() => Promise<any>> = []; + private isProcessingQueue: boolean = false; + + // Track items that have been successfully scrobbled to prevent duplicates + private scrobbledItems: Set<string> = new Set(); + private readonly SCROBBLE_EXPIRY_MS = 46 * 60 * 1000; // 46 minutes (based on Trakt's expiry window) + private scrobbledTimestamps: Map<string, number> = new Map(); private constructor() { // Initialization happens in initialize method @@ -254,10 +363,20 @@ export class TraktService { private async apiRequest<T>( endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', - body?: any + body?: any, + retryCount: number = 0 ): Promise<T> { await this.ensureInitialized(); + // Rate limiting: ensure minimum interval between API calls + const now = Date.now(); + const timeSinceLastCall = now - this.lastApiCall; + if (timeSinceLastCall < this.MIN_API_INTERVAL) { + const delay = this.MIN_API_INTERVAL - timeSinceLastCall; + await new Promise(resolve => setTimeout(resolve, delay)); + } + this.lastApiCall = Date.now(); + // Ensure we have a valid token if (this.tokenExpiry && this.tokenExpiry < Date.now() && this.refreshToken) { await this.refreshAccessToken(); @@ -285,11 +404,125 @@ export class TraktService { const response = await fetch(`${TRAKT_API_URL}${endpoint}`, options); + // Debug log API responses for scrobble endpoints + if (endpoint.includes('/scrobble/')) { + logger.log(`[TraktService] DEBUG API Response for ${endpoint}:`, { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()) + }); + } + + // Handle rate limiting with exponential backoff + if (response.status === 429) { + const maxRetries = 3; + if (retryCount < maxRetries) { + const retryAfter = response.headers.get('Retry-After'); + const delay = retryAfter + ? parseInt(retryAfter) * 1000 + : Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s + + logger.log(`[TraktService] Rate limited (429), retrying in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`); + + await new Promise(resolve => setTimeout(resolve, delay)); + return this.apiRequest<T>(endpoint, method, body, retryCount + 1); + } else { + logger.error(`[TraktService] Rate limited (429), max retries exceeded for ${endpoint}`); + throw new Error(`API request failed: 429 (Rate Limited)`); + } + } + + // Handle 409 conflicts gracefully (already watched/scrobbled) + if (response.status === 409) { + const errorText = await response.text(); + logger.log(`[TraktService] Content already scrobbled (409) for ${endpoint}:`, errorText); + + // Parse the error response to get expiry info + try { + const errorData = JSON.parse(errorText); + if (errorData.watched_at && errorData.expires_at) { + logger.log(`[TraktService] Item was already watched at ${errorData.watched_at}, expires at ${errorData.expires_at}`); + + // If this is a scrobble endpoint, mark the item as already scrobbled + if (endpoint.includes('/scrobble/') && body) { + const contentKey = this.getContentKeyFromPayload(body); + if (contentKey) { + this.scrobbledItems.add(contentKey); + this.scrobbledTimestamps.set(contentKey, Date.now()); + logger.log(`[TraktService] Marked content as already scrobbled: ${contentKey}`); + } + } + + // Return a success-like response for 409 conflicts + // This prevents the error from bubbling up and causing retry loops + return { + id: 0, + action: endpoint.includes('/stop') ? 'scrobble' : 'start', + progress: body?.progress || 0, + alreadyScrobbled: true + } as any; + } + } catch (parseError) { + logger.warn(`[TraktService] Could not parse 409 error response: ${parseError}`); + } + + // Return a graceful response even if we can't parse the error + return { + id: 0, + action: 'conflict', + progress: 0, + alreadyScrobbled: true + } as any; + } + if (!response.ok) { + const errorText = await response.text(); + logger.error(`[TraktService] API Error ${response.status} for ${endpoint}:`, errorText); throw new Error(`API request failed: ${response.status}`); } - return await response.json() as T; + const responseData = await response.json() as T; + + // Debug log successful scrobble responses + if (endpoint.includes('/scrobble/')) { + logger.log(`[TraktService] DEBUG API Success for ${endpoint}:`, responseData); + } + + return responseData; + } + + /** + * Helper method to extract content key from scrobble payload for deduplication + */ + private getContentKeyFromPayload(payload: any): string | null { + try { + if (payload.movie && payload.movie.ids && payload.movie.ids.imdb) { + return `movie:${payload.movie.ids.imdb}`; + } else if (payload.episode && payload.show && payload.show.ids && payload.show.ids.imdb) { + return `episode:${payload.show.ids.imdb}:${payload.episode.season}:${payload.episode.number}`; + } + } catch (error) { + logger.warn('[TraktService] Could not extract content key from payload:', error); + } + return null; + } + + /** + * Check if content was recently scrobbled to prevent duplicates + */ + private isRecentlyScrobbled(contentData: TraktContentData): boolean { + const contentKey = this.getWatchingKey(contentData); + + // Clean up expired entries + const now = Date.now(); + for (const [key, timestamp] of this.scrobbledTimestamps.entries()) { + if (now - timestamp > this.SCROBBLE_EXPIRY_MS) { + this.scrobbledItems.delete(key); + this.scrobbledTimestamps.delete(key); + } + } + + return this.scrobbledItems.has(contentKey); } /** @@ -459,6 +692,426 @@ export class TraktService { return false; } } + + /** + * Get current playback progress from Trakt + */ + public async getPlaybackProgress(type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> { + try { + const endpoint = type ? `/sync/playback/${type}` : '/sync/playback'; + return this.apiRequest<TraktPlaybackItem[]>(endpoint); + } catch (error) { + logger.error('[TraktService] Failed to get playback progress:', error); + return []; + } + } + + /** + * Start watching content (scrobble start) + */ + public async startWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> { + try { + const payload = await this.buildScrobblePayload(contentData, progress); + if (!payload) { + return null; + } + + return this.apiRequest<TraktScrobbleResponse>('/scrobble/start', 'POST', payload); + } catch (error) { + logger.error('[TraktService] Failed to start watching:', error); + return null; + } + } + + /** + * Pause watching content (scrobble pause) + */ + public async pauseWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> { + try { + const payload = await this.buildScrobblePayload(contentData, progress); + if (!payload) { + return null; + } + + return this.apiRequest<TraktScrobbleResponse>('/scrobble/pause', 'POST', payload); + } catch (error) { + logger.error('[TraktService] Failed to pause watching:', error); + return null; + } + } + + /** + * Stop watching content (scrobble stop) - handles completion logic + */ + public async stopWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> { + try { + const payload = await this.buildScrobblePayload(contentData, progress); + if (!payload) { + return null; + } + + return this.apiRequest<TraktScrobbleResponse>('/scrobble/stop', 'POST', payload); + } catch (error) { + logger.error('[TraktService] Failed to stop watching:', error); + return null; + } + } + + /** + * Update watching progress or mark as complete (legacy method) + * @deprecated Use specific methods: startWatching, pauseWatching, stopWatching + */ + public async updateProgress(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> { + // For backwards compatibility, use stop for now + return this.stopWatching(contentData, progress); + } + + /** + * Build scrobble payload for API requests + */ + private async buildScrobblePayload(contentData: TraktContentData, progress: number): Promise<any | null> { + try { + if (contentData.type === 'movie') { + // Clean IMDB ID - some APIs want it without 'tt' prefix + const cleanImdbId = contentData.imdbId.startsWith('tt') + ? contentData.imdbId.substring(2) + : contentData.imdbId; + + const payload = { + movie: { + title: contentData.title, + year: contentData.year, + ids: { + imdb: cleanImdbId + } + }, + progress: Math.round(progress * 100) / 100 // Round to 2 decimal places + }; + + logger.log('[TraktService] DEBUG movie payload:', JSON.stringify(payload, null, 2)); + return payload; + } else if (contentData.type === 'episode') { + if (!contentData.season || !contentData.episode || !contentData.showTitle || !contentData.showYear) { + logger.error('[TraktService] Missing episode data for scrobbling'); + return null; + } + + const payload: any = { + show: { + title: contentData.showTitle, + year: contentData.showYear, + ids: {} + }, + episode: { + season: contentData.season, + number: contentData.episode + }, + progress: Math.round(progress * 100) / 100 + }; + + // Add show IMDB ID if available + if (contentData.showImdbId) { + const cleanShowImdbId = contentData.showImdbId.startsWith('tt') + ? contentData.showImdbId.substring(2) + : contentData.showImdbId; + payload.show.ids.imdb = cleanShowImdbId; + } + + logger.log('[TraktService] DEBUG episode payload:', JSON.stringify(payload, null, 2)); + return payload; + } + + return null; + } catch (error) { + logger.error('[TraktService] Failed to build scrobble payload:', error); + return null; + } + } + + /** + * Process the request queue with proper rate limiting + */ + private async processQueue(): Promise<void> { + if (this.isProcessingQueue || this.requestQueue.length === 0) { + return; + } + + this.isProcessingQueue = true; + + while (this.requestQueue.length > 0) { + const request = this.requestQueue.shift(); + if (request) { + try { + await request(); + } catch (error) { + logger.error('[TraktService] Queue request failed:', error); + } + + // Wait minimum interval before next request + if (this.requestQueue.length > 0) { + await new Promise(resolve => setTimeout(resolve, this.MIN_API_INTERVAL)); + } + } + } + + this.isProcessingQueue = false; + } + + /** + * Add request to queue for rate-limited processing + */ + private queueRequest<T>(requestFn: () => Promise<T>): Promise<T> { + return new Promise((resolve, reject) => { + this.requestQueue.push(async () => { + try { + const result = await requestFn(); + resolve(result); + } catch (error) { + reject(error); + } + }); + + // Start processing if not already running + this.processQueue(); + }); + } + + /** + * Track currently watching sessions to avoid duplicate starts + */ + private currentlyWatching: Set<string> = new Set(); + private lastSyncTime: number = 0; + private readonly SYNC_DEBOUNCE_MS = 60000; // 60 seconds + + /** + * Generate a unique key for content being watched + */ + private getWatchingKey(contentData: TraktContentData): string { + if (contentData.type === 'movie') { + return `movie:${contentData.imdbId}`; + } else { + return `episode:${contentData.showImdbId || contentData.imdbId}:S${contentData.season}E${contentData.episode}`; + } + } + + /** + * Start watching content (use when playback begins) + */ + public async scrobbleStart(contentData: TraktContentData, progress: number): Promise<boolean> { + try { + if (!await this.isAuthenticated()) { + return false; + } + + // Check if this content was recently scrobbled (to prevent duplicates from component remounts) + if (this.isRecentlyScrobbled(contentData)) { + logger.log(`[TraktService] Content was recently scrobbled, skipping start: ${contentData.title}`); + return true; + } + + // Debug log the content data being sent + logger.log(`[TraktService] DEBUG scrobbleStart payload:`, { + type: contentData.type, + title: contentData.title, + year: contentData.year, + imdbId: contentData.imdbId, + season: contentData.season, + episode: contentData.episode, + showTitle: contentData.showTitle, + progress: progress + }); + + const watchingKey = this.getWatchingKey(contentData); + + // Only start if not already watching this content + if (this.currentlyWatching.has(watchingKey)) { + return true; // Already started + } + + const result = await this.queueRequest(async () => { + return await this.startWatching(contentData, progress); + }); + + if (result) { + this.currentlyWatching.add(watchingKey); + logger.log(`[TraktService] Started watching ${contentData.type}: ${contentData.title}`); + return true; + } + + return false; + } catch (error) { + logger.error('[TraktService] Failed to start scrobbling:', error); + return false; + } + } + + /** + * Update progress while watching (use for periodic progress updates) + */ + public async scrobblePause(contentData: TraktContentData, progress: number, force: boolean = false): Promise<boolean> { + try { + if (!await this.isAuthenticated()) { + return false; + } + + const now = Date.now(); + + // Debounce API calls unless forced + if (!force && (now - this.lastSyncTime) < this.SYNC_DEBOUNCE_MS) { + return true; // Skip this sync, but return success + } + + this.lastSyncTime = now; + + const result = await this.queueRequest(async () => { + return await this.pauseWatching(contentData, progress); + }); + + if (result) { + logger.log(`[TraktService] Updated progress ${progress.toFixed(1)}% for ${contentData.type}: ${contentData.title}`); + return true; + } + + return false; + } catch (error) { + // Handle rate limiting errors more gracefully + if (error instanceof Error && error.message.includes('429')) { + logger.warn('[TraktService] Rate limited, will retry later'); + return true; // Return success to avoid error spam + } + + logger.error('[TraktService] Failed to update progress:', error); + return false; + } + } + + /** + * Stop watching content (use when playback ends or stops) + */ + public async scrobbleStop(contentData: TraktContentData, progress: number): Promise<boolean> { + try { + if (!await this.isAuthenticated()) { + return false; + } + + const watchingKey = this.getWatchingKey(contentData); + + const result = await this.queueRequest(async () => { + return await this.stopWatching(contentData, progress); + }); + + if (result) { + this.currentlyWatching.delete(watchingKey); + + // Mark as scrobbled if >= 80% to prevent future duplicates + if (progress >= 80) { + this.scrobbledItems.add(watchingKey); + this.scrobbledTimestamps.set(watchingKey, Date.now()); + } + + // The stop endpoint automatically handles the 80%+ completion logic + // and will mark as scrobbled if >= 80%, or pause if < 80% + const action = progress >= 80 ? 'scrobbled' : 'paused'; + logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`); + + return true; + } + + return false; + } catch (error) { + // Handle rate limiting errors more gracefully + if (error instanceof Error && error.message.includes('429')) { + logger.warn('[TraktService] Rate limited, will retry later'); + return true; + } + + logger.error('[TraktService] Failed to stop scrobbling:', error); + return false; + } + } + + /** + * Legacy sync method - now delegates to proper scrobble methods + * @deprecated Use scrobbleStart, scrobblePause, scrobbleStop instead + */ + public async syncProgressToTrakt( + contentData: TraktContentData, + progress: number, + force: boolean = false + ): Promise<boolean> { + // For backward compatibility, treat as a pause update + return this.scrobblePause(contentData, progress, force); + } + + /** + * Debug method to test Trakt API connection and scrobble functionality + */ + public async debugTraktConnection(): Promise<any> { + try { + logger.log('[TraktService] Testing Trakt API connection...'); + + // Test basic API access + const userResponse = await this.apiRequest('/users/me', 'GET'); + logger.log('[TraktService] User info:', userResponse); + + // Test a minimal scrobble start to verify API works + const testPayload = { + movie: { + title: "Test Movie", + year: 2023, + ids: { + imdb: "1234567" // Fake IMDB ID for testing + } + }, + progress: 1.0 + }; + + logger.log('[TraktService] Testing scrobble/start endpoint with test payload...'); + const scrobbleResponse = await this.apiRequest('/scrobble/start', 'POST', testPayload); + logger.log('[TraktService] Scrobble test response:', scrobbleResponse); + + return { + authenticated: true, + user: userResponse, + scrobbleTest: scrobbleResponse + }; + } catch (error) { + logger.error('[TraktService] Debug connection failed:', error); + return { + authenticated: false, + error: error instanceof Error ? error.message : String(error) + }; + } + } + + /** + * Debug method to check current playback progress on Trakt + */ + public async debugPlaybackProgress(): Promise<void> { + try { + if (!await this.isAuthenticated()) { + logger.log('[TraktService] DEBUG: Not authenticated'); + return; + } + + const progress = await this.getPlaybackProgress(); + logger.log(`[TraktService] DEBUG: Found ${progress.length} items in Trakt playback progress:`); + + progress.forEach((item, index) => { + if (item.type === 'movie' && item.movie) { + logger.log(`[TraktService] DEBUG ${index + 1}: Movie "${item.movie.title}" (${item.movie.year}) - ${item.progress.toFixed(1)}% - Paused: ${item.paused_at}`); + } else if (item.type === 'episode' && item.episode && item.show) { + logger.log(`[TraktService] DEBUG ${index + 1}: Episode "${item.show.title}" S${item.episode.season}E${item.episode.number} - ${item.progress.toFixed(1)}% - Paused: ${item.paused_at}`); + } + }); + + if (progress.length === 0) { + logger.log('[TraktService] DEBUG: No items found in Trakt playback progress'); + } + } catch (error) { + logger.error('[TraktService] DEBUG: Error fetching playback progress:', error); + } + } } // Export a singleton instance -- 2.45.2 From fb7b58b97c45272d76759e902b8978914a7bc1f8 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Thu, 19 Jun 2025 21:50:42 +0530 Subject: [PATCH 59/88] Enhance video player components with detailed logging and resume functionality This update improves the AndroidVideoPlayer and VideoPlayer components by adding comprehensive logging for watch progress loading, resume preferences, and playback events. It also refines the handling of resume overlays based on user preferences and saved progress, ensuring a smoother user experience. Additionally, the ResumeOverlay component now logs its rendering state, providing better insights during debugging. --- src/components/player/AndroidVideoPlayer.tsx | 41 ++++++++++++++-- src/components/player/VideoPlayer.tsx | 49 +++++++++++++++++-- .../player/modals/ResumeOverlay.tsx | 14 +++++- 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index dc3f16d..d54fa18 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -260,24 +260,42 @@ const AndroidVideoPlayer: React.FC = () => { const loadWatchProgress = async () => { if (id && type) { try { + logger.log(`[AndroidVideoPlayer] Loading watch progress for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); const savedProgress = await storageService.getWatchProgress(id, type, episodeId); + logger.log(`[AndroidVideoPlayer] Saved progress:`, savedProgress); + if (savedProgress) { const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; + logger.log(`[AndroidVideoPlayer] Progress: ${progressPercent.toFixed(1)}% (${savedProgress.currentTime}/${savedProgress.duration})`); + if (progressPercent < 95) { setResumePosition(savedProgress.currentTime); + logger.log(`[AndroidVideoPlayer] Set resume position to: ${savedProgress.currentTime}`); + const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); + logger.log(`[AndroidVideoPlayer] Resume preference: ${pref}`); + if (pref === RESUME_PREF.ALWAYS_RESUME) { setInitialPosition(savedProgress.currentTime); + logger.log(`[AndroidVideoPlayer] Auto-resuming due to preference`); } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { setInitialPosition(0); + logger.log(`[AndroidVideoPlayer] Auto-starting over due to preference`); } else { setShowResumeOverlay(true); + logger.log(`[AndroidVideoPlayer] Showing resume overlay`); } + } else { + logger.log(`[AndroidVideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`); } + } else { + logger.log(`[AndroidVideoPlayer] No saved progress found`); } } catch (error) { logger.error('[AndroidVideoPlayer] Error loading watch progress:', error); } + } else { + logger.log(`[AndroidVideoPlayer] Missing id or type: id=${id}, type=${type}`); } }; loadWatchProgress(); @@ -420,8 +438,9 @@ const AndroidVideoPlayer: React.FC = () => { logger.log('[AndroidVideoPlayer] Video loaded:', data); } if (isMounted.current) { + const videoDuration = data.duration; if (data.duration > 0) { - setDuration(data.duration); + setDuration(videoDuration); } // Set aspect ratio from video dimensions @@ -452,14 +471,20 @@ const AndroidVideoPlayer: React.FC = () => { setIsVideoLoaded(true); setIsPlayerReady(true); - // Start Trakt watching session when video loads - traktAutosync.handlePlaybackStart(currentTime, data.duration || duration); + // Start Trakt watching session when video loads with proper duration + if (videoDuration > 0) { + traktAutosync.handlePlaybackStart(currentTime, videoDuration); + } if (initialPosition && !isInitialSeekComplete) { + logger.log(`[AndroidVideoPlayer] Seeking to initial position: ${initialPosition}s (duration: ${videoDuration}s)`); setTimeout(() => { - if (videoRef.current && duration > 0 && isMounted.current) { + if (videoRef.current && videoDuration > 0 && isMounted.current) { seekToTime(initialPosition); setIsInitialSeekComplete(true); + logger.log(`[AndroidVideoPlayer] Initial seek completed to: ${initialPosition}s`); + } else { + logger.error(`[AndroidVideoPlayer] Initial seek failed: videoRef=${!!videoRef.current}, duration=${videoDuration}, mounted=${isMounted.current}`); } }, 1000); } @@ -544,16 +569,24 @@ const AndroidVideoPlayer: React.FC = () => { useEffect(() => { const loadResumePreference = async () => { try { + logger.log(`[AndroidVideoPlayer] Loading resume preference, resumePosition=${resumePosition}`); const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); + logger.log(`[AndroidVideoPlayer] Resume preference loaded: ${pref}`); + if (pref) { setResumePreference(pref); if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) { + logger.log(`[AndroidVideoPlayer] Auto-resuming due to preference`); setShowResumeOverlay(false); setInitialPosition(resumePosition); } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { + logger.log(`[AndroidVideoPlayer] Auto-starting over due to preference`); setShowResumeOverlay(false); setInitialPosition(0); } + // Don't override overlay if no specific preference or preference doesn't match + } else { + logger.log(`[AndroidVideoPlayer] No resume preference found, keeping overlay state`); } } catch (error) { logger.error('[AndroidVideoPlayer] Error loading resume preference:', error); diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index feeb559..89cd404 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -255,24 +255,48 @@ const VideoPlayer: React.FC = () => { const loadWatchProgress = async () => { if (id && type) { try { + logger.log(`[VideoPlayer] Loading watch progress for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); const savedProgress = await storageService.getWatchProgress(id, type, episodeId); + logger.log(`[VideoPlayer] Saved progress:`, savedProgress); + if (savedProgress) { const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; + logger.log(`[VideoPlayer] Progress: ${progressPercent.toFixed(1)}% (${savedProgress.currentTime}/${savedProgress.duration})`); + if (progressPercent < 95) { setResumePosition(savedProgress.currentTime); + logger.log(`[VideoPlayer] Set resume position to: ${savedProgress.currentTime}`); + const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); - if (pref === RESUME_PREF.ALWAYS_RESUME) { + logger.log(`[VideoPlayer] Resume preference: ${pref}`); + + // TEMPORARY: Clear the preference to test overlay + if (pref) { + await AsyncStorage.removeItem(RESUME_PREF_KEY); + logger.log(`[VideoPlayer] CLEARED resume preference for testing`); + setShowResumeOverlay(true); + logger.log(`[VideoPlayer] Showing resume overlay after clearing preference`); + } else if (pref === RESUME_PREF.ALWAYS_RESUME) { setInitialPosition(savedProgress.currentTime); + logger.log(`[VideoPlayer] Auto-resuming due to preference`); } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { setInitialPosition(0); + logger.log(`[VideoPlayer] Auto-starting over due to preference`); } else { setShowResumeOverlay(true); + logger.log(`[VideoPlayer] Showing resume overlay`); } + } else { + logger.log(`[VideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`); } + } else { + logger.log(`[VideoPlayer] No saved progress found`); } } catch (error) { logger.error('[VideoPlayer] Error loading watch progress:', error); } + } else { + logger.log(`[VideoPlayer] Missing id or type: id=${id}, type=${type}`); } }; loadWatchProgress(); @@ -441,8 +465,9 @@ const VideoPlayer: React.FC = () => { logger.log('[VideoPlayer] Video loaded:', data); } if (isMounted.current) { + const videoDuration = data.duration / 1000; if (data.duration > 0) { - setDuration(data.duration / 1000); + setDuration(videoDuration); } setVideoAspectRatio(data.videoSize.width / data.videoSize.height); @@ -455,11 +480,21 @@ const VideoPlayer: React.FC = () => { setIsVideoLoaded(true); setIsPlayerReady(true); + + // Start Trakt watching session when video loads with proper duration + if (videoDuration > 0) { + traktAutosync.handlePlaybackStart(currentTime, videoDuration); + } + if (initialPosition && !isInitialSeekComplete) { + logger.log(`[VideoPlayer] Seeking to initial position: ${initialPosition}s (duration: ${videoDuration}s)`); setTimeout(() => { - if (vlcRef.current && duration > 0 && isMounted.current) { + if (vlcRef.current && videoDuration > 0 && isMounted.current) { seekToTime(initialPosition); setIsInitialSeekComplete(true); + logger.log(`[VideoPlayer] Initial seek completed to: ${initialPosition}s`); + } else { + logger.error(`[VideoPlayer] Initial seek failed: vlcRef=${!!vlcRef.current}, duration=${videoDuration}, mounted=${isMounted.current}`); } }, 1000); } @@ -552,16 +587,24 @@ const VideoPlayer: React.FC = () => { useEffect(() => { const loadResumePreference = async () => { try { + logger.log(`[VideoPlayer] Loading resume preference, resumePosition=${resumePosition}`); const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); + logger.log(`[VideoPlayer] Resume preference loaded: ${pref}`); + if (pref) { setResumePreference(pref); if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) { + logger.log(`[VideoPlayer] Auto-resuming due to preference`); setShowResumeOverlay(false); setInitialPosition(resumePosition); } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { + logger.log(`[VideoPlayer] Auto-starting over due to preference`); setShowResumeOverlay(false); setInitialPosition(0); } + // Don't override overlay if no specific preference or preference doesn't match + } else { + logger.log(`[VideoPlayer] No resume preference found, keeping overlay state`); } } catch (error) { logger.error('[VideoPlayer] Error loading resume preference:', error); diff --git a/src/components/player/modals/ResumeOverlay.tsx b/src/components/player/modals/ResumeOverlay.tsx index 0c1f4e0..0945165 100644 --- a/src/components/player/modals/ResumeOverlay.tsx +++ b/src/components/player/modals/ResumeOverlay.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { View, Text, TouchableOpacity } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { styles } from '../utils/playerStyles'; import { formatTime } from '../utils/playerUtils'; +import { logger } from '../../../utils/logger'; interface ResumeOverlayProps { showResumeOverlay: boolean; @@ -34,7 +35,16 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({ handleResume, handleStartFromBeginning, }) => { - if (!showResumeOverlay || resumePosition === null) return null; + useEffect(() => { + logger.log(`[ResumeOverlay] Props changed: showOverlay=${showResumeOverlay}, resumePosition=${resumePosition}, duration=${duration}, title=${title}`); + }, [showResumeOverlay, resumePosition, duration, title]); + + if (!showResumeOverlay || resumePosition === null) { + logger.log(`[ResumeOverlay] Not showing overlay: showOverlay=${showResumeOverlay}, resumePosition=${resumePosition}`); + return null; + } + + logger.log(`[ResumeOverlay] Rendering overlay for ${title} at ${resumePosition}s`); return ( <View style={styles.resumeOverlay}> -- 2.45.2 From cdec184c1408270c85ec76b3183245ac1bcea703 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Thu, 19 Jun 2025 22:56:04 +0530 Subject: [PATCH 60/88] Integrate Trakt support into watch progress management This update enhances the watch progress functionality by incorporating Trakt integration across various components. Key changes include the addition of Trakt-related properties in the watch progress state, improved synchronization logic, and enhanced UI elements to reflect Trakt sync status. The useTraktIntegration and useWatchProgress hooks have been updated to manage Trakt authentication and playback progress more effectively, ensuring a seamless user experience when tracking viewing history across devices. --- src/components/metadata/HeroSection.tsx | 71 +++++++++++++++++++--- src/contexts/TraktContext.tsx | 1 + src/hooks/useTraktIntegration.ts | 79 ++++++++++++++++++++++--- src/hooks/useWatchProgress.ts | 74 ++++++++++++++++++++--- src/screens/TraktSettingsScreen.tsx | 22 +++---- src/services/storageService.ts | 22 +++++-- 6 files changed, 229 insertions(+), 40 deletions(-) diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 457d56f..b74cdc8 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -23,6 +23,7 @@ import Animated, { withRepeat, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; +import { useTraktContext } from '../../contexts/TraktContext'; import { logger } from '../../utils/logger'; import { TMDBService } from '../../services/tmdbService'; @@ -52,6 +53,8 @@ interface HeroSectionProps { duration: number; lastUpdated: number; episodeId?: string; + traktSynced?: boolean; + traktProgress?: number; } | null; type: 'movie' | 'series'; getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; @@ -196,21 +199,29 @@ const ActionButtons = React.memo(({ ); }); -// Ultra-optimized WatchProgress Component +// Enhanced WatchProgress Component with Trakt integration const WatchProgressDisplay = React.memo(({ watchProgress, type, getEpisodeDetails, animatedStyle, }: { - watchProgress: { currentTime: number; duration: number; lastUpdated: number; episodeId?: string } | null; + watchProgress: { + currentTime: number; + duration: number; + lastUpdated: number; + episodeId?: string; + traktSynced?: boolean; + traktProgress?: number; + } | null; type: 'movie' | 'series'; getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; animatedStyle: any; }) => { const { currentTheme } = useTheme(); + const { isAuthenticated: isTraktAuthenticated } = useTraktContext(); - // Memoized progress calculation + // Memoized progress calculation with Trakt integration const progressData = useMemo(() => { if (!watchProgress || watchProgress.duration === 0) return null; @@ -225,13 +236,33 @@ const WatchProgressDisplay = React.memo(({ } } + // Enhanced display text with Trakt integration + let displayText = progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`; + let syncStatus = ''; + + // Show Trakt sync status if user is authenticated + if (isTraktAuthenticated) { + if (watchProgress.traktSynced) { + syncStatus = ' • Synced with Trakt'; + // If we have specific Trakt progress that differs from local, mention it + if (watchProgress.traktProgress !== undefined && + Math.abs(progressPercent - watchProgress.traktProgress) > 5) { + displayText = `${Math.round(progressPercent)}% watched (${Math.round(watchProgress.traktProgress)}% on Trakt)`; + } + } else { + syncStatus = ' • Sync pending'; + } + } + return { progressPercent, formattedTime, episodeInfo, - displayText: progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched` + displayText, + syncStatus, + isTraktSynced: watchProgress.traktSynced && isTraktAuthenticated }; - }, [watchProgress, type, getEpisodeDetails]); + }, [watchProgress, type, getEpisodeDetails, isTraktAuthenticated]); if (!progressData) return null; @@ -243,13 +274,26 @@ const WatchProgressDisplay = React.memo(({ styles.watchProgressFill, { width: `${progressData.progressPercent}%`, - backgroundColor: currentTheme.colors.primary + backgroundColor: progressData.isTraktSynced + ? '#E50914' // Netflix red for Trakt synced content + : currentTheme.colors.primary } ]} /> + {/* Trakt sync indicator */} + {progressData.isTraktSynced && ( + <View style={styles.traktSyncIndicator}> + <MaterialIcons + name="sync" + size={8} + color="rgba(255,255,255,0.9)" + /> + </View> + )} </View> <Text style={[styles.watchProgressText, { color: currentTheme.colors.textMuted }]}> {progressData.displayText}{progressData.episodeInfo} • Last watched on {progressData.formattedTime} + {progressData.syncStatus} </Text> </Animated.View> ); @@ -280,6 +324,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ setLogoLoadError, }) => { const { currentTheme } = useTheme(); + const { isAuthenticated: isTraktAuthenticated } = useTraktContext(); // Enhanced state for smooth image loading const [imageError, setImageError] = useState(false); @@ -470,7 +515,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ </Animated.View> </View> - {/* Optimized Watch Progress */} + {/* Enhanced Watch Progress with Trakt integration */} <WatchProgressDisplay watchProgress={watchProgress} type={type} @@ -636,12 +681,22 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(255, 255, 255, 0.2)', borderRadius: 1.25, overflow: 'hidden', - marginBottom: 6 + marginBottom: 6, + position: 'relative', }, watchProgressFill: { height: '100%', borderRadius: 1.25, }, + traktSyncIndicator: { + position: 'absolute', + right: 2, + top: -2, + bottom: -2, + width: 12, + alignItems: 'center', + justifyContent: 'center', + }, watchProgressText: { fontSize: 11, textAlign: 'center', diff --git a/src/contexts/TraktContext.tsx b/src/contexts/TraktContext.tsx index 05c27d1..01d5b22 100644 --- a/src/contexts/TraktContext.tsx +++ b/src/contexts/TraktContext.tsx @@ -15,6 +15,7 @@ interface TraktContextProps { isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise<boolean>; markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise<boolean>; markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise<boolean>; + forceSyncTraktProgress?: () => Promise<boolean>; } const TraktContext = createContext<TraktContextProps | undefined>(undefined); diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index 027205c..7b4e71b 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -13,15 +13,20 @@ export function useTraktIntegration() { // Check authentication status const checkAuthStatus = useCallback(async () => { + logger.log('[useTraktIntegration] checkAuthStatus called'); setIsLoading(true); try { const authenticated = await traktService.isAuthenticated(); + logger.log(`[useTraktIntegration] Authentication check result: ${authenticated}`); setIsAuthenticated(authenticated); if (authenticated) { + logger.log('[useTraktIntegration] User is authenticated, fetching profile...'); const profile = await traktService.getUserProfile(); + logger.log(`[useTraktIntegration] User profile: ${profile.username}`); setUserProfile(profile); } else { + logger.log('[useTraktIntegration] User is not authenticated'); setUserProfile(null); } @@ -187,10 +192,18 @@ export function useTraktIntegration() { // Get playback progress from Trakt const getTraktPlaybackProgress = useCallback(async (type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> => { - if (!isAuthenticated) return []; + logger.log(`[useTraktIntegration] getTraktPlaybackProgress called - isAuthenticated: ${isAuthenticated}, type: ${type || 'all'}`); + + if (!isAuthenticated) { + logger.log('[useTraktIntegration] getTraktPlaybackProgress: Not authenticated'); + return []; + } try { - return await traktService.getPlaybackProgress(type); + logger.log('[useTraktIntegration] Calling traktService.getPlaybackProgress...'); + const result = await traktService.getPlaybackProgress(type); + logger.log(`[useTraktIntegration] traktService.getPlaybackProgress returned ${result.length} items`); + return result; } catch (error) { logger.error('[useTraktIntegration] Error getting playback progress:', error); return []; @@ -260,10 +273,22 @@ export function useTraktIntegration() { // Fetch and merge Trakt progress with local progress const fetchAndMergeTraktProgress = useCallback(async (): Promise<boolean> => { - if (!isAuthenticated) return false; + logger.log(`[useTraktIntegration] fetchAndMergeTraktProgress called - isAuthenticated: ${isAuthenticated}`); + + if (!isAuthenticated) { + logger.log('[useTraktIntegration] Not authenticated, skipping Trakt progress fetch'); + return false; + } try { + logger.log('[useTraktIntegration] Fetching Trakt playback progress...'); const traktProgress = await getTraktPlaybackProgress(); + logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} Trakt progress items`); + + if (traktProgress.length === 0) { + logger.log('[useTraktIntegration] No Trakt progress found - user may not have any content in progress'); + return true; // Not an error, just no data + } for (const item of traktProgress) { try { @@ -274,14 +299,18 @@ export function useTraktIntegration() { if (item.type === 'movie' && item.movie) { id = item.movie.ids.imdb; type = 'movie'; + logger.log(`[useTraktIntegration] Processing Trakt movie: ${item.movie.title} (${id}) - ${item.progress}%`); } else if (item.type === 'episode' && item.show && item.episode) { id = item.show.ids.imdb; type = 'series'; - episodeId = `S${item.episode.season}E${item.episode.number}`; + episodeId = `${id}:${item.episode.season}:${item.episode.number}`; + logger.log(`[useTraktIntegration] Processing Trakt episode: ${item.show.title} S${item.episode.season}E${item.episode.number} (${id}) - ${item.progress}%`); } else { + logger.warn(`[useTraktIntegration] Skipping invalid Trakt item:`, item); continue; } + logger.log(`[useTraktIntegration] Merging progress for ${type} ${id}: ${item.progress}% from ${item.paused_at}`); await storageService.mergeWithTraktProgress( id, type, @@ -294,7 +323,7 @@ export function useTraktIntegration() { } } - logger.log(`[useTraktIntegration] Merged ${traktProgress.length} Trakt progress entries`); + logger.log(`[useTraktIntegration] Successfully merged ${traktProgress.length} Trakt progress entries`); return true; } catch (error) { logger.error('[useTraktIntegration] Error fetching and merging Trakt progress:', error); @@ -314,14 +343,47 @@ export function useTraktIntegration() { } }, [isAuthenticated, loadWatchedItems]); - // Auto-sync when authenticated changes + // Auto-sync when authenticated changes OR when auth status is refreshed useEffect(() => { if (isAuthenticated) { // Fetch Trakt progress and merge with local - fetchAndMergeTraktProgress(); + logger.log('[useTraktIntegration] User authenticated, fetching Trakt progress to replace local data'); + fetchAndMergeTraktProgress().then((success) => { + if (success) { + logger.log('[useTraktIntegration] Trakt progress merged successfully - local data replaced with Trakt data'); + } else { + logger.warn('[useTraktIntegration] Failed to merge Trakt progress'); + } + // Small delay to ensure storage subscribers are notified + setTimeout(() => { + logger.log('[useTraktIntegration] Trakt progress merge completed, UI should refresh'); + }, 100); + }); } }, [isAuthenticated, fetchAndMergeTraktProgress]); + // Trigger sync when auth status is manually refreshed (for login scenarios) + useEffect(() => { + if (isAuthenticated) { + logger.log('[useTraktIntegration] Auth status refresh detected, triggering Trakt progress merge'); + fetchAndMergeTraktProgress().then((success) => { + if (success) { + logger.log('[useTraktIntegration] Trakt progress merged after manual auth refresh'); + } + }); + } + }, [lastAuthCheck, isAuthenticated, fetchAndMergeTraktProgress]); + + // Manual force sync function for testing/troubleshooting + const forceSyncTraktProgress = useCallback(async (): Promise<boolean> => { + logger.log('[useTraktIntegration] Manual force sync triggered'); + if (!isAuthenticated) { + logger.log('[useTraktIntegration] Cannot force sync - not authenticated'); + return false; + } + return await fetchAndMergeTraktProgress(); + }, [isAuthenticated, fetchAndMergeTraktProgress]); + return { isAuthenticated, isLoading, @@ -341,6 +403,7 @@ export function useTraktIntegration() { syncProgress, // legacy getTraktPlaybackProgress, syncAllProgress, - fetchAndMergeTraktProgress + fetchAndMergeTraktProgress, + forceSyncTraktProgress // For manual testing }; } \ No newline at end of file diff --git a/src/hooks/useWatchProgress.ts b/src/hooks/useWatchProgress.ts index 7c71539..0dcc9df 100644 --- a/src/hooks/useWatchProgress.ts +++ b/src/hooks/useWatchProgress.ts @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect } from 'react'; import { useFocusEffect } from '@react-navigation/native'; +import { useTraktContext } from '../contexts/TraktContext'; import { logger } from '../utils/logger'; import { storageService } from '../services/storageService'; @@ -8,6 +9,8 @@ interface WatchProgressData { duration: number; lastUpdated: number; episodeId?: string; + traktSynced?: boolean; + traktProgress?: number; } export const useWatchProgress = ( @@ -17,6 +20,7 @@ export const useWatchProgress = ( episodes: any[] = [] ) => { const [watchProgress, setWatchProgress] = useState<WatchProgressData | null>(null); + const { isAuthenticated: isTraktAuthenticated } = useTraktContext(); // Function to get episode details from episodeId const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => { @@ -52,7 +56,7 @@ export const useWatchProgress = ( return null; }, [episodes]); - // Load watch progress + // Enhanced load watch progress with Trakt integration const loadWatchProgress = useCallback(async () => { try { if (id && type) { @@ -119,9 +123,20 @@ export const useWatchProgress = ( `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`; const nextProgress = await storageService.getWatchProgress(id, type, nextEpisodeId); if (nextProgress) { - setWatchProgress({ ...nextProgress, episodeId: nextEpisodeId }); + setWatchProgress({ + ...nextProgress, + episodeId: nextEpisodeId, + traktSynced: nextProgress.traktSynced, + traktProgress: nextProgress.traktProgress + }); } else { - setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: nextEpisodeId }); + setWatchProgress({ + currentTime: 0, + duration: 0, + lastUpdated: Date.now(), + episodeId: nextEpisodeId, + traktSynced: false + }); } return; } @@ -132,7 +147,12 @@ export const useWatchProgress = ( } // If current episode is not finished, show its progress - setWatchProgress({ ...progress, episodeId }); + setWatchProgress({ + ...progress, + episodeId, + traktSynced: progress.traktSynced, + traktProgress: progress.traktProgress + }); } else { setWatchProgress(null); } @@ -151,9 +171,20 @@ export const useWatchProgress = ( `${id}:${unfinishedEpisode.season_number}:${unfinishedEpisode.episode_number}`; const progress = await storageService.getWatchProgress(id, type, epId); if (progress) { - setWatchProgress({ ...progress, episodeId: epId }); + setWatchProgress({ + ...progress, + episodeId: epId, + traktSynced: progress.traktSynced, + traktProgress: progress.traktProgress + }); } else { - setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: epId }); + setWatchProgress({ + currentTime: 0, + duration: 0, + lastUpdated: Date.now(), + episodeId: epId, + traktSynced: false + }); } } else { setWatchProgress(null); @@ -167,7 +198,12 @@ export const useWatchProgress = ( if (progressPercent >= 95) { setWatchProgress(null); } else { - setWatchProgress({ ...progress, episodeId }); + setWatchProgress({ + ...progress, + episodeId, + traktSynced: progress.traktSynced, + traktProgress: progress.traktProgress + }); } } else { setWatchProgress(null); @@ -180,7 +216,7 @@ export const useWatchProgress = ( } }, [id, type, episodeId, episodes]); - // Function to get play button text based on watch progress + // Enhanced function to get play button text with Trakt awareness const getPlayButtonText = useCallback(() => { if (!watchProgress || watchProgress.currentTime <= 0) { return 'Play'; @@ -192,9 +228,21 @@ export const useWatchProgress = ( return 'Play'; } + // If we have Trakt data and it differs significantly from local, show "Resume" + // but the UI will show the discrepancy return 'Resume'; }, [watchProgress]); + // Subscribe to storage changes for real-time updates + useEffect(() => { + const unsubscribe = storageService.subscribeToWatchProgressUpdates(() => { + logger.log('[useWatchProgress] Storage updated, reloading progress'); + loadWatchProgress(); + }); + + return unsubscribe; + }, [loadWatchProgress]); + // Initial load useEffect(() => { loadWatchProgress(); @@ -207,6 +255,16 @@ export const useWatchProgress = ( }, [loadWatchProgress]) ); + // Re-load when Trakt authentication status changes + useEffect(() => { + if (isTraktAuthenticated !== undefined) { + // Small delay to ensure Trakt context is fully initialized + setTimeout(() => { + loadWatchProgress(); + }, 100); + } + }, [isTraktAuthenticated, loadWatchProgress]); + return { watchProgress, getEpisodeDetails, diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index d9f9e47..ec3ad88 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -330,18 +330,18 @@ const TraktSettingsScreen: React.FC = () => { <View style={styles.settingItem}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}> <View style={{ flex: 1 }}> - <Text style={[ - styles.settingLabel, - { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark } - ]}> - Auto-sync playback progress - </Text> - <Text style={[ - styles.settingDescription, - { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark } - ]}> + <Text style={[ + styles.settingLabel, + { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark } + ]}> + Auto-sync playback progress + </Text> + <Text style={[ + styles.settingDescription, + { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark } + ]}> Automatically sync watch progress to Trakt - </Text> + </Text> </View> <Switch value={autosyncSettings.enabled} diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 3bad143..0878c3e 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -208,10 +208,11 @@ class StorageService { }; await this.setWatchProgress(id, type, newProgress, episodeId); } else { - // Merge with existing local progress - const shouldUseTraktProgress = traktTimestamp > localProgress.lastUpdated; + // Always prioritize Trakt progress when merging + const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100; - if (shouldUseTraktProgress && localProgress.duration > 0) { + if (localProgress.duration > 0) { + // Use Trakt progress, keeping the existing duration const updatedProgress: WatchProgress = { ...localProgress, currentTime: (traktProgress / 100) * localProgress.duration, @@ -221,9 +222,20 @@ class StorageService { traktProgress }; await this.setWatchProgress(id, type, updatedProgress, episodeId); + logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%)`); } else { - // Local is newer, just mark as needing sync - await this.updateTraktSyncStatus(id, type, false, undefined, episodeId); + // If no duration, estimate it from Trakt progress + const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600; + const updatedProgress: WatchProgress = { + currentTime: (traktProgress / 100) * estimatedDuration, + duration: estimatedDuration, + lastUpdated: traktTimestamp, + traktSynced: true, + traktLastSynced: Date.now(), + traktProgress + }; + await this.setWatchProgress(id, type, updatedProgress, episodeId); + logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%) - estimated duration`); } } } catch (error) { -- 2.45.2 From 671861c207d1d7bcbf834f5cfdd9c26a46621645 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Thu, 19 Jun 2025 23:26:11 +0530 Subject: [PATCH 61/88] Enhance HeroSection and watch progress management with Trakt integration This update improves the HeroSection component by adding functionality to determine if content has been watched based on Trakt progress. The ActionButtons now reflect the watched state with updated styles and indicators. Additionally, the useWatchProgress hook has been modified to always show current episode progress, ensuring accurate display of watched status. The useTraktIntegration hook has also been enhanced to fetch and merge watched movies, improving synchronization with Trakt. --- src/components/metadata/HeroSection.tsx | 238 +++++++++++++++++++++--- src/hooks/useTraktIntegration.ts | 61 ++++-- src/hooks/useWatchProgress.ts | 77 +------- 3 files changed, 277 insertions(+), 99 deletions(-) diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index b74cdc8..7f36a02 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -77,7 +77,9 @@ const ActionButtons = React.memo(({ id, navigation, playButtonText, - animatedStyle + animatedStyle, + isWatched, + watchProgress }: { handleShowStreams: () => void; toggleLibrary: () => void; @@ -87,6 +89,8 @@ const ActionButtons = React.memo(({ navigation: any; playButtonText: string; animatedStyle: any; + isWatched: boolean; + watchProgress: any; }) => { const { currentTheme } = useTheme(); @@ -122,19 +126,48 @@ const ActionButtons = React.memo(({ } }, [id, navigation]); + // Determine play button style and text based on watched status + const playButtonStyle = useMemo(() => { + if (isWatched) { + return [styles.actionButton, styles.playButton, styles.watchedPlayButton]; + } + return [styles.actionButton, styles.playButton]; + }, [isWatched]); + + const playButtonTextStyle = useMemo(() => { + if (isWatched) { + return [styles.playButtonText, styles.watchedPlayButtonText]; + } + return styles.playButtonText; + }, [isWatched]); + + const finalPlayButtonText = useMemo(() => { + if (isWatched) { + return 'Watch Again'; + } + return playButtonText; + }, [isWatched, playButtonText]); + return ( <Animated.View style={[styles.actionButtons, animatedStyle]}> <TouchableOpacity - style={[styles.actionButton, styles.playButton]} + style={playButtonStyle} onPress={handleShowStreams} activeOpacity={0.85} > <MaterialIcons - name={playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow"} + name={isWatched ? "replay" : (playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow")} size={24} - color="#000" + color={isWatched ? "#fff" : "#000"} /> - <Text style={styles.playButtonText}>{playButtonText}</Text> + <Text style={playButtonTextStyle}>{finalPlayButtonText}</Text> + + {/* Subtle watched indicator in play button */} + {isWatched && ( + <View style={styles.watchedIndicator}> + <MaterialIcons name="check" size={12} color="#fff" /> + </View> + )} </TouchableOpacity> <TouchableOpacity @@ -199,12 +232,13 @@ const ActionButtons = React.memo(({ ); }); -// Enhanced WatchProgress Component with Trakt integration +// Enhanced WatchProgress Component with Trakt integration and watched status const WatchProgressDisplay = React.memo(({ watchProgress, type, getEpisodeDetails, animatedStyle, + isWatched }: { watchProgress: { currentTime: number; @@ -217,15 +251,68 @@ const WatchProgressDisplay = React.memo(({ type: 'movie' | 'series'; getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null; animatedStyle: any; + isWatched: boolean; }) => { const { currentTheme } = useTheme(); - const { isAuthenticated: isTraktAuthenticated } = useTraktContext(); + const { isAuthenticated: isTraktAuthenticated, forceSyncTraktProgress } = useTraktContext(); + + // Handle manual Trakt sync + const handleTraktSync = useMemo(() => async () => { + if (isTraktAuthenticated && forceSyncTraktProgress) { + logger.log('[HeroSection] Manual Trakt sync requested'); + try { + const success = await forceSyncTraktProgress(); + logger.log(`[HeroSection] Manual Trakt sync ${success ? 'successful' : 'failed'}`); + } catch (error) { + logger.error('[HeroSection] Manual Trakt sync error:', error); + } + } + }, [isTraktAuthenticated, forceSyncTraktProgress]); // Memoized progress calculation with Trakt integration const progressData = useMemo(() => { + // If content is fully watched, show watched status instead of progress + if (isWatched) { + let episodeInfo = ''; + if (type === 'series' && watchProgress?.episodeId) { + const details = getEpisodeDetails(watchProgress.episodeId); + if (details) { + episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`; + } + } + + const watchedDate = watchProgress?.lastUpdated + ? new Date(watchProgress.lastUpdated).toLocaleDateString() + : new Date().toLocaleDateString(); + + // Determine if watched via Trakt or local + const watchedViaTrakt = isTraktAuthenticated && + watchProgress?.traktProgress !== undefined && + watchProgress.traktProgress >= 95; + + return { + progressPercent: 100, + formattedTime: watchedDate, + episodeInfo, + displayText: watchedViaTrakt ? 'Watched on Trakt' : 'Watched', + syncStatus: isTraktAuthenticated && watchProgress?.traktSynced ? '' : '', // Clean look for watched + isTraktSynced: watchProgress?.traktSynced && isTraktAuthenticated, + isWatched: true + }; + } + if (!watchProgress || watchProgress.duration === 0) return null; - const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; + // Determine which progress to show - prioritize Trakt if available and authenticated + let progressPercent; + let isUsingTraktProgress = false; + + if (isTraktAuthenticated && watchProgress.traktProgress !== undefined) { + progressPercent = watchProgress.traktProgress; + isUsingTraktProgress = true; + } else { + progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; + } const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString(); let episodeInfo = ''; @@ -242,7 +329,12 @@ const WatchProgressDisplay = React.memo(({ // Show Trakt sync status if user is authenticated if (isTraktAuthenticated) { - if (watchProgress.traktSynced) { + if (isUsingTraktProgress) { + syncStatus = ' • Using Trakt progress'; + if (watchProgress.traktSynced) { + syncStatus = ' • Synced with Trakt'; + } + } else if (watchProgress.traktSynced) { syncStatus = ' • Synced with Trakt'; // If we have specific Trakt progress that differs from local, mention it if (watchProgress.traktProgress !== undefined && @@ -260,9 +352,10 @@ const WatchProgressDisplay = React.memo(({ episodeInfo, displayText, syncStatus, - isTraktSynced: watchProgress.traktSynced && isTraktAuthenticated + isTraktSynced: watchProgress.traktSynced && isTraktAuthenticated, + isWatched: false }; - }, [watchProgress, type, getEpisodeDetails, isTraktAuthenticated]); + }, [watchProgress, type, getEpisodeDetails, isTraktAuthenticated, isWatched]); if (!progressData) return null; @@ -274,14 +367,26 @@ const WatchProgressDisplay = React.memo(({ styles.watchProgressFill, { width: `${progressData.progressPercent}%`, - backgroundColor: progressData.isTraktSynced - ? '#E50914' // Netflix red for Trakt synced content - : currentTheme.colors.primary + backgroundColor: progressData.isWatched + ? '#666' // Subtle gray for completed + : progressData.isTraktSynced + ? '#E50914' // Netflix red for Trakt synced content + : currentTheme.colors.primary } ]} /> - {/* Trakt sync indicator */} - {progressData.isTraktSynced && ( + {/* Subtle watched indicator */} + {progressData.isWatched && ( + <View style={styles.watchedProgressIndicator}> + <MaterialIcons + name="check" + size={6} + color="rgba(255,255,255,0.8)" + /> + </View> + )} + {/* Trakt sync indicator for non-watched content */} + {progressData.isTraktSynced && !progressData.isWatched && ( <View style={styles.traktSyncIndicator}> <MaterialIcons name="sync" @@ -291,10 +396,30 @@ const WatchProgressDisplay = React.memo(({ </View> )} </View> - <Text style={[styles.watchProgressText, { color: currentTheme.colors.textMuted }]}> - {progressData.displayText}{progressData.episodeInfo} • Last watched on {progressData.formattedTime} - {progressData.syncStatus} - </Text> + <View style={styles.watchProgressTextContainer}> + <Text style={[styles.watchProgressText, { + color: progressData.isWatched ? 'rgba(255,255,255,0.6)' : currentTheme.colors.textMuted, + fontSize: progressData.isWatched ? 10 : 11 + }]}> + {progressData.displayText}{progressData.episodeInfo} • Last watched on {progressData.formattedTime} + {progressData.syncStatus} + </Text> + + {/* Manual Trakt sync button */} + {isTraktAuthenticated && forceSyncTraktProgress && ( + <TouchableOpacity + style={styles.traktSyncButton} + onPress={handleTraktSync} + activeOpacity={0.7} + > + <MaterialIcons + name="refresh" + size={14} + color={currentTheme.colors.textMuted} + /> + </TouchableOpacity> + )} + </View> </Animated.View> ); }); @@ -452,6 +577,25 @@ const HeroSection: React.FC<HeroSectionProps> = ({ // Memoized play button text const playButtonText = useMemo(() => getPlayButtonText(), [getPlayButtonText]); + // Calculate if content is watched (>=95% progress) - check both local and Trakt progress + const isWatched = useMemo(() => { + if (!watchProgress) return false; + + // Check Trakt progress first if available and user is authenticated + if (isTraktAuthenticated && watchProgress.traktProgress !== undefined) { + const traktWatched = watchProgress.traktProgress >= 95; + logger.log(`[HeroSection] Trakt authenticated: ${isTraktAuthenticated}, Trakt progress: ${watchProgress.traktProgress}%, Watched: ${traktWatched}`); + return traktWatched; + } + + // Fall back to local progress + if (watchProgress.duration === 0) return false; + const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; + const localWatched = progressPercent >= 95; + logger.log(`[HeroSection] Local progress: ${progressPercent.toFixed(1)}%, Watched: ${localWatched}`); + return localWatched; + }, [watchProgress, isTraktAuthenticated]); + return ( <Animated.View style={[styles.heroSection, heroAnimatedStyle]}> {/* Optimized Background */} @@ -521,6 +665,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ type={type} getEpisodeDetails={getEpisodeDetails} animatedStyle={watchProgressAnimatedStyle} + isWatched={isWatched} /> {/* Optimized Genres */} @@ -540,6 +685,8 @@ const HeroSection: React.FC<HeroSectionProps> = ({ navigation={navigation} playButtonText={playButtonText} animatedStyle={buttonsAnimatedStyle} + isWatched={isWatched} + watchProgress={watchProgress} /> </View> </LinearGradient> @@ -623,6 +770,7 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', width: '100%', + position: 'relative', }, actionButton: { flexDirection: 'row', @@ -697,11 +845,32 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + watchedProgressIndicator: { + position: 'absolute', + right: 2, + top: -1, + bottom: -1, + width: 10, + alignItems: 'center', + justifyContent: 'center', + }, + watchProgressTextContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, watchProgressText: { fontSize: 11, textAlign: 'center', opacity: 0.85, - letterSpacing: 0.1 + letterSpacing: 0.1, + flex: 1, + }, + traktSyncButton: { + padding: 4, + borderRadius: 12, + backgroundColor: 'rgba(255,255,255,0.1)', }, blurBackground: { position: 'absolute', @@ -737,6 +906,33 @@ const styles = StyleSheet.create({ borderRadius: 25, backgroundColor: 'rgba(255,255,255,0.15)', }, + watchedIndicator: { + position: 'absolute', + top: 4, + right: 4, + backgroundColor: 'rgba(0,0,0,0.6)', + borderRadius: 8, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + }, + watchedPlayButton: { + backgroundColor: '#1e1e1e', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.3)', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 4, + }, + watchedPlayButtonText: { + color: '#fff', + fontWeight: '700', + marginLeft: 6, + fontSize: 15, + }, }); export default React.memo(HeroSection); \ No newline at end of file diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index 7b4e71b..27c414b 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -281,15 +281,16 @@ export function useTraktIntegration() { } try { - logger.log('[useTraktIntegration] Fetching Trakt playback progress...'); - const traktProgress = await getTraktPlaybackProgress(); - logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} Trakt progress items`); + // Fetch both playback progress and recently watched movies + logger.log('[useTraktIntegration] Fetching Trakt playback progress and watched movies...'); + const [traktProgress, watchedMovies] = await Promise.all([ + getTraktPlaybackProgress(), + traktService.getWatchedMovies() + ]); - if (traktProgress.length === 0) { - logger.log('[useTraktIntegration] No Trakt progress found - user may not have any content in progress'); - return true; // Not an error, just no data - } + logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} Trakt progress items, ${watchedMovies.length} watched movies`); + // Process playback progress (in-progress items) for (const item of traktProgress) { try { let id: string; @@ -299,14 +300,14 @@ export function useTraktIntegration() { if (item.type === 'movie' && item.movie) { id = item.movie.ids.imdb; type = 'movie'; - logger.log(`[useTraktIntegration] Processing Trakt movie: ${item.movie.title} (${id}) - ${item.progress}%`); + logger.log(`[useTraktIntegration] Processing Trakt movie progress: ${item.movie.title} (${id}) - ${item.progress}%`); } else if (item.type === 'episode' && item.show && item.episode) { id = item.show.ids.imdb; type = 'series'; episodeId = `${id}:${item.episode.season}:${item.episode.number}`; - logger.log(`[useTraktIntegration] Processing Trakt episode: ${item.show.title} S${item.episode.season}E${item.episode.number} (${id}) - ${item.progress}%`); + logger.log(`[useTraktIntegration] Processing Trakt episode progress: ${item.show.title} S${item.episode.season}E${item.episode.number} (${id}) - ${item.progress}%`); } else { - logger.warn(`[useTraktIntegration] Skipping invalid Trakt item:`, item); + logger.warn(`[useTraktIntegration] Skipping invalid Trakt progress item:`, item); continue; } @@ -323,7 +324,27 @@ export function useTraktIntegration() { } } - logger.log(`[useTraktIntegration] Successfully merged ${traktProgress.length} Trakt progress entries`); + // Process watched movies (100% completed) + for (const movie of watchedMovies) { + try { + if (movie.movie?.ids?.imdb) { + const id = movie.movie.ids.imdb; + const watchedAt = movie.last_watched_at; + logger.log(`[useTraktIntegration] Processing watched movie: ${movie.movie.title} (${id}) - 100% watched on ${watchedAt}`); + + await storageService.mergeWithTraktProgress( + id, + 'movie', + 100, // 100% progress for watched items + watchedAt + ); + } + } catch (error) { + logger.error('[useTraktIntegration] Error merging watched movie:', error); + } + } + + logger.log(`[useTraktIntegration] Successfully merged ${traktProgress.length} progress items + ${watchedMovies.length} watched movies`); return true; } catch (error) { logger.error('[useTraktIntegration] Error fetching and merging Trakt progress:', error); @@ -362,6 +383,24 @@ export function useTraktIntegration() { } }, [isAuthenticated, fetchAndMergeTraktProgress]); + // Periodic sync - check for updates every 2 minutes when authenticated + useEffect(() => { + if (!isAuthenticated) return; + + const intervalId = setInterval(() => { + logger.log('[useTraktIntegration] Periodic Trakt sync check'); + fetchAndMergeTraktProgress().then((success) => { + if (success) { + logger.log('[useTraktIntegration] Periodic sync completed successfully'); + } + }).catch(error => { + logger.error('[useTraktIntegration] Periodic sync failed:', error); + }); + }, 2 * 60 * 1000); // 2 minutes + + return () => clearInterval(intervalId); + }, [isAuthenticated, fetchAndMergeTraktProgress]); + // Trigger sync when auth status is manually refreshed (for login scenarios) useEffect(() => { if (isAuthenticated) { diff --git a/src/hooks/useWatchProgress.ts b/src/hooks/useWatchProgress.ts index 0dcc9df..b17e434 100644 --- a/src/hooks/useWatchProgress.ts +++ b/src/hooks/useWatchProgress.ts @@ -91,62 +91,8 @@ export const useWatchProgress = ( if (episodeId) { const progress = await storageService.getWatchProgress(id, type, episodeId); if (progress) { - const progressPercent = (progress.currentTime / progress.duration) * 100; - - // If current episode is finished (≥95%), try to find next unwatched episode - if (progressPercent >= 95) { - const currentEpNum = getEpisodeNumber(episodeId); - if (currentEpNum && episodes.length > 0) { - // Find the next episode - const nextEpisode = episodes.find(ep => { - // First check in same season - if (ep.season_number === currentEpNum.season && ep.episode_number > currentEpNum.episode) { - const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; - const epProgress = seriesProgresses.find(p => p.episodeId === epId); - if (!epProgress) return true; - const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100; - return percent < 95; - } - // Then check next seasons - if (ep.season_number > currentEpNum.season) { - const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; - const epProgress = seriesProgresses.find(p => p.episodeId === epId); - if (!epProgress) return true; - const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100; - return percent < 95; - } - return false; - }); - - if (nextEpisode) { - const nextEpisodeId = nextEpisode.stremioId || - `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`; - const nextProgress = await storageService.getWatchProgress(id, type, nextEpisodeId); - if (nextProgress) { - setWatchProgress({ - ...nextProgress, - episodeId: nextEpisodeId, - traktSynced: nextProgress.traktSynced, - traktProgress: nextProgress.traktProgress - }); - } else { - setWatchProgress({ - currentTime: 0, - duration: 0, - lastUpdated: Date.now(), - episodeId: nextEpisodeId, - traktSynced: false - }); - } - return; - } - } - // If no next episode found or current episode is finished, show no progress - setWatchProgress(null); - return; - } - - // If current episode is not finished, show its progress + // Always show the current episode progress when viewing it specifically + // This allows HeroSection to properly display watched state setWatchProgress({ ...progress, episodeId, @@ -194,17 +140,14 @@ export const useWatchProgress = ( // For movies const progress = await storageService.getWatchProgress(id, type, episodeId); if (progress && progress.currentTime > 0) { - const progressPercent = (progress.currentTime / progress.duration) * 100; - if (progressPercent >= 95) { - setWatchProgress(null); - } else { - setWatchProgress({ - ...progress, - episodeId, - traktSynced: progress.traktSynced, - traktProgress: progress.traktProgress - }); - } + // Always show progress data, even if watched (≥95%) + // The HeroSection will handle the "watched" state display + setWatchProgress({ + ...progress, + episodeId, + traktSynced: progress.traktSynced, + traktProgress: progress.traktProgress + }); } else { setWatchProgress(null); } -- 2.45.2 From 6d8666d905c42dc66c6a8f18ec0d38d50bd3581b Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Thu, 19 Jun 2025 23:45:10 +0530 Subject: [PATCH 62/88] Enhance Trakt autosync functionality with improved session management and deduplication This update introduces several enhancements to the Trakt autosync logic, including new state tracking for session completion and stop calls. The useTraktAutosync hook now prevents duplicate session starts and rapid successive stop calls, improving the reliability of playback tracking. Additionally, the TraktService has been updated to manage stop call deduplication more effectively, ensuring accurate scrobbling and session handling. These changes enhance the overall user experience by maintaining consistent watch history and reducing unnecessary API calls. --- src/hooks/useTraktAutosync.ts | 82 ++++++++++++++++++++++++++++++-- src/hooks/useTraktIntegration.ts | 31 +++++++----- src/services/traktService.ts | 69 +++++++++++++++++++++++---- 3 files changed, 155 insertions(+), 27 deletions(-) diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index a389359..e316bc5 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -31,10 +31,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { const { settings: autosyncSettings } = useTraktAutosyncSettings(); const hasStartedWatching = useRef(false); + const hasStopped = useRef(false); // New: Track if we've already stopped for this session + const isSessionComplete = useRef(false); // New: Track if session is completely finished (scrobbled) const lastSyncTime = useRef(0); const lastSyncProgress = useRef(0); const sessionKey = useRef<string | null>(null); const unmountCount = useRef(0); + const lastStopCall = useRef(0); // New: Track last stop call timestamp // Generate a unique session key for this content instance useEffect(() => { @@ -43,6 +46,12 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { : `episode:${options.imdbId}:${options.season}:${options.episode}`; sessionKey.current = `${contentKey}:${Date.now()}`; + // Reset all session state for new content + hasStartedWatching.current = false; + hasStopped.current = false; + isSessionComplete.current = false; + lastStopCall.current = 0; + logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`); return () => { @@ -93,10 +102,27 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Start watching (scrobble start) const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => { - logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, session=${sessionKey.current}`); + logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, alreadyStopped=${hasStopped.current}, sessionComplete=${isSessionComplete.current}, session=${sessionKey.current}`); - if (!isAuthenticated || !autosyncSettings.enabled || hasStartedWatching.current) { - logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}`); + if (!isAuthenticated || !autosyncSettings.enabled) { + logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`); + return; + } + + // PREVENT SESSION RESTART: Don't start if session is complete (scrobbled) + if (isSessionComplete.current) { + logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session is complete, preventing any restart`); + return; + } + + // PREVENT SESSION RESTART: Don't start if we've already stopped this session + if (hasStopped.current) { + logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session already stopped, preventing restart`); + return; + } + + if (hasStartedWatching.current) { + logger.log(`[TraktAutosync] Skipping handlePlaybackStart: already started=${hasStartedWatching.current}`); return; } @@ -112,6 +138,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { const success = await startWatching(contentData, progressPercent); if (success) { hasStartedWatching.current = true; + hasStopped.current = false; // Reset stop flag when starting logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`); } } catch (error) { @@ -129,6 +156,11 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return; } + // Skip if session is already complete + if (isSessionComplete.current) { + return; + } + try { const progressPercent = (currentTime / duration) * 100; const now = Date.now(); @@ -166,13 +198,33 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Handle playback end/pause const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' = 'ended') => { - logger.log(`[TraktAutosync] handlePlaybackEnd called: reason=${reason}, time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, started=${hasStartedWatching.current}, session=${sessionKey.current}, unmountCount=${unmountCount.current}`); + const now = Date.now(); + + logger.log(`[TraktAutosync] handlePlaybackEnd called: reason=${reason}, time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, started=${hasStartedWatching.current}, stopped=${hasStopped.current}, complete=${isSessionComplete.current}, session=${sessionKey.current}, unmountCount=${unmountCount.current}`); if (!isAuthenticated || !autosyncSettings.enabled) { logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`); return; } + // ENHANCED DEDUPLICATION: Check if session is already complete + if (isSessionComplete.current) { + logger.log(`[TraktAutosync] Session already complete, skipping end call (reason: ${reason})`); + return; + } + + // ENHANCED DEDUPLICATION: Check if we've already stopped this session + if (hasStopped.current) { + logger.log(`[TraktAutosync] Already stopped this session, skipping duplicate call (reason: ${reason})`); + return; + } + + // ENHANCED DEDUPLICATION: Prevent rapid successive calls (within 5 seconds) + if (now - lastStopCall.current < 5000) { + logger.log(`[TraktAutosync] Ignoring rapid successive stop call within 5 seconds (reason: ${reason})`); + return; + } + // Skip rapid unmount calls (likely from React strict mode or component remounts) if (reason === 'unmount' && unmountCount.current > 1) { logger.log(`[TraktAutosync] Skipping duplicate unmount call #${unmountCount.current}`); @@ -208,6 +260,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return; } + // Mark stop attempt and update timestamp + lastStopCall.current = now; + hasStopped.current = true; + const contentData = buildContentData(); // Use stopWatching for proper scrobble stop @@ -222,6 +278,18 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { progressPercent, options.episodeId ); + + // Mark session as complete if high progress (scrobbled) + if (progressPercent >= 80) { + isSessionComplete.current = true; + logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`); + } + + logger.log(`[TraktAutosync] Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); + } else { + // If stop failed, reset the stop flag so we can try again later + hasStopped.current = false; + logger.warn(`[TraktAutosync] Failed to stop watching, reset stop flag for retry`); } // Reset state only for natural end or very high progress unmounts @@ -232,19 +300,23 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { logger.log(`[TraktAutosync] Reset session state for ${reason} at ${progressPercent.toFixed(1)}%`); } - logger.log(`[TraktAutosync] Ended watching: ${options.title} (${reason})`); } catch (error) { logger.error('[TraktAutosync] Error ending watch:', error); + // Reset stop flag on error so we can try again + hasStopped.current = false; } }, [isAuthenticated, autosyncSettings.enabled, stopWatching, buildContentData, options]); // Reset state (useful when switching content) const resetState = useCallback(() => { hasStartedWatching.current = false; + hasStopped.current = false; + isSessionComplete.current = false; lastSyncTime.current = 0; lastSyncProgress.current = 0; unmountCount.current = 0; sessionKey.current = null; + lastStopCall.current = 0; logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`); }, [options.title]); diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index 27c414b..d19ba3c 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; import { traktService, TraktUser, TraktWatchedItem, TraktContentData, TraktPlaybackItem } from '../services/traktService'; import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; @@ -383,22 +384,28 @@ export function useTraktIntegration() { } }, [isAuthenticated, fetchAndMergeTraktProgress]); - // Periodic sync - check for updates every 2 minutes when authenticated + // App focus sync - sync when app comes back into focus (much smarter than periodic) useEffect(() => { if (!isAuthenticated) return; - const intervalId = setInterval(() => { - logger.log('[useTraktIntegration] Periodic Trakt sync check'); - fetchAndMergeTraktProgress().then((success) => { - if (success) { - logger.log('[useTraktIntegration] Periodic sync completed successfully'); - } - }).catch(error => { - logger.error('[useTraktIntegration] Periodic sync failed:', error); - }); - }, 2 * 60 * 1000); // 2 minutes + const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (nextAppState === 'active') { + logger.log('[useTraktIntegration] App became active, syncing Trakt data'); + fetchAndMergeTraktProgress().then((success) => { + if (success) { + logger.log('[useTraktIntegration] App focus sync completed successfully'); + } + }).catch(error => { + logger.error('[useTraktIntegration] App focus sync failed:', error); + }); + } + }; - return () => clearInterval(intervalId); + const subscription = AppState.addEventListener('change', handleAppStateChange); + + return () => { + subscription?.remove(); + }; }, [isAuthenticated, fetchAndMergeTraktProgress]); // Trigger sync when auth status is manually refreshed (for login scenarios) diff --git a/src/services/traktService.ts b/src/services/traktService.ts index ffccb9a..5b3b965 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -163,8 +163,40 @@ export class TraktService { private readonly SCROBBLE_EXPIRY_MS = 46 * 60 * 1000; // 46 minutes (based on Trakt's expiry window) private scrobbledTimestamps: Map<string, number> = new Map(); + // Track currently watching sessions to avoid duplicate starts + private currentlyWatching: Set<string> = new Set(); + private lastSyncTime: number = 0; + private readonly SYNC_DEBOUNCE_MS = 60000; // 60 seconds + + // Enhanced deduplication for stop calls + private lastStopCalls: Map<string, number> = new Map(); + private readonly STOP_DEBOUNCE_MS = 10000; // 10 seconds debounce for stop calls + private constructor() { // Initialization happens in initialize method + + // Cleanup old stop call records every 5 minutes + setInterval(() => { + this.cleanupOldStopCalls(); + }, 5 * 60 * 1000); + } + + /** + * Cleanup old stop call records to prevent memory leaks + */ + private cleanupOldStopCalls(): void { + const now = Date.now(); + const cutoff = now - (this.STOP_DEBOUNCE_MS * 2); // Keep records for 2x the debounce time + + for (const [key, timestamp] of this.lastStopCalls.entries()) { + if (timestamp < cutoff) { + this.lastStopCalls.delete(key); + } + } + + if (this.lastStopCalls.size > 0) { + logger.log(`[TraktService] Cleaned up old stop call records. Remaining: ${this.lastStopCalls.size}`); + } } public static getInstance(): TraktService { @@ -876,13 +908,6 @@ export class TraktService { }); } - /** - * Track currently watching sessions to avoid duplicate starts - */ - private currentlyWatching: Set<string> = new Set(); - private lastSyncTime: number = 0; - private readonly SYNC_DEBOUNCE_MS = 60000; // 60 seconds - /** * Generate a unique key for content being watched */ @@ -903,12 +928,22 @@ export class TraktService { return false; } + const watchingKey = this.getWatchingKey(contentData); + // Check if this content was recently scrobbled (to prevent duplicates from component remounts) if (this.isRecentlyScrobbled(contentData)) { logger.log(`[TraktService] Content was recently scrobbled, skipping start: ${contentData.title}`); return true; } + // ENHANCED PROTECTION: Check if we recently stopped this content with high progress + // This prevents restarting sessions for content that was just completed + const lastStopTime = this.lastStopCalls.get(watchingKey); + if (lastStopTime && (Date.now() - lastStopTime) < 30000) { // 30 seconds + logger.log(`[TraktService] Recently stopped this content (${((Date.now() - lastStopTime) / 1000).toFixed(1)}s ago), preventing restart: ${contentData.title}`); + return true; + } + // Debug log the content data being sent logger.log(`[TraktService] DEBUG scrobbleStart payload:`, { type: contentData.type, @@ -920,11 +955,10 @@ export class TraktService { showTitle: contentData.showTitle, progress: progress }); - - const watchingKey = this.getWatchingKey(contentData); // Only start if not already watching this content if (this.currentlyWatching.has(watchingKey)) { + logger.log(`[TraktService] Already watching this content, skipping start: ${contentData.title}`); return true; // Already started } @@ -995,6 +1029,17 @@ export class TraktService { } const watchingKey = this.getWatchingKey(contentData); + const now = Date.now(); + + // Enhanced deduplication: Check if we recently stopped this content + const lastStopTime = this.lastStopCalls.get(watchingKey); + if (lastStopTime && (now - lastStopTime) < this.STOP_DEBOUNCE_MS) { + logger.log(`[TraktService] Ignoring duplicate stop call for ${contentData.title} (last stop ${((now - lastStopTime) / 1000).toFixed(1)}s ago)`); + return true; // Return success to avoid error handling + } + + // Record this stop attempt + this.lastStopCalls.set(watchingKey, now); const result = await this.queueRequest(async () => { return await this.stopWatching(contentData, progress); @@ -1003,10 +1048,11 @@ export class TraktService { if (result) { this.currentlyWatching.delete(watchingKey); - // Mark as scrobbled if >= 80% to prevent future duplicates + // Mark as scrobbled if >= 80% to prevent future duplicates and restarts if (progress >= 80) { this.scrobbledItems.add(watchingKey); this.scrobbledTimestamps.set(watchingKey, Date.now()); + logger.log(`[TraktService] Marked as scrobbled to prevent restarts: ${watchingKey}`); } // The stop endpoint automatically handles the 80%+ completion logic @@ -1015,6 +1061,9 @@ export class TraktService { logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`); return true; + } else { + // If failed, remove from lastStopCalls so we can try again + this.lastStopCalls.delete(watchingKey); } return false; -- 2.45.2 From 6bd9cc806455021a7fe3939a3a05893db1c4fc3a Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Thu, 19 Jun 2025 23:52:51 +0530 Subject: [PATCH 63/88] Refactor Trakt integration for improved session handling and UI updates This update refines the Trakt integration by enhancing session management and updating UI components to better reflect watched states. The useTraktAutosync and useWatchProgress hooks have been optimized for more reliable session tracking and progress display. Additionally, the HeroSection and ActionButtons now provide clearer indicators of content watched status, improving user experience and synchronization with Trakt. --- src/components/metadata/SeriesContent.tsx | 35 +++++++++++------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index a8ee469..bcdf564 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -334,14 +334,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ style={[ styles.episodeCardHorizontal, isTablet && styles.episodeCardHorizontalTablet, - // Enhanced border styling + // Gradient border styling { - borderWidth: 2, - borderColor: 'transparent', // Make base border transparent - // Add gradient border effect using shadow - shadowColor: currentTheme.colors.primary, - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.6, + borderWidth: 1, + borderColor: 'transparent', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, shadowRadius: 8, elevation: 12, } @@ -352,26 +351,26 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ {/* Gradient Border Container */} <View style={{ position: 'absolute', - top: -2, - left: -2, - right: -2, - bottom: -2, - borderRadius: 18, + top: -1, + left: -1, + right: -1, + bottom: -1, + borderRadius: 17, zIndex: -1, }}> <LinearGradient colors={[ - currentTheme.colors.primary + '80', // 80% opacity - currentTheme.colors.primary + '40', // 40% opacity - currentTheme.colors.primary + '20', // 20% opacity - currentTheme.colors.primary + '40', // 40% opacity - currentTheme.colors.primary + '80', // 80% opacity + '#ffffff80', // White with 50% opacity + '#ffffff40', // White with 25% opacity + '#ffffff20', // White with 12% opacity + '#ffffff40', // White with 25% opacity + '#ffffff80', // White with 50% opacity ]} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={{ flex: 1, - borderRadius: 18, + borderRadius: 17, }} /> </View> -- 2.45.2 From 17dec56bc9fb62b7aa36f2ab322be692582de299 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Thu, 19 Jun 2025 23:57:53 +0530 Subject: [PATCH 64/88] padding fix --- src/components/metadata/SeriesContent.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index bcdf564..37e0cf7 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -541,7 +541,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ const styles = StyleSheet.create({ container: { flex: 1, - padding: 16, + paddingVertical: 16, }, centeredContainer: { flex: 1, @@ -558,6 +558,7 @@ const styles = StyleSheet.create({ fontSize: 20, fontWeight: '700', marginBottom: 16, + paddingHorizontal: 16, }, episodeList: { flex: 1, @@ -566,6 +567,7 @@ const styles = StyleSheet.create({ // Vertical Layout Styles episodeListContentVertical: { paddingBottom: 20, + paddingHorizontal: 16, }, episodeListContentVerticalTablet: { paddingHorizontal: 8, @@ -706,7 +708,7 @@ const styles = StyleSheet.create({ // Horizontal Layout Styles episodeListContentHorizontal: { - paddingLeft: 0, + paddingLeft: 16, paddingRight: 16, }, episodeCardWrapperHorizontal: { @@ -843,6 +845,7 @@ const styles = StyleSheet.create({ // Season Selector Styles seasonSelectorWrapper: { marginBottom: 20, + paddingHorizontal: 16, }, seasonSelectorTitle: { fontSize: 18, -- 2.45.2 From 237c1fae3d3bc65254a55562d10a33cc85a009ba Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 00:07:46 +0530 Subject: [PATCH 65/88] Enhance Trakt integration with support for watchlist, collection, and ratings This update expands the Trakt integration by adding functionality to manage watchlist items, collections, and user ratings. New interfaces for TraktWatchlistItem, TraktCollectionItem, and TraktRatingItem have been introduced, along with corresponding methods in the TraktService to fetch this data. The useTraktIntegration hook has been updated to load all collections, and the LibraryScreen now displays these new categories, improving the overall user experience and content organization. --- src/contexts/TraktContext.tsx | 16 +- src/hooks/useTraktIntegration.ts | 59 ++++- src/screens/LibraryScreen.tsx | 396 +++++++++++++++++++++++++++++-- src/services/traktService.ts | 109 +++++++++ 4 files changed, 563 insertions(+), 17 deletions(-) diff --git a/src/contexts/TraktContext.tsx b/src/contexts/TraktContext.tsx index 01d5b22..1cc30fe 100644 --- a/src/contexts/TraktContext.tsx +++ b/src/contexts/TraktContext.tsx @@ -1,6 +1,13 @@ import React, { createContext, useContext, ReactNode } from 'react'; import { useTraktIntegration } from '../hooks/useTraktIntegration'; -import { TraktUser, TraktWatchedItem } from '../services/traktService'; +import { + TraktUser, + TraktWatchedItem, + TraktWatchlistItem, + TraktCollectionItem, + TraktRatingItem, + TraktPlaybackItem +} from '../services/traktService'; interface TraktContextProps { isAuthenticated: boolean; @@ -8,9 +15,16 @@ interface TraktContextProps { userProfile: TraktUser | null; watchedMovies: TraktWatchedItem[]; watchedShows: TraktWatchedItem[]; + watchlistMovies: TraktWatchlistItem[]; + watchlistShows: TraktWatchlistItem[]; + collectionMovies: TraktCollectionItem[]; + collectionShows: TraktCollectionItem[]; + continueWatching: TraktPlaybackItem[]; + ratedContent: TraktRatingItem[]; checkAuthStatus: () => Promise<void>; refreshAuthStatus: () => Promise<void>; loadWatchedItems: () => Promise<void>; + loadAllCollections: () => Promise<void>; isMovieWatched: (imdbId: string) => Promise<boolean>; isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise<boolean>; markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise<boolean>; diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index d19ba3c..425ac1c 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -1,6 +1,15 @@ import { useState, useEffect, useCallback } from 'react'; import { AppState, AppStateStatus } from 'react-native'; -import { traktService, TraktUser, TraktWatchedItem, TraktContentData, TraktPlaybackItem } from '../services/traktService'; +import { + traktService, + TraktUser, + TraktWatchedItem, + TraktWatchlistItem, + TraktCollectionItem, + TraktRatingItem, + TraktContentData, + TraktPlaybackItem +} from '../services/traktService'; import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; @@ -10,6 +19,12 @@ export function useTraktIntegration() { const [userProfile, setUserProfile] = useState<TraktUser | null>(null); const [watchedMovies, setWatchedMovies] = useState<TraktWatchedItem[]>([]); const [watchedShows, setWatchedShows] = useState<TraktWatchedItem[]>([]); + const [watchlistMovies, setWatchlistMovies] = useState<TraktWatchlistItem[]>([]); + const [watchlistShows, setWatchlistShows] = useState<TraktWatchlistItem[]>([]); + const [collectionMovies, setCollectionMovies] = useState<TraktCollectionItem[]>([]); + const [collectionShows, setCollectionShows] = useState<TraktCollectionItem[]>([]); + const [continueWatching, setContinueWatching] = useState<TraktPlaybackItem[]>([]); + const [ratedContent, setRatedContent] = useState<TraktRatingItem[]>([]); const [lastAuthCheck, setLastAuthCheck] = useState<number>(Date.now()); // Check authentication status @@ -65,6 +80,41 @@ export function useTraktIntegration() { } }, [isAuthenticated]); + // Load all collections (watchlist, collection, continue watching, ratings) + const loadAllCollections = useCallback(async () => { + if (!isAuthenticated) return; + + setIsLoading(true); + try { + const [ + watchlistMovies, + watchlistShows, + collectionMovies, + collectionShows, + continueWatching, + ratings + ] = await Promise.all([ + traktService.getWatchlistMovies(), + traktService.getWatchlistShows(), + traktService.getCollectionMovies(), + traktService.getCollectionShows(), + traktService.getPlaybackProgress(), + traktService.getRatings() + ]); + + setWatchlistMovies(watchlistMovies); + setWatchlistShows(watchlistShows); + setCollectionMovies(collectionMovies); + setCollectionShows(collectionShows); + setContinueWatching(continueWatching); + setRatedContent(ratings); + } catch (error) { + logger.error('[useTraktIntegration] Error loading all collections:', error); + } finally { + setIsLoading(false); + } + }, [isAuthenticated]); + // Check if a movie is watched const isMovieWatched = useCallback(async (imdbId: string): Promise<boolean> => { if (!isAuthenticated) return false; @@ -436,8 +486,15 @@ export function useTraktIntegration() { userProfile, watchedMovies, watchedShows, + watchlistMovies, + watchlistShows, + collectionMovies, + collectionShows, + continueWatching, + ratedContent, checkAuthStatus, loadWatchedItems, + loadAllCollections, isMovieWatched, isEpisodeWatched, markMovieAsWatched, diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 50eb5aa..36513b8 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { View, Text, @@ -42,12 +42,22 @@ interface TraktDisplayItem { type: 'movie' | 'series'; poster: string; year?: number; - lastWatched: string; - plays: number; + lastWatched?: string; + plays?: number; + rating?: number; imdbId?: string; traktId: number; } +interface TraktFolder { + id: string; + name: string; + icon: keyof typeof MaterialIcons.glyphMap; + description: string; + itemCount: number; + gradient: [string, string]; +} + const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const SkeletonLoader = () => { @@ -116,6 +126,7 @@ const LibraryScreen = () => { const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]); const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all'); const [showTraktContent, setShowTraktContent] = useState(false); + const [selectedTraktFolder, setSelectedTraktFolder] = useState<string | null>(null); const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); @@ -125,7 +136,14 @@ const LibraryScreen = () => { isLoading: traktLoading, watchedMovies, watchedShows, - loadWatchedItems + watchlistMovies, + watchlistShows, + collectionMovies, + collectionShows, + continueWatching, + ratedContent, + loadWatchedItems, + loadAllCollections } = useTraktContext(); // Force consistent status bar settings @@ -177,6 +195,57 @@ const LibraryScreen = () => { return true; }); + // Generate Trakt collection folders + const traktFolders = useMemo((): TraktFolder[] => { + if (!traktAuthenticated) return []; + + const folders: TraktFolder[] = [ + { + id: 'watched', + name: 'Watched', + icon: 'visibility', + description: 'Your watched content', + itemCount: (watchedMovies?.length || 0) + (watchedShows?.length || 0), + gradient: ['#4CAF50', '#2E7D32'] + }, + { + id: 'continue-watching', + name: 'Continue Watching', + icon: 'play-circle-outline', + description: 'Resume your progress', + itemCount: continueWatching?.length || 0, + gradient: ['#FF9800', '#F57C00'] + }, + { + id: 'watchlist', + name: 'Watchlist', + icon: 'bookmark', + description: 'Want to watch', + itemCount: (watchlistMovies?.length || 0) + (watchlistShows?.length || 0), + gradient: ['#2196F3', '#1976D2'] + }, + { + id: 'collection', + name: 'Collection', + icon: 'library-add', + description: 'Your collection', + itemCount: (collectionMovies?.length || 0) + (collectionShows?.length || 0), + gradient: ['#9C27B0', '#7B1FA2'] + }, + { + id: 'ratings', + name: 'Rated', + icon: 'star', + description: 'Your ratings', + itemCount: ratedContent?.length || 0, + gradient: ['#FF5722', '#D84315'] + } + ]; + + // Only return folders that have content + return folders.filter(folder => folder.itemCount > 0); + }, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); + // Prepare Trakt items with proper poster URLs const traktItems = useMemo(() => { if (!traktAuthenticated || (!watchedMovies?.length && !watchedShows?.length)) { @@ -226,7 +295,11 @@ const LibraryScreen = () => { } // Sort by last watched date (most recent first) - return items.sort((a, b) => new Date(b.lastWatched).getTime() - new Date(a.lastWatched).getTime()); + return items.sort((a, b) => { + const dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0; + const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0; + return dateB - dateA; + }); }, [traktAuthenticated, watchedMovies, watchedShows]); // State for tracking poster URLs @@ -348,6 +421,41 @@ const LibraryScreen = () => { </TouchableOpacity> ); + // Render individual Trakt collection folder + const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => ( + <TouchableOpacity + style={[styles.itemContainer, { width: itemWidth }]} + onPress={() => { + setSelectedTraktFolder(folder.id); + loadAllCollections(); // Load all collections when entering a specific folder + }} + activeOpacity={0.7} + > + <View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black }]}> + <LinearGradient + colors={folder.gradient} + style={styles.folderGradient} + > + <MaterialIcons + name={folder.icon} + size={60} + color={currentTheme.colors.white} + style={{ marginBottom: 12 }} + /> + <Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}> + {folder.name} + </Text> + <Text style={styles.folderCount}> + {folder.itemCount} items + </Text> + <Text style={styles.folderSubtitle}> + {folder.description} + </Text> + </LinearGradient> + </View> + </TouchableOpacity> + ); + const renderTraktFolder = () => ( <TouchableOpacity style={[styles.itemContainer, { width: itemWidth }]} @@ -356,6 +464,8 @@ const LibraryScreen = () => { navigation.navigate('TraktSettings'); } else { setShowTraktContent(true); + setSelectedTraktFolder(null); // Reset to folder view + loadAllCollections(); // Load all collections when opening } }} activeOpacity={0.7} @@ -427,7 +537,7 @@ const LibraryScreen = () => { <Text style={styles.lastWatched}> Last watched: {item.lastWatched} </Text> - {item.plays > 1 && ( + {item.plays && item.plays > 1 && ( <Text style={styles.playsCount}> {item.plays} plays </Text> @@ -446,18 +556,265 @@ const LibraryScreen = () => { ); }; + // Get items for a specific Trakt folder + const getTraktFolderItems = useCallback((folderId: string): TraktDisplayItem[] => { + const items: TraktDisplayItem[] = []; + + switch (folderId) { + case 'watched': + // Add watched movies + if (watchedMovies) { + for (const watchedMovie of watchedMovies) { + const movie = watchedMovie.movie; + if (movie) { + items.push({ + id: String(movie.ids.trakt), + name: movie.title, + type: 'movie', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: movie.year, + lastWatched: new Date(watchedMovie.last_watched_at).toLocaleDateString(), + plays: watchedMovie.plays, + imdbId: movie.ids.imdb, + traktId: movie.ids.trakt, + }); + } + } + } + // Add watched shows + if (watchedShows) { + for (const watchedShow of watchedShows) { + const show = watchedShow.show; + if (show) { + items.push({ + id: String(show.ids.trakt), + name: show.title, + type: 'series', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: show.year, + lastWatched: new Date(watchedShow.last_watched_at).toLocaleDateString(), + plays: watchedShow.plays, + imdbId: show.ids.imdb, + traktId: show.ids.trakt, + }); + } + } + } + break; + + case 'continue-watching': + // Add continue watching items + if (continueWatching) { + for (const item of continueWatching) { + if (item.type === 'movie' && item.movie) { + items.push({ + id: String(item.movie.ids.trakt), + name: item.movie.title, + type: 'movie', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: item.movie.year, + lastWatched: new Date(item.paused_at).toLocaleDateString(), + imdbId: item.movie.ids.imdb, + traktId: item.movie.ids.trakt, + }); + } else if (item.type === 'episode' && item.show && item.episode) { + items.push({ + id: `${item.show.ids.trakt}:${item.episode.season}:${item.episode.number}`, + name: `${item.show.title} S${item.episode.season}E${item.episode.number}`, + type: 'series', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: item.show.year, + lastWatched: new Date(item.paused_at).toLocaleDateString(), + imdbId: item.show.ids.imdb, + traktId: item.show.ids.trakt, + }); + } + } + } + break; + + case 'watchlist': + // Add watchlist movies + if (watchlistMovies) { + for (const watchlistMovie of watchlistMovies) { + const movie = watchlistMovie.movie; + if (movie) { + items.push({ + id: String(movie.ids.trakt), + name: movie.title, + type: 'movie', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: movie.year, + lastWatched: new Date(watchlistMovie.listed_at).toLocaleDateString(), + imdbId: movie.ids.imdb, + traktId: movie.ids.trakt, + }); + } + } + } + // Add watchlist shows + if (watchlistShows) { + for (const watchlistShow of watchlistShows) { + const show = watchlistShow.show; + if (show) { + items.push({ + id: String(show.ids.trakt), + name: show.title, + type: 'series', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: show.year, + lastWatched: new Date(watchlistShow.listed_at).toLocaleDateString(), + imdbId: show.ids.imdb, + traktId: show.ids.trakt, + }); + } + } + } + break; + + case 'collection': + // Add collection movies + if (collectionMovies) { + for (const collectionMovie of collectionMovies) { + const movie = collectionMovie.movie; + if (movie) { + items.push({ + id: String(movie.ids.trakt), + name: movie.title, + type: 'movie', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: movie.year, + lastWatched: new Date(collectionMovie.collected_at).toLocaleDateString(), + imdbId: movie.ids.imdb, + traktId: movie.ids.trakt, + }); + } + } + } + // Add collection shows + if (collectionShows) { + for (const collectionShow of collectionShows) { + const show = collectionShow.show; + if (show) { + items.push({ + id: String(show.ids.trakt), + name: show.title, + type: 'series', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: show.year, + lastWatched: new Date(collectionShow.collected_at).toLocaleDateString(), + imdbId: show.ids.imdb, + traktId: show.ids.trakt, + }); + } + } + } + break; + + case 'ratings': + // Add rated content + if (ratedContent) { + for (const ratedItem of ratedContent) { + if (ratedItem.movie) { + const movie = ratedItem.movie; + items.push({ + id: String(movie.ids.trakt), + name: movie.title, + type: 'movie', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: movie.year, + lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(), + rating: ratedItem.rating, + imdbId: movie.ids.imdb, + traktId: movie.ids.trakt, + }); + } else if (ratedItem.show) { + const show = ratedItem.show; + items.push({ + id: String(show.ids.trakt), + name: show.title, + type: 'series', + poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + year: show.year, + lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(), + rating: ratedItem.rating, + imdbId: show.ids.imdb, + traktId: show.ids.trakt, + }); + } + } + } + break; + } + + // Sort by last watched/added date (most recent first) + return items.sort((a, b) => { + const dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0; + const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0; + return dateB - dateA; + }); + }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); + const renderTraktContent = () => { if (traktLoading) { return <SkeletonLoader />; } - if (traktItems.length === 0) { + // If no specific folder is selected, show the folder structure + if (!selectedTraktFolder) { + if (traktFolders.length === 0) { + return ( + <View style={styles.emptyContainer}> + <TraktIcon width={80} height={80} style={{ opacity: 0.7, marginBottom: 16 }} /> + <Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>No Trakt collections</Text> + <Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}> + Your Trakt collections will appear here once you start using Trakt + </Text> + <TouchableOpacity + style={[styles.exploreButton, { + backgroundColor: currentTheme.colors.primary, + shadowColor: currentTheme.colors.black + }]} + onPress={() => { + loadAllCollections(); + }} + activeOpacity={0.7} + > + <Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Load Collections</Text> + </TouchableOpacity> + </View> + ); + } + + // Show collection folders + return ( + <FlatList + data={traktFolders} + renderItem={({ item }) => renderTraktCollectionFolder({ folder: item })} + keyExtractor={item => item.id} + numColumns={2} + contentContainerStyle={styles.listContainer} + showsVerticalScrollIndicator={false} + columnWrapperStyle={styles.columnWrapper} + initialNumToRender={6} + maxToRenderPerBatch={6} + windowSize={5} + removeClippedSubviews={Platform.OS === 'android'} + /> + ); + } + + // Show content for specific folder + const folderItems = getTraktFolderItems(selectedTraktFolder); + + if (folderItems.length === 0) { + const folderName = traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection'; return ( <View style={styles.emptyContainer}> <TraktIcon width={80} height={80} style={{ opacity: 0.7, marginBottom: 16 }} /> - <Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>No watched content</Text> + <Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>No content in {folderName}</Text> <Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}> - Your Trakt watched history will appear here + This collection is empty </Text> <TouchableOpacity style={[styles.exploreButton, { @@ -465,7 +822,7 @@ const LibraryScreen = () => { shadowColor: currentTheme.colors.black }]} onPress={() => { - loadWatchedItems(); + loadAllCollections(); }} activeOpacity={0.7} > @@ -475,9 +832,9 @@ const LibraryScreen = () => { ); } - // Separate movies and shows - const movies = traktItems.filter(item => item.type === 'movie'); - const shows = traktItems.filter(item => item.type === 'series'); + // Separate movies and shows for the selected folder + const movies = folderItems.filter(item => item.type === 'movie'); + const shows = folderItems.filter(item => item.type === 'series'); return ( <ScrollView @@ -656,7 +1013,13 @@ const LibraryScreen = () => { <> <TouchableOpacity style={styles.backButton} - onPress={() => setShowTraktContent(false)} + onPress={() => { + if (selectedTraktFolder) { + setSelectedTraktFolder(null); + } else { + setShowTraktContent(false); + } + }} activeOpacity={0.7} > <MaterialIcons @@ -667,7 +1030,10 @@ const LibraryScreen = () => { </TouchableOpacity> <View style={styles.headerTitleContainer}> <Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}> - Trakt Collection + {selectedTraktFolder + ? traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection' + : 'Trakt Collection' + } </Text> </View> <View style={styles.headerSpacer} /> diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 5b3b965..7aba7f7 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -47,6 +47,79 @@ export interface TraktWatchedItem { last_watched_at: string; } +export interface TraktWatchlistItem { + movie?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + imdb: string; + tmdb: number; + }; + }; + show?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + imdb: string; + tmdb: number; + }; + }; + listed_at: string; +} + +export interface TraktCollectionItem { + movie?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + imdb: string; + tmdb: number; + }; + }; + show?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + imdb: string; + tmdb: number; + }; + }; + collected_at: string; +} + +export interface TraktRatingItem { + movie?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + imdb: string; + tmdb: number; + }; + }; + show?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + imdb: string; + tmdb: number; + }; + }; + rating: number; + rated_at: string; +} + // New types for scrobbling export interface TraktPlaybackItem { progress: number; @@ -578,6 +651,42 @@ export class TraktService { return this.apiRequest<TraktWatchedItem[]>('/sync/watched/shows'); } + /** + * Get the user's watchlist movies + */ + public async getWatchlistMovies(): Promise<TraktWatchlistItem[]> { + return this.apiRequest<TraktWatchlistItem[]>('/sync/watchlist/movies'); + } + + /** + * Get the user's watchlist shows + */ + public async getWatchlistShows(): Promise<TraktWatchlistItem[]> { + return this.apiRequest<TraktWatchlistItem[]>('/sync/watchlist/shows'); + } + + /** + * Get the user's collection movies + */ + public async getCollectionMovies(): Promise<TraktCollectionItem[]> { + return this.apiRequest<TraktCollectionItem[]>('/sync/collection/movies'); + } + + /** + * Get the user's collection shows + */ + public async getCollectionShows(): Promise<TraktCollectionItem[]> { + return this.apiRequest<TraktCollectionItem[]>('/sync/collection/shows'); + } + + /** + * Get the user's ratings + */ + public async getRatings(type?: 'movies' | 'shows'): Promise<TraktRatingItem[]> { + const endpoint = type ? `/sync/ratings/${type}` : '/sync/ratings'; + return this.apiRequest<TraktRatingItem[]>(endpoint); + } + /** * Get trakt id from IMDb id */ -- 2.45.2 From 7c3934be03e211652746816972e3b128fc6086d7 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 00:23:43 +0530 Subject: [PATCH 66/88] Update Trakt integration to fetch watched, watchlist, and collection items with images This update enhances the Trakt integration by modifying the data fetching methods to include images for watched movies, shows, watchlist items, and collections. The useTraktIntegration hook and LibraryScreen have been updated accordingly to handle and display these images, improving the visual representation of content. Additionally, new interfaces have been introduced in the TraktService to support these changes, ensuring a more comprehensive user experience. --- src/hooks/useTraktIntegration.ts | 16 +-- src/screens/LibraryScreen.tsx | 208 ++++++++++++++++++------------ src/services/imageCacheService.ts | 137 ++++++++++++++++++++ src/services/traktService.ts | 162 +++++++++++++++++++++++ 4 files changed, 435 insertions(+), 88 deletions(-) create mode 100644 src/services/imageCacheService.ts diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index 425ac1c..3b61d44 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -68,8 +68,8 @@ export function useTraktIntegration() { setIsLoading(true); try { const [movies, shows] = await Promise.all([ - traktService.getWatchedMovies(), - traktService.getWatchedShows() + traktService.getWatchedMoviesWithImages(), + traktService.getWatchedShowsWithImages() ]); setWatchedMovies(movies); setWatchedShows(shows); @@ -94,12 +94,12 @@ export function useTraktIntegration() { continueWatching, ratings ] = await Promise.all([ - traktService.getWatchlistMovies(), - traktService.getWatchlistShows(), - traktService.getCollectionMovies(), - traktService.getCollectionShows(), - traktService.getPlaybackProgress(), - traktService.getRatings() + traktService.getWatchlistMoviesWithImages(), + traktService.getWatchlistShowsWithImages(), + traktService.getCollectionMoviesWithImages(), + traktService.getCollectionShowsWithImages(), + traktService.getPlaybackProgressWithImages(), + traktService.getRatingsWithImages() ]); setWatchlistMovies(watchlistMovies); diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 36513b8..22f61a2 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -28,7 +28,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; import { useTraktContext } from '../contexts/TraktContext'; import TraktIcon from '../../assets/rating-icons/trakt.svg'; -import { TMDBService } from '../services/tmdbService'; +import { traktService, TraktService } from '../services/traktService'; // Define interfaces for proper typing interface LibraryItem extends StreamingContent { @@ -246,7 +246,10 @@ const LibraryScreen = () => { return folders.filter(folder => folder.itemCount > 0); }, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); - // Prepare Trakt items with proper poster URLs + // State for poster URLs (since they're now async) + const [traktPostersMap, setTraktPostersMap] = useState<Map<string, string>>(new Map()); + + // Prepare Trakt items with placeholders, then load posters async const traktItems = useMemo(() => { if (!traktAuthenticated || (!watchedMovies?.length && !watchedShows?.length)) { return []; @@ -259,11 +262,14 @@ const LibraryScreen = () => { for (const watchedMovie of watchedMovies) { const movie = watchedMovie.movie; if (movie) { + const itemId = String(movie.ids.trakt); + const cachedPoster = traktPostersMap.get(itemId); + items.push({ - id: String(movie.ids.trakt), + id: itemId, name: movie.title, type: 'movie', - poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', year: movie.year, lastWatched: new Date(watchedMovie.last_watched_at).toLocaleDateString(), plays: watchedMovie.plays, @@ -279,11 +285,14 @@ const LibraryScreen = () => { for (const watchedShow of watchedShows) { const show = watchedShow.show; if (show) { + const itemId = String(show.ids.trakt); + const cachedPoster = traktPostersMap.get(itemId); + items.push({ - id: String(show.ids.trakt), + id: itemId, name: show.title, type: 'series', - poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', year: show.year, lastWatched: new Date(watchedShow.last_watched_at).toLocaleDateString(), plays: watchedShow.plays, @@ -300,69 +309,72 @@ const LibraryScreen = () => { const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0; return dateB - dateA; }); - }, [traktAuthenticated, watchedMovies, watchedShows]); + }, [traktAuthenticated, watchedMovies, watchedShows, traktPostersMap]); - // State for tracking poster URLs - const [traktPostersMap, setTraktPostersMap] = useState<Map<string, string>>(new Map()); - - // Effect to fetch poster URLs for Trakt items + // Effect to load cached poster URLs useEffect(() => { - const fetchTraktPosters = async () => { - if (!traktAuthenticated || traktItems.length === 0) return; + const loadCachedPosters = async () => { + if (!traktAuthenticated) return; - const tmdbService = TMDBService.getInstance(); + const postersToLoad = new Map<string, any>(); - // Process items individually and update state as each poster is fetched - for (const item of traktItems) { - try { - // Get TMDB ID from the original Trakt data - let tmdbId: number | null = null; - - if (item.type === 'movie' && watchedMovies) { - const watchedMovie = watchedMovies.find(wm => wm.movie?.ids.trakt === item.traktId); - tmdbId = watchedMovie?.movie?.ids.tmdb || null; - } else if (item.type === 'series' && watchedShows) { - const watchedShow = watchedShows.find(ws => ws.show?.ids.trakt === item.traktId); - tmdbId = watchedShow?.show?.ids.tmdb || null; - } - - if (tmdbId) { - // Fetch details from TMDB to get poster path - let posterPath: string | null = null; - - if (item.type === 'movie') { - const movieDetails = await tmdbService.getMovieDetails(String(tmdbId)); - posterPath = movieDetails?.poster_path || null; - } else { - const showDetails = await tmdbService.getTVShowDetails(tmdbId); - posterPath = showDetails?.poster_path || null; - } - - if (posterPath) { - const fullPosterUrl = tmdbService.getImageUrl(posterPath, 'w500'); - if (fullPosterUrl) { - // Update state immediately for this item - setTraktPostersMap(prevMap => { - const newMap = new Map(prevMap); - newMap.set(item.id, fullPosterUrl); - return newMap; - }); - } + // Collect movies that need posters + if (watchedMovies) { + for (const watchedMovie of watchedMovies) { + const movie = watchedMovie.movie; + if (movie) { + const itemId = String(movie.ids.trakt); + if (!traktPostersMap.has(itemId)) { + postersToLoad.set(itemId, movie.images); } } - } catch (error) { - logger.error(`Failed to fetch poster for Trakt item ${item.id}:`, error); } } + + // Collect shows that need posters + if (watchedShows) { + for (const watchedShow of watchedShows) { + const show = watchedShow.show; + if (show) { + const itemId = String(show.ids.trakt); + if (!traktPostersMap.has(itemId)) { + postersToLoad.set(itemId, show.images); + } + } + } + } + + // Load posters in parallel + const posterPromises = Array.from(postersToLoad.entries()).map(async ([itemId, images]) => { + try { + const posterUrl = await TraktService.getTraktPosterUrl(images); + return { + itemId, + posterUrl: posterUrl || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster' + }; + } catch (error) { + logger.error(`Failed to get cached poster for ${itemId}:`, error); + return { + itemId, + posterUrl: 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster' + }; + } + }); + + const results = await Promise.all(posterPromises); + + // Update state with new posters + setTraktPostersMap(prevMap => { + const newMap = new Map(prevMap); + results.forEach(({ itemId, posterUrl }) => { + newMap.set(itemId, posterUrl); + }); + return newMap; + }); }; - fetchTraktPosters(); - }, [traktItems, traktAuthenticated, watchedMovies, watchedShows]); - - // Log when posters map updates - useEffect(() => { - // Removed debugging logs - }, [traktPostersMap]); + loadCachedPosters(); + }, [traktAuthenticated, watchedMovies, watchedShows]); const itemWidth = (width - 48) / 2; // 2 items per row with padding @@ -503,7 +515,7 @@ const LibraryScreen = () => { ); const renderTraktItem = ({ item, customWidth }: { item: TraktDisplayItem; customWidth?: number }) => { - const posterUrl = traktPostersMap.get(item.id) || 'https://via.placeholder.com/300x450/ff0000/ffffff?text=No+Poster'; + const posterUrl = item.poster || 'https://via.placeholder.com/300x450/ff0000/ffffff?text=No+Poster'; const width = customWidth || itemWidth; return ( @@ -567,11 +579,14 @@ const LibraryScreen = () => { for (const watchedMovie of watchedMovies) { const movie = watchedMovie.movie; if (movie) { + const itemId = String(movie.ids.trakt); + const cachedPoster = traktPostersMap.get(itemId); + items.push({ - id: String(movie.ids.trakt), + id: itemId, name: movie.title, type: 'movie', - poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', year: movie.year, lastWatched: new Date(watchedMovie.last_watched_at).toLocaleDateString(), plays: watchedMovie.plays, @@ -586,11 +601,14 @@ const LibraryScreen = () => { for (const watchedShow of watchedShows) { const show = watchedShow.show; if (show) { + const itemId = String(show.ids.trakt); + const cachedPoster = traktPostersMap.get(itemId); + items.push({ - id: String(show.ids.trakt), + id: itemId, name: show.title, type: 'series', - poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', year: show.year, lastWatched: new Date(watchedShow.last_watched_at).toLocaleDateString(), plays: watchedShow.plays, @@ -607,22 +625,28 @@ const LibraryScreen = () => { if (continueWatching) { for (const item of continueWatching) { if (item.type === 'movie' && item.movie) { + const itemId = String(item.movie.ids.trakt); + const cachedPoster = traktPostersMap.get(itemId); + items.push({ - id: String(item.movie.ids.trakt), + id: itemId, name: item.movie.title, type: 'movie', - poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', year: item.movie.year, lastWatched: new Date(item.paused_at).toLocaleDateString(), imdbId: item.movie.ids.imdb, traktId: item.movie.ids.trakt, }); } else if (item.type === 'episode' && item.show && item.episode) { + const itemId = String(item.show.ids.trakt); + const cachedPoster = traktPostersMap.get(itemId); + items.push({ id: `${item.show.ids.trakt}:${item.episode.season}:${item.episode.number}`, name: `${item.show.title} S${item.episode.season}E${item.episode.number}`, type: 'series', - poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', year: item.show.year, lastWatched: new Date(item.paused_at).toLocaleDateString(), imdbId: item.show.ids.imdb, @@ -639,11 +663,15 @@ const LibraryScreen = () => { for (const watchlistMovie of watchlistMovies) { const movie = watchlistMovie.movie; if (movie) { + const itemId = String(movie.ids.trakt); + const posterUrl = TraktService.getTraktPosterUrl(movie.images) || + 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; + items.push({ - id: String(movie.ids.trakt), + id: itemId, name: movie.title, type: 'movie', - poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: posterUrl, year: movie.year, lastWatched: new Date(watchlistMovie.listed_at).toLocaleDateString(), imdbId: movie.ids.imdb, @@ -657,11 +685,15 @@ const LibraryScreen = () => { for (const watchlistShow of watchlistShows) { const show = watchlistShow.show; if (show) { + const itemId = String(show.ids.trakt); + const posterUrl = TraktService.getTraktPosterUrl(show.images) || + 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; + items.push({ - id: String(show.ids.trakt), + id: itemId, name: show.title, type: 'series', - poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: posterUrl, year: show.year, lastWatched: new Date(watchlistShow.listed_at).toLocaleDateString(), imdbId: show.ids.imdb, @@ -678,11 +710,15 @@ const LibraryScreen = () => { for (const collectionMovie of collectionMovies) { const movie = collectionMovie.movie; if (movie) { + const itemId = String(movie.ids.trakt); + const posterUrl = TraktService.getTraktPosterUrl(movie.images) || + 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; + items.push({ - id: String(movie.ids.trakt), + id: itemId, name: movie.title, type: 'movie', - poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: posterUrl, year: movie.year, lastWatched: new Date(collectionMovie.collected_at).toLocaleDateString(), imdbId: movie.ids.imdb, @@ -696,11 +732,15 @@ const LibraryScreen = () => { for (const collectionShow of collectionShows) { const show = collectionShow.show; if (show) { + const itemId = String(show.ids.trakt); + const posterUrl = TraktService.getTraktPosterUrl(show.images) || + 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; + items.push({ - id: String(show.ids.trakt), + id: itemId, name: show.title, type: 'series', - poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: posterUrl, year: show.year, lastWatched: new Date(collectionShow.collected_at).toLocaleDateString(), imdbId: show.ids.imdb, @@ -717,11 +757,15 @@ const LibraryScreen = () => { for (const ratedItem of ratedContent) { if (ratedItem.movie) { const movie = ratedItem.movie; + const itemId = String(movie.ids.trakt); + const posterUrl = TraktService.getTraktPosterUrl(movie.images) || + 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; + items.push({ - id: String(movie.ids.trakt), + id: itemId, name: movie.title, type: 'movie', - poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: posterUrl, year: movie.year, lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(), rating: ratedItem.rating, @@ -730,11 +774,15 @@ const LibraryScreen = () => { }); } else if (ratedItem.show) { const show = ratedItem.show; + const itemId = String(show.ids.trakt); + const posterUrl = TraktService.getTraktPosterUrl(show.images) || + 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; + items.push({ - id: String(show.ids.trakt), + id: itemId, name: show.title, type: 'series', - poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: posterUrl, year: show.year, lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(), rating: ratedItem.rating, @@ -753,7 +801,7 @@ const LibraryScreen = () => { const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0; return dateB - dateA; }); - }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); + }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent, traktPostersMap]); const renderTraktContent = () => { if (traktLoading) { diff --git a/src/services/imageCacheService.ts b/src/services/imageCacheService.ts new file mode 100644 index 0000000..e5a8bca --- /dev/null +++ b/src/services/imageCacheService.ts @@ -0,0 +1,137 @@ +import { logger } from '../utils/logger'; + +interface CachedImage { + url: string; + localPath: string; + timestamp: number; + expiresAt: number; +} + +class ImageCacheService { + private cache = new Map<string, CachedImage>(); + private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours + private readonly MAX_CACHE_SIZE = 100; // Maximum number of cached images + + /** + * Get a cached image URL or cache the original if not present + */ + public async getCachedImageUrl(originalUrl: string): Promise<string> { + if (!originalUrl || originalUrl.includes('placeholder')) { + return originalUrl; // Don't cache placeholder images + } + + // Check if we have a valid cached version + const cached = this.cache.get(originalUrl); + if (cached && cached.expiresAt > Date.now()) { + logger.log(`[ImageCache] Retrieved from cache: ${originalUrl}`); + return cached.localPath; + } + + try { + // For now, return the original URL but mark it as cached + // In a production app, you would implement actual local caching here + const cachedImage: CachedImage = { + url: originalUrl, + localPath: originalUrl, // In production, this would be a local file path + timestamp: Date.now(), + expiresAt: Date.now() + this.CACHE_DURATION, + }; + + this.cache.set(originalUrl, cachedImage); + this.enforceMaxCacheSize(); + + logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl} (Cache size: ${this.cache.size})`); + return cachedImage.localPath; + } catch (error) { + logger.error('[ImageCache] Failed to cache image:', error); + return originalUrl; // Fallback to original URL + } + } + + /** + * Check if an image is cached + */ + public isCached(url: string): boolean { + const cached = this.cache.get(url); + return cached !== undefined && cached.expiresAt > Date.now(); + } + + /** + * Log cache status (for debugging) + */ + public logCacheStatus(): void { + const stats = this.getCacheStats(); + logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`); + + // Log first 5 cached URLs for debugging + const entries = Array.from(this.cache.entries()).slice(0, 5); + entries.forEach(([url, cached]) => { + const isExpired = cached.expiresAt <= Date.now(); + const timeLeft = Math.max(0, cached.expiresAt - Date.now()) / 1000 / 60; // minutes + logger.log(`[ImageCache] - ${url.substring(0, 60)}... (${isExpired ? 'EXPIRED' : `${timeLeft.toFixed(1)}m left`})`); + }); + } + + /** + * Clear expired cache entries + */ + public clearExpiredCache(): void { + const now = Date.now(); + for (const [url, cached] of this.cache.entries()) { + if (cached.expiresAt <= now) { + this.cache.delete(url); + } + } + } + + /** + * Clear all cached images + */ + public clearAllCache(): void { + this.cache.clear(); + logger.log('[ImageCache] Cleared all cached images'); + } + + /** + * Get cache statistics + */ + public getCacheStats(): { size: number; expired: number } { + const now = Date.now(); + let expired = 0; + + for (const cached of this.cache.values()) { + if (cached.expiresAt <= now) { + expired++; + } + } + + return { + size: this.cache.size, + expired, + }; + } + + /** + * Enforce maximum cache size by removing oldest entries + */ + private enforceMaxCacheSize(): void { + if (this.cache.size <= this.MAX_CACHE_SIZE) { + return; + } + + // Convert to array and sort by timestamp (oldest first) + const entries = Array.from(this.cache.entries()).sort( + (a, b) => a[1].timestamp - b[1].timestamp + ); + + // Remove oldest entries + const toRemove = this.cache.size - this.MAX_CACHE_SIZE; + for (let i = 0; i < toRemove; i++) { + this.cache.delete(entries[i][0]); + } + + logger.log(`[ImageCache] Removed ${toRemove} old entries to enforce cache size limit`); + } +} + +export const imageCacheService = new ImageCacheService(); \ No newline at end of file diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 7aba7f7..071deec 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -1,5 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { logger } from '../utils/logger'; +import { imageCacheService } from './imageCacheService'; // Storage keys export const TRAKT_ACCESS_TOKEN_KEY = 'trakt_access_token'; @@ -32,6 +33,7 @@ export interface TraktWatchedItem { imdb: string; tmdb: number; }; + images?: TraktImages; }; show?: { title: string; @@ -42,6 +44,7 @@ export interface TraktWatchedItem { imdb: string; tmdb: number; }; + images?: TraktImages; }; plays: number; last_watched_at: string; @@ -57,6 +60,7 @@ export interface TraktWatchlistItem { imdb: string; tmdb: number; }; + images?: TraktImages; }; show?: { title: string; @@ -67,6 +71,7 @@ export interface TraktWatchlistItem { imdb: string; tmdb: number; }; + images?: TraktImages; }; listed_at: string; } @@ -81,6 +86,7 @@ export interface TraktCollectionItem { imdb: string; tmdb: number; }; + images?: TraktImages; }; show?: { title: string; @@ -91,6 +97,7 @@ export interface TraktCollectionItem { imdb: string; tmdb: number; }; + images?: TraktImages; }; collected_at: string; } @@ -105,6 +112,7 @@ export interface TraktRatingItem { imdb: string; tmdb: number; }; + images?: TraktImages; }; show?: { title: string; @@ -115,11 +123,33 @@ export interface TraktRatingItem { imdb: string; tmdb: number; }; + images?: TraktImages; }; rating: number; rated_at: string; } +export interface TraktImages { + fanart?: string[]; + poster?: string[]; + logo?: string[]; + clearart?: string[]; + banner?: string[]; + thumb?: string[]; +} + +export interface TraktItemWithImages { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + imdb: string; + tmdb: number; + }; + images?: TraktImages; +} + // New types for scrobbling export interface TraktPlaybackItem { progress: number; @@ -135,6 +165,7 @@ export interface TraktPlaybackItem { imdb: string; tmdb: number; }; + images?: TraktImages; }; episode?: { season: number; @@ -146,6 +177,7 @@ export interface TraktPlaybackItem { imdb?: string; tmdb?: number; }; + images?: TraktImages; }; show?: { title: string; @@ -157,6 +189,7 @@ export interface TraktPlaybackItem { imdb: string; tmdb: number; }; + images?: TraktImages; }; } @@ -687,6 +720,124 @@ export class TraktService { return this.apiRequest<TraktRatingItem[]>(endpoint); } + /** + * Get the user's watched movies with images + */ + public async getWatchedMoviesWithImages(): Promise<TraktWatchedItem[]> { + return this.apiRequest<TraktWatchedItem[]>('/sync/watched/movies?extended=images'); + } + + /** + * Get the user's watched shows with images + */ + public async getWatchedShowsWithImages(): Promise<TraktWatchedItem[]> { + return this.apiRequest<TraktWatchedItem[]>('/sync/watched/shows?extended=images'); + } + + /** + * Get the user's watchlist movies with images + */ + public async getWatchlistMoviesWithImages(): Promise<TraktWatchlistItem[]> { + return this.apiRequest<TraktWatchlistItem[]>('/sync/watchlist/movies?extended=images'); + } + + /** + * Get the user's watchlist shows with images + */ + public async getWatchlistShowsWithImages(): Promise<TraktWatchlistItem[]> { + return this.apiRequest<TraktWatchlistItem[]>('/sync/watchlist/shows?extended=images'); + } + + /** + * Get the user's collection movies with images + */ + public async getCollectionMoviesWithImages(): Promise<TraktCollectionItem[]> { + return this.apiRequest<TraktCollectionItem[]>('/sync/collection/movies?extended=images'); + } + + /** + * Get the user's collection shows with images + */ + public async getCollectionShowsWithImages(): Promise<TraktCollectionItem[]> { + return this.apiRequest<TraktCollectionItem[]>('/sync/collection/shows?extended=images'); + } + + /** + * Get the user's ratings with images + */ + public async getRatingsWithImages(type?: 'movies' | 'shows'): Promise<TraktRatingItem[]> { + const endpoint = type ? `/sync/ratings/${type}?extended=images` : '/sync/ratings?extended=images'; + return this.apiRequest<TraktRatingItem[]>(endpoint); + } + + /** + * Get playback progress with images + */ + public async getPlaybackProgressWithImages(type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> { + try { + const endpoint = type ? `/sync/playback/${type}?extended=images` : '/sync/playback?extended=images'; + return this.apiRequest<TraktPlaybackItem[]>(endpoint); + } catch (error) { + logger.error('[TraktService] Failed to get playback progress with images:', error); + return []; + } + } + + /** + * Extract poster URL from Trakt images with basic caching + */ + public static getTraktPosterUrl(images?: TraktImages): string | null { + if (!images || !images.poster || images.poster.length === 0) { + return null; + } + + // Get the first poster and add https prefix + const posterPath = images.poster[0]; + const fullUrl = posterPath.startsWith('http') ? posterPath : `https://${posterPath}`; + + // Try to use cached version synchronously (basic cache check) + const isCached = imageCacheService.isCached(fullUrl); + if (isCached) { + logger.log(`[TraktService] 🎯 Using cached poster: ${fullUrl.substring(0, 60)}...`); + } else { + logger.log(`[TraktService] 📥 New poster URL: ${fullUrl.substring(0, 60)}...`); + // Queue for async caching + imageCacheService.getCachedImageUrl(fullUrl).catch(error => { + logger.error('[TraktService] Background caching failed:', error); + }); + } + + return fullUrl; + } + + /** + * Extract poster URL from Trakt images with async caching + */ + public static async getTraktPosterUrlCached(images?: TraktImages): Promise<string | null> { + const url = this.getTraktPosterUrl(images); + if (!url) return null; + + try { + return await imageCacheService.getCachedImageUrl(url); + } catch (error) { + logger.error('[TraktService] Failed to cache image:', error); + return url; + } + } + + /** + * Extract fanart URL from Trakt images + */ + public static getTraktFanartUrl(images?: TraktImages): string | null { + if (!images || !images.fanart || images.fanart.length === 0) { + return null; + } + + // Get the first fanart and add https prefix + const fanartPath = images.fanart[0]; + return fanartPath.startsWith('http') ? fanartPath : `https://${fanartPath}`; + } + /** * Get trakt id from IMDb id */ @@ -1270,6 +1421,17 @@ export class TraktService { logger.error('[TraktService] DEBUG: Error fetching playback progress:', error); } } + /** + * Debug image cache status + */ + public static debugImageCache(): void { + try { + logger.log('[TraktService] === IMAGE CACHE DEBUG ==='); + imageCacheService.logCacheStatus(); + } catch (error) { + logger.error('[TraktService] Debug image cache failed:', error); + } + } } // Export a singleton instance -- 2.45.2 From 8fc9098502948947ca653e54bc604a701d5e9e14 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 00:37:17 +0530 Subject: [PATCH 67/88] Enhance HeroSection with animated progress display and improved UI elements This update introduces animated effects for the watch progress display in the HeroSection component, including completion glow, celebration animations, and progress pulsing. The progress bar and text elements have been redesigned for better visibility and user interaction, with enhanced styles and inline Trakt sync buttons. Additionally, the logo scaling behavior has been adjusted based on the presence of the progress bar, improving the overall user experience. --- src/components/metadata/HeroSection.tsx | 445 ++++++++++++++++++++---- 1 file changed, 369 insertions(+), 76 deletions(-) diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 7f36a02..a4822d0 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -256,6 +256,14 @@ const WatchProgressDisplay = React.memo(({ const { currentTheme } = useTheme(); const { isAuthenticated: isTraktAuthenticated, forceSyncTraktProgress } = useTraktContext(); + // Animated values for enhanced effects + const completionGlow = useSharedValue(0); + const celebrationScale = useSharedValue(1); + const progressPulse = useSharedValue(1); + const progressBoxOpacity = useSharedValue(0); + const progressBoxScale = useSharedValue(0.8); + const progressBoxTranslateY = useSharedValue(20); + // Handle manual Trakt sync const handleTraktSync = useMemo(() => async () => { if (isTraktAuthenticated && forceSyncTraktProgress) { @@ -357,69 +365,177 @@ const WatchProgressDisplay = React.memo(({ }; }, [watchProgress, type, getEpisodeDetails, isTraktAuthenticated, isWatched]); + // Trigger appearance and completion animations + useEffect(() => { + if (progressData) { + // Smooth entrance animation for the glassmorphic box + progressBoxOpacity.value = withTiming(1, { duration: 400 }); + progressBoxScale.value = withTiming(1, { duration: 400 }); + progressBoxTranslateY.value = withTiming(0, { duration: 400 }); + + if (progressData.isWatched || (progressData.progressPercent && progressData.progressPercent >= 95)) { + // Celebration animation sequence + celebrationScale.value = withRepeat( + withTiming(1.05, { duration: 200 }), + 2, + true + ); + + // Glow effect + completionGlow.value = withRepeat( + withTiming(1, { duration: 1500 }), + -1, + true + ); + } else { + // Subtle progress pulse for ongoing content + progressPulse.value = withRepeat( + withTiming(1.02, { duration: 2000 }), + -1, + true + ); + } + } else { + // Hide animation when no progress data + progressBoxOpacity.value = withTiming(0, { duration: 300 }); + progressBoxScale.value = withTiming(0.8, { duration: 300 }); + progressBoxTranslateY.value = withTiming(20, { duration: 300 }); + } + }, [progressData]); + + // Animated styles for enhanced effects + const celebrationAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: celebrationScale.value }], + })); + + const glowAnimatedStyle = useAnimatedStyle(() => ({ + opacity: interpolate(completionGlow.value, [0, 1], [0.3, 0.8], Extrapolate.CLAMP), + })); + + const progressPulseStyle = useAnimatedStyle(() => ({ + transform: [{ scale: progressPulse.value }], + })); + + const progressBoxAnimatedStyle = useAnimatedStyle(() => ({ + opacity: progressBoxOpacity.value, + transform: [ + { scale: progressBoxScale.value }, + { translateY: progressBoxTranslateY.value } + ], + })); + if (!progressData) return null; + const isCompleted = progressData.isWatched || progressData.progressPercent >= 95; + return ( <Animated.View style={[styles.watchProgressContainer, animatedStyle]}> - <View style={styles.watchProgressBar}> - <View - style={[ - styles.watchProgressFill, - { - width: `${progressData.progressPercent}%`, - backgroundColor: progressData.isWatched - ? '#666' // Subtle gray for completed - : progressData.isTraktSynced - ? '#E50914' // Netflix red for Trakt synced content - : currentTheme.colors.primary - } - ]} - /> - {/* Subtle watched indicator */} - {progressData.isWatched && ( - <View style={styles.watchedProgressIndicator}> - <MaterialIcons - name="check" - size={6} - color="rgba(255,255,255,0.8)" - /> - </View> + {/* Glass morphism background with entrance animation */} + <Animated.View style={[styles.progressGlassBackground, progressBoxAnimatedStyle]}> + {Platform.OS === 'ios' ? ( + <ExpoBlurView intensity={20} style={styles.blurBackground} tint="dark" /> + ) : ( + <View style={styles.androidProgressBlur} /> )} - {/* Trakt sync indicator for non-watched content */} - {progressData.isTraktSynced && !progressData.isWatched && ( - <View style={styles.traktSyncIndicator}> - <MaterialIcons - name="sync" - size={8} - color="rgba(255,255,255,0.9)" - /> - </View> - )} - </View> - <View style={styles.watchProgressTextContainer}> - <Text style={[styles.watchProgressText, { - color: progressData.isWatched ? 'rgba(255,255,255,0.6)' : currentTheme.colors.textMuted, - fontSize: progressData.isWatched ? 10 : 11 - }]}> - {progressData.displayText}{progressData.episodeInfo} • Last watched on {progressData.formattedTime} - {progressData.syncStatus} - </Text> - {/* Manual Trakt sync button */} - {isTraktAuthenticated && forceSyncTraktProgress && ( - <TouchableOpacity - style={styles.traktSyncButton} - onPress={handleTraktSync} - activeOpacity={0.7} - > - <MaterialIcons - name="refresh" - size={14} - color={currentTheme.colors.textMuted} + {/* Enhanced progress bar with glow effects */} + <Animated.View style={[styles.watchProgressBarContainer, celebrationAnimatedStyle]}> + <View style={styles.watchProgressBar}> + {/* Background glow for completed content */} + {isCompleted && ( + <Animated.View style={[styles.completionGlow, glowAnimatedStyle]} /> + )} + + <Animated.View + style={[ + styles.watchProgressFill, + !isCompleted && progressPulseStyle, + { + width: `${progressData.progressPercent}%`, + backgroundColor: isCompleted + ? '#00ff88' // Bright green for completed + : progressData.isTraktSynced + ? '#E50914' // Netflix red for Trakt synced content + : currentTheme.colors.primary, + // Add gradient effect for completed content + ...(isCompleted && { + background: 'linear-gradient(90deg, #00ff88, #00cc6a)', + }) + } + ]} /> - </TouchableOpacity> - )} - </View> + + {/* Shimmer effect for active progress */} + {!isCompleted && progressData.progressPercent > 0 && ( + <View style={styles.progressShimmer} /> + )} + </View> + </Animated.View> + + {/* Enhanced text container with better typography */} + <View style={styles.watchProgressTextContainer}> + <View style={styles.progressInfoMain}> + <Text style={[styles.watchProgressMainText, { + color: isCompleted ? '#00ff88' : currentTheme.colors.white, + fontSize: isCompleted ? 13 : 12, + fontWeight: isCompleted ? '700' : '600' + }]}> + {progressData.displayText} + </Text> + + {/* Progress percentage badge */} + {!isCompleted && ( + <View style={styles.percentageBadge}> + <Text style={styles.percentageText}> + {Math.round(progressData.progressPercent)}% + </Text> + </View> + )} + </View> + + <Text style={[styles.watchProgressSubText, { + color: isCompleted ? 'rgba(0,255,136,0.7)' : currentTheme.colors.textMuted, + }]}> + {progressData.episodeInfo} • Last watched {progressData.formattedTime} + </Text> + + {/* Trakt sync status with enhanced styling */} + {progressData.syncStatus && ( + <View style={styles.syncStatusContainer}> + <MaterialIcons + name={progressData.isTraktSynced ? "sync" : "sync-problem"} + size={12} + color={progressData.isTraktSynced ? "#E50914" : "rgba(255,255,255,0.6)"} + /> + <Text style={[styles.syncStatusText, { + color: progressData.isTraktSynced ? "#E50914" : "rgba(255,255,255,0.6)" + }]}> + {progressData.syncStatus} + </Text> + + {/* Enhanced manual Trakt sync button - moved inline */} + {isTraktAuthenticated && forceSyncTraktProgress && ( + <TouchableOpacity + style={styles.traktSyncButtonInline} + onPress={handleTraktSync} + activeOpacity={0.7} + > + <LinearGradient + colors={['#E50914', '#B8070F']} + style={styles.syncButtonGradientInline} + > + <MaterialIcons + name="refresh" + size={12} + color="#fff" + /> + </LinearGradient> + </TouchableOpacity> + )} + </View> + )} + </View> + </Animated.View> </Animated.View> ); }); @@ -513,17 +629,28 @@ const HeroSection: React.FC<HeroSectionProps> = ({ opacity: heroOpacity.value, }), []); - const logoAnimatedStyle = useAnimatedStyle(() => ({ - opacity: logoOpacity.value, - transform: [{ - translateY: interpolate( - scrollY.value, - [0, 100], - [0, -20], - Extrapolate.CLAMP - ) - }] - }), []); + const logoAnimatedStyle = useAnimatedStyle(() => { + // Determine if progress bar should be shown + const hasProgress = watchProgress && watchProgress.duration > 0; + + // Scale down logo when progress bar is present + const logoScale = hasProgress ? 0.85 : 1; + + return { + opacity: logoOpacity.value, + transform: [ + { + translateY: interpolate( + scrollY.value, + [0, 100], + [0, -20], + Extrapolate.CLAMP + ) + }, + { scale: withTiming(logoScale, { duration: 300 }) } + ] + }; + }, [watchProgress]); const watchProgressAnimatedStyle = useAnimatedStyle(() => ({ opacity: watchProgressOpacity.value, @@ -818,18 +945,40 @@ const styles = StyleSheet.create({ }, watchProgressContainer: { marginTop: 4, - marginBottom: 6, + marginBottom: 4, width: '100%', alignItems: 'center', - height: 44, + minHeight: 36, + position: 'relative', + }, + progressGlassBackground: { + width: '75%', + backgroundColor: 'rgba(255,255,255,0.08)', + borderRadius: 12, + padding: 8, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + overflow: 'hidden', + }, + androidProgressBlur: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 16, + backgroundColor: 'rgba(0,0,0,0.3)', + }, + watchProgressBarContainer: { + position: 'relative', + marginBottom: 6, }, watchProgressBar: { - width: '70%', - height: 2.5, - backgroundColor: 'rgba(255, 255, 255, 0.2)', - borderRadius: 1.25, + width: '100%', + height: 3, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + borderRadius: 1.5, overflow: 'hidden', - marginBottom: 6, position: 'relative', }, watchProgressFill: { @@ -845,6 +994,18 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + traktSyncIndicatorEnhanced: { + position: 'absolute', + right: 4, + top: -2, + bottom: -2, + width: 16, + height: 16, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, watchedProgressIndicator: { position: 'absolute', right: 2, @@ -855,10 +1016,10 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, watchProgressTextContainer: { - flexDirection: 'row', + flexDirection: 'column', alignItems: 'center', justifyContent: 'center', - gap: 8, + width: '100%', }, watchProgressText: { fontSize: 11, @@ -933,6 +1094,138 @@ const styles = StyleSheet.create({ marginLeft: 6, fontSize: 15, }, + // Enhanced progress indicator styles + progressShimmer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 2, + backgroundColor: 'rgba(255,255,255,0.1)', + }, + completionGlow: { + position: 'absolute', + top: -2, + left: -2, + right: -2, + bottom: -2, + borderRadius: 4, + backgroundColor: 'rgba(0,255,136,0.2)', + }, + completionIndicator: { + position: 'absolute', + right: 4, + top: -6, + bottom: -6, + width: 16, + height: 16, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + completionGradient: { + width: 16, + height: 16, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + sparkleContainer: { + position: 'absolute', + top: -10, + left: 0, + right: 0, + bottom: -10, + borderRadius: 2, + }, + sparkle: { + position: 'absolute', + width: 8, + height: 8, + borderRadius: 4, + alignItems: 'center', + justifyContent: 'center', + }, + progressInfoMain: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 2, + }, + watchProgressMainText: { + fontSize: 11, + fontWeight: '600', + textAlign: 'center', + }, + percentageBadge: { + backgroundColor: 'rgba(255,255,255,0.2)', + borderRadius: 8, + paddingHorizontal: 6, + paddingVertical: 2, + marginLeft: 8, + }, + percentageText: { + fontSize: 10, + fontWeight: '600', + color: '#fff', + }, + watchProgressSubText: { + fontSize: 9, + textAlign: 'center', + opacity: 0.8, + marginBottom: 1, + }, + syncStatusContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginTop: 2, + width: '100%', + flexWrap: 'wrap', + }, + syncStatusText: { + fontSize: 9, + marginLeft: 4, + fontWeight: '500', + }, + traktSyncButtonEnhanced: { + position: 'absolute', + top: 8, + right: 8, + width: 24, + height: 24, + borderRadius: 12, + overflow: 'hidden', + }, + traktSyncButtonInline: { + marginLeft: 8, + width: 20, + height: 20, + borderRadius: 10, + overflow: 'hidden', + }, + syncButtonGradient: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, + syncButtonGradientInline: { + width: 20, + height: 20, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + traktIndicatorGradient: { + width: 16, + height: 16, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, }); export default React.memo(HeroSection); \ No newline at end of file -- 2.45.2 From 7fb168f5306a9c4dffca4f4982b54ab026a7e9f2 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 01:00:49 +0530 Subject: [PATCH 68/88] Refactor HeroSection progress display for improved readability and UI consistency This update refines the WatchProgressDisplay component within the HeroSection by enhancing the layout and styling of the progress bar and related elements. Key changes include adjustments to the structure of the JSX for better readability, improved inline Trakt sync button placement, and consistent application of styles. These modifications aim to enhance user interaction and visual clarity in the progress display. --- src/components/metadata/HeroSection.tsx | 66 ++++++++++++------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index a4822d0..2139561 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -440,30 +440,30 @@ const WatchProgressDisplay = React.memo(({ {/* Enhanced progress bar with glow effects */} <Animated.View style={[styles.watchProgressBarContainer, celebrationAnimatedStyle]}> - <View style={styles.watchProgressBar}> + <View style={styles.watchProgressBar}> {/* Background glow for completed content */} {isCompleted && ( <Animated.View style={[styles.completionGlow, glowAnimatedStyle]} /> )} <Animated.View - style={[ - styles.watchProgressFill, + style={[ + styles.watchProgressFill, !isCompleted && progressPulseStyle, - { - width: `${progressData.progressPercent}%`, + { + width: `${progressData.progressPercent}%`, backgroundColor: isCompleted ? '#00ff88' // Bright green for completed - : progressData.isTraktSynced - ? '#E50914' // Netflix red for Trakt synced content + : progressData.isTraktSynced + ? '#E50914' // Netflix red for Trakt synced content : currentTheme.colors.primary, // Add gradient effect for completed content ...(isCompleted && { background: 'linear-gradient(90deg, #00ff88, #00cc6a)', }) - } - ]} - /> + } + ]} + /> {/* Shimmer effect for active progress */} {!isCompleted && progressData.progressPercent > 0 && ( @@ -489,9 +489,9 @@ const WatchProgressDisplay = React.memo(({ <Text style={styles.percentageText}> {Math.round(progressData.progressPercent)}% </Text> - </View> - )} </View> + )} + </View> <Text style={[styles.watchProgressSubText, { color: isCompleted ? 'rgba(0,255,136,0.7)' : currentTheme.colors.textMuted, @@ -510,31 +510,31 @@ const WatchProgressDisplay = React.memo(({ <Text style={[styles.syncStatusText, { color: progressData.isTraktSynced ? "#E50914" : "rgba(255,255,255,0.6)" }]}> - {progressData.syncStatus} - </Text> - + {progressData.syncStatus} + </Text> + {/* Enhanced manual Trakt sync button - moved inline */} - {isTraktAuthenticated && forceSyncTraktProgress && ( - <TouchableOpacity + {isTraktAuthenticated && forceSyncTraktProgress && ( + <TouchableOpacity style={styles.traktSyncButtonInline} - onPress={handleTraktSync} - activeOpacity={0.7} + onPress={handleTraktSync} + activeOpacity={0.7} > <LinearGradient colors={['#E50914', '#B8070F']} style={styles.syncButtonGradientInline} - > - <MaterialIcons - name="refresh" + > + <MaterialIcons + name="refresh" size={12} color="#fff" - /> + /> </LinearGradient> - </TouchableOpacity> + </TouchableOpacity> )} </View> - )} - </View> + )} + </View> </Animated.View> </Animated.View> ); @@ -637,15 +637,15 @@ const HeroSection: React.FC<HeroSectionProps> = ({ const logoScale = hasProgress ? 0.85 : 1; return { - opacity: logoOpacity.value, + opacity: logoOpacity.value, transform: [ { - translateY: interpolate( - scrollY.value, - [0, 100], - [0, -20], - Extrapolate.CLAMP - ) + translateY: interpolate( + scrollY.value, + [0, 100], + [0, -20], + Extrapolate.CLAMP + ) }, { scale: withTiming(logoScale, { duration: 300 }) } ] -- 2.45.2 From 6c2259f6e8c1120b2eaa8bee8df53b01b5edab63 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 01:25:14 +0530 Subject: [PATCH 69/88] Enhance ContinueWatchingSection with improved logging, validation, and UI updates This update introduces a validation function for IMDB IDs, enhancing the robustness of the ContinueWatchingSection. It also adds detailed logging throughout the loading process, providing insights into the progress items being processed and any filtering based on completion percentage. The UI has been refined with new styles for content items, including a wider layout and improved progress display, ensuring a better user experience. Additionally, the HomeScreen has been updated to include debug buttons for managing watch progress, facilitating easier testing and interaction. --- package-lock.json | 1165 ++++++++++++++++- .../home/ContinueWatchingSection.tsx | 307 ++++- src/screens/HomeScreen.tsx | 135 +- src/services/stremioService.ts | 6 - 4 files changed, 1555 insertions(+), 58 deletions(-) diff --git a/package-lock.json b/package-lock.json index a8a42dd..b49aae1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,8 @@ "@types/react-native-video": "^5.0.20", "axios": "^1.10.0", "base64-js": "^1.5.1", + "cheerio": "^1.1.0", + "cors": "^2.8.5", "date-fns": "^4.1.0", "eventemitter3": "^5.0.1", "expo": "~52.0.43", @@ -43,8 +45,10 @@ "expo-status-bar": "~2.0.1", "expo-system-ui": "^4.0.9", "expo-web-browser": "~14.0.2", + "express": "^5.1.0", "lodash": "^4.17.21", "node-fetch": "^2.6.7", + "puppeteer": "^24.10.1", "react": "18.3.1", "react-native": "0.76.9", "react-native-awesome-slider": "^2.9.0", @@ -3372,6 +3376,39 @@ "node": ">=14" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.5.tgz", + "integrity": "sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.1", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.2", + "tar-fs": "^3.0.8", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@react-native-async-storage/async-storage": { "version": "1.23.1", "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz", @@ -4462,6 +4499,12 @@ "node": ">=4" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -4648,6 +4691,16 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@urql/core": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.1.1.tgz", @@ -4718,6 +4771,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -4952,6 +5014,12 @@ "node": ">= 6" } }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "license": "Apache-2.0" + }, "node_modules/babel-core": { "version": "7.0.0-bridge.0", "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz", @@ -5181,6 +5249,78 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/bare-fs": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz", + "integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", + "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", + "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5201,6 +5341,15 @@ ], "license": "MIT" }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/better-opn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", @@ -5239,6 +5388,38 @@ "node": ">=0.6" } }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/body-parser/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -5369,6 +5550,15 @@ "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", "license": "MIT" }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-fill": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", @@ -5631,6 +5821,19 @@ "node": ">=12.13.0" } }, + "node_modules/chromium-bidi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-5.1.0.tgz", + "integrity": "sha512-9MSRhWRVoRPDG0TgzkHrshFSJJNZzfY5UFqUMuksg7zL1yoZIZ3jLB0YAgHclbiAxPI86pBnwDX1tbzoiV8aFw==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/chromium-edge-launcher": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", @@ -5941,6 +6144,27 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5956,6 +6180,15 @@ "node": ">= 0.6" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-js-compat": { "version": "3.41.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", @@ -5969,6 +6202,19 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cosmiconfig": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", @@ -6263,6 +6509,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/degenerator/node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/del": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", @@ -6325,6 +6597,12 @@ "node": ">=0.10" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1452169", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1452169.tgz", + "integrity": "sha512-FOFDVMGrAUNp0dDKsAU1TorWJUx2JOU1k9xdgBKKJF3IBh/Uhl2yswG5r3TEAOrCiGY2QRp1e6LVDQrCsTKO4g==", + "license": "BSD-3-Clause" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -6520,6 +6798,15 @@ "node": ">=8" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -6610,6 +6897,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -6623,12 +6941,20 @@ "node": ">=4" } }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7219,12 +7545,231 @@ "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", "license": "Apache-2.0" }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/express/node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -7301,6 +7846,15 @@ "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", "license": "MIT" }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -7513,6 +8067,15 @@ "node": ">=12.20.0" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/freeport-async": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", @@ -7662,6 +8225,29 @@ "node": ">=6" } }, + "node_modules/get-uri": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", + "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/getenv": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/getenv/-/getenv-1.0.0.tgz", @@ -7944,6 +8530,32 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -8110,6 +8722,25 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, "node_modules/ip-regex": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", @@ -8319,6 +8950,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -8650,6 +9287,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, "node_modules/jsc-android": { "version": "250231.0.0", "resolved": "https://registry.npmjs.org/jsc-android/-/jsc-android-250231.0.0.tgz", @@ -8717,7 +9360,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -9258,12 +9900,33 @@ "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", "license": "CC0-1.0" }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-options": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", @@ -9855,6 +10518,12 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -9923,6 +10592,15 @@ "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", "license": "MIT" }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -10109,6 +10787,18 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-is": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", @@ -10384,6 +11074,38 @@ "node": ">=6" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -10394,7 +11116,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -10407,7 +11128,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10558,6 +11278,15 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -10567,6 +11296,12 @@ "node": ">=8" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10855,6 +11590,47 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -10880,6 +11656,131 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "24.10.2", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.10.2.tgz", + "integrity": "sha512-+k26rCz6akFZntx0hqUoFjCojgOLIxZs6p2k53LmEicwsT8F/FMBKfRfiBw1sitjiCvlR/15K7lBqfjXa251FA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.5", + "chromium-bidi": "5.1.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1452169", + "puppeteer-core": "24.10.2", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.10.2", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.10.2.tgz", + "integrity": "sha512-CnzhOgrZj8DvkDqI+Yx+9or33i3Y9uUYbKyYpP4C13jWwXx/keQ38RMTMmxuLCWQlxjZrOH0Foq7P2fGP7adDQ==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.5", + "chromium-bidi": "5.1.0", + "debug": "^4.4.1", + "devtools-protocol": "0.0.1452169", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/puppeteer/node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/puppeteer/node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/puppeteer/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/puppeteer/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/puppeteer/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/qrcode-terminal": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz", @@ -10888,6 +11789,21 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/query-string": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", @@ -10944,6 +11860,21 @@ "node": ">= 0.6" } }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -11986,6 +12917,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -12340,6 +13287,78 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -12423,6 +13442,16 @@ "node": ">=8.0.0" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -12434,6 +13463,34 @@ "tslib": "^2.0.3" } }, + "node_modules/socks": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.5.tgz", + "integrity": "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==", + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -12567,6 +13624,19 @@ "node": ">= 0.10.0" } }, + "node_modules/streamx": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", + "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, "node_modules/strict-uri-encode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", @@ -12860,6 +13930,31 @@ "node": ">=10" } }, + "node_modules/tar-fs": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.10.tgz", + "integrity": "sha512-C1SwlQGNLe/jPNqapK8epDsXME7CAJR5RL3GcE6KWx1d9OUByzoHVcbu1VPI8tevg9H8Alae0AApHHFGzrD5zA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/tar/node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -13084,6 +14179,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -13186,11 +14290,43 @@ "node": ">=8" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -13864,6 +15000,16 @@ "node": ">=8" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -13875,6 +15021,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.67", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", + "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 17e64d2..7763914 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -9,6 +9,7 @@ import { AppState, AppStateStatus } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; @@ -60,6 +61,13 @@ const { width } = Dimensions.get('window'); const posterLayout = calculatePosterLayout(width); const POSTER_WIDTH = posterLayout.posterWidth; +// Function to validate IMDB ID format +const isValidImdbId = (id: string): boolean => { + // IMDB IDs should start with 'tt' followed by 7-10 digits + const imdbPattern = /^tt\d{7,10}$/; + return imdbPattern.test(id); +}; + // Create a proper imperative handle with React.forwardRef and updated type const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => { const navigation = useNavigation<NavigationProp<RootStackParamList>>(); @@ -72,9 +80,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re // Modified loadContinueWatching to be more efficient const loadContinueWatching = useCallback(async () => { try { + console.log('[ContinueWatching] Starting to load continue watching items...'); setLoading(true); const allProgress = await storageService.getAllWatchProgress(); + console.log(`[ContinueWatching] Found ${Object.keys(allProgress).length} progress items in storage`); + if (Object.keys(allProgress).length === 0) { + console.log('[ContinueWatching] No progress items found, setting empty array'); setContinueWatchingItems([]); return; } @@ -85,33 +97,80 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re // Process each saved progress for (const key in allProgress) { + console.log(`[ContinueWatching] Raw key from storage: "${key}"`); + // Parse the key to get type and id - const [type, id, episodeId] = key.split(':'); + const keyParts = key.split(':'); + console.log(`[ContinueWatching] Key parts:`, keyParts); + + const [type, id, ...episodeIdParts] = keyParts; + const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined; const progress = allProgress[key]; + console.log(`[ContinueWatching] Parsed - type: "${type}", id: "${id}", episodeId: "${episodeId}"`); + // Skip items that are more than 95% complete (effectively finished) const progressPercent = (progress.currentTime / progress.duration) * 100; - if (progressPercent >= 95) continue; + console.log(`[ContinueWatching] Progress for ${key}: ${progressPercent.toFixed(1)}%`); + + if (progressPercent >= 95) { + console.log(`[ContinueWatching] Skipping ${key} - too high progress (${progressPercent.toFixed(1)}%)`); + continue; + } const contentPromise = (async () => { try { + // Validate IMDB ID format before attempting to fetch + if (!isValidImdbId(id)) { + console.log(`[ContinueWatching] Skipping ${type}:${id} - invalid IMDB ID format`); + return; + } + + console.log(`[ContinueWatching] Fetching content details for ${type}:${id}`); let content: StreamingContent | null = null; // Get content details using catalogService content = await catalogService.getContentDetails(type, id); if (content) { + console.log(`[ContinueWatching] Successfully fetched content: ${content.name}`); + // Extract season and episode info from episodeId if available let season: number | undefined; let episode: number | undefined; let episodeTitle: string | undefined; if (episodeId && type === 'series') { - const match = episodeId.match(/s(\d+)e(\d+)/i); + console.log(`[ContinueWatching] Parsing episode ID: ${episodeId}`); + + // Try different episode ID formats + let match = episodeId.match(/s(\d+)e(\d+)/i); // Format: s1e1 if (match) { season = parseInt(match[1], 10); episode = parseInt(match[2], 10); episodeTitle = `Episode ${episode}`; + console.log(`[ContinueWatching] Parsed s1e1 format: S${season}E${episode}`); + } else { + // Try format: seriesId:season:episode (e.g., tt0108778:4:6) + const parts = episodeId.split(':'); + if (parts.length >= 3) { + const seasonPart = parts[parts.length - 2]; // Second to last part + const episodePart = parts[parts.length - 1]; // Last part + + const seasonNum = parseInt(seasonPart, 10); + const episodeNum = parseInt(episodePart, 10); + + if (!isNaN(seasonNum) && !isNaN(episodeNum)) { + season = seasonNum; + episode = episodeNum; + episodeTitle = `Episode ${episode}`; + console.log(`[ContinueWatching] Parsed colon format: S${season}E${episode}`); + } + } + } + + if (!season || !episode) { + console.log(`[ContinueWatching] Failed to parse episode details from: ${episodeId}`); } } @@ -124,18 +183,31 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re episodeTitle }; + console.log(`[ContinueWatching] Created item for ${content.name}:`, { + type, + season, + episode, + episodeTitle, + episodeId, + originalKey: key + }); + if (type === 'series') { // For series, keep only the latest watched episode for each show if (!latestEpisodes[id] || latestEpisodes[id].lastUpdated < progress.lastUpdated) { latestEpisodes[id] = continueWatchingItem; + console.log(`[ContinueWatching] Updated latest episode for series ${id}`); } } else { // For movies, add to the list directly progressItems.push(continueWatchingItem); + console.log(`[ContinueWatching] Added movie to progress items`); } + } else { + console.log(`[ContinueWatching] Failed to fetch content details for ${type}:${id}`); } } catch (error) { - logger.error(`Failed to get content details for ${type}:${id}`, error); + console.error(`[ContinueWatching] Failed to get content details for ${type}:${id}`, error); } })(); @@ -143,18 +215,35 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re } // Wait for all content to be processed + console.log(`[ContinueWatching] Waiting for ${contentPromises.length} content promises...`); await Promise.all(contentPromises); // Add the latest episodes for each series to the items list progressItems.push(...Object.values(latestEpisodes)); + console.log(`[ContinueWatching] Total items after processing: ${progressItems.length}`); // Sort by last updated time (most recent first) progressItems.sort((a, b) => b.lastUpdated - a.lastUpdated); // Limit to 10 items - setContinueWatchingItems(progressItems.slice(0, 10)); + const finalItems = progressItems.slice(0, 10); + console.log(`[ContinueWatching] Final continue watching items: ${finalItems.length}`); + + // Debug: Log the final items with their episode details + finalItems.forEach((item, index) => { + console.log(`[ContinueWatching] Item ${index}:`, { + name: item.name, + type: item.type, + season: item.season, + episode: item.episode, + episodeTitle: item.episodeTitle, + progress: item.progress + }); + }); + + setContinueWatchingItems(finalItems); } catch (error) { - logger.error('Failed to load continue watching items:', error); + console.error('[ContinueWatching] Failed to load continue watching items:', error); } finally { setLoading(false); } @@ -219,9 +308,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re // Properly expose the refresh method React.useImperativeHandle(ref, () => ({ refresh: async () => { + console.log('[ContinueWatching] Refresh method called'); await loadContinueWatching(); // Return whether there are items to help parent determine visibility - return continueWatchingItems.length > 0; + const hasItems = continueWatchingItems.length > 0; + console.log(`[ContinueWatching] Refresh returning hasItems: ${hasItems}, items count: ${continueWatchingItems.length}`); + return hasItems; } })); @@ -235,7 +327,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re } return ( - <View style={styles.container}> + <Animated.View entering={FadeIn.duration(400).delay(250)} style={styles.container}> <View style={styles.header}> <View style={styles.titleContainer}> <Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>Continue Watching</Text> @@ -252,41 +344,91 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re data={continueWatchingItems} renderItem={({ item }) => ( <TouchableOpacity - style={[styles.contentItem, { + style={[styles.wideContentItem, { + backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.border, shadowColor: currentTheme.colors.black }]} - activeOpacity={0.7} + activeOpacity={0.8} onPress={() => handleContentPress(item.id, item.type)} > - <View style={styles.contentItemContainer}> + {/* Poster Image */} + <View style={styles.posterContainer}> <ExpoImage source={{ uri: item.poster }} - style={styles.poster} + style={styles.widePoster} contentFit="cover" transition={200} cachePolicy="memory-disk" /> - {item.type === 'series' && item.season && item.episode && ( - <View style={[styles.episodeInfoContainer, { backgroundColor: 'rgba(0, 0, 0, 0.7)' }]}> - <Text style={[styles.episodeInfo, { color: currentTheme.colors.white }]}> - S{item.season.toString().padStart(2, '0')}E{item.episode.toString().padStart(2, '0')} - </Text> - {item.episodeTitle && ( - <Text style={[styles.episodeTitle, { color: currentTheme.colors.white, opacity: 0.9 }]} numberOfLines={1}> - {item.episodeTitle} - </Text> - )} + </View> + + {/* Content Details */} + <View style={styles.contentDetails}> + <View style={styles.titleRow}> + <Text + style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]} + numberOfLines={1} + > + {item.name} + </Text> + <View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}> + <Text style={styles.progressText}>{Math.round(item.progress)}%</Text> </View> - )} - {/* Progress bar indicator */} - <View style={styles.progressBarContainer}> - <View - style={[ - styles.progressBar, - { width: `${item.progress}%`, backgroundColor: currentTheme.colors.primary } - ]} - /> + </View> + + {/* Episode Info or Year */} + {(() => { + console.log(`[ContinueWatching] Rendering item:`, { + name: item.name, + type: item.type, + season: item.season, + episode: item.episode, + episodeTitle: item.episodeTitle, + hasSeasonAndEpisode: !!(item.season && item.episode) + }); + + if (item.type === 'series' && item.season && item.episode) { + return ( + <View style={styles.episodeRow}> + <Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}> + Season {item.season} + </Text> + {item.episodeTitle && ( + <Text + style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]} + numberOfLines={1} + > + {item.episodeTitle} + </Text> + )} + </View> + ); + } else { + return ( + <Text style={[styles.yearText, { color: currentTheme.colors.mediumEmphasis }]}> + {item.year} • {item.type === 'movie' ? 'Movie' : 'Series'} + </Text> + ); + } + })()} + + {/* Progress Bar */} + <View style={styles.wideProgressContainer}> + <View style={styles.wideProgressTrack}> + <View + style={[ + styles.wideProgressBar, + { + width: `${item.progress}%`, + backgroundColor: currentTheme.colors.primary + } + ]} + /> + </View> + <Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}> + {Math.round(item.progress)}% watched + </Text> </View> </View> </TouchableOpacity> @@ -294,13 +436,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re keyExtractor={(item) => `continue-${item.id}-${item.type}`} horizontal showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.list} - snapToInterval={POSTER_WIDTH + 10} + contentContainerStyle={styles.wideList} + snapToInterval={280 + 16} // Card width + margin decelerationRate="fast" snapToAlignment="start" - ItemSeparatorComponent={() => <View style={{ width: 10 }} />} + ItemSeparatorComponent={() => <View style={{ width: 16 }} />} /> - </View> + </Animated.View> ); }); @@ -315,7 +457,7 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, - marginBottom: 8, + marginBottom: 12, }, titleContainer: { position: 'relative', @@ -335,6 +477,96 @@ const styles = StyleSheet.create({ height: 3, borderRadius: 1.5, }, + wideList: { + paddingHorizontal: 16, + paddingBottom: 8, + paddingTop: 4, + }, + wideContentItem: { + width: 280, + height: 120, + flexDirection: 'row', + borderRadius: 12, + overflow: 'hidden', + elevation: 6, + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.2, + shadowRadius: 6, + borderWidth: 1, + }, + posterContainer: { + width: 80, + height: '100%', + }, + widePoster: { + width: '100%', + height: '100%', + borderTopLeftRadius: 12, + borderBottomLeftRadius: 12, + }, + contentDetails: { + flex: 1, + padding: 12, + justifyContent: 'space-between', + }, + titleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 4, + }, + contentTitle: { + fontSize: 16, + fontWeight: '700', + flex: 1, + marginRight: 8, + }, + progressBadge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 12, + minWidth: 44, + alignItems: 'center', + }, + progressText: { + fontSize: 12, + fontWeight: '700', + color: '#FFFFFF', + }, + episodeRow: { + marginBottom: 8, + }, + episodeText: { + fontSize: 13, + fontWeight: '600', + marginBottom: 2, + }, + episodeTitle: { + fontSize: 12, + }, + yearText: { + fontSize: 13, + fontWeight: '500', + marginBottom: 8, + }, + wideProgressContainer: { + marginTop: 'auto', + }, + wideProgressTrack: { + height: 4, + backgroundColor: 'rgba(255,255,255,0.1)', + borderRadius: 2, + marginBottom: 4, + }, + wideProgressBar: { + height: '100%', + borderRadius: 2, + }, + progressLabel: { + fontSize: 11, + fontWeight: '500', + }, + // Keep old styles for backward compatibility list: { paddingHorizontal: 16, paddingBottom: 8, @@ -377,9 +609,6 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: 'bold', }, - episodeTitle: { - fontSize: 10, - }, progressBarContainer: { position: 'absolute', bottom: 0, diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 4c63eb4..52efa75 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -488,7 +488,7 @@ const HomeScreen = () => { await Promise.all(imagePromises); } catch (error) { - console.error('Error preloading images:', error); + // Silently handle preload errors } }, []); @@ -530,9 +530,37 @@ const HomeScreen = () => { }, [featuredContent, navigation]); const refreshContinueWatching = useCallback(async () => { + console.log('[HomeScreen] Refreshing continue watching...'); if (continueWatchingRef.current) { - const hasContent = await continueWatchingRef.current.refresh(); - setHasContinueWatching(hasContent); + try { + const hasContent = await continueWatchingRef.current.refresh(); + console.log(`[HomeScreen] Continue watching has content: ${hasContent}`); + setHasContinueWatching(hasContent); + + // Debug: Let's check what's in storage + const allProgress = await storageService.getAllWatchProgress(); + console.log('[HomeScreen] All watch progress in storage:', Object.keys(allProgress).length, 'items'); + console.log('[HomeScreen] Watch progress items:', allProgress); + + // Check if any items are being filtered out due to >95% progress + let filteredCount = 0; + for (const [key, progress] of Object.entries(allProgress)) { + const progressPercent = (progress.currentTime / progress.duration) * 100; + if (progressPercent >= 95) { + filteredCount++; + console.log(`[HomeScreen] Filtered out ${key}: ${progressPercent.toFixed(1)}% complete`); + } else { + console.log(`[HomeScreen] Valid progress ${key}: ${progressPercent.toFixed(1)}% complete`); + } + } + console.log(`[HomeScreen] Filtered out ${filteredCount} completed items`); + + } catch (error) { + console.error('[HomeScreen] Error refreshing continue watching:', error); + setHasContinueWatching(false); + } + } else { + console.log('[HomeScreen] Continue watching ref is null'); } }, []); @@ -596,11 +624,50 @@ const HomeScreen = () => { <ThisWeekSection /> </Animated.View> - {hasContinueWatching && ( - <Animated.View entering={FadeIn.duration(400).delay(250)}> - <ContinueWatchingSection ref={continueWatchingRef} /> - </Animated.View> - )} + {/* Debug buttons for Continue Watching */} + <View style={{ flexDirection: 'row', padding: 16, gap: 10 }}> + <TouchableOpacity + style={{ + backgroundColor: currentTheme.colors.primary, + padding: 10, + borderRadius: 8, + flex: 1 + }} + onPress={addTestWatchProgress} + > + <Text style={{ color: 'white', textAlign: 'center', fontSize: 12 }}> + Add Test Progress + </Text> + </TouchableOpacity> + <TouchableOpacity + style={{ + backgroundColor: currentTheme.colors.error || '#ff4444', + padding: 10, + borderRadius: 8, + flex: 1 + }} + onPress={clearAllWatchProgress} + > + <Text style={{ color: 'white', textAlign: 'center', fontSize: 12 }}> + Clear All Progress + </Text> + </TouchableOpacity> + <TouchableOpacity + style={{ + backgroundColor: currentTheme.colors.secondary, + padding: 10, + borderRadius: 8, + flex: 1 + }} + onPress={refreshContinueWatching} + > + <Text style={{ color: 'white', textAlign: 'center', fontSize: 12 }}> + Refresh + </Text> + </TouchableOpacity> + </View> + + <ContinueWatchingSection ref={continueWatchingRef} /> {catalogs.length > 0 ? ( catalogs.map((catalog, index) => ( @@ -642,6 +709,58 @@ const HomeScreen = () => { featuredContentSource ]); + // Debug function to add test watch progress + const addTestWatchProgress = useCallback(async () => { + console.log('[HomeScreen] Adding test watch progress data...'); + try { + // Add a test movie with 50% progress + await storageService.setWatchProgress( + 'tt1375666', // Inception IMDB ID + 'movie', + { + currentTime: 3600, // 1 hour + duration: 7200, // 2 hours (50% progress) + lastUpdated: Date.now() + } + ); + + // Add a test series episode with 30% progress + await storageService.setWatchProgress( + 'tt0944947', // Game of Thrones IMDB ID + 'series', + { + currentTime: 1800, // 30 minutes + duration: 6000, // 100 minutes (30% progress) + lastUpdated: Date.now() - 86400000 // 1 day ago + }, + 'tt0944947:1:1' // Season 1, Episode 1 + ); + + console.log('[HomeScreen] Test watch progress added successfully'); + + // Refresh the continue watching section + await refreshContinueWatching(); + } catch (error) { + console.error('[HomeScreen] Error adding test watch progress:', error); + } + }, [refreshContinueWatching]); + + // Debug function to clear all watch progress + const clearAllWatchProgress = useCallback(async () => { + console.log('[HomeScreen] Clearing all watch progress...'); + try { + const allProgress = await storageService.getAllWatchProgress(); + for (const key of Object.keys(allProgress)) { + const [type, id, episodeId] = key.split(':'); + await storageService.removeWatchProgress(id, type, episodeId); + } + console.log('[HomeScreen] All watch progress cleared'); + await refreshContinueWatching(); + } catch (error) { + console.error('[HomeScreen] Error clearing watch progress:', error); + } + }, [refreshContinueWatching]); + return isLoading ? renderLoadingScreen : renderMainContent; }; diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index f30e075..c116386 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -415,14 +415,11 @@ class StremioService { }); } - logger.log(`Cinemeta catalog request URL: ${url}`); - const response = await this.retryRequest(async () => { return await axios.get(url); }); if (response.data && response.data.metas && Array.isArray(response.data.metas)) { - logger.log(`Cinemeta returned ${response.data.metas.length} items`); return response.data.metas; } return []; @@ -453,14 +450,11 @@ class StremioService { }); } - logger.log(`${manifest.name} catalog request URL: ${url}`); - const response = await this.retryRequest(async () => { return await axios.get(url); }); if (response.data && response.data.metas && Array.isArray(response.data.metas)) { - logger.log(`${manifest.name} returned ${response.data.metas.length} items`); return response.data.metas; } return []; -- 2.45.2 From c0a63b3c531e2d8be4f41a6c8aca50e9f3d98874 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 01:58:48 +0530 Subject: [PATCH 70/88] Refactor watch progress management and enhance episode scrolling functionality This update improves the watch progress management by incorporating last updated timestamps for episodes, allowing for more accurate tracking of the most recently watched content. The SeriesContent component now automatically selects the season based on the most recent watch progress, enhancing user experience. Additionally, the scrolling functionality to the most recently watched episode has been implemented, ensuring users can easily continue watching from where they left off. UI adjustments in the ContinueWatchingSection and HomeScreen have also been made for better layout consistency. --- .../home/ContinueWatchingSection.tsx | 20 ++--- src/components/metadata/SeriesContent.tsx | 79 ++++++++++++++++++- src/hooks/useMetadata.ts | 58 ++++++++++++-- src/hooks/useWatchProgress.ts | 49 +++++------- src/screens/HomeScreen.tsx | 6 +- 5 files changed, 161 insertions(+), 51 deletions(-) diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 7763914..2539ed5 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -393,16 +393,16 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re <View style={styles.episodeRow}> <Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}> Season {item.season} - </Text> - {item.episodeTitle && ( + </Text> + {item.episodeTitle && ( <Text style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={1} > - {item.episodeTitle} - </Text> - )} - </View> + {item.episodeTitle} + </Text> + )} + </View> ); } else { return ( @@ -416,15 +416,15 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re {/* Progress Bar */} <View style={styles.wideProgressContainer}> <View style={styles.wideProgressTrack}> - <View - style={[ + <View + style={[ styles.wideProgressBar, { width: `${item.progress}%`, backgroundColor: currentTheme.colors.primary } - ]} - /> + ]} + /> </View> <Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}> {Math.round(item.progress)}% watched diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 37e0cf7..82086fd 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -40,16 +40,17 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ const { width } = useWindowDimensions(); const isTablet = width > 768; const isDarkMode = useColorScheme() === 'dark'; - const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number } }>({}); + const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({}); - // Add ref for the season selector ScrollView + // Add refs for the scroll views const seasonScrollViewRef = useRef<ScrollView | null>(null); + const episodeScrollViewRef = useRef<ScrollView | null>(null); const loadEpisodesProgress = async () => { if (!metadata?.id) return; const allProgress = await storageService.getAllWatchProgress(); - const progress: { [key: string]: { currentTime: number; duration: number } } = {}; + 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}`; @@ -57,7 +58,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ if (allProgress[key]) { progress[episodeId] = { currentTime: allProgress[key].currentTime, - duration: allProgress[key].duration + duration: allProgress[key].duration, + lastUpdated: allProgress[key].lastUpdated }; } }); @@ -65,6 +67,67 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ setEpisodeProgress(progress); }; + // Function to find and scroll to the most recently watched episode + const scrollToMostRecentEpisode = () => { + if (!metadata?.id || !episodeScrollViewRef.current || settings.episodeLayoutStyle !== 'horizontal') { + console.log('[SeriesContent] Scroll conditions not met:', { + hasMetadataId: !!metadata?.id, + hasScrollRef: !!episodeScrollViewRef.current, + isHorizontal: settings.episodeLayoutStyle === 'horizontal' + }); + return; + } + + const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || []; + if (currentSeasonEpisodes.length === 0) { + console.log('[SeriesContent] No episodes in current season:', selectedSeason); + 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; + } + }); + + console.log('[SeriesContent] Episode scroll analysis:', { + totalEpisodes: currentSeasonEpisodes.length, + mostRecentIndex: mostRecentEpisodeIndex, + mostRecentEpisode: mostRecentEpisodeName, + selectedSeason + }); + + // Scroll to the most recently watched episode if found + if (mostRecentEpisodeIndex >= 0) { + const cardWidth = isTablet ? width * 0.4 + 16 : width * 0.85 + 16; + const scrollPosition = mostRecentEpisodeIndex * cardWidth; + + console.log('[SeriesContent] Scrolling to episode:', { + index: mostRecentEpisodeIndex, + cardWidth, + scrollPosition, + episodeName: mostRecentEpisodeName + }); + + setTimeout(() => { + episodeScrollViewRef.current?.scrollTo({ + x: scrollPosition, + animated: true + }); + }, 500); // Delay to ensure the season has loaded + } + }; + // Initial load of watch progress useEffect(() => { loadEpisodesProgress(); @@ -96,6 +159,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ } }, [selectedSeason, groupedEpisodes]); + // Add effect to scroll to most recently watched episode when season changes or progress loads + useEffect(() => { + if (Object.keys(episodeProgress).length > 0 && selectedSeason) { + scrollToMostRecentEpisode(); + } + }, [selectedSeason, episodeProgress, settings.episodeLayoutStyle, groupedEpisodes]); + if (loadingSeasons) { return ( <View style={styles.centeredContainer}> @@ -480,6 +550,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ {settings.episodeLayoutStyle === 'horizontal' ? ( // Horizontal Layout (Netflix-style) <ScrollView + ref={episodeScrollViewRef} horizontal showsHorizontalScrollIndicator={false} style={styles.episodeList} diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index fe71136..5a44301 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -11,6 +11,7 @@ import { logger } from '../utils/logger'; import { usePersistentSeasons } from './usePersistentSeasons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Stream } from '../types/metadata'; +import { storageService } from '../services/storageService'; // Constants for timeouts and retries const API_TIMEOUT = 10000; // 10 seconds @@ -589,14 +590,61 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = // Get the first available season as fallback const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number)); - // Get saved season from persistence, fallback to first season if not found - const persistedSeason = getSeason(id, firstSeason); + // Check for watch progress to auto-select season + let selectedSeasonNumber = firstSeason; - // Set the selected season from persistence - setSelectedSeason(persistedSeason); + try { + // Check watch progress for auto-season selection + const allProgress = await storageService.getAllWatchProgress(); + + // Find the most recently watched episode for this series + let mostRecentEpisodeId = ''; + let mostRecentTimestamp = 0; + + Object.entries(allProgress).forEach(([key, progress]) => { + if (key.includes(`series:${id}:`)) { + const episodeId = key.split(`series:${id}:`)[1]; + if (progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) { + mostRecentTimestamp = progress.lastUpdated; + mostRecentEpisodeId = episodeId; + } + } + }); + + if (mostRecentEpisodeId) { + // Parse season number from episode ID + const parts = mostRecentEpisodeId.split(':'); + if (parts.length === 3) { + const watchProgressSeason = parseInt(parts[1], 10); + if (transformedEpisodes[watchProgressSeason]) { + selectedSeasonNumber = watchProgressSeason; + logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for ${mostRecentEpisodeId}`); + } + } else { + // Try to find episode by stremioId to get season + const allEpisodesList = Object.values(transformedEpisodes).flat(); + const episode = allEpisodesList.find(ep => ep.stremioId === mostRecentEpisodeId); + if (episode) { + selectedSeasonNumber = episode.season_number; + logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for episode with stremioId ${mostRecentEpisodeId}`); + } + } + } else { + // No watch progress found, use persistent storage as fallback + selectedSeasonNumber = getSeason(id, firstSeason); + logger.log(`[useMetadata] No watch progress found, using persistent season ${selectedSeasonNumber}`); + } + } catch (error) { + logger.error('[useMetadata] Error checking watch progress for season selection:', error); + // Fall back to persistent storage + selectedSeasonNumber = getSeason(id, firstSeason); + } + + // Set the selected season + setSelectedSeason(selectedSeasonNumber); // Set episodes for the selected season - setEpisodes(transformedEpisodes[persistedSeason] || []); + setEpisodes(transformedEpisodes[selectedSeasonNumber] || []); } } catch (error) { console.error('Failed to load episodes:', error); diff --git a/src/hooks/useWatchProgress.ts b/src/hooks/useWatchProgress.ts index b17e434..ff47bf4 100644 --- a/src/hooks/useWatchProgress.ts +++ b/src/hooks/useWatchProgress.ts @@ -103,36 +103,27 @@ export const useWatchProgress = ( setWatchProgress(null); } } else { - // Find the first unfinished episode - const unfinishedEpisode = episodes.find(ep => { - const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; - const progress = seriesProgresses.find(p => p.episodeId === epId); - if (!progress) return true; - const percent = (progress.progress.currentTime / progress.progress.duration) * 100; - return percent < 95; - }); - - if (unfinishedEpisode) { - const epId = unfinishedEpisode.stremioId || - `${id}:${unfinishedEpisode.season_number}:${unfinishedEpisode.episode_number}`; - const progress = await storageService.getWatchProgress(id, type, epId); - if (progress) { - setWatchProgress({ - ...progress, - episodeId: epId, - traktSynced: progress.traktSynced, - traktProgress: progress.traktProgress - }); - } else { - setWatchProgress({ - currentTime: 0, - duration: 0, - lastUpdated: Date.now(), - episodeId: epId, - traktSynced: false - }); - } + // FIXED: Find the most recently watched episode instead of first unfinished + // Sort by lastUpdated timestamp (most recent first) + const sortedProgresses = seriesProgresses.sort((a, b) => + b.progress.lastUpdated - a.progress.lastUpdated + ); + + if (sortedProgresses.length > 0) { + // Use the most recently watched episode + const mostRecentProgress = sortedProgresses[0]; + const progress = mostRecentProgress.progress; + + logger.log(`[useWatchProgress] Using most recent progress for ${mostRecentProgress.episodeId}, updated at ${new Date(progress.lastUpdated).toLocaleString()}`); + + setWatchProgress({ + ...progress, + episodeId: mostRecentProgress.episodeId, + traktSynced: progress.traktSynced, + traktProgress: progress.traktProgress + }); } else { + // No watched episodes found setWatchProgress(null); } } diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 52efa75..9a248ee 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -533,9 +533,9 @@ const HomeScreen = () => { console.log('[HomeScreen] Refreshing continue watching...'); if (continueWatchingRef.current) { try { - const hasContent = await continueWatchingRef.current.refresh(); + const hasContent = await continueWatchingRef.current.refresh(); console.log(`[HomeScreen] Continue watching has content: ${hasContent}`); - setHasContinueWatching(hasContent); + setHasContinueWatching(hasContent); // Debug: Let's check what's in storage const allProgress = await storageService.getAllWatchProgress(); @@ -667,7 +667,7 @@ const HomeScreen = () => { </TouchableOpacity> </View> - <ContinueWatchingSection ref={continueWatchingRef} /> + <ContinueWatchingSection ref={continueWatchingRef} /> {catalogs.length > 0 ? ( catalogs.map((catalog, index) => ( -- 2.45.2 From 3816435e0176c1cd84d17960f5e8c8ffc122cf52 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 02:01:31 +0530 Subject: [PATCH 71/88] Refactor ContinueWatchingSection and remove debug buttons from HomeScreen This update simplifies the ContinueWatchingSection by removing the loading condition for rendering when there are no continue watching items. Additionally, debug buttons for managing watch progress have been removed from the HomeScreen, streamlining the UI and enhancing user experience. --- .../home/ContinueWatchingSection.tsx | 2 +- src/screens/HomeScreen.tsx | 95 ------------------- 2 files changed, 1 insertion(+), 96 deletions(-) diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 2539ed5..2872d41 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -322,7 +322,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re }, [navigation]); // If no continue watching items, don't render anything - if (continueWatchingItems.length === 0 && !loading) { + if (continueWatchingItems.length === 0) { return null; } diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 9a248ee..9eb77a5 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -624,49 +624,6 @@ const HomeScreen = () => { <ThisWeekSection /> </Animated.View> - {/* Debug buttons for Continue Watching */} - <View style={{ flexDirection: 'row', padding: 16, gap: 10 }}> - <TouchableOpacity - style={{ - backgroundColor: currentTheme.colors.primary, - padding: 10, - borderRadius: 8, - flex: 1 - }} - onPress={addTestWatchProgress} - > - <Text style={{ color: 'white', textAlign: 'center', fontSize: 12 }}> - Add Test Progress - </Text> - </TouchableOpacity> - <TouchableOpacity - style={{ - backgroundColor: currentTheme.colors.error || '#ff4444', - padding: 10, - borderRadius: 8, - flex: 1 - }} - onPress={clearAllWatchProgress} - > - <Text style={{ color: 'white', textAlign: 'center', fontSize: 12 }}> - Clear All Progress - </Text> - </TouchableOpacity> - <TouchableOpacity - style={{ - backgroundColor: currentTheme.colors.secondary, - padding: 10, - borderRadius: 8, - flex: 1 - }} - onPress={refreshContinueWatching} - > - <Text style={{ color: 'white', textAlign: 'center', fontSize: 12 }}> - Refresh - </Text> - </TouchableOpacity> - </View> - <ContinueWatchingSection ref={continueWatchingRef} /> {catalogs.length > 0 ? ( @@ -709,58 +666,6 @@ const HomeScreen = () => { featuredContentSource ]); - // Debug function to add test watch progress - const addTestWatchProgress = useCallback(async () => { - console.log('[HomeScreen] Adding test watch progress data...'); - try { - // Add a test movie with 50% progress - await storageService.setWatchProgress( - 'tt1375666', // Inception IMDB ID - 'movie', - { - currentTime: 3600, // 1 hour - duration: 7200, // 2 hours (50% progress) - lastUpdated: Date.now() - } - ); - - // Add a test series episode with 30% progress - await storageService.setWatchProgress( - 'tt0944947', // Game of Thrones IMDB ID - 'series', - { - currentTime: 1800, // 30 minutes - duration: 6000, // 100 minutes (30% progress) - lastUpdated: Date.now() - 86400000 // 1 day ago - }, - 'tt0944947:1:1' // Season 1, Episode 1 - ); - - console.log('[HomeScreen] Test watch progress added successfully'); - - // Refresh the continue watching section - await refreshContinueWatching(); - } catch (error) { - console.error('[HomeScreen] Error adding test watch progress:', error); - } - }, [refreshContinueWatching]); - - // Debug function to clear all watch progress - const clearAllWatchProgress = useCallback(async () => { - console.log('[HomeScreen] Clearing all watch progress...'); - try { - const allProgress = await storageService.getAllWatchProgress(); - for (const key of Object.keys(allProgress)) { - const [type, id, episodeId] = key.split(':'); - await storageService.removeWatchProgress(id, type, episodeId); - } - console.log('[HomeScreen] All watch progress cleared'); - await refreshContinueWatching(); - } catch (error) { - console.error('[HomeScreen] Error clearing watch progress:', error); - } - }, [refreshContinueWatching]); - return isLoading ? renderLoadingScreen : renderMainContent; }; -- 2.45.2 From 00491c83e559cc978b66ca1ee66dd1e8b1168528 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 02:21:29 +0530 Subject: [PATCH 72/88] Enhance ContinueWatchingSection and MetadataScreen with improved logging and Trakt integration This update refines the ContinueWatchingSection by removing excessive console logging, enhancing error handling with a logger, and optimizing the loading process for continue watching items. Additionally, the MetadataScreen now fetches and logs Trakt progress data, providing detailed insights into playback progress for movies and series. This enhancement improves user experience by ensuring accurate tracking of watched content and streamlining the logging process. --- .../home/ContinueWatchingSection.tsx | 100 ++++-------------- src/screens/MetadataScreen.tsx | 100 ++++++++++++++++++ 2 files changed, 119 insertions(+), 81 deletions(-) diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 2872d41..f8840ea 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -80,13 +80,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re // Modified loadContinueWatching to be more efficient const loadContinueWatching = useCallback(async () => { try { - console.log('[ContinueWatching] Starting to load continue watching items...'); setLoading(true); const allProgress = await storageService.getAllWatchProgress(); - console.log(`[ContinueWatching] Found ${Object.keys(allProgress).length} progress items in storage`); if (Object.keys(allProgress).length === 0) { - console.log('[ContinueWatching] No progress items found, setting empty array'); setContinueWatchingItems([]); return; } @@ -97,24 +94,16 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re // Process each saved progress for (const key in allProgress) { - console.log(`[ContinueWatching] Raw key from storage: "${key}"`); - // Parse the key to get type and id const keyParts = key.split(':'); - console.log(`[ContinueWatching] Key parts:`, keyParts); - const [type, id, ...episodeIdParts] = keyParts; const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined; const progress = allProgress[key]; - console.log(`[ContinueWatching] Parsed - type: "${type}", id: "${id}", episodeId: "${episodeId}"`); - // Skip items that are more than 95% complete (effectively finished) const progressPercent = (progress.currentTime / progress.duration) * 100; - console.log(`[ContinueWatching] Progress for ${key}: ${progressPercent.toFixed(1)}%`); if (progressPercent >= 95) { - console.log(`[ContinueWatching] Skipping ${key} - too high progress (${progressPercent.toFixed(1)}%)`); continue; } @@ -122,34 +111,27 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re try { // Validate IMDB ID format before attempting to fetch if (!isValidImdbId(id)) { - console.log(`[ContinueWatching] Skipping ${type}:${id} - invalid IMDB ID format`); return; } - console.log(`[ContinueWatching] Fetching content details for ${type}:${id}`); let content: StreamingContent | null = null; // Get content details using catalogService content = await catalogService.getContentDetails(type, id); if (content) { - console.log(`[ContinueWatching] Successfully fetched content: ${content.name}`); - // Extract season and episode info from episodeId if available let season: number | undefined; let episode: number | undefined; let episodeTitle: string | undefined; if (episodeId && type === 'series') { - console.log(`[ContinueWatching] Parsing episode ID: ${episodeId}`); - // Try different episode ID formats let match = episodeId.match(/s(\d+)e(\d+)/i); // Format: s1e1 if (match) { season = parseInt(match[1], 10); episode = parseInt(match[2], 10); episodeTitle = `Episode ${episode}`; - console.log(`[ContinueWatching] Parsed s1e1 format: S${season}E${episode}`); } else { // Try format: seriesId:season:episode (e.g., tt0108778:4:6) const parts = episodeId.split(':'); @@ -164,14 +146,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re season = seasonNum; episode = episodeNum; episodeTitle = `Episode ${episode}`; - console.log(`[ContinueWatching] Parsed colon format: S${season}E${episode}`); } } } - - if (!season || !episode) { - console.log(`[ContinueWatching] Failed to parse episode details from: ${episodeId}`); - } } const continueWatchingItem: ContinueWatchingItem = { @@ -183,31 +160,18 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re episodeTitle }; - console.log(`[ContinueWatching] Created item for ${content.name}:`, { - type, - season, - episode, - episodeTitle, - episodeId, - originalKey: key - }); - if (type === 'series') { // For series, keep only the latest watched episode for each show if (!latestEpisodes[id] || latestEpisodes[id].lastUpdated < progress.lastUpdated) { latestEpisodes[id] = continueWatchingItem; - console.log(`[ContinueWatching] Updated latest episode for series ${id}`); } } else { // For movies, add to the list directly progressItems.push(continueWatchingItem); - console.log(`[ContinueWatching] Added movie to progress items`); } - } else { - console.log(`[ContinueWatching] Failed to fetch content details for ${type}:${id}`); } } catch (error) { - console.error(`[ContinueWatching] Failed to get content details for ${type}:${id}`, error); + logger.error(`Failed to get content details for ${type}:${id}`, error); } })(); @@ -215,35 +179,20 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re } // Wait for all content to be processed - console.log(`[ContinueWatching] Waiting for ${contentPromises.length} content promises...`); await Promise.all(contentPromises); // Add the latest episodes for each series to the items list progressItems.push(...Object.values(latestEpisodes)); - console.log(`[ContinueWatching] Total items after processing: ${progressItems.length}`); // Sort by last updated time (most recent first) progressItems.sort((a, b) => b.lastUpdated - a.lastUpdated); // Limit to 10 items const finalItems = progressItems.slice(0, 10); - console.log(`[ContinueWatching] Final continue watching items: ${finalItems.length}`); - - // Debug: Log the final items with their episode details - finalItems.forEach((item, index) => { - console.log(`[ContinueWatching] Item ${index}:`, { - name: item.name, - type: item.type, - season: item.season, - episode: item.episode, - episodeTitle: item.episodeTitle, - progress: item.progress - }); - }); setContinueWatchingItems(finalItems); } catch (error) { - console.error('[ContinueWatching] Failed to load continue watching items:', error); + logger.error('Failed to load continue watching items:', error); } finally { setLoading(false); } @@ -308,11 +257,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re // Properly expose the refresh method React.useImperativeHandle(ref, () => ({ refresh: async () => { - console.log('[ContinueWatching] Refresh method called'); await loadContinueWatching(); // Return whether there are items to help parent determine visibility const hasItems = continueWatchingItems.length > 0; - console.log(`[ContinueWatching] Refresh returning hasItems: ${hasItems}, items count: ${continueWatchingItems.length}`); return hasItems; } })); @@ -379,32 +326,23 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re {/* Episode Info or Year */} {(() => { - console.log(`[ContinueWatching] Rendering item:`, { - name: item.name, - type: item.type, - season: item.season, - episode: item.episode, - episodeTitle: item.episodeTitle, - hasSeasonAndEpisode: !!(item.season && item.episode) - }); - - if (item.type === 'series' && item.season && item.episode) { - return ( - <View style={styles.episodeRow}> - <Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}> - Season {item.season} - </Text> - {item.episodeTitle && ( - <Text - style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]} - numberOfLines={1} - > - {item.episodeTitle} - </Text> - )} - </View> - ); - } else { + if (item.type === 'series' && item.season && item.episode) { + return ( + <View style={styles.episodeRow}> + <Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}> + Season {item.season} + </Text> + {item.episodeTitle && ( + <Text + style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]} + numberOfLines={1} + > + {item.episodeTitle} + </Text> + )} + </View> + ); + } else { return ( <Text style={[styles.yearText, { color: currentTheme.colors.mediumEmphasis }]}> {item.year} • {item.type === 'movie' ? 'Movie' : 'Series'} diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index d6caeb6..73b5d5b 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -40,6 +40,7 @@ import MetadataDetails from '../components/metadata/MetadataDetails'; import { useMetadataAnimations } from '../hooks/useMetadataAnimations'; import { useMetadataAssets } from '../hooks/useMetadataAssets'; import { useWatchProgress } from '../hooks/useWatchProgress'; +import { TraktService, TraktPlaybackItem } from '../services/traktService'; const { height } = Dimensions.get('window'); @@ -84,6 +85,105 @@ const MetadataScreen: React.FC = () => { const assetData = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata); const animations = useMetadataAnimations(safeAreaTop, watchProgressData.watchProgress); + // Fetch and log Trakt progress data when entering the screen + useEffect(() => { + const fetchTraktProgress = async () => { + try { + const traktService = TraktService.getInstance(); + const isAuthenticated = await traktService.isAuthenticated(); + + console.log(`[MetadataScreen] === TRAKT PROGRESS DATA FOR ${type.toUpperCase()}: ${metadata?.name || id} ===`); + console.log(`[MetadataScreen] IMDB ID: ${id}`); + console.log(`[MetadataScreen] Trakt authenticated: ${isAuthenticated}`); + + if (!isAuthenticated) { + console.log(`[MetadataScreen] Not authenticated with Trakt, no progress data available`); + return; + } + + // Get all playback progress from Trakt + const allProgress = await traktService.getPlaybackProgress(); + console.log(`[MetadataScreen] Total Trakt progress items: ${allProgress.length}`); + + if (allProgress.length === 0) { + console.log(`[MetadataScreen] No Trakt progress data found`); + return; + } + + // Filter progress for current content + let relevantProgress: TraktPlaybackItem[] = []; + + if (type === 'movie') { + relevantProgress = allProgress.filter(item => + item.type === 'movie' && + item.movie?.ids.imdb === id.replace('tt', '') + ); + } else if (type === 'series') { + relevantProgress = allProgress.filter(item => + item.type === 'episode' && + item.show?.ids.imdb === id.replace('tt', '') + ); + } + + console.log(`[MetadataScreen] Relevant progress items for this ${type}: ${relevantProgress.length}`); + + if (relevantProgress.length === 0) { + console.log(`[MetadataScreen] No Trakt progress found for this ${type}`); + return; + } + + // Log detailed progress information + relevantProgress.forEach((item, index) => { + console.log(`[MetadataScreen] --- Progress Item ${index + 1} ---`); + console.log(`[MetadataScreen] Type: ${item.type}`); + console.log(`[MetadataScreen] Progress: ${item.progress.toFixed(2)}%`); + console.log(`[MetadataScreen] Paused at: ${item.paused_at}`); + console.log(`[MetadataScreen] Trakt ID: ${item.id}`); + + if (item.movie) { + console.log(`[MetadataScreen] Movie: ${item.movie.title} (${item.movie.year})`); + console.log(`[MetadataScreen] Movie IMDB: tt${item.movie.ids.imdb}`); + console.log(`[MetadataScreen] Movie TMDB: ${item.movie.ids.tmdb}`); + } + + if (item.episode && item.show) { + console.log(`[MetadataScreen] Show: ${item.show.title} (${item.show.year})`); + console.log(`[MetadataScreen] Show IMDB: tt${item.show.ids.imdb}`); + console.log(`[MetadataScreen] Episode: S${item.episode.season}E${item.episode.number} - ${item.episode.title}`); + console.log(`[MetadataScreen] Episode IMDB: ${item.episode.ids.imdb || 'N/A'}`); + console.log(`[MetadataScreen] Episode TMDB: ${item.episode.ids.tmdb || 'N/A'}`); + } + + console.log(`[MetadataScreen] Raw item:`, JSON.stringify(item, null, 2)); + }); + + // Find most recent progress if multiple episodes + if (type === 'series' && relevantProgress.length > 1) { + const mostRecent = relevantProgress.sort((a, b) => + new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime() + )[0]; + + console.log(`[MetadataScreen] === MOST RECENT EPISODE PROGRESS ===`); + if (mostRecent.episode && mostRecent.show) { + console.log(`[MetadataScreen] Most recent: S${mostRecent.episode.season}E${mostRecent.episode.number} - ${mostRecent.episode.title}`); + console.log(`[MetadataScreen] Progress: ${mostRecent.progress.toFixed(2)}%`); + console.log(`[MetadataScreen] Watched on: ${new Date(mostRecent.paused_at).toLocaleString()}`); + } + } + + console.log(`[MetadataScreen] === END TRAKT PROGRESS DATA ===`); + + } catch (error) { + console.error(`[MetadataScreen] Failed to fetch Trakt progress:`, error); + } + }; + + // Only fetch when we have metadata loaded + if (metadata && id) { + fetchTraktProgress(); + } + }, [metadata, id, type]); + // Memoized derived values for performance const isReady = useMemo(() => !loading && metadata && !metadataError, [loading, metadata, metadataError]); -- 2.45.2 From d3061bf83f7af285d9eca11aea6b6807d53585be Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 02:24:27 +0530 Subject: [PATCH 73/88] Refactor useTraktAutosyncSettings for improved manual sync logic and logging This update enhances the manual sync process in the useTraktAutosyncSettings hook by introducing a more comprehensive success condition. The sync is now considered successful if either fetching from Trakt or uploading local progress succeeds, improving user feedback. Additionally, unnecessary debug buttons have been removed from the TraktSettingsScreen, streamlining the UI and enhancing user experience. --- src/hooks/useTraktAutosyncSettings.ts | 14 +++++--- src/screens/TraktSettingsScreen.tsx | 50 --------------------------- 2 files changed, 10 insertions(+), 54 deletions(-) diff --git a/src/hooks/useTraktAutosyncSettings.ts b/src/hooks/useTraktAutosyncSettings.ts index 9bcac08..29d3268 100644 --- a/src/hooks/useTraktAutosyncSettings.ts +++ b/src/hooks/useTraktAutosyncSettings.ts @@ -107,13 +107,19 @@ export function useTraktAutosyncSettings() { logger.log('[useTraktAutosyncSettings] Starting manual sync...'); // First, fetch and merge Trakt progress with local - await fetchAndMergeTraktProgress(); + const fetchSuccess = await fetchAndMergeTraktProgress(); // Then, sync any unsynced local progress to Trakt - const success = await syncAllProgress(); + const uploadSuccess = await syncAllProgress(); - logger.log(`[useTraktAutosyncSettings] Manual sync ${success ? 'completed' : 'failed'}`); - return success; + // Consider sync successful if either: + // 1. We successfully fetched from Trakt (main purpose of manual sync) + // 2. We successfully uploaded local progress to Trakt + // 3. Everything was already in sync (uploadSuccess = false is OK if fetchSuccess = true) + const overallSuccess = fetchSuccess || uploadSuccess; + + logger.log(`[useTraktAutosyncSettings] Manual sync ${overallSuccess ? 'completed' : 'failed'}`); + return overallSuccess; } catch (error) { logger.error('[useTraktAutosyncSettings] Error during manual sync:', error); return false; diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index ec3ad88..d0cfa96 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -401,57 +401,7 @@ const TraktSettingsScreen: React.FC = () => { )} </TouchableOpacity> - <TouchableOpacity - style={[ - styles.button, - { - backgroundColor: isDarkMode ? '#FF6B35' + '40' : '#FF6B35' + '20', - marginTop: 8 - } - ]} - onPress={async () => { - await traktService.debugPlaybackProgress(); - Alert.alert( - 'Debug Complete', - 'Check the app logs for current Trakt playback progress. Look for lines starting with "[TraktService] DEBUG".', - [{ text: 'OK' }] - ); - }} - > - <Text style={[ - styles.buttonText, - { color: '#FF6B35' } - ]}> - Debug Trakt Progress - </Text> - </TouchableOpacity> - <TouchableOpacity - style={[ - styles.button, - { - backgroundColor: isDarkMode ? '#9B59B6' + '40' : '#9B59B6' + '20', - marginTop: 8 - } - ]} - onPress={async () => { - const result = await traktService.debugTraktConnection(); - Alert.alert( - 'Connection Test', - result.authenticated - ? `Connection successful! User: ${result.user?.username || 'Unknown'}` - : `Connection failed: ${result.error}`, - [{ text: 'OK' }] - ); - }} - > - <Text style={[ - styles.buttonText, - { color: '#9B59B6' } - ]}> - Test API Connection - </Text> - </TouchableOpacity> </View> </View> )} -- 2.45.2 From 5545ed40fb4eccbfb54c9843c19aa0ad6ad92ca9 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 02:27:50 +0530 Subject: [PATCH 74/88] Update styles in CatalogSection, ContinueWatchingSection, ThisWeekSection, and HomeScreen for improved UI consistency This commit enhances the visual appearance of the CatalogSection, ContinueWatchingSection, ThisWeekSection, and HomeScreen by updating font sizes, weights, letter spacing, and margins. Additionally, adjustments to the title underline styles have been made for better alignment and opacity, contributing to a more cohesive user interface across these components. --- src/components/home/CatalogSection.tsx | 18 +++++++++--------- .../home/ContinueWatchingSection.tsx | 18 +++++++++--------- src/components/home/ThisWeekSection.tsx | 5 +++-- src/screens/HomeScreen.tsx | 18 +++++++++--------- src/screens/TraktSettingsScreen.tsx | 2 +- 5 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx index d4cdd9c..71937b0 100644 --- a/src/components/home/CatalogSection.tsx +++ b/src/components/home/CatalogSection.tsx @@ -132,19 +132,19 @@ const styles = StyleSheet.create({ position: 'relative', }, catalogTitle: { - fontSize: 18, - fontWeight: '800', - textTransform: 'uppercase', - letterSpacing: 0.5, - marginBottom: 6, + fontSize: 19, + fontWeight: '700', + letterSpacing: 0.2, + marginBottom: 4, }, titleUnderline: { position: 'absolute', - bottom: -4, + bottom: -2, left: 0, - width: 60, - height: 3, - borderRadius: 1.5, + width: 35, + height: 2, + borderRadius: 1, + opacity: 0.8, }, seeAllButton: { flexDirection: 'row', diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index f8840ea..13c420d 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -401,19 +401,19 @@ const styles = StyleSheet.create({ position: 'relative', }, title: { - fontSize: 18, - fontWeight: '800', - textTransform: 'uppercase', - letterSpacing: 0.5, - marginBottom: 6, + fontSize: 20, + fontWeight: '700', + letterSpacing: 0.3, + marginBottom: 4, }, titleUnderline: { position: 'absolute', - bottom: -4, + bottom: -2, left: 0, - width: 60, - height: 3, - borderRadius: 1.5, + width: 40, + height: 2, + borderRadius: 1, + opacity: 0.8, }, wideList: { paddingHorizontal: 16, diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx index 1fe8cd5..30ebfe4 100644 --- a/src/components/home/ThisWeekSection.tsx +++ b/src/components/home/ThisWeekSection.tsx @@ -303,8 +303,9 @@ const styles = StyleSheet.create({ marginBottom: 12, }, title: { - fontSize: 18, - fontWeight: 'bold', + fontSize: 19, + fontWeight: '700', + letterSpacing: 0.2, }, viewAllButton: { flexDirection: 'row', diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 9eb77a5..28d92fa 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -876,19 +876,19 @@ const styles = StyleSheet.create<any>({ position: 'relative', }, catalogTitle: { - fontSize: 18, - fontWeight: '800', - textTransform: 'uppercase', - letterSpacing: 0.5, - marginBottom: 6, + fontSize: 19, + fontWeight: '700', + letterSpacing: 0.2, + marginBottom: 4, }, titleUnderline: { position: 'absolute', - bottom: -4, + bottom: -2, left: 0, - width: 60, - height: 3, - borderRadius: 1.5, + width: 35, + height: 2, + borderRadius: 1, + opacity: 0.8, }, seeAllButton: { flexDirection: 'row', diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index d0cfa96..ba6bb44 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -365,7 +365,7 @@ const TraktSettingsScreen: React.FC = () => { styles.settingDescription, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark } ]}> - Coming soon + Use "Sync Now" to import your watch history and progress from Trakt </Text> </View> <TouchableOpacity -- 2.45.2 From 9cb2049d6b8a95c8f265279fd2a3ad7e56105111 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 02:47:50 +0530 Subject: [PATCH 75/88] ui fixes --- src/components/home/CatalogSection.tsx | 52 ++-- src/components/home/ContentItem.tsx | 60 +++-- src/screens/HomeScreen.tsx | 317 ++++++++++++++++++++----- 3 files changed, 334 insertions(+), 95 deletions(-) diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx index 71937b0..80a3bdc 100644 --- a/src/components/home/CatalogSection.tsx +++ b/src/components/home/CatalogSection.tsx @@ -15,26 +15,40 @@ interface CatalogSectionProps { const { width } = Dimensions.get('window'); -// Dynamic poster calculation based on screen width +// Dynamic poster calculation based on screen width - show 1/4 of next poster const calculatePosterLayout = (screenWidth: number) => { - const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability - const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters - const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins + const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters + const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters + const LEFT_PADDING = 16; // Left padding + const SPACING = 8; // Space between posters - // Calculate how many posters can fit - const availableWidth = screenWidth - HORIZONTAL_PADDING; - const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); + // Calculate available width for posters (reserve space for left padding) + const availableWidth = screenWidth - LEFT_PADDING; - // Limit to reasonable number of columns (3-6) - const numColumns = Math.min(Math.max(maxColumns, 3), 6); + // Try different numbers of full posters to find the best fit + let bestLayout = { numFullPosters: 3, posterWidth: 120 }; - // Calculate actual poster width - const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); + for (let n = 3; n <= 6; n++) { + // Calculate poster width needed for N full posters + 0.25 partial poster + // Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding + // Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding + // We'll use minimal right padding (8px) to maximize space + const usableWidth = availableWidth - 8; + const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25); + + console.log(`[CatalogSection] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`); + + if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) { + bestLayout = { numFullPosters: n, posterWidth }; + console.log(`[CatalogSection] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`); + } + } return { - numColumns, - posterWidth, - spacing: 12 // Space between posters + numFullPosters: bestLayout.numFullPosters, + posterWidth: bestLayout.posterWidth, + spacing: SPACING, + partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster }; }; @@ -98,18 +112,18 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { keyExtractor={(item) => `${item.id}-${item.type}`} horizontal showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.catalogList} - snapToInterval={POSTER_WIDTH + 12} + contentContainerStyle={[styles.catalogList, { paddingRight: 16 - posterLayout.partialPosterWidth }]} + snapToInterval={POSTER_WIDTH + 8} decelerationRate="fast" snapToAlignment="start" - ItemSeparatorComponent={() => <View style={{ width: 12 }} />} + ItemSeparatorComponent={() => <View style={{ width: 8 }} />} initialNumToRender={4} maxToRenderPerBatch={4} windowSize={5} removeClippedSubviews={Platform.OS === 'android'} getItemLayout={(data, index) => ({ - length: POSTER_WIDTH + 12, - offset: (POSTER_WIDTH + 12) * index, + length: POSTER_WIDTH + 8, + offset: (POSTER_WIDTH + 8) * index, index, })} /> diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index f5061c5..860a301 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -13,26 +13,40 @@ interface ContentItemProps { const { width } = Dimensions.get('window'); -// Dynamic poster calculation based on screen width +// Dynamic poster calculation based on screen width - show 1/4 of next poster const calculatePosterLayout = (screenWidth: number) => { - const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability - const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters - const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins + const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters + const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters + const LEFT_PADDING = 16; // Left padding + const SPACING = 8; // Space between posters - // Calculate how many posters can fit - const availableWidth = screenWidth - HORIZONTAL_PADDING; - const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); + // Calculate available width for posters (reserve space for left padding) + const availableWidth = screenWidth - LEFT_PADDING; - // Limit to reasonable number of columns (3-6) - const numColumns = Math.min(Math.max(maxColumns, 3), 6); + // Try different numbers of full posters to find the best fit + let bestLayout = { numFullPosters: 3, posterWidth: 120 }; - // Calculate actual poster width - const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); + for (let n = 3; n <= 6; n++) { + // Calculate poster width needed for N full posters + 0.25 partial poster + // Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding + // Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding + // We'll use minimal right padding (8px) to maximize space + const usableWidth = availableWidth - 8; + const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25); + + console.log(`[ContentItem] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`); + + if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) { + bestLayout = { numFullPosters: n, posterWidth }; + console.log(`[ContentItem] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`); + } + } return { - numColumns, - posterWidth, - spacing: 12 // Space between posters + numFullPosters: bestLayout.numFullPosters, + posterWidth: bestLayout.posterWidth, + spacing: SPACING, + partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster }; }; @@ -157,28 +171,28 @@ const styles = StyleSheet.create({ width: POSTER_WIDTH, aspectRatio: 2/3, margin: 0, - borderRadius: 8, + borderRadius: 4, overflow: 'hidden', position: 'relative', - elevation: 8, + elevation: 6, shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.08)', + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.25, + shadowRadius: 6, + borderWidth: 0.5, + borderColor: 'rgba(255,255,255,0.12)', }, contentItemContainer: { width: '100%', height: '100%', - borderRadius: 8, + borderRadius: 4, overflow: 'hidden', position: 'relative', }, poster: { width: '100%', height: '100%', - borderRadius: 8, + borderRadius: 4, }, loadingOverlay: { position: 'absolute', diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 28d92fa..483f512 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -22,6 +22,7 @@ import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService'; +import { stremioService } from '../services/stremioService'; import { Stream } from '../types/metadata'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; @@ -386,16 +387,16 @@ const HomeScreen = () => { const { currentTheme } = useTheme(); const continueWatchingRef = useRef<ContinueWatchingRef>(null); const { settings } = useSettings(); + const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection); const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource); const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null); const [hasContinueWatching, setHasContinueWatching] = useState(false); - const { - catalogs, - loading: catalogsLoading, - refreshCatalogs - } = useHomeCatalogs(); + const [catalogs, setCatalogs] = useState<CatalogContent[]>([]); + const [catalogsLoading, setCatalogsLoading] = useState(true); + const [loadedCatalogCount, setLoadedCatalogCount] = useState(0); + const totalCatalogsRef = useRef(0); const { featuredContent, @@ -405,10 +406,116 @@ const HomeScreen = () => { refreshFeatured } = useFeaturedContent(); + // Progressive catalog loading function + const loadCatalogsProgressively = useCallback(async () => { + setCatalogsLoading(true); + setCatalogs([]); + setLoadedCatalogCount(0); + + try { + const addons = await catalogService.getAllAddons(); + + // Create placeholder array with proper order and track indices + const catalogPlaceholders: (CatalogContent | null)[] = []; + const catalogPromises: Promise<void>[] = []; + let catalogIndex = 0; + + for (const addon of addons) { + if (addon.catalogs) { + for (const catalog of addon.catalogs) { + const currentIndex = catalogIndex; + catalogPlaceholders.push(null); // Reserve position + + const catalogPromise = (async () => { + try { + const addonManifest = await stremioService.getInstalledAddonsAsync(); + const manifest = addonManifest.find((a: any) => a.id === addon.id); + if (!manifest) return; + + const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); + if (metas && metas.length > 0) { + const items = metas.map((meta: any) => ({ + id: meta.id, + type: meta.type, + name: meta.name, + poster: meta.poster, + posterShape: meta.posterShape, + banner: meta.background, + logo: meta.logo, + imdbRating: meta.imdbRating, + year: meta.year, + genres: meta.genres, + description: meta.description, + runtime: meta.runtime, + released: meta.released, + trailerStreams: meta.trailerStreams, + videos: meta.videos, + directors: meta.director, + creators: meta.creator, + certification: meta.certification + })); + + let displayName = catalog.name; + const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; + if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { + displayName = `${displayName} ${contentType}`; + } + + const catalogContent = { + addon: addon.id, + type: catalog.type, + id: catalog.id, + name: displayName, + items + }; + + console.log(`[HomeScreen] Loaded catalog: ${displayName} at position ${currentIndex} (${items.length} items)`); + + // Update the catalog at its specific position + setCatalogs(prevCatalogs => { + const newCatalogs = [...prevCatalogs]; + newCatalogs[currentIndex] = catalogContent; + return newCatalogs; + }); + } + } catch (error) { + console.error(`[HomeScreen] Failed to load ${catalog.name} from ${addon.name}:`, error); + } finally { + setLoadedCatalogCount(prev => prev + 1); + } + })(); + + catalogPromises.push(catalogPromise); + catalogIndex++; + } + } + } + + totalCatalogsRef.current = catalogIndex; + console.log(`[HomeScreen] Starting to load ${catalogIndex} catalogs progressively...`); + + // Initialize catalogs array with proper length + setCatalogs(new Array(catalogIndex).fill(null)); + + // Wait for all catalogs to finish loading (success or failure) + await Promise.allSettled(catalogPromises); + console.log('[HomeScreen] All catalogs processed'); + + // Filter out null values to get only successfully loaded catalogs + setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null)); + + } catch (error) { + console.error('[HomeScreen] Error in progressive catalog loading:', error); + } finally { + setCatalogsLoading(false); + } + }, []); + // Only count feature section as loading if it's enabled in settings + // For catalogs, we show them progressively, so only show loading if no catalogs are loaded yet const isLoading = useMemo(() => - (showHeroSection ? featuredLoading : false) || catalogsLoading, - [showHeroSection, featuredLoading, catalogsLoading] + (showHeroSection ? featuredLoading : false) || (catalogsLoading && catalogs.length === 0), + [showHeroSection, featuredLoading, catalogsLoading, catalogs.length] ); // React to settings changes @@ -417,6 +524,21 @@ const HomeScreen = () => { setFeaturedContentSource(settings.featuredContentSource); }, [settings]); + // Load catalogs progressively on mount and when settings change + useEffect(() => { + loadCatalogsProgressively(); + }, [loadCatalogsProgressively]); + + // Listen for catalog changes (addon additions/removals) and reload catalogs + useEffect(() => { + loadCatalogsProgressively(); + }, [lastUpdate, loadCatalogsProgressively]); + + // Create a refresh function for catalogs + const refreshCatalogs = useCallback(() => { + return loadCatalogsProgressively(); + }, [loadCatalogsProgressively]); + // Subscribe directly to settings emitter for immediate updates useEffect(() => { const handleSettingsChange = () => { @@ -567,10 +689,13 @@ const HomeScreen = () => { useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { refreshContinueWatching(); + // Also refresh catalogs when returning to home screen + // This ensures new addons are shown even if the context event was missed + loadCatalogsProgressively(); }); return unsubscribe; - }, [navigation, refreshContinueWatching]); + }, [navigation, refreshContinueWatching, loadCatalogsProgressively]); // Memoize the loading screen to prevent unnecessary re-renders const renderLoadingScreen = useMemo(() => { @@ -626,28 +751,63 @@ const HomeScreen = () => { <ContinueWatchingSection ref={continueWatchingRef} /> - {catalogs.length > 0 ? ( - catalogs.map((catalog, index) => ( - <View key={`${catalog.addon}-${catalog.id}-${index}`}> + {/* Show catalogs as they load */} + {catalogs.map((catalog, index) => { + if (!catalog) { + // Show placeholder for loading catalog + return ( + <View key={`placeholder-${index}`} style={styles.catalogPlaceholder}> + <View style={styles.placeholderHeader}> + <View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} /> + <ActivityIndicator size="small" color={currentTheme.colors.primary} /> + </View> + <View style={styles.placeholderPosters}> + {[...Array(4)].map((_, posterIndex) => ( + <View + key={posterIndex} + style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]} + /> + ))} + </View> + </View> + ); + } + + return ( + <Animated.View + key={`${catalog.addon}-${catalog.id}-${index}`} + entering={FadeIn.duration(300)} + > <CatalogSection catalog={catalog} /> - </View> - )) - ) : ( - !catalogsLoading && ( - <View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}> - <MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} /> - <Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}> - No content available - </Text> - <TouchableOpacity - style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]} - onPress={() => navigation.navigate('Settings')} - > - <MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} /> - <Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text> - </TouchableOpacity> - </View> - ) + </Animated.View> + ); + })} + + {/* Show loading indicator for remaining catalogs */} + {catalogsLoading && catalogs.length < totalCatalogsRef.current && ( + <View style={styles.loadingMoreCatalogs}> + <ActivityIndicator size="small" color={currentTheme.colors.primary} /> + <Text style={[styles.loadingMoreText, { color: currentTheme.colors.textMuted }]}> + Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current}) + </Text> + </View> + )} + + {/* Show empty state only if all catalogs are loaded and none are available */} + {!catalogsLoading && catalogs.length === 0 && ( + <View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}> + <MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} /> + <Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}> + No content available + </Text> + <TouchableOpacity + style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]} + onPress={() => navigation.navigate('Settings')} + > + <MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} /> + <Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text> + </TouchableOpacity> + </View> )} </ScrollView> </View> @@ -671,26 +831,40 @@ const HomeScreen = () => { const { width, height } = Dimensions.get('window'); -// Dynamic poster calculation based on screen width +// Dynamic poster calculation based on screen width - show 1/4 of next poster const calculatePosterLayout = (screenWidth: number) => { - const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability - const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters - const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins + const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters + const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters + const LEFT_PADDING = 16; // Left padding + const SPACING = 8; // Space between posters - // Calculate how many posters can fit - const availableWidth = screenWidth - HORIZONTAL_PADDING; - const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); + // Calculate available width for posters (reserve space for left padding) + const availableWidth = screenWidth - LEFT_PADDING; - // Limit to reasonable number of columns (3-6) - const numColumns = Math.min(Math.max(maxColumns, 3), 6); + // Try different numbers of full posters to find the best fit + let bestLayout = { numFullPosters: 3, posterWidth: 120 }; - // Calculate actual poster width - const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); + for (let n = 3; n <= 6; n++) { + // Calculate poster width needed for N full posters + 0.25 partial poster + // Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding + // Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding + // We'll use minimal right padding (8px) to maximize space + const usableWidth = availableWidth - 8; + const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25); + + console.log(`[HomeScreen] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`); + + if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) { + bestLayout = { numFullPosters: n, posterWidth }; + console.log(`[HomeScreen] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`); + } + } return { - numColumns, - posterWidth, - spacing: 12 // Space between posters + numFullPosters: bestLayout.numFullPosters, + posterWidth: bestLayout.posterWidth, + spacing: SPACING, + partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster }; }; @@ -714,6 +888,42 @@ const styles = StyleSheet.create<any>({ marginTop: 12, fontSize: 14, }, + loadingMoreCatalogs: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 16, + marginHorizontal: 16, + marginBottom: 16, + }, + loadingMoreText: { + marginLeft: 12, + fontSize: 14, + }, + catalogPlaceholder: { + marginBottom: 24, + paddingHorizontal: 16, + }, + placeholderHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + placeholderTitle: { + width: 150, + height: 20, + borderRadius: 4, + }, + placeholderPosters: { + flexDirection: 'row', + gap: 8, + }, + placeholderPoster: { + width: POSTER_WIDTH, + aspectRatio: 2/3, + borderRadius: 4, + }, emptyCatalog: { padding: 32, alignItems: 'center', @@ -903,7 +1113,8 @@ const styles = StyleSheet.create<any>({ marginRight: 4, }, catalogList: { - paddingHorizontal: 16, + paddingLeft: 16, + paddingRight: 16 - posterLayout.partialPosterWidth, paddingBottom: 12, paddingTop: 6, }, @@ -911,21 +1122,21 @@ const styles = StyleSheet.create<any>({ width: POSTER_WIDTH, aspectRatio: 2/3, margin: 0, - borderRadius: 8, + borderRadius: 4, overflow: 'hidden', position: 'relative', - elevation: 8, + elevation: 6, shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.08)', + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.25, + shadowRadius: 6, + borderWidth: 0.5, + borderColor: 'rgba(255,255,255,0.12)', }, poster: { width: '100%', height: '100%', - borderRadius: 8, + borderRadius: 4, }, imdbLogo: { width: 35, @@ -964,7 +1175,7 @@ const styles = StyleSheet.create<any>({ contentItemContainer: { width: '100%', height: '100%', - borderRadius: 8, + borderRadius: 4, overflow: 'hidden', position: 'relative', }, -- 2.45.2 From 805d7e1fa6eab00b63cf91a55dbb18edd4b354ae Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 03:33:56 +0530 Subject: [PATCH 76/88] homescreen refresh fix --- src/screens/HomeScreen.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 483f512..99ad580 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -688,14 +688,17 @@ const HomeScreen = () => { useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { + // Only refresh continue watching section on focus refreshContinueWatching(); - // Also refresh catalogs when returning to home screen - // This ensures new addons are shown even if the context event was missed - loadCatalogsProgressively(); + // Don't reload catalogs unless they haven't been loaded yet + // Catalogs will be refreshed through context updates when addons change + if (catalogs.length === 0 && !catalogsLoading) { + loadCatalogsProgressively(); + } }); return unsubscribe; - }, [navigation, refreshContinueWatching, loadCatalogsProgressively]); + }, [navigation, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]); // Memoize the loading screen to prevent unnecessary re-renders const renderLoadingScreen = useMemo(() => { -- 2.45.2 From d88962ae01740c5701f3fe7a1f189e0dc99465cd Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 13:00:24 +0530 Subject: [PATCH 77/88] Enhance metadata handling and navigation with addon support This update introduces support for addon IDs in various components, including CatalogSection, ContinueWatchingSection, and MetadataScreen, allowing for enhanced metadata fetching. The CatalogService now includes methods for retrieving both basic and enhanced content details based on the specified addon. Additionally, improvements to the loading process in HomeScreen ensure a more efficient catalog loading experience. These changes enhance user experience by providing richer content details and smoother navigation. --- src/components/home/CatalogSection.tsx | 2 +- .../home/ContinueWatchingSection.tsx | 4 +- src/components/metadata/MovieContent.tsx | 6 +- src/hooks/useMetadata.ts | 88 ++++++++++-- src/navigation/AppNavigator.tsx | 1 + src/screens/CatalogScreen.tsx | 2 +- src/screens/HomeScreen.tsx | 14 +- src/screens/MetadataScreen.tsx | 6 +- src/services/catalogService.ts | 128 ++++++++++++++++- src/services/stremioService.ts | 135 ++++++++++++++++-- src/types/metadata.ts | 21 ++- src/types/navigation.d.ts | 1 + 12 files changed, 365 insertions(+), 43 deletions(-) diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx index 80a3bdc..1a2987d 100644 --- a/src/components/home/CatalogSection.tsx +++ b/src/components/home/CatalogSection.tsx @@ -60,7 +60,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { const { currentTheme } = useTheme(); const handleContentPress = (id: string, type: string) => { - navigation.navigate('Metadata', { id, type }); + navigation.navigate('Metadata', { id, type, addonId: catalog.addon }); }; const renderContentItem = ({ item, index }: { item: StreamingContent, index: number }) => { diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 13c420d..ba9a8ce 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -116,8 +116,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re let content: StreamingContent | null = null; - // Get content details using catalogService - content = await catalogService.getContentDetails(type, id); + // Get basic content details using catalogService (no enhanced metadata needed for continue watching) + content = await catalogService.getBasicContentDetails(type, id); if (content) { // Extract season and episode info from episodeId if available diff --git a/src/components/metadata/MovieContent.tsx b/src/components/metadata/MovieContent.tsx index 95836e1..eceabe3 100644 --- a/src/components/metadata/MovieContent.tsx +++ b/src/components/metadata/MovieContent.tsx @@ -10,7 +10,7 @@ interface MovieContentProps { export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => { const { currentTheme } = useTheme(); const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0; - const castDisplay = hasCast ? (metadata.cast as string[]).slice(0, 5).join(', ') : ''; + const castDisplay = hasCast ? metadata.cast!.slice(0, 5).join(', ') : ''; return ( <View style={styles.container}> @@ -23,10 +23,10 @@ export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => { </View> )} - {metadata.writer && ( + {metadata.writer && metadata.writer.length > 0 && ( <View style={styles.metadataRow}> <Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Writer:</Text> - <Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{metadata.writer}</Text> + <Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{Array.isArray(metadata.writer) ? metadata.writer.join(', ') : metadata.writer}</Text> </View> )} diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 5a44301..4e48dde 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -60,6 +60,7 @@ const withRetry = async <T>( interface UseMetadataProps { id: string; type: string; + addonId?: string; } interface UseMetadataReturn { @@ -94,7 +95,7 @@ interface UseMetadataReturn { imdbId: string | null; } -export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn => { +export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => { const [metadata, setMetadata] = useState<StreamingContent | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); @@ -411,7 +412,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = if (writers.length > 0) { (formattedMovie as any).creators = writers; - (formattedMovie as StreamingContent & { writer: string }).writer = writers.join(', '); + (formattedMovie as any).writer = writers; } } } catch (error) { @@ -513,10 +514,10 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = const [content, castData] = await Promise.allSettled([ // Load content with timeout and retry withRetry(async () => { - const result = await withTimeout( - catalogService.getContentDetails(type, actualId), - API_TIMEOUT - ); + const result = await withTimeout( + catalogService.getEnhancedContentDetails(type, actualId, addonId), + API_TIMEOUT + ); // Store the actual ID used (could be IMDB) if (actualId.startsWith('tt')) { setImdbId(actualId); @@ -540,8 +541,10 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = cacheService.setMetadata(id, type, content.value); if (type === 'series') { - // Load series data in parallel with other data - loadSeriesData().catch(console.error); + // Load series data after the enhanced metadata is processed + setTimeout(() => { + loadSeriesData().catch(console.error); + }, 100); } } else { throw new Error('Content not found'); @@ -564,6 +567,67 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = const loadSeriesData = async () => { setLoadingSeasons(true); try { + // First check if we have episode data from the addon + const addonVideos = metadata?.videos; + if (addonVideos && Array.isArray(addonVideos) && addonVideos.length > 0) { + logger.log(`🎬 Found ${addonVideos.length} episodes from addon metadata for ${metadata?.name || id}`); + + // Group addon episodes by season + const groupedAddonEpisodes: GroupedEpisodes = {}; + + addonVideos.forEach((video: any) => { + const seasonNumber = video.season || 1; + const episodeNumber = video.episode || video.number || 1; + + if (!groupedAddonEpisodes[seasonNumber]) { + groupedAddonEpisodes[seasonNumber] = []; + } + + // Convert addon episode format to our Episode interface + const episode: Episode = { + id: video.id, + name: video.name || video.title || `Episode ${episodeNumber}`, + overview: video.overview || video.description || '', + season_number: seasonNumber, + episode_number: episodeNumber, + air_date: video.released ? video.released.split('T')[0] : video.firstAired ? video.firstAired.split('T')[0] : '', + still_path: video.thumbnail ? video.thumbnail.replace('https://image.tmdb.org/t/p/w500', '') : null, + vote_average: parseFloat(video.rating) || 0, + runtime: undefined, + episodeString: `S${seasonNumber.toString().padStart(2, '0')}E${episodeNumber.toString().padStart(2, '0')}`, + stremioId: video.id, + season_poster_path: null + }; + + groupedAddonEpisodes[seasonNumber].push(episode); + }); + + // Sort episodes within each season + Object.keys(groupedAddonEpisodes).forEach(season => { + groupedAddonEpisodes[parseInt(season)].sort((a, b) => a.episode_number - b.episode_number); + }); + + logger.log(`📺 Processed addon episodes into ${Object.keys(groupedAddonEpisodes).length} seasons`); + setGroupedEpisodes(groupedAddonEpisodes); + + // Set the first available season + const seasons = Object.keys(groupedAddonEpisodes).map(Number); + const firstSeason = Math.min(...seasons); + logger.log(`📺 Setting season ${firstSeason} as selected (${groupedAddonEpisodes[firstSeason]?.length || 0} episodes)`); + setSelectedSeason(firstSeason); + setEpisodes(groupedAddonEpisodes[firstSeason] || []); + + // Try to get TMDB ID for additional metadata (cast, etc.) but don't override episodes + const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id); + if (tmdbIdResult) { + setTmdbId(tmdbIdResult); + } + + return; // Use addon episodes, skip TMDB loading + } + + // Fallback to TMDB if no addon episodes + logger.log('📺 No addon episodes found, falling back to TMDB'); const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id); if (tmdbIdResult) { setTmdbId(tmdbIdResult); @@ -866,6 +930,14 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = loadMetadata(); }, [id, type]); + // Re-run series data loading when metadata updates with videos + useEffect(() => { + if (metadata && type === 'series' && metadata.videos && metadata.videos.length > 0) { + logger.log(`🎬 Metadata updated with ${metadata.videos.length} episodes, reloading series data`); + loadSeriesData().catch(console.error); + } + }, [metadata?.videos, type]); + const loadRecommendations = useCallback(async () => { if (!tmdbId) return; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index ee73cb2..1c1cec2 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -54,6 +54,7 @@ export type RootStackParamList = { id: string; type: string; episodeId?: string; + addonId?: string; }; Streams: { id: string; diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 8401e79..e80afcc 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -503,7 +503,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { width: NUM_COLUMNS === 2 ? ITEM_WIDTH : ITEM_WIDTH } ]} - onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })} + onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type, addonId })} activeOpacity={0.7} > <Image diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 99ad580..4c8b5f9 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -497,12 +497,14 @@ const HomeScreen = () => { // Initialize catalogs array with proper length setCatalogs(new Array(catalogIndex).fill(null)); - // Wait for all catalogs to finish loading (success or failure) - await Promise.allSettled(catalogPromises); - console.log('[HomeScreen] All catalogs processed'); - - // Filter out null values to get only successfully loaded catalogs - setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null)); + // Start all catalog loading promises but don't wait for them + // They will update the state progressively as they complete + Promise.allSettled(catalogPromises).then(() => { + console.log('[HomeScreen] All catalogs processed'); + + // Final cleanup: Filter out null values to get only successfully loaded catalogs + setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null)); + }); } catch (error) { console.error('[HomeScreen] Error in progressive catalog loading:', error); diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 73b5d5b..325f003 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -45,9 +45,9 @@ import { TraktService, TraktPlaybackItem } from '../services/traktService'; const { height } = Dimensions.get('window'); const MetadataScreen: React.FC = () => { - const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>(); + const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string; addonId?: string }>, string>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>(); - const { id, type, episodeId } = route.params; + const { id, type, episodeId, addonId } = route.params; // Consolidated hooks for better performance const { settings } = useSettings(); @@ -78,7 +78,7 @@ const MetadataScreen: React.FC = () => { loadingRecommendations, setMetadata, imdbId, - } = useMetadata({ id, type }); + } = useMetadata({ id, type, addonId }); // Optimized hooks with memoization const watchProgressData = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes); diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index 6c07846..d8ede4b 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -54,6 +54,22 @@ export interface StreamingContent { directors?: string[]; creators?: string[]; certification?: string; + // Enhanced metadata from addons + country?: string; + writer?: string[]; + links?: Array<{ + name: string; + category: string; + url: string; + }>; + behaviorHints?: { + defaultVideoId?: string; + hasScheduledVideos?: boolean; + [key: string]: any; + }; + imdb_id?: string; + slug?: string; + releaseInfo?: string; } export interface CatalogContent { @@ -442,7 +458,7 @@ class CatalogService { } } - async getContentDetails(type: string, id: string): Promise<StreamingContent | null> { + async getContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> { try { // Try up to 3 times with increasing delays let meta = null; @@ -450,7 +466,7 @@ class CatalogService { for (let i = 0; i < 3; i++) { try { - meta = await stremioService.getMetaDetails(type, id); + meta = await stremioService.getMetaDetails(type, id, preferredAddonId); if (meta) break; await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); } catch (error) { @@ -461,8 +477,8 @@ class CatalogService { } if (meta) { - // Add to recent content - const content = this.convertMetaToStreamingContent(meta); + // Add to recent content using enhanced conversion for full metadata + const content = this.convertMetaToStreamingContentEnhanced(meta); this.addToRecentContent(content); // Check if it's in the library @@ -482,7 +498,54 @@ class CatalogService { } } + // Public method for getting enhanced metadata details (used by MetadataScreen) + async getEnhancedContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> { + logger.log(`🔍 [MetadataScreen] Fetching enhanced metadata for ${type}:${id} ${preferredAddonId ? `from addon ${preferredAddonId}` : ''}`); + return this.getContentDetails(type, id, preferredAddonId); + } + + // Public method for getting basic content details without enhanced processing (used by ContinueWatching, etc.) + async getBasicContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> { + try { + // Try up to 3 times with increasing delays + let meta = null; + let lastError = null; + + for (let i = 0; i < 3; i++) { + try { + meta = await stremioService.getMetaDetails(type, id, preferredAddonId); + if (meta) break; + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); + } catch (error) { + lastError = error; + logger.error(`Attempt ${i + 1} failed to get basic content details for ${type}:${id}:`, error); + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); + } + } + + if (meta) { + // Use basic conversion without enhanced metadata processing + const content = this.convertMetaToStreamingContent(meta); + + // Check if it's in the library + content.inLibrary = this.library[`${type}:${id}`] !== undefined; + + return content; + } + + if (lastError) { + throw lastError; + } + + return null; + } catch (error) { + logger.error(`Failed to get basic content details for ${type}:${id}:`, error); + return null; + } + } + private convertMetaToStreamingContent(meta: Meta): StreamingContent { + // Basic conversion for catalog display - no enhanced metadata processing return { id: meta.id, type: meta.type, @@ -490,17 +553,70 @@ class CatalogService { poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image', posterShape: 'poster', banner: meta.background, - logo: `https://images.metahub.space/logo/medium/${meta.id}/img`, + logo: `https://images.metahub.space/logo/medium/${meta.id}/img`, // Use metahub for catalog display imdbRating: meta.imdbRating, year: meta.year, genres: meta.genres, description: meta.description, runtime: meta.runtime, inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined, - certification: meta.certification + certification: meta.certification, + releaseInfo: meta.releaseInfo, }; } + // Enhanced conversion for detailed metadata (used only when fetching individual content details) + private convertMetaToStreamingContentEnhanced(meta: Meta): StreamingContent { + // Enhanced conversion to utilize all available metadata from addons + const converted: StreamingContent = { + id: meta.id, + type: meta.type, + name: meta.name, + poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image', + posterShape: 'poster', + banner: meta.background, + // Use addon's logo if available, fallback to metahub + logo: (meta as any).logo || `https://images.metahub.space/logo/medium/${meta.id}/img`, + imdbRating: meta.imdbRating, + year: meta.year, + genres: meta.genres, + description: meta.description, + runtime: meta.runtime, + inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined, + certification: meta.certification, + // Enhanced fields from addon metadata + directors: (meta as any).director ? + (Array.isArray((meta as any).director) ? (meta as any).director : [(meta as any).director]) + : undefined, + writer: (meta as any).writer || undefined, + country: (meta as any).country || undefined, + imdb_id: (meta as any).imdb_id || undefined, + slug: (meta as any).slug || undefined, + releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined, + trailerStreams: (meta as any).trailerStreams || undefined, + links: (meta as any).links || undefined, + behaviorHints: (meta as any).behaviorHints || undefined, + }; + + // Cast is handled separately by the dedicated CastSection component via TMDB + + // Log if rich metadata is found + if ((meta as any).trailerStreams?.length > 0) { + logger.log(`🎬 Enhanced metadata: Found ${(meta as any).trailerStreams.length} trailers for ${meta.name}`); + } + + if ((meta as any).links?.length > 0) { + logger.log(`🔗 Enhanced metadata: Found ${(meta as any).links.length} links for ${meta.name}`); + } + + // Handle videos/episodes if available + if ((meta as any).videos) { + converted.videos = (meta as any).videos; + } + + return converted; + } + private notifyLibrarySubscribers(): void { const items = Object.values(this.library); this.librarySubscribers.forEach(callback => callback(items)); diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index c116386..7aef799 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -26,9 +26,35 @@ export interface Meta { genres?: string[]; runtime?: string; cast?: string[]; - director?: string; - writer?: string; + director?: string | string[]; + writer?: string | string[]; certification?: string; + // Extended fields available from some addons + country?: string; + imdb_id?: string; + slug?: string; + released?: string; + trailerStreams?: Array<{ + title: string; + ytId: string; + }>; + links?: Array<{ + name: string; + category: string; + url: string; + }>; + behaviorHints?: { + defaultVideoId?: string; + hasScheduledVideos?: boolean; + [key: string]: any; + }; + app_extras?: { + cast?: Array<{ + name: string; + character?: string; + photo?: string; + }>; + }; } export interface Subtitle { @@ -464,8 +490,71 @@ class StremioService { } } - async getMetaDetails(type: string, id: string): Promise<MetaDetails | null> { + async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise<MetaDetails | null> { try { + const addons = this.getInstalledAddons(); + + // If a preferred addon is specified, try it first + if (preferredAddonId) { + logger.log(`🎯 Trying preferred addon first: ${preferredAddonId}`); + const preferredAddon = addons.find(addon => addon.id === preferredAddonId); + + if (preferredAddon && preferredAddon.resources) { + // Log what URL would be used for debugging + const { baseUrl, queryParams } = this.getAddonBaseURL(preferredAddon.url || ''); + const wouldBeUrl = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`; + logger.log(`🔍 Would check URL: ${wouldBeUrl} (addon: ${preferredAddon.name})`); + + // Log addon resources for debugging + logger.log(`🔍 Addon resources:`, JSON.stringify(preferredAddon.resources, null, 2)); + + // Check if addon supports meta resource for this type + let hasMetaSupport = false; + + for (const resource of preferredAddon.resources) { + // Check if the current element is a ResourceObject + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as ResourceObject; + if (typedResource.name === 'meta' && + Array.isArray(typedResource.types) && + typedResource.types.includes(type)) { + hasMetaSupport = true; + break; + } + } + // Check if the element is the simple string "meta" AND the addon has a top-level types array + else if (typeof resource === 'string' && resource === 'meta' && preferredAddon.types) { + if (Array.isArray(preferredAddon.types) && preferredAddon.types.includes(type)) { + hasMetaSupport = true; + break; + } + } + } + + logger.log(`🔍 Meta support check: ${hasMetaSupport} (addon types: ${JSON.stringify(preferredAddon.types)})`); + + if (hasMetaSupport) { + try { + logger.log(`HTTP GET: ${wouldBeUrl} (preferred addon: ${preferredAddon.name})`); + const response = await this.retryRequest(async () => { + return await axios.get(wouldBeUrl, { timeout: 10000 }); + }); + + if (response.data && response.data.meta) { + logger.log(`✅ Metadata fetched successfully from preferred addon: ${wouldBeUrl}`); + return response.data.meta; + } + } catch (error) { + logger.warn(`❌ Failed to fetch meta from preferred addon ${preferredAddon.name}:`, error); + } + } else { + logger.warn(`⚠️ Preferred addon ${preferredAddonId} does not support meta for type ${type}`); + } + } else { + logger.warn(`⚠️ Preferred addon ${preferredAddonId} not found or has no resources`); + } + } + // Try Cinemeta with different base URLs const cinemetaUrls = [ 'https://v3-cinemeta.strem.io', @@ -475,44 +564,66 @@ class StremioService { for (const baseUrl of cinemetaUrls) { try { const url = `${baseUrl}/meta/${type}/${id}.json`; + logger.log(`HTTP GET: ${url}`); const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); }); if (response.data && response.data.meta) { + logger.log(`✅ Metadata fetched successfully from: ${url}`); return response.data.meta; } } catch (error) { - logger.warn(`Failed to fetch meta from ${baseUrl}:`, error); + logger.warn(`❌ Failed to fetch meta from ${baseUrl}:`, error); continue; // Try next URL } } - // If Cinemeta fails, try other addons - const addons = this.getInstalledAddons(); + // If Cinemeta fails, try other addons (excluding the preferred one already tried) for (const addon of addons) { - if (!addon.resources || addon.id === 'com.linvo.cinemeta') continue; + if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) continue; - const metaResource = addon.resources.find( - resource => resource.name === 'meta' && resource.types.includes(type) - ); + // Check if addon supports meta resource for this type (handles both string and object formats) + let hasMetaSupport = false; - if (!metaResource) continue; + for (const resource of addon.resources) { + // Check if the current element is a ResourceObject + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as ResourceObject; + if (typedResource.name === 'meta' && + Array.isArray(typedResource.types) && + typedResource.types.includes(type)) { + hasMetaSupport = true; + break; + } + } + // Check if the element is the simple string "meta" AND the addon has a top-level types array + else if (typeof resource === 'string' && resource === 'meta' && addon.types) { + if (Array.isArray(addon.types) && addon.types.includes(type)) { + hasMetaSupport = true; + break; + } + } + } + + if (!hasMetaSupport) continue; try { const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || ''); const url = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`; + logger.log(`HTTP GET: ${url}`); const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); }); if (response.data && response.data.meta) { + logger.log(`✅ Metadata fetched successfully from: ${url}`); return response.data.meta; } } catch (error) { - logger.warn(`Failed to fetch meta from ${addon.name}:`, error); + logger.warn(`❌ Failed to fetch meta from ${addon.name} (${addon.id}):`, error); continue; // Try next addon } } diff --git a/src/types/metadata.ts b/src/types/metadata.ts index 84d3ebf..aff9d85 100644 --- a/src/types/metadata.ts +++ b/src/types/metadata.ts @@ -81,6 +81,7 @@ export interface StreamingContent { name: string; description?: string; poster?: string; + posterShape?: string; banner?: string; logo?: string; year?: string | number; @@ -88,12 +89,30 @@ export interface StreamingContent { imdbRating?: string; genres?: string[]; director?: string; - writer?: string; + writer?: string[]; cast?: string[]; releaseInfo?: string; directors?: string[]; creators?: string[]; certification?: string; + released?: string; + trailerStreams?: any[]; + videos?: any[]; + inLibrary?: boolean; + // Enhanced metadata from addons + country?: string; + links?: Array<{ + name: string; + category: string; + url: string; + }>; + behaviorHints?: { + defaultVideoId?: string; + hasScheduledVideos?: boolean; + [key: string]: any; + }; + imdb_id?: string; + slug?: string; } // Navigation types diff --git a/src/types/navigation.d.ts b/src/types/navigation.d.ts index c01523f..a69bd54 100644 --- a/src/types/navigation.d.ts +++ b/src/types/navigation.d.ts @@ -6,6 +6,7 @@ export type RootStackParamList = { Metadata: { id: string; type: string; + addonId?: string; }; Streams: { id: string; -- 2.45.2 From c9db098b0e035367c545baa9bc33da2e061b0aeb Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 13:48:49 +0530 Subject: [PATCH 78/88] Refactor MovieContent and MetadataScreen for improved metadata handling This update removes the writer display from the MovieContent component to streamline the metadata presentation. Additionally, it modifies pointer event handling in the MetadataScreen to ensure proper interaction based on the availability of metadata, enhancing user experience during content loading and display. --- src/components/metadata/MovieContent.tsx | 8 +------- src/screens/MetadataScreen.tsx | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/components/metadata/MovieContent.tsx b/src/components/metadata/MovieContent.tsx index eceabe3..b2b46e4 100644 --- a/src/components/metadata/MovieContent.tsx +++ b/src/components/metadata/MovieContent.tsx @@ -23,13 +23,7 @@ export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => { </View> )} - {metadata.writer && metadata.writer.length > 0 && ( - <View style={styles.metadataRow}> - <Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Writer:</Text> - <Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{Array.isArray(metadata.writer) ? metadata.writer.join(', ') : metadata.writer}</Text> - </View> - )} - + {hasCast && ( <View style={styles.metadataRow}> <Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Cast:</Text> diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 325f003..d55d06b 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -305,7 +305,7 @@ const MetadataScreen: React.FC = () => { {showSkeleton && ( <Animated.View style={[StyleSheet.absoluteFill, skeletonStyle]} - pointerEvents={isContentReady ? 'none' : 'auto'} + pointerEvents={metadata ? 'none' : 'auto'} > <MetadataLoadingScreen type={metadata?.type === 'movie' ? 'movie' : 'series'} /> </Animated.View> @@ -315,7 +315,7 @@ const MetadataScreen: React.FC = () => { {metadata && ( <Animated.View style={[StyleSheet.absoluteFill, transitionStyle]} - pointerEvents={isContentReady ? 'auto' : 'none'} + pointerEvents={metadata ? 'auto' : 'none'} > <SafeAreaView style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]} -- 2.45.2 From 8e8353635b361f0bd71aa5605537684f5c224d2b Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 14:00:56 +0530 Subject: [PATCH 79/88] Enhance useMetadata hook for improved ID handling and logging This update refines the useMetadata hook by adding logic to determine the correct Stremio ID from TMDB and IMDb IDs, improving compatibility with Stremio addons. Enhanced logging provides clearer insights during the ID conversion process for both movies and episodes, ensuring better tracking of the loading state and potential errors. These changes contribute to a more robust metadata handling experience. --- src/hooks/useMetadata.ts | 86 +++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 4e48dde..e8694e9 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -741,24 +741,58 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat console.log('🚀 [loadStreams] START - Loading streams for:', id); updateLoadingState(); - // Get TMDB ID for external sources first before starting parallel requests + // Get TMDB ID for external sources and determine the correct ID for Stremio addons console.log('🔍 [loadStreams] Getting TMDB ID for:', id); let tmdbId; + let stremioId = id; // Default to original ID + if (id.startsWith('tmdb:')) { tmdbId = id.split(':')[1]; console.log('✅ [loadStreams] Using TMDB ID from ID:', tmdbId); + + // Try to get IMDb ID from metadata first, then convert if needed + if (metadata?.imdb_id) { + stremioId = metadata.imdb_id; + console.log('✅ [loadStreams] Using IMDb ID from metadata for Stremio:', stremioId); + } else if (imdbId) { + stremioId = imdbId; + console.log('✅ [loadStreams] Using stored IMDb ID for Stremio:', stremioId); + } else { + // Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format) + try { + let externalIds = null; + if (type === 'movie') { + const movieDetails = await withTimeout(tmdbService.getMovieDetails(tmdbId), API_TIMEOUT); + externalIds = movieDetails?.external_ids; + } else if (type === 'series') { + externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT); + } + + if (externalIds?.imdb_id) { + stremioId = externalIds.imdb_id; + console.log('✅ [loadStreams] Converted TMDB to IMDb ID for Stremio:', stremioId); + } else { + console.log('⚠️ [loadStreams] No IMDb ID found for TMDB ID, using original:', stremioId); + } + } catch (error) { + console.log('⚠️ [loadStreams] Failed to convert TMDB to IMDb, using original ID:', error); + } + } } else if (id.startsWith('tt')) { - // This is an IMDB ID + // This is already an IMDB ID, perfect for Stremio + stremioId = id; console.log('📝 [loadStreams] Converting IMDB ID to TMDB ID...'); tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT); console.log('✅ [loadStreams] Converted to TMDB ID:', tmdbId); } else { tmdbId = id; - console.log('ℹ️ [loadStreams] Using ID as TMDB ID:', tmdbId); + stremioId = id; + console.log('ℹ️ [loadStreams] Using ID as both TMDB and Stremio ID:', tmdbId); } - // Start Stremio request using the callback method - processStremioSource(type, id, false); + // Start Stremio request using the converted ID format + console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId); + processStremioSource(type, stremioId, false); // Add HDRezka source const hdrezkaPromise = processExternalSource('hdrezka', processHDRezkaSource(type, id), false); @@ -816,31 +850,61 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId); updateEpisodeLoadingState(); - // Get TMDB ID for external sources first before starting parallel requests + // Get TMDB ID for external sources and determine the correct ID for Stremio addons console.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id); let tmdbId; + let stremioEpisodeId = episodeId; // Default to original episode ID + if (id.startsWith('tmdb:')) { tmdbId = id.split(':')[1]; console.log('✅ [loadEpisodeStreams] Using TMDB ID from ID:', tmdbId); + + // Try to get IMDb ID from metadata first, then convert if needed + if (metadata?.imdb_id) { + // Replace the series ID in episodeId with the IMDb ID + const [, season, episode] = episodeId.split(':'); + stremioEpisodeId = `series:${metadata.imdb_id}:${season}:${episode}`; + console.log('✅ [loadEpisodeStreams] Using IMDb ID from metadata for Stremio episode:', stremioEpisodeId); + } else if (imdbId) { + const [, season, episode] = episodeId.split(':'); + stremioEpisodeId = `series:${imdbId}:${season}:${episode}`; + console.log('✅ [loadEpisodeStreams] Using stored IMDb ID for Stremio episode:', stremioEpisodeId); + } else { + // Convert TMDB ID to IMDb ID for Stremio addons + try { + const externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT); + + if (externalIds?.imdb_id) { + const [, season, episode] = episodeId.split(':'); + stremioEpisodeId = `series:${externalIds.imdb_id}:${season}:${episode}`; + console.log('✅ [loadEpisodeStreams] Converted TMDB to IMDb ID for Stremio episode:', stremioEpisodeId); + } else { + console.log('⚠️ [loadEpisodeStreams] No IMDb ID found for TMDB ID, using original episode ID:', stremioEpisodeId); + } + } catch (error) { + console.log('⚠️ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using original episode ID:', error); + } + } } else if (id.startsWith('tt')) { - // This is an IMDB ID + // This is already an IMDB ID, perfect for Stremio console.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...'); tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT); console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId); } else { tmdbId = id; - console.log('ℹ️ [loadEpisodeStreams] Using ID as TMDB ID:', tmdbId); + console.log('ℹ️ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId); } - // Extract episode info from the episodeId + // Extract episode info from the episodeId for logging const [, season, episode] = episodeId.split(':'); const episodeQuery = `?s=${season}&e=${episode}`; console.log(`ℹ️ [loadEpisodeStreams] Episode query: ${episodeQuery}`); console.log('🔄 [loadEpisodeStreams] Starting stream requests'); - // Start Stremio request using the callback method - processStremioSource('series', episodeId, true); + // Start Stremio request using the converted episode ID format + console.log('🎬 [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId); + processStremioSource('series', stremioEpisodeId, true); // Add HDRezka source for episodes const hdrezkaEpisodePromise = processExternalSource('hdrezka', -- 2.45.2 From b953e99e3dda313a9f43b0710ab87dbeccc7affe Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 14:53:00 +0530 Subject: [PATCH 80/88] some fixes --- src/hooks/useSettings.ts | 2 + src/screens/PlayerSettingsScreen.tsx | 63 ++++++++++ src/screens/StreamsScreen.tsx | 166 ++++++++++++++++++++++++++- 3 files changed, 229 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index bb13546..ec3b664 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -36,6 +36,7 @@ export interface AppSettings { tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code) enableInternalProviders: boolean; // Toggle for internal providers like HDRezka episodeLayoutStyle: 'vertical' | 'horizontal'; // Layout style for episode cards + autoplayBestStream: boolean; // Automatically play the best available stream } export const DEFAULT_SETTINGS: AppSettings = { @@ -54,6 +55,7 @@ export const DEFAULT_SETTINGS: AppSettings = { tmdbLanguagePreference: 'en', // Default to English enableInternalProviders: true, // Enable internal providers by default episodeLayoutStyle: 'horizontal', // Default to the new horizontal layout + autoplayBestStream: false, // Disabled by default for user choice }; const SETTINGS_STORAGE_KEY = 'app_settings'; diff --git a/src/screens/PlayerSettingsScreen.tsx b/src/screens/PlayerSettingsScreen.tsx index 93fd342..31a18ba 100644 --- a/src/screens/PlayerSettingsScreen.tsx +++ b/src/screens/PlayerSettingsScreen.tsx @@ -8,6 +8,7 @@ import { Platform, TouchableOpacity, StatusBar, + Switch, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useSettings, AppSettings } from '../hooks/useSettings'; @@ -219,6 +220,68 @@ const PlayerSettingsScreen: React.FC = () => { ))} </View> </View> + + <View style={styles.section}> + <Text + style={[ + styles.sectionTitle, + { color: currentTheme.colors.textMuted }, + ]} + > + PLAYBACK OPTIONS + </Text> + <View + style={[ + styles.card, + { + backgroundColor: currentTheme.colors.elevation2, + }, + ]} + > + <View style={styles.settingItem}> + <View style={styles.settingContent}> + <View style={[ + styles.settingIconContainer, + { backgroundColor: 'rgba(255,255,255,0.1)' } + ]}> + <MaterialIcons + name="play-arrow" + size={20} + color={currentTheme.colors.primary} + /> + </View> + <View style={styles.settingText}> + <Text + style={[ + styles.settingTitle, + { color: currentTheme.colors.text }, + ]} + > + Auto-play Best Stream + </Text> + <Text + style={[ + styles.settingDescription, + { color: currentTheme.colors.textMuted }, + ]} + > + Automatically play the highest quality stream when available + </Text> + </View> + <Switch + value={settings.autoplayBestStream} + onValueChange={(value) => updateSetting('autoplayBestStream', value)} + trackColor={{ + false: 'rgba(255,255,255,0.2)', + true: currentTheme.colors.primary + '40' + }} + thumbColor={settings.autoplayBestStream ? currentTheme.colors.primary : 'rgba(255,255,255,0.8)'} + ios_backgroundColor="rgba(255,255,255,0.2)" + /> + </View> + </View> + </View> + </View> </ScrollView> </SafeAreaView> ); diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 70d7475..e09c4ee 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -302,6 +302,10 @@ export const StreamsScreen = () => { } }>({}); + // Add state for autoplay functionality + const [autoplayTriggered, setAutoplayTriggered] = useState(false); + const [isAutoplayWaiting, setIsAutoplayWaiting] = useState(false); + // Monitor streams loading start and completion - FIXED to prevent loops useEffect(() => { // Skip processing if component is unmounting @@ -438,6 +442,8 @@ export const StreamsScreen = () => { } }, [type, groupedStreams, episodeStreams, loadingStreams, loadingEpisodeStreams, selectedProvider]); + + React.useEffect(() => { if (type === 'series' && episodeId) { logger.log(`🎬 Loading episode streams for: ${episodeId}`); @@ -455,7 +461,16 @@ export const StreamsScreen = () => { // }); loadStreams(); } - }, [type, episodeId]); + + // Reset autoplay state when content changes + setAutoplayTriggered(false); + if (settings.autoplayBestStream) { + setIsAutoplayWaiting(true); + logger.log('🔄 Autoplay enabled, waiting for best stream...'); + } else { + setIsAutoplayWaiting(false); + } + }, [type, episodeId, settings.autoplayBestStream]); React.useEffect(() => { // Trigger entrance animations @@ -500,6 +515,101 @@ export const StreamsScreen = () => { setSelectedProvider(provider); }, []); + // Function to determine the best stream based on quality, provider priority, and other factors + const getBestStream = useCallback((streamsData: typeof groupedStreams): Stream | null => { + if (!streamsData || Object.keys(streamsData).length === 0) { + return null; + } + + // Helper function to extract quality as number + const getQualityNumeric = (title: string | undefined): number => { + if (!title) return 0; + 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) { + const quality = parseInt(match[1], 10); + if (quality >= 240 && quality <= 8000) return quality; + } + } + return 0; + }; + + // Provider priority (higher number = higher priority) + const getProviderPriority = (addonId: string): number => { + if (addonId === 'hdrezka') return 100; // HDRezka highest priority + + // 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 + }; + + // Collect all streams with metadata + const allStreams: Array<{ + stream: Stream; + quality: number; + providerPriority: number; + isDebrid: boolean; + isCached: boolean; + }> = []; + + Object.entries(streamsData).forEach(([addonId, { streams }]) => { + streams.forEach(stream => { + const quality = getQualityNumeric(stream.name || stream.title); + const providerPriority = getProviderPriority(addonId); + const isDebrid = stream.behaviorHints?.cached || false; + const isCached = isDebrid; + + allStreams.push({ + stream, + quality, + providerPriority, + isDebrid, + isCached, + }); + }); + }); + + if (allStreams.length === 0) return null; + + // Sort streams by multiple criteria (best first) + allStreams.sort((a, b) => { + // 1. Prioritize cached/debrid streams + if (a.isCached !== b.isCached) { + return a.isCached ? -1 : 1; + } + + // 2. Prioritize higher quality + if (a.quality !== b.quality) { + return b.quality - a.quality; + } + + // 3. Prioritize better providers + if (a.providerPriority !== b.providerPriority) { + return b.providerPriority - a.providerPriority; + } + + return 0; + }); + + logger.log(`🎯 Best stream selected: ${allStreams[0].stream.name || allStreams[0].stream.title} (Quality: ${allStreams[0].quality}p, Provider Priority: ${allStreams[0].providerPriority}, Cached: ${allStreams[0].isCached})`); + + return allStreams[0].stream; + }, []); + const currentEpisode = useMemo(() => { if (!selectedEpisode) return null; @@ -710,6 +820,48 @@ export const StreamsScreen = () => { } }, [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer]); + // Autoplay effect - triggers when streams are available and autoplay is enabled + useEffect(() => { + if ( + settings.autoplayBestStream && + !autoplayTriggered && + !loadingStreams && + !loadingEpisodeStreams && + isAutoplayWaiting + ) { + const streams = type === 'series' ? episodeStreams : groupedStreams; + + if (Object.keys(streams).length > 0) { + const bestStream = getBestStream(streams); + + if (bestStream) { + logger.log('🚀 Autoplay: Best stream found, starting playback...'); + setAutoplayTriggered(true); + setIsAutoplayWaiting(false); + + // Add a small delay to let the UI settle + setTimeout(() => { + handleStreamPress(bestStream); + }, 500); + } else { + logger.log('⚠️ Autoplay: No suitable stream found'); + setIsAutoplayWaiting(false); + } + } + } + }, [ + settings.autoplayBestStream, + autoplayTriggered, + loadingStreams, + loadingEpisodeStreams, + isAutoplayWaiting, + type, + episodeStreams, + groupedStreams, + getBestStream, + handleStreamPress + ]); + const filterItems = useMemo(() => { const installedAddons = stremioService.getInstalledAddons(); const streams = type === 'series' ? episodeStreams : groupedStreams; @@ -1140,7 +1292,17 @@ export const StreamsScreen = () => { style={styles.loadingContainer} > <ActivityIndicator size="large" color={colors.primary} /> - <Text style={styles.loadingText}>Finding available streams...</Text> + <Text style={styles.loadingText}> + {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'} + </Text> + </Animated.View> + ) : isAutoplayWaiting && !autoplayTriggered ? ( + <Animated.View + entering={FadeIn.duration(300)} + style={styles.loadingContainer} + > + <ActivityIndicator size="large" color={colors.primary} /> + <Text style={styles.loadingText}>Starting best stream...</Text> </Animated.View> ) : Object.keys(streams).length === 0 && !loadingStreams && !loadingEpisodeStreams ? ( <Animated.View -- 2.45.2 From d9614a6defc89e9c78883f1f43355d5f6f3047b9 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 14:56:49 +0530 Subject: [PATCH 81/88] Refactor MovieContent, useMetadata, and HomeScreen for code consistency and readability This update includes minor formatting adjustments across the MovieContent, useMetadata, and HomeScreen components to enhance code readability. Changes involve whitespace normalization and alignment improvements, contributing to a cleaner codebase without altering functionality. --- src/components/metadata/MovieContent.tsx | 2 +- src/hooks/useMetadata.ts | 8 ++++---- src/screens/HomeScreen.tsx | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/metadata/MovieContent.tsx b/src/components/metadata/MovieContent.tsx index b2b46e4..458caa8 100644 --- a/src/components/metadata/MovieContent.tsx +++ b/src/components/metadata/MovieContent.tsx @@ -23,7 +23,7 @@ export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => { </View> )} - + {hasCast && ( <View style={styles.metadataRow}> <Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Cast:</Text> diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index e8694e9..0c796c4 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -514,10 +514,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const [content, castData] = await Promise.allSettled([ // Load content with timeout and retry withRetry(async () => { - const result = await withTimeout( + const result = await withTimeout( catalogService.getEnhancedContentDetails(type, actualId, addonId), - API_TIMEOUT - ); + API_TIMEOUT + ); // Store the actual ID used (could be IMDB) if (actualId.startsWith('tt')) { setImdbId(actualId); @@ -543,7 +543,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (type === 'series') { // Load series data after the enhanced metadata is processed setTimeout(() => { - loadSeriesData().catch(console.error); + loadSeriesData().catch(console.error); }, 100); } } else { diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 4c8b5f9..8501985 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -500,10 +500,10 @@ const HomeScreen = () => { // Start all catalog loading promises but don't wait for them // They will update the state progressively as they complete Promise.allSettled(catalogPromises).then(() => { - console.log('[HomeScreen] All catalogs processed'); - + console.log('[HomeScreen] All catalogs processed'); + // Final cleanup: Filter out null values to get only successfully loaded catalogs - setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null)); + setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null)); }); } catch (error) { -- 2.45.2 From 25e648e135c12b79c31ec1522caf52c3204faec1 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 15:51:19 +0530 Subject: [PATCH 82/88] Refactor StreamsScreen for improved stream loading and autoplay handling This update enhances the StreamsScreen component by streamlining the logic for monitoring stream loading and updating available providers. The autoplay feature now triggers immediately when streams are available, improving user experience. Additionally, loading indicators and state management have been refined for better clarity and responsiveness during stream availability checks. --- src/screens/StreamsScreen.tsx | 261 +++++++++++++--------------------- 1 file changed, 98 insertions(+), 163 deletions(-) diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index e09c4ee..d7f2f59 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -306,141 +306,48 @@ export const StreamsScreen = () => { const [autoplayTriggered, setAutoplayTriggered] = useState(false); const [isAutoplayWaiting, setIsAutoplayWaiting] = useState(false); - // Monitor streams loading start and completion - FIXED to prevent loops + // Monitor streams loading and update available providers immediately useEffect(() => { // Skip processing if component is unmounting if (!isMounted.current) return; - const now = Date.now(); - // Define all providers you expect to load. This could be dynamic. - const expectedProviders = ['stremio', 'hdrezka']; - - // Prevent infinite rerendering by using refs - if (loadingStreams || loadingEpisodeStreams) { - // --- Stream Loading has STARTED or is IN PROGRESS --- - // Only log once when loading starts - if (loadStartTimeRef.current === 0) { - logger.log("⏱️ Stream loading started or in progress..."); - // Update ref directly to avoid render cycle - loadStartTimeRef.current = now; - // Also update state for components that need it - setLoadStartTime(now); - } - - // Only update these once per loading cycle - if (!hasDoneInitialLoadRef.current) { - hasDoneInitialLoadRef.current = true; - - // Use the guarded setState to prevent issues after unmount - guardedSetState(() => setProviderLoadTimes({})); - - // Update provider status to loading for all expected providers - guardedSetState(() => setProviderStatus(prevStatus => { - const newStatus = { ...prevStatus }; - expectedProviders.forEach(providerId => { - // If not already marked as loading, or if it's a fresh cycle, set to loading - if (!newStatus[providerId] || !newStatus[providerId].loading) { - newStatus[providerId] = { - loading: true, - success: false, - error: false, - message: 'Loading...', - timeStarted: (newStatus[providerId]?.loading && newStatus[providerId]?.timeStarted) ? newStatus[providerId].timeStarted : now, - timeCompleted: 0, - }; - } - }); - return newStatus; - })); - - // Update simple loading flag for all expected providers - guardedSetState(() => setLoadingProviders(prevLoading => { - const newLoading = { ...prevLoading }; - expectedProviders.forEach(providerId => { - newLoading[providerId] = true; - }); - return newLoading; - })); - } - } else if (loadStartTimeRef.current > 0) { - // --- Stream Loading has FINISHED --- - logger.log("🏁 Stream loading finished. Processing results."); - - const currentStreamsData = type === 'series' ? episodeStreams : groupedStreams; - - // Find all providers that returned streams - const providersWithStreams = Object.entries(currentStreamsData) - .filter(([_, data]) => data.streams && data.streams.length > 0) - .map(([providerId]) => providerId); - + const currentStreamsData = type === 'series' ? episodeStreams : groupedStreams; + + // 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(', ')}`); - - // Reset refs for next load cycle - loadStartTimeRef.current = 0; - hasDoneInitialLoadRef.current = false; - - // Update states only if component is still mounted - if (isMounted.current) { - // Update simple loading flag: all expected providers are no longer loading - guardedSetState(() => setLoadingProviders(prevLoading => { - const newLoading = { ...prevLoading }; - expectedProviders.forEach(providerId => { - newLoading[providerId] = false; - }); - return newLoading; - })); - - // Update detailed provider status based on results - guardedSetState(() => setProviderStatus(prevStatus => { - const newStatus = { ...prevStatus }; - expectedProviders.forEach(providerId => { - if (newStatus[providerId]) { // Ensure the provider entry exists - const providerHasStreams = currentStreamsData[providerId] && - currentStreamsData[providerId].streams && - currentStreamsData[providerId].streams.length > 0; - - newStatus[providerId] = { - ...newStatus[providerId], // Preserve timeStarted - loading: false, - success: providerHasStreams, - // Mark error if it was loading and now no streams, and wasn't already successful - error: !providerHasStreams && newStatus[providerId].loading && !newStatus[providerId].success, - message: providerHasStreams ? 'Loaded successfully' : (newStatus[providerId].error ? 'Error or no streams' : 'No streams found'), - timeCompleted: now, - }; - } - }); - return newStatus; - })); - - // Update the set of available providers based on what actually loaded streams - const providersWithStreamsSet = new Set(providersWithStreams); - guardedSetState(() => setAvailableProviders(providersWithStreamsSet)); - - // Reset loadStartTime to signify the end of this loading cycle - guardedSetState(() => setLoadStartTime(0)); - } + const providersWithStreamsSet = new Set(providersWithStreams); + setAvailableProviders(providersWithStreamsSet); } - }, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type, guardedSetState]); + + // Update loading states for individual providers + const expectedProviders = ['stremio', 'hdrezka']; + const now = Date.now(); + + setLoadingProviders(prevLoading => { + const newLoading = { ...prevLoading }; + expectedProviders.forEach(providerId => { + // Provider is loading if overall loading is true OR if it doesn't have streams yet + const hasStreams = currentStreamsData[providerId] && + currentStreamsData[providerId].streams && + currentStreamsData[providerId].streams.length > 0; + newLoading[providerId] = (loadingStreams || loadingEpisodeStreams) && !hasStreams; + }); + return newLoading; + }); + + }, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type]); - // Add useEffect to update availableProviders whenever streams change + // Reset the selected provider to 'all' if the current selection is no longer available useEffect(() => { - if (!loadingStreams && !loadingEpisodeStreams) { - const streams = type === 'series' ? episodeStreams : groupedStreams; - // Only include providers that actually have streams - const providers = new Set( - Object.entries(streams) - .filter(([_, data]) => data.streams && data.streams.length > 0) - .map(([providerId]) => providerId) - ); - setAvailableProviders(providers); - - // Also reset the selected provider to 'all' if the current selection is no longer available - if (selectedProvider !== 'all' && !providers.has(selectedProvider)) { - setSelectedProvider('all'); - } + if (selectedProvider !== 'all' && !availableProviders.has(selectedProvider)) { + setSelectedProvider('all'); } - }, [type, groupedStreams, episodeStreams, loadingStreams, loadingEpisodeStreams, selectedProvider]); + }, [selectedProvider, availableProviders]); @@ -820,13 +727,11 @@ export const StreamsScreen = () => { } }, [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer]); - // Autoplay effect - triggers when streams are available and autoplay is enabled + // Autoplay effect - triggers immediately when streams are available and autoplay is enabled useEffect(() => { if ( settings.autoplayBestStream && !autoplayTriggered && - !loadingStreams && - !loadingEpisodeStreams && isAutoplayWaiting ) { const streams = type === 'series' ? episodeStreams : groupedStreams; @@ -835,14 +740,12 @@ export const StreamsScreen = () => { const bestStream = getBestStream(streams); if (bestStream) { - logger.log('🚀 Autoplay: Best stream found, starting playback...'); + logger.log('🚀 Autoplay: Best stream found, starting playback immediately...'); setAutoplayTriggered(true); setIsAutoplayWaiting(false); - // Add a small delay to let the UI settle - setTimeout(() => { - handleStreamPress(bestStream); - }, 500); + // Start playback immediately - no delay needed + handleStreamPress(bestStream); } else { logger.log('⚠️ Autoplay: No suitable stream found'); setIsAutoplayWaiting(false); @@ -852,8 +755,6 @@ export const StreamsScreen = () => { }, [ settings.autoplayBestStream, autoplayTriggered, - loadingStreams, - loadingEpisodeStreams, isAutoplayWaiting, type, episodeStreams, @@ -1285,35 +1186,45 @@ export const StreamsScreen = () => { )} </Animated.View> - {/* Show streams immediately as they become available, with loading indicators for pending providers */} - {Object.keys(streams).length === 0 && (loadingStreams || loadingEpisodeStreams) ? ( - <Animated.View - entering={FadeIn.duration(300)} - style={styles.loadingContainer} - > - <ActivityIndicator size="large" color={colors.primary} /> - <Text style={styles.loadingText}> - {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'} - </Text> - </Animated.View> - ) : isAutoplayWaiting && !autoplayTriggered ? ( - <Animated.View - entering={FadeIn.duration(300)} - style={styles.loadingContainer} - > - <ActivityIndicator size="large" color={colors.primary} /> - <Text style={styles.loadingText}>Starting best stream...</Text> - </Animated.View> - ) : Object.keys(streams).length === 0 && !loadingStreams && !loadingEpisodeStreams ? ( - <Animated.View - entering={FadeIn.duration(300)} - style={styles.noStreams} - > - <MaterialIcons name="error-outline" size={48} color={colors.textMuted} /> - <Text style={styles.noStreamsText}>No streams available</Text> - </Animated.View> + {/* Show streams immediately as they become available */} + {Object.keys(streams).length === 0 ? ( + // Only show initial loading if no streams are available yet + (loadingStreams || loadingEpisodeStreams) ? ( + <Animated.View + entering={FadeIn.duration(300)} + style={styles.loadingContainer} + > + <ActivityIndicator size="large" color={colors.primary} /> + <Text style={styles.loadingText}> + {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'} + </Text> + </Animated.View> + ) : ( + // No streams and not loading = no streams available + <Animated.View + entering={FadeIn.duration(300)} + style={styles.noStreams} + > + <MaterialIcons name="error-outline" size={48} color={colors.textMuted} /> + <Text style={styles.noStreamsText}>No streams available</Text> + </Animated.View> + ) ) : ( + // Show streams immediately when available, even if still loading others <View collapsable={false} style={{ flex: 1 }}> + {/* Show autoplay loading overlay if waiting for autoplay */} + {isAutoplayWaiting && !autoplayTriggered && ( + <Animated.View + entering={FadeIn.duration(300)} + style={styles.autoplayOverlay} + > + <View style={styles.autoplayIndicator}> + <ActivityIndicator size="small" color={colors.primary} /> + <Text style={styles.autoplayText}>Starting best stream...</Text> + </View> + </Animated.View> + )} + <SectionList sections={sections} keyExtractor={(item) => item.url || `${item.name}-${item.title}`} @@ -1763,6 +1674,30 @@ const createStyles = (colors: any) => StyleSheet.create({ sectionLoadingText: { marginLeft: 8, }, + autoplayOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: 'rgba(0,0,0,0.8)', + padding: 16, + alignItems: 'center', + zIndex: 10, + }, + autoplayIndicator: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.elevation2, + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 8, + }, + autoplayText: { + color: colors.primary, + fontSize: 14, + marginLeft: 8, + fontWeight: '600', + }, }); export default memo(StreamsScreen); \ No newline at end of file -- 2.45.2 From 026c42566036749e52091c9ad443dd9a14edfc00 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 17:07:57 +0530 Subject: [PATCH 83/88] Enhance useTraktAutosync for improved session management and progress tracking This update introduces enhanced deduplication logic in the useTraktAutosync hook, allowing updates if progress improves significantly after a session has stopped. Additionally, it refines the handling of unmount scenarios by ensuring the highest available progress is used, including checks for saved progress in local storage. These changes improve the accuracy of progress reporting and overall user experience during content synchronization. --- src/hooks/useTraktAutosync.ts | 53 ++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index e316bc5..299efd4 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -214,9 +214,19 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } // ENHANCED DEDUPLICATION: Check if we've already stopped this session + // However, allow updates if the new progress is significantly higher (>5% improvement) if (hasStopped.current) { - logger.log(`[TraktAutosync] Already stopped this session, skipping duplicate call (reason: ${reason})`); - return; + const currentProgressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; + const progressImprovement = currentProgressPercent - lastSyncProgress.current; + + if (progressImprovement > 5) { + logger.log(`[TraktAutosync] Session already stopped, but progress improved significantly by ${progressImprovement.toFixed(1)}% (${lastSyncProgress.current.toFixed(1)}% → ${currentProgressPercent.toFixed(1)}%), allowing update`); + // Reset stopped flag to allow this significant update + hasStopped.current = false; + } else { + logger.log(`[TraktAutosync] Already stopped this session, skipping duplicate call (reason: ${reason})`); + return; + } } // ENHANCED DEDUPLICATION: Prevent rapid successive calls (within 5 seconds) @@ -235,11 +245,40 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; logger.log(`[TraktAutosync] Initial progress calculation: ${progressPercent.toFixed(1)}%`); - // If progress is 0 during unmount, use the last synced progress instead - // This happens when video player state is reset before component unmount - if (reason === 'unmount' && progressPercent < 1 && lastSyncProgress.current > 0) { - progressPercent = lastSyncProgress.current; - logger.log(`[TraktAutosync] Using last synced progress for unmount: ${progressPercent.toFixed(1)}%`); + // For unmount calls, always use the highest available progress + // Check current progress, last synced progress, and local storage progress + if (reason === 'unmount') { + let maxProgress = progressPercent; + + // Check last synced progress + if (lastSyncProgress.current > maxProgress) { + maxProgress = lastSyncProgress.current; + } + + // Also check local storage for the highest recorded progress + try { + const savedProgress = await storageService.getWatchProgress( + options.id, + options.type, + options.episodeId + ); + + if (savedProgress && savedProgress.duration > 0) { + const savedProgressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; + if (savedProgressPercent > maxProgress) { + maxProgress = savedProgressPercent; + } + } + } catch (error) { + logger.error('[TraktAutosync] Error checking saved progress:', error); + } + + if (maxProgress !== progressPercent) { + logger.log(`[TraktAutosync] Using highest available progress for unmount: ${maxProgress.toFixed(1)}% (current: ${progressPercent.toFixed(1)}%, last synced: ${lastSyncProgress.current.toFixed(1)}%)`); + progressPercent = maxProgress; + } else { + logger.log(`[TraktAutosync] Current progress is already highest: ${progressPercent.toFixed(1)}%`); + } } // If we have valid progress but no started session, force start one first -- 2.45.2 From 93a016b96eb713ce100ef75865d36dd908d5f5ff Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 17:37:34 +0530 Subject: [PATCH 84/88] Enhance navigation animations and transitions for improved user experience This update modifies the AppNavigator to refine screen transition animations, including adjustments to animation types and durations based on platform. The SearchScreen now features smoother keyboard dismissal before navigation, and animations are added to enhance the visual experience during screen transitions. Additionally, the StreamsScreen is updated to prevent animation conflicts during exits, improving overall responsiveness and clarity in user interactions. --- src/navigation/AppNavigator.tsx | 32 +++++++-- src/screens/SearchScreen.tsx | 20 +++++- src/screens/StreamsScreen.tsx | 113 +++++++++++++++++++++++++++++--- 3 files changed, 149 insertions(+), 16 deletions(-) diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 1c1cec2..86d41b1 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -780,8 +780,10 @@ const AppNavigator = () => { component={StreamsScreen as any} options={{ headerShown: false, - animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'fade_from_bottom', - animationDuration: Platform.OS === 'android' ? 200 : 300, + animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'none', + animationDuration: Platform.OS === 'android' ? 0 : 300, + gestureEnabled: true, + gestureDirection: Platform.OS === 'ios' ? 'vertical' : 'horizontal', ...(Platform.OS === 'ios' && { presentation: 'modal' }), contentStyle: { backgroundColor: currentTheme.colors.darkBackground, @@ -825,8 +827,30 @@ const AppNavigator = () => { name="Search" component={SearchScreen as any} options={{ - animation: 'fade', - animationDuration: Platform.OS === 'android' ? 300 : 350, + animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', + animationDuration: Platform.OS === 'android' ? 250 : 350, + gestureEnabled: true, + gestureDirection: 'horizontal', + ...(Platform.OS === 'android' && { + cardStyleInterpolator: ({ current, layouts }: any) => { + return { + cardStyle: { + transform: [ + { + translateX: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [layouts.screen.width, 0], + }), + }, + ], + opacity: current.progress.interpolate({ + inputRange: [0, 0.3, 1], + outputRange: [0, 0.85, 1], + }), + }, + }; + }, + }), contentStyle: { backgroundColor: currentTheme.colors.darkBackground, }, diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 2d57e65..7ba205b 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -287,7 +287,14 @@ const SearchScreen = () => { setShowRecent(true); loadRecentSearches(); } else { - navigation.goBack(); + // Add a small delay to allow keyboard to dismiss smoothly before navigation + if (Platform.OS === 'android') { + setTimeout(() => { + navigation.goBack(); + }, 100); + } else { + navigation.goBack(); + } } }; @@ -497,7 +504,14 @@ const SearchScreen = () => { const headerHeight = headerBaseHeight + topSpacing + 60; return ( - <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> + <Animated.View + style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]} + entering={Platform.OS === 'android' ? SlideInRight.duration(250) : FadeIn.duration(350)} + exiting={Platform.OS === 'android' ? + FadeOut.duration(200).withInitialValues({ opacity: 1 }) : + FadeOut.duration(250) + } + > <StatusBar barStyle="light-content" backgroundColor="transparent" @@ -656,7 +670,7 @@ const SearchScreen = () => { )} </View> </View> - </View> + </Animated.View> ); }; diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index d7f2f59..c3750c5 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -33,6 +33,7 @@ import { useSettings } from '../hooks/useSettings'; import QualityBadge from '../components/metadata/QualityBadge'; import Animated, { FadeIn, + FadeOut, FadeInDown, SlideInDown, withSpring, @@ -55,13 +56,14 @@ const DOLBY_ICON = 'https://upload.wikimedia.org/wikipedia/en/thumb/3/3f/Dolby_V const { width, height } = Dimensions.get('window'); // Extracted Components -const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }: { +const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme, isExiting }: { stream: Stream; onPress: () => void; index: number; isLoading?: boolean; statusMessage?: string; theme: any; + isExiting?: boolean; }) => { const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); @@ -78,13 +80,92 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }: const displayTitle = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream'); const displayAddonName = isHDRezka ? '' : (stream.title || ''); - // Animation delay based on index - stagger effect - const enterDelay = 100 + (index * 50); + // Animation delay based on index - stagger effect (only if not exiting) + const enterDelay = isExiting ? 0 : 100 + (index * 30); + + // Use simple View when exiting to prevent animation conflicts + if (isExiting) { + return ( + <View> + <TouchableOpacity + style={[ + styles.streamCard, + isLoading && styles.streamCardLoading + ]} + onPress={onPress} + disabled={isLoading} + activeOpacity={0.7} + > + <View style={styles.streamDetails}> + <View style={styles.streamNameRow}> + <View style={styles.streamTitleContainer}> + <Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}> + {displayTitle} + </Text> + {displayAddonName && displayAddonName !== displayTitle && ( + <Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}> + {displayAddonName} + </Text> + )} + </View> + + {/* Show loading indicator if stream is loading */} + {isLoading && ( + <View style={styles.loadingIndicator}> + <ActivityIndicator size="small" color={theme.colors.primary} /> + <Text style={[styles.loadingText, { color: theme.colors.primary }]}> + {statusMessage || "Loading..."} + </Text> + </View> + )} + </View> + + <View style={styles.streamMetaRow}> + {quality && quality >= "720" && ( + <QualityBadge type="HD" /> + )} + + {isDolby && ( + <QualityBadge type="VISION" /> + )} + + {size && ( + <View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}> + <Text style={[styles.chipText, { color: theme.colors.white }]}>{size}</Text> + </View> + )} + + {isDebrid && ( + <View style={[styles.chip, { backgroundColor: theme.colors.success }]}> + <Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text> + </View> + )} + + {/* Special badge for HDRezka streams */} + {isHDRezka && ( + <View style={[styles.chip, { backgroundColor: theme.colors.accent }]}> + <Text style={[styles.chipText, { color: theme.colors.white }]}>HDREZKA</Text> + </View> + )} + </View> + </View> + + <View style={styles.streamAction}> + <MaterialIcons + name="play-arrow" + size={24} + color={theme.colors.primary} + /> + </View> + </TouchableOpacity> + </View> + ); + } return ( <Animated.View - entering={FadeInDown.duration(300).delay(enterDelay).springify()} - layout={Layout.springify()} + entering={FadeInDown.duration(200).delay(enterDelay)} + layout={Layout.duration(200)} > <TouchableOpacity style={[ @@ -249,6 +330,9 @@ export const StreamsScreen = () => { // Add state for handling orientation transition const [isTransitioning, setIsTransitioning] = useState(false); + + // Add state to prevent animation conflicts during exit + const [isExiting, setIsExiting] = useState(false); // Add timing logs const [loadStartTime, setLoadStartTime] = useState(0); @@ -400,20 +484,25 @@ export const StreamsScreen = () => { // Memoize handlers const handleBack = useCallback(() => { + // Set exit state to prevent animation conflicts and hide content immediately + setIsExiting(true); + const cleanup = () => { - headerOpacity.value = withTiming(0, { duration: 200 }); - heroScale.value = withTiming(0.95, { duration: 200 }); - filterOpacity.value = withTiming(0, { duration: 200 }); + headerOpacity.value = withTiming(0, { duration: 100 }); + heroScale.value = withTiming(0.95, { duration: 100 }); + filterOpacity.value = withTiming(0, { duration: 100 }); }; cleanup(); // For series episodes, always replace current screen with metadata screen if (type === 'series') { + // Immediate navigation for series navigation.replace('Metadata', { id: id, type: type }); } else { + // Immediate navigation for movies navigation.goBack(); } }, [navigation, headerOpacity, heroScale, filterOpacity, type, id]); @@ -954,9 +1043,10 @@ export const StreamsScreen = () => { isLoading={isLoading} statusMessage={undefined} theme={currentTheme} + isExiting={isExiting} /> ); - }, [handleStreamPress, currentTheme]); + }, [handleStreamPress, currentTheme, isExiting]); const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string } }) => { const isProviderLoading = loadingProviders[section.addonId]; @@ -1035,6 +1125,11 @@ export const StreamsScreen = () => { barStyle="light-content" /> + {/* Instant overlay when exiting to prevent glitches */} + {isExiting && ( + <View style={[StyleSheet.absoluteFill, { backgroundColor: colors.darkBackground, zIndex: 100 }]} /> + )} + {/* Transition overlay to mask orientation changes */} {isTransitioning && ( <View style={styles.transitionOverlay}> -- 2.45.2 From 71e2d39d9612e5f919a2989fd7c365a19b576ae7 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 18:07:30 +0530 Subject: [PATCH 85/88] Implement stream provider checks and error handling in StreamsScreen This update enhances the StreamsScreen component by adding checks for available streaming sources, improving user feedback when no sources are available. A new state for displaying an error message is introduced, along with logic to handle the loading of streams based on the availability of Stremio addons and internal providers. Additionally, the UI is updated to reflect these changes, ensuring a more responsive and informative user experience during content loading. --- src/screens/StreamsScreen.tsx | 159 ++++++++++++++++++++++----------- src/services/stremioService.ts | 23 +++++ 2 files changed, 131 insertions(+), 51 deletions(-) diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index c3750c5..bb86987 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -390,6 +390,13 @@ export const StreamsScreen = () => { const [autoplayTriggered, setAutoplayTriggered] = useState(false); const [isAutoplayWaiting, setIsAutoplayWaiting] = useState(false); + // Add check for available streaming sources + const [hasStreamProviders, setHasStreamProviders] = useState(true); // Assume true initially + const [hasStremioStreamProviders, setHasStremioStreamProviders] = useState(true); // For footer logic + + // Add state for no sources error + const [showNoSourcesError, setShowNoSourcesError] = useState(false); + // Monitor streams loading and update available providers immediately useEffect(() => { // Skip processing if component is unmounting @@ -433,35 +440,50 @@ export const StreamsScreen = () => { } }, [selectedProvider, availableProviders]); + // Update useEffect to check for sources + useEffect(() => { + const checkProviders = async () => { + // Check for both Stremio addons and if the internal provider is enabled + const hasStremioProviders = await stremioService.hasStreamProviders(); + const hasProviders = hasStremioProviders || settings.enableInternalProviders; + if (!isMounted.current) return; - React.useEffect(() => { - if (type === 'series' && episodeId) { - logger.log(`🎬 Loading episode streams for: ${episodeId}`); - setLoadingProviders({ - 'stremio': true, - 'hdrezka': true - }); - setSelectedEpisode(episodeId); - loadEpisodeStreams(episodeId); - } else if (type === 'movie') { - logger.log(`🎬 Loading movie streams for: ${id}`); - // setLoadingProviders({ // This is now handled by the main effect - // 'stremio': true, - // 'hdrezka': true - // }); - loadStreams(); - } + setHasStreamProviders(hasProviders); + setHasStremioStreamProviders(hasStremioProviders); - // Reset autoplay state when content changes - setAutoplayTriggered(false); - if (settings.autoplayBestStream) { - setIsAutoplayWaiting(true); - logger.log('🔄 Autoplay enabled, waiting for best stream...'); - } else { - setIsAutoplayWaiting(false); - } - }, [type, episodeId, settings.autoplayBestStream]); + if (!hasProviders) { + const timer = setTimeout(() => { + if (isMounted.current) setShowNoSourcesError(true); + }, 500); + return () => clearTimeout(timer); + } else { + if (type === 'series' && episodeId) { + logger.log(`🎬 Loading episode streams for: ${episodeId}`); + setLoadingProviders({ + 'stremio': true, + 'hdrezka': true + }); + setSelectedEpisode(episodeId); + loadEpisodeStreams(episodeId); + } else if (type === 'movie') { + logger.log(`🎬 Loading movie streams for: ${id}`); + loadStreams(); + } + + // Reset autoplay state when content changes + setAutoplayTriggered(false); + if (settings.autoplayBestStream) { + setIsAutoplayWaiting(true); + logger.log('🔄 Autoplay enabled, waiting for best stream...'); + } else { + setIsAutoplayWaiting(false); + } + } + }; + + checkProviders(); + }, [type, id, episodeId, settings.autoplayBestStream, settings.enableInternalProviders]); React.useEffect(() => { // Trigger entrance animations @@ -1281,9 +1303,25 @@ export const StreamsScreen = () => { )} </Animated.View> - {/* Show streams immediately as they become available */} - {Object.keys(streams).length === 0 ? ( - // Only show initial loading if no streams are available yet + {/* Update the streams/loading state display logic */} + { showNoSourcesError ? ( + <Animated.View + entering={FadeIn.duration(300)} + style={styles.noStreams} + > + <MaterialIcons name="error-outline" size={48} color={colors.textMuted} /> + <Text style={styles.noStreamsText}>No streaming sources available</Text> + <Text style={styles.noStreamsSubText}> + Please add streaming sources in settings + </Text> + <TouchableOpacity + style={styles.addSourcesButton} + onPress={() => navigation.navigate('Settings')} + > + <Text style={styles.addSourcesButtonText}>Add Sources</Text> + </TouchableOpacity> + </Animated.View> + ) : Object.keys(streams).length === 0 ? ( (loadingStreams || loadingEpisodeStreams) ? ( <Animated.View entering={FadeIn.duration(300)} @@ -1336,7 +1374,7 @@ export const StreamsScreen = () => { bounces={true} overScrollMode="never" ListFooterComponent={ - (loadingStreams || loadingEpisodeStreams) ? ( + (loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders ? ( <View style={styles.footerLoading}> <ActivityIndicator size="small" color={colors.primary} /> <Text style={styles.footerLoadingText}>Loading more sources...</Text> @@ -1394,25 +1432,25 @@ const createStyles = (colors: any) => StyleSheet.create({ flexGrow: 0, }, filterChip: { - backgroundColor: colors.transparentLight, + backgroundColor: 'transparent', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, marginRight: 8, borderWidth: 1, - borderColor: colors.transparent, + borderColor: colors.border, }, filterChipSelected: { - backgroundColor: colors.transparentLight, + backgroundColor: colors.primary, borderColor: colors.primary, }, filterChipText: { - color: colors.text, + color: colors.mediumEmphasis, fontWeight: '500', }, filterChipTextSelected: { - color: colors.primary, - fontWeight: 'bold', + color: colors.white, + fontWeight: '600', }, streamsContent: { flex: 1, @@ -1429,23 +1467,23 @@ const createStyles = (colors: any) => StyleSheet.create({ width: '100%', }, streamGroupTitle: { - color: colors.text, - fontSize: 16, - fontWeight: '600', - marginBottom: 4, + color: colors.highEmphasis, + fontSize: 15, + fontWeight: '700', + marginBottom: 8, marginTop: 0, backgroundColor: 'transparent', }, streamCard: { flexDirection: 'row', alignItems: 'flex-start', - padding: 12, - borderRadius: 12, - marginBottom: 8, + padding: 16, + borderRadius: 10, + marginBottom: 12, minHeight: 70, - backgroundColor: colors.elevation1, + backgroundColor: colors.card, borderWidth: 1, - borderColor: 'rgba(255,255,255,0.05)', + borderColor: colors.cardHighlight, width: '100%', zIndex: 1, }, @@ -1487,11 +1525,12 @@ const createStyles = (colors: any) => StyleSheet.create({ alignItems: 'center', }, chip: { - paddingHorizontal: 8, - paddingVertical: 2, + paddingHorizontal: 10, + paddingVertical: 4, borderRadius: 4, marginRight: 4, marginBottom: 4, + backgroundColor: colors.surfaceVariant, }, chipText: { color: colors.highEmphasis, @@ -1516,10 +1555,10 @@ const createStyles = (colors: any) => StyleSheet.create({ marginLeft: 8, }, streamAction: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: colors.elevation2, + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: colors.card, justifyContent: 'center', alignItems: 'center', }, @@ -1793,6 +1832,24 @@ const createStyles = (colors: any) => StyleSheet.create({ marginLeft: 8, fontWeight: '600', }, + noStreamsSubText: { + color: colors.mediumEmphasis, + fontSize: 14, + marginTop: 8, + textAlign: 'center', + }, + addSourcesButton: { + marginTop: 24, + paddingHorizontal: 20, + paddingVertical: 10, + backgroundColor: colors.primary, + borderRadius: 8, + }, + addSourcesButtonText: { + color: colors.white, + fontSize: 14, + fontWeight: '600', + }, }); export default memo(StreamsScreen); \ No newline at end of file diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 7aef799..6415c14 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -1039,6 +1039,29 @@ class StremioService { } return false; } + + // Check if any installed addons can provide streams + async hasStreamProviders(): Promise<boolean> { + await this.ensureInitialized(); + const addons = Array.from(this.installedAddons.values()); + + for (const addon of addons) { + if (addon.resources && Array.isArray(addon.resources)) { + // Check for 'stream' resource in the modern format + const hasStreamResource = addon.resources.some(resource => + typeof resource === 'string' + ? resource === 'stream' + : resource.name === 'stream' + ); + + if (hasStreamResource) { + return true; + } + } + } + + return false; + } } export const stremioService = StremioService.getInstance(); -- 2.45.2 From e14783343c052321f599ad40d17702bea6fe9ea5 Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 18:14:49 +0530 Subject: [PATCH 86/88] Refactor StreamsScreen layout for improved UI consistency and performance This update simplifies the movie title display in the StreamsScreen by removing the ImageBackground and gradient layers, resulting in a cleaner layout. The height of the movie title container is adjusted, and styles are refined for better alignment and responsiveness. These changes enhance the overall user experience by providing a more streamlined and visually appealing interface. --- src/screens/StreamsScreen.tsx | 70 +++++++++++------------------------ 1 file changed, 22 insertions(+), 48 deletions(-) diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index bb86987..e0b08ee 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -1177,36 +1177,19 @@ export const StreamsScreen = () => { {type === 'movie' && metadata && ( <Animated.View style={[styles.movieTitleContainer, heroStyle]}> - <ImageBackground - source={{ uri: metadata.banner || metadata.poster }} - style={styles.movieTitleBackground} - resizeMode="cover" - > - <LinearGradient - colors={[ - 'rgba(0,0,0,0.2)', - 'rgba(0,0,0,0.4)', - 'rgba(0,0,0,0.6)', - colors.darkBackground - ]} - locations={[0, 0.4, 0.7, 1]} - style={styles.movieTitleGradient} - > - <View style={styles.movieTitleContent}> - {metadata.logo ? ( - <Image - source={{ uri: metadata.logo }} - style={styles.movieLogo} - contentFit="contain" - /> - ) : ( - <Text style={styles.movieTitle} numberOfLines={2}> - {metadata.name} - </Text> - )} - </View> - </LinearGradient> - </ImageBackground> + <View style={styles.movieTitleContent}> + {metadata.logo ? ( + <Image + source={{ uri: metadata.logo }} + style={styles.movieLogo} + contentFit="contain" + /> + ) : ( + <Text style={styles.movieTitle} numberOfLines={2}> + {metadata.name} + </Text> + )} + </View> </Animated.View> )} @@ -1734,39 +1717,30 @@ const createStyles = (colors: any) => StyleSheet.create({ }, movieTitleContainer: { width: '100%', - height: 200, - backgroundColor: colors.black, + height: 120, + backgroundColor: colors.darkBackground, pointerEvents: 'box-none', - }, - movieTitleBackground: { - width: '100%', - height: '100%', - backgroundColor: colors.black, - }, - movieTitleGradient: { - flex: 1, justifyContent: 'center', - padding: 16, + paddingTop: Platform.OS === 'android' ? 45 : 35, }, movieTitleContent: { width: '100%', + height: '100%', alignItems: 'center', - marginTop: Platform.OS === 'android' ? 35 : 45, + justifyContent: 'center', }, movieLogo: { - width: width * 0.6, - height: 70, - marginBottom: 8, + width: '100%', + height: '100%', + maxWidth: width * 0.85, }, movieTitle: { color: colors.highEmphasis, fontSize: 28, fontWeight: '900', textAlign: 'center', - textShadowColor: 'rgba(0,0,0,0.75)', - textShadowOffset: { width: 0, height: 2 }, - textShadowRadius: 4, letterSpacing: -0.5, + paddingHorizontal: 20, }, streamsHeroRuntime: { flexDirection: 'row', -- 2.45.2 From ddf8d007b7a336862b8d1bdd0785ef79f9b4174f Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 18:57:41 +0530 Subject: [PATCH 87/88] Update progress thresholds across components to 85% for improved user experience This update modifies various components, including ContinueWatchingSection, HeroSection, SeriesContent, and player components, to adjust the progress completion threshold from 95% to 85%. This change ensures that users can continue watching content that is closer to completion, enhancing engagement and usability. Additionally, related logic in the useWatchProgress hook and HomeScreen is updated to reflect this new threshold, providing a consistent experience across the application. --- src/components/home/ContinueWatchingSection.tsx | 4 ++-- src/components/metadata/HeroSection.tsx | 10 +++++----- src/components/metadata/SeriesContent.tsx | 12 ++++++------ src/components/player/AndroidVideoPlayer.tsx | 2 +- src/components/player/VideoPlayer.tsx | 2 +- src/hooks/useWatchProgress.ts | 4 ++-- src/screens/HomeScreen.tsx | 4 ++-- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index ba9a8ce..7aeda06 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -100,10 +100,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined; const progress = allProgress[key]; - // Skip items that are more than 95% complete (effectively finished) + // Skip items that are more than 85% complete (effectively finished) const progressPercent = (progress.currentTime / progress.duration) * 100; - if (progressPercent >= 95) { + if (progressPercent >= 85) { continue; } diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 2139561..80d15ab 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -332,7 +332,7 @@ const WatchProgressDisplay = React.memo(({ } // Enhanced display text with Trakt integration - let displayText = progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`; + let displayText = progressPercent >= 85 ? 'Watched' : `${Math.round(progressPercent)}% watched`; let syncStatus = ''; // Show Trakt sync status if user is authenticated @@ -373,7 +373,7 @@ const WatchProgressDisplay = React.memo(({ progressBoxScale.value = withTiming(1, { duration: 400 }); progressBoxTranslateY.value = withTiming(0, { duration: 400 }); - if (progressData.isWatched || (progressData.progressPercent && progressData.progressPercent >= 95)) { + if (progressData.isWatched || (progressData.progressPercent && progressData.progressPercent >= 85)) { // Celebration animation sequence celebrationScale.value = withRepeat( withTiming(1.05, { duration: 200 }), @@ -426,7 +426,7 @@ const WatchProgressDisplay = React.memo(({ if (!progressData) return null; - const isCompleted = progressData.isWatched || progressData.progressPercent >= 95; + const isCompleted = progressData.isWatched || progressData.progressPercent >= 85; return ( <Animated.View style={[styles.watchProgressContainer, animatedStyle]}> @@ -704,7 +704,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ // Memoized play button text const playButtonText = useMemo(() => getPlayButtonText(), [getPlayButtonText]); - // Calculate if content is watched (>=95% progress) - check both local and Trakt progress + // Calculate if content is watched (>=85% progress) - check both local and Trakt progress const isWatched = useMemo(() => { if (!watchProgress) return false; @@ -718,7 +718,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ // Fall back to local progress if (watchProgress.duration === 0) return false; const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; - const localWatched = progressPercent >= 95; + const localWatched = progressPercent >= 85; logger.log(`[HeroSection] Local progress: ${progressPercent.toFixed(1)}%, Watched: ${localWatched}`); return localWatched; }, [watchProgress, isTraktAuthenticated]); diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 82086fd..5f5feb5 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -285,8 +285,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ const progress = episodeProgress[episodeId]; const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0; - // Don't show progress bar if episode is complete (>= 95%) - const showProgress = progress && progressPercent < 95; + // Don't show progress bar if episode is complete (>= 85%) + const showProgress = progress && progressPercent < 85; return ( <TouchableOpacity @@ -318,7 +318,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ /> </View> )} - {progressPercent >= 95 && ( + {progressPercent >= 85 && ( <View style={[styles.completedBadge, { backgroundColor: currentTheme.colors.primary }]}> <MaterialIcons name="check" size={12} color={currentTheme.colors.white} /> </View> @@ -395,8 +395,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ const progress = episodeProgress[episodeId]; const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0; - // Don't show progress bar if episode is complete (>= 95%) - const showProgress = progress && progressPercent < 95; + // Don't show progress bar if episode is complete (>= 85%) + const showProgress = progress && progressPercent < 85; return ( <TouchableOpacity @@ -517,7 +517,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ )} {/* Completed Badge */} - {progressPercent >= 95 && ( + {progressPercent >= 85 && ( <View style={[styles.completedBadgeHorizontal, { backgroundColor: currentTheme.colors.primary, }]}> diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index d54fa18..13d5c96 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -268,7 +268,7 @@ const AndroidVideoPlayer: React.FC = () => { const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; logger.log(`[AndroidVideoPlayer] Progress: ${progressPercent.toFixed(1)}% (${savedProgress.currentTime}/${savedProgress.duration})`); - if (progressPercent < 95) { + if (progressPercent < 85) { setResumePosition(savedProgress.currentTime); logger.log(`[AndroidVideoPlayer] Set resume position to: ${savedProgress.currentTime}`); diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index 89cd404..f5d2857 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -263,7 +263,7 @@ const VideoPlayer: React.FC = () => { const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; logger.log(`[VideoPlayer] Progress: ${progressPercent.toFixed(1)}% (${savedProgress.currentTime}/${savedProgress.duration})`); - if (progressPercent < 95) { + if (progressPercent < 85) { setResumePosition(savedProgress.currentTime); logger.log(`[VideoPlayer] Set resume position to: ${savedProgress.currentTime}`); diff --git a/src/hooks/useWatchProgress.ts b/src/hooks/useWatchProgress.ts index ff47bf4..a4ec9d5 100644 --- a/src/hooks/useWatchProgress.ts +++ b/src/hooks/useWatchProgress.ts @@ -156,9 +156,9 @@ export const useWatchProgress = ( return 'Play'; } - // Consider episode complete if progress is >= 95% + // Consider episode complete if progress is >= 85% const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; - if (progressPercent >= 95) { + if (progressPercent >= 85) { return 'Play'; } diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 8501985..e622643 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -666,11 +666,11 @@ const HomeScreen = () => { console.log('[HomeScreen] All watch progress in storage:', Object.keys(allProgress).length, 'items'); console.log('[HomeScreen] Watch progress items:', allProgress); - // Check if any items are being filtered out due to >95% progress + // Check if any items are being filtered out due to >85% progress let filteredCount = 0; for (const [key, progress] of Object.entries(allProgress)) { const progressPercent = (progress.currentTime / progress.duration) * 100; - if (progressPercent >= 95) { + if (progressPercent >= 85) { filteredCount++; console.log(`[HomeScreen] Filtered out ${key}: ${progressPercent.toFixed(1)}% complete`); } else { -- 2.45.2 From a5799c2f6254676a92ac8b28c1175ef58912aebf Mon Sep 17 00:00:00 2001 From: tapframe <nayifveliya99@gmail.com> Date: Fri, 20 Jun 2025 19:00:59 +0530 Subject: [PATCH 88/88] Adjust movie title container height and padding for improved layout in StreamsScreen This update modifies the height of the movie title container from 120 to 140 and adjusts the padding for Android devices from 45 to 65. These changes enhance the visual consistency and responsiveness of the layout, contributing to a better user experience. --- src/screens/StreamsScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index e0b08ee..66807f7 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -1717,11 +1717,11 @@ const createStyles = (colors: any) => StyleSheet.create({ }, movieTitleContainer: { width: '100%', - height: 120, + height: 140, backgroundColor: colors.darkBackground, pointerEvents: 'box-none', justifyContent: 'center', - paddingTop: Platform.OS === 'android' ? 45 : 35, + paddingTop: Platform.OS === 'android' ? 65 : 35, }, movieTitleContent: { width: '100%', -- 2.45.2