mirror of
https://github.com/yattee/yattee.git
synced 2026-10-28 15:37:17 +00:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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: []) {
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
Reference in New Issue
Block a user