feat: added new poster like layout for continue watching card

This commit is contained in:
tapframe 2026-01-05 17:54:17 +05:30
parent ab7f008bbb
commit edeb6ebe3c
3 changed files with 448 additions and 83 deletions

View file

@ -1002,8 +1002,128 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
setAlertVisible(true);
}, [currentTheme.colors.error]);
// Memoized render function for continue watching items
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
// Compute poster dimensions for poster-style cards
const computedPosterWidth = useMemo(() => {
switch (deviceType) {
case 'tv':
return 180;
case 'largeTablet':
return 160;
case 'tablet':
return 140;
default:
return 120;
}
}, [deviceType]);
const computedPosterHeight = useMemo(() => {
return computedPosterWidth * 1.5; // 2:3 aspect ratio
}, [computedPosterWidth]);
// Memoized render function for poster-style continue watching items
const renderPosterStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
<TouchableOpacity
style={[
styles.posterContentItem,
{
width: computedPosterWidth,
}
]}
activeOpacity={0.8}
onPress={() => handleContentPress(item)}
onLongPress={() => handleLongPress(item)}
delayLongPress={800}
>
{/* Poster Image */}
<View style={[
styles.posterImageContainer,
{
height: computedPosterHeight,
borderRadius: settings.posterBorderRadius ?? 12,
}
]}>
<FastImage
source={{
uri: item.poster || 'https://via.placeholder.com/300x450',
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable
}}
style={[styles.posterImage, { borderRadius: settings.posterBorderRadius ?? 12 }]}
resizeMode={FastImage.resizeMode.cover}
/>
{/* Gradient overlay */}
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.8)']}
style={[styles.posterGradient, { borderRadius: settings.posterBorderRadius ?? 12 }]}
/>
{/* Episode Info Overlay */}
{item.type === 'series' && item.season && item.episode && (
<View style={styles.posterEpisodeOverlay}>
<Text style={[styles.posterEpisodeText, { fontSize: isTV ? 14 : isLargeTablet ? 13 : 12 }]}>
S{item.season} E{item.episode}
</Text>
</View>
)}
{/* Up Next Badge */}
{item.type === 'series' && item.progress === 0 && (
<View style={[styles.posterUpNextBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={[styles.posterUpNextText, { fontSize: isTV ? 12 : 10 }]}>UP NEXT</Text>
</View>
)}
{/* Progress Bar */}
{item.progress > 0 && (
<View style={styles.posterProgressContainer}>
<View style={[styles.posterProgressTrack, { backgroundColor: 'rgba(255,255,255,0.3)' }]}>
<View
style={[
styles.posterProgressBar,
{
width: `${item.progress}%`,
backgroundColor: currentTheme.colors.primary
}
]}
/>
</View>
</View>
)}
{/* Delete Indicator Overlay */}
{deletingItemId === item.id && (
<View style={[styles.deletingOverlay, { borderRadius: settings.posterBorderRadius ?? 12 }]}>
<ActivityIndicator size="large" color="#FFFFFF" />
</View>
)}
</View>
{/* Title below poster */}
<View style={styles.posterTitleContainer}>
<Text
style={[
styles.posterTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : 14
}
]}
numberOfLines={2}
>
{item.name}
</Text>
{item.progress > 0 && (
<Text style={[styles.posterProgressLabel, { color: currentTheme.colors.textMuted, fontSize: isTV ? 13 : 11 }]}>
{Math.round(item.progress)}%
</Text>
)}
</View>
</TouchableOpacity>
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedPosterWidth, computedPosterHeight, isTV, isLargeTablet, settings.posterBorderRadius]);
// Memoized render function for wide-style continue watching items
const renderWideStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
<TouchableOpacity
style={[
styles.wideContentItem,
@ -1165,7 +1285,15 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
)}
</View>
</TouchableOpacity>
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet]);
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet, settings.posterBorderRadius]);
// Choose the appropriate render function based on settings
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => {
if (settings.continueWatchingCardStyle === 'poster') {
return renderPosterStyleItem({ item });
}
return renderWideStyleItem({ item });
}, [settings.continueWatchingCardStyle, renderPosterStyleItem, renderWideStyleItem]);
// Memoized key extractor
const keyExtractor = useCallback((item: ContinueWatchingItem) => `continue-${item.id}-${item.type}`, []);
@ -1421,6 +1549,87 @@ const styles = StyleSheet.create({
progressBar: {
height: '100%',
},
// Poster-style card styles
posterContentItem: {
overflow: 'visible',
},
posterImageContainer: {
width: '100%',
overflow: 'hidden',
position: 'relative',
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
elevation: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
},
posterImage: {
width: '100%',
height: '100%',
},
posterGradient: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '50%',
},
posterEpisodeOverlay: {
position: 'absolute',
bottom: 8,
left: 8,
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
},
posterEpisodeText: {
color: '#FFFFFF',
fontWeight: '600',
},
posterUpNextBadge: {
position: 'absolute',
top: 8,
right: 8,
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
},
posterUpNextText: {
color: '#FFFFFF',
fontWeight: '700',
letterSpacing: 0.5,
},
posterProgressContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
posterProgressTrack: {
height: 4,
},
posterProgressBar: {
height: '100%',
},
posterTitleContainer: {
paddingHorizontal: 4,
paddingVertical: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
posterTitle: {
fontWeight: '600',
flex: 1,
lineHeight: 18,
},
posterProgressLabel: {
fontWeight: '500',
marginLeft: 6,
},
});
export default React.memo(ContinueWatchingSection, (prevProps, nextProps) => {

View file

@ -100,6 +100,7 @@ export interface AppSettings {
useCachedStreams: boolean; // Enable/disable direct player navigation from Continue Watching cache
openMetadataScreenWhenCacheDisabled: boolean; // When cache disabled, open MetadataScreen instead of StreamsScreen
streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour)
continueWatchingCardStyle: 'wide' | 'poster'; // Card style: 'wide' (horizontal) or 'poster' (vertical)
enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile
useExternalPlayerForDownloads: boolean; // Enable/disable external player for downloaded content
// Android MPV player settings
@ -186,6 +187,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
useCachedStreams: false, // Enable by default
openMetadataScreenWhenCacheDisabled: true, // Default to StreamsScreen when cache disabled
streamCacheTTL: 60 * 60 * 1000, // Default: 1 hour in milliseconds
continueWatchingCardStyle: 'wide', // Default to wide (horizontal) card style
enableStreamsBackdrop: true, // Enable by default (new behavior)
// Android MPV player settings
videoPlayerEngine: 'auto', // Default to auto (ExoPlayer primary, MPV fallback)

View file

@ -53,7 +53,7 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
if (Platform.OS === 'ios') {
StatusBar.setHidden(false);
}
} catch {}
} catch { }
}, [colors.darkBackground]);
const handleBack = useCallback(() => {
@ -97,22 +97,22 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
/>
);
const SettingItem = ({
title,
description,
value,
onValueChange,
isLast = false
}: {
title: string;
description: string;
value: boolean;
const SettingItem = ({
title,
description,
value,
onValueChange,
isLast = false
}: {
title: string;
description: string;
value: boolean;
onValueChange: (value: boolean) => void;
isLast?: boolean;
}) => (
<View style={[
styles.settingItem,
{
{
borderBottomColor: isLast ? 'transparent' : colors.border,
}
]}>
@ -159,10 +159,10 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
<TouchableOpacity
style={styles.backButton}
onPress={handleBack}
>
@ -170,13 +170,13 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>
Continue Watching
</Text>
{/* Content */}
<ScrollView
<ScrollView
style={styles.content}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
@ -184,22 +184,98 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
<View style={styles.section}>
<Text style={styles.sectionTitle}>PLAYBACK BEHAVIOR</Text>
<View style={styles.settingsCard}>
<SettingItem
title="Use Cached Streams"
description="When enabled, clicking Continue Watching items will open the player directly using previously played streams. When disabled, opens a content screen instead."
value={settings.useCachedStreams}
onValueChange={(value) => handleUpdateSetting('useCachedStreams', value)}
isLast={!settings.useCachedStreams}
/>
{!settings.useCachedStreams && (
<SettingItem
title="Open Metadata Screen"
description="When cached streams are disabled, open the Metadata screen instead of the Streams screen. This shows content details and allows manual stream selection."
value={settings.openMetadataScreenWhenCacheDisabled}
onValueChange={(value) => handleUpdateSetting('openMetadataScreenWhenCacheDisabled', value)}
isLast={true}
title="Use Cached Streams"
description="When enabled, clicking Continue Watching items will open the player directly using previously played streams. When disabled, opens a content screen instead."
value={settings.useCachedStreams}
onValueChange={(value) => handleUpdateSetting('useCachedStreams', value)}
isLast={!settings.useCachedStreams}
/>
)}
{!settings.useCachedStreams && (
<SettingItem
title="Open Metadata Screen"
description="When cached streams are disabled, open the Metadata screen instead of the Streams screen. This shows content details and allows manual stream selection."
value={settings.openMetadataScreenWhenCacheDisabled}
onValueChange={(value) => handleUpdateSetting('openMetadataScreenWhenCacheDisabled', value)}
isLast={true}
/>
)}
</View>
</View>
{/* Card Appearance Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>CARD APPEARANCE</Text>
<View style={styles.settingsCard}>
<View style={[styles.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}>
<Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}>
Card Style
</Text>
<Text style={[styles.settingDescription, { color: colors.mediumEmphasis, marginBottom: 16 }]}>
Choose how Continue Watching items appear on the home screen
</Text>
<View style={styles.cardStyleOptionsContainer}>
<TouchableOpacity
style={[
styles.cardStyleOption,
{
backgroundColor: settings.continueWatchingCardStyle === 'wide' ? colors.primary : colors.elevation1,
borderColor: settings.continueWatchingCardStyle === 'wide' ? colors.primary : colors.border,
}
]}
onPress={() => handleUpdateSetting('continueWatchingCardStyle', 'wide')}
activeOpacity={0.7}
>
<View style={styles.cardPreviewWide}>
<View style={[styles.cardPreviewImage, { backgroundColor: colors.mediumGray }]} />
<View style={styles.cardPreviewContent}>
<View style={[styles.cardPreviewLine, { backgroundColor: colors.highEmphasis, width: '70%' }]} />
<View style={[styles.cardPreviewLine, { backgroundColor: colors.mediumEmphasis, width: '50%', height: 6 }]} />
<View style={[styles.cardPreviewProgress, { backgroundColor: colors.elevation2 }]}>
<View style={[styles.cardPreviewProgressFill, { backgroundColor: colors.primary, width: '60%' }]} />
</View>
</View>
</View>
<Text style={[
styles.cardStyleLabel,
{ color: settings.continueWatchingCardStyle === 'wide' ? colors.white : colors.highEmphasis }
]}>
Wide
</Text>
{settings.continueWatchingCardStyle === 'wide' && (
<MaterialIcons name="check-circle" size={18} color={colors.white} style={styles.cardStyleCheck} />
)}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.cardStyleOption,
{
backgroundColor: settings.continueWatchingCardStyle === 'poster' ? colors.primary : colors.elevation1,
borderColor: settings.continueWatchingCardStyle === 'poster' ? colors.primary : colors.border,
}
]}
onPress={() => handleUpdateSetting('continueWatchingCardStyle', 'poster')}
activeOpacity={0.7}
>
<View style={styles.cardPreviewPoster}>
<View style={[styles.cardPreviewPosterImage, { backgroundColor: colors.mediumGray }]} />
<View style={[styles.cardPreviewPosterProgress, { backgroundColor: colors.elevation2 }]}>
<View style={[styles.cardPreviewProgressFill, { backgroundColor: colors.primary, width: '45%' }]} />
</View>
</View>
<Text style={[
styles.cardStyleLabel,
{ color: settings.continueWatchingCardStyle === 'poster' ? colors.white : colors.highEmphasis }
]}>
Poster
</Text>
{settings.continueWatchingCardStyle === 'poster' && (
<MaterialIcons name="check-circle" size={18} color={colors.white} style={styles.cardStyleCheck} />
)}
</TouchableOpacity>
</View>
</View>
</View>
</View>
@ -207,80 +283,80 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
<View style={styles.section}>
<Text style={styles.sectionTitle}>CACHE SETTINGS</Text>
<View style={styles.settingsCard}>
<View style={[styles.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}>
<Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}>
Stream Cache Duration
</Text>
<Text style={[styles.settingDescription, { color: colors.mediumEmphasis, marginBottom: 16 }]}>
How long to keep cached stream links before they expire
</Text>
<View style={styles.ttlOptionsContainer}>
{TTL_OPTIONS.map((row, rowIndex) => (
<View key={rowIndex} style={styles.ttlRow}>
{row.map((option) => (
<TTLPickerItem key={option.value} option={option} />
))}
</View>
))}
<View style={[styles.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}>
<Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}>
Stream Cache Duration
</Text>
<Text style={[styles.settingDescription, { color: colors.mediumEmphasis, marginBottom: 16 }]}>
How long to keep cached stream links before they expire
</Text>
<View style={styles.ttlOptionsContainer}>
{TTL_OPTIONS.map((row, rowIndex) => (
<View key={rowIndex} style={styles.ttlRow}>
{row.map((option) => (
<TTLPickerItem key={option.value} option={option} />
))}
</View>
))}
</View>
</View>
</View>
</View>
</View>
)}
{settings.useCachedStreams && (
<View style={styles.section}>
<View style={[styles.warningCard, { borderColor: colors.warning }]}>
<View style={styles.warningHeader}>
<MaterialIcons name="warning" size={20} color={colors.warning} />
<Text style={[styles.warningTitle, { color: colors.warning }]}>
Important Note
<View style={styles.warningHeader}>
<MaterialIcons name="warning" size={20} color={colors.warning} />
<Text style={[styles.warningTitle, { color: colors.warning }]}>
Important Note
</Text>
</View>
<Text style={[styles.warningText, { color: colors.mediumEmphasis }]}>
Not all stream links may remain active for the full cache duration. Longer cache times may result in expired links. If a cached link fails, the app will fall back to fetching fresh streams.
</Text>
</View>
<Text style={[styles.warningText, { color: colors.mediumEmphasis }]}>
Not all stream links may remain active for the full cache duration. Longer cache times may result in expired links. If a cached link fails, the app will fall back to fetching fresh streams.
</Text>
</View>
</View>
)}
<View style={styles.section}>
<View style={styles.infoCard}>
<View style={styles.infoHeader}>
<MaterialIcons name="info" size={20} color={colors.primary} />
<Text style={[styles.infoTitle, { color: colors.highEmphasis }]}>
How it works
<View style={styles.infoHeader}>
<MaterialIcons name="info" size={20} color={colors.primary} />
<Text style={[styles.infoTitle, { color: colors.highEmphasis }]}>
How it works
</Text>
</View>
<Text style={[styles.infoText, { color: colors.mediumEmphasis }]}>
{settings.useCachedStreams ? (
<>
Streams are cached for your selected duration after playing{'\n'}
Cached streams are validated before use{'\n'}
If cache is invalid or expired, falls back to content screen{'\n'}
"Use Cached Streams" controls direct player vs screen navigation{'\n'}
"Open Metadata Screen" appears only when cached streams are disabled
</>
) : (
<>
When cached streams are disabled, clicking Continue Watching items opens content screens{'\n'}
"Open Metadata Screen" option controls which screen to open{'\n'}
Metadata screen shows content details and allows manual stream selection{'\n'}
Streams screen shows available streams for immediate playback
</>
)}
</Text>
</View>
<Text style={[styles.infoText, { color: colors.mediumEmphasis }]}>
{settings.useCachedStreams ? (
<>
Streams are cached for your selected duration after playing{'\n'}
Cached streams are validated before use{'\n'}
If cache is invalid or expired, falls back to content screen{'\n'}
"Use Cached Streams" controls direct player vs screen navigation{'\n'}
"Open Metadata Screen" appears only when cached streams are disabled
</>
) : (
<>
When cached streams are disabled, clicking Continue Watching items opens content screens{'\n'}
"Open Metadata Screen" option controls which screen to open{'\n'}
Metadata screen shows content details and allows manual stream selection{'\n'}
Streams screen shows available streams for immediate playback
</>
)}
</Text>
</View>
</View>
</ScrollView>
{/* Saved indicator */}
<Animated.View
<Animated.View
style={[
styles.savedIndicator,
{
{
backgroundColor: colors.primary,
opacity: fadeAnim
opacity: fadeAnim
}
]}
>
@ -466,6 +542,84 @@ const createStyles = (colors: any) => StyleSheet.create({
fontSize: 14,
lineHeight: 20,
},
// Card Style Selector Styles
cardStyleOptionsContainer: {
flexDirection: 'row',
width: '100%',
gap: 12,
},
cardStyleOption: {
flex: 1,
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
position: 'relative',
},
cardPreviewWide: {
flexDirection: 'row',
width: 100,
height: 60,
borderRadius: 6,
overflow: 'hidden',
marginBottom: 8,
alignSelf: 'center',
},
cardPreviewImage: {
width: 40,
height: '100%',
borderTopLeftRadius: 6,
borderBottomLeftRadius: 6,
},
cardPreviewContent: {
flex: 1,
padding: 4,
justifyContent: 'space-between',
},
cardPreviewLine: {
height: 8,
borderRadius: 2,
},
cardPreviewProgress: {
height: 4,
borderRadius: 2,
width: '100%',
},
cardPreviewProgressFill: {
height: '100%',
borderRadius: 2,
},
cardPreviewPoster: {
width: 44,
height: 60,
borderRadius: 6,
overflow: 'hidden',
marginBottom: 8,
position: 'relative',
},
cardPreviewPosterImage: {
width: '100%',
height: '100%',
borderRadius: 6,
},
cardPreviewPosterProgress: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 4,
},
cardStyleLabel: {
fontSize: 14,
fontWeight: '600',
marginTop: 4,
},
cardStyleCheck: {
position: 'absolute',
top: 8,
right: 8,
},
});
export default ContinueWatchingSettingsScreen;