Keep player controls clear of the camera cutout

On the iPhone Duo exterior display in landscape the camera cutout sits
in the bottom trailing corner, outside the safe area, and covered the
rotate button and the end of the progress bar. The top and bottom bars
now take extra padding on the side where an occlusion reserved region
reaches into them. Regions spanning the middle, like a portrait Dynamic
Island, are left to the safe area.
This commit is contained in:
Arkadiusz Fal
2026-10-13 23:41:32 +02:00
parent 7f4555dc2e
commit decaec7026

View File

@@ -606,13 +606,32 @@ struct PlayerControlsView: View {
// Extra padding at bottom to account for space below video area
let bottomExtraPadding = geometry.size.height - effectiveVideoBottom
// Keep the bars' ends clear of a camera cutout in a screen corner
// (iPhone Duo exterior display in landscape)
let topBarBandBottom = topPadding + topBarOffset + Self.cutoutBarBandHeight
let topCutout = cutoutClearance(
in: geometry,
band: 0...topBarBandBottom,
leadingPadding: leftPadding,
trailingPadding: rightPadding
)
let bottomBarBandTop = geometry.size.height - bottomPadding
- (isPanelVisible && !isPanelPinned ? bottomExtraPadding : 0)
- Self.cutoutBarBandHeight
let bottomCutout = cutoutClearance(
in: geometry,
band: bottomBarBandTop...geometry.size.height,
leadingPadding: leftPadding,
trailingPadding: rightPadding
)
ZStack {
// Top bar - aligned to screen top (stays fixed regardless of video area)
VStack {
topBar
.padding(.top, topPadding + topBarOffset)
.padding(.leading, leftPadding)
.padding(.trailing, rightPadding)
.padding(.leading, leftPadding + topCutout.leading)
.padding(.trailing, rightPadding + topCutout.trailing)
.applyCornerAdaptationOffset()
.overlay {
if showPlayerAreaDebug {
@@ -696,8 +715,8 @@ struct PlayerControlsView: View {
// Only apply bottomExtraPadding when floating panel is visible (to stay above panel)
// When panel is pinned (side panel), bottom bar stays at screen bottom
.padding(.bottom, bottomPadding + (isPanelVisible && !isPanelPinned ? bottomExtraPadding : 0))
.padding(.leading, leftPadding)
.padding(.trailing, rightPadding)
.padding(.leading, leftPadding + bottomCutout.leading)
.padding(.trailing, rightPadding + bottomCutout.trailing)
.overlay {
if showPlayerAreaDebug {
Rectangle().stroke(Color.blue, lineWidth: 1)
@@ -717,6 +736,7 @@ struct PlayerControlsView: View {
Text(verbatim: "vidTop: \(Int(effectiveVideoTop)) vidH: \(Int(effectiveVideoHeight))")
Text(verbatim: "centerOff: \(Int(centerYOffset)) btmExtra: \(Int(bottomExtraPadding))")
Text(verbatim: "vis: \(isPanelVisible ? "Y" : "N") fitH: \(Int(videoFitHeight ?? -1)) effVH: \(Int(effectiveVideoHeight))")
Text(verbatim: "cutout top L:\(Int(topCutout.leading)) R:\(Int(topCutout.trailing)) btm L:\(Int(bottomCutout.leading)) R:\(Int(bottomCutout.trailing))")
}
.font(.system(size: 9, weight: .medium, design: .monospaced))
.foregroundStyle(.cyan)
@@ -729,6 +749,41 @@ struct PlayerControlsView: View {
}
}
/// Height of the strip along the top and bottom edges where a bar's
/// buttons and progress bar sit, for matching against cutouts.
private static let cutoutBarBandHeight: CGFloat = 80
/// Space between a cutout and the nearest end of a bar.
private static let cutoutBarGap: CGFloat = 8
/// Extra leading/trailing padding that keeps a bar clear of occluding
/// hardware reaching into its `band` (the iPhone Duo exterior camera
/// sits in a screen corner in landscape, outside the safe area). Regions
/// spanning the middle, like a Dynamic Island in portrait, are left to
/// the safe area.
private func cutoutClearance(
in geometry: GeometryProxy,
band: ClosedRange<CGFloat>,
leadingPadding: CGFloat,
trailingPadding: CGFloat
) -> (leading: CGFloat, trailing: CGFloat) {
guard #available(iOS 27.1, *) else { return (0, 0) }
let width = geometry.size.width
var leading: CGFloat = 0
var trailing: CGFloat = 0
for region in geometry.reservedRegions(kind: .occlusion) {
let frame = region.frame
guard frame.maxY > band.lowerBound, frame.minY < band.upperBound,
frame.maxX > 0, frame.minX < width else { continue }
if frame.maxX < width / 2 {
leading = max(leading, frame.maxX + Self.cutoutBarGap - leadingPadding)
} else if frame.minX > width / 2 {
trailing = max(trailing, width - frame.minX + Self.cutoutBarGap - trailingPadding)
}
}
return (leading, trailing)
}
// MARK: - Top Bar
private var topBar: some View {