docs

TV Remote

+
UniversalWorks with Expo & React Native CLI

This tool works with both Expo and React Native CLI projects. Just install and go.

Press the remote without picking one up. The TV Remote tool sends real D-pad, select, menu, media and long presses — and typed text — to a connected TV app from Buoy Desktop, records what you pressed as a named macro, and replays that macro against one device, or every mapped device at once.

Record a short navigation sequence, replay it, and inspect each event result alongside the resulting focus and screen state. Receiving a key event does not prove that the app reached the intended screen.

record it once, in 70 seconds

Installation

Complete TV Quick Start first, including core, @buoy-gg/external-sync, your account key, and a working Desktop connection. Then add this tool.

npm install @buoy-gg/tv-remote
tsx
import { FloatingDevTools } from "@buoy-gg/core";

export default function App() {
  return (
    <>
      {/* your app */}
      <FloatingDevTools headless licenseKey={process.env.EXPO_PUBLIC_BUOY_KEY} />  {/* TV apps run headless — no bubble */}
    </>
  );
}
import { FloatingDevTools } from "@buoy-gg/core";

export default function App() {
  return (
    <>
      {/* your app */}
      <FloatingDevTools headless licenseKey={process.env.EXPO_PUBLIC_BUOY_KEY} />  {/* TV apps run headless — no bubble */}
    </>
  );
}

Then open TV Remote in Buoy Desktop, pair your device with an injection target, and press away.


How presses are delivered — and why it matters

Buoy injects presses from your Mac, not from inside your app. Buoy Desktop shells out to adb shell input keyevent for Android TV and idb ui key for the Apple TV simulator. Both go through the platform's real input pipeline, so the press travels the same focus engine a physical remote drives.

The package you install in the app does the other half: it observes. It reports the remote events your app actually received, which is what lets a replay tell "the app handled that press" apart from "something swallowed it."

Nothing is faked in-process, and that is deliberate:

  • On tvOS, an app can post React Native's internal key-event notification (the JS event fires, but focus never moves) or call requestTVFocus (focus jumps, but no event fires and nothing is traversed). Each is half of a press, and the missing half is the half QA cares about.
  • On Android, directional focus navigation for unconsumed D-pad keys happens in ViewRootImpl, above the Activity. An in-process dispatchKeyEvent would fire JS events without moving focus exactly on the screens that are broken.

The package also renders nothing on screen. On Android TV any focusable view in an overlay becomes a D-pad stop in your app's focus order — a visible tool would change the navigation it is meant to be testing.


What works where

TargetReplayHow
Android TV emulator✅ everythingadb -s <serial> shell input keyevent
Android TV device✅ everythingthe same, over adb connect <ip>:5555
Apple TV simulator✅ D-pad, select, menu, hold, Home, text — ❌ media keysidb ui key / ui button / ui text
Apple TV device❌ record onlyno supported host-side injection exists

What you can send

Apple TV simAndroid TVEchoed back by the app?
D-pad, Select, Menu✅✅yes (Menu: only with menu capture on)
Hold (long press)✅✅yes — as longSelect, longLeft, …
Type text✅✅no — see below
Play/pause, rewind, fast-forward, next, previous❌✅yes, on Android
Home / TV✅✅no — it backgrounds the app
Swipe / pan (touch surface)❌❌—
Voice / Siri❌❌—

Replay limits:

  • No media transport keys on an Apple TV simulator. idb ui key speaks the HID keyboard page, which has no usages for play/pause, rewind, fast-forward, next or previous, and idb ui button offers only Apple Pay, Home, Lock, Side button and Siri. Those steps are reported skipped-unsupported on that target — flagged, never silently passed. They work on Android.
  • A retail Apple TV cannot be driven at all. Apple's only supported way to press buttons on physical hardware programmatically is an XCUITest runner paired to the device, which is far too heavy to live inside Buoy Desktop.
  • Swipes and pans can be recorded but never replayed. There is no touch surface on a simulated Apple TV (idb ui swipe errors out and takes the app with it), and Android TV has no touchscreen. So swipeUp/pan events reach the capture stream from a physical remote, but no macro step can reproduce them.
  • Voice / Siri is out of reach, and would not help. Siri is a system service: the audio goes to Apple and your app never sees it, so there is nothing to inject and nothing to assert. What your app actually observes is the resulting text — which is what Type text sends.

Recording works everywhere, including retail hardware, because capture is pure JS. That gives you the record-on-retail workflow: a tester presses the physical remote on a rack device, the macro is recorded from the app's own event stream, and replay then runs against emulators and simulators.

Requirements on your Mac

  • Android: adb — part of the Android SDK platform-tools.

  • Apple TV simulator: idb, which does not ship with Xcode:

    bash
    brew tap facebook/fb && brew install idb-companion
    pipx install fb-idb
    
    brew tap facebook/fb && brew install idb-companion
    pipx install fb-idb
    

    Without it the Apple TV lane is disabled and the panel says so.

