A TV negotiated the refresh its menu pinned, not the one it outputs #378

Merged
enricobuehler merged 1 commits from worktree-tv-refresh-mismatch into main 2026-08-22 22:47:22 +00:00
Owner

Field report: on Android TV / Fire Stick, latency explodes whenever the client's refresh differs from the host's, and setting the refresh by hand is the only workaround.

The client was manufacturing that mismatch itself, in three steps:

  1. MainActivity.onCreate pins the panel to its highest-refresh mode for the console UI (setConsoleHighRefreshRate(true)) — unconditionally, TVs included. That pin exists for phone refresh governors (Nothing OS's LTPO logic among them) which cap third-party apps at 60 Hz. No TV has one.
  2. At connect, nativeDisplayMode resolves "Native" refresh from display.mode — which now reports the mode the menu pinned, not the TV's real HDMI output. So the session negotiates (say) 120.
  3. StreamScreen releases the pin again on TV, by design: there the decoder's own setFrameRate(CHANGE_FRAME_RATE_ALWAYS) governs the HDMI mode. The panel falls back to 60 while the host is already serving 120.

A 120 fps stream on a 60 Hz output, by construction, on exactly the two form factors in the report. Picking a refresh explicitly is precisely what bypasses step 2, which is why that is the workaround people found. The mode comparator sorts refresh before area, so the same pin could also drop a 4K TV to 1080p120 and negotiate the stream at that.

The fix

Fixed at the choke point: resolveHighRefreshMode returns early on a TV, leaving highRefreshModeId at 0, which setConsoleHighRefreshRate already treats as a no-op — so all three of its callers are covered by the one guard. A TV that genuinely wants 120 still gets it by choosing it, driven by the native mode switch, exactly as the TV path documents.

Also in the same chain: nativeDisplayMode truncated the panel rate, so a TV reporting the fractional NTSC rates over HDMI (59.94, 29.97, 23.976) asked the host for 59 / 29 / 23 — rates no display mode has, which the host serves by clamping down to the highest it advertises at or below (pf-win-display's chosen_hz). Rounded now, which also makes it agree with MainActivity.streamPanelFps; the two describe the same panel and must not disagree.

Not done

No clamp of a user-chosen refresh to what the panel can actually present. That mismatch is explicit rather than manufactured, and the TV mode-switch path already owns it. Worth adding only if the report recurs with a refresh set by hand.

Checks

:app:compileDebugKotlin clean and :app:testDebugUnitTest green (only pre-existing Compose / Roborazzi deprecation warnings). Kotlin-only change; nothing versioned moves, so no CHANGELOG.md entry — the release cut assembles that.

Field report: on Android TV / Fire Stick, latency explodes whenever the client's refresh differs from the host's, and setting the refresh by hand is the only workaround. The client was manufacturing that mismatch itself, in three steps: 1. `MainActivity.onCreate` pins the panel to its highest-refresh mode for the console UI (`setConsoleHighRefreshRate(true)`) — unconditionally, TVs included. That pin exists for phone refresh governors (Nothing OS's LTPO logic among them) which cap third-party apps at 60 Hz. No TV has one. 2. At connect, `nativeDisplayMode` resolves "Native" refresh from `display.mode` — which now reports the mode the **menu** pinned, not the TV's real HDMI output. So the session negotiates (say) 120. 3. `StreamScreen` releases the pin again on TV, by design: there the decoder's own `setFrameRate(CHANGE_FRAME_RATE_ALWAYS)` governs the HDMI mode. The panel falls back to 60 while the host is already serving 120. A 120 fps stream on a 60 Hz output, by construction, on exactly the two form factors in the report. Picking a refresh explicitly is precisely what bypasses step 2, which is why that is the workaround people found. The mode comparator sorts refresh before area, so the same pin could also drop a 4K TV to 1080p120 and negotiate the stream at that. ## The fix Fixed at the choke point: `resolveHighRefreshMode` returns early on a TV, leaving `highRefreshModeId` at 0, which `setConsoleHighRefreshRate` already treats as a no-op — so all three of its callers are covered by the one guard. A TV that genuinely wants 120 still gets it by choosing it, driven by the native mode switch, exactly as the TV path documents. Also in the same chain: `nativeDisplayMode` **truncated** the panel rate, so a TV reporting the fractional NTSC rates over HDMI (59.94, 29.97, 23.976) asked the host for 59 / 29 / 23 — rates no display mode has, which the host serves by clamping *down* to the highest it advertises at or below (`pf-win-display`'s `chosen_hz`). Rounded now, which also makes it agree with `MainActivity.streamPanelFps`; the two describe the same panel and must not disagree. ## Not done No clamp of a user-chosen refresh to what the panel can actually present. That mismatch is explicit rather than manufactured, and the TV mode-switch path already owns it. Worth adding only if the report recurs with a refresh set by hand. ## Checks `:app:compileDebugKotlin` clean and `:app:testDebugUnitTest` green (only pre-existing Compose / Roborazzi deprecation warnings). Kotlin-only change; nothing versioned moves, so no `CHANGELOG.md` entry — the release cut assembles that.
enricobuehler added 1 commit 2026-08-22 22:40:12 +00:00
fix(android): a TV negotiated the refresh its MENU pinned, not the one it outputs
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 39s
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 2m17s
ci / rust (pull_request) Successful in 5m7s
android / android (pull_request) Successful in 6m22s
064ea3de7d
Field report: on Android TV / Fire Stick, latency explodes whenever the client's
refresh differs from the host's, and setting the refresh by hand is the only
workaround.

The client was manufacturing that mismatch itself, in three steps:

  1. `MainActivity.onCreate` pins the panel to its highest-refresh mode for the
     console UI (`setConsoleHighRefreshRate(true)`) — unconditionally, TVs
     included. That pin exists for phone refresh governors (Nothing OS's LTPO
     logic among them) which cap third-party apps at 60 Hz. No TV has one.
  2. At connect, `nativeDisplayMode` resolves "Native" refresh from
     `display.mode` — which now reports the mode the MENU pinned, not the TV's
     real HDMI output. So the session negotiates (say) 120.
  3. `StreamScreen` releases the pin again on TV, by design: there the decoder's
     own `setFrameRate(CHANGE_FRAME_RATE_ALWAYS)` governs the HDMI mode. The
     panel falls back to 60 while the host is already serving 120.

A 120 fps stream on a 60 Hz output, by construction, on exactly the two form
factors in the report. Picking a refresh explicitly is precisely what bypasses
step 2, which is why that is the workaround people found. The mode comparator
sorts refresh before area, so the same pin could also drop a 4K TV to 1080p120
and negotiate the stream at that.

Fixed at the choke point: `resolveHighRefreshMode` returns early on a TV, leaving
`highRefreshModeId` at 0, which `setConsoleHighRefreshRate` already treats as a
no-op — so all three of its callers are covered by the one guard. A TV that
genuinely wants 120 still gets it by choosing it, driven by the native mode
switch, exactly as the TV path documents.

Also in the same chain: `nativeDisplayMode` TRUNCATED the panel rate, so a TV
reporting the fractional NTSC rates over HDMI (59.94, 29.97, 23.976) asked the
host for 59 / 29 / 23 — rates no display mode has, which the host serves by
clamping down to the highest it advertises at or below. Rounded now, which also
makes it agree with `MainActivity.streamPanelFps`; the two describe the same
panel and must not disagree.
enricobuehler merged commit b670b5d844 into main 2026-08-22 22:47:22 +00:00
enricobuehler deleted branch worktree-tv-refresh-mismatch 2026-08-22 22:47:29 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#378