discover screen init

This commit is contained in:
tapframe 2025-12-28 22:22:18 +05:30
parent a30fa604d7
commit cf5cc2d8f9
6 changed files with 353 additions and 50 deletions

View file

@ -0,0 +1,268 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
Platform,
Dimensions,
ViewStyle,
} from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
const { width } = Dimensions.get('window');
// Enhanced responsive breakpoints
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
const getDeviceType = (screenWidth: number) => {
if (screenWidth >= BREAKPOINTS.tv) return 'tv';
if (screenWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (screenWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
};
export type PosterShape = 'poster' | 'landscape' | 'square';
export interface PosterProps {
/** The poster image URL */
uri?: string | null;
/** Width of the poster */
width: number;
/** Shape of the poster - determines aspect ratio */
shape?: PosterShape;
/** Optional custom aspect ratio override */
aspectRatio?: number;
/** Optional custom border radius (uses settings.posterBorderRadius by default) */
borderRadius?: number;
/** Optional title to display below the poster */
title?: string;
/** Whether to show the title */
showTitle?: boolean;
/** Fallback text to show when no poster is available */
fallbackText?: string;
/** Additional styles for the container */
style?: ViewStyle;
/** Additional styles for the poster container */
posterStyle?: ViewStyle;
}
/**
* Shared Poster component with consistent styling across the app.
* Matches the design from ContentItem.tsx with:
* - Border: 1.5px solid rgba(255,255,255,0.15)
* - Border Radius: settings.posterBorderRadius (default 12)
* - Shadow: elevation 1 on Android, subtle shadow on iOS
* - Aspect Ratio: 2/3 for poster, 16/9 for landscape, 1/1 for square
*/
export const Poster: React.FC<PosterProps> = ({
uri,
width: posterWidth,
shape = 'poster',
aspectRatio: customAspectRatio,
borderRadius: customBorderRadius,
title,
showTitle = false,
fallbackText,
style,
posterStyle,
}) => {
const { currentTheme } = useTheme();
const { settings, isLoaded } = useSettings();
const [imageError, setImageError] = useState(false);
// Reset error state when URI changes
useEffect(() => {
setImageError(false);
}, [uri]);
// Determine aspect ratio based on shape
const aspectRatio = useMemo(() => {
if (customAspectRatio) return customAspectRatio;
switch (shape) {
case 'landscape':
return 16 / 9;
case 'square':
return 1;
case 'poster':
default:
return 2 / 3;
}
}, [shape, customAspectRatio]);
// Border radius from settings or custom
const borderRadius = customBorderRadius ??
(typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12);
// Device type for responsive title sizing
const deviceType = getDeviceType(width);
// Title font size based on device type
const titleFontSize = useMemo(() => {
switch (deviceType) {
case 'tv':
return 16;
case 'largeTablet':
return 15;
case 'tablet':
return 14;
default:
return 13;
}
}, [deviceType]);
// Optimize poster URL for TMDB
const optimizedUrl = useMemo(() => {
if (!uri || uri.includes('placeholder')) {
return null;
}
if (uri.includes('image.tmdb.org')) {
return uri.replace(/\/w\d+\//, '/w154/');
}
return uri;
}, [uri]);
// Placeholder while settings load
if (!isLoaded) {
return (
<View style={[styles.container, { width: posterWidth }, style]}>
<View
style={[
styles.posterContainer,
{
width: posterWidth,
aspectRatio,
borderRadius,
backgroundColor: currentTheme.colors.elevation1,
},
posterStyle,
]}
/>
{showTitle && <View style={{ height: 18, marginTop: 4 }} />}
</View>
);
}
return (
<View style={[styles.container, { width: posterWidth }, style]}>
<View
style={[
styles.posterContainer,
{
width: posterWidth,
aspectRatio,
borderRadius,
backgroundColor: currentTheme.colors.elevation1,
},
posterStyle,
]}
>
{optimizedUrl && !imageError ? (
<FastImage
source={{
uri: optimizedUrl,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable,
}}
style={[styles.poster, { borderRadius }]}
resizeMode={FastImage.resizeMode.cover}
onLoad={() => setImageError(false)}
onError={() => setImageError(true)}
/>
) : (
<View
style={[
styles.poster,
styles.fallbackContainer,
{
backgroundColor: currentTheme.colors.elevation1,
borderRadius,
},
]}
>
{imageError ? (
<MaterialIcons
name="broken-image"
size={24}
color={currentTheme.colors.textMuted}
/>
) : fallbackText ? (
<Text
style={[styles.fallbackText, { color: currentTheme.colors.textMuted }]}
numberOfLines={2}
>
{fallbackText.length > 20 ? `${fallbackText.substring(0, 20)}...` : fallbackText}
</Text>
) : (
<MaterialIcons
name="image"
size={24}
color={currentTheme.colors.textMuted}
/>
)}
</View>
)}
</View>
{showTitle && title && (
<Text
style={[
styles.title,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: titleFontSize,
},
]}
numberOfLines={2}
>
{title}
</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {},
posterContainer: {
overflow: 'hidden',
position: 'relative',
// Consistent shadow/elevation matching ContentItem
elevation: Platform.OS === 'android' ? 1 : 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
// Consistent border styling
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
marginBottom: 8,
},
poster: {
width: '100%',
height: '100%',
},
fallbackContainer: {
justifyContent: 'center',
alignItems: 'center',
},
fallbackText: {
fontSize: 10,
textAlign: 'center',
paddingHorizontal: 4,
},
title: {
fontWeight: '500',
marginTop: 4,
textAlign: 'center',
},
});
export default Poster;

View file

@ -1089,7 +1089,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
borderColor: currentTheme.colors.border,
shadowColor: currentTheme.colors.black,
width: computedItemWidth,
height: computedItemHeight
height: computedItemHeight,
borderRadius: settings.posterBorderRadius ?? 12,
}
]}
activeOpacity={0.8}
@ -1110,7 +1111,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable
}}
style={styles.continueWatchingPoster}
style={[styles.continueWatchingPoster, { borderTopLeftRadius: settings.posterBorderRadius ?? 12, borderBottomLeftRadius: settings.posterBorderRadius ?? 12 }]}
resizeMode={FastImage.resizeMode.cover}
/>
@ -1348,13 +1349,15 @@ const styles = StyleSheet.create({
width: 280,
height: 120,
flexDirection: 'row',
borderRadius: 14,
borderRadius: 12,
overflow: 'hidden',
elevation: 6,
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.2,
shadowRadius: 6,
borderWidth: 1,
elevation: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
},
posterContainer: {
width: 80,
@ -1364,8 +1367,8 @@ const styles = StyleSheet.create({
continueWatchingPoster: {
width: '100%',
height: '100%',
borderTopLeftRadius: 14,
borderBottomLeftRadius: 14,
borderTopLeftRadius: 12,
borderBottomLeftRadius: 12,
},
deletingOverlay: {
position: 'absolute',
@ -1451,26 +1454,28 @@ const styles = StyleSheet.create({
width: POSTER_WIDTH,
aspectRatio: 2 / 3,
margin: 0,
borderRadius: 8,
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
elevation: 8,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
borderWidth: 1,
elevation: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
},
contentItemContainer: {
width: '100%',
height: '100%',
borderRadius: 8,
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
},
poster: {
width: '100%',
height: '100%',
borderRadius: 8,
borderRadius: 12,
},
episodeInfoContainer: {
position: 'absolute',

View file

@ -7,6 +7,7 @@ import {
TouchableOpacity,
ActivityIndicator,
Dimensions,
Platform,
} from 'react-native';
import FastImage from '@d11/react-native-fast-image';
import { useNavigation, StackActions } from '@react-navigation/native';
@ -14,6 +15,7 @@ import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { StreamingContent } from '../../services/catalogService';
import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
import { TMDBService } from '../../services/tmdbService';
import { catalogService } from '../../services/catalogService';
import CustomAlert from '../../components/CustomAlert';
@ -33,12 +35,14 @@ interface MoreLikeThisSectionProps {
loadingRecommendations: boolean;
}
export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
recommendations,
loadingRecommendations
export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
recommendations,
loadingRecommendations
}) => {
const { currentTheme } = useTheme();
const { settings } = useSettings();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const borderRadius = settings.posterBorderRadius ?? 12;
// Determine device type
const deviceWidth = Dimensions.get('window').width;
@ -91,16 +95,16 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
try {
// Extract TMDB ID from the tmdb:123456 format
const tmdbId = item.id.replace('tmdb:', '');
// Get Stremio ID directly using catalogService
// The catalogService.getStremioId method already handles the conversion internally
const stremioId = await catalogService.getStremioId(item.type, tmdbId);
if (stremioId) {
navigation.dispatch(
StackActions.push('Metadata', {
id: stremioId,
type: item.type
StackActions.push('Metadata', {
id: stremioId,
type: item.type
})
);
} else {
@ -110,19 +114,19 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
if (__DEV__) console.error('Error navigating to recommendation:', error);
setAlertTitle('Error');
setAlertMessage('Unable to load this content. Please try again later.');
setAlertActions([{ label: 'OK', onPress: () => {} }]);
setAlertActions([{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true);
}
};
const renderItem = ({ item }: { item: StreamingContent }) => (
<TouchableOpacity
<TouchableOpacity
style={[styles.itemContainer, { width: posterWidth, marginRight: itemSpacing }]}
onPress={() => handleItemPress(item)}
>
<FastImage
source={{ uri: item.poster }}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, width: posterWidth, height: posterHeight, borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8 }]}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, width: posterWidth, height: posterHeight, borderRadius }]}
resizeMode={FastImage.resizeMode.cover}
/>
<Text style={[styles.title, { color: currentTheme.colors.mediumEmphasis, fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13, lineHeight: isTV ? 20 : 18 }]} numberOfLines={2}>
@ -144,7 +148,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
}
return (
<View style={[styles.container, { paddingLeft: 0 }] }>
<View style={[styles.container, { paddingLeft: 0 }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>More Like This</Text>
<FlatList
data={recommendations}
@ -183,10 +187,17 @@ const styles = StyleSheet.create({
marginRight: 12, // will be overridden responsively
},
poster: {
borderRadius: 8, // overridden responsively
borderRadius: 12,
marginBottom: 8,
borderWidth: 1,
// Consistent border styling matching ContentItem
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
// Consistent shadow/elevation
elevation: Platform.OS === 'android' ? 1 : 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
},
title: {
fontSize: 13, // overridden responsively

View file

@ -103,8 +103,10 @@ const DownloadItemComponent: React.FC<{
onRequestRemove: (item: DownloadItem) => void;
}> = React.memo(({ item, onPress, onAction, onRequestRemove }) => {
const { currentTheme } = useTheme();
const { settings } = useSettings();
const { showSuccess, showInfo } = useToast();
const [posterUrl, setPosterUrl] = useState<string | null>(item.posterUrl || null);
const borderRadius = settings.posterBorderRadius ?? 12;
// Try to fetch poster if not available
useEffect(() => {
@ -212,10 +214,10 @@ const DownloadItemComponent: React.FC<{
activeOpacity={0.8}
>
{/* Poster */}
<View style={styles.posterContainer}>
<View style={[styles.posterContainer, { borderRadius }]}>
<FastImage
source={{ uri: optimizePosterUrl(posterUrl) }}
style={styles.poster}
style={[styles.poster, { borderRadius }]}
resizeMode={FastImage.resizeMode.cover}
/>
{/* Status indicator overlay */}
@ -790,16 +792,25 @@ const styles = StyleSheet.create({
posterContainer: {
width: POSTER_WIDTH,
height: POSTER_HEIGHT,
borderRadius: 8,
borderRadius: 12,
marginRight: isTablet ? 20 : 16,
position: 'relative',
overflow: 'hidden',
backgroundColor: '#333',
// Consistent border styling matching ContentItem
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
// Consistent shadow/elevation
elevation: Platform.OS === 'android' ? 1 : 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
},
poster: {
width: '100%',
height: '100%',
borderRadius: 8,
borderRadius: 12,
},
statusOverlay: {
position: 'absolute',

View file

@ -405,10 +405,10 @@ const LibraryScreen = () => {
activeOpacity={0.7}
>
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black, borderRadius: settings.posterBorderRadius ?? 12 }]}>
<FastImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
style={[styles.poster, { borderRadius: settings.posterBorderRadius ?? 12 }]}
resizeMode={FastImage.resizeMode.cover}
/>
{item.watched && (
@ -1063,11 +1063,14 @@ const styles = StyleSheet.create({
overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.03)',
aspectRatio: 2 / 3,
elevation: 5,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
borderWidth: 1,
// Consistent shadow/elevation matching ContentItem
elevation: Platform.OS === 'android' ? 1 : 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
// Consistent border styling
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
},
poster: {

View file

@ -1014,16 +1014,14 @@ const SearchScreen = () => {
>
<View style={[styles.horizontalItemPosterContainer, {
width: itemWidth,
height: undefined, // Let aspect ratio control height or keep fixed height with width?
// Actually, since we derived width from fixed height, we can keep height fixed or use aspect.
// Using aspect ratio is safer if baseHeight changes.
height: undefined, // Let aspect ratio control height
aspectRatio: aspectRatio,
backgroundColor: currentTheme.colors.darkBackground,
borderColor: 'rgba(255,255,255,0.05)'
borderRadius: settings.posterBorderRadius ?? 12,
}]}>
<FastImage
source={{ uri: item.poster || PLACEHOLDER_POSTER }}
style={styles.horizontalItemPoster}
style={[styles.horizontalItemPoster, { borderRadius: settings.posterBorderRadius ?? 12 }]}
resizeMode={FastImage.resizeMode.cover}
/>
{/* Bookmark and watched icons top right, bookmark to the left of watched */}
@ -1723,10 +1721,17 @@ const styles = StyleSheet.create({
horizontalItemPosterContainer: {
width: HORIZONTAL_ITEM_WIDTH,
height: HORIZONTAL_POSTER_HEIGHT,
borderRadius: 16,
borderRadius: 12,
overflow: 'hidden',
marginBottom: 8,
borderWidth: 1,
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
// Consistent shadow/elevation matching ContentItem
elevation: Platform.OS === 'android' ? 1 : 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
},
horizontalItemPoster: {
width: '100%',