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-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",

View file

@ -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",

View file

@ -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 (
<Portal>
<Modal
visible={visible}
transparent
animationType="fade"
animationType="none"
onRequestClose={onClose}
statusBarTranslucent={false}
statusBarTranslucent={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} />
<View style={styles.centered}>
<View style={[
<Animated.View style={[
styles.alertContainer,
alertStyle,
{
backgroundColor: themeColors.darkBackground,
borderColor: themeColors.primary,
@ -105,12 +108,12 @@ export const CustomAlert = ({
<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) => {
@ -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 = ({
>
<Text style={[
styles.actionText,
isPrimary
isPrimary
? { color: themeColors.white }
: { color: themeColors.primary }
]}>
@ -140,82 +143,17 @@ export const CustomAlert = ({
);
})}
</View>
</View>
</Animated.View>
</View>
</View>
</Animated.View>
</Modal>
);
}
// 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>
</Portal>
);
};
const styles = StyleSheet.create({
overlay: {
flex: 1,
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
},

View file

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

View file

@ -1501,10 +1501,10 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
pointerEvents="none"
/>
<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.titleLogoContainer, logoAnimatedStyle]}>
{shouldLoadSecondaryData && logoUri && !logoLoadError ? (
{logoUri && !logoLoadError ? (
<Image
source={{ uri: logoUri }}
style={isTablet ? styles.tabletTitleLogo : styles.titleLogo}
@ -2127,4 +2127,4 @@ const styles = StyleSheet.create({
},
});
export default HeroSection;
export default HeroSection;

View file

@ -58,6 +58,7 @@ type DownloadsContextValue = {
resumeDownload: (id: string) => Promise<void>;
cancelDownload: (id: string) => Promise<void>;
removeDownload: (id: string) => Promise<void>;
isDownloadingUrl: (url: string) => boolean;
};
const DownloadsContext = createContext<DownloadsContextValue | undefined>(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<DownloadItem[]>([]);
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 (

View file

@ -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;
}

View file

@ -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<string | null>(item.posterUrl || null);
@ -323,16 +324,7 @@ const DownloadItemComponent: React.FC<{
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => {
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}
>
<MaterialCommunityIcons
@ -355,6 +347,8 @@ const DownloadsScreen: React.FC = () => {
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<DownloadItem | null>(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 */}
<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>
);
};
@ -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,

View file

@ -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) && (
<Animated.View style={[styles.productionContainer, productionSectionAnimatedStyle]}>
<Text style={styles.productionHeader}>Production</Text>
<View style={styles.productionRow}>
{metadata.networks.slice(0, 6).map((net) => (
<View key={String(net.id || net.name)} style={styles.productionChip}>
{net.logo ? (
{metadata.networks
.filter((net: any) => !!net?.logo)
.slice(0, 6)
.map((net: any) => (
<View key={String(net.id || net.name)} style={styles.productionChip}>
<FastImage
source={{ uri: net.logo }}
style={styles.productionLogo}
resizeMode={FastImage.resizeMode.contain}
/>
) : (
<Text style={styles.productionText}>{net.name}</Text>
)}
</View>
))}
</View>
))}
</View>
</Animated.View>
)}

View file

@ -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 (
<PaperProvider>
<View style={styles.container}>
<StatusBar
translucent
@ -2248,16 +2260,15 @@ export const StreamsScreen = () => {
</View>
)}
</View>
{Platform.OS === 'ios' && (
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
)}
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
</View>
</PaperProvider>
);
};