tvOS T2a — Editor-Remote (iPhone / iPad)¶
Date: 2026-06-16
Status: Approved (design), ready for planning
Scope: new apple/PhysarumApp/Sources/Remote/* (shared + per-platform), small touches to
AmbientController, RootView (iOS/iPad), TVRootView, project.yml (Info.plist/Bonjour). Engine/WGSL untouched.
Motivation¶
tvOS T1 is autonomous (Siri Remote, lean-back). The natural companion is using the full editor app on iPhone/iPad as a remote that drives the ATV over the local network — the documented T2. Johannes has a real Apple TV 4K (Wohnzimmer) now, so this is buildable + device-verifiable.
Goal (T2a)¶
The iPhone/iPad full editor discovers an Apple TV running Physarum and mirrors its editing session to it: preset selection, live param edits, and prev/next/cycle commands flow to the ATV in real time. The phone keeps showing its own local preview (WYSIWYG); the ATV mirrors it. On disconnect the ATV resumes autonomous T1 cycling.
Non-Goals (explicit follow-ups)¶
- macOS as remote — DeviceDiscoveryUI is iOS/iPadOS/tvOS only; macOS needs an
NWBrowser-based discovery. Discovery is abstracted behind a protocol now so macOS is a small fast-follow. - T2b — phone-mic → audio-reactive (real-time band streaming): separate increment.
- No Engine/Renderer/WGSL changes; no change to the standalone iOS/macOS editor behavior when not connected.
Architecture¶
Key idea (max reuse): the remote is just another source for the same AmbientController sink
the Siri Remote already feeds (per the original Inc-12 design). No parallel "driven mode". The only
thing beyond T1: the remote also streams live param edits, applied via the existing setParams
path. The ATV's crossfade-to-black + cycle logic are reused unchanged.
iPhone/iPad editor (AppState) ──observe──▶ RemoteController ──RemoteMessage──▶ NWConnection
│
ATV: NWListener (Bonjour) ──accept──▶ AmbientHost ──apply──▶ AmbientController.apply(intent)
└──▶ AppState.edited = params
Components (small, focused, mostly shared)¶
Remote/RemoteMessage.swift(shared, host-testable): the Codable wire protocol.Requires addingenum RemoteMessage: Codable, Equatable { case hello(RemoteState) // full sync on connect case intent(ControlIntent) // next / previous / jumpTo / togglePlaylist case params(PresetParams) // live edited params (throttled) } struct RemoteState: Codable, Equatable { let selectedID: String; let params: PresetParams; let cycling: Bool }CodabletoControlIntent(currentlyEquatable; synthesizable).PresetParamsis alreadyCodable.Remote/RemoteLink.swift(shared): wraps anNWConnection; sends/receives length-prefixed (UInt32+ JSON)RemoteMessages; a receive callback + asend(_:); runs on a dedicated queue, hops to main for delivery. Connection-state callbacks (ready/failed/cancelled).Remote/RemoteDiscovery.swift(protocol + iOS impl):protocol RemoteDiscovering→ surfaces a chosenNWConnection/endpoint. iOS/iPad impl wraps DeviceDiscoveryUI (DDDevicePickerViewController) filtered to our Bonjour service. (macOSNWBrowserimpl later.)Remote/AmbientHost.swift(tvOS): anNWListeneradvertising_physarum._tcp; on an incoming connection builds aRemoteLink, decodes messages and applies them —intent→AmbientController.apply,params→appState.edited,hello→ all three. On connect: adopt the remote's state (its cycle state — typically off, so the ATV holds). On disconnect/cancel: resume autonomous cycling (T1).Remote/RemoteController.swift(iOS/iPad): owns the discovery +RemoteLink; observes the localAppState(selectedID,edited, cycle) and sends messages — selection/intents immediately, params throttled to ~20 Hz (LAN-smooth, avoids flooding). Exposes a connection state for the UI.- iOS/iPad UI (
RootViewtouch): a "Connect to Apple TV" affordance (toolbar/menu) that presents the DeviceDiscoveryUI picker, plus a small connection-status indicator. The editor itself is unchanged; when connected, its existing edits flow throughRemoteController.
Data flow & lifecycle¶
- ATV launches →
AmbientHostadvertises the Bonjour service, keeps running T1 autonomously. - User taps "Connect to Apple TV" on the phone → DeviceDiscoveryUI picker → picks the ATV →
NWConnection. - Phone sends
.hello(currentState); thereafter.intent/.paramson local changes (params throttled). - ATV applies: selection → crossfade-to-black transition (reused), params → live
setParams, cycle → toggle. - Disconnect (user, or network loss) → ATV resumes T1 auto-cycle from the current preset; phone shows disconnected + can reconnect.
Concurrency¶
NWConnection/NWListener run on a dedicated dispatch queue; all AppState/AmbientController
mutations hop to main (the model is main-only, like the existing tick path). Param decode +
apply on main; throttle on the send side.
Entitlements / Info.plist (verify exactly during impl)¶
- ATV + phone:
NSLocalNetworkUsageDescription,NSBonjourServices=_physarum._tcp. - DeviceDiscoveryUI: declare the browsed service; the system picker handles user consent (no separate local-network prompt for the browse). Exact keys confirmed at implementation against current SDK.
project.yml: per-SDK Info.plist additions (tvOS advertises; iOS browses).
Testing & Verification¶
- Host-unit (macOS):
RemoteMessageCodable round-trip (each case, incl.ControlIntentcases + aPresetParams);AmbientHost's apply mapping (message → AmbientController/AppState effect) with an injected link; throttle logic pure-tested.ControlIntentCodable round-trip. - Device-empirical (sign-off, real ATV + phone, same network — no simulator): discover + pair via DeviceDiscoveryUI; selection/edits/prev-next/cycle mirror live to the ATV; disconnect → ATV resumes cycling; reconnect works. This is the real validation; the transport + DeviceDiscoveryUI can't be simulated.
Risks¶
- DeviceDiscoveryUI specifics (entitlement/Info.plist/picker API) are the main unknown — pin down
against the current SDK during impl; fall back to raw
NWBrowserif it fights us (same NWConnection core). - Device-only validation: most of the value is verifiable only on hardware — host tests cover the protocol + apply logic; the network path is Johannes' device sign-off.
- Param flood: throttle (~20 Hz) + send only on actual change; selection is a discrete event.
- Security: local-network only, no auth in T2a (home LAN). A pairing/auth step is a later concern if needed.