mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-25 02:23:11 +00:00
ui changes on new herocarousal
This commit is contained in:
parent
de36ec8186
commit
8700b10843
3 changed files with 192 additions and 21 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, Dimensions, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, FlatList, StyleProp } from 'react-native';
|
import { View, Text, StyleSheet, Dimensions, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, FlatList, StyleProp } from 'react-native';
|
||||||
import Animated, { FadeIn, Easing } from 'react-native-reanimated';
|
import Animated, { FadeIn, FadeOut, Easing } from 'react-native-reanimated';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Image as ExpoImage } from 'expo-image';
|
import { Image as ExpoImage } from 'expo-image';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
|
@ -9,29 +9,115 @@ import { NavigationProp } from '@react-navigation/native';
|
||||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
import { StreamingContent } from '../../services/catalogService';
|
import { StreamingContent } from '../../services/catalogService';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
interface HeroCarouselProps {
|
interface HeroCarouselProps {
|
||||||
items: StreamingContent[];
|
items: StreamingContent[];
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
const CARD_WIDTH = Math.min(width * 0.88, 520);
|
const CARD_WIDTH = Math.min(width * 0.8, 480);
|
||||||
const CARD_HEIGHT = Math.round(CARD_WIDTH * 9 / 16) + 160; // increased extra space for text/actions
|
const CARD_HEIGHT = Math.round(CARD_WIDTH * 9 / 16) + 270; // further increased space for text/actions
|
||||||
|
|
||||||
const HeroCarousel: React.FC<HeroCarouselProps> = ({ items }) => {
|
const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) => {
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]);
|
const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (loading) {
|
||||||
return null;
|
return (
|
||||||
|
<View style={[styles.container, { paddingVertical: 12 }] as StyleProp<ViewStyle>}>
|
||||||
|
<View style={{ height: CARD_HEIGHT }}>
|
||||||
|
<FlatList
|
||||||
|
data={[1, 2, 3] as any}
|
||||||
|
keyExtractor={(i) => String(i)}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
snapToInterval={CARD_WIDTH + 16}
|
||||||
|
decelerationRate="fast"
|
||||||
|
contentContainerStyle={{ paddingHorizontal: (width - CARD_WIDTH) / 2 }}
|
||||||
|
renderItem={() => (
|
||||||
|
<View style={{ width: CARD_WIDTH + 16 }}>
|
||||||
|
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }] as StyleProp<ViewStyle>}>
|
||||||
|
<View style={styles.bannerContainer as ViewStyle}>
|
||||||
|
<View style={styles.skeletonBannerFull as ViewStyle} />
|
||||||
|
<LinearGradient
|
||||||
|
colors={["transparent", "rgba(0,0,0,0.25)"]}
|
||||||
|
locations={[0.6, 1]}
|
||||||
|
style={styles.bannerOverlay as ViewStyle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.info as ViewStyle}>
|
||||||
|
<View style={[styles.skeletonLine, { width: '62%' }] as StyleProp<ViewStyle>} />
|
||||||
|
<View style={[styles.skeletonLine, { width: '44%', marginTop: 6 }] as StyleProp<ViewStyle>} />
|
||||||
|
<View style={styles.skeletonActions as ViewStyle}>
|
||||||
|
<View style={[styles.skeletonPill, { width: 96 }] as StyleProp<ViewStyle>} />
|
||||||
|
<View style={[styles.skeletonPill, { width: 80 }] as StyleProp<ViewStyle>} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) return null;
|
||||||
|
|
||||||
|
const handleMomentumEnd = (event: any) => {
|
||||||
|
const offsetX = event?.nativeEvent?.contentOffset?.x ?? 0;
|
||||||
|
const interval = CARD_WIDTH + 16;
|
||||||
|
const idx = Math.round(offsetX / interval);
|
||||||
|
const clamped = Math.max(0, Math.min(idx, data.length - 1));
|
||||||
|
if (clamped !== activeIndex) setActiveIndex(clamped);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeIn.duration(350).easing(Easing.out(Easing.cubic))}>
|
<Animated.View entering={FadeIn.duration(350).easing(Easing.out(Easing.cubic))}>
|
||||||
<View style={styles.container as ViewStyle}>
|
<View style={styles.container as ViewStyle}>
|
||||||
|
{data[activeIndex] && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.backgroundContainer,
|
||||||
|
{ top: -insets.top },
|
||||||
|
] as StyleProp<ViewStyle>}
|
||||||
|
pointerEvents="none"
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
key={data[activeIndex].id}
|
||||||
|
entering={FadeIn.duration(300).easing(Easing.out(Easing.cubic))}
|
||||||
|
exiting={FadeOut.duration(300).easing(Easing.in(Easing.cubic))}
|
||||||
|
style={{ flex: 1 } as ViewStyle}
|
||||||
|
>
|
||||||
|
<ExpoImage
|
||||||
|
source={{ uri: data[activeIndex].banner || data[activeIndex].poster }}
|
||||||
|
style={styles.backgroundImage as ImageStyle}
|
||||||
|
contentFit="cover"
|
||||||
|
blurRadius={36}
|
||||||
|
cachePolicy="memory-disk"
|
||||||
|
/>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.65)", "rgba(0,0,0,0.85)"]}
|
||||||
|
locations={[0.4, 1]}
|
||||||
|
style={styles.backgroundOverlay as ViewStyle}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* Bottom blend to HomeScreen background (not the card) */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["transparent", currentTheme.colors.background]}
|
||||||
|
locations={[0, 1]}
|
||||||
|
style={styles.bottomBlend as ViewStyle}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data}
|
data={data}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
|
|
@ -40,6 +126,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items }) => {
|
||||||
snapToInterval={CARD_WIDTH + 16}
|
snapToInterval={CARD_WIDTH + 16}
|
||||||
decelerationRate="fast"
|
decelerationRate="fast"
|
||||||
contentContainerStyle={{ paddingHorizontal: (width - CARD_WIDTH) / 2 }}
|
contentContainerStyle={{ paddingHorizontal: (width - CARD_WIDTH) / 2 }}
|
||||||
|
onMomentumScrollEnd={handleMomentumEnd}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={{ width: CARD_WIDTH + 16 }}>
|
<View style={{ width: CARD_WIDTH + 16 }}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|
@ -56,8 +143,8 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items }) => {
|
||||||
cachePolicy="memory-disk"
|
cachePolicy="memory-disk"
|
||||||
/>
|
/>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={["transparent", "rgba(0,0,0,0.7)"]}
|
colors={["transparent", "rgba(0,0,0,0.35)"]}
|
||||||
locations={[0.55, 1]}
|
locations={[0.6, 1]}
|
||||||
style={styles.bannerGradient as ViewStyle}
|
style={styles.bannerGradient as ViewStyle}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -103,6 +190,34 @@ const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
},
|
},
|
||||||
|
backgroundContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
backgroundOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
bottomBlend: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: 160,
|
||||||
|
},
|
||||||
card: {
|
card: {
|
||||||
width: CARD_WIDTH,
|
width: CARD_WIDTH,
|
||||||
height: CARD_HEIGHT,
|
height: CARD_HEIGHT,
|
||||||
|
|
@ -114,6 +229,47 @@ const styles = StyleSheet.create({
|
||||||
shadowOpacity: 0.3,
|
shadowOpacity: 0.3,
|
||||||
shadowRadius: 12,
|
shadowRadius: 12,
|
||||||
},
|
},
|
||||||
|
skeletonCard: {
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
height: CARD_HEIGHT,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
skeletonBannerFull: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.06)'
|
||||||
|
},
|
||||||
|
bannerOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
skeletonInfo: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
skeletonLine: {
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 7,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.08)'
|
||||||
|
},
|
||||||
|
skeletonActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
skeletonPill: {
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.1)'
|
||||||
|
},
|
||||||
bannerContainer: {
|
bannerContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|
@ -132,6 +288,7 @@ const styles = StyleSheet.create({
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
info: {
|
info: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|
|
||||||
|
|
@ -312,18 +312,31 @@ export function useFeaturedContent() {
|
||||||
// Subscribe directly to settings emitter for immediate updates
|
// Subscribe directly to settings emitter for immediate updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSettingsChange = () => {
|
const handleSettingsChange = () => {
|
||||||
// Only refresh if current content source is different from settings
|
// Always reflect settings immediately in this hook
|
||||||
// This prevents duplicate refreshes when HomeScreen also handles this event
|
const nextSource = settings.featuredContentSource;
|
||||||
if (contentSource !== settings.featuredContentSource) {
|
const nextSelected = settings.selectedHeroCatalogs || [];
|
||||||
logger.info('[useFeaturedContent] event:content-source-changed', { from: contentSource, to: settings.featuredContentSource });
|
|
||||||
// Content source will be updated in the next render cycle due to state updates
|
const sourceChanged = contentSource !== nextSource;
|
||||||
// No need to call loadFeaturedContent here as it will be triggered by contentSource change
|
const catalogsChanged = JSON.stringify(selectedCatalogs) !== JSON.stringify(nextSelected);
|
||||||
} else if (
|
|
||||||
contentSource === 'catalogs' &&
|
if (sourceChanged || (nextSource === 'catalogs' && catalogsChanged)) {
|
||||||
JSON.stringify(selectedCatalogs) !== JSON.stringify(settings.selectedHeroCatalogs)
|
logger.info('[useFeaturedContent] event:settings-changed:immediate-refresh', {
|
||||||
) {
|
fromSource: contentSource,
|
||||||
// Only refresh if using catalogs and selected catalogs changed
|
toSource: nextSource,
|
||||||
logger.info('[useFeaturedContent] event:selected-catalogs-changed');
|
catalogsChanged
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update internal state immediately so dependent effects are in sync
|
||||||
|
setContentSource(nextSource);
|
||||||
|
setSelectedCatalogs(nextSelected);
|
||||||
|
|
||||||
|
// Clear current data to reflect change instantly in UI
|
||||||
|
setAllFeaturedContent([]);
|
||||||
|
setFeaturedContent(null);
|
||||||
|
persistentStore.allFeaturedContent = [];
|
||||||
|
persistentStore.featuredContent = null;
|
||||||
|
|
||||||
|
// Force a fresh load
|
||||||
loadFeaturedContent(true);
|
loadFeaturedContent(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -612,6 +612,7 @@ const HomeScreen = () => {
|
||||||
<HeroCarousel
|
<HeroCarousel
|
||||||
key={`carousel-${featuredContentSource}`}
|
key={`carousel-${featuredContentSource}`}
|
||||||
items={allFeaturedContent || (featuredContent ? [featuredContent] : [])}
|
items={allFeaturedContent || (featuredContent ? [featuredContent] : [])}
|
||||||
|
loading={featuredLoading}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FeaturedContent
|
<FeaturedContent
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue