UI changes

This commit is contained in:
tapframe 2025-10-14 02:43:21 +05:30
parent 9e7b9c5fe4
commit 1a9d59e804
6 changed files with 333 additions and 136 deletions

View file

@ -44,13 +44,13 @@ export const CustomAlert = ({
const themeColors = currentTheme.colors;
useEffect(() => {
const animDuration = Platform.OS === 'android' ? 200 : 120;
const duration = Platform.OS === 'android' ? 200 : 150;
if (visible) {
opacity.value = withTiming(1, { duration: animDuration });
scale.value = withTiming(1, { duration: animDuration });
opacity.value = withTiming(1, { duration });
scale.value = withTiming(1, { duration });
} else {
opacity.value = withTiming(0, { duration: animDuration });
scale.value = withTiming(0.95, { duration: animDuration });
opacity.value = withTiming(0, { duration });
scale.value = withTiming(0.95, { duration });
}
}, [visible]);
@ -63,10 +63,6 @@ export const CustomAlert = ({
opacity: opacity.value,
}));
const backgroundColor = isDarkMode ? themeColors.darkBackground : themeColors.elevation2 || '#FFFFFF';
const textColor = isDarkMode ? themeColors.white : themeColors.black || '#000000';
const borderColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
// Safe action handler to prevent crashes
const handleActionPress = useCallback((action: { label: string; onPress: () => void; style?: object }) => {
try {
@ -95,23 +91,54 @@ export const CustomAlert = ({
statusBarTranslucent={false}
hardwareAccelerated={true}
>
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}>
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.6)' }]}>
<Pressable style={styles.overlayPressable} onPress={onClose} />
<View style={styles.centered}>
<View style={[styles.alertContainer, { backgroundColor, borderColor }]}>
<Text style={[styles.title, { color: textColor }]}>{title}</Text>
<Text style={[styles.message, { color: textColor }]}>{message}</Text>
<View style={[
styles.alertContainer,
{
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) => (
<TouchableOpacity
key={action.label}
style={[styles.actionButton, idx === actions.length - 1 && styles.lastActionButton, action.style]}
onPress={() => handleActionPress(action)}
activeOpacity={0.7}
>
<Text style={[styles.actionText, { color: themeColors.primary }]}>{action.label}</Text>
</TouchableOpacity>
))}
{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>
</View>
</View>
@ -129,23 +156,55 @@ export const CustomAlert = ({
onRequestClose={onClose}
presentationStyle="overFullScreen"
>
<Animated.View style={[styles.overlay, { backgroundColor: themeColors.transparentDark || 'rgba(0,0,0,0.5)' }, overlayStyle]}>
<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, borderColor }]}>
<Text style={[styles.title, { color: textColor }]}>{title}</Text>
<Text style={[styles.message, { color: textColor }]}>{message}</Text>
<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) => (
<TouchableOpacity
key={action.label}
style={[styles.actionButton, idx === actions.length - 1 && styles.lastActionButton, action.style]}
onPress={() => handleActionPress(action)}
activeOpacity={0.7}
>
<Text style={[styles.actionText, { color: themeColors.primary }]}>{action.label}</Text>
</TouchableOpacity>
))}
{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>
@ -167,52 +226,66 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 24,
},
alertContainer: {
minWidth: 280,
maxWidth: '85%',
borderRadius: 20,
padding: 24,
width: '100%',
maxWidth: 340,
borderRadius: 24,
padding: 28,
borderWidth: 1,
borderColor: '#007AFF', // iOS blue - will be overridden by theme
overflow: 'hidden', // Ensure background fills entire card
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 8,
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 24,
},
android: {
elevation: 8,
elevation: 12,
},
}),
},
title: {
fontSize: 18,
fontSize: 20,
fontWeight: '700',
marginBottom: 12,
marginBottom: 8,
textAlign: 'center',
letterSpacing: 0.2,
},
message: {
fontSize: 16,
marginBottom: 20,
fontSize: 15,
marginBottom: 24,
textAlign: 'center',
lineHeight: 22,
letterSpacing: 0.1,
},
actionsRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
marginTop: 4,
},
actionButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
paddingHorizontal: 20,
paddingVertical: 11,
borderRadius: 12,
minWidth: 80,
alignItems: 'center',
justifyContent: 'center',
},
lastActionButton: {
// Optionally style the last button differently
primaryButton: {
// Background color set dynamically via theme
},
secondaryButton: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
actionText: {
fontSize: 16,
fontWeight: '600',
letterSpacing: 0.2,
},
});

View file

