UI changes

This commit is contained in:
tapframe 2025-10-14 13:50:22 +05:30
parent 2a5798c107
commit ab8f870e73
10 changed files with 160 additions and 169 deletions

2
package-lock.json generated
View file

@ -69,7 +69,7 @@
"react-native-image-colors": "^2.5.0", "react-native-image-colors": "^2.5.0",
"react-native-immersive-mode": "^2.0.2", "react-native-immersive-mode": "^2.0.2",
"react-native-markdown-display": "^7.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-reanimated": "^3.17.4",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",

View file

@ -69,7 +69,7 @@
"react-native-image-colors": "^2.5.0", "react-native-image-colors": "^2.5.0",
"react-native-immersive-mode": "^2.0.2", "react-native-immersive-mode": "^2.0.2",
"react-native-markdown-display": "^7.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-reanimated": "^3.17.4",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",

View file

@ -15,6 +15,7 @@ import Animated, {
withTiming, withTiming,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { Portal, Dialog, Button } from 'react-native-paper';
interface CustomAlertProps { interface CustomAlertProps {
visible: boolean; visible: boolean;
@ -75,27 +76,29 @@ export const CustomAlert = ({
} }
}, [onClose]); }, [onClose]);
// Don't render anything if not visible // Use Portal with Modal for proper rendering and animations
if (!visible) { return (
return null; <Portal>
}
// Use different rendering approach for Android to avoid Modal issues
if (Platform.OS === 'android') {
return (
<Modal <Modal
visible={visible} visible={visible}
transparent transparent
animationType="fade" animationType="none"
onRequestClose={onClose} onRequestClose={onClose}
statusBarTranslucent={false} statusBarTranslucent={true}
hardwareAccelerated={true} hardwareAccelerated={true}
> >
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.6)' }]}> <Animated.View
style={[
styles.overlay,
{ backgroundColor: 'rgba(0,0,0,0.6)' },
overlayStyle
]}
>
<Pressable style={styles.overlayPressable} onPress={onClose} /> <Pressable style={styles.overlayPressable} onPress={onClose} />
<View style={styles.centered}> <View style={styles.centered}>
<View style={[ <Animated.View style={[
styles.alertContainer, styles.alertContainer,
alertStyle,
{ {
backgroundColor: themeColors.darkBackground, backgroundColor: themeColors.darkBackground,
borderColor: themeColors.primary, borderColor: themeColors.primary,
@ -140,82 +143,17 @@ export const CustomAlert = ({
); );
})} })}
</View> </View>
</View> </Animated.View>
</View> </View>
</View> </Animated.View>
</Modal> </Modal>
); </Portal>
}
// iOS version with animations
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
presentationStyle="overFullScreen"
>
<Animated.View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.6)' }, overlayStyle]}>
<Pressable style={styles.overlayPressable} onPress={onClose} />
<View style={styles.centered}>
<Animated.View style={[
styles.alertContainer,
alertStyle,
{
backgroundColor: themeColors.darkBackground,
borderColor: themeColors.primary,
}
]}>
{/* Title */}
<Text style={[styles.title, { color: themeColors.highEmphasis }]}>
{title}
</Text>
{/* Message */}
<Text style={[styles.message, { color: themeColors.mediumEmphasis }]}>
{message}
</Text>
{/* Actions */}
<View style={styles.actionsRow}>
{actions.map((action, idx) => {
const isPrimary = idx === actions.length - 1;
return (
<TouchableOpacity
key={action.label}
style={[
styles.actionButton,
isPrimary
? { ...styles.primaryButton, backgroundColor: themeColors.primary }
: styles.secondaryButton,
action.style
]}
onPress={() => handleActionPress(action)}
activeOpacity={0.7}
>
<Text style={[
styles.actionText,
isPrimary
? { color: themeColors.white }
: { color: themeColors.primary }
]}>
{action.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</Animated.View>
</View>
</Animated.View>
</Modal>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlay: { overlay: {
flex: 1, ...StyleSheet.absoluteFillObject,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },

View file

@ -640,7 +640,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<MaterialIcons name="chat-bubble-outline" size={48} color={currentTheme.colors.mediumEmphasis} /> <MaterialIcons name="chat-bubble-outline" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
{error ? 'Comments unavailable' : 'No comments yet'} {error ? 'Comments unavailable' : 'No comments on Trakt yet'}
</Text> </Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}> <Text style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}>
{error {error
@ -729,13 +729,13 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
</View> </View>
)} )}
{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() renderEmpty()
)} )}
{comments.length > 0 && ( {Array.isArray(comments) && comments.length > 0 && (
<Animated.FlatList <Animated.FlatList
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}

View file

@ -1501,10 +1501,10 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
pointerEvents="none" pointerEvents="none"
/> />
<View style={[styles.heroContent, isTablet && { maxWidth: 800, alignSelf: 'center' }]}> <View style={[styles.heroContent, isTablet && { maxWidth: 800, alignSelf: 'center' }]}>
{/* Optimized Title/Logo */} {/* Optimized Title/Logo - Show logo immediately when available */}
<Animated.View style={[styles.logoContainer, titleCardAnimatedStyle]}> <Animated.View style={[styles.logoContainer, titleCardAnimatedStyle]}>
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}> <Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
{shouldLoadSecondaryData && logoUri && !logoLoadError ? ( {logoUri && !logoLoadError ? (
<Image <Image
source={{ uri: logoUri }} source={{ uri: logoUri }}
style={isTablet ? styles.tabletTitleLogo : styles.titleLogo} style={isTablet ? styles.tabletTitleLogo : styles.titleLogo}

View file

@ -58,6 +58,7 @@ type DownloadsContextValue = {
resumeDownload: (id: string) => Promise<void>; resumeDownload: (id: string) => Promise<void>;
cancelDownload: (id: string) => Promise<void>; cancelDownload: (id: string) => Promise<void>;
removeDownload: (id: string) => Promise<void>; removeDownload: (id: string) => Promise<void>;
isDownloadingUrl: (url: string) => boolean;
}; };
const DownloadsContext = createContext<DownloadsContextValue | undefined>(undefined); const DownloadsContext = createContext<DownloadsContextValue | undefined>(undefined);
@ -116,6 +117,17 @@ function isDownloadableUrl(url: string): boolean {
return !isStreamingFormat; 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 }) => { export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [downloads, setDownloads] = useState<DownloadItem[]>([]); const [downloads, setDownloads] = useState<DownloadItem[]>([]);
const downloadsRef = useRef(downloads); const downloadsRef = useRef(downloads);
@ -351,13 +363,15 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
} }
const contentId = input.id; const contentId = input.id;
// Compose per-episode id for series // Create unique ID per URL - allows same episode/movie from different sources
const compoundId = input.type === 'series' && input.season && input.episode const urlHash = hashString(input.url);
const baseId = input.type === 'series' && input.season && input.episode
? `${contentId}:S${input.season}E${input.episode}` ? `${contentId}:S${input.season}E${input.episode}`
: contentId; : contentId;
const compoundId = `${baseId}:${urlHash}`;
// If already exists, handle based on status // Check if this exact URL is already being downloaded
const existing = downloadsRef.current.find(d => d.id === compoundId); const existing = downloadsRef.current.find(d => d.sourceUrl === input.url);
if (existing) { if (existing) {
if (existing.status === 'completed') { if (existing.status === 'completed') {
return; // Already completed, do nothing return; // Already completed, do nothing
@ -365,7 +379,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
return; // Already downloading, do nothing return; // Already downloading, do nothing
} else if (existing.status === 'paused' || existing.status === 'error') { } else if (existing.status === 'paused' || existing.status === 'error') {
// Resume the paused or errored download instead of starting new one // Resume the paused or errored download instead of starting new one
await resumeDownload(compoundId); await resumeDownload(existing.id);
return; return;
} }
} }
@ -555,6 +569,9 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
resumeDownload, resumeDownload,
cancelDownload, cancelDownload,
removeDownload, 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]); }), [downloads, startDownload, pauseDownload, resumeDownload, cancelDownload, removeDownload]);
return ( return (

View file

@ -81,19 +81,10 @@ export const useMetadataAssets = (
forcedLogoRefreshDone.current = false; forcedLogoRefreshDone.current = false;
logoRefreshCounter.current = 0; logoRefreshCounter.current = 0;
// Force logo refresh on preference change // Mark that we need to refetch logo but DON'T clear it yet
if (metadata?.logo) { // This prevents text from showing during the transition
const currentLogoIsExternal = isTmdbUrl(metadata.logo); logoFetchInProgress.current = false;
const currentLogoIsTmdb = isTmdbUrl(metadata.logo); }, [settings.logoSourcePreference]);
const preferenceIsMetahub = settings.logoSourcePreference === 'metahub';
// Always clear logo on preference change to force proper refresh
setMetadata((prevMetadata: any) => ({
...prevMetadata!,
logo: undefined
}));
}
}, [settings.logoSourcePreference, setMetadata]);
// Original reset logo load error effect // Original reset logo load error effect
useEffect(() => { useEffect(() => {
@ -119,8 +110,10 @@ export const useMetadataAssets = (
// If enrichment is disabled, use addon logo and don't fetch from external sources // If enrichment is disabled, use addon logo and don't fetch from external sources
if (!settings.enrichMetadataWithTMDB) { 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)) { if (metadata?.logo && !isTmdbUrl(metadata.logo)) {
// Preload addon logo for instant display
FastImage.preload([{ uri: metadata.logo }]);
// This is an addon logo, keep it // This is an addon logo, keep it
return; return;
} }
@ -128,11 +121,17 @@ export const useMetadataAssets = (
return; 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) // Determine if we need to fetch a new logo (only when enrichment is enabled)
if (!currentLogoUrl) { else if (!currentLogoUrl) {
shouldFetchLogo = true; shouldFetchLogo = true;
} else { } else {
const isCurrentLogoExternal = isTmdbUrl(currentLogoUrl);
const isCurrentLogoTmdb = isTmdbUrl(currentLogoUrl); const isCurrentLogoTmdb = isTmdbUrl(currentLogoUrl);
if (logoPreference === 'tmdb' && !isCurrentLogoTmdb) { if (logoPreference === 'tmdb' && !isCurrentLogoTmdb) {
@ -145,10 +144,8 @@ export const useMetadataAssets = (
logoFetchInProgress.current = true; logoFetchInProgress.current = true;
const fetchLogo = async () => { const fetchLogo = async () => {
// Clear existing logo before fetching new one to avoid briefly showing wrong logo // Store the original logo to restore if needed
if (shouldFetchLogo) { const originalLogoUrl = currentLogoUrl;
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined }));
}
try { try {
const preferredLanguage = settings.tmdbLanguagePreference || 'en'; const preferredLanguage = settings.tmdbLanguagePreference || 'en';
@ -198,32 +195,37 @@ export const useMetadataAssets = (
} }
if (logoUrl) { if (logoUrl) {
// Preload the image // Preload the image before setting it
FastImage.preload([{ uri: logoUrl }]); await FastImage.preload([{ uri: logoUrl }]);
// Only update if we got a valid logo
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: logoUrl })); setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: logoUrl }));
} else { } else {
// TMDB logo not found, try to restore addon logo if it exists // TMDB logo not found
if (currentLogoUrl && !isTmdbUrl(currentLogoUrl)) { // When enrichment is ON, don't fallback to addon logos - show text instead
if (__DEV__) console.log('[useMetadataAssets] Restoring addon logo after TMDB logo not found'); if (__DEV__) {
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: currentLogoUrl })); console.log('[useMetadataAssets] No TMDB logo found for ID:', tmdbId);
} else if (__DEV__) {
console.log('[useMetadataAssets] No logo found for TMDB ID:', tmdbId);
} }
// Keep logo as undefined to show text title
} }
} catch (error) { } catch (error) {
// TMDB logo fetch failed, try to restore addon logo if it exists // TMDB logo fetch failed
if (currentLogoUrl && !isTmdbUrl(currentLogoUrl)) { // When enrichment is ON, don't fallback to addon logos - show text instead
if (__DEV__) console.log('[useMetadataAssets] Restoring addon logo after TMDB fetch error'); if (__DEV__) {
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: currentLogoUrl }));
} else if (__DEV__) {
console.error('[useMetadataAssets] Logo fetch error:', error); 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) { } catch (error) {
// Handle error silently // Handle error silently, keep existing logo
if (__DEV__) console.error('[useMetadataAssets] Unexpected error in logo fetch:', error);
} finally { } finally {
logoFetchInProgress.current = false; logoFetchInProgress.current = false;
} }

