Files
punktfunk/clients/apple/Sources/PunktfunkShared/SafeDisplay.swift
T
enricobuehler 78ad675507
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 2m2s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m55s
ci / rust (pull_request) Successful in 7m19s
feat(clients): a safe-area resolution that keeps the picture out of the notch
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.
2026-08-06 13:44:01 +02:00

87 lines
5.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Safe-area stream sizing — the pure geometry behind the "safe area" resolution row.
//
// An iPhone clips the picture in HARDWARE: the sensor housing (notch / Dynamic Island) and the four
// rounded corners eat whatever the stream draws underneath them. The session view is deliberately
// edge-to-edge (ContentView's `.ignoresSafeArea()` on iOS) and the presenter aspect-FITS the host
// mode into it, so which pixels survive is decided entirely by the mode's aspect ratio:
//
// * A 16:9 mode on a 19.5:9 phone pillarboxes, and those black bars land exactly on the unsafe
// regions. That is why 1080p has always "just worked" and never needed a setting.
// * The device's NATIVE mode has the screen's own aspect ratio, so it fills every pixel —
// including the ones behind the housing and under the corner radii. That is the mode that
// loses its corners, and the reason this file exists.
//
// So the fix needs no layout change and no input change: ask the host for a mode that is narrower
// by the safe-area insets, and the existing aspect-fit centres it inside the safe region. Pointer
// input keeps mapping correctly for free, because `hostPoint(from:)` derives the video rect from
// the live host mode (`AVMakeRect(aspectRatio:insideRect:)`) instead of assuming full-bleed.
//
// The formula is Moonlight's (its settings' resolution table carries the same row): full native
// height, width reduced by the left+right safe-area insets. Width-only is not a simplification —
// under aspect-fit only one axis can bind, and on a landscape phone that axis is always the
// horizontal one. Insetting the height too would shrink the picture without uncovering anything.
import Foundation
public enum SafeDisplay {
/// The host rejects odd dimensions and anything under 320×200 (`validate_dimensions` in
/// `pf-encode`), so the computed mode is even-floored and clamped exactly like `RenderScale`.
public static let minWidth = 320
public static let minHeight = 200
/// A portrait top inset at or above this many points means a sensor housing rather than a
/// status bar. Notched and Dynamic Island iPhones report 4459 pt; a plain status bar (older
/// iPhones, every iPad) reports 2024 pt. Used only by [`sideInsetPoints`] and only when the
/// horizontal insets are unavailable — see there for why that case exists at all.
public static let housingTopInsetThreshold: Double = 40
/// The per-side inset, in points, that the **landscape** stream will be subject to — which is
/// not necessarily the inset the caller can read right now.
///
/// The stream is always landscape, but the settings screen the resolution row is rendered in may
/// be portrait, and `safeAreaInsets` only ever describes the CURRENT orientation. In portrait a
/// notched iPhone reports its housing on `top` and reports `left`/`right` as zero, so reading
/// the horizontal insets there would compute "no inset needed" for exactly the devices that
/// need one.
///
/// - In landscape, `max(left, right)` is the answer directly. (iOS symmetrizes the two so
/// content stays centred, so they normally agree; `max` is simply the safe reduction.)
/// - In portrait, the housing's portrait TOP inset equals its landscape SIDE inset on every
/// notched/Dynamic Island iPhone — the same physical intrusion, measured on the axis that
/// happens to be vertical at the time — so `top` is the correct stand-in. It is accepted only
/// on phones and only past [`housingTopInsetThreshold`], so an iPad's status bar (or an older
/// iPhone's) never fabricates an inset for a device with nothing to avoid.
///
/// Returns 0 when there is no housing to route around, which makes the safe mode identical to
/// the native one — and the caller's dedup then drops the duplicate row on its own.
public static func sideInsetPoints(
left: Double, right: Double, top: Double, isPhone: Bool
) -> Double {
let horizontal = max(left, right)
if horizontal > 0 { return horizontal }
if isPhone, top >= housingTopInsetThreshold { return top }
return 0
}
/// The landscape safe-area mode in PIXELS: full native height, width reduced by
/// `sideInsetPoints` on each side.
///
/// `nativeWidth`/`nativeHeight` are the device's native landscape pixels (the long edge first —
/// `UIScreen.main.nativeBounds` is portrait-oriented, so the caller swaps). `scale` converts the
/// point-valued insets into those same pixels and must therefore be `nativeScale`, not `scale`:
/// with Display Zoom on, the two differ and only the former matches `nativeBounds`.
///
/// Even-floored and clamped so the result is directly host-valid — an odd width is rejected
/// outright by the encoder, and an inset subtraction lands odd about half the time.
public static func mode(
nativeWidth: Int, nativeHeight: Int, sideInsetPoints: Double, scale: Double
) -> (width: Int, height: Int) {
let insetPixels = max(0, sideInsetPoints) * max(scale, 1) * 2 // both sides
let width = Double(nativeWidth) - insetPixels
let evenFloor: (Double, Int) -> Int = { value, minimum in
max(Int(value.rounded(.down)), minimum) / 2 * 2
}
return (evenFloor(width, minWidth), evenFloor(Double(nativeHeight), minHeight))
}
}