fix: scope control fades to preserve responsive window animation
This commit is contained in:
@@ -46,10 +46,12 @@ def check_window_layout(root, app, fixtures):
|
|||||||
)
|
)
|
||||||
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
|
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
|
||||||
report = json.loads(result_path.read_text()) if result_path.exists() else {}
|
report = json.loads(result_path.read_text()) if result_path.exists() else {}
|
||||||
if result.returncode != 0 or not report.get("passed") or report.get("steps") != 32:
|
expected_animations = 0 if report.get("reduceMotion") else 16
|
||||||
|
if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 32
|
||||||
|
or report.get("animatedTransitions") != expected_animations):
|
||||||
raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}")
|
raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}")
|
||||||
print("PASS native SwiftUI window layout: repeated compact/normal transitions with queue open/closed, "
|
print(f"PASS native SwiftUI window layout: {report['animatedTransitions']} observed animations, "
|
||||||
"rapid intent changes, original window and exact normal geometry restored")
|
"responsive mid-animation requests, queue open/closed, minimum sizes and exact normal geometry restored")
|
||||||
|
|
||||||
|
|
||||||
def check_cli_instances(root, app, fixtures):
|
def check_cli_instances(root, app, fixtures):
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ private enum WindowLayoutCheck {
|
|||||||
static var step = 0
|
static var step = 0
|
||||||
static var windowIdentity: ObjectIdentifier?
|
static var windowIdentity: ObjectIdentifier?
|
||||||
static var normalFrame = NSRect.zero
|
static var normalFrame = NSRect.zero
|
||||||
|
static var animationStart: NSRect?
|
||||||
|
static var animationTarget = NSRect.zero
|
||||||
|
static var sawIntermediateFrame = false
|
||||||
|
static var animatedTransitions = 0
|
||||||
|
static var expectsAnimation: Bool { !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }
|
||||||
// Infrastructure timeout only; assertions depend on native transition completion.
|
// Infrastructure timeout only; assertions depend on native transition completion.
|
||||||
static let deadline = ContinuousClock.now.advanced(by: .seconds(30))
|
static let deadline = ContinuousClock.now.advanced(by: .seconds(30))
|
||||||
|
|
||||||
@@ -47,17 +52,46 @@ private enum WindowLayoutCheck {
|
|||||||
|
|
||||||
static func finish(_ error: String? = nil) {
|
static func finish(_ error: String? = nil) {
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
let result: [String: Any] = ["passed": error == nil, "error": error ?? "", "steps": step]
|
let result: [String: Any] = ["passed": error == nil, "error": error ?? "", "steps": step,
|
||||||
|
"animatedTransitions": animatedTransitions, "reduceMotion": !expectsAnimation]
|
||||||
try! JSONSerialization.data(withJSONObject: result).write(to: resultURL, options: .atomic)
|
try! JSONSerialization.data(withJSONObject: result).write(to: resultURL, options: .atomic)
|
||||||
PlayerModel.shared.shutdown()
|
PlayerModel.shared.shutdown()
|
||||||
NSApp.terminate(nil)
|
NSApp.terminate(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func sameFrame(_ lhs: NSRect, _ rhs: NSRect) -> Bool {
|
||||||
|
abs(lhs.minX - rhs.minX) < 1 && abs(lhs.minY - rhs.minY) < 1 &&
|
||||||
|
abs(lhs.width - rhs.width) < 1 && abs(lhs.height - rhs.height) < 1
|
||||||
|
}
|
||||||
|
|
||||||
|
static func observeAnimation(from window: NSWindow, to target: NSRect) {
|
||||||
|
animationStart = window.frame
|
||||||
|
animationTarget = target
|
||||||
|
sawIntermediateFrame = false
|
||||||
|
}
|
||||||
|
|
||||||
static func advance() {
|
static func advance() {
|
||||||
let model = PlayerModel.shared
|
let model = PlayerModel.shared
|
||||||
guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return }
|
guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return }
|
||||||
guard model.canTogglePlayback, model.state.has_video, !model.compactWindow.isTransitioning,
|
guard model.canTogglePlayback, model.state.has_video,
|
||||||
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
||||||
|
if model.compactWindow.isTransitioning {
|
||||||
|
if expectsAnimation, let start = animationStart, !sawIntermediateFrame,
|
||||||
|
!sameFrame(window.frame, start), !sameFrame(window.frame, animationTarget) {
|
||||||
|
sawIntermediateFrame = true
|
||||||
|
// These run-loop-delivered requests must remain responsive mid-animation.
|
||||||
|
model.toggleCompact()
|
||||||
|
model.toggleCompact()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if animationStart != nil {
|
||||||
|
if expectsAnimation {
|
||||||
|
guard sawIntermediateFrame else { finish("No nonblocking animation frames at step \(step)"); return }
|
||||||
|
animatedTransitions += 1
|
||||||
|
}
|
||||||
|
animationStart = nil
|
||||||
|
}
|
||||||
if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) }
|
if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) }
|
||||||
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
|
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
|
||||||
window.layoutIfNeeded()
|
window.layoutIfNeeded()
|
||||||
@@ -74,8 +108,14 @@ private enum WindowLayoutCheck {
|
|||||||
case 1, 5:
|
case 1, 5:
|
||||||
window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true)
|
window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true)
|
||||||
normalFrame = window.frame
|
normalFrame = window.frame
|
||||||
|
guard window.minSize.width >= (model.showsQueue ? 660 : 400), window.minSize.height >= 240 else {
|
||||||
|
finish("Normal minimum size was lost: \(window.minSize)"); return
|
||||||
|
}
|
||||||
case 2, 6:
|
case 2, 6:
|
||||||
// Repeated intent changes must keep the original restoration frame.
|
// Repeated intent changes must keep the original restoration frame.
|
||||||
|
observeAnimation(from: window, to: CompactWindowController.initialFrame(
|
||||||
|
aspect: model.state.video_aspect_ratio,
|
||||||
|
visibleFrame: (window.screen ?? NSScreen.main!).visibleFrame, width: 320))
|
||||||
model.toggleCompact()
|
model.toggleCompact()
|
||||||
model.toggleCompact()
|
model.toggleCompact()
|
||||||
model.toggleCompact()
|
model.toggleCompact()
|
||||||
@@ -84,6 +124,10 @@ private enum WindowLayoutCheck {
|
|||||||
abs(window.frame.width / window.frame.height - model.state.video_aspect_ratio) < 0.01 else {
|
abs(window.frame.width / window.frame.height - model.state.video_aspect_ratio) < 0.01 else {
|
||||||
finish("Compact geometry did not settle at step \(step): \(window.frame)"); return
|
finish("Compact geometry did not settle at step \(step): \(window.frame)"); return
|
||||||
}
|
}
|
||||||
|
guard window.minSize.width >= 160, window.minSize.height >= 90 else {
|
||||||
|
finish("Compact minimum size was lost: \(window.minSize)"); return
|
||||||
|
}
|
||||||
|
observeAnimation(from: window, to: normalFrame)
|
||||||
model.toggleCompact()
|
model.toggleCompact()
|
||||||
}
|
}
|
||||||
step += 1
|
step += 1
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Open one or more local files with **⌘O**, drag them onto the video, or use Fin
|
|||||||
|
|
||||||
Compact mode keeps playback in the same floating window; drag to move it and double-click to restore. Long window titles truncate at the end in both normal and compact mode; hover the title to see the full name. The titlebar pin keeps the normal window on top. macOS media keys also control playback; previous/next seek five seconds rather than changing tracks.
|
Compact mode keeps playback in the same floating window; drag to move it and double-click to restore. Long window titles truncate at the end in both normal and compact mode; hover the title to see the full name. The titlebar pin keeps the normal window on top. macOS media keys also control playback; previous/next seek five seconds rather than changing tracks.
|
||||||
|
|
||||||
Compact entry and exit preserve the original window frame and queue visibility, including rapid toggles. Resizing uses AppKit's coordinated window animation rather than an animator proxy, avoiding competing SwiftUI window-size updates. The native resize call is synchronous for the duration of the animation.
|
Compact entry and exit animate asynchronously over 0.25 seconds, preserving the original window frame and queue visibility even with rapid toggles. Control fades animate only their opacity rather than the whole window update, avoiding competing SwiftUI window-size animations. Reduce Motion disables these animations.
|
||||||
|
|
||||||
The normal titlebar control container uses matching 4-point top and right margins. Its horizontal padding accounts for the buttons' internal spacing to visually match the title container.
|
The normal titlebar control container uses matching 4-point top and right margins. Its horizontal padding accounts for the buttons' internal spacing to visually match the title container.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import QuartzCore
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CompactWindowController: NSObject {
|
final class CompactWindowController: NSObject {
|
||||||
@@ -378,10 +379,10 @@ final class CompactWindowController: NSObject {
|
|||||||
frameAnimationGeneration &+= 1
|
frameAnimationGeneration &+= 1
|
||||||
let generation = frameAnimationGeneration
|
let generation = frameAnimationGeneration
|
||||||
frameAnimating = true
|
frameAnimating = true
|
||||||
// Coordinate the resize through NSWindow. Its animator proxy races SwiftUI's
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
// animated window sizing and can re-enter layout until AppKit aborts.
|
context.duration = Self.transitionDuration
|
||||||
NSAnimationContext.runAnimationGroup { _ in
|
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
window.setFrame(frame, display: true, animate: true)
|
window.animator().setFrame(frame, display: true)
|
||||||
} completionHandler: { [weak self, weak window] in
|
} completionHandler: { [weak self, weak window] in
|
||||||
MainActor.assumeIsolated {
|
MainActor.assumeIsolated {
|
||||||
guard let self, let window, self.window === window, self.frameAnimating,
|
guard let self, let window, self.window === window, self.frameAnimating,
|
||||||
@@ -396,7 +397,11 @@ final class CompactWindowController: NSObject {
|
|||||||
guard frameAnimating else { return }
|
guard frameAnimating else { return }
|
||||||
frameAnimating = false
|
frameAnimating = false
|
||||||
frameAnimationGeneration &+= 1
|
frameAnimationGeneration &+= 1
|
||||||
// Native resize animation has returned; invalidate only its pending completion.
|
guard let window else { return }
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0
|
||||||
|
window.animator().setFrame(window.frame, display: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setState(compact: Bool, transitioning: Bool) {
|
private func setState(compact: Bool, transitioning: Bool) {
|
||||||
|
|||||||
+21
-15
@@ -935,9 +935,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
guard shouldHideControls else { return }
|
guard shouldHideControls else { return }
|
||||||
hideDeadline = nil
|
hideDeadline = nil
|
||||||
keyboardNavigation = false
|
keyboardNavigation = false
|
||||||
withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeIn(duration: 0.25)) {
|
|
||||||
controlsVisible = false
|
controlsVisible = false
|
||||||
}
|
|
||||||
NSCursor.setHiddenUntilMouseMoves(hideCursor && pointerInsideWindow && windowActive && !isCompact)
|
NSCursor.setHiddenUntilMouseMoves(hideCursor && pointerInsideWindow && windowActive && !isCompact)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,11 +954,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
hideControls(hideCursor: false)
|
hideControls(hideCursor: false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !controlsVisible {
|
if !controlsVisible { controlsVisible = true }
|
||||||
withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeOut(duration: 0.25)) {
|
|
||||||
controlsVisible = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard shouldHideControls else { return }
|
guard shouldHideControls else { return }
|
||||||
hideDeadline = ContinuousClock.now.advanced(by: .milliseconds(2500))
|
hideDeadline = ContinuousClock.now.advanced(by: .milliseconds(2500))
|
||||||
}
|
}
|
||||||
@@ -2009,9 +2003,7 @@ private struct PlayerContent: View {
|
|||||||
|
|
||||||
private func toggleYouTubeInput() {
|
private func toggleYouTubeInput() {
|
||||||
if showsYouTubeInput { youTubeInputFocused = false }
|
if showsYouTubeInput { youTubeInputFocused = false }
|
||||||
withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.22)) {
|
|
||||||
showsYouTubeInput.toggle()
|
showsYouTubeInput.toggle()
|
||||||
}
|
|
||||||
if showsYouTubeInput { youTubeInputFocused = true }
|
if showsYouTubeInput { youTubeInputFocused = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2024,6 +2016,10 @@ private struct PlayerContent: View {
|
|||||||
? nil : .easeInOut(duration: CompactWindowController.transitionDuration)
|
? nil : .easeInOut(duration: CompactWindowController.transitionDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var controlsVisibilityAnimation: Animation? {
|
||||||
|
reduceMotion ? nil : (model.controlsVisible ? .easeOut(duration: 0.25) : .easeIn(duration: 0.25))
|
||||||
|
}
|
||||||
|
|
||||||
private var bufferedStatus: String? {
|
private var bufferedStatus: String? {
|
||||||
model.bufferedRange.map {
|
model.bufferedRange.map {
|
||||||
let behind = max(0, model.state.position - $0.lowerBound)
|
let behind = max(0, model.state.position - $0.lowerBound)
|
||||||
@@ -2092,7 +2088,9 @@ private struct PlayerContent: View {
|
|||||||
.frame(height: 32)
|
.frame(height: 32)
|
||||||
.foregroundStyle(Color(SunsetPalette.text))
|
.foregroundStyle(Color(SunsetPalette.text))
|
||||||
.onHover(perform: model.setTitleHovered)
|
.onHover(perform: model.setTitleHovered)
|
||||||
.opacity(model.controlsVisible ? 1 : 0)
|
.animation(controlsVisibilityAnimation) { content in
|
||||||
|
content.opacity(model.controlsVisible ? 1 : 0)
|
||||||
|
}
|
||||||
.allowsHitTesting(model.controlsVisible)
|
.allowsHitTesting(model.controlsVisible)
|
||||||
.accessibilityHidden(!model.controlsVisible)
|
.accessibilityHidden(!model.controlsVisible)
|
||||||
}
|
}
|
||||||
@@ -2188,7 +2186,9 @@ private struct PlayerContent: View {
|
|||||||
.foregroundStyle(Color(SunsetPalette.text))
|
.foregroundStyle(Color(SunsetPalette.text))
|
||||||
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)))
|
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)))
|
||||||
.onHover(perform: model.setControlsHovered)
|
.onHover(perform: model.setControlsHovered)
|
||||||
.opacity(model.controlsVisible ? 1 : 0)
|
.animation(controlsVisibilityAnimation) { content in
|
||||||
|
content.opacity(model.controlsVisible ? 1 : 0)
|
||||||
|
}
|
||||||
.allowsHitTesting(model.controlsVisible)
|
.allowsHitTesting(model.controlsVisible)
|
||||||
.accessibilityHidden(!model.controlsVisible)
|
.accessibilityHidden(!model.controlsVisible)
|
||||||
}
|
}
|
||||||
@@ -2413,9 +2413,11 @@ private struct PlayerContent: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
|
.clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
|
||||||
.frame(maxWidth: geometry.size.width * 0.8)
|
.frame(maxWidth: geometry.size.width * 0.8)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.bottom, model.controlsVisible
|
.animation(controlsVisibilityAnimation) { content in
|
||||||
|
content.padding(.bottom, model.controlsVisible
|
||||||
? (model.isCompact ? compactControlHeight + 20 : 110)
|
? (model.isCompact ? compactControlHeight + 20 : 110)
|
||||||
: (model.isCompact ? 12 : 24))
|
: (model.isCompact ? 12 : 24))
|
||||||
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
@@ -2520,7 +2522,7 @@ private struct PlayerContent: View {
|
|||||||
.overlay(RoundedRectangle(cornerRadius: 8)
|
.overlay(RoundedRectangle(cornerRadius: 8)
|
||||||
.strokeBorder(Color(SunsetPalette.outline), lineWidth: 0.5)
|
.strokeBorder(Color(SunsetPalette.outline), lineWidth: 0.5)
|
||||||
.allowsHitTesting(false))
|
.allowsHitTesting(false))
|
||||||
.transition(.opacity)
|
.transition(.opacity.animation(reduceMotion ? nil : .easeInOut(duration: 0.22)))
|
||||||
.disabled(!model.canOpen)
|
.disabled(!model.canOpen)
|
||||||
}
|
}
|
||||||
Text("Drop a media file or YouTube link here")
|
Text("Drop a media file or YouTube link here")
|
||||||
@@ -2578,7 +2580,9 @@ private struct PlayerContent: View {
|
|||||||
.padding(8)
|
.padding(8)
|
||||||
.foregroundStyle(Color(SunsetPalette.text))
|
.foregroundStyle(Color(SunsetPalette.text))
|
||||||
.onHover(perform: model.setTitleHovered)
|
.onHover(perform: model.setTitleHovered)
|
||||||
.opacity(model.controlsVisible ? 1 : 0)
|
.animation(controlsVisibilityAnimation) { content in
|
||||||
|
content.opacity(model.controlsVisible ? 1 : 0)
|
||||||
|
}
|
||||||
.allowsHitTesting(model.controlsVisible)
|
.allowsHitTesting(model.controlsVisible)
|
||||||
.accessibilityHidden(!model.controlsVisible)
|
.accessibilityHidden(!model.controlsVisible)
|
||||||
compactControls
|
compactControls
|
||||||
@@ -2607,7 +2611,9 @@ private struct PlayerContent: View {
|
|||||||
height: controlHeight * min(1, (geometry.size.width - 32) / 440))
|
height: controlHeight * min(1, (geometry.size.width - 32) / 440))
|
||||||
.onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 }
|
.onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 }
|
||||||
.position(controllerPosition(in: geometry.size))
|
.position(controllerPosition(in: geometry.size))
|
||||||
.opacity(model.controlsVisible ? 1 : 0)
|
.animation(controlsVisibilityAnimation) { content in
|
||||||
|
content.opacity(model.controlsVisible ? 1 : 0)
|
||||||
|
}
|
||||||
.animation(compactChromeAnimation) { content in
|
.animation(compactChromeAnimation) { content in
|
||||||
content.opacity(model.isCompact ? 0 : 1)
|
content.opacity(model.isCompact ? 0 : 1)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user