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
Auto-cast respects each TV's per-TV mode. Disable here to require manual confirmation every time, even with a paired TV nearby.
Dark
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
SageOx never auto-pairs TVs. You always choose which devices can show your meetings.
Dark
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
Pair status updates as TVs come into range. Long-press a paired TV to forget it.
Dark
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
Make sure your phone and the TV are on the same Wi-Fi network.
Dark
Make sure your phone and the TV are on the same Wi-Fi network.
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
requires a passcode
Dark · After PIN — back to our app, TV now paired
Implementor notes
- We never build a PIN UI. Selecting an AirPlay-protected route via
AVRoutePickertriggers the system sheet automatically. The user enters the code; iOS verifies;AVRouteDetector.multipleRoutesDetected+routeDidChangenotifications 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
Dark
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
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
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
Dark · Phase = Connecting…
Implementor notes
- Status line text comes straight from
SessionPhase: Connecting · Authenticating · Launching · Loading · Showing on TV. Failure goes to Couldn't reach TV + the specificSessionFailurereason (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
lastCastAtupdates 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/streamalready 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
Dark · Participant view + Back to host pill
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.tsxtakes a new prop/query param?as=hostthat 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
Dark · Success
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
wfidfrom 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) | Live | No | No |
| Record screen — casting (§12) | Live | Yes (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
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
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={…} />insrc/components/cast/MiniCastPreview.tsx. - Render path:
react-native-video(orexpo-video) pointed at the same signed HLS URL the TV is playing. We get the URL fromrequestCastStream({ url, res: 480 })— separate request, separatewfid, 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
Your recording is still going — only the cast paused. You can keep going without the TV, or fix it and resume.
Dark · retries exhausted
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
Dark
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
Dark
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
Dark · cast failed (toast)
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
Dark · preview fully collapsed (long-press)
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
People in the room will see who's talking and high-level insights from the meeting.
Dark
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=15000window). 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.
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.modeImplementor notes
useFeatureFlag(name, default=false)reads fromDeviceConfigContext; the context bootsensureDeviceRegistered()on auth, then pollsGET /api/v1/devices/{dev_id}/configevery 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
SSIDaccess. 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 viaSessionManagerpresence 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._tcpon 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:
- Last-cast-to-this-session — same TV, same foreground session → silent re-use.
- Last-cast-to within 7 days + Mode = Replace → silent auto-cast.
- Single Mode = Replace TV nearby → silent auto-cast.
- Multiple Mode = Replace TVs nearby → chooser sheet (see §13a).
- Mode = Ask, single TV nearby → Ask prompt (§13).
- Mode = Ask, multiple TVs nearby → chooser sheet (§13a).
- 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()requiresACCESS_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.cppfor 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
lastCastAtis 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.