rn-to-tv-quickstart

📁 giolaq/multi-tv-dev-power 📅 4 days ago
1
总安装量
1
周安装量
#45550
全站排名
安装命令
npx skills add https://github.com/giolaq/multi-tv-dev-power --skill rn-to-tv-quickstart

Agent 安装分布

amp 1
opencode 1
kimi-cli 1
codex 1
github-copilot 1
gemini-cli 1

Skill 文档

React Native to TV: Quick Start Guide

You know React Native. Here’s what’s different for TV.

Official Docs:


The Big 5 Differences from Mobile

1. No Touch – Everything is Remote/D-pad

On mobile: Users tap buttons On TV: Users navigate with D-pad (up/down/left/right) + select

// Mobile: TouchableOpacity, Pressable just work
// TV: You need spatial navigation

import { SpatialNavigationNode } from 'react-tv-space-navigation';

const TVButton = ({ onPress, children }) => {
  return (
    <SpatialNavigationNode onSelect={onPress}>
      {({ isFocused }) => (
        <View style={[styles.button, isFocused && styles.focused]}>
          {children}
        </View>
      )}
    </SpatialNavigationNode>
  );
};

Key library: react-tv-space-navigation

Important: v6.0.0+ uses a different API than v5.x:

  • v6: <SpatialNavigationRoot> wrapper + <SpatialNavigationNode> components
  • v5: SpatialNavigation.init() + useFocusable() hook

2. Focus is King

Every interactive element MUST have a visible focus state. Users can’t tap what they can’t see.

const styles = StyleSheet.create({
  button: {
    padding: 16,
    backgroundColor: '#333',
  },
  // CRITICAL: Always show what's focused
  focused: {
    backgroundColor: '#555',
    borderWidth: 3,
    borderColor: '#fff',
    transform: [{ scale: 1.05 }],
  },
});

Focus Indicators (Use at least 2):

  • ✅ Border (3px+ white/colored)
  • ✅ Scale (1.05x – 1.1x)
  • ✅ Background color change
  • ✅ Shadow/glow effect
  • ✅ Opacity change

