Cast experience — mobile 2026-05-21 · windhoek-v3 · mirrors ~/Code/sageox/speaker
Mode

1Account → Settings entry

The Settings screen is the front door for app-level preferences. Cast is exposed here only when the cloud flag is on; the Settings screen itself is always reachable.

Light

9:41●●● 100%
Casting
Auto-cast to paired TVs
Start casting automatically when near a paired TV.
Paired TVs
3 TVs · 1 nearby right now
Appearance
Theme
System

Auto-cast respects each TV's per-TV mode. Disable here to require manual confirmation every time, even with a paired TV nearby.

Dark

9:41●●● 100%
Casting
Auto-cast to paired TVs
Start casting automatically when near a paired TV.
Paired TVs
3 TVs · 1 nearby right now

Auto-cast respects each TV's per-TV mode. Disable here to require manual confirmation every time, even with a paired TV nearby.

Implementor notes
  • Top of Settings = "Casting" section, surfaced first when the cloud flag is on. Above Appearance because users land on Settings primarily for cast in early rollout.
  • Switch state binds to preferences.autoCastEnabled. Tap is optimistic; SecureStore write is fire-and-forget (in-memory state already reflects user choice).
  • "Paired TVs" row meta shows total + nearby count. Updates live as discovery results arrive.
  • Footnote is permanent — explains why this section exists. Drops away if cloud flag is off (entire section hidden, see §6).

2Settings → Cast (empty)

First-run state: cloud flag on, no TVs paired yet. The picker invites the user with a single primary action and an inline explanation. Mirrors the speaker's Settings → Cast empty state but rewritten for a phone (no SSID notion).

Light

9:41●●● 100%
No TVs paired yet
Pair a Chromecast or Apple TV on your network to mirror live meetings.

SageOx never auto-pairs TVs. You always choose which devices can show your meetings.

Dark

9:41●●● 100%
No TVs paired yet
Pair a Chromecast or Apple TV on your network to mirror live meetings.

SageOx never auto-pairs TVs. You always choose which devices can show your meetings.

Implementor notes
  • Single CTA on this screen — "Add a TV" is the only Copper element. Hierarchy is unambiguous.
  • Empty-state illustration is the same TV glyph that appears throughout the cast surfaces (consistent visual anchor).
  • Footnote re-stated for trust — even sophisticated users want to know nothing happens behind their back when they walk into a coffee shop with a stranger's Chromecast on the network.

3Settings → Cast (populated)

Three TVs paired across the user's life: a Chromecast at home (currently visible, default mode), an Apple TV at the office (also visible), and an Apple TV at the coffee shop (offline). Each row is its own destination with its own per-TV mode badge — tap to drill into mode picker.

Light

9:41●●● 100%
Paired TVs
Living Room
Chromecast · Nearby · Ask before casting
Office Apple TV
AirPlay · Nearby · Replace at record
Coffee shop
AirPlay · Not on this network
Discoverable now
Kitchen Chromecast
Tap to pair

Pair status updates as TVs come into range. Long-press a paired TV to forget it.

Dark

9:41●●● 100%
Paired TVs
Living Room
Chromecast · Nearby · Ask before casting
Office Apple TV
AirPlay · Nearby · Replace at record
Coffee shop
AirPlay · Not on this network
Discoverable now
Kitchen Chromecast
Tap to pair

Pair status updates as TVs come into range. Long-press a paired TV to forget it.

Implementor notes
  • Per-TV mode is shown as part of the status sub-row to keep the list dense (Linear pattern). The mode picker is one tap into the detail screen (§5) — not a per-row segmented control here, because three modes × N TVs would create visual chaos.
  • "Discoverable now" appears only when a non-paired target is visible. Drops away cleanly when discovery stops or the target leaves the network.
  • "Test cast" CTA lives at the bottom — used most often the first day of pairing, then ignored. Outline, not Copper, so it doesn't compete with the per-row Pair button.
  • Long-press to forget mirrors the speaker firmware's WiFi list pattern.
  • Network change recognition: mobile can't reliably read SSID on iOS, so "Not on this network" is inferred from "TV has not been seen in N minutes during the current foreground session." Conservative — better to say "Not on this network" too often than to falsely show a TV as available.

