Picking the device's native mode on a phone hands the host the panel's own aspect ratio, so the aspect-fit presenter fills every pixel — including the ones behind the sensor housing and under the four rounded corners. That's why the corners look cut off at max resolution while 1080p has always been fine: a 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe regions.
Android's StreamScreen even documents the assumption that made this invisible — "the stream's own letterbox is black anyway, so the cutout region can never show anything wrong." True for every preset, false for the native mode sitting right above them in the list.
What
The fix is entirely a sizing one — no layout change, no input change. Ask the host for a mode narrowed by the unsafe inset and the existing aspect-fit centres it inside the safe region. Pointer mapping follows for free, because both clients already derive the picture rect from the live host mode (AVMakeRect(aspectRatio:insideRect:) / MouseInput's picture rect) rather than assuming full-bleed.
Apple — SafeDisplay in PunktfunkShared (pure, unit-tested) plus a "This device (safe area)" row beside the native one, using Moonlight's formula: full native height, width less the left+right safe insets. It reaches the touch wheel and the gamepad settings screen for free (both derive from resolutionModes()), and its WxH tag registers as a preset so selecting it doesn't flip the UI into "Custom".
Android — the same shape via SafeArea + a SAFE_AREA_MODE sentinel resolved at connect like the existing 0=native one.
Two things worth a reviewer's eye
The orientation trap. The stream is always landscape, but the settings screen may be portrait — where the same housing reports on top and the horizontal insets read zero. Reading the horizontal insets there computes "no inset needed" for exactly the devices that need one. Both clients fall back to the vertical inset, gated (phone idiom + a ≥40 pt threshold on Apple) so an iPad's status bar can't fabricate an inset for a device with nothing to avoid.
Android needed more than Moonlight's formula. Rounded corners aren't part of Android's cutout insets, so they're added on top: for a full-height picture the horizontal clearance a corner of radius r needs is exactly r — at the topmost row the display boundary sits at x = r. Not conservative, precise.
Both even-floor and clamp, since validate_dimensions rejects odd dimensions outright and an inset subtraction lands odd about half the time. Where a display has neither cutout nor rounded corners the safe mode equals the native one — which on Apple lets the existing dedup drop the duplicate row, and on Android leaves the row honestly showing it changes nothing here.
Apple: swift build --target PunktfunkShared clean; the #if os(iOS) UIKit block typechecked against the real iOS SDK (arm64-apple-ios17.0) since a plain swift build compiles for macOS and never sees it.
⚠️SafeDisplayTests was not executed locally: the checked-in PunktfunkCore.xcframework predates origin/main's Swift (punktfunk_connection_report_phase missing), so PunktfunkKit doesn't link. Pre-existing and unrelated to this change — CI builds the framework and will run the suite. The same assertions were verified meanwhile through a standalone harness against the real SafeDisplay.swift (12/12).
Scope
Library work (launcher entries, per-title exclusions) is deliberately not in this PR — it's being handled separately, since it belongs in the library plugins rather than the deprecated in-host scanners.
Follow-on worth considering: notched MacBooks have the identical problem in fullscreen, where ContentView ignores the safe area on all edges. Left alone here because that behaviour is deliberate and documented in place.
## Why
Picking the device's native mode on a phone hands the host the panel's own aspect ratio, so the aspect-fit presenter fills every pixel — including the ones behind the sensor housing and under the four rounded corners. That's why the corners look cut off at max resolution while 1080p has always been fine: a 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe regions.
Android's `StreamScreen` even documents the assumption that made this invisible — *"the stream's own letterbox is black anyway, so the cutout region can never show anything wrong."* True for every preset, false for the native mode sitting right above them in the list.
## What
The fix is entirely a **sizing** one — no layout change, no input change. Ask the host for a mode narrowed by the unsafe inset and the existing aspect-fit centres it inside the safe region. Pointer mapping follows for free, because both clients already derive the picture rect from the live host mode (`AVMakeRect(aspectRatio:insideRect:)` / `MouseInput`'s picture rect) rather than assuming full-bleed.
**Apple** — `SafeDisplay` in `PunktfunkShared` (pure, unit-tested) plus a *"This device (safe area)"* row beside the native one, using Moonlight's formula: full native height, width less the left+right safe insets. It reaches the touch wheel **and** the gamepad settings screen for free (both derive from `resolutionModes()`), and its `WxH` tag registers as a preset so selecting it doesn't flip the UI into "Custom".
**Android** — the same shape via `SafeArea` + a `SAFE_AREA_MODE` sentinel resolved at connect like the existing `0`=native one.
## Two things worth a reviewer's eye
**The orientation trap.** The stream is always landscape, but the settings screen may be portrait — where the same housing reports on `top` and the horizontal insets read *zero*. Reading the horizontal insets there computes "no inset needed" for exactly the devices that need one. Both clients fall back to the vertical inset, gated (phone idiom + a ≥40 pt threshold on Apple) so an iPad's status bar can't fabricate an inset for a device with nothing to avoid.
**Android needed more than Moonlight's formula.** Rounded corners aren't part of Android's cutout insets, so they're added on top: for a full-height picture the horizontal clearance a corner of radius `r` needs is exactly `r` — at the topmost row the display boundary sits at `x = r`. Not conservative, precise.
Both even-floor and clamp, since `validate_dimensions` rejects odd dimensions outright and an inset subtraction lands odd about half the time. Where a display has neither cutout nor rounded corners the safe mode equals the native one — which on Apple lets the existing dedup drop the duplicate row, and on Android leaves the row honestly showing it changes nothing here.
## Testing
- Android: `./gradlew :app:testDebugUnitTest` — whole app compiles, `SafeAreaTest` **4 tests / 0 failures**.
- Apple: `swift build --target PunktfunkShared` clean; the `#if os(iOS)` UIKit block typechecked against the real iOS SDK (`arm64-apple-ios17.0`) since a plain `swift build` compiles for macOS and never sees it.
- ⚠️ `SafeDisplayTests` was **not** executed locally: the checked-in `PunktfunkCore.xcframework` predates `origin/main`'s Swift (`punktfunk_connection_report_phase` missing), so `PunktfunkKit` doesn't link. Pre-existing and unrelated to this change — CI builds the framework and will run the suite. The same assertions were verified meanwhile through a standalone harness against the real `SafeDisplay.swift` (12/12).
## Scope
Library work (launcher entries, per-title exclusions) is deliberately **not** in this PR — it's being handled separately, since it belongs in the library plugins rather than the deprecated in-host scanners.
Follow-on worth considering: notched MacBooks have the identical problem in fullscreen, where `ContentView` ignores the safe area on all edges. Left alone here because that behaviour is deliberate and documented in place.
Picking the device's native mode on a phone hands the host the panel's own
aspect ratio, so the aspect-fit presenter fills every pixel — including the ones
behind the sensor housing and under the four rounded corners. That is why the
corners look cut off at max resolution while 1080p has always been fine: a 16:9
mode on a 20:9 phone pillarboxes, and those black bars land exactly on the
unsafe regions.
So the fix is entirely a sizing one — no layout change, no input change. Ask the
host for a mode narrowed by the unsafe inset and the existing aspect-fit centres
it inside the safe region; pointer mapping follows for free, because both
clients derive the picture rect from the live host mode rather than assuming
full-bleed.
Apple: `SafeDisplay` (PunktfunkShared, pure + unit-tested) and a "This device
(safe area)" row beside the native one, using Moonlight's formula — full native
height, width less the left+right safe insets. The stream is always landscape
but the settings screen may be portrait, where the same housing is reported on
`top` and the horizontal insets read zero; the portrait top inset stands in,
gated so an iPad's status bar never fabricates an inset.
Android: the same shape via `SafeArea` + a `SAFE_AREA_MODE` sentinel resolved at
connect like the existing `0`=native one. The cutout insets get the same
portrait fallback, and the rounded corners are added on top — Android does not
count them as cutout, and a full-height picture needs exactly the corner radius
of horizontal clearance.
Both even-floor and clamp, since `validate_dimensions` rejects odd dimensions
and an inset subtraction lands odd about half the time. Where a display has
neither cutout nor rounded corners the safe mode equals the native one, which on
Apple lets the existing dedup drop the duplicate row.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Why
Picking the device's native mode on a phone hands the host the panel's own aspect ratio, so the aspect-fit presenter fills every pixel — including the ones behind the sensor housing and under the four rounded corners. That's why the corners look cut off at max resolution while 1080p has always been fine: a 16:9 mode on a 20:9 phone pillarboxes, and those black bars land exactly on the unsafe regions.
Android's
StreamScreeneven documents the assumption that made this invisible — "the stream's own letterbox is black anyway, so the cutout region can never show anything wrong." True for every preset, false for the native mode sitting right above them in the list.What
The fix is entirely a sizing one — no layout change, no input change. Ask the host for a mode narrowed by the unsafe inset and the existing aspect-fit centres it inside the safe region. Pointer mapping follows for free, because both clients already derive the picture rect from the live host mode (
AVMakeRect(aspectRatio:insideRect:)/MouseInput's picture rect) rather than assuming full-bleed.Apple —
SafeDisplayinPunktfunkShared(pure, unit-tested) plus a "This device (safe area)" row beside the native one, using Moonlight's formula: full native height, width less the left+right safe insets. It reaches the touch wheel and the gamepad settings screen for free (both derive fromresolutionModes()), and itsWxHtag registers as a preset so selecting it doesn't flip the UI into "Custom".Android — the same shape via
SafeArea+ aSAFE_AREA_MODEsentinel resolved at connect like the existing0=native one.Two things worth a reviewer's eye
The orientation trap. The stream is always landscape, but the settings screen may be portrait — where the same housing reports on
topand the horizontal insets read zero. Reading the horizontal insets there computes "no inset needed" for exactly the devices that need one. Both clients fall back to the vertical inset, gated (phone idiom + a ≥40 pt threshold on Apple) so an iPad's status bar can't fabricate an inset for a device with nothing to avoid.Android needed more than Moonlight's formula. Rounded corners aren't part of Android's cutout insets, so they're added on top: for a full-height picture the horizontal clearance a corner of radius
rneeds is exactlyr— at the topmost row the display boundary sits atx = r. Not conservative, precise.Both even-floor and clamp, since
validate_dimensionsrejects odd dimensions outright and an inset subtraction lands odd about half the time. Where a display has neither cutout nor rounded corners the safe mode equals the native one — which on Apple lets the existing dedup drop the duplicate row, and on Android leaves the row honestly showing it changes nothing here.Testing
./gradlew :app:testDebugUnitTest— whole app compiles,SafeAreaTest4 tests / 0 failures.swift build --target PunktfunkSharedclean; the#if os(iOS)UIKit block typechecked against the real iOS SDK (arm64-apple-ios17.0) since a plainswift buildcompiles for macOS and never sees it.SafeDisplayTestswas not executed locally: the checked-inPunktfunkCore.xcframeworkpredatesorigin/main's Swift (punktfunk_connection_report_phasemissing), soPunktfunkKitdoesn't link. Pre-existing and unrelated to this change — CI builds the framework and will run the suite. The same assertions were verified meanwhile through a standalone harness against the realSafeDisplay.swift(12/12).Scope
Library work (launcher entries, per-title exclusions) is deliberately not in this PR — it's being handled separately, since it belongs in the library plugins rather than the deprecated in-host scanners.
Follow-on worth considering: notched MacBooks have the identical problem in fullscreen, where
ContentViewignores the safe area on all edges. Left alone here because that behaviour is deliberate and documented in place.