feat: animate queue sidebar expansion and collapse
This commit is contained in:
@@ -27,6 +27,10 @@ private enum WindowLayoutCheck {
|
||||
static var animationTarget = NSRect.zero
|
||||
static var sawIntermediateFrame = false
|
||||
static var animatedTransitions = 0
|
||||
static var animatedQueueTransitions = 0
|
||||
static var queueVideoSize: NSSize?
|
||||
static var observingQueue = false
|
||||
static var animationQueueWidth: CGFloat = 0
|
||||
static var expectsAnimation: Bool { !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }
|
||||
// Infrastructure timeout only; assertions depend on native transition completion.
|
||||
static let deadline = ContinuousClock.now.advanced(by: .seconds(30))
|
||||
@@ -53,7 +57,9 @@ private enum WindowLayoutCheck {
|
||||
static func finish(_ error: String? = nil) {
|
||||
timer?.invalidate()
|
||||
let result: [String: Any] = ["passed": error == nil, "error": error ?? "", "steps": step,
|
||||
"animatedTransitions": animatedTransitions, "reduceMotion": !expectsAnimation]
|
||||
"animatedTransitions": animatedTransitions,
|
||||
"animatedQueueTransitions": animatedQueueTransitions,
|
||||
"reduceMotion": !expectsAnimation]
|
||||
try! JSONSerialization.data(withJSONObject: result).write(to: resultURL, options: .atomic)
|
||||
PlayerModel.shared.shutdown()
|
||||
NSApp.terminate(nil)
|
||||
@@ -68,6 +74,7 @@ private enum WindowLayoutCheck {
|
||||
animationStart = window.frame
|
||||
animationTarget = target
|
||||
sawIntermediateFrame = false
|
||||
animationQueueWidth = PlayerModel.shared.queueWidth
|
||||
}
|
||||
|
||||
static func sidebarHeaderIsVisible(in window: NSWindow) -> Bool {
|
||||
@@ -81,11 +88,13 @@ private enum WindowLayoutCheck {
|
||||
// Each control must paint a visible glyph, not merely exist in the view tree.
|
||||
return (0..<3).allSatisfy { index in
|
||||
var visiblePixels = 0
|
||||
// Fullscreen disables pinning; its glyph uses the native dimmed tint.
|
||||
let minimumBrightness = index == 0 && window.styleMask.contains(.fullScreen) ? 0.25 : 0.5
|
||||
for x in Int(CGFloat(index * 28) * scale)..<Int(CGFloat(index * 28 + 24) * scale) {
|
||||
for y in 0..<bitmap.pixelsHigh {
|
||||
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB),
|
||||
color.alphaComponent > 0.5,
|
||||
min(color.redComponent, color.greenComponent, color.blueComponent) > 0.5 {
|
||||
min(color.redComponent, color.greenComponent, color.blueComponent) > minimumBrightness {
|
||||
visiblePixels += 1
|
||||
}
|
||||
}
|
||||
@@ -99,29 +108,73 @@ private enum WindowLayoutCheck {
|
||||
guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return }
|
||||
guard model.canTogglePlayback, model.state.has_video,
|
||||
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
||||
if model.compactWindow.isTransitioning {
|
||||
if model.compactWindow.isTransitioning || model.isQueueTransitioning {
|
||||
window.layoutIfNeeded()
|
||||
if let size = queueVideoSize, let video = model.videoView {
|
||||
guard abs(video.bounds.width - size.width) < 2, abs(video.bounds.height - size.height) < 2 else {
|
||||
finish("Queue animation changed video size at step \(step): \(video.bounds.size), expected \(size)"); return
|
||||
}
|
||||
}
|
||||
if expectsAnimation, let start = animationStart, !sawIntermediateFrame,
|
||||
!sameFrame(window.frame, start), !sameFrame(window.frame, animationTarget) {
|
||||
(!sameFrame(window.frame, start) && !sameFrame(window.frame, animationTarget) ||
|
||||
observingQueue && abs(model.queueWidth - animationQueueWidth) > 1 &&
|
||||
model.queueWidth > 1 && model.queueWidth < 259) {
|
||||
sawIntermediateFrame = true
|
||||
// These run-loop-delivered requests must remain responsive mid-animation.
|
||||
model.toggleCompact()
|
||||
model.toggleCompact()
|
||||
if model.isQueueTransitioning {
|
||||
let frame = window.frame
|
||||
model.showsQueue.toggle()
|
||||
model.showsQueue.toggle()
|
||||
guard sameFrame(window.frame, frame) else { finish("Queue reversal jumped the window"); return }
|
||||
} else {
|
||||
model.toggleCompact()
|
||||
model.toggleCompact()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if animationStart != nil {
|
||||
if expectsAnimation {
|
||||
guard sawIntermediateFrame else { finish("No nonblocking animation frames at step \(step)"); return }
|
||||
animatedTransitions += 1
|
||||
if observingQueue { animatedQueueTransitions += 1 }
|
||||
else { animatedTransitions += 1 }
|
||||
}
|
||||
animationStart = nil
|
||||
guard sameFrame(window.frame, animationTarget) else {
|
||||
finish("Animation missed target at step \(step): \(window.frame), expected \(animationTarget)"); return
|
||||
}
|
||||
queueVideoSize = nil
|
||||
observingQueue = false
|
||||
}
|
||||
if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) }
|
||||
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
|
||||
if model.fullScreenIntent != nil { return }
|
||||
window.layoutIfNeeded()
|
||||
if model.showsQueue && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
|
||||
finish("Sidebar pin, Cast or settings glyph is not visible at step \(step)"); return
|
||||
}
|
||||
if step >= 33 {
|
||||
switch step {
|
||||
case 33, 34:
|
||||
guard model.isFullScreen else { finish("Fullscreen entry failed"); return }
|
||||
normalFrame = window.frame
|
||||
observeAnimation(from: window, to: normalFrame)
|
||||
observingQueue = true
|
||||
model.showsQueue.toggle()
|
||||
case 35:
|
||||
guard sameFrame(window.frame, normalFrame), model.showsQueue,
|
||||
let video = model.videoView, let content = window.contentView,
|
||||
abs(video.bounds.width - (content.bounds.width - 260)) < 2 else {
|
||||
finish("Fullscreen queue did not retain fixed window bounds and fit the video"); return
|
||||
}
|
||||
model.toggleFullScreen()
|
||||
default:
|
||||
guard !model.isFullScreen else { finish("Fullscreen exit failed"); return }
|
||||
finish(); return
|
||||
}
|
||||
step += 1
|
||||
return
|
||||
}
|
||||
switch step % 8 {
|
||||
case 0, 4:
|
||||
if step > 0 {
|
||||
@@ -130,8 +183,26 @@ private enum WindowLayoutCheck {
|
||||
abs(actual.width - normalFrame.width) < 1, abs(actual.height - normalFrame.height) < 1,
|
||||
!model.isCompact else { finish("Normal geometry was not restored at step \(step): \(actual)"); return }
|
||||
}
|
||||
if step == 32 { finish(); return }
|
||||
model.showsQueue = step % 8 == 4
|
||||
if step == 32 {
|
||||
model.toggleFullScreen()
|
||||
step += 1
|
||||
return
|
||||
}
|
||||
let showsQueue = step % 8 == 4
|
||||
if showsQueue != model.showsQueue {
|
||||
if showsQueue, let screen = window.screen?.visibleFrame {
|
||||
window.setFrameOrigin(NSPoint(x: screen.maxX - window.frame.width, y: window.frame.minY))
|
||||
}
|
||||
var target = window.frame
|
||||
target.size.width += showsQueue ? 260 : -260
|
||||
if let screen = window.screen?.visibleFrame {
|
||||
target.origin.x = max(screen.minX, min(target.minX, screen.maxX - target.width))
|
||||
}
|
||||
observeAnimation(from: window, to: target)
|
||||
observingQueue = true
|
||||
queueVideoSize = model.videoView?.bounds.size
|
||||
model.showsQueue = showsQueue
|
||||
}
|
||||
case 1, 5:
|
||||
window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true)
|
||||
normalFrame = window.frame
|
||||
|
||||
Reference in New Issue
Block a user