feat: animate queue sidebar expansion and collapse

This commit is contained in:
2026-10-18 21:48:06 +02:00
parent e4504ea5c4
commit 22f13dd08d
6 changed files with 183 additions and 31 deletions
+80 -9
View File
@@ -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