Buoy looks for both on your PATH and in the usual install locations, so a packaged desktop app finds them even though a GUI app inherits a minimal PATH.


Pairing

The panel lists every booted target it finds and suggests one that matches your device's platform. Pairing is manual and remembered per device. Buoy will not probe for a pairing by injecting keys into devices you didn't ask it to touch.


Typing, and holding

Type text sends a string to whatever field currently has focus (idb ui text / adb shell input text). This is the practical stand-in for the Siri remote's dictation, and it saves pecking out a search query on a D-pad keyboard grid one letter at a time.

Typed text uses a different input path: text travels the platform's keyboard path, so it reaches the native text field without ever touching the app's TV event pipe. Verified: an app with capture armed reports nothing at all for injected text. A text step is therefore fire-and-wait — the tool can prove it was sent, not that it was received. (Menu and Home are fire-and-wait for the same kind of reason.)

Hold turns the next press into a long press — the same keycode with a duration flag (--longpress / --duration). React Native reports it under its own event name, so a held Select comes back as longSelect and is echo-confirmed like any other press. Holding is meaningful for the D-pad, Select and Play/Pause; the fork emits no long variant for Menu.

Macros

A macro is a list of raw presses with the timing you recorded:

json
{
  "version": 1,
  "name": "search-for-arrow",
  "createdAt": "2026-08-25T14:02:11.000Z",
  "steps": [
    { "key": "right", "delayMs": 420 },
    { "key": "select", "delayMs": 800 },
    { "key": "select", "hold": true, "delayMs": 600 },
    { "kind": "text", "text": "arrow", "delayMs": 500 }
  ]
}
{
  "version": 1,
  "name": "search-for-arrow",
  "createdAt": "2026-08-25T14:02:11.000Z",
  "steps": [
    { "key": "right", "delayMs": 420 },
    { "key": "select", "delayMs": 800 },
    { "key": "select", "hold": true, "delayMs": 600 },
    { "kind": "text", "text": "arrow", "delayMs": 500 }
  ]
}

A step is either a key press (key, optionally hold) or typed text (kind: "text"). A step with no kind has always meant a key press, so macros recorded before text steps existed still replay.

Record one two ways:

  • From the D-pad — every button you press in the panel is injected and recorded. No capture gaps, because the desktop knows exactly what it sent.
  • From the real remote — a human presses the physical remote and the macro is built from the app's capture stream. This is the retail-device path.

Macros export and import as JSON, so they can live in the repo next to the app under test.

Replay

Each step is injected, then Buoy waits for the app to echo the event back before pacing the recorded delay and moving on. Recorded delays are clamped to 150ms–5s and scaled by the speed multiplier, so a long pause while you were thinking doesn't become a long pause in CI.

Every step reports one of:

OutcomeMeans
okInjected, and the app reported receiving it (with the round-trip time).
no-echoInjected, but no matching app event was observed in time. Check native focus, event capture, connection state, and a blocked JS thread.
skipped-unsupportedThis target has no way to send that key (play/pause on a tvOS simulator).
inject-failedThe injector itself failed — target offline, adb unauthorized, idb missing.

Menu is expected to produce no echo: on Android the back key goes to BackHandler rather than the TV event pipe, and on tvOS the Menu key is handled natively. Turn on Capture the Menu key while recording on Apple TV to route it to JS instead — the panel warns you that your app won't go back or background while that is on, and it is always handed back when recording stops.

Fleet replay

Run on N devices replays one macro against every paired device at once, one injection lane each, and lays the outcomes out as a device × step matrix with a final screenshot per device for eyeball diffing. Partial fleets are normal: an Apple TV simulator skipping a play/pause step, or one device being offline, marks that cell and never blocks the rest.


What's Next


FAQ

Can I test my app on a real Apple TV with this?

You can record on one — capture runs in your app and works on any hardware. You cannot replay against one in this version; there is no supported host-side way to inject remote presses into a retail Apple TV.

Why doesn't the tool show anything on the TV screen?

On purpose. On Android TV a focusable view in an overlay joins your app's D-pad focus order, so an on-screen tool would alter the navigation it is supposed to be testing.

My macro reports no-echo on a video screen.

That is the tool doing its job. Native players (ExoPlayer, AVPlayer) take focus and eat D-pad and play/pause input while video is mounted. A fixed-delay replay would have marched on and reported a pass.

No. idb does expose a SIRI button, but the simulator has no Siri, and on real hardware Siri is a system service — the audio goes to Apple and your app never receives it. There is nothing to inject and nothing for the app to echo. Use Type text instead: the text is the part your app actually sees.

Does this need a native module?

No. @buoy-gg/tv-remote is JavaScript. Other Buoy tools may have native dependencies. The shelling out happens in the desktop app.

Web support

Browser capture observes keyboard events without consuming them. It does not reproduce a TV’s native focus behavior. Import it from the package's /web entry (7.0.41 or later). See Web installation for registration, dependencies, and browser boundaries.