@ -38,7 +38,6 @@ import { logger } from '../../utils/logger';
import { TMDBService } from '../../services/tmdbService';
import TrailerService from '../../services/trailerService';
import TrailerPlayer from '../video/TrailerPlayer';
import { isTmdbUrl } from '../../utils/logoUtils';
const { width, height } = Dimensions.get('window');
const isTablet = width >= 768;
@ -895,17 +894,10 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
bannerImage || metadata.banner || metadata.poster
, [bannerImage, metadata.banner, metadata.poster]);
// Prefer TMDB logo when enrichment is enabled; fallback to addon's logo
// Use the logo provided by metadata (already enriched by useMetadataAssets based on settings)
const logoUri = useMemo(() => {
const candidate = metadata?.logo as string | undefined;
if (!candidate) return undefined;
if (settings?.enrichMetadataWithTMDB) {
// If the current logo is a TMDB URL, use it; otherwise still use available logo
if (isTmdbUrl(candidate)) return candidate;
return candidate;
}
return candidate;
}, [metadata.logo, settings?.enrichMetadataWithTMDB]);
return metadata?.logo as string | undefined;
}, [metadata?.logo]);
// Performance optimization: Lazy loading setup
useEffect(() => {

View file

@ -26,11 +26,34 @@ import Animated, {
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { useDownloads } from '../contexts/DownloadsContext';
import type { DownloadItem } from '../contexts/DownloadsContext';
import { Toast } from 'toastify-react-native';
import CustomAlert from '../components/CustomAlert';
const { height, width } = Dimensions.get('window');
const isTablet = width >= 768;
// Tablet-optimized poster sizes
const HORIZONTAL_ITEM_WIDTH = isTablet ? width * 0.18 : width * 0.3;
const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5;
const POSTER_WIDTH = isTablet ? 70 : 90;
const POSTER_HEIGHT = isTablet ? 105 : 135;
// Helper function to optimize poster URLs
const optimizePosterUrl = (poster: string | undefined | null): string => {
if (!poster || poster.includes('placeholder')) {
return 'https://via.placeholder.com/80x120/333333/666666?text=No+Image';
}
// For TMDB images, use larger sizes for bigger posters
if (poster.includes('image.tmdb.org')) {
return poster.replace(/\/w\d+\//, '/w300/');
}
return poster;
};
// Download items come from DownloadsContext
@ -74,6 +97,16 @@ const DownloadItemComponent: React.FC<{
onAction: (item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => void;
}> = React.memo(({ item, onPress, onAction }) => {
const { currentTheme } = useTheme();
const [posterUrl, setPosterUrl] = useState<string | null>(item.posterUrl || null);
// Try to fetch poster if not available
useEffect(() => {
if (!posterUrl && (item.imdbId || item.tmdbId)) {
// This could be enhanced to fetch poster from TMDB API if needed
// For now, we'll use the existing posterUrl or fallback to placeholder
setPosterUrl(item.posterUrl || null);
}
}, [item.imdbId, item.tmdbId, item.posterUrl, posterUrl]);
const handleLongPress = useCallback(() => {
if (item.status === 'completed' && item.fileUri) {
@ -171,6 +204,29 @@ const DownloadItemComponent: React.FC<{
onLongPress={handleLongPress}
activeOpacity={0.8}
>
{/* Poster */}
<View style={styles.posterContainer}>
<FastImage
source={{ uri: optimizePosterUrl(posterUrl) }}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
/>
{/* Status indicator overlay */}
<View style={[styles.statusOverlay, { backgroundColor: getStatusColor() }]}>
<MaterialCommunityIcons
name={
item.status === 'completed' ? 'check' :
item.status === 'downloading' ? 'download' :
item.status === 'paused' ? 'pause' :
item.status === 'error' ? 'alert-circle' :
'clock'
}
size={12}
color="white"
/>
</View>
</View>
{/* Content info */}
<View style={styles.downloadContent}>
<View style={styles.downloadHeader}>
@ -208,7 +264,21 @@ const DownloadItemComponent: React.FC<{
{formatBytes(item.downloadedBytes)} / {item.totalBytes ? formatBytes(item.totalBytes) : '—'}
</Text>
</View>
{/* Warning for small files */}
{item.totalBytes && item.totalBytes < 1048576 && ( // Less than 1MB
<View style={styles.warningRow}>
<MaterialCommunityIcons
name="alert-circle"
size={14}
color={currentTheme.colors.warning || '#FF9500'}
/>
<Text style={[styles.warningText, { color: currentTheme.colors.warning || '#FF9500' }]}>
May not play - streaming playlist
</Text>
</View>
)}
{/* Progress bar */}
<View style={[styles.progressContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Animated.View
@ -284,6 +354,7 @@ const DownloadsScreen: React.FC = () => {
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all');
const [showHelpAlert, setShowHelpAlert] = useState(false);
// Animation values
const headerOpacity = useSharedValue(1);
@ -383,6 +454,11 @@ const DownloadsScreen: React.FC = () => {
setSelectedFilter(filter);
}, []);
const showDownloadHelp = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setShowHelpAlert(true);
}, []);
// Focus effect
useFocusEffect(
useCallback(() => {
@ -462,9 +538,22 @@ const DownloadsScreen: React.FC = () => {
},
headerStyle,
]}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Downloads
</Text>
<View style={styles.headerTitleRow}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Downloads
</Text>
<TouchableOpacity
style={styles.helpButton}
onPress={showDownloadHelp}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="help-circle-outline"
size={24}
color={currentTheme.colors.mediumEmphasis}
/>
</TouchableOpacity>
</View>
{downloads.length > 0 && (
<View style={styles.filterContainer}>
@ -518,6 +607,14 @@ const DownloadsScreen: React.FC = () => {
)}
/>
)}
{/* Help Alert */}
<CustomAlert
visible={showHelpAlert}
title="Download Limitations"
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)}
/>
</View>
);
};
@ -527,29 +624,38 @@ const styles = StyleSheet.create({
flex: 1,
},
header: {
paddingHorizontal: 20,
paddingBottom: 16,
paddingHorizontal: isTablet ? 24 : 20,
paddingBottom: isTablet ? 20 : 16,
borderBottomWidth: StyleSheet.hairlineWidth,
},
headerTitleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: isTablet ? 20 : 16,
},
headerTitle: {
fontSize: 32,
fontSize: isTablet ? 36 : 32,
fontWeight: '700',
marginBottom: 16,
},
helpButton: {
padding: 8,
marginLeft: 8,
},
filterContainer: {
flexDirection: 'row',
gap: 12,
gap: isTablet ? 16 : 12,
},
filterButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
paddingHorizontal: isTablet ? 20 : 16,
paddingVertical: isTablet ? 10 : 8,
borderRadius: 20,
gap: 8,
},
filterButtonText: {
fontSize: 14,
fontSize: isTablet ? 16 : 14,
fontWeight: '600',
},
filterBadge: {
@ -565,24 +671,54 @@ const styles = StyleSheet.create({
fontWeight: '700',
},
listContainer: {
padding: 20,
padding: isTablet ? 24 : 20,
paddingTop: 8,
paddingBottom: isTablet ? 120 : 100, // Extra padding for tablet bottom nav
},
downloadItem: {
borderRadius: 16,
padding: 16,
marginBottom: 12,
padding: isTablet ? 20 : 16,
marginBottom: isTablet ? 16 : 12,
flexDirection: 'row',
alignItems: 'center',
minHeight: isTablet ? 165 : 152, // Accommodate tablet poster + padding
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
posterContainer: {
width: POSTER_WIDTH,
height: POSTER_HEIGHT,
borderRadius: 8,
marginRight: isTablet ? 20 : 16,
position: 'relative',
overflow: 'hidden',
backgroundColor: '#333',
},
poster: {
width: '100%',
height: '100%',
borderRadius: 8,
},
statusOverlay: {
position: 'absolute',
top: 4,
right: 4,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.3,
shadowRadius: 2,
elevation: 2,
},
downloadContent: {
flex: 1,
marginRight: 12,
},
downloadHeader: {
marginBottom: 12,
@ -627,6 +763,16 @@ const styles = StyleSheet.create({
sizeRow: {
marginBottom: 6,
},
warningRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginBottom: 6,
},
warningText: {
fontSize: 11,
fontWeight: '500',
},
progressInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
@ -679,7 +825,8 @@ const styles = StyleSheet.create({
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 40,
paddingHorizontal: isTablet ? 64 : 40,
paddingBottom: isTablet ? 120 : 100,
},
emptyIconContainer: {
width: 96,
@ -690,16 +837,16 @@ const styles = StyleSheet.create({
marginBottom: 24,
},
emptyTitle: {
fontSize: 24,
fontSize: isTablet ? 28 : 24,
fontWeight: '700',
marginBottom: 8,
textAlign: 'center',
},
emptySubtitle: {
fontSize: 16,
fontSize: isTablet ? 18 : 16,
textAlign: 'center',
lineHeight: 24,
marginBottom: 32,
lineHeight: isTablet ? 28 : 24,
marginBottom: isTablet ? 40 : 32,
},
exploreButton: {
paddingHorizontal: 24,
@ -713,7 +860,7 @@ const styles = StyleSheet.create({
emptyFilterContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
paddingVertical: isTablet ? 80 : 60,
},
emptyFilterTitle: {
fontSize: 18,

View file

@ -282,8 +282,6 @@ const SettingsScreen: React.FC = () => {
// Tablet-specific state
const [selectedCategory, setSelectedCategory] = useState('account');
const [downloadsDevUnlocked, setDownloadsDevUnlocked] = useState(false);
const [versionTapCount, setVersionTapCount] = useState(0);
// States for dynamic content
const [addonCount, setAddonCount] = useState<number>(0);
@ -582,22 +580,20 @@ const SettingsScreen: React.FC = () => {
)}
isTablet={isTablet}
/>
{downloadsDevUnlocked && (
<SettingItem
title="Enable Downloads"
description="Show Downloads tab and enable saving streams"
icon="download"
renderControl={() => (
<Switch
value={settings?.enableDownloads ?? true}
onValueChange={(value) => updateSetting('enableDownloads', value)}
trackColor={{ false: 'rgba(255,255,255,0.2)', true: currentTheme.colors.primary }}
thumbColor={settings?.enableDownloads ? '#fff' : '#f4f3f4'}
/>
)}
isTablet={isTablet}
/>
)}
<SettingItem
title="Enable Downloads"
description="Show Downloads tab and enable saving streams"
icon="download"
renderControl={() => (
<Switch
value={settings?.enableDownloads ?? false}
onValueChange={(value) => updateSetting('enableDownloads', value)}
trackColor={{ false: 'rgba(255,255,255,0.2)', true: currentTheme.colors.primary }}
thumbColor={settings?.enableDownloads ? '#fff' : '#f4f3f4'}
/>
)}
isTablet={isTablet}
/>
<SettingItem
title="Notifications"
description="Episode reminders"
@ -631,16 +627,6 @@ const SettingsScreen: React.FC = () => {
title="Version"
description={getDisplayedAppVersion()}
icon="info-outline"
onPress={() => {
if (downloadsDevUnlocked) return;
const next = versionTapCount + 1;
setVersionTapCount(next);
if (next >= 5) {
setDownloadsDevUnlocked(true);
setVersionTapCount(0);
openAlert('Developer option unlocked', 'Downloads toggle is now visible in Playback settings.');
}
}}
isLast={true}
isTablet={isTablet}
/>

View file

@ -238,18 +238,14 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
Toast.success('Stream URL copied to clipboard!', 'bottom');
} else {
// iOS uses custom alert
setTimeout(() => {
showAlert('Copied!', 'Stream URL has been copied to clipboard.');
}, 50);
showAlert('Copied!', 'Stream URL has been copied to clipboard.');
}
} catch (error) {
// Fallback: show URL in alert if clipboard fails
if (Platform.OS === 'android') {
Toast.info(`Stream URL: ${stream.url}`, 'bottom');
} else {
setTimeout(() => {
showAlert('Stream URL', stream.url);
}, 50);
showAlert('Stream URL', stream.url);
}
}
}
@ -331,19 +327,18 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
imdbId: parentImdbId || parent.imdbId || undefined,
tmdbId: tmdbId,
});
Toast.success('Download started', 'bottom');
showAlert('Download Started', 'Your download has been added to the queue.');
} catch {}
}, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title, parentId, parentImdbId, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName]);
const isDebrid = streamInfo.isDebrid;
return (
<TouchableOpacity
<TouchableOpacity
style={[
styles.streamCard,
styles.streamCard,
isLoading && styles.streamCardLoading,
isDebrid && styles.streamCardHighlighted
]}
onPress={onPress}
]}
onLongPress={handleLongPress}
disabled={isLoading}
activeOpacity={0.7}
@ -402,20 +397,24 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
</View>
</View>
<View style={styles.streamAction}>
<MaterialIcons
name="play-arrow"
size={22}
color={theme.colors.white}
<TouchableOpacity
style={styles.streamAction}
onPress={() => onPress()}
activeOpacity={0.7}
>
<MaterialIcons
name="play-arrow"
size={22}
color={theme.colors.white}
/>
</View>
</TouchableOpacity>
{settings?.enableDownloads !== false && (
<TouchableOpacity
style={[styles.streamAction, { marginLeft: 8, backgroundColor: theme.colors.elevation2 }]}
onPress={handleDownload}
activeOpacity={0.7}
>
<MaterialIcons
<MaterialIcons
name="download"
size={20}
color={theme.colors.highEmphasis}
@ -2204,7 +2203,7 @@ export const StreamsScreen = () => {
parentPosterUrl={episodeImage || metadata?.poster || undefined}
providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))}
parentId={id}
parentImdbId={imdbId}
parentImdbId={imdbId || undefined}
/>
</View>
)}

View file

@ -49,8 +49,8 @@ export const colors = {
surfaceVariant: 'rgba(255, 255, 255, 0.03)', // Material dark theme surface variant
// Material Design elevation overlays
elevation1: 'rgba(255, 255, 255, 0.05)',
elevation2: 'rgba(255, 255, 255, 0.08)',
elevation1: 'rgba(255, 255, 255, 0.03)',
elevation2: 'rgba(255, 255, 255, 0.03)',
elevation3: 'rgba(255, 255, 255, 0.11)',
elevation4: 'rgba(255, 255, 255, 0.12)',