Testing Focus:

  • Navigate with D-pad/arrow keys
  • Focused element should be immediately obvious
  • Test in a dark room (10-foot viewing distance)
  • All interactive elements must be reachable button: { padding: 16, backgroundColor: ‘#333’, }, // CRITICAL: Always show what’s focused focused: { backgroundColor: ‘#555’, borderWidth: 3, borderColor: ‘#fff’, transform: [{ scale: 1.05 }], }, });

### 3. 10-Foot UI (Bigger Everything)

Users sit 10 feet away. What works on mobile is too small.

| Element | Mobile | TV |
|---------|--------|-----|
| Body text | 14-16px | 24-32px |
| Buttons | 44px height | 60-80px height |
| Touch targets | 44x44px | 80x80px+ |
| Margins | 16px | 48px+ |

### 4. Safe Zones (TV Overscan)

TVs crop edges. Keep content away from borders.

```typescript
const safeZones = {
  horizontal: 48,  // Left/right padding
  vertical: 27,    // Top/bottom padding
};

5. Landscape Only

TV apps are always landscape. Configure in app.json:

{ "expo": { "orientation": "landscape" } }

Platform Quick Reference

Platform Technology Remote Events
Android TV Expo + react-native-tvos KeyEvent
Apple TV Expo + react-native-tvos TVEventHandler
Fire TV FOS Expo + react-native-tvos KeyEvent
Fire TV Vega Amazon Vega SDK Kepler TVEventHandler
Web TV React Native Web Keyboard events

Prerequisites

For Android TV / Fire TV (Fire OS)

  • Node.js LTS (macOS or Linux)
  • Android Studio Iguana or later
  • Android SDK API 31+ with TV system image
  • Android TV emulator configured

For Apple TV (tvOS)

  • Node.js LTS on macOS
  • Xcode 16+
  • tvOS SDK 17+ (install via xcodebuild -downloadAllPlatforms)

For Fire TV Vega OS

  • Amazon Vega SDK installed (see Vega section below)

Complete Project Setup

This guide creates a production-ready monorepo structure supporting all TV platforms.

Claude will ask for your app name and package ID when setting up.

Step 1: Create Monorepo Structure

# Create project root
mkdir <PROJECT_NAME> && cd <PROJECT_NAME>

# Initialize Yarn
yarn init -y
yarn set version stable

# Create directory structure
mkdir -p apps packages/shared-ui/src

Step 2: Configure Root package.json

Create package.json:

{
  "name": "@<PROJECT_NAME>/monorepo",
  "version": "1.0.0",
  "private": true,
  "packageManager": "yarn@4.5.0",
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "dev": "yarn workspace @<PROJECT_NAME>/expo-multi-tv start",
    "dev:android": "yarn workspace @<PROJECT_NAME>/expo-multi-tv android",
    "dev:ios": "yarn workspace @<PROJECT_NAME>/expo-multi-tv ios",
    "dev:web": "yarn workspace @<PROJECT_NAME>/expo-multi-tv web",
    "dev:vega": "yarn workspace @<PROJECT_NAME>/vega start",
    "build:vega": "yarn workspace @<PROJECT_NAME>/vega build",
    "lint:all": "yarn workspaces foreach -pt run lint",
    "typecheck": "yarn workspaces foreach -pt run typecheck"
  },
  "resolutions": {
    "metro-source-map": "0.80.12"
  }
}

CRITICAL: The metro-source-map resolution fixes babel plugin errors in monorepos.

Step 3: Create Shared TypeScript Config

Create tsconfig.base.json:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "CommonJS",
    "lib": ["ES2021"],
    "jsx": "react-native",
    "strict": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true
  }
}

Step 4: Create Root Babel Config

Create babel.config.js:

const path = require('path');

module.exports = function (api) {
  api.cache(true);

  let reanimatedPlugin;
  try {
    reanimatedPlugin = require.resolve('react-native-reanimated/plugin', {
      paths: [path.join(__dirname, 'apps/expo-multi-tv/node_modules')]
    });
  } catch (e) {
    reanimatedPlugin = null;
  }

  return {
    presets: ['babel-preset-expo'],
    plugins: reanimatedPlugin ? [reanimatedPlugin] : [],
  };
};

Step 5: Create Expo TV App

cd apps

# Create Expo TV project
npx create-expo-app@latest expo-multi-tv -e with-tv

cd expo-multi-tv

Update apps/expo-multi-tv/package.json:

{
  "name": "@<PROJECT_NAME>/expo-multi-tv",
  "dependencies": {
    "@<PROJECT_NAME>/shared-ui": "*"
  }
}

Create apps/expo-multi-tv/metro.config.js:

const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');

const config = getDefaultConfig(projectRoot);

config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];

module.exports = config;

Step 6: Add Vega App (Fire TV Vega OS)

# Create and enter vega directory first (files generate in current directory)
mkdir -p apps/vega
cd apps/vega

# Generate Vega project
vega project generate -n <APP_NAME> --template helloWorld

npm install

Update apps/vega/package.json:

{
  "name": "@<PROJECT_NAME>/vega",
  "scripts": {
    "build:debug": "react-native build-kepler --build-type Debug",
    "build:release": "react-native build-kepler --build-type Release"
  },
  "dependencies": {
    "@<PROJECT_NAME>/shared-ui": "*",
    "react-tv-space-navigation": "^6.0.0-beta1"
  }
}

CRITICAL: Update apps/vega/manifest.toml – Add the [processes] section or the app will crash on launch:

schema-version = 1

[package]
title = "My App"
version = "0.1.0"
id = "com.mycompany.myapp"

[components]
[[components.interactive]]
id = "com.mycompany.myapp.main"
runtime-module = "/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0"
launch-type = "singleton"
categories = ["com.amazon.category.main"]

# REQUIRED: Without this section, the app crashes immediately on launch
[processes]
[[processes.group]]
component-ids = ["com.mycompany.myapp.main"]

Create apps/vega/metro.config.js:

const path = require('path');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');

const config = {
  watchFolders: [workspaceRoot],
  resolver: {
    nodeModulesPaths: [
      path.resolve(projectRoot, 'node_modules'),
      path.resolve(workspaceRoot, 'node_modules'),
    ],
    sourceExts: ['kepler.tsx', 'kepler.ts', 'tsx', 'ts', 'js', 'jsx', 'json'],
  },
};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);

Step 6b: Configure Spatial Navigation for Vega

react-tv-space-navigation works with Vega but requires a remote control configuration.

Create packages/shared-ui/src/app/remote-control/SupportedKeys.ts:

export enum SupportedKeys {
  Up = 'up', Down = 'down', Left = 'left', Right = 'right',
  Enter = 'enter', Back = 'back', PlayPause = 'playPause',
  Rewind = 'rewind', FastForward = 'fastForward',
}

Create packages/shared-ui/src/app/remote-control/RemoteControlManager.kepler.ts:

import { SupportedKeys } from './SupportedKeys';

const EVENT_MAP: Record<string, SupportedKeys> = {
  left: SupportedKeys.Left, right: SupportedKeys.Right,
  down: SupportedKeys.Down, up: SupportedKeys.Up,
  select: SupportedKeys.Enter, back: SupportedKeys.Back,
};

class RemoteControlManager {
  private listeners = new Set<(e: SupportedKeys) => void>();
  constructor() {
    const { TVEventHandler } = require('react-native');
    new TVEventHandler().enable(this, (_: any, e: any) => {
      if (e.eventKeyAction === 0 || e.eventKeyAction === undefined) {
        const key = EVENT_MAP[e.eventType];
        if (key) this.listeners.forEach(l => l(key));
      }
    });
  }
  addKeydownListener = (l: (e: SupportedKeys) => void) => { this.listeners.add(l); return l; };
  removeKeydownListener = (l: (e: SupportedKeys) => void) => { this.listeners.delete(l); };
}
export default new RemoteControlManager();

Create packages/shared-ui/src/app/configureRemoteControl.ts:

import { Directions, SpatialNavigation } from 'react-tv-space-navigation';
import { SupportedKeys } from './remote-control/SupportedKeys';
import RemoteControlManager from './remote-control/RemoteControlManager';

SpatialNavigation.configureRemoteControl({
  remoteControlSubscriber: (callback) => {
    const map = {
      [SupportedKeys.Right]: Directions.RIGHT, [SupportedKeys.Left]: Directions.LEFT,
      [SupportedKeys.Up]: Directions.UP, [SupportedKeys.Down]: Directions.DOWN,
      [SupportedKeys.Enter]: Directions.ENTER,
    };
    return RemoteControlManager.addKeydownListener((k) => callback(map[k] ?? null));
  },
  remoteControlUnsubscriber: (l) => RemoteControlManager.removeKeydownListener(l),
});

Import in Vega App.tsx:

import '../../../packages/shared-ui/src/app/configureRemoteControl';

Step 7: Create Shared UI Package

Create packages/shared-ui/package.json:

{
  "name": "@<PROJECT_NAME>/shared-ui",
  "version": "1.0.0",
  "main": "src/index.ts",
  "types": "src/index.ts",
  "peerDependencies": {
    "react": "*",
    "react-native": "*"
  }
}

Create packages/shared-ui/tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

Create packages/shared-ui/src/index.ts:

// Theme
export * from './theme';

// Components
export * from './components/FocusablePressable';

// Hooks
export * from './hooks/useScale';

Step 8: Create Theme System

Create packages/shared-ui/src/theme/index.ts:

export const colors = {
  primary: '#E50914',
  background: '#141414',
  card: '#2F2F2F',
  text: '#FFFFFF',
  textSecondary: '#B3B3B3',
};

export const spacing = {
  xs: 4,
  sm: 8,
  md: 16,
  lg: 24,
  xl: 32,
  xxl: 48,
};

export const safeZones = {
  horizontal: 48,
  vertical: 27,
};

Step 9: Create useScale Hook

Create packages/shared-ui/src/hooks/useScale.ts:

import { Dimensions } from 'react-native';

const BASE_WIDTH = 1920;
const BASE_HEIGHT = 1080;

export const useScale = () => {
  const { width, height } = Dimensions.get('window');
  const scaleWidth = width / BASE_WIDTH;
  const scaleHeight = height / BASE_HEIGHT;
  const scale = Math.min(scaleWidth, scaleHeight);

  return {
    scale: (size: number) => size * scale,
    width,
    height,
  };
};

Step 10: Create FocusablePressable Component

Create packages/shared-ui/src/components/FocusablePressable.tsx:

import React from 'react';
import { Pressable, StyleSheet, ViewStyle } from 'react-native';
import { SpatialNavigationNode } from 'react-tv-space-navigation';

interface FocusablePressableProps {
  children: React.ReactNode;
  onPress?: () => void;
  style?: ViewStyle;
  focusedStyle?: ViewStyle;
}

export const FocusablePressable: React.FC<FocusablePressableProps> = ({
  children,
  onPress,
  style,
  focusedStyle,
}) => {
  return (
    <SpatialNavigationNode onSelect={onPress}>
      {({ isFocused }) => (
        <Pressable
          onPress={onPress}
          style={[
            styles.container,
            style,
            isFocused && styles.focused,
            isFocused && focusedStyle,
          ]}
        >
          {children}
        </Pressable>
      )}
    </SpatialNavigationNode>
  );
};

const styles = StyleSheet.create({
  container: {
    padding: 16,
  },
  focused: {
    borderWidth: 3,
    borderColor: '#fff',
    transform: [{ scale: 1.05 }],
  },
});

Important: Pass onPress to both SpatialNavigationNode (for remote/D-pad) AND Pressable (for touch/click).

Focus Highlighting: The isFocused prop from the render function is used to apply visual feedback:

{({ isFocused }) => (
  <Pressable
    onPress={onPress}
    style={[
      styles.container,
      style,
      isFocused && styles.focused,  // Apply focus styles
      isFocused && focusedStyle,    // Allow custom focus styles
    ]}
  >
    {children}
  </Pressable>
)}

The default focus styles provide:

  • 3px white border – Clear visual boundary
  • 1.05x scale – Subtle size increase
  • Custom focusedStyle prop – Override with app-specific styles

Best Practices for Focus:

  • Always use high-contrast colors (white/yellow on dark backgrounds)
  • Combine multiple indicators (border + scale + color)
  • Test on actual TV hardware (emulators may not show true visibility)
  • Ensure focus is visible from 10 feet away

Step 11: Install Dependencies and Run

# From project root
cd ../..
yarn install

# Run on platforms
yarn dev:android    # Android TV / Fire TV FOS
yarn dev:ios        # Apple TV
yarn dev:web        # Web TV
yarn dev:vega       # Fire TV Vega OS

Final Project Structure

<PROJECT_NAME>/
├── apps/
│   ├── expo-multi-tv/      # Android TV, Apple TV, Fire TV FOS, Web
│   │   ├── App.tsx         # Main app entry point
│   │   ├── app.json
│   │   ├── metro.config.js
│   │   └── package.json
│   └── vega/               # Fire TV Vega OS
│       ├── App.tsx
│       ├── metro.config.js
│       └── package.json
├── packages/
│   └── shared-ui/          # Shared components & utilities
│       ├── src/
│       │   ├── components/
│       │   ├── hooks/
│       │   ├── theme/
│       │   └── index.ts
│       ├── package.json
│       └── tsconfig.json
├── babel.config.js
├── package.json
├── tsconfig.base.json
└── yarn.lock

Platform-Specific File Resolution

Metro bundler automatically resolves platform files:

  • .kepler.ts/tsx – Fire TV Vega OS (highest priority for Vega)
  • .android.ts/tsx – Android TV & Fire TV Fire OS
  • .ios.ts/tsx – Apple TV (tvOS)
  • .web.ts/tsx – Web platforms
  • .ts/tsx – Default/shared implementation

Example:

RemoteControlManager.android.ts  → Android TV / Fire TV FOS
RemoteControlManager.ios.ts      → Apple TV
RemoteControlManager.kepler.ts   → Fire TV Vega

Build Commands

Expo TV App (Android TV, Apple TV, Fire TV FOS, Web)

yarn dev:android    # Run on Android TV emulator
yarn dev:ios        # Run on Apple TV simulator
yarn dev:web        # Run in browser

# Production builds
npx expo build:android
npx expo build:ios
npx expo export --platform web

Vega App (Fire TV Vega OS)

yarn dev:vega       # Start Metro for Vega

# Build VPKG
vega build --arch armv7 --buildType release     # Fire TV Stick
vega build --arch aarch64 --buildType release   # Fire TV (ARM64)
vega build --arch x86_64 --buildType debug      # Virtual devices

# Deploy
vega device list
vega install --vpkg build/armv7-release/<APP_NAME>.vpkg
vega run --packageId <PACKAGE_ID>

Common Build Issues & Solutions

Java Version Error

Error: Android Gradle plugin requires Java 17 to run. You are currently using Java 11.

Solution: Create android/gradle.properties:

org.gradle.java.home=/path/to/java17
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m
hermesEnabled=true
android.useAndroidX=true
android.enableJetifier=true

Metro Source Map Error (Monorepo)

Error: Cannot find module 'metro-source-map/private/source-map'

Cause: Expo’s metro package expects a private/ directory that doesn’t exist in metro-source-map 0.80.x

Solution: Create symlinks after yarn install:

cd node_modules/metro-source-map
mkdir -p private/Consumer
ln -sf ../src/source-map.js private/source-map.js
for file in src/Consumer/*.js; do 
  ln -sf "../../$file" "private/Consumer/$(basename $file)"
done

Better Solution: Add to package.json scripts:

{
  "scripts": {
    "postinstall": "node scripts/fix-metro-source-map.js"
  }
}

Create scripts/fix-metro-source-map.js:

const fs = require('fs');
const path = require('path');

const metroSourceMapPath = path.join(__dirname, '../node_modules/metro-source-map');
const privatePath = path.join(metroSourceMapPath, 'private');
const consumerPath = path.join(privatePath, 'Consumer');

if (!fs.existsSync(privatePath)) {
  fs.mkdirSync(privatePath, { recursive: true });
}
if (!fs.existsSync(consumerPath)) {
  fs.mkdirSync(consumerPath, { recursive: true });
}

// Create source-map.js symlink
const sourceMapSrc = path.join(metroSourceMapPath, 'src/source-map.js');
const sourceMapDest = path.join(privatePath, 'source-map.js');
if (!fs.existsSync(sourceMapDest)) {
  fs.symlinkSync(path.relative(privatePath, sourceMapSrc), sourceMapDest);
}

// Create Consumer symlinks
const consumerSrcPath = path.join(metroSourceMapPath, 'src/Consumer');
const files = fs.readdirSync(consumerSrcPath).filter(f => f.endsWith('.js'));
files.forEach(file => {
  const src = path.join(consumerSrcPath, file);
  const dest = path.join(consumerPath, file);
  if (!fs.existsSync(dest)) {
    fs.symlinkSync(path.relative(consumerPath, src), dest);
  }
});

console.log('✓ Fixed metro-source-map private paths');

App Entry Point Error (Monorepo)

Error: Unable to resolve "../../App" from "node_modules/expo/AppEntry.js"

Cause: In monorepo, Expo’s default AppEntry.js looks for App.tsx at wrong path

Solution: Create index.js in app root:

import { registerRootComponent } from 'expo';
import App from './App';

registerRootComponent(App);

This overrides Expo’s default entry point and uses the correct relative path.

react-tv-space-navigation API Error

Error: TypeError: SpatialNavigation.init is not a function (it is undefined)

Cause: react-tv-space-navigation v6.0.0+ has breaking API changes from v5.x

v5.x API (OLD – Don’t use):

import { SpatialNavigation, useFocusable } from 'react-tv-space-navigation';

// Initialize
SpatialNavigation.init({ debug: true });

// Use hook
const { ref, focused } = useFocusable({ onEnterPress: onPress });

v6.0.0+ API (NEW – Use this):

import { SpatialNavigationRoot, SpatialNavigationNode } from 'react-tv-space-navigation';

// Wrap app
<SpatialNavigationRoot>
  <App />
</SpatialNavigationRoot>

// Use component with render prop
<SpatialNavigationNode onSelect={onPress}>
  {({ isFocused }) => (
    <View style={isFocused && styles.focused}>
      {children}
    </View>
  )}
</SpatialNavigationNode>

Migration Steps:

  1. Remove SpatialNavigation.init() calls
  2. Wrap root component in <SpatialNavigationRoot>
  3. Replace useFocusable() with <SpatialNavigationNode> render prop
  4. Change focused to isFocused in render prop
  5. Change onEnterPress to onSelect

Hermes Configuration Missing

Error: Could not get unknown property 'hermesEnabled'

Solution: Add to android/gradle.properties:

hermesEnabled=true

Focus Management Troubleshooting

Focus Not Visible

Problem: Can’t see which element is focused

Solutions:

  1. Increase border width: borderWidth: 4 or higher
  2. Use high-contrast colors: borderColor: '#FFFF00' (yellow)
  3. Add multiple indicators:
focused: {
  borderWidth: 4,
  borderColor: '#fff',
  transform: [{ scale: 1.1 }],
  backgroundColor: '#444',
  shadowColor: '#fff',
  shadowOffset: { width: 0, height: 0 },
  shadowOpacity: 0.8,
  shadowRadius: 10,
}

Focus Not Moving Between Elements

Problem: D-pad navigation doesn’t move focus

Causes & Solutions:

  1. Missing SpatialNavigationRoot: Wrap entire app
  2. Elements not aligned: Ensure buttons are in proper layout (Row/Column)
  3. Check console: Look for spatial navigation warnings
  4. Test with multiple elements: Need at least 2 focusable items

Button Press Not Working

Problem: Focused button doesn’t respond to Enter/Select

Solution: Ensure both onSelect AND onPress are set:

<SpatialNavigationNode onSelect={handlePress}>
  {({ isFocused }) => (
    <Pressable onPress={handlePress}>  {/* Both needed! */}

Common Mistakes (Don’t Do These)

❌ Using Pressable without Focus Management

// WRONG
<Pressable onPress={handlePress}><Text>Click</Text></Pressable>

// RIGHT - Use FocusablePressable from shared-ui
<FocusablePressable onPress={handlePress}><Text>Select</Text></FocusablePressable>

❌ Forgetting Safe Zones

// WRONG - Content at edges
<View style={{ position: 'absolute', top: 0, left: 0 }}>

// RIGHT
<View style={{ position: 'absolute', top: 27, left: 48 }}>

❌ Small Touch Targets

// WRONG - Too small for TV
<View style={{ width: 44, height: 44 }} />

// RIGHT
<View style={{ width: 80, height: 80 }} />

Vega Troubleshooting

App Crashes Immediately on Launch (Black Screen)

Symptom: App installs successfully, “Successfully launched” message appears, but app immediately crashes with black screen and no error.

Cause: Missing [processes] section in manifest.toml

Fix: Add to apps/vega/manifest.toml:

[processes]
[[processes.group]]
component-ids = ["com.yourcompany.yourapp.main"]

Running on Vega Virtual Device

# Start simulator
vega simulator

# Check device is ready
vega device list

# Build and run
yarn build:release  # or build:debug
vega run-app build/aarch64-release/<APP_NAME>_aarch64.vpkg <PACKAGE_ID>

# Check if running
vega device is-app-running --appName <PACKAGE_ID>

Testing Your TV App

Android TV

# In Android Studio: Tools > Device Manager > Create Device > TV
yarn dev:android

Apple TV

# In Xcode: Window > Devices and Simulators > Simulators
yarn dev:ios

Web (with keyboard)

yarn dev:web
# Navigate with arrow keys, Enter to select

Physical Devices

  • Android TV: Enable developer mode, connect via ADB
  • Fire TV: Enable ADB debugging in Settings
  • Apple TV: Connect via Xcode

Next Steps

  1. Add screens – Create screens in packages/shared-ui/src/screens/
  2. Add navigation – Set up React Navigation in shared-ui
  3. Add video player – Use react-native-video (Expo) or VideoView (Vega)
  4. Handle remote events – Create platform-specific RemoteControlManagers
  5. Test on real hardware – Emulators don’t show real performance

Need More?

  • Full knowledge base: Use the multi-tv-builder skill
  • Apple TV issues: Use the apple-tv-troubleshooter skill
  • Movie/TV data API: Use the tmdb-integration skill

Reference Implementation

For a complete working example with all features implemented:

Use this as a reference to see advanced patterns like video playback, navigation, and remote control handling.


Android TV / Fire TV Runtime Troubleshooting

TypeError: Cannot read property ‘displayName’ of undefined

Symptoms:

  • App builds successfully but crashes immediately
  • Metro shows: ERROR TypeError: Cannot read property 'displayName' of undefined
  • App returns to launcher after brief flash

Common Causes & Fixes:

  1. Wrong import in index.js (Most Common)

    // ❌ WRONG - Named import when App uses default export
    import { App } from './App';
    
    // ✅ CORRECT - Default import
    import App from './App';
    
  2. Metro cache corruption

    pkill -f "expo" 2>/dev/null || true
    rm -rf node_modules/.cache /tmp/metro-* /tmp/haste-map-*
    npx expo start --clear
    
  3. react-tv-space-navigation v6 missing configuration

    // Create src/configureRemoteControl.ts
    import { SpatialNavigation } from 'react-tv-space-navigation';
    
    SpatialNavigation.configureRemoteControl({
      remoteControlSubscriber: (callback) => () => {},
      remoteControlUnsubscriber: () => {},
    });
    

Metro Bundler Connection Issues

Symptoms:

  • Couldn't connect to "ws://localhost:8081/message..."
  • App shows loading screen indefinitely

Fixes:

# Set up ADB reverse port forwarding
adb reverse tcp:8081 tcp:8081

# Verify emulator connection
adb devices -l

# Restart ADB if stale
adb kill-server && adb start-server

Android TV Emulator Quick Commands

# Start emulator
emulator -avd Android_TV_720p -no-snapshot-load &

# Wait for boot
adb wait-for-device

# Force restart app
adb shell am force-stop <package_name>
adb shell am start -n <package_name>/.MainActivity

# Check logs for JS errors
adb logcat -d | grep -iE "(ReactNativeJS|error)" | tail -30

Expo Go vs Development Builds

Important: TV apps should use development builds, not Expo Go.

# ❌ May cause SDK version issues on TV
npx expo start

# ✅ Correct for TV development
npx expo run:android
# or
npx expo start --dev-client

Vega in Monorepo – Critical Setup

Port Forwarding Required

Vega virtual devices need reverse port forwarding to connect to Metro:

# REQUIRED before launching app
vega device start-port-forwarding --port 8081 --forward false

Without this, the app shows a black screen because it can’t fetch the JS bundle.

React Version Conflicts (Expo + Vega)

If your monorepo has both Expo (React 19) and Vega (React 18), you’ll get:

TypeError: Cannot read property 'ReactCurrentOwner' of undefined

Fix: Move root React packages before running Vega:

mv node_modules/react node_modules/react.bak
mv node_modules/react-native node_modules/react-native.bak

Node.js 23 Compatibility

Node 23 breaks Metro’s package exports. Fix by removing exports field:

// Run this after yarn install
const fs = require('fs');
['metro', 'metro-source-map', 'metro-transform-worker', 'metro-runtime'].forEach(pkg => {
  const p = `node_modules/${pkg}/package.json`;
  if (fs.existsSync(p)) {
    const json = JSON.parse(fs.readFileSync(p));
    delete json.exports;
    fs.writeFileSync(p, JSON.stringify(json, null, 2));
  }
});

Complete Vega Launch Sequence

# 1. Fix Metro (Node 23 only)
node fix-metro-exports.js

# 2. Move root React (monorepo only)
mv node_modules/react node_modules/react.bak

# 3. Start Metro
cd apps/vega && npm start

# 4. Port forwarding (new terminal)
vega device start-port-forwarding --port 8081 --forward false

# 5. Launch
vega device launch-app --appName com.yourcompany.app

# 6. Verify
vega device running-apps | grep yourapp