├── hooks
├── useColorScheme.ts
├── useTextStyles.ts
├── useColorScheme.web.ts
├── useThemeColor.ts
└── useInterval.ts
├── assets
├── bar_icons
│ ├── tv.png
│ ├── code.png
│ ├── home.png
│ └── video.png
├── images
│ ├── icon.png
│ ├── favicon.png
│ ├── splash.png
│ ├── react-logo.png
│ ├── adaptive-icon.png
│ ├── react-logo@2x.png
│ ├── react-logo@3x.png
│ └── partial-react-logo.png
├── audio
│ └── paza-moduless.mp3
├── tv_icons
│ ├── icon-1280x768.png
│ ├── icon-1920x720.png
│ ├── icon-2320x720.png
│ ├── icon-400x240.png
│ ├── icon-760x760.png
│ ├── icon-800x480.png
│ ├── icon-3840x1440.png
│ └── icon-4640x1440.png
└── fonts
│ └── SpaceMono-Regular.ttf
├── .eslintrc.js
├── app
├── (tabs)
│ ├── _layout.tsx
│ ├── video.tsx
│ ├── tv_focus.tsx
│ ├── index.tsx
│ └── explore.tsx
├── +not-found.tsx
├── +html.tsx
├── _layout.tsx
└── modal.tsx
├── tsconfig.json
├── .gitignore
├── components
├── ThemedView.tsx
├── navigation
│ └── TabBarIcon.tsx
├── DemoButton.tsx
├── HelloWave.tsx
├── ThemedText.tsx
├── Collapsible.tsx
├── ProgressBar.tsx
├── ExternalLink.tsx
├── AudioTest.tsx
├── ParallaxScrollView.tsx
├── VideoTest.tsx
└── EventHandlingDemo.tsx
├── constants
├── ReactNativeInfo.ts
├── TextStyles.ts
└── Colors.ts
├── layouts
├── TabLayout.tsx
└── TabLayout.web.tsx
├── patches
└── react-native-screens+4.16.0.patch
├── metro.config.js
├── eas.json
├── app.json
├── package.json
├── scripts
└── reset-project.js
└── README.md
/hooks/useColorScheme.ts:
--------------------------------------------------------------------------------
1 | export { useColorScheme } from 'react-native';
2 |
--------------------------------------------------------------------------------
/assets/bar_icons/tv.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/bar_icons/tv.png
--------------------------------------------------------------------------------
/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/images/icon.png
--------------------------------------------------------------------------------
/assets/bar_icons/code.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/bar_icons/code.png
--------------------------------------------------------------------------------
/assets/bar_icons/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/bar_icons/home.png
--------------------------------------------------------------------------------
/assets/bar_icons/video.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/bar_icons/video.png
--------------------------------------------------------------------------------
/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/images/favicon.png
--------------------------------------------------------------------------------
/assets/images/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/images/splash.png
--------------------------------------------------------------------------------
/assets/images/react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/images/react-logo.png
--------------------------------------------------------------------------------
/assets/audio/paza-moduless.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/audio/paza-moduless.mp3
--------------------------------------------------------------------------------
/assets/images/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/images/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/images/react-logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/images/react-logo@2x.png
--------------------------------------------------------------------------------
/assets/images/react-logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/images/react-logo@3x.png
--------------------------------------------------------------------------------
/assets/tv_icons/icon-1280x768.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/tv_icons/icon-1280x768.png
--------------------------------------------------------------------------------
/assets/tv_icons/icon-1920x720.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/tv_icons/icon-1920x720.png
--------------------------------------------------------------------------------
/assets/tv_icons/icon-2320x720.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/tv_icons/icon-2320x720.png
--------------------------------------------------------------------------------
/assets/tv_icons/icon-400x240.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/tv_icons/icon-400x240.png
--------------------------------------------------------------------------------
/assets/tv_icons/icon-760x760.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/tv_icons/icon-760x760.png
--------------------------------------------------------------------------------
/assets/tv_icons/icon-800x480.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/tv_icons/icon-800x480.png
--------------------------------------------------------------------------------
/assets/fonts/SpaceMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/fonts/SpaceMono-Regular.ttf
--------------------------------------------------------------------------------
/assets/images/partial-react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/images/partial-react-logo.png
--------------------------------------------------------------------------------
/assets/tv_icons/icon-3840x1440.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/tv_icons/icon-3840x1440.png
--------------------------------------------------------------------------------
/assets/tv_icons/icon-4640x1440.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/react-native-tvos/ExpoRouterTV/HEAD/assets/tv_icons/icon-4640x1440.png
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | // https://docs.expo.dev/guides/using-eslint/
2 | module.exports = {
3 | extends: 'expo',
4 | ignorePatterns: ['/dist/*'],
5 | };
6 |
--------------------------------------------------------------------------------
/app/(tabs)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import TabLayout from '../../layouts/TabLayout';
2 |
3 | export default function PlatformTabLayout() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/hooks/useTextStyles.ts:
--------------------------------------------------------------------------------
1 | import { textStyles } from '@/constants/TextStyles';
2 | import { useThemeColor } from './useThemeColor';
3 |
4 | export function useTextStyles() {
5 | const linkColor = useThemeColor({}, 'link');
6 | return textStyles(linkColor);
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "@/*": [
7 | "./*"
8 | ]
9 | }
10 | },
11 | "include": [
12 | "**/*.ts",
13 | "**/*.tsx",
14 | ".expo/types/**/*.ts",
15 | "expo-env.d.ts"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | ios/
5 | android/
6 | npm-debug.*
7 | *.jks
8 | *.p8
9 | *.p12
10 | *.key
11 | *.mobileprovision
12 | *.orig.*
13 | web-build/
14 |
15 | # macOS
16 | .DS_Store
17 |
18 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
19 | # The following patterns were generated by expo-cli
20 |
21 | expo-env.d.ts
22 | # @end expo-cli
--------------------------------------------------------------------------------
/hooks/useColorScheme.web.ts:
--------------------------------------------------------------------------------
1 | // NOTE: The default React Native styling doesn't support server rendering.
2 | // Server rendered styles should not change between the first render of the HTML
3 | // and the first render on the client. Typically, web developers will use CSS media queries
4 | // to render different styles on the client and server, these aren't directly supported in React Native
5 | // but can be achieved using a styling library like Nativewind.
6 | export function useColorScheme() {
7 | return 'light';
8 | }
9 |
--------------------------------------------------------------------------------
/components/ThemedView.tsx:
--------------------------------------------------------------------------------
1 | import { View, type ViewProps } from 'react-native';
2 |
3 | import { useThemeColor } from '@/hooks/useThemeColor';
4 |
5 | export type ThemedViewProps = ViewProps & {
6 | lightColor?: string;
7 | darkColor?: string;
8 | };
9 |
10 | export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
11 | const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
12 |
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/hooks/useThemeColor.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Learn more about light and dark modes:
3 | * https://docs.expo.dev/guides/color-schemes/
4 | */
5 |
6 | import { useColorScheme } from 'react-native';
7 |
8 | import { Colors } from '@/constants/Colors';
9 |
10 | export function useThemeColor(
11 | props: { light?: string; dark?: string },
12 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark
13 | ) {
14 | const theme = useColorScheme() ?? 'light';
15 | const colorFromProps = props[theme];
16 |
17 | if (colorFromProps) {
18 | return colorFromProps;
19 | } else {
20 | return Colors[theme][colorName];
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/constants/ReactNativeInfo.ts:
--------------------------------------------------------------------------------
1 | import { version as rnVersion } from 'react-native/package.json';
2 | import { version as routerVersion } from 'expo-router/package.json';
3 | import { Platform } from 'react-native';
4 |
5 | const hermesVersion = (global as any)?.HermesInternal?.getRuntimeProperties();
6 | const jsEngine =
7 | Platform.OS === 'web' ? 'Browser' : hermesVersion ? `Hermes` : 'JSC';
8 |
9 | const uiManager =
10 | ((global as any)?.nativeFabricUIManager as any) !== undefined
11 | ? 'Fabric'
12 | : 'Paper';
13 |
14 | export const reactNativeInfo = {
15 | rnVersion,
16 | routerVersion,
17 | hermesVersion,
18 | uiManager,
19 | jsEngine,
20 | };
21 |
--------------------------------------------------------------------------------
/components/navigation/TabBarIcon.tsx:
--------------------------------------------------------------------------------
1 | // You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
2 |
3 | import Ionicons from '@expo/vector-icons/Ionicons';
4 | import { type IconProps } from '@expo/vector-icons/build/createIconSet';
5 | import { type ComponentProps } from 'react';
6 | import { scale } from 'react-native-size-matters';
7 |
8 | export function TabBarIcon({
9 | style,
10 | ...rest
11 | }: IconProps['name']>) {
12 | return (
13 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/hooks/useInterval.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react"
2 |
3 | /**
4 | * Hook that calls a callback periodically
5 | * Based on https://balavishnuvj.com/blog/using-callbacks-in-custom-hooks/
6 | *
7 | * @param callback The callback to be called
8 | * @param interval The interval between callbacks, in milliseconds
9 | */
10 | export const useInterval: (callback: () => void, interval: number) => void = (
11 | callback,
12 | interval,
13 | ) => {
14 | const callbackRef = useRef()
15 | useEffect(() => {
16 | callbackRef.current = callback
17 | }, [callback])
18 |
19 | // Set up the interval.
20 | useEffect(() => {
21 | function cb() {
22 | callbackRef.current && callbackRef.current()
23 | }
24 | const id = setInterval(cb, interval)
25 | return () => clearInterval(id)
26 | }, [interval])
27 | }
28 |
--------------------------------------------------------------------------------
/app/+not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Link, Stack } from 'expo-router';
2 | import { StyleSheet } from 'react-native';
3 |
4 | import { ThemedText } from '@/components/ThemedText';
5 | import { ThemedView } from '@/components/ThemedView';
6 |
7 | export default function NotFoundScreen() {
8 | return (
9 | <>
10 |
11 |
12 | This screen doesn't exist.
13 |
14 | Go to home screen!
15 |
16 |
17 | >
18 | );
19 | }
20 |
21 | const styles = StyleSheet.create({
22 | container: {
23 | flex: 1,
24 | alignItems: 'center',
25 | justifyContent: 'center',
26 | padding: 20,
27 | },
28 | link: {
29 | marginTop: 15,
30 | paddingVertical: 15,
31 | },
32 | });
33 |
--------------------------------------------------------------------------------
/components/DemoButton.tsx:
--------------------------------------------------------------------------------
1 | import { scale } from 'react-native-size-matters';
2 | import { Pressable, StyleSheet, Text } from 'react-native';
3 |
4 | export const DemoButton = (props: { title: string; onPress: () => void }) => {
5 | const styles = useVideoStyles();
6 | return (
7 | props.onPress()}
9 | style={({ pressed, focused }) => [
10 | styles.button,
11 | pressed || focused ? { backgroundColor: 'blue' } : {},
12 | ]}
13 | >
14 | {props.title}
15 |
16 | );
17 | };
18 |
19 | export const useVideoStyles = () => {
20 | return StyleSheet.create({
21 | button: {
22 | backgroundColor: 'darkblue',
23 | margin: scale(5),
24 | borderRadius: scale(2),
25 | padding: scale(5),
26 | },
27 | buttonText: {
28 | color: 'white',
29 | fontSize: scale(8),
30 | },
31 | });
32 | };
33 |
--------------------------------------------------------------------------------
/layouts/TabLayout.tsx:
--------------------------------------------------------------------------------
1 | import { NativeTabs, Label, Icon } from 'expo-router/unstable-native-tabs';
2 | import { Platform } from 'react-native';
3 |
4 | import WebTabLayout from './TabLayout.web';
5 |
6 | export default function TabLayout() {
7 | if (Platform.OS === 'android' && Platform.isTV) {
8 | return ;
9 | }
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/patches/react-native-screens+4.16.0.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/react-native-screens/ios/bottom-tabs/RNSTabBarController.mm b/node_modules/react-native-screens/ios/bottom-tabs/RNSTabBarController.mm
2 | index 1c70eec..375cba4 100644
3 | --- a/node_modules/react-native-screens/ios/bottom-tabs/RNSTabBarController.mm
4 | +++ b/node_modules/react-native-screens/ios/bottom-tabs/RNSTabBarController.mm
5 | @@ -19,6 +19,15 @@
6 | _tabBarAppearanceCoordinator = [RNSTabBarAppearanceCoordinator new];
7 | _tabsHostComponentView = nil;
8 |
9 | +#if !TARGET_OS_TV
10 | + if (@available(iOS 18.0, *)) {
11 | + if ([[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPad) {
12 | + [self setMode:UITabBarControllerModeTabSidebar]; // Enable the sidebar
13 | + self.sidebar.hidden = YES; // Hide it by default
14 | + }
15 | + }
16 | +#endif
17 | +
18 | #if !RCT_NEW_ARCH_ENABLED
19 | _isControllerFlushBlockScheduled = NO;
20 | #endif // !RCT_NEW_ARCH_ENABLED
21 |
--------------------------------------------------------------------------------
/metro.config.js:
--------------------------------------------------------------------------------
1 | // Learn more https://docs.expo.io/guides/customizing-metro
2 | const { getDefaultConfig } = require('expo/metro-config');
3 |
4 | /** @type {import('expo/metro-config').MetroConfig} */
5 | const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef
6 |
7 | // Add Hermes parser
8 | config.transformer.hermesParser = true;
9 |
10 | // When enabled, the optional code below will allow Metro to resolve
11 | // and bundle source files with TV-specific extensions
12 | // (e.g., *.ios.tv.tsx, *.android.tv.tsx, *.tv.tsx)
13 | //
14 | // Metro will still resolve source files with standard extensions
15 | // as usual if TV-specific files are not found for a module.
16 | //
17 | if (process.env?.EXPO_TV === '1') {
18 | const originalSourceExts = config.resolver.sourceExts;
19 | const tvSourceExts = [
20 | ...originalSourceExts.map((e) => `tv.${e}`),
21 | ...originalSourceExts,
22 | ];
23 | config.resolver.sourceExts = tvSourceExts;
24 | }
25 |
26 | module.exports = config;
27 |
--------------------------------------------------------------------------------
/components/HelloWave.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet } from 'react-native';
2 | import Animated, {
3 | useSharedValue,
4 | useAnimatedStyle,
5 | withTiming,
6 | withRepeat,
7 | withSequence,
8 | } from 'react-native-reanimated';
9 |
10 | import { ThemedText } from '@/components/ThemedText';
11 |
12 | export function HelloWave() {
13 | const rotationAnimation = useSharedValue(0);
14 |
15 | rotationAnimation.value = withRepeat(
16 | withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })),
17 | 4 // Run the animation 4 times
18 | );
19 |
20 | const animatedStyle = useAnimatedStyle(() => ({
21 | transform: [{ rotate: `${rotationAnimation.value}deg` }],
22 | }));
23 |
24 | return (
25 |
26 | 👋
27 |
28 | );
29 | }
30 |
31 | const styles = StyleSheet.create({
32 | text: {
33 | fontSize: 28,
34 | lineHeight: 32,
35 | marginTop: -6,
36 | },
37 | });
38 |
--------------------------------------------------------------------------------
/constants/TextStyles.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Below are text styles used in the app, primarily in the ThemedText component.
3 | */
4 |
5 | import { TextStyle } from 'react-native';
6 | import { scale } from 'react-native-size-matters';
7 |
8 | export const textStyles = function (linkColor: string): {
9 | [key: string]: TextStyle & { fontSize: number; lineHeight: number };
10 | } {
11 | return {
12 | default: {
13 | fontSize: scale(10),
14 | lineHeight: scale(12),
15 | },
16 | defaultSemiBold: {
17 | fontSize: scale(10),
18 | lineHeight: scale(12),
19 | fontWeight: '600',
20 | },
21 | title: {
22 | fontSize: scale(16),
23 | fontWeight: 'bold',
24 | lineHeight: scale(20),
25 | },
26 | subtitle: {
27 | fontSize: scale(12),
28 | lineHeight: scale(15),
29 | fontWeight: 'bold',
30 | },
31 | link: {
32 | lineHeight: scale(8),
33 | fontSize: scale(10),
34 | color: linkColor,
35 | },
36 | small: {
37 | lineHeight: scale(5),
38 | fontSize: scale(4),
39 | },
40 | };
41 | };
42 |
--------------------------------------------------------------------------------
/constants/Colors.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
3 | * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
4 | */
5 |
6 | const tintColorLight = '#0a7ea4';
7 | const tintColorDark = '#aaa';
8 |
9 | const containerBackgroundLight = '#D0D0D0';
10 | const containerBackgroundDark = '#353636';
11 |
12 | export const Colors = {
13 | light: {
14 | text: '#11181C',
15 | background: '#fff',
16 | tint: tintColorLight,
17 | icon: '#687076',
18 | tabIconDefault: '#687076',
19 | tabIconSelected: tintColorLight,
20 | link: '#0a7ea4',
21 | containerBackground: containerBackgroundLight,
22 | },
23 | dark: {
24 | text: '#ECEDEE',
25 | background: '#151718',
26 | tint: tintColorDark,
27 | icon: '#9BA1A6',
28 | tabIconDefault: '#9BA1A6',
29 | tabIconSelected: tintColorDark,
30 | link: '#0a7ea4',
31 | containerBackground: containerBackgroundDark,
32 | },
33 | };
34 |
--------------------------------------------------------------------------------
/components/ThemedText.tsx:
--------------------------------------------------------------------------------
1 | import { Text, type TextProps } from 'react-native';
2 |
3 | import { useThemeColor } from '@/hooks/useThemeColor';
4 | import { useTextStyles } from '@/hooks/useTextStyles';
5 |
6 | export type ThemedTextProps = TextProps & {
7 | lightColor?: string;
8 | darkColor?: string;
9 | type?:
10 | | 'default'
11 | | 'title'
12 | | 'defaultSemiBold'
13 | | 'subtitle'
14 | | 'link'
15 | | 'small';
16 | };
17 |
18 | export function ThemedText({
19 | style,
20 | lightColor,
21 | darkColor,
22 | type = 'default',
23 | ...rest
24 | }: ThemedTextProps) {
25 | const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
26 | const styles = useTextStyles();
27 |
28 | return (
29 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/eas.json:
--------------------------------------------------------------------------------
1 | {
2 | "cli": {
3 | "version": ">= 3.15.1",
4 | "appVersionSource": "remote"
5 | },
6 | "build": {
7 | "development": {
8 | "extends": "production",
9 | "distribution": "internal",
10 | "android": {
11 | "buildType": "apk",
12 | "withoutCredentials": true,
13 | "gradleCommand": ":app:assembleDebug"
14 | },
15 | "ios": {
16 | "buildConfiguration": "Debug",
17 | "simulator": true
18 | }
19 | },
20 | "development:tv": {
21 | "extends": "development",
22 | "env": {
23 | "EXPO_TV": "1"
24 | }
25 | },
26 | "preview": {
27 | "extends": "production",
28 | "autoIncrement": true,
29 | "distribution": "internal",
30 | "ios": {
31 | "simulator": true
32 | },
33 | "android": {
34 | "buildType": "apk",
35 | "withoutCredentials": true
36 | }
37 | },
38 | "preview:tv": {
39 | "autoIncrement": true,
40 | "extends": "preview",
41 | "env": {
42 | "EXPO_TV": "1"
43 | }
44 | },
45 | "production": {},
46 | "production:tv": {
47 | "extends": "production",
48 | "env": {
49 | "EXPO_TV": "1"
50 | }
51 | }
52 | },
53 | "submit": {
54 | "production": {}
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/(tabs)/video.tsx:
--------------------------------------------------------------------------------
1 | import Ionicons from '@expo/vector-icons/Ionicons';
2 | import { StyleSheet } from 'react-native';
3 | import { scale } from 'react-native-size-matters';
4 |
5 | import ParallaxScrollView from '@/components/ParallaxScrollView';
6 | import { ThemedText } from '@/components/ThemedText';
7 | import { ThemedView } from '@/components/ThemedView';
8 | import VideoTest from '@/components/VideoTest';
9 | import AudioTest from '@/components/AudioTest';
10 |
11 | export default function VideoDemoScreen() {
12 | return (
13 |
21 | }
22 | >
23 |
24 | Audio demo
25 |
26 |
27 |
28 | Video demo
29 |
30 |
31 |
32 | );
33 | }
34 |
35 | const styles = StyleSheet.create({
36 | headerImage: {
37 | color: '#808080',
38 | bottom: scale(30),
39 | left: 0,
40 | position: 'absolute',
41 | },
42 | titleContainer: {
43 | flexDirection: 'row',
44 | gap: scale(8),
45 | },
46 | });
47 |
--------------------------------------------------------------------------------
/components/Collapsible.tsx:
--------------------------------------------------------------------------------
1 | import Ionicons from '@expo/vector-icons/Ionicons';
2 | import { PropsWithChildren, useState } from 'react';
3 | import { StyleSheet, TouchableOpacity, useColorScheme } from 'react-native';
4 | import { scale } from 'react-native-size-matters';
5 |
6 | import { ThemedText } from '@/components/ThemedText';
7 | import { ThemedView } from '@/components/ThemedView';
8 | import { Colors } from '@/constants/Colors';
9 |
10 | export function Collapsible({
11 | children,
12 | title,
13 | }: PropsWithChildren & { title: string }) {
14 | const [isOpen, setIsOpen] = useState(false);
15 | const theme = useColorScheme() ?? 'light';
16 |
17 | return (
18 |
19 | setIsOpen((value) => !value)}
22 | activeOpacity={0.6}
23 | >
24 |
29 | {title}
30 |
31 | {isOpen && {children}}
32 |
33 | );
34 | }
35 |
36 | const styles = StyleSheet.create({
37 | heading: {
38 | flexDirection: 'row',
39 | alignItems: 'center',
40 | gap: scale(6),
41 | },
42 | content: {
43 | marginTop: scale(6),
44 | marginLeft: scale(24),
45 | },
46 | });
47 |
--------------------------------------------------------------------------------
/components/ProgressBar.tsx:
--------------------------------------------------------------------------------
1 | import { scale } from 'react-native-size-matters';
2 | import { StyleSheet, View } from 'react-native';
3 |
4 | export const ProgressBar = (props: any) => {
5 | const styles = useProgressBarStyles();
6 | const progressBarStyles = {
7 | container: styles.progressContainer,
8 | left: [styles.progressLeft, { flex: props?.fractionComplete || 0.0 }],
9 | right: [
10 | styles.progressRight,
11 | { flex: 1.0 - props?.fractionComplete || 1.0 },
12 | ],
13 | };
14 | return (
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export const fractionCompleteFromPosition = (
23 | position: number | undefined,
24 | duration: number | undefined,
25 | ) => {
26 | return duration !== undefined ? (position ?? 0) / duration : 0;
27 | };
28 |
29 | const useProgressBarStyles = () => {
30 | const vidHeight = scale(200);
31 | const vidWidth = 2 * vidHeight;
32 | return StyleSheet.create({
33 | progressContainer: {
34 | flexDirection: 'row',
35 | width: vidWidth,
36 | height: scale(5),
37 | margin: 0,
38 | },
39 | progressLeft: {
40 | backgroundColor: 'blue',
41 | borderTopRightRadius: scale(5),
42 | borderBottomRightRadius: scale(5),
43 | flexDirection: 'row',
44 | height: '100%',
45 | },
46 | progressRight: {
47 | flexDirection: 'row',
48 | height: '100%',
49 | },
50 | });
51 | };
52 |
--------------------------------------------------------------------------------
/app/+html.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollViewStyleReset } from 'expo-router/html';
2 | import { type PropsWithChildren } from 'react';
3 |
4 | /**
5 | * This file is web-only and used to configure the root HTML for every web page during static rendering.
6 | * The contents of this function only run in Node.js environments and do not have access to the DOM or browser APIs.
7 | */
8 | export default function Root({ children }: PropsWithChildren) {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 | {/*
17 | Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
18 | However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
19 | */}
20 |
21 |
22 | {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
23 |
24 | {/* Add any additional elements that you want globally available on web... */}
25 |
26 | {children}
27 |
28 | );
29 | }
30 |
31 | const responsiveBackground = `
32 | body {
33 | background-color: #fff;
34 | }
35 | @media (prefers-color-scheme: dark) {
36 | body {
37 | background-color: #000;
38 | }
39 | }`;
40 |
--------------------------------------------------------------------------------
/components/ExternalLink.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'expo-router';
2 | import * as Linking from 'expo-linking';
3 | import type { Href } from 'expo-router';
4 | import { type ComponentProps } from 'react';
5 | import { Platform, Pressable } from 'react-native';
6 |
7 | const openBrowserAsync =
8 | Platform.isTV && Platform.OS === 'ios'
9 | ? async () => {}
10 | : require('expo-web-browser').openBrowserAsync;
11 |
12 | type Props = Omit, 'href'> & {
13 | href: string;
14 | };
15 |
16 | function ExternalLinkMobile({ href, ...rest }: Props) {
17 | return (
18 | }
22 | onPress={async (event) => {
23 | if (Platform.OS !== 'web') {
24 | // Prevent the default behavior of linking to the default browser on native.
25 | event.preventDefault();
26 | // Open the link in an in-app browser.
27 | await openBrowserAsync(href);
28 | }
29 | }}
30 | />
31 | );
32 | }
33 |
34 | function ExternalLinkTV({ href, ...rest }: Props) {
35 | return (
36 |
38 | Linking.openURL(href).catch((reason) => alert(`${reason}`))
39 | }
40 | style={({ pressed, focused }) => ({
41 | opacity: pressed || focused ? 0.6 : 1.0,
42 | })}
43 | >
44 | {rest.children}
45 |
46 | );
47 | }
48 |
49 | export function ExternalLink(props: Props) {
50 | return Platform.isTV ? ExternalLinkTV(props) : ExternalLinkMobile(props);
51 | }
52 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "plugins": [
4 | [
5 | "@react-native-tvos/config-tv",
6 | {
7 | "androidTVBanner": "./assets/tv_icons/icon-400x240.png",
8 | "androidTVIcon": "./assets/tv_icons/icon-760x760.png",
9 | "appleTVImages": {
10 | "icon": "./assets/tv_icons/icon-1280x768.png",
11 | "iconSmall": "./assets/tv_icons/icon-400x240.png",
12 | "iconSmall2x": "./assets/tv_icons/icon-800x480.png",
13 | "topShelf": "./assets/tv_icons/icon-1920x720.png",
14 | "topShelf2x": "./assets/tv_icons/icon-3840x1440.png",
15 | "topShelfWide": "./assets/tv_icons/icon-2320x720.png",
16 | "topShelfWide2x": "./assets/tv_icons/icon-4640x1440.png"
17 | }
18 | }
19 | ],
20 | "expo-build-properties",
21 | "expo-router",
22 | "expo-font",
23 | "expo-video",
24 | "expo-web-browser",
25 | "expo-audio"
26 | ],
27 | "experiments": {
28 | "typedRoutes": true
29 | },
30 | "name": "ExpoRouterTV",
31 | "slug": "ExpoRouterTV",
32 | "scheme": "exporoutertv",
33 | "icon": "./assets/tv_icons/icon-760x760.png",
34 | "newArchEnabled": true,
35 | "version": "1.0.0",
36 | "extra": {
37 | "router": {
38 | "origin": false
39 | }
40 | },
41 | "splash": {
42 | "image": "./assets/tv_icons/icon-1280x768.png"
43 | },
44 | "web": {
45 | "bundler": "metro",
46 | "output": "static",
47 | "favicon": "./assets/images/favicon.png"
48 | },
49 | "android": {
50 | "edgeToEdgeEnabled": true
51 | },
52 | "ios": {
53 | "supportsTablet": true
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DarkTheme,
3 | DefaultTheme,
4 | ThemeProvider,
5 | } from '@react-navigation/native';
6 | import { useFonts } from 'expo-font';
7 | import { Stack } from 'expo-router';
8 | import * as SplashScreen from 'expo-splash-screen';
9 | import { useEffect } from 'react';
10 | import { scale } from 'react-native-size-matters';
11 | import {
12 | configureReanimatedLogger,
13 | ReanimatedLogLevel,
14 | } from 'react-native-reanimated';
15 |
16 | import { useColorScheme } from '@/hooks/useColorScheme';
17 | import { Platform, TVEventControl } from 'react-native';
18 |
19 | // Prevent the splash screen from auto-hiding before asset loading is complete.
20 | SplashScreen.preventAutoHideAsync();
21 |
22 | // Disable reanimated warnings
23 | configureReanimatedLogger({
24 | level: ReanimatedLogLevel.warn,
25 | strict: false,
26 | });
27 |
28 | export default function RootLayout() {
29 | const colorScheme = useColorScheme();
30 | const [loaded, error] = useFonts({
31 | SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
32 | });
33 |
34 | useEffect(() => {
35 | if (loaded || error) {
36 | SplashScreen.hideAsync();
37 | if (Platform.isTVOS) {
38 | TVEventControl.enableTVMenuKey();
39 | }
40 | }
41 | }, [loaded, error]);
42 |
43 | if (!loaded && !error) {
44 | return null;
45 | }
46 |
47 | return (
48 |
49 |
50 |
51 |
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "exporoutertv",
3 | "main": "expo-router/entry",
4 | "license": "MIT",
5 | "version": "1.0.0",
6 | "scripts": {
7 | "postinstall": "patch-package",
8 | "prebuild": "DEBUG=expo:* expo prebuild --clean",
9 | "prebuild:tv": "DEBUG=expo:* EXPO_TV=1 expo prebuild --clean",
10 | "start": "expo start",
11 | "android": "expo run:android",
12 | "ios": "expo run:ios",
13 | "web": "expo start --web",
14 | "reset-project": "./scripts/reset-project.js",
15 | "test": "jest --watchAll",
16 | "lint": "expo lint"
17 | },
18 | "dependencies": {
19 | "@expo/metro-runtime": "~6.1.2",
20 | "@expo/vector-icons": "^15.0.2",
21 | "expo": "^54.0.7",
22 | "expo-application": "~7.0.7",
23 | "expo-audio": "~1.0.11",
24 | "expo-build-properties": "~1.0.8",
25 | "expo-constants": "~18.0.8",
26 | "expo-dev-client": "~6.0.12",
27 | "expo-device": "~8.0.7",
28 | "expo-file-system": "~19.0.14",
29 | "expo-font": "~14.0.8",
30 | "expo-linking": "~8.0.8",
31 | "expo-router": "~6.0.4",
32 | "expo-splash-screen": "~31.0.10",
33 | "expo-status-bar": "~3.0.8",
34 | "expo-system-ui": "~6.0.7",
35 | "expo-video": "~3.0.11",
36 | "expo-web-browser": "~15.0.7",
37 | "react": "19.1.0",
38 | "react-dom": "19.1.0",
39 | "react-native": "npm:react-native-tvos@0.81.4-0",
40 | "react-native-gesture-handler": "~2.28.0",
41 | "react-native-reanimated": "~4.1.0",
42 | "react-native-safe-area-context": "~5.6.0",
43 | "react-native-screens": "~4.16.0",
44 | "react-native-size-matters": "^0.4.2",
45 | "react-native-web": "^0.21.0",
46 | "react-native-worklets": "0.5.1"
47 | },
48 | "devDependencies": {
49 | "@babel/core": "^7.20.0",
50 | "@react-native-community/cli": "^14.1.1",
51 | "@react-native-tvos/config-tv": "^0.1.4",
52 | "@types/react": "~19.1.10",
53 | "eslint": "^8.57.0",
54 | "eslint-config-expo": "~10.0.0",
55 | "patch-package": "^8.0.0",
56 | "postinstall-postinstall": "^2.1.0",
57 | "typescript": "~5.9.2"
58 | },
59 | "expo": {
60 | "install": {
61 | "exclude": [
62 | "react-native"
63 | ]
64 | }
65 | },
66 | "private": true
67 | }
68 |
--------------------------------------------------------------------------------
/components/AudioTest.tsx:
--------------------------------------------------------------------------------
1 | import { scale } from 'react-native-size-matters';
2 | import {
3 | AudioPlayerOptions,
4 | AudioSource,
5 | AudioStatus,
6 | useAudioPlayer,
7 | useAudioPlayerStatus,
8 | } from 'expo-audio';
9 | import { Platform, StyleSheet, View } from 'react-native';
10 | import { DemoButton } from './DemoButton';
11 | import { fractionCompleteFromPosition, ProgressBar } from './ProgressBar';
12 |
13 | const source: AudioSource =
14 | require('@/assets/audio/paza-moduless.mp3') as AudioSource;
15 | const options: AudioPlayerOptions = {
16 | updateInterval: 1000,
17 | downloadFirst: false,
18 | };
19 | export default function App() {
20 | const styles = useAudioStyles();
21 | const player = useAudioPlayer(source, options);
22 | const status: AudioStatus = useAudioPlayerStatus(player);
23 | const fractionComplete = fractionCompleteFromPosition(
24 | status.currentTime,
25 | status.duration,
26 | );
27 | const isPlaying = status.playing;
28 |
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | (isPlaying ? player.pause() : player.play())}
40 | />
41 | {
44 | player.seekTo(0);
45 | player.play();
46 | }}
47 | />
48 |
49 |
50 | );
51 | }
52 |
53 | const useAudioStyles = () => {
54 | return StyleSheet.create({
55 | container: {
56 | flex: 1,
57 | flexDirection: Platform.isTV ? 'row' : 'column',
58 | justifyContent: 'center',
59 | alignItems: 'center',
60 | },
61 | barContainer: {
62 | borderWidth: scale(1),
63 | borderColor: 'black',
64 | width: scale(400),
65 | },
66 | buttons: {
67 | justifyContent: 'center',
68 | alignItems: Platform.isTV ? 'flex-start' : 'center',
69 | },
70 | });
71 | };
72 |
--------------------------------------------------------------------------------
/scripts/reset-project.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * This script is used to reset the project to a blank state.
5 | * It moves the /app directory to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file.
6 | * You can remove the `reset-project` script from package.json and safely delete this file after running it.
7 | */
8 |
9 | const fs = require('fs');
10 | const path = require('path');
11 |
12 | const root = process.cwd();
13 | const oldDirPath = path.join(root, 'app');
14 | const newDirPath = path.join(root, 'app-example');
15 | const newAppDirPath = path.join(root, 'app');
16 |
17 | const indexContent = `import { Text, View } from "react-native";
18 |
19 | export default function Index() {
20 | return (
21 |
28 | Edit app/index.tsx to edit this screen.
29 |
30 | );
31 | }
32 | `;
33 |
34 | const layoutContent = `import { Stack } from "expo-router";
35 |
36 | export default function RootLayout() {
37 | return (
38 |
39 |
40 |
41 | );
42 | }
43 | `;
44 |
45 | fs.rename(oldDirPath, newDirPath, (error) => {
46 | if (error) {
47 | return console.error(`Error renaming directory: ${error}`);
48 | }
49 | console.log('/app moved to /app-example.');
50 |
51 | fs.mkdir(newAppDirPath, { recursive: true }, (error) => {
52 | if (error) {
53 | return console.error(`Error creating new app directory: ${error}`);
54 | }
55 | console.log('New /app directory created.');
56 |
57 | const indexPath = path.join(newAppDirPath, 'index.tsx');
58 | fs.writeFile(indexPath, indexContent, (error) => {
59 | if (error) {
60 | return console.error(`Error creating index.tsx: ${error}`);
61 | }
62 | console.log('app/index.tsx created.');
63 |
64 | const layoutPath = path.join(newAppDirPath, '_layout.tsx');
65 | fs.writeFile(layoutPath, layoutContent, (error) => {
66 | if (error) {
67 | return console.error(`Error creating _layout.tsx: ${error}`);
68 | }
69 | console.log('app/_layout.tsx created.');
70 | });
71 | });
72 | });
73 | });
74 |
--------------------------------------------------------------------------------
/components/ParallaxScrollView.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren, ReactElement } from 'react';
2 | import { StyleSheet, useColorScheme } from 'react-native';
3 | import Animated, {
4 | interpolate,
5 | useAnimatedRef,
6 | useAnimatedStyle,
7 | useScrollViewOffset,
8 | } from 'react-native-reanimated';
9 | import { scale } from 'react-native-size-matters';
10 |
11 | import { ThemedView } from '@/components/ThemedView';
12 |
13 | type Props = PropsWithChildren<{
14 | headerImage: ReactElement;
15 | headerBackgroundColor: { dark: string; light: string };
16 | }>;
17 |
18 | export default function ParallaxScrollView({
19 | children,
20 | headerImage,
21 | headerBackgroundColor,
22 | }: Props) {
23 | const colorScheme = useColorScheme() ?? 'light';
24 | const scrollRef = useAnimatedRef();
25 | const scrollOffset = useScrollViewOffset(scrollRef);
26 |
27 | const HEADER_HEIGHT = scale(75);
28 |
29 | const headerAnimatedStyle = useAnimatedStyle(() => {
30 | return {
31 | transform: [
32 | {
33 | translateY: interpolate(
34 | scrollOffset.value,
35 | [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
36 | [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
37 | ),
38 | },
39 | {
40 | scale: interpolate(
41 | scrollOffset.value,
42 | [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
43 | [2, 1, 1],
44 | ),
45 | },
46 | ],
47 | };
48 | });
49 |
50 | return (
51 |
52 |
53 |
60 | {headerImage}
61 |
62 | {children}
63 |
64 |
65 | );
66 | }
67 |
68 | const styles = StyleSheet.create({
69 | container: {
70 | flex: 1,
71 | },
72 | header: {
73 | height: scale(75),
74 | overflow: 'hidden',
75 | },
76 | content: {
77 | flex: 1,
78 | padding: scale(32),
79 | gap: scale(16),
80 | overflow: 'hidden',
81 | },
82 | });
83 |
--------------------------------------------------------------------------------
/app/modal.tsx:
--------------------------------------------------------------------------------
1 | import Ionicons from '@expo/vector-icons/Ionicons';
2 | import { Link } from 'expo-router';
3 | import { StyleSheet, Pressable } from 'react-native';
4 | import { scale } from 'react-native-size-matters';
5 |
6 | import ParallaxScrollView from '@/components/ParallaxScrollView';
7 | import { ThemedText } from '@/components/ThemedText';
8 | import { ThemedView } from '@/components/ThemedView';
9 |
10 | import { reactNativeInfo } from '@/constants/ReactNativeInfo';
11 |
12 | export default function Modal() {
13 | const { rnVersion, routerVersion, hermesVersion, uiManager } =
14 | reactNativeInfo;
15 | // If the page was reloaded or navigated to directly, then the modal should be presented as
16 | // a full screen page. You may need to change the UI to account for this.
17 | return (
18 |
26 | }
27 | >
28 |
29 | About this demo
30 | {`expo-router: ${routerVersion}`}
31 | {`react-native-tvos: ${rnVersion}`}
32 | {`Hermes bytecode version: ${JSON.stringify(
33 | hermesVersion,
34 | null,
35 | 2,
36 | )}`}
37 | {`${
38 | uiManager === 'Fabric' ? 'Fabric enabled' : ''
39 | }`}
40 |
41 | {/* Use `../` as a simple way to navigate to the root. This is not analogous to "goBack". */}
42 |
43 |
44 | {({ focused }) => (
45 |
46 | Dismiss
47 |
48 | )}
49 |
50 |
51 |
52 | );
53 | }
54 |
55 | const styles = StyleSheet.create({
56 | titleContainer: {
57 | flexDirection: 'row',
58 | alignItems: 'center',
59 | gap: scale(8),
60 | },
61 | stepContainer: {
62 | gap: scale(8),
63 | marginBottom: scale(8),
64 | },
65 | headerImage: {
66 | color: '#1D3D47',
67 | bottom: 0,
68 | left: scale(10),
69 | position: 'absolute',
70 | },
71 | });
72 |
--------------------------------------------------------------------------------
/layouts/TabLayout.web.tsx:
--------------------------------------------------------------------------------
1 | import { Tabs } from 'expo-router';
2 | import React from 'react';
3 | import { Platform, Pressable } from 'react-native';
4 | import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
5 | import { Colors } from '@/constants/Colors';
6 | import { useColorScheme } from '@/hooks/useColorScheme';
7 | import { useTextStyles } from '@/hooks/useTextStyles';
8 |
9 | /**
10 | * This layout is required for the web platform.
11 | */
12 | export default function TabLayout() {
13 | const colorScheme = useColorScheme();
14 | const textStyles = useTextStyles();
15 |
16 | const tabBarButton = (props: any) => {
17 | const style: any = props.style ?? {};
18 | return (
19 | [
22 | style,
23 | {
24 | opacity: pressed || focused ? 0.6 : 1.0,
25 | },
26 | ]}
27 | />
28 | );
29 | };
30 |
31 | return (
32 |
47 | null,
54 | }}
55 | />
56 | null,
63 | }}
64 | />
65 | null,
77 | }
78 | }
79 | />
80 | null,
92 | }
93 | }
94 | />
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/app/(tabs)/tv_focus.tsx:
--------------------------------------------------------------------------------
1 | import Ionicons from '@expo/vector-icons/Ionicons';
2 | import { StyleSheet, Platform } from 'react-native';
3 | import { scale } from 'react-native-size-matters';
4 |
5 | import { Collapsible } from '@/components/Collapsible';
6 | import ParallaxScrollView from '@/components/ParallaxScrollView';
7 | import { ThemedText } from '@/components/ThemedText';
8 | import { ThemedView } from '@/components/ThemedView';
9 | import { EventHandlingDemo } from '@/components/EventHandlingDemo';
10 |
11 | export default function FocusDemoScreen() {
12 | return (
13 |
21 | }
22 | >
23 |
24 | TV event handling demo
25 |
26 |
27 | Demo of focus handling and TV remote event handling in{' '}
28 | Pressable and{' '}
29 | Touchable components.
30 |
31 |
32 |
33 | • On TV platforms, these components have "onFocus()" and "onBlur()"
34 | props, in addition to the usual "onPress()". These can be used to
35 | modify the style of the component when it is navigated to or navigated
36 | away from by the TV focus engine. In addition, the functional forms of
37 | the Pressable style prop and the Pressable content, which in React
38 | Native core take a "pressed" boolean parameter, can also take
39 | "focused" as a parameter on TV platforms.
40 |
41 |
42 | • As you use the arrow keys to navigate around the screen, the demo
43 | uses the above props to update lists of recent events.
44 |
45 |
46 | • In RNTV 0.76.2, the focus, blur, pressIn, and pressOut events of
47 | Pressable and Touchable components are implemented as core React
48 | Native events, emitted directly from native code for better
49 | performance. They can be received by containing views in either the
50 | capture or bubble phase. This demo shows how information can be
51 | attached to these events by a Pressable, and then received by a
52 | containing View's event handler.
53 |
54 |
55 | {Platform.isTV ? (
56 |
57 | ) : (
58 |
59 | Run this on Apple TV or Android TV to see the demo.
60 |
61 | )}
62 |
63 | );
64 | }
65 |
66 | const styles = StyleSheet.create({
67 | headerImage: {
68 | color: '#808080',
69 | bottom: scale(-30),
70 | left: 0,
71 | position: 'absolute',
72 | },
73 | titleContainer: {
74 | flexDirection: 'row',
75 | gap: scale(8),
76 | },
77 | });
78 |
--------------------------------------------------------------------------------
/app/(tabs)/index.tsx:
--------------------------------------------------------------------------------
1 | import { Image, StyleSheet, Platform, Pressable } from 'react-native';
2 | import { Link } from 'expo-router';
3 | import { scale } from 'react-native-size-matters';
4 |
5 | import { HelloWave } from '@/components/HelloWave';
6 | import ParallaxScrollView from '@/components/ParallaxScrollView';
7 | import { ThemedText } from '@/components/ThemedText';
8 | import { ThemedView } from '@/components/ThemedView';
9 |
10 | export default function HomeScreen() {
11 | return (
12 |
19 | }
20 | >
21 |
22 | Welcome!
23 |
24 |
25 |
26 | Step 1: Try it
27 |
28 | Edit{' '}
29 | app/(tabs)/index.tsx{' '}
30 | to see changes. Press{' '}
31 |
32 | {Platform.select({ ios: 'cmd + d', android: 'cmd + m' })}
33 | {' '}
34 | to open developer tools.
35 |
36 |
37 |
38 | Step 2: Explore
39 |
40 | Tap the Explore tab to learn more about what's included in this
41 | starter app.
42 |
43 |
44 |
45 | Step 3: Get a fresh start
46 |
47 | When you're ready, run{' '}
48 | npm run reset-project{' '}
49 | to get a fresh app{' '}
50 | directory. This will move the current{' '}
51 | app to{' '}
52 | app-example.
53 |
54 |
55 |
56 |
57 |
58 | {({ focused }) => {
59 | return (
60 |
64 | About this demo
65 |
66 | );
67 | }}
68 |
69 |
70 |
71 |
72 | );
73 | }
74 |
75 | const styles = StyleSheet.create({
76 | titleContainer: {
77 | flexDirection: 'row',
78 | alignItems: 'center',
79 | gap: scale(8),
80 | },
81 | stepContainer: {
82 | gap: scale(8),
83 | marginBottom: scale(8),
84 | },
85 | reactLogo: {
86 | height: scale(75),
87 | width: scale(150),
88 | bottom: 0,
89 | left: 0,
90 | position: 'absolute',
91 | },
92 | });
93 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ExpoRouterTV 👋
2 |
3 | This is an [Expo Router](https://docs.expo.dev/router/introduction/) SDK 52 project demonstrating how Expo apps can be built for Apple TV and Android TV.
4 |
5 | 
6 |
7 | 
8 |
9 | Some of the packages used:
10 |
11 | - The [React Native TV fork](https://github.com/react-native-tvos/react-native-tvos), which supports both phone (Android and iOS) and TV (Android TV and Apple TV) targets
12 | - The [React Native TV config plugin](https://github.com/react-native-tvos/config-tv/tree/main/packages/config-tv), to allow Expo prebuild to modify the project's native files for TV builds
13 | - The [expo-router native tabs](https://docs.expo.dev/router/advanced/native-tabs/) experimental feature in Expo SDK 54.
14 | - The [react-native-size-matters](https://github.com/nirsky/react-native-size-matters) package, that provides methods and stylesheets for easily scaling the app to different screen sizes.
15 | - The [expo-video](https://docs.expo.dev/versions/latest/sdk/video/) package, providing cross-platform video playback for both mobile and TV devices.
16 |
17 | ## 🚀 How to use
18 |
19 | - `cd` into the project
20 |
21 | - TV builds:
22 |
23 | ```sh
24 | yarn
25 | yarn prebuild:tv # Executes Expo prebuild with TV modifications
26 | yarn ios # Build and run for Apple TV
27 | yarn android # Build and run for Android TV
28 | ```
29 |
30 | - Mobile builds:
31 |
32 | ```sh
33 | yarn
34 | yarn prebuild # Executes Expo prebuild without TV modifications
35 | yarn ios # Build and run for iOS
36 | yarn android # Build and run for Android mobile
37 | ```
38 |
39 | ## Development
40 |
41 | You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
42 |
43 | This project includes a [demo](./components/EventHandlingDemo.tsx) showing how to use React Native TV APIs to highlight controls as the user navigates the screen with the remote control.
44 |
45 | ## TV specific file extensions
46 |
47 | This project includes an [example Metro configuration](./metro.config.js) that allows Metro to resolve application source files with TV-specific code, indicated by specific file extensions (`*.ios.tv.tsx`, `*.android.tv.tsx`, `*.tv.tsx`).
48 |
49 | ## Get a fresh project
50 |
51 | When you're ready, run:
52 |
53 | ```bash
54 | npm run reset-project
55 | ```
56 |
57 | This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
58 |
59 | ## Learn more
60 |
61 | To learn more about developing your project with Expo, look at the following resources:
62 |
63 | - [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
64 | - [Learn Expo tutorial](https://docs.expo.dev/learn): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
65 |
66 | ## Join the community
67 |
68 | Join our community of developers creating universal apps.
69 |
70 | - [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
71 | - [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
72 |
--------------------------------------------------------------------------------
/components/VideoTest.tsx:
--------------------------------------------------------------------------------
1 | import { useVideoPlayer, VideoView, VideoPlayerStatus } from 'expo-video';
2 | import { useEffect, useRef, useState } from 'react';
3 | import { Platform, StyleSheet, View } from 'react-native';
4 | import { verticalScale } from 'react-native-size-matters';
5 | import { useInterval } from '@/hooks/useInterval';
6 | import { ProgressBar } from './ProgressBar';
7 | import { DemoButton } from './DemoButton';
8 |
9 | const videoSource =
10 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4';
11 |
12 | export default function VideoTest() {
13 | const styles = useVideoStyles();
14 | const ref: any = useRef(null);
15 | const [isPlaying, setIsPlaying] = useState(false);
16 | const [videoStatus, setVideoStatus] = useState('idle');
17 | const [fractionComplete, setFractionComplete] = useState(0);
18 |
19 | const fractionCompleteFromPosition = (
20 | position: number | undefined,
21 | duration: number | undefined,
22 | ) => {
23 | return duration !== undefined ? (position ?? 0) / duration : 0;
24 | };
25 |
26 | const player = useVideoPlayer(videoSource, (player) => {
27 | player.addListener('statusChange', (payload) => {
28 | setVideoStatus(payload.status);
29 | console.log(`video status = ${payload.status}`);
30 | });
31 | });
32 |
33 | useEffect(() => {
34 | if (player.playing) {
35 | setIsPlaying(true);
36 | } else {
37 | setIsPlaying(false);
38 | }
39 | }, [player.playing]);
40 |
41 | useEffect(() => {
42 | if (videoStatus === 'readyToPlay') {
43 | // Autoplay on start
44 | // player.play();
45 | }
46 | }, [videoStatus]);
47 |
48 | useInterval(() => {
49 | setFractionComplete(
50 | fractionCompleteFromPosition(player.currentTime, player.duration),
51 | );
52 | }, 1000);
53 |
54 | return (
55 |
56 |
57 | {videoStatus === 'readyToPlay' || Platform.OS === 'android' ? (
58 |
71 | ) : (
72 |
73 | )}
74 |
75 |
76 |
77 | {
80 | player.currentTime = 0;
81 | setFractionComplete(
82 | fractionCompleteFromPosition(player.currentTime, player.duration),
83 | );
84 | }}
85 | />
86 | {
89 | player.seekBy(-5);
90 | setFractionComplete(
91 | fractionCompleteFromPosition(player.currentTime, player.duration),
92 | );
93 | }}
94 | />
95 | {
98 | if (player.playing) {
99 | player.pause();
100 | } else {
101 | player.play();
102 | }
103 | }}
104 | />
105 | {
108 | player.seekBy(5);
109 | setFractionComplete(
110 | fractionCompleteFromPosition(player.currentTime, player.duration),
111 | );
112 | }}
113 | />
114 | {
117 | ref.current.enterFullscreen();
118 | }}
119 | />
120 |
121 |
122 | );
123 | }
124 |
125 | const useVideoStyles = () => {
126 | const vidHeight = verticalScale(200);
127 | const vidWidth = 2 * vidHeight;
128 | return StyleSheet.create({
129 | container: {
130 | flex: 1,
131 | flexDirection: Platform.isTV ? 'row' : 'column',
132 | justifyContent: 'center',
133 | alignItems: 'center',
134 | },
135 | videoStyle: {
136 | flex: 1,
137 | justifyContent: 'center',
138 | alignItems: 'center',
139 | width: vidWidth,
140 | height: vidHeight,
141 | },
142 | buttons: {
143 | justifyContent: 'center',
144 | alignItems: Platform.isTV ? 'flex-start' : 'center',
145 | },
146 | });
147 | };
148 |
--------------------------------------------------------------------------------
/app/(tabs)/explore.tsx:
--------------------------------------------------------------------------------
1 | import Ionicons from '@expo/vector-icons/Ionicons';
2 | import { StyleSheet, Image, Platform } from 'react-native';
3 | import { scale } from 'react-native-size-matters';
4 |
5 | import { Collapsible } from '@/components/Collapsible';
6 | import { ExternalLink } from '@/components/ExternalLink';
7 | import ParallaxScrollView from '@/components/ParallaxScrollView';
8 | import { ThemedText } from '@/components/ThemedText';
9 | import { ThemedView } from '@/components/ThemedView';
10 |
11 | export default function ExploreScreen() {
12 | return (
13 |
21 | }
22 | >
23 |
24 | Explore
25 |
26 |
27 | This app includes example code to help you get started.
28 |
29 |
30 |
31 | This app has three screens:{' '}
32 | app/(tabs)/index.tsx{' '}
33 | (the home screen),{' '}
34 | app/(tabs)/explore.tsx{' '}
35 | (the "Explore" screen), and{' '}
36 |
37 | app/(tabs)/tv_focus.tsx
38 | {' '}
39 | (the TV event demo screen).
40 |
41 |
42 | The layout file in{' '}
43 | app/(tabs)/_layout.tsx{' '}
44 | sets up the tab navigator.
45 |
46 |
47 | Learn more
48 |
49 |
50 |
51 |
52 | You can open this project on Android, iOS, and the web. To open the
53 | web version, press w{' '}
54 | in the terminal running this project.
55 |
56 |
57 |
58 |
59 | For static images, you can use the{' '}
60 | @2x and{' '}
61 | @3x suffixes to
62 | provide files for different screen densities
63 |
64 |
68 |
69 | Learn more
70 |
71 |
72 |
73 |
74 | Open app/_layout.tsx{' '}
75 | to see how to load{' '}
76 |
77 | custom fonts such as this one.
78 |
79 |
80 |
81 | Learn more
82 |
83 |
84 |
85 |
86 | This template has light and dark mode support. The{' '}
87 | useColorScheme() hook
88 | lets you inspect what the user's current color scheme is, and so you
89 | can adjust UI colors accordingly.
90 |
91 |
92 | Learn more
93 |
94 |
95 |
96 |
97 | This template includes an example of an animated component. The{' '}
98 |
99 | components/HelloWave.tsx
100 | {' '}
101 | component uses the powerful{' '}
102 |
103 | react-native-reanimated
104 | {' '}
105 | library to create a waving hand animation.
106 |
107 | {Platform.select({
108 | ios: (
109 |
110 | The{' '}
111 |
112 | components/ParallaxScrollView.tsx
113 | {' '}
114 | component provides a parallax effect for the header image.
115 |
116 | ),
117 | })}
118 |
119 |
120 | );
121 | }
122 |
123 | const styles = StyleSheet.create({
124 | headerImage: {
125 | color: '#808080',
126 | bottom: scale(-30),
127 | left: 0,
128 | position: 'absolute',
129 | },
130 | titleContainer: {
131 | flexDirection: 'row',
132 | gap: scale(8),
133 | },
134 | });
135 |
--------------------------------------------------------------------------------
/components/EventHandlingDemo.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | StyleSheet,
3 | Text,
4 | View,
5 | TVFocusGuideView,
6 | useTVEventHandler,
7 | Pressable,
8 | TouchableHighlight,
9 | TouchableOpacity,
10 | FocusEvent,
11 | BlurEvent,
12 | PressableProps,
13 | FlatList,
14 | ScrollView,
15 | } from 'react-native';
16 | import { useState } from 'react';
17 | import { scale } from 'react-native-size-matters';
18 |
19 | import { ThemedText } from '@/components/ThemedText';
20 | import { ThemedView } from '@/components/ThemedView';
21 | import { useThemeColor } from '@/hooks/useThemeColor';
22 |
23 | export function EventHandlingDemo() {
24 | const [remoteEventLog, setRemoteEventLog] = useState([]);
25 | const [pressableEventLog, setPressableEventLog] = useState([]);
26 |
27 | const logWithAppendedEntry = (log: string[], entry: string) => {
28 | const limit = 50;
29 | const newEventLog = log.slice(log.length === limit ? 1 : 0, limit);
30 | newEventLog.push(entry);
31 | return newEventLog;
32 | };
33 |
34 | const updatePressableLog = (entry: string) => {
35 | setPressableEventLog((log) => logWithAppendedEntry(log, entry));
36 | };
37 |
38 | useTVEventHandler((event) => {
39 | const { eventType, eventKeyAction } = event;
40 | if (eventType !== 'focus' && eventType !== 'blur') {
41 | setRemoteEventLog((log) =>
42 | logWithAppendedEntry(
43 | log,
44 | `type=${eventType}, action=${
45 | eventKeyAction !== undefined ? eventKeyAction : ''
46 | }`,
47 | ),
48 | );
49 | }
50 | });
51 |
52 | const styles = useDemoStyles();
53 |
54 | return (
55 |
56 |
57 |
58 |
59 |
60 |
61 | Remote control events
62 |
63 | (
67 | {item}
68 | )}
69 | />
70 |
71 |
72 |
73 |
74 |
75 | Native focus/blur/press events
76 |
77 | (
81 | {item}
82 | )}
83 | />
84 |
85 |
86 |
87 | {
90 | updatePressableLog(`Bubbled focus event from ${event.title}`);
91 | }}
92 | onBlur={(event: any) => {
93 | updatePressableLog(`Bubbled blur event from ${event.title}`);
94 | }}
95 | >
96 | View receives bubbled focus/blur events
97 |
98 |
99 |
104 |
108 |
112 |
113 |
114 |
115 | );
116 | }
117 |
118 | type ButtonEvent = (FocusEvent | BlurEvent) & { title?: string };
119 |
120 | type ButtonProps = {
121 | title: string;
122 | log: (entry: string) => void;
123 | disableFocusAndBlurEventBubbling?: boolean;
124 | };
125 |
126 | const handleFocusOrBlur = (
127 | event: ButtonEvent,
128 | props: ButtonProps,
129 | type: string,
130 | ) => {
131 | event.title = props.title; // Attach info to the event before it bubbles up
132 | props.log(`${props.title} ${type}`); // Log the event
133 | if (props.disableFocusAndBlurEventBubbling) {
134 | event.stopPropagation();
135 | }
136 | };
137 |
138 | const PressableButton = (props: PressableProps & ButtonProps) => {
139 | const styles = useDemoStyles();
140 |
141 | return (
142 | handleFocusOrBlur(event, props, 'focus')}
144 | onBlur={(event) => handleFocusOrBlur(event, props, 'blur')}
145 | onPress={() => props.log(`${props.title} press`)}
146 | onPressIn={() => props.log(`${props.title} pressIn`)}
147 | onPressOut={() => props.log(`${props.title} pressOut`)}
148 | onLongPress={() => props.log(`${props.title} longPress`)}
149 | style={({ pressed, focused }) =>
150 | pressed || focused ? styles.pressableFocused : styles.pressable
151 | }
152 | {...props}
153 | >
154 | {({ focused, pressed }) => {
155 | return (
156 |
157 | {pressed
158 | ? `${props.title} pressed`
159 | : focused
160 | ? `${props.title} focused`
161 | : props.title}
162 |
163 | );
164 | }}
165 |
166 | );
167 | };
168 |
169 | const TouchableOpacityButton = (props: ButtonProps) => {
170 | const styles = useDemoStyles();
171 | const [focused, setFocused] = useState(false);
172 | const [pressed, setPressed] = useState(false);
173 |
174 | return (
175 | {
179 | handleFocusOrBlur(event, props, 'focus');
180 | setFocused(true);
181 | }}
182 | onBlur={(event) => {
183 | handleFocusOrBlur(event, props, 'blur');
184 | setFocused(false);
185 | }}
186 | onPress={() => props.log(`${props.title} press`)}
187 | onPressIn={() => {
188 | props.log(`${props.title} pressIn`);
189 | setPressed(true);
190 | }}
191 | onPressOut={() => {
192 | props.log(`${props.title} pressOut`);
193 | setPressed(false);
194 | }}
195 | onLongPress={() => props.log(`${props.title} longPress`)}
196 | >
197 | {`${props.title}${
198 | pressed ? ' pressed' : focused ? ' focused' : ''
199 | }`}
200 |
201 | );
202 | };
203 |
204 | const TouchableHighlightButton = (props: ButtonProps) => {
205 | const styles = useDemoStyles();
206 | const underlayColor = useThemeColor({}, 'tint');
207 | const [focused, setFocused] = useState(false);
208 | const [pressed, setPressed] = useState(false);
209 | return (
210 | {
214 | handleFocusOrBlur(event, props, 'focus');
215 | setFocused(true);
216 | }}
217 | onBlur={(event) => {
218 | handleFocusOrBlur(event, props, 'blur');
219 | setFocused(false);
220 | }}
221 | onPress={() => props.log(`${props.title} press`)}
222 | onPressIn={() => {
223 | props.log(`${props.title} pressIn`);
224 | setPressed(true);
225 | }}
226 | onPressOut={() => {
227 | props.log(`${props.title} pressOut`);
228 | setPressed(false);
229 | }}
230 | onLongPress={() => props.log(`${props.title} longPress`)}
231 | >
232 | {`${props.title}${
233 | pressed ? ' pressed' : focused ? ' focused' : ''
234 | }`}
235 |
236 | );
237 | };
238 |
239 | const useDemoStyles = function () {
240 | const highlightColor = useThemeColor({}, 'link');
241 | const backgroundColor = useThemeColor({}, 'background');
242 | const tintColor = useThemeColor({}, 'tint');
243 | const textColor = useThemeColor({}, 'text');
244 | const buttonContainerBackgroundColor = useThemeColor(
245 | {},
246 | 'containerBackground',
247 | );
248 | return StyleSheet.create({
249 | container: {
250 | flex: 1,
251 | flexDirection: 'row',
252 | alignItems: 'flex-start',
253 | justifyContent: 'flex-start',
254 | },
255 | buttonsContainer: {
256 | flex: 3,
257 | justifyContent: 'flex-start',
258 | alignItems: 'center',
259 | backgroundColor: buttonContainerBackgroundColor,
260 | padding: scale(20),
261 | },
262 | logContainer: {
263 | flex: 3,
264 | flexDirection: 'row',
265 | padding: scale(5),
266 | margin: scale(5),
267 | alignItems: 'flex-start',
268 | justifyContent: 'flex-start',
269 | },
270 | logText: {
271 | maxHeight: scale(150),
272 | width: scale(100),
273 | fontSize: scale(5),
274 | lineHeight: scale(7),
275 | alignItems: 'flex-end',
276 | justifyContent: 'flex-end',
277 | },
278 | pressable: {
279 | borderColor: highlightColor,
280 | backgroundColor: textColor,
281 | borderWidth: 1,
282 | borderRadius: scale(5),
283 | margin: scale(5),
284 | },
285 | pressableFocused: {
286 | borderColor: highlightColor,
287 | backgroundColor: tintColor,
288 | borderWidth: 1,
289 | borderRadius: scale(5),
290 | margin: scale(5),
291 | },
292 | pressableText: {
293 | color: backgroundColor,
294 | fontSize: scale(8),
295 | margin: scale(2),
296 | },
297 | });
298 | };
299 |
--------------------------------------------------------------------------------