Replace UIScreen.main with scene-local context

- List row hairlines read displayScale from the SwiftUI environment
- MPV render views take contentScaleFactor from traitCollection and
  re-apply it on UITraitDisplayScale changes
- ExpandedPlayerWindow scales against the presenting view's own height
- MPVPiPBridge sizes the PiP layer from the container view's window
  scene screen (UIScreen.main kept only as a nil-window fallback)
- WideScreenPlayerLayout falls back to the offered geometry size
- Remote control fullscreen state comes from the player sheet's layout
  pass via NavigationCoordinator.isWideScreenLayoutActive instead of
  screen bounds aspect
- Orientation TODOs for Dynamic Island side detection and
  prefersInterfaceOrientationLocked
This commit is contained in:
Arkadiusz Fal
2026-09-30 19:15:45 +02:00
parent 3395847763
commit 39041e4b39
11 changed files with 49 additions and 20 deletions

View File

@@ -68,6 +68,7 @@ final class NavigationCoordinator {
if !isPlayerExpanded { if !isPlayerExpanded {
hasAppliedPanelDefaultVisibility = false hasAppliedPanelDefaultVisibility = false
isCornerLayoutActive = false isCornerLayoutActive = false
isWideScreenLayoutActive = false
} }
} }
} }
@@ -82,6 +83,11 @@ final class NavigationCoordinator {
/// different view than the layout resolver. /// different view than the layout resolver.
var isCornerLayoutActive = false var isCornerLayoutActive = false
/// Whether the expanded player currently resolves to the widescreen
/// (landscape) layout. Set from the sheet's layout pass so non-view code
/// (remote control state) reads the real layout instead of screen bounds.
var isWideScreenLayoutActive = false
/// Whether the mini player queue sheet is showing. /// Whether the mini player queue sheet is showing.
var isMiniPlayerQueueSheetPresented = false var isMiniPlayerQueueSheetPresented = false

View File

@@ -55,6 +55,8 @@ final class OrientationManager {
notifyOrientationChange() notifyOrientationChange()
} }
// TODO: Modernization - Consider adopting `prefersInterfaceOrientationLocked` (iOS 26+)
// as the modern replacement for orientation locking via `supportedInterfaceOrientations`.
/// Returns the supported interface orientations. /// Returns the supported interface orientations.
/// When locked, returns only the locked orientation. /// When locked, returns only the locked orientation.
/// Otherwise: iPhone = all except upside-down, iPad = all /// Otherwise: iPhone = all except upside-down, iPad = all

View File

@@ -30,6 +30,8 @@ final class MPVPiPBridge: NSObject {
private let sampleBufferLayer = AVSampleBufferDisplayLayer() private let sampleBufferLayer = AVSampleBufferDisplayLayer()
private var pipController: AVPictureInPictureController? private var pipController: AVPictureInPictureController?
private weak var mpvBackend: MPVBackend? private weak var mpvBackend: MPVBackend?
/// The view currently hosting `sampleBufferLayer`; used to reach the scene's screen while PiP is active.
private weak var containerView: PlatformView?
/// Whether PiP is currently active. /// Whether PiP is currently active.
var isPiPActive: Bool { var isPiPActive: Bool {
@@ -123,6 +125,7 @@ final class MPVPiPBridge: NSObject {
/// - containerView: The view to embed the sample buffer layer in /// - containerView: The view to embed the sample buffer layer in
func setup(backend: MPVBackend, in containerView: PlatformView) { func setup(backend: MPVBackend, in containerView: PlatformView) {
self.mpvBackend = backend self.mpvBackend = backend
self.containerView = containerView
// Configure sample buffer layer // Configure sample buffer layer
sampleBufferLayer.frame = containerView.bounds sampleBufferLayer.frame = containerView.bounds
@@ -283,9 +286,13 @@ final class MPVPiPBridge: NSObject {
} }
} }
let screenWidth = UIScreen.main.bounds.width // TODO: Modernization - The UIScreen.main fallback assumes a single display. It is only
// reached when the container view has no window scene (e.g. layer detached); consider
// returning early there instead of sizing against a screen the layer may not be on.
let screenBounds = containerView?.window?.windowScene?.screen.bounds ?? UIScreen.main.bounds
let screenWidth = screenBounds.width
// Calculate height based on aspect ratio, capped to leave room for details // Calculate height based on aspect ratio, capped to leave room for details
let maxHeight = UIScreen.main.bounds.height * 0.6 // Leave 40% for details let maxHeight = screenBounds.height * 0.6 // Leave 40% for details
let calculatedHeight = screenWidth / aspectRatio let calculatedHeight = screenWidth / aspectRatio
let height = min(calculatedHeight, maxHeight) let height = min(calculatedHeight, maxHeight)
let width = height < calculatedHeight ? height * aspectRatio : screenWidth let width = height < calculatedHeight ? height * aspectRatio : screenWidth
@@ -409,6 +416,7 @@ final class MPVPiPBridge: NSObject {
/// as the layer must be in a visible window hierarchy. /// as the layer must be in a visible window hierarchy.
func moveLayer(to containerView: PlatformView) { func moveLayer(to containerView: PlatformView) {
sampleBufferLayer.removeFromSuperlayer() sampleBufferLayer.removeFromSuperlayer()
self.containerView = containerView
if let frame = sanitizedGeometry(containerView.bounds, from: "moveLayer(to:)") { if let frame = sanitizedGeometry(containerView.bounds, from: "moveLayer(to:)") {
sampleBufferLayer.frame = frame sampleBufferLayer.frame = frame
} }

View File

@@ -229,7 +229,10 @@ final class MPVRenderView: UIView {
private func commonInit() { private func commonInit() {
backgroundColor = .black backgroundColor = .black
contentScaleFactor = UIScreen.main.scale contentScaleFactor = traitCollection.displayScale
registerForTraitChanges([UITraitDisplayScale.self]) { (self: MPVRenderView, _) in
self.contentScaleFactor = self.traitCollection.displayScale
}
// Configure EAGL layer // Configure EAGL layer
// Note: retainedBacking=true is required for glReadPixels to work (PiP frame capture). // Note: retainedBacking=true is required for glReadPixels to work (PiP frame capture).

View File

@@ -86,7 +86,10 @@ final class MPVSoftwareRenderView: UIView {
private func commonInit() { private func commonInit() {
backgroundColor = .black backgroundColor = .black
contentScaleFactor = UIScreen.main.scale contentScaleFactor = traitCollection.displayScale
registerForTraitChanges([UITraitDisplayScale.self]) { (self: MPVSoftwareRenderView, _) in
self.contentScaleFactor = self.traitCollection.displayScale
}
// Observe app lifecycle // Observe app lifecycle
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(

View File

@@ -480,8 +480,7 @@ final class RemoteControlCoordinator {
// Compute fullscreen state (same logic as shouldShowFullscreenButton in PlayerControlsActions) // Compute fullscreen state (same logic as shouldShowFullscreenButton in PlayerControlsActions)
#if os(iOS) #if os(iOS)
let isIPad = UIDevice.current.userInterfaceIdiom == .pad let isIPad = UIDevice.current.userInterfaceIdiom == .pad
let screenBounds = UIScreen.main.bounds let isWideScreenLayout = navigationCoordinator?.isWideScreenLayoutActive ?? false
let isWideScreenLayout = screenBounds.width > screenBounds.height
// iPad in widescreen = can't force rotation, so fullscreen toggle not available // iPad in widescreen = can't force rotation, so fullscreen toggle not available
let canToggleFullscreen = !(isIPad && isWideScreenLayout) && state.currentVideo != nil let canToggleFullscreen = !(isIPad && isWideScreenLayout) && state.currentVideo != nil
let isFullscreen = isWideScreenLayout let isFullscreen = isWideScreenLayout

View File

@@ -16,6 +16,8 @@ struct SourceListRow<Content: View>: View {
@ViewBuilder let content: () -> Content @ViewBuilder let content: () -> Content
@Environment(\.displayScale) private var displayScale
/// Horizontal padding for row content. /// Horizontal padding for row content.
#if os(macOS) #if os(macOS)
private let horizontalPadding: CGFloat = 12 private let horizontalPadding: CGFloat = 12
@@ -64,7 +66,7 @@ struct SourceListRow<Content: View>: View {
#if os(iOS) #if os(iOS)
Rectangle() Rectangle()
.fill(Color(uiColor: .separator)) .fill(Color(uiColor: .separator))
.frame(height: 1 / UIScreen.main.scale) .frame(height: 1 / displayScale)
.padding(.leading, dividerLeadingPadding) .padding(.leading, dividerLeadingPadding)
#elseif os(macOS) #elseif os(macOS)
Rectangle() Rectangle()

View File

@@ -56,6 +56,8 @@ struct VideoListRow<Content: View>: View {
@ViewBuilder let content: () -> Content @ViewBuilder let content: () -> Content
@Environment(\.displayScale) private var displayScale
/// Horizontal padding for row content. /// Horizontal padding for row content.
private let horizontalPadding: CGFloat = 16 private let horizontalPadding: CGFloat = 16
@@ -91,7 +93,7 @@ struct VideoListRow<Content: View>: View {
#if os(iOS) #if os(iOS)
Rectangle() Rectangle()
.fill(Color(uiColor: .separator)) .fill(Color(uiColor: .separator))
.frame(height: 1 / UIScreen.main.scale) .frame(height: 1 / displayScale)
.padding(.leading, dividerLeadingPadding) .padding(.leading, dividerLeadingPadding)
#elseif os(macOS) #elseif os(macOS)
Rectangle() Rectangle()

View File

@@ -367,6 +367,9 @@ struct ExpandedPlayerSheet: View {
navigationCoordinator?.isCornerLayoutActive = newValue navigationCoordinator?.isCornerLayoutActive = newValue
} }
#endif #endif
.onChange(of: wideScreen, initial: true) { _, newValue in
navigationCoordinator?.isWideScreenLayoutActive = newValue
}
.onChange(of: wideScreen) { _, newValue in .onChange(of: wideScreen) { _, newValue in
isInWideScreenLayout = newValue isInWideScreenLayout = newValue
#if os(iOS) #if os(iOS)

View File

@@ -622,11 +622,6 @@ final class ExpandedPlayerWindowManager {
mainWindow?.subviews.first mainWindow?.subviews.first
} }
/// Current window height for calculating transforms
private var windowHeight: CGFloat {
mainWindow?.bounds.height ?? UIScreen.main.bounds.height
}
/// Applies scale and corner radius to main window based on progress (0 = normal, 1 = fully scaled) /// Applies scale and corner radius to main window based on progress (0 = normal, 1 = fully scaled)
func scaleMainWindow(progress: CGFloat) { func scaleMainWindow(progress: CGFloat) {
// Capture the presenting view reference on first call // Capture the presenting view reference on first call
@@ -648,7 +643,7 @@ final class ExpandedPlayerWindowManager {
let scale = 1 - scaleAmount let scale = 1 - scaleAmount
// Offset to keep the view centered vertically after scaling // Offset to keep the view centered vertically after scaling
let offsetY = (windowHeight * scaleAmount) / 2 let offsetY = (presentingView.bounds.height * scaleAmount) / 2
// Apply corner radius proportional to progress // Apply corner radius proportional to progress
presentingView.layer.cornerRadius = clampedProgress * scaledCornerRadius presentingView.layer.cornerRadius = clampedProgress * scaledCornerRadius
@@ -667,7 +662,7 @@ final class ExpandedPlayerWindowManager {
let clampedProgress = max(0, min(1, progress)) let clampedProgress = max(0, min(1, progress))
let scaleAmount = clampedProgress * maxScaleProgress let scaleAmount = clampedProgress * maxScaleProgress
let scale = 1 - scaleAmount let scale = 1 - scaleAmount
let offsetY = (windowHeight * scaleAmount) / 2 let offsetY = (presentingView.bounds.height * scaleAmount) / 2
let cornerRadius = clampedProgress * scaledCornerRadius let cornerRadius = clampedProgress * scaledCornerRadius
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: 0, options: []) { UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: 0, options: []) {

View File

@@ -139,12 +139,11 @@ struct WideScreenPlayerLayout<PlayerContent: View>: View {
/// Get full screen bounds from window scene (ignores safe area constraints from parent) /// Get full screen bounds from window scene (ignores safe area constraints from parent)
/// On macOS, this is not used - geometry reader size is used directly /// On macOS, this is not used - geometry reader size is used directly
private var windowSceneBounds: CGSize { private func windowSceneBounds(fallback: CGSize) -> CGSize {
#if os(iOS) #if os(iOS)
guard let window = foregroundWindow else { guard let window = foregroundWindow else {
// Fallback to screen bounds // Fallback to the space this view was actually offered
let screen = UIScreen.main.bounds return fallback
return CGSize(width: max(screen.width, screen.height), height: min(screen.width, screen.height))
} }
// Window frame gives actual size including orientation // Window frame gives actual size including orientation
return window.frame.size return window.frame.size
@@ -158,7 +157,7 @@ struct WideScreenPlayerLayout<PlayerContent: View>: View {
// On iOS: Use window bounds to get full size (avoids safe area constraint from parent) // On iOS: Use window bounds to get full size (avoids safe area constraint from parent)
// On macOS: Use geometry reader size directly (no safe area concerns, window is the source of truth) // On macOS: Use geometry reader size directly (no safe area concerns, window is the source of truth)
#if os(iOS) #if os(iOS)
let windowBounds = windowSceneBounds let windowBounds = windowSceneBounds(fallback: geometry.size)
let availableWidth = windowBounds.width let availableWidth = windowBounds.width
let availableHeight = windowBounds.height let availableHeight = windowBounds.height
let safeAreaLeft = windowSafeAreaInsets.leading let safeAreaLeft = windowSafeAreaInsets.leading
@@ -181,6 +180,13 @@ struct WideScreenPlayerLayout<PlayerContent: View>: View {
// Detect which side has Dynamic Island based on interface orientation // Detect which side has Dynamic Island based on interface orientation
// Note: Can't use safe area comparison because both sides have similar values (~62px each) // Note: Can't use safe area comparison because both sides have similar values (~62px each)
#if os(iOS) #if os(iOS)
// TODO: Modernization - This distinguishes landscapeLeft from landscapeRight to locate
// the Dynamic Island, which window bounds/size classes cannot express. Read the
// orientation from the hosting scene's `effectiveGeometry.interfaceOrientation`
// (iOS 26+) instead of the deprecated `UIWindowScene.interfaceOrientation` once the
// deployment target allows, and consider `traitCollection.verticalBarEdge` (iOS 27.1)
// for the bar-edge question. Global scene lookup should be replaced by the hosting
// window (e.g. via a UIViewRepresentable reading `view.window?.windowScene`).
let interfaceOrientation = UIApplication.shared.connectedScenes let interfaceOrientation = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene } .compactMap { $0 as? UIWindowScene }
.first? .first?