mirror of
https://github.com/yattee/yattee.git
synced 2026-07-21 14:52:03 +00:00
Resize macOS player sheet to match video aspect ratio
In sheet (inline) mode the expanded player kept a fixed size and never reacted to the video's aspect ratio, unlike window/floating mode. Drive the sheet's backing NSWindow size from the video aspect ratio, reusing the same sizing math as the standalone window. - Extract shared sizing into ExpandedPlayerWindowManager.fittedPlayerSize and add fittedSheetSize for the sheet path. - Add SheetWindowResizer (NSViewRepresentable) + .sheetWindowSize modifier that resizes the hosting sheet window, since .presentationSizing(.fitted) only fits once at presentation. - Let the sheet content fill the window and paint the window background black so animated resizes stay gap-free with no white bars.
This commit is contained in:
@@ -93,9 +93,18 @@ struct ContentView: View {
|
|||||||
},
|
},
|
||||||
set: { appEnvironment.navigationCoordinator.isPlayerExpanded = $0 }
|
set: { appEnvironment.navigationCoordinator.isPlayerExpanded = $0 }
|
||||||
)) {
|
)) {
|
||||||
|
let size = expandedSheetSize(appEnvironment: appEnvironment)
|
||||||
ExpandedPlayerSheet()
|
ExpandedPlayerSheet()
|
||||||
.frame(minWidth: 640, minHeight: 480)
|
// Only a floor — the content fills the sheet window so it tracks
|
||||||
|
// the window's animated resize with no gap. Do NOT pin an exact
|
||||||
|
// size here: a fixed frame snaps instantly while the window
|
||||||
|
// animates, exposing the window background as bars.
|
||||||
|
.frame(minWidth: 640, minHeight: 360)
|
||||||
.presentationSizing(.fitted)
|
.presentationSizing(.fitted)
|
||||||
|
// `.presentationSizing(.fitted)` only fits the sheet once, at
|
||||||
|
// presentation. Resize the backing window directly when the
|
||||||
|
// aspect-ratio-derived size changes so the sheet tracks the video.
|
||||||
|
.sheetWindowSize(size)
|
||||||
}
|
}
|
||||||
#elseif os(tvOS)
|
#elseif os(tvOS)
|
||||||
.fullScreenCover(isPresented: Binding(
|
.fullScreenCover(isPresented: Binding(
|
||||||
@@ -111,6 +120,22 @@ struct ContentView: View {
|
|||||||
private func presentExpandedPlayerWindow(appEnvironment: AppEnvironment) {
|
private func presentExpandedPlayerWindow(appEnvironment: AppEnvironment) {
|
||||||
ExpandedPlayerWindowManager.shared.show(with: appEnvironment, animated: true)
|
ExpandedPlayerWindowManager.shared.show(with: appEnvironment, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Size for the expanded-player sheet, derived from the current video aspect
|
||||||
|
/// ratio (when auto-resize is enabled) so the sheet re-fits like window mode.
|
||||||
|
/// Reading `videoAspectRatio` / `playerSheetAutoResize` here registers the
|
||||||
|
/// @Observable dependency that drives the re-fit.
|
||||||
|
private func expandedSheetSize(appEnvironment: AppEnvironment) -> CGSize {
|
||||||
|
let settings = appEnvironment.settingsManager
|
||||||
|
let aspect: Double
|
||||||
|
if settings.playerSheetAutoResize,
|
||||||
|
let ratio = appEnvironment.playerService.state.videoAspectRatio, ratio > 0 {
|
||||||
|
aspect = ratio
|
||||||
|
} else {
|
||||||
|
aspect = 16.0 / 9.0 // fixed default when auto-resize is off / not yet known
|
||||||
|
}
|
||||||
|
return ExpandedPlayerWindowManager.fittedSheetSize(for: aspect)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ final class ExpandedPlayerWindowManager: NSObject {
|
|||||||
private weak var appEnvironment: AppEnvironment?
|
private weak var appEnvironment: AppEnvironment?
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
private let minWidth: CGFloat = 640
|
private static let minWidth: CGFloat = 640
|
||||||
private let minHeight: CGFloat = 360
|
private static let minHeight: CGFloat = 360
|
||||||
private let maxScreenRatio: CGFloat = 0.7
|
private static let maxScreenRatio: CGFloat = 0.7
|
||||||
private let targetVideoHeight: CGFloat = 720
|
private static let targetVideoHeight: CGFloat = 720
|
||||||
private let defaultAspectRatio: Double = 16.0 / 9.0
|
private static let defaultAspectRatio: Double = 16.0 / 9.0
|
||||||
|
|
||||||
var isPresented: Bool {
|
var isPresented: Bool {
|
||||||
playerWindow != nil
|
playerWindow != nil
|
||||||
@@ -106,7 +106,7 @@ final class ExpandedPlayerWindowManager: NSObject {
|
|||||||
|
|
||||||
// Lock manual resize to the video aspect ratio. Seeded with 16:9 here;
|
// Lock manual resize to the video aspect ratio. Seeded with 16:9 here;
|
||||||
// updated as soon as the real video aspect ratio is known.
|
// updated as soon as the real video aspect ratio is known.
|
||||||
applyAspectRatioConstraint(defaultAspectRatio, to: window)
|
applyAspectRatioConstraint(Self.defaultAspectRatio, to: window)
|
||||||
|
|
||||||
// Force the intended size. Assigning `contentViewController` above resizes
|
// Force the intended size. Assigning `contentViewController` above resizes
|
||||||
// the window to the hosting controller's fitting size — and the lightweight
|
// the window to the hosting controller's fitting size — and the lightweight
|
||||||
@@ -327,8 +327,8 @@ final class ExpandedPlayerWindowManager: NSObject {
|
|||||||
// Derive a minimum size that lies on the same ratio so the lower bound
|
// Derive a minimum size that lies on the same ratio so the lower bound
|
||||||
// doesn't force the window off-ratio (which would re-introduce bars).
|
// doesn't force the window off-ratio (which would re-introduce bars).
|
||||||
// Anchor on minHeight and scale width by aspect.
|
// Anchor on minHeight and scale width by aspect.
|
||||||
let derivedMinWidth = max(minHeight * CGFloat(aspectRatio), 320)
|
let derivedMinWidth = max(Self.minHeight * CGFloat(aspectRatio), 320)
|
||||||
window.minSize = NSSize(width: derivedMinWidth, height: minHeight)
|
window.minSize = NSSize(width: derivedMinWidth, height: Self.minHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureWindowLevel(_ window: NSWindow, floating: Bool) {
|
private func configureWindowLevel(_ window: NSWindow, floating: Bool) {
|
||||||
@@ -347,12 +347,12 @@ final class ExpandedPlayerWindowManager: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let screenFrame = screen.visibleFrame
|
let screenFrame = screen.visibleFrame
|
||||||
let maxWidth = screenFrame.width * maxScreenRatio
|
let maxWidth = screenFrame.width * Self.maxScreenRatio
|
||||||
let maxHeight = screenFrame.height * maxScreenRatio
|
let maxHeight = screenFrame.height * Self.maxScreenRatio
|
||||||
|
|
||||||
// Start with 16:9 aspect ratio at target height
|
// Start with 16:9 aspect ratio at target height
|
||||||
var width: CGFloat = targetVideoHeight * 16 / 9
|
var width: CGFloat = Self.targetVideoHeight * 16 / 9
|
||||||
var height: CGFloat = targetVideoHeight
|
var height: CGFloat = Self.targetVideoHeight
|
||||||
|
|
||||||
// Scale down if needed
|
// Scale down if needed
|
||||||
if width > maxWidth {
|
if width > maxWidth {
|
||||||
@@ -364,10 +364,17 @@ final class ExpandedPlayerWindowManager: NSObject {
|
|||||||
width = height * 16 / 9
|
width = height * 16 / 9
|
||||||
}
|
}
|
||||||
|
|
||||||
return NSSize(width: max(width, minWidth), height: max(height, minHeight))
|
return NSSize(width: max(width, Self.minWidth), height: max(height, Self.minHeight))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateWindowSize(for aspectRatio: Double, screenFrame: NSRect) -> NSSize {
|
private func calculateWindowSize(for aspectRatio: Double, screenFrame: NSRect) -> NSSize {
|
||||||
|
Self.fittedPlayerSize(for: aspectRatio, screenFrame: screenFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared sizing math for both the standalone-window path and the sheet path.
|
||||||
|
/// Anchors on `targetVideoHeight`, derives width from the aspect ratio, then
|
||||||
|
/// clamps to `maxScreenRatio` of the screen and the minimum size.
|
||||||
|
static func fittedPlayerSize(for aspectRatio: Double, screenFrame: NSRect) -> NSSize {
|
||||||
let maxWidth = screenFrame.width * maxScreenRatio
|
let maxWidth = screenFrame.width * maxScreenRatio
|
||||||
let maxHeight = screenFrame.height * maxScreenRatio
|
let maxHeight = screenFrame.height * maxScreenRatio
|
||||||
|
|
||||||
@@ -397,6 +404,15 @@ final class ExpandedPlayerWindowManager: NSObject {
|
|||||||
return NSSize(width: width, height: height)
|
return NSSize(width: width, height: height)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fitted sheet content size for a given aspect ratio, resolving the screen
|
||||||
|
/// internally so callers (e.g. `ContentView`) don't need AppKit. Returns a
|
||||||
|
/// plain `CGSize` (`NSSize == CGSize` on macOS).
|
||||||
|
static func fittedSheetSize(for aspectRatio: Double) -> CGSize {
|
||||||
|
let screenFrame = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame
|
||||||
|
?? NSRect(x: 0, y: 0, width: 1280, height: 800)
|
||||||
|
return fittedPlayerSize(for: aspectRatio, screenFrame: screenFrame)
|
||||||
|
}
|
||||||
|
|
||||||
private func constrainToScreen(_ frame: NSRect, screen: NSScreen) -> NSRect {
|
private func constrainToScreen(_ frame: NSRect, screen: NSScreen) -> NSRect {
|
||||||
let screenFrame = screen.visibleFrame
|
let screenFrame = screen.visibleFrame
|
||||||
var adjustedFrame = frame
|
var adjustedFrame = frame
|
||||||
@@ -487,4 +503,73 @@ private struct ExpandedPlayerWindowRoot: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sheet Window Resizer
|
||||||
|
|
||||||
|
/// Resizes the hosting sheet's `NSWindow` to `targetSize` whenever it changes.
|
||||||
|
///
|
||||||
|
/// `.presentationSizing(.fitted)` only fits the sheet to its content once, at
|
||||||
|
/// presentation time — it does not re-fit when the content's ideal size changes
|
||||||
|
/// later (e.g. when the video aspect ratio is decoded). This reaches the sheet's
|
||||||
|
/// backing window directly and resizes it, mirroring how the standalone window
|
||||||
|
/// path drives `setFrame` in `resizeToFitAspectRatio`. It keeps the window
|
||||||
|
/// horizontally centered and top-anchored so the sheet grows/shrinks the way an
|
||||||
|
/// attached sheet naturally sits.
|
||||||
|
struct SheetWindowResizer: NSViewRepresentable {
|
||||||
|
let targetSize: CGSize
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||||
|
|
||||||
|
final class Coordinator {
|
||||||
|
var hasApplied = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeNSView(context _: Context) -> NSView {
|
||||||
|
NSView(frame: .zero)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: NSView, context: Context) {
|
||||||
|
let target = targetSize
|
||||||
|
let coordinator = context.coordinator
|
||||||
|
// The window isn't attached during the same runloop tick as the SwiftUI
|
||||||
|
// update, so defer the resize until the view is in a window.
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard target.width > 0, target.height > 0 else { return }
|
||||||
|
guard let window = nsView.window else { return }
|
||||||
|
|
||||||
|
// Paint the window/content black so that if the content ever lags the
|
||||||
|
// window frame mid-resize, the exposed area is black (matching the
|
||||||
|
// player background) rather than the system's light window color.
|
||||||
|
if window.backgroundColor != .black {
|
||||||
|
window.backgroundColor = .black
|
||||||
|
window.isOpaque = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = window.frame
|
||||||
|
guard abs(current.width - target.width) > 1
|
||||||
|
|| abs(current.height - target.height) > 1 else { return }
|
||||||
|
|
||||||
|
// Snap to the correct size the first time (no animation) so the sheet
|
||||||
|
// opens at the right aspect; animate later aspect-ratio changes.
|
||||||
|
let shouldAnimate = coordinator.hasApplied
|
||||||
|
coordinator.hasApplied = true
|
||||||
|
|
||||||
|
// Keep horizontally centered; anchor the top edge so the sheet grows
|
||||||
|
// downward (AppKit y-origin is bottom-left, so hold `maxY`).
|
||||||
|
let newOrigin = NSPoint(
|
||||||
|
x: current.midX - target.width / 2,
|
||||||
|
y: current.maxY - target.height
|
||||||
|
)
|
||||||
|
let newFrame = NSRect(origin: newOrigin, size: target)
|
||||||
|
window.setFrame(newFrame, display: true, animate: shouldAnimate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Resizes the hosting sheet window to `size` when it changes (macOS sheets).
|
||||||
|
func sheetWindowSize(_ size: CGSize) -> some View {
|
||||||
|
background(SheetWindowResizer(targetSize: size))
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
Reference in New Issue
Block a user