From ab8f870e73296da86eb899f5bafa4d6ef1b39ed4 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 14 Oct 2025 13:50:22 +0530 Subject: [PATCH] UI changes --- package-lock.json | 2 +- package.json | 2 +- src/components/CustomAlert.tsx | 108 +++++--------------- src/components/metadata/CommentsSection.tsx | 8 +- src/components/metadata/HeroSection.tsx | 6 +- src/contexts/DownloadsContext.tsx | 27 ++++- src/hooks/useMetadataAssets.ts | 70 +++++++------ src/screens/DownloadsScreen.tsx | 55 +++++++--- src/screens/MetadataScreen.tsx | 22 ++-- src/screens/StreamsScreen.tsx | 29 ++++-- 10 files changed, 160 insertions(+), 169 deletions(-) diff --git a/package-lock.json b/package-lock.json index c4628d7..a87600f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,7 @@ "react-native-image-colors": "^2.5.0", "react-native-immersive-mode": "^2.0.2", "react-native-markdown-display": "^7.0.2", - "react-native-paper": "^5.13.1", + "react-native-paper": "^5.14.5", "react-native-reanimated": "^3.17.4", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", diff --git a/package.json b/package.json index 88e5ddb..279da29 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "react-native-image-colors": "^2.5.0", "react-native-immersive-mode": "^2.0.2", "react-native-markdown-display": "^7.0.2", - "react-native-paper": "^5.13.1", + "react-native-paper": "^5.14.5", "react-native-reanimated": "^3.17.4", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", diff --git a/src/components/CustomAlert.tsx b/src/components/CustomAlert.tsx index 574ff49..1441f12 100644 --- a/src/components/CustomAlert.tsx +++ b/src/components/CustomAlert.tsx @@ -15,6 +15,7 @@ import Animated, { withTiming, } from 'react-native-reanimated'; import { useTheme } from '../contexts/ThemeContext'; +import { Portal, Dialog, Button } from 'react-native-paper'; interface CustomAlertProps { visible: boolean; @@ -75,27 +76,29 @@ export const CustomAlert = ({ } }, [onClose]); - // Don't render anything if not visible - if (!visible) { - return null; - } - - // Use different rendering approach for Android to avoid Modal issues - if (Platform.OS === 'android') { - return ( + // Use Portal with Modal for proper rendering and animations + return ( + - + - {title} - + {/* Message */} {message} - + {/* Actions */} {actions.map((action, idx) => { @@ -120,7 +123,7 @@ export const CustomAlert = ({ key={action.label} style={[ styles.actionButton, - isPrimary + isPrimary ? { ...styles.primaryButton, backgroundColor: themeColors.primary } : styles.secondaryButton, action.style @@ -130,7 +133,7 @@ export const CustomAlert = ({ > @@ -140,82 +143,17 @@ export const CustomAlert = ({ ); })} - + - + - ); - } - - // iOS version with animations - return ( - - - - - - {/* Title */} - - {title} - - - {/* Message */} - - {message} - - - {/* Actions */} - - {actions.map((action, idx) => { - const isPrimary = idx === actions.length - 1; - return ( - handleActionPress(action)} - activeOpacity={0.7} - > - - {action.label} - - - ); - })} - - - - - + ); }; const styles = StyleSheet.create({ overlay: { - flex: 1, + ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center', }, diff --git a/src/components/metadata/CommentsSection.tsx b/src/components/metadata/CommentsSection.tsx index c7dcb47..60b99c6 100644 --- a/src/components/metadata/CommentsSection.tsx +++ b/src/components/metadata/CommentsSection.tsx @@ -640,7 +640,7 @@ export const CommentsSection: React.FC = ({ - {error ? 'Comments unavailable' : 'No comments yet'} + {error ? 'Comments unavailable' : 'No comments on Trakt yet'} {error @@ -729,13 +729,13 @@ export const CommentsSection: React.FC = ({ )} - {loading && comments.length === 0 && renderSkeletons()} + {loading && Array.isArray(comments) && comments.length === 0 && renderSkeletons()} - {(!loading && comments.length === 0 && hasLoadedOnce && !error) && ( + {(!loading && Array.isArray(comments) && comments.length === 0 && hasLoadedOnce && !error) && ( renderEmpty() )} - {comments.length > 0 && ( + {Array.isArray(comments) && comments.length > 0 && ( = memo(({ pointerEvents="none" /> - {/* Optimized Title/Logo */} + {/* Optimized Title/Logo - Show logo immediately when available */} - {shouldLoadSecondaryData && logoUri && !logoLoadError ? ( + {logoUri && !logoLoadError ? ( Promise; cancelDownload: (id: string) => Promise; removeDownload: (id: string) => Promise; + isDownloadingUrl: (url: string) => boolean; }; const DownloadsContext = createContext(undefined); @@ -116,6 +117,17 @@ function isDownloadableUrl(url: string): boolean { return !isStreamingFormat; } +function hashString(input: string): string { + let hash = 0; + for (let i = 0; i < input.length; i++) { + const chr = input.charCodeAt(i); + hash = (hash << 5) - hash + chr; + hash |= 0; // Convert to 32bit integer + } + // Convert to unsigned and hex + return (hash >>> 0).toString(16); +} + export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [downloads, setDownloads] = useState([]); const downloadsRef = useRef(downloads); @@ -351,13 +363,15 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi } const contentId = input.id; - // Compose per-episode id for series - const compoundId = input.type === 'series' && input.season && input.episode + // Create unique ID per URL - allows same episode/movie from different sources + const urlHash = hashString(input.url); + const baseId = input.type === 'series' && input.season && input.episode ? `${contentId}:S${input.season}E${input.episode}` : contentId; + const compoundId = `${baseId}:${urlHash}`; - // If already exists, handle based on status - const existing = downloadsRef.current.find(d => d.id === compoundId); + // Check if this exact URL is already being downloaded + const existing = downloadsRef.current.find(d => d.sourceUrl === input.url); if (existing) { if (existing.status === 'completed') { return; // Already completed, do nothing @@ -365,7 +379,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi return; // Already downloading, do nothing } else if (existing.status === 'paused' || existing.status === 'error') { // Resume the paused or errored download instead of starting new one - await resumeDownload(compoundId); + await resumeDownload(existing.id); return; } } @@ -555,6 +569,9 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi resumeDownload, cancelDownload, removeDownload, + isDownloadingUrl: (url: string) => { + return downloadsRef.current.some(d => d.sourceUrl === url && (d.status === 'queued' || d.status === 'downloading' || d.status === 'paused')); + }, }), [downloads, startDownload, pauseDownload, resumeDownload, cancelDownload, removeDownload]); return ( diff --git a/src/hooks/useMetadataAssets.ts b/src/hooks/useMetadataAssets.ts index f938f67..db2d826 100644 --- a/src/hooks/useMetadataAssets.ts +++ b/src/hooks/useMetadataAssets.ts @@ -81,19 +81,10 @@ export const useMetadataAssets = ( forcedLogoRefreshDone.current = false; logoRefreshCounter.current = 0; - // Force logo refresh on preference change - if (metadata?.logo) { - const currentLogoIsExternal = isTmdbUrl(metadata.logo); - const currentLogoIsTmdb = isTmdbUrl(metadata.logo); - const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; - - // Always clear logo on preference change to force proper refresh - setMetadata((prevMetadata: any) => ({ - ...prevMetadata!, - logo: undefined - })); - } - }, [settings.logoSourcePreference, setMetadata]); + // Mark that we need to refetch logo but DON'T clear it yet + // This prevents text from showing during the transition + logoFetchInProgress.current = false; + }, [settings.logoSourcePreference]); // Original reset logo load error effect useEffect(() => { @@ -119,8 +110,10 @@ export const useMetadataAssets = ( // If enrichment is disabled, use addon logo and don't fetch from external sources if (!settings.enrichMetadataWithTMDB) { - // If we have an addon logo, use it and don't fetch external logos + // If we have an addon logo, preload it immediately for instant display if (metadata?.logo && !isTmdbUrl(metadata.logo)) { + // Preload addon logo for instant display + FastImage.preload([{ uri: metadata.logo }]); // This is an addon logo, keep it return; } @@ -128,11 +121,17 @@ export const useMetadataAssets = ( return; } + // When TMDB enrichment is ON, remove addon logos immediately + // We don't want to show addon logos briefly before TMDB logos load + if (settings.enrichMetadataWithTMDB && currentLogoUrl && !isTmdbUrl(currentLogoUrl)) { + // Clear addon logo when enrichment is enabled + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + shouldFetchLogo = true; + } // Determine if we need to fetch a new logo (only when enrichment is enabled) - if (!currentLogoUrl) { + else if (!currentLogoUrl) { shouldFetchLogo = true; } else { - const isCurrentLogoExternal = isTmdbUrl(currentLogoUrl); const isCurrentLogoTmdb = isTmdbUrl(currentLogoUrl); if (logoPreference === 'tmdb' && !isCurrentLogoTmdb) { @@ -145,10 +144,8 @@ export const useMetadataAssets = ( logoFetchInProgress.current = true; const fetchLogo = async () => { - // Clear existing logo before fetching new one to avoid briefly showing wrong logo - if (shouldFetchLogo) { - setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); - } + // Store the original logo to restore if needed + const originalLogoUrl = currentLogoUrl; try { const preferredLanguage = settings.tmdbLanguagePreference || 'en'; @@ -198,32 +195,37 @@ export const useMetadataAssets = ( } if (logoUrl) { - // Preload the image - FastImage.preload([{ uri: logoUrl }]); + // Preload the image before setting it + await FastImage.preload([{ uri: logoUrl }]); + // Only update if we got a valid logo setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: logoUrl })); } else { - // TMDB logo not found, try to restore addon logo if it exists - if (currentLogoUrl && !isTmdbUrl(currentLogoUrl)) { - if (__DEV__) console.log('[useMetadataAssets] Restoring addon logo after TMDB logo not found'); - setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: currentLogoUrl })); - } else if (__DEV__) { - console.log('[useMetadataAssets] No logo found for TMDB ID:', tmdbId); + // TMDB logo not found + // When enrichment is ON, don't fallback to addon logos - show text instead + if (__DEV__) { + console.log('[useMetadataAssets] No TMDB logo found for ID:', tmdbId); } + // Keep logo as undefined to show text title } } catch (error) { - // TMDB logo fetch failed, try to restore addon logo if it exists - if (currentLogoUrl && !isTmdbUrl(currentLogoUrl)) { - if (__DEV__) console.log('[useMetadataAssets] Restoring addon logo after TMDB fetch error'); - setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: currentLogoUrl })); - } else if (__DEV__) { + // TMDB logo fetch failed + // When enrichment is ON, don't fallback to addon logos - show text instead + if (__DEV__) { console.error('[useMetadataAssets] Logo fetch error:', error); } + // Keep logo as undefined to show text title } + } else { + // No TMDB ID found + // When enrichment is ON, don't use addon logos - show text instead + if (__DEV__) console.log('[useMetadataAssets] No TMDB ID found, will show text title'); + // Keep logo as undefined to show text title } } } catch (error) { - // Handle error silently + // Handle error silently, keep existing logo + if (__DEV__) console.error('[useMetadataAssets] Unexpected error in logo fetch:', error); } finally { logoFetchInProgress.current = false; } diff --git a/src/screens/DownloadsScreen.tsx b/src/screens/DownloadsScreen.tsx index 953dfbb..a5f3591 100644 --- a/src/screens/DownloadsScreen.tsx +++ b/src/screens/DownloadsScreen.tsx @@ -95,7 +95,8 @@ const DownloadItemComponent: React.FC<{ item: DownloadItem; onPress: (item: DownloadItem) => void; onAction: (item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => void; -}> = React.memo(({ item, onPress, onAction }) => { + onRequestRemove: (item: DownloadItem) => void; +}> = React.memo(({ item, onPress, onAction, onRequestRemove }) => { const { currentTheme } = useTheme(); const [posterUrl, setPosterUrl] = useState(item.posterUrl || null); @@ -323,16 +324,7 @@ const DownloadItemComponent: React.FC<{ { - Alert.alert( - 'Remove Download', - 'Are you sure you want to remove this download?', - [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Remove', style: 'destructive', onPress: () => onAction(item, 'cancel') }, - ] - ); - }} + onPress={() => onRequestRemove(item)} activeOpacity={0.7} > { const [isRefreshing, setIsRefreshing] = useState(false); const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all'); const [showHelpAlert, setShowHelpAlert] = useState(false); + const [showRemoveAlert, setShowRemoveAlert] = useState(false); + const [pendingRemoveItem, setPendingRemoveItem] = useState(null); // Animation values const headerOpacity = useSharedValue(1); @@ -449,6 +443,11 @@ const DownloadsScreen: React.FC = () => { if (action === 'cancel') cancelDownload(item.id); }, [pauseDownload, resumeDownload, cancelDownload]); + const handleRequestRemove = useCallback((item: DownloadItem) => { + setPendingRemoveItem(item); + setShowRemoveAlert(true); + }, []); + const handleFilterPress = useCallback((filter: 'all' | 'downloading' | 'completed' | 'paused') => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setSelectedFilter(filter); @@ -533,7 +532,9 @@ const DownloadsScreen: React.FC = () => { styles.header, { backgroundColor: currentTheme.colors.darkBackground, - paddingTop: safeAreaTop + 16, + paddingTop: (Platform.OS === 'android' + ? (StatusBar.currentHeight || 0) + 26 + : safeAreaTop + 15) + (isTablet ? 64 : 0), borderBottomColor: currentTheme.colors.border, }, headerStyle, @@ -577,6 +578,7 @@ const DownloadsScreen: React.FC = () => { item={item} onPress={handleDownloadPress} onAction={handleDownloadAction} + onRequestRemove={handleRequestRemove} /> )} style={{ backgroundColor: currentTheme.colors.darkBackground }} @@ -615,6 +617,18 @@ const DownloadsScreen: React.FC = () => { message="• Files smaller than 1MB are typically M3U8 streaming playlists and cannot be downloaded for offline viewing. These only work with online streaming and contain links to video segments, not the actual video content." onClose={() => setShowHelpAlert(false)} /> + + {/* Remove Download Confirmation */} + setShowRemoveAlert(false) }, + { label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: { } }, + ]} + onClose={() => { setShowRemoveAlert(false); setPendingRemoveItem(null); }} + /> ); }; @@ -624,19 +638,21 @@ const styles = StyleSheet.create({ flex: 1, }, header: { - paddingHorizontal: isTablet ? 24 : 20, + paddingHorizontal: isTablet ? 24 : Math.max(1, width * 0.05), paddingBottom: isTablet ? 20 : 16, borderBottomWidth: StyleSheet.hairlineWidth, }, headerTitleRow: { flexDirection: 'row', - alignItems: 'center', + alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: isTablet ? 20 : 16, + paddingBottom: 8, }, headerTitle: { - fontSize: isTablet ? 36 : 32, - fontWeight: '700', + fontSize: isTablet ? 36 : Math.min(32, width * 0.08), + fontWeight: '800', + letterSpacing: 0.3, }, helpButton: { padding: 8, @@ -671,7 +687,7 @@ const styles = StyleSheet.create({ fontWeight: '700', }, listContainer: { - padding: isTablet ? 24 : 20, + paddingHorizontal: 0, paddingTop: 8, paddingBottom: isTablet ? 120 : 100, // Extra padding for tablet bottom nav }, @@ -687,6 +703,11 @@ const styles = StyleSheet.create({ shadowOpacity: 0.1, shadowRadius: 8, elevation: 3, + marginHorizontal: 0, + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, }, posterContainer: { width: POSTER_WIDTH, diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 53e99a6..71b712e 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -924,24 +924,26 @@ const MetadataScreen: React.FC = () => { /> )} - {/* Production info row — shown after cast for movies */} - {shouldLoadSecondaryData && Object.keys(groupedEpisodes).length === 0 && metadata?.networks && metadata.networks.length > 0 && ( + {/* Production info row — only render companies with logos */} + {shouldLoadSecondaryData && + Object.keys(groupedEpisodes).length === 0 && + metadata?.networks && Array.isArray(metadata.networks) && + metadata.networks.some((n: any) => !!n?.logo) && ( Production - {metadata.networks.slice(0, 6).map((net) => ( - - {net.logo ? ( + {metadata.networks + .filter((net: any) => !!net?.logo) + .slice(0, 6) + .map((net: any) => ( + - ) : ( - {net.name} - )} - - ))} + + ))} )} diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 3489f71..1de8f7c 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -49,6 +49,7 @@ import { isMkvStream } from '../utils/mkvDetection'; import CustomAlert from '../components/CustomAlert'; import { Toast } from 'toastify-react-native'; import { useDownloads } from '../contexts/DownloadsContext'; +import { PaperProvider } from 'react-native-paper'; const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906'; const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png'; @@ -291,6 +292,16 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the try { const url = stream.url; if (!url) return; + // Prevent duplicate downloads for the same exact URL + try { + const downloadsModule = require('../contexts/DownloadsContext'); + if (downloadsModule && downloadsModule.isDownloadingUrl && downloadsModule.isDownloadingUrl(url)) { + showAlert('Already Downloading', 'This download has already started for this exact link.'); + return; + } + } catch {} + // Show immediate feedback on both platforms + showAlert('Starting Download', 'Download will be started.'); const parent: any = stream as any; const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content'; const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie'); @@ -1951,6 +1962,7 @@ export const StreamsScreen = () => { return ( + { )} - {Platform.OS === 'ios' && ( - setAlertVisible(false)} - /> - )} + setAlertVisible(false)} + /> + ); };