feat: add expandable compact queue and sidebar shortcut

This commit is contained in:
2026-10-22 18:11:13 +02:00
parent 6ed7db8167
commit 98e6dcb3c5
11 changed files with 330 additions and 39 deletions
+10 -5
View File
@@ -101,22 +101,24 @@ def check_queue_drag(root, app, fixtures):
def check_window_layout(root, app, fixtures):
result_path = root / "window-layout.json"
diagnostics = root / "window-layout.log"
compact_queue_only = os.environ.get("TEREBI_CHECK_COMPACT_QUEUE_ONLY") == "1"
extra = ["--compact-queue-only"] if compact_queue_only else []
result = subprocess.run(
["open", "-g", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-layout", str(result_path)],
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-layout", str(result_path), *extra],
capture_output=True, text=True, timeout=40,
)
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
report = json.loads(result_path.read_text()) if result_path.exists() else {}
expected_animations = 0 if report.get("reduceMotion") else 16
expected_queue_animations = 0 if report.get("reduceMotion") else 9
if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 36
expected_animations = 0 if report.get("reduceMotion") else (4 if compact_queue_only else 19)
expected_queue_animations = 0 if report.get("reduceMotion") else (4 if compact_queue_only else 13)
if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 45
or report.get("animatedTransitions") != expected_animations
or report.get("animatedQueueTransitions") != expected_queue_animations):
raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}")
print(f"PASS native SwiftUI window layout: {report['animatedTransitions']} compact and "
f"{report['animatedQueueTransitions']} queue animations, stable video size, responsive reversals, "
"screen-edge fitting, fullscreen fixed bounds, minimum sizes and exact normal geometry restored")
"downward compact queue with native shortcut and painted content, minimum sizes and exact normal geometry restored")
def check_startup_errors(root, app):
config = root / "invalid-startup.toml"
@@ -781,6 +783,9 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
launch = None
try:
if os.environ.get("TEREBI_CHECK_COMPACT_QUEUE_ONLY") == "1":
check_window_layout(root, app, fixtures)
sys.exit(0)
if os.environ.get("TEREBI_CHECK_QUEUE_ADD_ONLY") == "1":
check_queue_add(root, app, fixtures)
sys.exit(0)
+119 -4
View File
@@ -590,6 +590,7 @@ private enum WindowLayoutCheck {
static var step = 0
static var windowIdentity: ObjectIdentifier?
static var normalFrame = NSRect.zero
static var compactFrame = NSRect.zero
static var animationStart: NSRect?
static var animationTarget = NSRect.zero
static var sawIntermediateFrame = false
@@ -598,6 +599,7 @@ private enum WindowLayoutCheck {
static var queueVideoSize: NSSize?
static var observingQueue = false
static var animationQueueWidth: CGFloat = 0
static var compactQueueOnly: Bool { CommandLine.arguments.contains("--compact-queue-only") }
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))
@@ -677,7 +679,7 @@ private enum WindowLayoutCheck {
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
if model.compactWindow.isTransitioning || model.isQueueTransitioning {
window.layoutIfNeeded()
if model.isQueueTransitioning && !sidebarHeaderIsVisible(in: window) {
if model.isQueueTransitioning && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
finish("Top-right controls left the window corner during queue animation at step \(step)"); return
}
if let size = queueVideoSize, let video = model.videoView {
@@ -693,8 +695,8 @@ private enum WindowLayoutCheck {
// These run-loop-delivered requests must remain responsive mid-animation.
if model.isQueueTransitioning {
let frame = window.frame
model.showsQueue.toggle()
model.showsQueue.toggle()
model.perform(model.queueAction)
model.perform(model.queueAction)
guard sameFrame(window.frame, frame) else { finish("Queue reversal jumped the window"); return }
} else {
model.toggleCompact()
@@ -720,9 +722,20 @@ private enum WindowLayoutCheck {
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
if model.fullScreenIntent != nil { return }
window.layoutIfNeeded()
if compactQueueOnly && step == 0 {
if !model.showsQueue {
guard toggleQueue(model, in: window) else { finish("Normal queue shortcut was not handled"); return }
return
}
step = 36
}
if model.showsQueue && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
finish("Sidebar pin, Cast or settings glyph is not visible at step \(step)"); return
}
if step >= 36 {
advanceCompactQueue(model, window: window)
return
}
if step >= 33 {
switch step {
case 33, 34:
@@ -771,7 +784,9 @@ private enum WindowLayoutCheck {
observeAnimation(from: window, to: target)
observingQueue = true
queueVideoSize = model.videoView?.bounds.size
model.showsQueue = showsQueue
guard toggleQueue(model, in: window), model.showsQueue == showsQueue else {
finish("Native queue shortcut failed in normal mode"); return
}
}
case 1, 5:
window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true)
@@ -800,6 +815,106 @@ private enum WindowLayoutCheck {
}
step += 1
}
static func toggleQueue(_ model: PlayerModel, in window: NSWindow, repeatKey: Bool = false) -> Bool {
let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [.command, .shift],
timestamp: 0, windowNumber: window.windowNumber, context: nil,
characters: "L", charactersIgnoringModifiers: "l", isARepeat: repeatKey, keyCode: 37)!
return model.handleKeyEvent(event, in: window)
}
static func observeCompactQueue(_ model: PlayerModel, window: NSWindow) {
let target = model.compactWindow.queueLayout(visible: !model.queueVisible)!
observeAnimation(from: window, to: target.frame)
observingQueue = true
queueVideoSize = model.videoView?.bounds.size
if !toggleQueue(model, in: window) { finish("Native queue shortcut was not handled") }
}
static func compactQueueHasPaintedContent(_ model: PlayerModel, window: NSWindow) -> Bool {
guard let content = window.contentView else { return false }
let height = model.compactWindow.queueHeight
let region = NSRect(x: 0, y: content.isFlipped ? content.bounds.maxY - height : 0,
width: content.bounds.width, height: height)
guard let bitmap = content.bitmapImageRepForCachingDisplay(in: region) else { return false }
content.cacheDisplay(in: region, to: bitmap)
var textPixels = 0
for x in 0..<bitmap.pixelsWide {
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 {
textPixels += 1
}
}
}
return textPixels > 30
}
static func advanceCompactQueue(_ model: PlayerModel, window: NSWindow) {
switch step {
case 36, 42:
guard !model.isFullScreen, !model.isCompact else { finish("Expanded compact/fullscreen restoration failed"); return }
if step == 42, !sameFrame(window.frame, normalFrame) {
finish("Expanded queue corrupted the normal restoration frame"); return
}
normalFrame = window.frame
observeAnimation(from: window, to: CompactWindowController.initialFrame(
aspect: model.state.video_aspect_ratio,
visibleFrame: (window.screen ?? NSScreen.main!).visibleFrame, width: 320))
model.toggleCompact()
case 37, 43:
guard model.isCompact, !model.queueVisible, model.compactWindow.queueHeight == 0 else {
finish("Compact entry did not default to a hidden queue"); return
}
compactFrame = window.frame
observeCompactQueue(model, window: window)
case 38, 40, 44:
let height = model.compactWindow.queueHeight
guard model.queueVisible, height > 0, model.showsQueue,
abs(window.frame.maxY - compactFrame.maxY) < 1,
abs(window.frame.width / (window.frame.height - height) - model.state.video_aspect_ratio) < 0.01,
let video = model.videoView, abs(video.bounds.height - compactFrame.height) < 2 else {
finish("Compact queue did not expand below the unchanged video at step \(step)"); return
}
guard compactQueueHasPaintedContent(model, window: window) else {
finish("Expanded compact queue did not paint its native rows and footer"); return
}
guard toggleQueue(model, in: window, repeatKey: true), model.queueVisible else {
finish("Held queue shortcut toggled repeatedly"); return
}
if step == 38 {
observeCompactQueue(model, window: window)
} else if step == 40 {
if compactQueueOnly {
observeAnimation(from: window, to: normalFrame)
model.toggleCompact()
step = 41
} else {
// The explicit fullscreen action must discard dropdown geometry before entering the native Space.
model.toggleFullScreen()
}
} else {
observeAnimation(from: window, to: normalFrame)
model.toggleCompact()
}
case 39:
guard !model.queueVisible, sameFrame(window.frame, compactFrame) else {
finish("Compact queue did not collapse to its original video frame"); return
}
observeCompactQueue(model, window: window)
case 41:
guard model.isFullScreen, !model.isCompact, model.compactWindow.queueHeight == 0 else {
finish("Fullscreen retained compact dropdown geometry"); return
}
model.toggleFullScreen()
default:
guard !model.isCompact, model.queueVisible, sameFrame(window.frame, normalFrame) else {
finish("Normal queue visibility or restoration frame was lost"); return
}
finish(); return
}
step += 1
}
}
@MainActor
+63 -2
View File
@@ -96,11 +96,11 @@ struct CompactWindowChecks {
// Run the real AppKit completion callbacks. The monotonic deadline is only a hang watchdog.
static func settled(_ controller: CompactWindowController, compact: Bool,
checking: () throws -> Void = {}) throws {
line: UInt = #line, checking: () throws -> Void = {}) throws {
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while controller.isTransitioning {
try checking()
try require(ContinuousClock.now < deadline, "native compact transition completes")
try require(ContinuousClock.now < deadline, "native compact transition completes (call at line \(line), compact \(controller.isCompact), target \(compact))")
CFRunLoopRunInMode(.defaultMode, 0.01, false)
}
try checking()
@@ -504,12 +504,73 @@ struct CompactWindowChecks {
}
}
static func compactQueue() throws {
let fixture = try WindowFixture()
let controller = CompactWindowController(reduceMotion: { true })
let window = fixture.window
defer {
controller.restore(resumeFullScreen: false, animated: false)
window.close()
}
controller.attach(window)
controller.toggle(aspect: 16.0 / 9.0)
try require(controller.queueHeight == 0, "compact entry hides the queue")
let videoFrame = window.frame
let expanded = controller.queueLayout(visible: true)!
controller.updateQueue(height: expanded.height, frame: expanded.frame)
try require(close(window.frame.maxY, videoFrame.maxY) && close(window.frame.width, videoFrame.width)
&& close(window.frame.height - controller.queueHeight, videoFrame.height),
"queue expands downwards without moving or resizing the compact video")
try require(window.aspectRatio == .zero && close(window.minSize.height, 90 + controller.queueHeight),
"expanded queue constrains video, not the combined window aspect")
for horizontal in [true, false] {
let start = window.frame
window.delegate?.windowWillStartLiveResize?(Notification(name: NSWindow.willStartLiveResizeNotification, object: window))
for offset in [CGFloat(20), 21, 22, 21, 0, -10] {
let proposed = NSSize(width: start.width + (horizontal ? offset : 0),
height: start.height + (horizontal ? 0 : offset))
let size = window.delegate!.windowWillResize!(window, to: proposed)
try require(close(size.width / (size.height - controller.queueHeight), 16.0 / 9.0),
"both resize axes preserve the compact video's aspect with queue open")
try require(close(horizontal ? size.width : size.height, horizontal ? proposed.width : proposed.height),
"queue resize follows the chosen edge continuously")
window.setFrame(NSRect(origin: start.origin, size: size), display: false)
}
window.delegate?.windowDidEndLiveResize?(Notification(name: NSWindow.didEndLiveResizeNotification, object: window))
}
controller.updateAspect(4.0 / 3.0)
try require(abs(window.frame.height - controller.queueHeight - window.frame.width / (4.0 / 3.0)) <= 1,
"metadata changes aspect only in the video above the queue: \(window.frame), queue \(controller.queueHeight)")
let hidden = controller.queueLayout(visible: false)!
let top = window.frame.maxY
controller.updateQueue(height: hidden.height, frame: hidden.frame)
try require(controller.queueHeight == 0 && close(window.frame.maxY, top)
&& abs(window.frame.height - window.frame.width / (4.0 / 3.0)) <= 1,
"hiding the queue contracts upwards and restores native aspect locking")
var atBottom = window.frame
atBottom.origin.y = (window.screen ?? NSScreen.main!).visibleFrame.minY + 16
window.setFrame(atBottom, display: false)
let bounded = controller.queueLayout(visible: true)!
controller.updateQueue(height: bounded.height, frame: bounded.frame)
try require(window.frame.minY >= (window.screen ?? NSScreen.main!).visibleFrame.minY + 16
&& abs(window.frame.height - controller.queueHeight - window.frame.width / (4.0 / 3.0)) <= 1,
"queue remains onscreen at the lower edge without distorting the video")
controller.restore(resumeFullScreen: false)
try fixture.restored()
controller.toggle(aspect: 4.0 / 3.0)
try require(controller.queueHeight == 0, "each compact entry resets queue visibility")
try fixture.compact(aspect: 4.0 / 3.0)
controller.restore(resumeFullScreen: false)
try fixture.restored()
}
static func main() throws {
try geometry()
try windowBehavior()
try reducedMotion()
try rememberedSize()
try continuousQueueResize()
try compactQueue()
print("Compact window checks passed")
}
}
+6
View File
@@ -43,10 +43,16 @@ struct ConfigurationChecks {
try require(PlayerAction(name: "open_youtube") == nil, "obsolete YouTube action has no alias")
try require(bindings.shortcuts(for: .openStream) == [try KeyChord("cmd+shift+o")],
"stream action preserves its shortcut")
try require(PlayerAction(name: "show_queue") == .showQueue && PlayerAction.hideQueue.name == "hide_queue",
"queue visibility actions round-trip through configuration")
try require(Set(bindings.actions(for: try KeyChord("cmd+shift+l"))) == [.showQueue, .hideQueue]
&& !PlayerAction.showQueue.allowsRepeat && !PlayerAction.hideQueue.allowsRepeat,
"queue shortcut toggles without auto-repeat")
try requireError(["open_youtube"]) { _ = try KeyBindings(overrides: ["open_youtube": []]) }
let inversePairs: [(PlayerAction, PlayerAction)] = [
(.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen),
(.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop),
(.showQueue, .hideQueue),
(.showBufferInfo, .hideBufferInfo), (.enableCompactSnapping, .disableCompactSnapping),
(.enableAutomaticQuality, .disableAutomaticQuality)
]