From e27e3de14df4bce6a4f7af0ee3ddf61c8ed4d3a5 Mon Sep 17 00:00:00 2001 From: Chance Date: Mon, 9 Feb 2026 19:53:41 -0500 Subject: [PATCH] feat: improve landscape UI for tablets and phones - Unlock device rotation (orientation: 'default' in Expo config) - Add orientation/screenSize to Android configChanges to prevent activity recreation - Stabilize breakpoint detection using shorter dimension (min of width/height) so rotating a phone doesn't jump to tablet tier; TV always returns 'tv' via Platform.isTV - Add isLandscape to useResponsiveLayout and adjust grid columns per orientation - Scale hero and cover image heights proportionally in landscape - Add side-by-side details layout (poster left, info right) for landscape phones/tablets - Compact tab bar on phones in landscape to save vertical space - Include left/right safe area edges in landscape for notch/punch-hole handling - Constrain tablet landscape content width in ResponsiveLayout sidebar mode - Replace static Dimensions.get('window') with reactive useWindowDimensions in MediaDetailsHeader --- app.config.ts | 2 +- plugins/withAndroidConfigChanges.ts | 6 +- src/app/(app)/(tabs)/_layout.tsx | 16 +++-- src/components/basic/Container.tsx | 23 ++++++- src/components/layout/ResponsiveLayout.tsx | 9 ++- src/components/media/DetailsShell.tsx | 71 ++++++++++++++++++++- src/components/media/HeroSection.tsx | 9 ++- src/components/media/MediaDetailsHeader.tsx | 13 ++-- src/hooks/useBreakpoint.ts | 46 +++++++++---- 9 files changed, 162 insertions(+), 33 deletions(-) diff --git a/app.config.ts b/app.config.ts index 37d8c66..c86b1c8 100644 --- a/app.config.ts +++ b/app.config.ts @@ -93,7 +93,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { tsconfigPaths: true, reactCompiler: true }, - orientation: 'portrait', + orientation: 'default', icon: './assets/app/icon.png', backgroundColor: appBackgroundColor, userInterfaceStyle: 'dark', diff --git a/plugins/withAndroidConfigChanges.ts b/plugins/withAndroidConfigChanges.ts index 4746cbc..ba52fa9 100644 --- a/plugins/withAndroidConfigChanges.ts +++ b/plugins/withAndroidConfigChanges.ts @@ -5,9 +5,9 @@ import { ConfigPlugin, AndroidConfig, withAndroidManifest } from 'expo/config-pl * * This prevents the activity from being recreated when certain configuration changes occur, * which fixes the "linking configured in multiple places" error with expo-router - * and prevents app reloads when video display modes change. + * and prevents app reloads when video display modes or orientation change. * - * Adds: smallestScreenSize, density + * Adds: smallestScreenSize, density, orientation, screenSize */ const withAndroidConfigChanges: ConfigPlugin = (config) => { return withAndroidManifest(config, (config) => { @@ -17,7 +17,7 @@ const withAndroidConfigChanges: ConfigPlugin = (config) => { const existingConfigChanges = mainActivity.$?.['android:configChanges']?.split('|') || []; // Add our required config changes if not already present - const requiredChanges = ['smallestScreenSize', 'density']; + const requiredChanges = ['smallestScreenSize', 'density', 'orientation', 'screenSize']; const newConfigChanges = [...new Set([...existingConfigChanges, ...requiredChanges])]; // Update the activity with the new configChanges diff --git a/src/app/(app)/(tabs)/_layout.tsx b/src/app/(app)/(tabs)/_layout.tsx index c251118..9e07c77 100644 --- a/src/app/(app)/(tabs)/_layout.tsx +++ b/src/app/(app)/(tabs)/_layout.tsx @@ -5,13 +5,18 @@ import { useBreakpoint } from '@/hooks/useBreakpoint'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'; import { NAV_ITEMS } from '@/constants/navigation'; +import { useWindowDimensions } from 'react-native'; export default function TabsLayout() { const { bottom } = useSafeAreaInsets(); const breakpoint = useBreakpoint(); + const { width, height } = useWindowDimensions(); + const isLandscape = width > height; - // Hide tabs on tablet/TV since we have sidebar + // Hide tabs on tablet/TV since we have sidebar, + // and collapse to a compact style on phones in landscape to save vertical space const showTabs = breakpoint === 'mobile'; + const compactTabs = showTabs && isLandscape; return ( @@ -23,9 +28,9 @@ export default function TabsLayout() { backgroundColor: theme.colors.cardBackground, borderTopColor: theme.colors.cardBorder, borderTopWidth: 1, - paddingBottom: bottom, - paddingTop: 10, - height: 65 + bottom, + paddingBottom: compactTabs ? 2 : bottom, + paddingTop: compactTabs ? 4 : 10, + height: compactTabs ? 46 : 65 + bottom, } : { display: 'none', // Hide tabs on tablet/TV @@ -34,8 +39,9 @@ export default function TabsLayout() { tabBarInactiveTintColor: theme.colors.textSecondary, tabBarLabelStyle: { fontFamily: theme.fonts.poppinsSemiBold, - fontSize: 12, + fontSize: compactTabs ? 10 : 12, }, + tabBarIconStyle: compactTabs ? { marginBottom: -2 } : undefined, }}> {NAV_ITEMS.map((item) => ( > = ({ disablePadding, safeAreaEdges, }) => { + const { width, height } = useWindowDimensions(); + const isLandscape = width > height; + + // In landscape, always include left and right safe area edges + // to account for notches, punch-holes, and rounded corners + const edges = useMemo(() => { + if (!safeAreaEdges) { + if (isLandscape) return ['left', 'right']; + return undefined; + } + if (!isLandscape) return safeAreaEdges; + + const edgeSet = new Set(safeAreaEdges); + edgeSet.add('left'); + edgeSet.add('right'); + return Array.from(edgeSet) as Edge[]; + }, [safeAreaEdges, isLandscape]); + return ( = ({ children, maxWidth }) => { const breakpoint = useBreakpoint(); - const { width } = useWindowDimensions(); + const { width, height } = useWindowDimensions(); + const isLandscape = width > height; // Show sidebar on tablet and TV const showSidebar = breakpoint === 'tablet' || breakpoint === 'tv'; - // Calculate max width for content (50% on large screens) + // Calculate max width for content const contentMaxWidth: number | undefined = maxWidth !== undefined ? typeof maxWidth === 'number' @@ -25,7 +26,9 @@ export const ResponsiveLayout: FC = ({ children, maxWidth : undefined : breakpoint === 'tv' ? width * 0.5 - : undefined; + : breakpoint === 'tablet' && isLandscape + ? Math.min(width * 0.75, 1000) + : undefined; // Handle TV back button to focus sidebar const handleBackPress = useCallback(() => { diff --git a/src/components/media/DetailsShell.tsx b/src/components/media/DetailsShell.tsx index 5046776..ce69913 100644 --- a/src/components/media/DetailsShell.tsx +++ b/src/components/media/DetailsShell.tsx @@ -13,6 +13,8 @@ import { MediaInfo } from '@/components/media/MediaInfo'; import { useResponsiveLayout } from '@/hooks/useBreakpoint'; import { getDetailsCoverSource, getDetailsLogoSource } from '@/utils/media-artwork'; import FadeIn from '@/components/basic/FadeIn'; +import { NO_POSTER_PORTRAIT } from '@/constants/images'; +import { getImageSource } from '@/utils/image'; interface DetailsShellProps { media: MetaDetail; @@ -25,9 +27,10 @@ interface DetailsShellProps { export const DetailsShell = memo( ({ media, forceTVLayout, headerChildren, children }: PropsWithChildren) => { const theme = useTheme(); - const { isPlatformTV, width } = useResponsiveLayout(); + const { isPlatformTV, isLandscape, isTablet, isMobile, width } = useResponsiveLayout(); const useTVLayout = forceTVLayout ?? isPlatformTV; + const useLandscapeLayout = !useTVLayout && isLandscape && (isTablet || isMobile); const coverSource = useMemo( () => getDetailsCoverSource(media.background, media.poster), @@ -35,6 +38,72 @@ export const DetailsShell = memo( ); const logoSource = useMemo(() => getDetailsLogoSource(media.logo), [media.logo]); + const posterSource = useMemo( + () => getImageSource(media.poster, NO_POSTER_PORTRAIT), + [media.poster] + ); + + // Landscape layout for phones/tablets: poster on the left, info + children on the right + if (useLandscapeLayout) { + return ( + + + + + + + {/* Left column: poster */} + + + + + + + {/* Right column: title, info, header children */} + + {!!logoSource ? ( + + ) : ( + + {media.name} + + )} + + {headerChildren} + + + + + {children} + + + + ); + } + if (!useTVLayout) { return ( diff --git a/src/components/media/HeroSection.tsx b/src/components/media/HeroSection.tsx index e4f5bc0..53d38e9 100644 --- a/src/components/media/HeroSection.tsx +++ b/src/components/media/HeroSection.tsx @@ -1,5 +1,5 @@ import { memo, useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { StyleSheet } from 'react-native'; +import { StyleSheet, useWindowDimensions } from 'react-native'; import { MotiView } from 'moti'; import { LinearGradient } from 'expo-linear-gradient'; import { Image } from 'expo-image'; @@ -30,6 +30,11 @@ interface HeroSectionProps { export const HeroSection = memo(({ hasTVPreferredFocus = false }: HeroSectionProps) => { const theme = useTheme(); const { pushToStreams, navigateToDetails } = useMediaNavigation(); + const { width, height } = useWindowDimensions(); + const isLandscape = width > height; + + // In landscape, scale hero height to avoid consuming the entire viewport + const heroHeight = isLandscape ? Math.min(HERO_HEIGHT, height * 0.7) : HERO_HEIGHT; const [activeIndex, setActiveIndex] = useState(0); const autoScrollRef = useRef | null>(null); @@ -162,7 +167,7 @@ export const HeroSection = memo(({ hasTVPreferredFocus = false }: HeroSectionPro } return ( - + {/* Background Image with Fade Animation */} ) => { const theme = useTheme(); + const { width, height } = useWindowDimensions(); + const isLandscape = width > height; + + // Scale cover height in landscape to avoid consuming the whole viewport + const coverHeight = isLandscape + ? Math.min(MEDIA_DETAILS_HEADER_COVER_HEIGHT, height * 0.5) + : MEDIA_DETAILS_HEADER_COVER_HEIGHT; const coverSource = useMemo(() => { return getDetailsCoverSource(media.background, media.poster); @@ -50,7 +55,7 @@ export const MediaDetailsHeader = memo( return ( {variant !== 'minimal' && ( - + = theme.breakpoints.tv) { + // TV platform is always 'tv' regardless of resolution (e.g. 720p = 1280×720) + if (Platform.isTV) return 'tv'; + + // Use the shorter side so rotation doesn't change breakpoint tier + const shortSide = Math.min(width, height); + + if (shortSide >= theme.breakpoints.tv) { return 'tv'; } - if (width >= theme.breakpoints.tablet) { + if (shortSide >= theme.breakpoints.tablet) { return 'tablet'; } return 'mobile'; @@ -58,11 +75,14 @@ export interface ResponsiveLayoutResult { /** True for tablet or TV (wide layouts that can show split views) */ isWide: boolean; + /** True when the viewport is wider than it is tall */ + isLandscape: boolean; + /** Current window dimensions */ width: number; height: number; - /** Number of grid columns appropriate for current breakpoint */ + /** Number of grid columns appropriate for current breakpoint and orientation */ columns: number; /** Maximum content width for current breakpoint */ @@ -115,20 +135,21 @@ export function useResponsiveLayout(): ResponsiveLayoutResult { const isTV = breakpoint === 'tv'; const isWide = isTablet || isTV; const isPlatformTV = Platform.isTV; + const isLandscape = width > height; - // Grid columns based on breakpoint + // Grid columns based on breakpoint + orientation const columns = useMemo(() => { - if (isTV) return 4; - if (isTablet) return 3; - return 2; - }, [isTV, isTablet]); + if (isTV) return isLandscape ? 5 : 4; + if (isTablet) return isLandscape ? 5 : 3; + return isLandscape ? 3 : 2; + }, [isTV, isTablet, isLandscape]); // Max content width const containerMaxWidth = useMemo(() => { if (isTV) return Math.min(width * 0.7, 1200); - if (isTablet) return Math.min(width * 0.85, 900); + if (isTablet) return isLandscape ? Math.min(width * 0.9, 1100) : Math.min(width * 0.85, 900); return undefined; // Full width on mobile - }, [isTV, isTablet, width]); + }, [isTV, isTablet, isLandscape, width]); // Content padding const contentPadding = useMemo(() => { @@ -167,6 +188,7 @@ export function useResponsiveLayout(): ResponsiveLayoutResult { isTablet, isTV, isWide, + isLandscape, width, height, columns,