View file

@ -95,7 +95,8 @@ const DownloadItemComponent: React.FC<{
item: DownloadItem; item: DownloadItem;
onPress: (item: DownloadItem) => void; onPress: (item: DownloadItem) => void;
onAction: (item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => 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 { currentTheme } = useTheme();
const [posterUrl, setPosterUrl] = useState<string | null>(item.posterUrl || null); const [posterUrl, setPosterUrl] = useState<string | null>(item.posterUrl || null);
@ -323,16 +324,7 @@ const DownloadItemComponent: React.FC<{
<TouchableOpacity <TouchableOpacity
style={[styles.actionButton, { backgroundColor: currentTheme.colors.elevation2 }]} style={[styles.actionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => { onPress={() => onRequestRemove(item)}
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') },
]
);
}}
activeOpacity={0.7} activeOpacity={0.7}
> >
<MaterialCommunityIcons <MaterialCommunityIcons
@ -355,6 +347,8 @@ const DownloadsScreen: React.FC = () => {
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all'); const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all');
const [showHelpAlert, setShowHelpAlert] = useState(false); const [showHelpAlert, setShowHelpAlert] = useState(false);
const [showRemoveAlert, setShowRemoveAlert] = useState(false);
const [pendingRemoveItem, setPendingRemoveItem] = useState<DownloadItem | null>(null);
// Animation values // Animation values
const headerOpacity = useSharedValue(1); const headerOpacity = useSharedValue(1);
@ -449,6 +443,11 @@ const DownloadsScreen: React.FC = () => {
if (action === 'cancel') cancelDownload(item.id); if (action === 'cancel') cancelDownload(item.id);
}, [pauseDownload, resumeDownload, cancelDownload]); }, [pauseDownload, resumeDownload, cancelDownload]);
const handleRequestRemove = useCallback((item: DownloadItem) => {
setPendingRemoveItem(item);
setShowRemoveAlert(true);
}, []);
const handleFilterPress = useCallback((filter: 'all' | 'downloading' | 'completed' | 'paused') => { const handleFilterPress = useCallback((filter: 'all' | 'downloading' | 'completed' | 'paused') => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setSelectedFilter(filter); setSelectedFilter(filter);
@ -533,7 +532,9 @@ const DownloadsScreen: React.FC = () => {
styles.header, styles.header,
{ {
backgroundColor: currentTheme.colors.darkBackground, backgroundColor: currentTheme.colors.darkBackground,
paddingTop: safeAreaTop + 16, paddingTop: (Platform.OS === 'android'
? (StatusBar.currentHeight || 0) + 26
: safeAreaTop + 15) + (isTablet ? 64 : 0),
borderBottomColor: currentTheme.colors.border, borderBottomColor: currentTheme.colors.border,
}, },
headerStyle, headerStyle,
@ -577,6 +578,7 @@ const DownloadsScreen: React.FC = () => {
item={item} item={item}
onPress={handleDownloadPress} onPress={handleDownloadPress}
onAction={handleDownloadAction} onAction={handleDownloadAction}
onRequestRemove={handleRequestRemove}
/> />
)} )}
style={{ backgroundColor: currentTheme.colors.darkBackground }} 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." 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)} onClose={() => setShowHelpAlert(false)}
/> />
{/* Remove Download Confirmation */}
<CustomAlert
visible={showRemoveAlert}
title="Remove Download"
message={pendingRemoveItem ? `Remove \"${pendingRemoveItem.title}\"${pendingRemoveItem.type === 'series' && pendingRemoveItem.season && pendingRemoveItem.episode ? ` S${String(pendingRemoveItem.season).padStart(2,'0')}E${String(pendingRemoveItem.episode).padStart(2,'0')}` : ''}?` : 'Remove this download?'}
actions={[
{ label: 'Cancel', onPress: () => setShowRemoveAlert(false) },
{ label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: { } },
]}
onClose={() => { setShowRemoveAlert(false); setPendingRemoveItem(null); }}
/>
</View> </View>
); );
}; };
@ -624,19 +638,21 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
header: { header: {
paddingHorizontal: isTablet ? 24 : 20, paddingHorizontal: isTablet ? 24 : Math.max(1, width * 0.05),
paddingBottom: isTablet ? 20 : 16, paddingBottom: isTablet ? 20 : 16,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
}, },
headerTitleRow: { headerTitleRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'flex-end',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: isTablet ? 20 : 16, marginBottom: isTablet ? 20 : 16,
paddingBottom: 8,
}, },
headerTitle: { headerTitle: {
fontSize: isTablet ? 36 : 32, fontSize: isTablet ? 36 : Math.min(32, width * 0.08),
fontWeight: '700', fontWeight: '800',
letterSpacing: 0.3,
}, },
helpButton: { helpButton: {
padding: 8, padding: 8,
@ -671,7 +687,7 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
}, },
listContainer: { listContainer: {
padding: isTablet ? 24 : 20, paddingHorizontal: 0,
paddingTop: 8, paddingTop: 8,
paddingBottom: isTablet ? 120 : 100, // Extra padding for tablet bottom nav paddingBottom: isTablet ? 120 : 100, // Extra padding for tablet bottom nav
}, },
@ -687,6 +703,11 @@ const styles = StyleSheet.create({
shadowOpacity: 0.1, shadowOpacity: 0.1,
shadowRadius: 8, shadowRadius: 8,
elevation: 3, elevation: 3,
marginHorizontal: 0,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
}, },
posterContainer: { posterContainer: {
width: POSTER_WIDTH, width: POSTER_WIDTH,

View file

@ -924,24 +924,26 @@ const MetadataScreen: React.FC = () => {
/> />
)} )}
{/* Production info row — shown after cast for movies */} {/* Production info row — only render companies with logos */}
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length === 0 && metadata?.networks && metadata.networks.length > 0 && ( {shouldLoadSecondaryData &&
Object.keys(groupedEpisodes).length === 0 &&
metadata?.networks && Array.isArray(metadata.networks) &&
metadata.networks.some((n: any) => !!n?.logo) && (
<Animated.View style={[styles.productionContainer, productionSectionAnimatedStyle]}> <Animated.View style={[styles.productionContainer, productionSectionAnimatedStyle]}>
<Text style={styles.productionHeader}>Production</Text> <Text style={styles.productionHeader}>Production</Text>
<View style={styles.productionRow}> <View style={styles.productionRow}>
{metadata.networks.slice(0, 6).map((net) => ( {metadata.networks
<View key={String(net.id || net.name)} style={styles.productionChip}> .filter((net: any) => !!net?.logo)
{net.logo ? ( .slice(0, 6)
.map((net: any) => (
<View key={String(net.id || net.name)} style={styles.productionChip}>
<FastImage <FastImage
source={{ uri: net.logo }} source={{ uri: net.logo }}
style={styles.productionLogo} style={styles.productionLogo}
resizeMode={FastImage.resizeMode.contain} resizeMode={FastImage.resizeMode.contain}
/> />
) : ( </View>
<Text style={styles.productionText}>{net.name}</Text> ))}
)}
</View>
))}
</View> </View>
</Animated.View> </Animated.View>
)} )}

View file

@ -49,6 +49,7 @@ import { isMkvStream } from '../utils/mkvDetection';
import CustomAlert from '../components/CustomAlert'; import CustomAlert from '../components/CustomAlert';
import { Toast } from 'toastify-react-native'; import { Toast } from 'toastify-react-native';
import { useDownloads } from '../contexts/DownloadsContext'; 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 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'; 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 { try {
const url = stream.url; const url = stream.url;
if (!url) return; 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 parent: any = stream as any;
const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content'; const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content';
const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie'); const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie');
@ -1951,6 +1962,7 @@ export const StreamsScreen = () => {
return ( return (
<PaperProvider>
<View style={styles.container}> <View style={styles.container}>
<StatusBar <StatusBar
translucent translucent
@ -2248,16 +2260,15 @@ export const StreamsScreen = () => {
</View> </View>
)} )}
</View> </View>
{Platform.OS === 'ios' && ( <CustomAlert
<CustomAlert visible={alertVisible}
visible={alertVisible} title={alertTitle}
title={alertTitle} message={alertMessage}
message={alertMessage} actions={alertActions}
actions={alertActions} onClose={() => setAlertVisible(false)}
onClose={() => setAlertVisible(false)} />
/>
)}
</View> </View>
</PaperProvider>
); );
}; };