Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 3 additions & 3 deletions plugins/withAndroidConfigChanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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
Expand Down
16 changes: 11 additions & 5 deletions src/app/(app)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ResponsiveLayout>
Expand All @@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we even need this 2 here? Isn't the marginBottom: -2 on the icon just equaling this out?

paddingTop: compactTabs ? 4 : 10,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved these constants to the theme on master, please adjust this accordingly

height: compactTabs ? 46 : 65 + bottom,
}
: {
display: 'none', // Hide tabs on tablet/TV
Expand All @@ -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) => (
<Tabs.Screen
Expand Down
23 changes: 21 additions & 2 deletions src/components/basic/Container.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SafeAreaView, type Edge } from 'react-native-safe-area-context';
import { useWindowDimensions } from 'react-native';
import theme, { Box } from '@/theme/theme';
import { FC, PropsWithChildren } from 'react';
import { FC, PropsWithChildren, useMemo } from 'react';

interface ContainerProps {
disablePadding?: boolean;
Expand All @@ -12,9 +13,27 @@ export const Container: FC<PropsWithChildren<ContainerProps>> = ({
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<Edge[] | undefined>(() => {
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 (
<SafeAreaView
edges={safeAreaEdges}
edges={edges}
style={{ flex: 1, backgroundColor: theme.colors.mainBackground }}>
<Box
flex={1}
Expand Down
9 changes: 6 additions & 3 deletions src/components/layout/ResponsiveLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,23 @@ interface ResponsiveLayoutProps {

export const ResponsiveLayout: FC<ResponsiveLayoutProps> = ({ 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'
? maxWidth
: undefined
: breakpoint === 'tv'
? width * 0.5
: undefined;
: breakpoint === 'tablet' && isLandscape
? Math.min(width * 0.75, 1000)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't hardcode, use theme

: undefined;

// Handle TV back button to focus sidebar
const handleBackPress = useCallback(() => {
Expand Down
71 changes: 70 additions & 1 deletion src/components/media/DetailsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,16 +27,83 @@ interface DetailsShellProps {
export const DetailsShell = memo(
({ media, forceTVLayout, headerChildren, children }: PropsWithChildren<DetailsShellProps>) => {
const theme = useTheme<Theme>();
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),
[media.background, media.poster]
);
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 (
<Box flex={1}>
<AnimatedImage
source={coverSource}
contentFit="cover"
style={StyleSheet.absoluteFillObject}
/>
<LinearGradient
colors={[theme.colors.semiTransparentBackground, theme.colors.mainBackground]}
locations={[0, 0.6]}
style={StyleSheet.absoluteFillObject}
/>

<ScrollView>
<Box flexDirection="row" padding="l" gap="l" position="relative">
{/* Left column: poster */}
<Box width={isMobile ? 140 : 180}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't hardcode, use theme

<Box
borderRadius="l"
overflow="hidden"
backgroundColor="cardBackground"
style={{ aspectRatio: 2 / 3 }}>
<AnimatedImage
source={posterSource}
style={{ width: '100%', height: '100%' }}
contentFit="cover"
/>
</Box>
</Box>

{/* Right column: title, info, header children */}
<Box flex={1} gap="m" justifyContent="center">
{!!logoSource ? (
<AnimatedImage
source={logoSource}
contentFit="contain"
style={{
width: Math.min(width * 0.4, theme.sizes.logoMaxWidth),
height: theme.sizes.stickyLogoHeight,
}}
/>
) : (
<FadeIn>
<Text variant="header">{media.name}</Text>
</FadeIn>
)}
<MediaInfo media={media} variant="compact" />
{headerChildren}
</Box>
</Box>

<Box paddingHorizontal="l" paddingBottom="l" gap="m">
{children}
</Box>
</ScrollView>
</Box>
);
}

if (!useTVLayout) {
return (
<ScrollView>
Expand Down
9 changes: 7 additions & 2 deletions src/components/media/HeroSection.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -30,6 +30,11 @@ interface HeroSectionProps {
export const HeroSection = memo(({ hasTVPreferredFocus = false }: HeroSectionProps) => {
const theme = useTheme<Theme>();
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is in theme on master now


const [activeIndex, setActiveIndex] = useState(0);
const autoScrollRef = useRef<ReturnType<typeof setInterval> | null>(null);
Expand Down Expand Up @@ -162,7 +167,7 @@ export const HeroSection = memo(({ hasTVPreferredFocus = false }: HeroSectionPro
}

return (
<Box height={HERO_HEIGHT} width="100%" overflow="hidden">
<Box height={heroHeight} width="100%" overflow="hidden">
{/* Background Image with Fade Animation */}
<MotiView
key={activeItem.id}
Expand Down
13 changes: 9 additions & 4 deletions src/components/media/MediaDetailsHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { memo, PropsWithChildren, useMemo } from 'react';
import { Dimensions } from 'react-native';
import { useWindowDimensions } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { AnimatedImage } from '@/components/basic/AnimatedImage';
import { Box, Text } from '@/theme/theme';
Expand All @@ -14,8 +14,6 @@ import { MEDIA_DETAILS_HEADER_COVER_HEIGHT } from '@/constants/media';
import { Tag } from '@/components/basic/Tag';
import FadeIn from '@/components/basic/FadeIn';

const { width } = Dimensions.get('window');

interface MediaDetailsHeaderProps {
media: MetaDetail;
video?: MetaVideo;
Expand All @@ -25,6 +23,13 @@ interface MediaDetailsHeaderProps {
export const MediaDetailsHeader = memo(
({ media, video, variant = 'full', children }: PropsWithChildren<MediaDetailsHeaderProps>) => {
const theme = useTheme<Theme>();
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);
Expand All @@ -50,7 +55,7 @@ export const MediaDetailsHeader = memo(
return (
<Box>
{variant !== 'minimal' && (
<Box height={MEDIA_DETAILS_HEADER_COVER_HEIGHT} width={width} position="relative">
<Box height={coverHeight} width={width} position="relative">
<AnimatedImage
source={coverSource}
style={{ width: '100%', height: '100%' }}
Expand Down
46 changes: 34 additions & 12 deletions src/hooks/useBreakpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,33 @@ import theme from '@/theme/theme';
export type Breakpoint = 'mobile' | 'tablet' | 'tv';

/**
* Hook to detect current breakpoint based on window width
* Hook to detect current breakpoint based on window width.
*
* On TV platforms (`Platform.isTV`), always returns `'tv'` regardless of
* screen resolution to handle 720p Android TV devices correctly.
*
* On non-TV platforms, uses the *shorter* dimension (min of width/height)
* so that a phone rotated to landscape doesn't suddenly jump to the
* "tablet" breakpoint, and a tablet in landscape doesn't jump to "tv".
* This keeps the breakpoint stable across orientation changes while still
* allowing orientation-specific layout tweaks via `isLandscape` from
* `useResponsiveLayout`.
*
* @returns Current breakpoint: 'mobile' | 'tablet' | 'tv'
*/
export function useBreakpoint(): Breakpoint {
const { width } = useWindowDimensions();
const { width, height } = useWindowDimensions();

if (width >= 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';
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was unused and got removed on master

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(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was unused and got removed on master

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(() => {
Expand Down Expand Up @@ -167,6 +188,7 @@ export function useResponsiveLayout(): ResponsiveLayoutResult {
isTablet,
isTV,
isWide,
isLandscape,
width,
height,
columns,
Expand Down