4Add TV — discovery sheet

Bottom sheet that runs discovery (mDNS / Bonjour) and shows targets as they appear. The "Searching…" pulse never disappears entirely during the discovery window — TVs can take 5–10 seconds to advertise after wake.

Light

9:41●●● 100%
Paired TVs
Living Room
Add a TV

Make sure your phone and the TV are on the same Wi-Fi network.

Kitchen Chromecast
Chromecast · this network
Bedroom Apple TV
AirPlay · this network
Searching · 8s 2 found

Dark

9:41●●● 100%
Paired TVs
Living Room
Add a TV

Make sure your phone and the TV are on the same Wi-Fi network.

Kitchen Chromecast
Chromecast · this network
Bedroom Apple TV
AirPlay · this network
Searching · 8s 2 found
Implementor notes
  • Discovery sheet is a half-height bottom sheet (not full-screen) so the Settings → Cast context remains visible behind it. Mirrors iOS standard.
  • Searching badge pulses for the duration of the active discovery scan (~15s default; reset on swipe-to-refresh).
  • Cross-protocol discovery: Chromecast uses mDNS via the native Cast SDK; AirPlay uses Bonjour (_airplay._tcp). Results merge before display, deduping by display name where possible.
  • Each discovery row shows a short, plain-language origin ("this network") rather than mDNS internals like hop count. The discovery layer knows hop count and signal strength internally for the priority ranker, but users don't care.

4aAirPlay 2 — PIN pairing (system-handled)

When a user pairs with an AirPlay receiver that has "Require Code" enabled (Apple TV → Settings → AirPlay → not "Anyone on the Same Network"; common in conference rooms and hotels), iOS shows a native PIN entry sheet. We don't build this UI — selecting the route via AVRoutePicker triggers the system flow, and routeDidChange fires when pairing completes. The mockup below illustrates the user's perceived journey; it's drawn as iOS native chrome because that's what they actually see.

Dark · iOS native PIN sheet over our app

9:41●●● 100%
Paired TVs
Living Room
"Boardroom Apple TV"
requires a passcode
Look at the TV. Enter the four-digit code shown on screen.
4
2
7
_
Cancel
OK

Dark · After PIN — back to our app, TV now paired

9:41●●● 100%
Paired TVs
Boardroom Apple TV
AirPlay · Just paired · Ask before casting
Paired with Boardroom Apple TV
Tap to set Replace mode if you want auto-cast in this room.
Implementor notes
  • We never build a PIN UI. Selecting an AirPlay-protected route via AVRoutePicker triggers the system sheet automatically. The user enters the code; iOS verifies; AVRouteDetector.multipleRoutesDetected + routeDidChange notifications complete the round trip. Our native module observes the route-change event and surfaces the new target to the JS layer.
  • Failure modes: wrong PIN → iOS shows its own retry. User cancels → no notification fires; we just don't see a new target appear. Timeout → ditto. We don't get a "wrong PIN" callback; the UX is opaque-by-design on Apple's side.
  • Chromecast equivalent: no PIN — Cast SDK handles its own auth handshake. Roku / DIAL: same, HTTP-based with no user-visible PIN.
  • Post-pair success: we surface a one-time inline toast suggesting the user upgrade the new TV's mode from Ask to Replace if they want frictionless auto-cast. Not auto-promoted — explicit choice protects against pairing-and-forgetting in a transient location.

5Per-TV detail — mode picker

Each paired TV has its own detail screen with name (editable), protocol, last-seen, and the per-TV auto-cast mode. Mode is per-TV, not global: a user can set their home TV to Replace while keeping the office one on Ask.

Light

