mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-25 18:42:53 +00:00
improved tablet alyout
This commit is contained in:
parent
575289f654
commit
d553be3ec5
4 changed files with 176 additions and 5 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -176,6 +176,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
||||||
opacity: overlayOpacity.value,
|
opacity: overlayOpacity.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Stable hero height for tablets to prevent layout jumps; keep hooks unconditional
|
||||||
|
const tabletHeroHeight = useMemo(() => {
|
||||||
|
const aspectBased = width * 0.56; // ~16:9 visual
|
||||||
|
const screenBased = height * 0.62;
|
||||||
|
return Math.min(screenBased, aspectBased);
|
||||||
|
}, [width, height, featuredContent?.id]);
|
||||||
|
|
||||||
// Preload the image
|
// Preload the image
|
||||||
const preloadImage = async (url: string): Promise<boolean> => {
|
const preloadImage = async (url: string): Promise<boolean> => {
|
||||||
// Skip if already cached to prevent redundant prefetch
|
// Skip if already cached to prevent redundant prefetch
|
||||||
|
|
@ -472,7 +479,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeIn.duration(400).easing(Easing.out(Easing.cubic))}
|
entering={FadeIn.duration(400).easing(Easing.out(Easing.cubic))}
|
||||||
style={[styles.tabletContainer as ViewStyle]}
|
style={[styles.tabletContainer as ViewStyle, { height: tabletHeroHeight }]}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
activeOpacity={0.95}
|
activeOpacity={0.95}
|
||||||
|
|
|
||||||
28
src/contexts/HeaderVisibility.ts
Normal file
28
src/contexts/HeaderVisibility.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
type Listener = (hidden: boolean) => void;
|
||||||
|
|
||||||
|
let currentHidden = false;
|
||||||
|
const listeners: Listener[] = [];
|
||||||
|
|
||||||
|
export const HeaderVisibility = {
|
||||||
|
setHidden(hidden: boolean) {
|
||||||
|
if (currentHidden === hidden) return;
|
||||||
|
currentHidden = hidden;
|
||||||
|
listeners.slice().forEach(l => {
|
||||||
|
try { l(currentHidden); } catch {}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
subscribe(listener: Listener) {
|
||||||
|
listeners.push(listener);
|
||||||
|
// Immediate call to sync initial state
|
||||||
|
try { listener(currentHidden); } catch {}
|
||||||
|
return () => {
|
||||||
|
const idx = listeners.indexOf(listener);
|
||||||
|
if (idx >= 0) listeners.splice(idx, 1);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
isHidden() {
|
||||||
|
return currentHidden;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
||||||
import { NavigationContainer, DefaultTheme as NavigationDefaultTheme, DarkTheme as NavigationDarkTheme, Theme, NavigationProp } from '@react-navigation/native';
|
import { NavigationContainer, DefaultTheme as NavigationDefaultTheme, DarkTheme as NavigationDarkTheme, Theme, NavigationProp } from '@react-navigation/native';
|
||||||
import { createNativeStackNavigator, NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator, NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||||
import { useColorScheme, Platform, Animated, StatusBar, TouchableOpacity, View, Text, AppState, Easing } from 'react-native';
|
import { useColorScheme, Platform, Animated, StatusBar, TouchableOpacity, View, Text, AppState, Easing, Dimensions } from 'react-native';
|
||||||
import { PaperProvider, MD3DarkTheme, MD3LightTheme, adaptNavigationTheme } from 'react-native-paper';
|
import { PaperProvider, MD3DarkTheme, MD3LightTheme, adaptNavigationTheme } from 'react-native-paper';
|
||||||
import type { MD3Theme } from 'react-native-paper';
|
import type { MD3Theme } from 'react-native-paper';
|
||||||
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
||||||
|
|
@ -10,8 +10,9 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { colors } from '../styles/colors';
|
import { colors } from '../styles/colors';
|
||||||
|
import { HeaderVisibility } from '../contexts/HeaderVisibility';
|
||||||
import { Stream } from '../types/streams';
|
import { Stream } from '../types/streams';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { Toasts } from '@backpackapp-io/react-native-toast';
|
import { Toasts } from '@backpackapp-io/react-native-toast';
|
||||||
import { PostHogProvider } from 'posthog-react-native';
|
import { PostHogProvider } from 'posthog-react-native';
|
||||||
|
|
@ -356,6 +357,8 @@ const TabIcon = React.memo(({ focused, color, iconName }: {
|
||||||
|
|
||||||
// Update the TabScreenWrapper component with fixed layout dimensions
|
// Update the TabScreenWrapper component with fixed layout dimensions
|
||||||
const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) => {
|
const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) => {
|
||||||
|
const isTablet = Dimensions.get('window').width >= 768;
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
// Force consistent status bar settings
|
// Force consistent status bar settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const applyStatusBarConfig = () => {
|
const applyStatusBarConfig = () => {
|
||||||
|
|
@ -390,7 +393,7 @@ const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) =
|
||||||
}}>
|
}}>
|
||||||
{/* Reserve consistent space for the header area on all screens */}
|
{/* Reserve consistent space for the header area on all screens */}
|
||||||
<View style={{
|
<View style={{
|
||||||
height: Platform.OS === 'android' ? 80 : 60,
|
height: isTablet ? (insets.top + 64) : (Platform.OS === 'android' ? 80 : 60),
|
||||||
width: '100%',
|
width: '100%',
|
||||||
backgroundColor: colors.darkBackground,
|
backgroundColor: colors.darkBackground,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
@ -416,8 +419,116 @@ const WrappedScreen: React.FC<{Screen: React.ComponentType<any>}> = ({ Screen })
|
||||||
// Tab Navigator
|
// Tab Navigator
|
||||||
const MainTabs = () => {
|
const MainTabs = () => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
const isTablet = Dimensions.get('window').width >= 768;
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const isIosTablet = Platform.OS === 'ios' && isTablet;
|
||||||
|
const [hidden, setHidden] = React.useState(HeaderVisibility.isHidden());
|
||||||
|
React.useEffect(() => HeaderVisibility.subscribe(setHidden), []);
|
||||||
|
// Smooth animate header hide/show
|
||||||
|
const headerAnim = React.useRef(new Animated.Value(0)).current; // 0: shown, 1: hidden
|
||||||
|
React.useEffect(() => {
|
||||||
|
Animated.timing(headerAnim, {
|
||||||
|
toValue: hidden ? 1 : 0,
|
||||||
|
duration: 220,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}, [hidden, headerAnim]);
|
||||||
|
const translateY = headerAnim.interpolate({ inputRange: [0, 1], outputRange: [0, -70] });
|
||||||
|
const fade = headerAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0] });
|
||||||
|
|
||||||
const renderTabBar = (props: BottomTabBarProps) => {
|
const renderTabBar = (props: BottomTabBarProps) => {
|
||||||
|
if (isTablet) {
|
||||||
|
// Top floating, text-only pill nav for tablets
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[{
|
||||||
|
position: 'absolute',
|
||||||
|
top: insets.top + 12,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
zIndex: 100,
|
||||||
|
}, {
|
||||||
|
transform: [{ translateY }],
|
||||||
|
opacity: fade,
|
||||||
|
}]}>
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 28,
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: 4,
|
||||||
|
position: 'relative',
|
||||||
|
backgroundColor: isIosTablet ? 'transparent' : 'rgba(0,0,0,0.7)'
|
||||||
|
}}>
|
||||||
|
{isIosTablet && (
|
||||||
|
<BlurView
|
||||||
|
tint="dark"
|
||||||
|
intensity={75}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
borderRadius: 28,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{props.state.routes.map((route, index) => {
|
||||||
|
const { options } = props.descriptors[route.key];
|
||||||
|
const label =
|
||||||
|
options.tabBarLabel !== undefined
|
||||||
|
? options.tabBarLabel
|
||||||
|
: options.title !== undefined
|
||||||
|
? options.title
|
||||||
|
: route.name;
|
||||||
|
|
||||||
|
const isFocused = props.state.index === index;
|
||||||
|
|
||||||
|
const onPress = () => {
|
||||||
|
const event = props.navigation.emit({
|
||||||
|
type: 'tabPress',
|
||||||
|
target: route.key,
|
||||||
|
canPreventDefault: true,
|
||||||
|
});
|
||||||
|
if (!isFocused && !event.defaultPrevented) {
|
||||||
|
props.navigation.navigate(route.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={route.key}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onPress={onPress}
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
marginHorizontal: 2,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: isFocused ? 'rgba(255,255,255,0.12)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{
|
||||||
|
color: isFocused ? currentTheme.colors.primary : currentTheme.colors.white,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
}}>
|
||||||
|
{typeof label === 'string' ? label : ''}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default bottom tab for phones
|
||||||
return (
|
return (
|
||||||
<View style={{
|
<View style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { toast, ToastPosition } from '@backpackapp-io/react-native-toast';
|
import { toast, ToastPosition } from '@backpackapp-io/react-native-toast';
|
||||||
import FirstTimeWelcome from '../components/FirstTimeWelcome';
|
import FirstTimeWelcome from '../components/FirstTimeWelcome';
|
||||||
import { imageCacheService } from '../services/imageCacheService';
|
import { imageCacheService } from '../services/imageCacheService';
|
||||||
|
import { HeaderVisibility } from '../contexts/HeaderVisibility';
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
||||||
|
|
@ -609,6 +610,15 @@ const HomeScreen = () => {
|
||||||
|
|
||||||
const memoizedThisWeekSection = useMemo(() => <ThisWeekSection />, []);
|
const memoizedThisWeekSection = useMemo(() => <ThisWeekSection />, []);
|
||||||
const memoizedContinueWatchingSection = useMemo(() => <ContinueWatchingSection ref={continueWatchingRef} />, []);
|
const memoizedContinueWatchingSection = useMemo(() => <ContinueWatchingSection ref={continueWatchingRef} />, []);
|
||||||
|
// Track scroll direction manually for reliable behavior across platforms
|
||||||
|
const lastScrollYRef = useRef(0);
|
||||||
|
const lastToggleRef = useRef(0);
|
||||||
|
const toggleHeader = useCallback((hide: boolean) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastToggleRef.current < 120) return; // debounce
|
||||||
|
lastToggleRef.current = now;
|
||||||
|
HeaderVisibility.setHidden(hide);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const renderListItem = useCallback(({ item, index }: { item: HomeScreenListItem, index: number }) => {
|
const renderListItem = useCallback(({ item, index }: { item: HomeScreenListItem, index: number }) => {
|
||||||
const wrapper = (child: React.ReactNode) => (
|
const wrapper = (child: React.ReactNode) => (
|
||||||
|
|
@ -724,6 +734,21 @@ const HomeScreen = () => {
|
||||||
onEndReached={handleLoadMoreCatalogs}
|
onEndReached={handleLoadMoreCatalogs}
|
||||||
onEndReachedThreshold={0.6}
|
onEndReachedThreshold={0.6}
|
||||||
scrollEventThrottle={32}
|
scrollEventThrottle={32}
|
||||||
|
onScroll={event => {
|
||||||
|
const y = event.nativeEvent.contentOffset.y;
|
||||||
|
const dy = y - lastScrollYRef.current;
|
||||||
|
lastScrollYRef.current = y;
|
||||||
|
if (y <= 10) {
|
||||||
|
toggleHeader(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Threshold to avoid jitter
|
||||||
|
if (dy > 6) {
|
||||||
|
toggleHeader(true); // scrolling down
|
||||||
|
} else if (dy < -6) {
|
||||||
|
toggleHeader(false); // scrolling up
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Toasts are rendered globally at root */}
|
{/* Toasts are rendered globally at root */}
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue