From 39041e4b3947462891c96a77fd9329028d693c8d Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 30 Sep 2026 19:15:45 +0200 Subject: [PATCH] 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 --- .../Navigation/NavigationCoordinator.swift | 6 ++++++ Yattee/Services/OrientationManager.swift | 2 ++ Yattee/Services/Player/MPV/MPVPiPBridge.swift | 12 ++++++++++-- Yattee/Services/Player/MPV/MPVRenderView.swift | 5 ++++- .../Player/MPV/MPVSoftwareRenderView.swift | 5 ++++- .../RemoteControl/RemoteControlCoordinator.swift | 3 +-- Yattee/Views/Components/SourceListRow.swift | 4 +++- Yattee/Views/Components/VideoListRow.swift | 4 +++- Yattee/Views/Player/ExpandedPlayerSheet.swift | 3 +++ Yattee/Views/Player/ExpandedPlayerWindow.swift | 9 ++------- Yattee/Views/Player/WideScreenPlayerLayout.swift | 16 +++++++++++----- 11 files changed, 49 insertions(+), 20 deletions(-) diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index 21c68659..a856186d 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -68,6 +68,7 @@ final class NavigationCoordinator { if !isPlayerExpanded { hasAppliedPanelDefaultVisibility = false isCornerLayoutActive = false + isWideScreenLayoutActive = false } } } @@ -82,6 +83,11 @@ final class NavigationCoordinator { /// different view than the layout resolver. 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. var isMiniPlayerQueueSheetPresented = false diff --git a/Yattee/Services/OrientationManager.swift b/Yattee/Services/OrientationManager.swift index 1f9d76cd..804516e3 100644 --- a/Yattee/Services/OrientationManager.swift +++ b/Yattee/Services/OrientationManager.swift @@ -55,6 +55,8 @@ final class OrientationManager { notifyOrientationChange() } + // TODO: Modernization - Consider adopting `prefersInterfaceOrientationLocked` (iOS 26+) + // as the modern replacement for orientation locking via `supportedInterfaceOrientations`. /// Returns the supported interface orientations. /// When locked, returns only the locked orientation. /// Otherwise: iPhone = all except upside-down, iPad = all diff --git a/Yattee/Services/Player/MPV/MPVPiPBridge.swift b/Yattee/Services/Player/MPV/MPVPiPBridge.swift index b37b9752..1662edb2 100644 --- a/Yattee/Services/Player/MPV/MPVPiPBridge.swift +++ b/Yattee/Services/Player/MPV/MPVPiPBridge.swift @@ -30,6 +30,8 @@ final class MPVPiPBridge: NSObject { private let sampleBufferLayer = AVSampleBufferDisplayLayer() private var pipController: AVPictureInPictureController? 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. var isPiPActive: Bool { @@ -123,6 +125,7 @@ final class MPVPiPBridge: NSObject { /// - containerView: The view to embed the sample buffer layer in func setup(backend: MPVBackend, in containerView: PlatformView) { self.mpvBackend = backend + self.containerView = containerView // Configure sample buffer layer 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 - 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 height = min(calculatedHeight, maxHeight) 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. func moveLayer(to containerView: PlatformView) { sampleBufferLayer.removeFromSuperlayer() + self.containerView = containerView if let frame = sanitizedGeometry(containerView.bounds, from: "moveLayer(to:)") { sampleBufferLayer.frame = frame } diff --git a/Yattee/Services/Player/MPV/MPVRenderView.swift b/Yattee/Services/Player/MPV/MPVRenderView.swift index 0b8cd21a..7299731b 100644 --- a/Yattee/Services/Player/MPV/MPVRenderView.swift +++ b/Yattee/Services/Player/MPV/MPVRenderView.swift @@ -229,7 +229,10 @@ final class MPVRenderView: UIView { private func commonInit() { backgroundColor = .black - contentScaleFactor = UIScreen.main.scale + contentScaleFactor = traitCollection.displayScale + registerForTraitChanges([UITraitDisplayScale.self]) { (self: MPVRenderView, _) in + self.contentScaleFactor = self.traitCollection.displayScale + } // Configure EAGL layer // Note: retainedBacking=true is required for glReadPixels to work (PiP frame capture). diff --git a/Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift b/Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift index 0e280615..50977496 100644 --- a/Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift +++ b/Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift @@ -86,7 +86,10 @@ final class MPVSoftwareRenderView: UIView { private func commonInit() { backgroundColor = .black - contentScaleFactor = UIScreen.main.scale + contentScaleFactor = traitCollection.displayScale + registerForTraitChanges([UITraitDisplayScale.self]) { (self: MPVSoftwareRenderView, _) in + self.contentScaleFactor = self.traitCollection.displayScale + } // Observe app lifecycle NotificationCenter.default.addObserver( diff --git a/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift b/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift index 65e91fea..37fcedca 100644 --- a/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift +++ b/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift @@ -480,8 +480,7 @@ final class RemoteControlCoordinator { // Compute fullscreen state (same logic as shouldShowFullscreenButton in PlayerControlsActions) #if os(iOS) let isIPad = UIDevice.current.userInterfaceIdiom == .pad - let screenBounds = UIScreen.main.bounds - let isWideScreenLayout = screenBounds.width > screenBounds.height + let isWideScreenLayout = navigationCoordinator?.isWideScreenLayoutActive ?? false // iPad in widescreen = can't force rotation, so fullscreen toggle not available let canToggleFullscreen = !(isIPad && isWideScreenLayout) && state.currentVideo != nil let isFullscreen = isWideScreenLayout diff --git a/Yattee/Views/Components/SourceListRow.swift b/Yattee/Views/Components/SourceListRow.swift index ac68e7fe..fe71a1fc 100644 --- a/Yattee/Views/Components/SourceListRow.swift +++ b/Yattee/Views/Components/SourceListRow.swift @@ -16,6 +16,8 @@ struct SourceListRow: View { @ViewBuilder let content: () -> Content + @Environment(\.displayScale) private var displayScale + /// Horizontal padding for row content. #if os(macOS) private let horizontalPadding: CGFloat = 12 @@ -64,7 +66,7 @@ struct SourceListRow: View { #if os(iOS) Rectangle() .fill(Color(uiColor: .separator)) - .frame(height: 1 / UIScreen.main.scale) + .frame(height: 1 / displayScale) .padding(.leading, dividerLeadingPadding) #elseif os(macOS) Rectangle() diff --git a/Yattee/Views/Components/VideoListRow.swift b/Yattee/Views/Components/VideoListRow.swift index f13153d0..0d4f872a 100644 --- a/Yattee/Views/Components/VideoListRow.swift +++ b/Yattee/Views/Components/VideoListRow.swift @@ -56,6 +56,8 @@ struct VideoListRow: View { @ViewBuilder let content: () -> Content + @Environment(\.displayScale) private var displayScale + /// Horizontal padding for row content. private let horizontalPadding: CGFloat = 16 @@ -91,7 +93,7 @@ struct VideoListRow: View { #if os(iOS) Rectangle() .fill(Color(uiColor: .separator)) - .frame(height: 1 / UIScreen.main.scale) + .frame(height: 1 / displayScale) .padding(.leading, dividerLeadingPadding) #elseif os(macOS) Rectangle() diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index e767074a..41072e6b 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -367,6 +367,9 @@ struct ExpandedPlayerSheet: View { navigationCoordinator?.isCornerLayoutActive = newValue } #endif + .onChange(of: wideScreen, initial: true) { _, newValue in + navigationCoordinator?.isWideScreenLayoutActive = newValue + } .onChange(of: wideScreen) { _, newValue in isInWideScreenLayout = newValue #if os(iOS) diff --git a/Yattee/Views/Player/ExpandedPlayerWindow.swift b/Yattee/Views/Player/ExpandedPlayerWindow.swift index 707d68ef..999dcaf1 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindow.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindow.swift @@ -622,11 +622,6 @@ final class ExpandedPlayerWindowManager { 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) func scaleMainWindow(progress: CGFloat) { // Capture the presenting view reference on first call @@ -648,7 +643,7 @@ final class ExpandedPlayerWindowManager { let scale = 1 - scaleAmount // 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 presentingView.layer.cornerRadius = clampedProgress * scaledCornerRadius @@ -667,7 +662,7 @@ final class ExpandedPlayerWindowManager { let clampedProgress = max(0, min(1, progress)) let scaleAmount = clampedProgress * maxScaleProgress let scale = 1 - scaleAmount - let offsetY = (windowHeight * scaleAmount) / 2 + let offsetY = (presentingView.bounds.height * scaleAmount) / 2 let cornerRadius = clampedProgress * scaledCornerRadius UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: 0, options: []) { diff --git a/Yattee/Views/Player/WideScreenPlayerLayout.swift b/Yattee/Views/Player/WideScreenPlayerLayout.swift index e23ffcee..7de32af5 100644 --- a/Yattee/Views/Player/WideScreenPlayerLayout.swift +++ b/Yattee/Views/Player/WideScreenPlayerLayout.swift @@ -139,12 +139,11 @@ struct WideScreenPlayerLayout: View { /// 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 - private var windowSceneBounds: CGSize { + private func windowSceneBounds(fallback: CGSize) -> CGSize { #if os(iOS) guard let window = foregroundWindow else { - // Fallback to screen bounds - let screen = UIScreen.main.bounds - return CGSize(width: max(screen.width, screen.height), height: min(screen.width, screen.height)) + // Fallback to the space this view was actually offered + return fallback } // Window frame gives actual size including orientation return window.frame.size @@ -158,7 +157,7 @@ struct WideScreenPlayerLayout: View { // 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) #if os(iOS) - let windowBounds = windowSceneBounds + let windowBounds = windowSceneBounds(fallback: geometry.size) let availableWidth = windowBounds.width let availableHeight = windowBounds.height let safeAreaLeft = windowSafeAreaInsets.leading @@ -181,6 +180,13 @@ struct WideScreenPlayerLayout: View { // 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) #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 .compactMap { $0 as? UIWindowScene } .first?