9:41●●● 100%
Living Room
Chromecast · Last seen now
Rename
Auto-cast on this TV
Off
Never auto-cast. Manual cast still works from the recording screen.
Ask
When a recording starts and this TV is nearby, ask first.
Replace
Auto-cast immediately. Use for trusted rooms.

Dark

9:41●●● 100%
Living Room
Chromecast · Last seen now
Rename
Auto-cast on this TV
Off
Never auto-cast. Manual cast still works from the recording screen.
Ask
When a recording starts and this TV is nearby, ask first.
Replace
Auto-cast immediately. Use for trusted rooms.
Implementor notes
  • Per-TV mode is the substantive design departure from the speaker firmware, which set mode globally per WiFi SSID. On mobile, "per-TV" is the natural unit since the user might roam between work and home with different trust levels.
  • The "Auto-cast on this TV" section is dimmed/disabled (not hidden) when the global "Auto-cast to paired TVs" toggle is off — preserves the user's per-TV preferences for when they re-enable.
  • "Forget this TV" is the destructive action; styled with the danger outline (not solid red — this isn't catastrophic).
  • "Last seen now / 12 minutes ago / 3 days ago" is the discoverability heuristic. After ~30 minutes of not seeing it in the foreground, status badge flips from Nearby to Offline.
  • Multi-protocol TVs (e.g., Sony Bravia advertising both Cast and AirPlay): v1 dedupes by display name and prefers Chromecast (better media-cast surface). v2 adds a "Cast via" picker on this detail screen for the rare user who needs it. Tracked as a follow-up beads issue.
  • Protocol icons in the list rows come from src/components/BrandChip.tsx (chromecast / airplay / roku glyphs). One badge per row, never two.

6Cloud flag OFF — feature not available

When PostHog has not enabled cast-enabled for this device, the entire Casting section disappears. This is the test of the feature-flag delivery system: flip in PostHog, and within one poll cycle (~60s), the section reappears or disappears on every device.

Light · cast-enabled: false

9:41●●● 100%
Appearance
Theme
System

When the feature isn't available, there's nothing here. No section, no TV button, no SDK init. The user is never told why — it simply isn't.

Dark · cast-enabled: true

9:41●●● 100%
Casting
Auto-cast to paired TVs
Start automatically when nearby.
Paired TVs
3 TVs · 1 nearby right now
Appearance
Theme
System

Same bundle, same install — flag flips in PostHog → section appears within ~60s. (Internal-only mental model; not surfaced to the user.)

Implementor notes
  • When the cloud flag is false: everything is hidden, not disabled. Hidden Settings row, hidden record-screen TV button, native cast SDK is not even initialized (no battery drain from background mDNS scans, no console warnings about missing config).
  • The footnote on the OFF side is deliberately neutral — never apologetic or hopeful. The feature simply isn't available on this device.
  • BDD scenario: cast-enabled.flag.flips.appears-within-one-poll-cycle. Test asserts that within 60s of the server changing the flag, the Settings row visibility flips.
  • Local auto-cast preference persists when cloud flag goes off → on → off. User's "Replace at record" choice for Living Room is preserved even when they had no Cast section for a year.

7Cast Control sheet

Bottom sheet during an active cast session. Mirrors screen_cast_control.cpp in the speaker firmware: TV name header, join URL as QR + tappable text, status line that tracks SessionPhase, single Action Pill. Close (×) does NOT stop the cast; Disconnect does.

Light · Phase = Playing

9:41●●● 100%
Standup · Living Room
Team SageOx Internal · 12:34
SageOx Casting
Living Room
sageox.ai/m/3kp9
Scan to join · auto-expires in 30 min
Showing on TV Living Room · 12s ago

Dark · Phase = Connecting…

9:41●●● 100%
Standup · Living Room
Team SageOx Internal · 12:34
SageOx Casting
Living Room
sageox.ai/m/3kp9
Scan to join
Connecting… Attempt 2 of 4
Implementor notes
  • Status line text comes straight from SessionPhase: Connecting · Authenticating · Launching · Loading · Showing on TV. Failure goes to Couldn't reach TV + the specific SessionFailure reason (network_error, source_not_allowed, …).
  • Action pill is mutually exclusive: Reconnect when failed/stopped; Disconnect when playing; Cancel during an in-flight attempt. Mirrors the speaker firmware's single-pill slot pattern (screen_cast_control.cpp).
  • QR + URL block is the join URL (sageox.ai/m/<short>), not the cast HLS URL. Lets a colleague in the room hop onto the meeting from their phone — the cast pipeline is incidental.
  • Close (✕) and swipe-down dismiss the sheet but leave the cast running. Disconnect explicitly stops it. Mirrors speaker firmware behavior.
  • Sheet height: ~58% of screen on iPhone-class devices. Half-modal pattern, recording state visible beneath.
  • Switch TV mid-recording: an additional "Switch TV" action in the Action Pill area opens the discovery picker; tapping a new target stops the current session and starts a new one with the same recording id. Recording continues uninterrupted. Per-TV lastCastAt updates to the newly-chosen TV.
  • Multi-screen cast (V2): out of scope for V1. Chromecast multi-zone is audio-only today; AirPlay 2 multi-room video is iOS 17+ but cross-protocol simultaneous broadcast (Chromecast + Apple TV at once) needs careful adapter coordination. Server-side /cast/stream already supports it (one signed wfid per destination); the mobile pipeline can opt in later.

7aHost-as-participant view

The host (recording owner) needs to see what attendees see — reactions, raised hands, chat — without losing cast/recording controls. Cast Control sheet gains a compact "Live participation" strip; tapping it opens the participant experience in an embedded webview pointing at sageox.ai/join/<rec_id>?as=host (V1), with a persistent "Back to host" pill overlaid in the native shell. Native re-implementation is a future-V2 effort once participant features stabilize on the web.

Light · Cast Control with participation strip

9:41●●● 100%
Standup · Living Room
SageOx Casting
Living Room
Showing on TV 12s
Live participation
4 joined
💬 3 chats ✋ 1 hand last 1m

Dark · Participant view + Back to host pill

9:41●●● 100%
Participants · 4
R
Ryan
Host · You
P
Priya
✋ Raised hand
M
Maya
A
Alex
Chat
Priya: Quick q on the timeline ⏱️
Maya: +1, can we pause?
Back to host
Implementor notes
  • The "Live participation" strip in Cast Control gives the host a glance at attendee activity without leaving the host context. Subscribes to the same SSE stream as app/meeting/[recId]/join.tsx (existing) but rolls events into counts.
  • The "Back to host" pill is the structural insight — when the host enters participant view, they're not "leaving" the recording. The pill sits above the participant chrome, persistent, and tapping it pops the modal. Recording + cast keep running underneath.
  • Participant view shows the same UI an attendee on their phone sees, MINUS the join QR / cast-control UX (the host already has that on the other side). Read-only by default for the host (they can react/raise-hand if they want, but the default focus is awareness, not participation).
  • Implementation: app/meeting/[recId]/join.tsx takes a new prop/query param ?as=host that hides the "Join this meeting" surface and overlays the Back-to-host pill. The host's recording state stays in the parent stack so navigating back doesn't tear anything down.
  • Routes: from the recording screen, the host can also access participant view via the long-press on the live-cast indicator. Same destination, same Back pill.

8Cast Test — 30-second countdown

From Settings → Cast → "Test cast." Casts https://<env>.sageox.ai/cast/welcome and shows the same HLS stream as a mini preview on the phone — always live for the duration of the test, no toggle. The user glances at the phone, glances at the TV, and confirms they match without putting the phone down. Mirrors screen_cast_test.cpp.

Light · t=21s

9:41●●● 100%
Live
SAGEOX
Welcome card · test cast
Living RoomHLS · 480p
21s
This is what your TV sees.
Glance at the TV — what's on screen there should match the preview.

Dark · Success

9:41●●● 100%
Test cast complete
Living Room is paired and reachable. You can mirror live meetings to it.
Implementor notes
  • The countdown is generous (30s) so the user has time to walk over and physically look at the TV. The speaker firmware uses the same window.
  • "Looks good" early-completes the test and records a successful pair in metrics. "Stop test" cancels and goes back to Cast settings.
  • Success state is the only place we use a non-Copper success-tinted illustration — this is rewarding the first-pair success moment.
  • The mini preview pulls from a 480p HLS stream (separate wfid from the TV's 1080p). On the Test surface specifically the preview is always live, no toggle — the whole point of Test is to verify pairing visually. Toggle lives only on the recording-screen embedded preview (§12 + §12a) where bandwidth/focus tradeoffs are real.

8aMini cast preview — the component

A 16:9 thumbnail of the live HLS stream. Two states: Live (decoding the feed, "LIVE" pip top-left) and Muted (placeholder, decoder paused). Where it appears, and whether the user can toggle it:

Surface Default Toggle? Long-press?
Cast Test (§8)LiveNoNo
Record screen — casting (§12)LiveYes (eye → muted)Yes (collapse entirely)
Cast Control sheet (§7)Not shown

Toggling never affects the TV — only the phone-side decoder. The TV keeps playing.

Live — decoding HLS

9:41●●● 100%
Live
STANDUP
Ryan is speaking · 12:34
Living RoomHLS · 480p

Glance verification — what's on the TV right now. Tap the eye to stop streaming to the phone (saves data on cellular). The TV keeps playing.

Muted — decoder paused

9:41●●● 100%
Streaming paused on phone
Still showing on Living Room
Living RoomTV active

Toggling off on the phone never affects the TV. The decoder is cheap to stop and restart — tap again to re-mount.

Implementor notes
  • Component: <MiniCastPreview tvName="..." active={…} muted={…} onToggle={…} /> in src/components/cast/MiniCastPreview.tsx.
  • Render path: react-native-video (or expo-video) pointed at the same signed HLS URL the TV is playing. We get the URL from requestCastStream({ url, res: 480 }) — separate request, separate wfid, so the phone preview is its own renderer workflow (480p) and won't compete with the TV's 1080p.
  • "Muted" stops the decoder (cheap), keeps the component mounted, preserves the position when re-toggled. Bandwidth saved: ~0.4 Mbps continuous at 480p.
  • Default: on. The cost-benefit of seeing what's on the TV is overwhelming for new pairs; experienced users can disable globally in Settings → Cast → "Phone preview" (future).
  • Failure-path: if the phone HLS stream fails (network, signed-URL expiry), the mini preview shows a "Reconnect preview" inline button and falls back to a static cast-icon thumbnail — never blocks the parent screen.
  • Always above the fold when present. The user should never have to scroll to confirm what's on the TV.

9Cast failed — recovery

Recovery surface — strict CTA purity actively hurts here. The user needs visually co-equal recovery options (Reconnect, switch TVs, give up). Exempt from one-Copper-per-screen.

Light · network_error

Standup · Living Room
Team SageOx Internal · 12:34
Cast paused
Living Room
Couldn't reach the TV Check Wi-Fi

Your recording is still going — only the cast paused. You can keep going without the TV, or fix it and resume.

Dark · retries exhausted

Standup · Living Room
Team SageOx Internal · 12:34
Cast stopped
Living Room
Tried 4 times — TV not responding 62s

Your recording is still going. The TV may have gone to sleep or switched inputs.

Implementor notes
  • Sacred-data invariant: the recording is never affected. Every recovery surface restates this — "Your recording is still going" — because that's what the user actually wants to know in the panic moment.
  • The 4-attempt retry schedule (3s, 10s, 30s, 30s) is ported verbatim from the speaker firmware's app_flow.cpp::kAutoCastMaxAttempts.
  • This surface marks data-recovery="true" for the design-lint — opting out of one-Copper-per-screen because the user needs parallel recovery paths, not focus.
  • "Switch TV" opens the picker filtered to nearby TVs only.

10Record screen — no TV button

Baseline: cloud flag off, OR no paired TVs, OR no paired TV currently nearby. Single recording surface, no extra noise.

Light

9:41●●● 100%
Standup
Team SageOx Internal · No TV in range
12:34
Recording
Pause
Stop

Dark

9:41●●● 100%
Standup
Team SageOx Internal · No TV in range
12:34
Recording
Pause
Stop

11Record screen — TV ready (manual)

A paired TV is nearby (Office Apple TV) and its mode is Ask or Off. TV button appears alongside Pause and Stop. Tap to cast manually.

Light

9:41●●● 100%
Standup
Team SageOx Internal
12:34
📺 Office Apple TV ready
Cast
Pause
Stop

Dark

9:41●●● 100%
Standup
Team SageOx Internal
12:34
📺 Office Apple TV ready
Cast
Pause
Stop

12Record screen — casting active

When a cast is active, the recording screen makes the cast impossible to miss: live HLS mini preview above the timer, "Casting to {TV}" label, Sage dot on the Cast button. The user always knows what's on the TV without leaving the recording surface.

Light · mini preview live

9:41●●● 100%
Standup
Team SageOx Internal
Casting
RYAN
Speaking now
Living RoomHLS · 480p
12:34
● Casting to Living Room · tap Cast to control
Cast
Pause
Stop

Dark · cast failed (toast)

9:41●●● 100%
Standup
Team SageOx Internal
12:34
Cast
Pause
Stop
Cast paused — recording continues
Couldn't reach Living Room. Tap to retry.
Implementor notes
  • Active-cast Sage dot is in the top-right of the Cast button (the speaker-firmware "outer ring sage" indicator translated to phone-scale).
  • Cast confidence comes from three redundant cues: the mini-preview thumbnail (what's on the TV), the "● Casting to {TV}" label, and the Sage dot on the Cast button. Any one of them on its own is ambiguous; together they're unmissable.
  • Mini preview is collapsible — long-press the thumbnail or tap the eye toggle to hide. Hidden state restores the larger timer. Setting persists across recordings.
  • Stop cast without stopping recording: long-press the Cast button on the recording screen (~600ms) → immediate disconnect, recording uninterrupted. Light haptic confirms. Two-tap path (Cast → Disconnect on the sheet) remains as the discoverable alternative.
  • Cast failures during recording surface as a toast (not a modal!) — recording continues uninterrupted. Tap toast → opens Cast Control sheet to retry or switch.
  • This is the Sacred-data discipline: the recording UI never blocks for cast issues. The cast is supplementary; the recording is core.

12aRecord screen — mini preview toggled off

When the user toggles the phone preview off (eye icon), the thumbnail collapses to a muted placeholder. The TV keeps playing — only the phone-side decoder stops. Useful on cellular or in a focused mode.

Light · phone preview off, TV active

9:41●●● 100%
Standup
Team SageOx Internal
Phone preview paused
Still showing on Living Room
Living RoomTV active
12:34
● Casting to Living Room
Cast
Pause
Stop

Dark · preview fully collapsed (long-press)

9:41●●● 100%
Standup
Team SageOx Internal
12:34
● Casting to Living Room · preview hidden
Cast
Pause
Stop
Implementor notes
  • Two collapse levels: (a) eye-toggle = muted placeholder still visible (preserves layout, hints that cast is active); (b) long-press = thumbnail removed entirely (timer expands). Both persist; both leave the "● Casting to {TV}" label so the user can never lose track of where the meeting is going.
  • Cellular detection (NetInfo) auto-suggests muting after 60s on metered networks via a one-time inline toast — the user accepts or dismisses.

13Auto-cast — "Ask" prompt

Mode = Ask. When recording starts and the TV is nearby, the app surfaces a brief bottom sheet. One-tap accept, one-tap decline, swipe-to-dismiss = decline.

Light

9:41●●● 100%
Starting…
Team SageOx Internal
0:02
Cast this recording to Living Room?

People in the room will see who's talking and high-level insights from the meeting.

Dark

9:41●●● 100%
Starting…
Team SageOx Internal
0:02
Cast this recording to Living Room?

People in the room will see who's talking and high-level insights from the meeting.

Implementor notes
  • Ask-prompt timing: shown ~1 second after recording starts (within the speaker firmware's kAutoCastGraceMs=15000 window). Auto-dismisses after 8 seconds → treated as "Not now."
  • "Not now" is intentional language — the user might want to start casting later via the Cast button. Doesn't change the TV's mode setting; just this session.
  • Mode = Replace skips this surface entirely — cast just starts. Mode = Off never surfaces it. Mode = Ask is the in-between for "trusted but not always."
  • If the user dismisses the prompt, the TV button stays available for the rest of the recording (manual cast still possible).

Cast to TV — mobile

Port of the Scribe firmware's cast-to-TV experience to the windhoek-v3 React Native app. Pair a Chromecast / Apple TV once, then mirror live meetings during recording with one tap — or zero taps, if you've opted into auto-cast. Everything routes through the same /api/v1/cast/stream contract the firmware uses.

cast-enabled = PostHog flag, delivered via /devices/{id}/config · Mobile self-registers as hardware_type=mobile-ios | mobile-android · Poll cadence: 60s + on auth refresh + on foreground

AThree-layer gate

The cast experience only appears when all three gates pass. Stack matches the speaker firmware's compound check (FEATURE_SCREEN_CAST && cast-enabled flag && cast::has_paired() && cast::mode() != Off) one-for-one.

① Cloud flag

Server-evaluated PostHog flag. Default false. When off, Settings → Cast and the recording-screen TV button are hidden; no native cast SDK initializes.

useFeatureFlag('cast-enabled')

② Local auto-cast

User preference, persisted in SecureStore. Default on. Auto-cast is the expected behavior; the user can disable it if they want manual-only ("don't surprise me"). When off, per-TV mode is inert and manual cast still works.

preferences.autoCastEnabled

③ Per-TV mode

Set on each paired TV. Off / Replace / Ask. Replace = auto-cast on record. Ask = bottom-sheet prompt. Off = manual only. Defaults to Ask on new pairs.

pairedTv.mode
Implementor notes
  • useFeatureFlag(name, default=false) reads from DeviceConfigContext; the context boots ensureDeviceRegistered() on auth, then polls GET /api/v1/devices/{dev_id}/config every 60s. Backgrounded app pauses the timer.
  • The cloud flag flipping in PostHog should produce an observable change within one poll cycle. This is the test of the flag-delivery system end-to-end — there is no other way to enable cast on a device.
  • Local toggle deliberately exists even though the cloud could be the only gate. Default is on — auto-cast is the expected behavior. A user can flip it off as a backstop ("no surprise broadcasts on this phone").
  • SSID-based pairing (per speaker firmware) is dropped — iOS heavily restricts SSID access. Substituted: discoverability = "what TVs are visible via mDNS / Bonjour right now," intersected with paired TVs.

BHow "near a TV" is detected — and which one wins

Without SSID, the phone leans on active discovery to answer "is a paired TV in this room?" When several paired TVs are visible at once, a deterministic priority ranking picks one. The user always gets the final word by tapping the TV button.

Detection — "is this TV nearby?"

Discovery runs whenever the app is foregrounded AND either Settings → Cast or the recording screen is mounted. Sources:

  • Google Cast SDK on iOS + Android — uses its own bundled mDNS browser (_googlecast._tcp) and surfaces routes via SessionManager presence callbacks.
  • AVRoutePicker / AVAudioSession on iOS — surfaces AirPlay routes the system already knows about (no permission required for the picker itself; the actual cast may prompt).
  • Direct mDNS browse as a fallback (react-native-zeroconf) for _airplay._tcp on Android (rare path) and any "lost in the SDK" targets.

Each source pushes targets into a single unified list keyed by stable id. A paired TV is "Nearby" when its lastSeenAt is within 30s of "now" during the current foreground session. Going to background pauses the timer; foreground resumes it.

iOS local-network permission is required for mDNS. The system prompt fires the first time the user opens Settings → Cast (not on app launch). Denial degrades cast to "system AirPlay only."

Priority — when more than one is nearby

Deterministic ranking. First match wins:

  1. Last-cast-to-this-session — same TV, same foreground session → silent re-use.
  2. Last-cast-to within 7 days + Mode = Replace → silent auto-cast.
  3. Single Mode = Replace TV nearby → silent auto-cast.
  4. Multiple Mode = Replace TVs nearby → chooser sheet (see §13a).
  5. Mode = Ask, single TV nearby → Ask prompt (§13).
  6. Mode = Ask, multiple TVs nearby → chooser sheet (§13a).
  7. Mode = Off only nearby → no auto-cast; manual button still works.

In a corporate office with 8 conference-room Apple TVs visible, default each to Mode = Ask. The chooser surfaces; the user picks once; next time, "last-cast-to-this-session" sticks.

Wi-Fi awareness — what we know, what we don't
  • Neither platform lets us list saved or available Wi-Fi networks. Apple removed that API surface in iOS 13; Android has never exposed it for third-party apps. Tracking-prevention reasons.
  • Current SSID is permission-gated. iOS: NEHotspotNetwork.fetchCurrent() requires Location-When-In-Use permission and (for some use cases) an Apple-approved entitlement. Android 10+: WifiManager.getConnectionInfo().getSSID() requires ACCESS_FINE_LOCATION + runtime prompt. App Store review often pushes back on Location asks that aren't core to the product.
  • So we don't read SSID by default. The cost (scary permission prompt + review risk) outweighs the benefit (we'd still need the same discovery layer underneath).
  • Discovery-set fingerprint (May 2026 best practice). Instead of SSID, we hash the SET of mDNS responders we see together (Cast device IDs + AirPlay UIDs + local IPs). The fingerprint IS the network. When we re-enter the office, we see the same fingerprint members → infer "Office network" → load the right paired-TV subset. Permission-free, leverages the discovery layer we already need.
  • Persisted as knownNetworks: {fingerprint, label?, tvIds[]}[] in SecureStore. User can optionally name a network ("Office") via long-press on the Settings → Cast header when on it; otherwise unnamed. Two visits → fingerprint match → silently associate.
  • We use discoverability as our network signal. If a TV is in mDNS results right now, we're on the same LAN as it. If a paired TV hasn't been seen in 30s of foreground, we're not. No SSID round-trip needed.
  • Cellular = no cast. mDNS doesn't cross WAN boundaries. Our discovery returns empty; UI says "No TVs in range" (same as a coffee-shop scenario).
  • VPN / split-tunnel can break discovery even on Wi-Fi — the platform's mDNS browser may bind to the VPN interface. There's no clean fix; we surface a "Trying to find your TV — VPN can interfere" inline tip after 15s of empty discovery on Settings → Cast.
  • Multi-TV corporate networks: the chooser is the answer (§13a). We don't try to be clever about "which conference room am I in" — Apple/Google can't do that reliably either, and asking is one tap.
  • Optional Group label per TV (§5) lets power users hand-label "Office floor 3" / "Home" so the chooser can group rows visually. Not required; off by default.
Implementor notes
  • Discovery is not a continuous background scan — battery cost and the iOS local-network-permission UX both push us toward "scan when the user is looking, cache when they're not." The 30s liveness window is conservative enough that a user opening the app in a paired room sees the TV light up as Nearby within ~2s.
  • Speaker-firmware parity: the firmware uses presence_table.cpp + presence_ticker.cpp for liveness (FRESH/STALE/DEAD). Mobile collapses this into a simpler "seen in last 30s = Nearby" because the phone's foreground window is short anyway.
  • Per-TV lastCastAt is the session-stickiness anchor — once a user picks a TV from the chooser, subsequent record-starts in the same foreground session re-use it silently.