fix: stabilize native window layout transitions

This commit is contained in:
2026-10-18 18:16:16 +02:00
parent 9670645b9a
commit 29a70678a4
4 changed files with 104 additions and 12 deletions
+19 -1
View File
@@ -36,6 +36,22 @@ class StartupStreamingServer(StreamingServer):
super()._get(request) super()._get(request)
def check_window_layout(root, app, fixtures):
result_path = root / "window-layout.json"
diagnostics = root / "window-layout.log"
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)],
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 {}
if result.returncode != 0 or not report.get("passed") or report.get("steps") != 32:
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, "
"rapid intent changes, original window and exact normal geometry restored")
def check_cli_instances(root, app, fixtures): def check_cli_instances(root, app, fixtures):
cli = root / "terebi" cli = root / "terebi"
cli.symlink_to(app / "Contents/MacOS/terebi-cli") cli.symlink_to(app / "Contents/MacOS/terebi-cli")
@@ -635,7 +651,9 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier: if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}") raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing") print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
check_cli_instances(root, app, fixtures) check_window_layout(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1":
check_cli_instances(root, app, fixtures)
finally: finally:
os.close(descriptor) os.close(descriptor)
subprocess.run( subprocess.run(
+78
View File
@@ -5,6 +5,10 @@ import MediaPlayer
@_cdecl("terebi_app_main") @_cdecl("terebi_app_main")
@MainActor @MainActor
public func checkAppStartup() { public func checkAppStartup() {
if let index = CommandLine.arguments.firstIndex(of: "--check-window-layout") {
WindowLayoutCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
}
if let configuration = Bundle.main.url(forResource: "cli-check", withExtension: "json") { if let configuration = Bundle.main.url(forResource: "cli-check", withExtension: "json") {
CLIInstanceCheck.run(configuration) CLIInstanceCheck.run(configuration)
return return
@@ -12,6 +16,80 @@ public func checkAppStartup() {
AppStartupCheck.run() AppStartupCheck.run()
} }
@MainActor
private enum WindowLayoutCheck {
static var timer: Timer?
static var resultURL: URL!
static var step = 0
static var windowIdentity: ObjectIdentifier?
static var normalFrame = NSRect.zero
// Infrastructure timeout only; assertions depend on native transition completion.
static let deadline = ContinuousClock.now.advanced(by: .seconds(30))
static func run(result: URL) {
resultURL = result
_ = NSApplication.shared
_ = NotificationCenter.default.addObserver(forName: NSApplication.willFinishLaunchingNotification,
object: nil, queue: .main) { _ in
MainActor.assumeIsolated { PlayerModel.shared.setVolume(0) }
}
_ = NotificationCenter.default.addObserver(forName: NSWindow.didUpdateNotification,
object: nil, queue: .main) { _ in
MainActor.assumeIsolated {
for window in NSApp.windows { window.alphaValue = 0; window.ignoresMouseEvents = true }
}
}
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { advance() }
}
terebiAppMain()
}
static func finish(_ error: String? = nil) {
timer?.invalidate()
let result: [String: Any] = ["passed": error == nil, "error": error ?? "", "steps": step]
try! JSONSerialization.data(withJSONObject: result).write(to: resultURL, options: .atomic)
PlayerModel.shared.shutdown()
NSApp.terminate(nil)
}
static func advance() {
let model = PlayerModel.shared
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,
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) }
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
window.layoutIfNeeded()
switch step % 8 {
case 0, 4:
if step > 0 {
let actual = window.frame
guard abs(actual.minX - normalFrame.minX) < 1, abs(actual.minY - normalFrame.minY) < 1,
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
case 1, 5:
window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true)
normalFrame = window.frame
case 2, 6:
// Repeated intent changes must keep the original restoration frame.
model.toggleCompact()
model.toggleCompact()
model.toggleCompact()
default:
guard model.isCompact, window.frame.width >= 160, window.frame.height >= 90,
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
}
model.toggleCompact()
}
step += 1
}
}
@MainActor @MainActor
private enum AppStartupCheck { private enum AppStartupCheck {
static var model: PlayerModel? static var model: PlayerModel?
+2
View File
@@ -49,6 +49,8 @@ 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.
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.
Drag the video or unused playback background to move the window, including with the queue open. A single video click hides the controls; double-click toggles fullscreen. Playback controls stay anchored at the bottom center of the video and cannot be dragged. Native window edges remain available for resizing; the open queue allows free resizing with letterboxed video. Drag the video or unused playback background to move the window, including with the queue open. A single video click hides the controls; double-click toggles fullscreen. Playback controls stay anchored at the bottom center of the video and cannot be dragged. Native window edges remain available for resizing; the open queue allows free resizing with letterboxed video.
+5 -11
View File
@@ -1,5 +1,4 @@
import AppKit import AppKit
import QuartzCore
@MainActor @MainActor
final class CompactWindowController: NSObject { final class CompactWindowController: NSObject {
@@ -379,10 +378,10 @@ final class CompactWindowController: NSObject {
frameAnimationGeneration &+= 1 frameAnimationGeneration &+= 1
let generation = frameAnimationGeneration let generation = frameAnimationGeneration
frameAnimating = true frameAnimating = true
NSAnimationContext.runAnimationGroup { context in // Coordinate the resize through NSWindow. Its animator proxy races SwiftUI's
context.duration = Self.transitionDuration // animated window sizing and can re-enter layout until AppKit aborts.
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) NSAnimationContext.runAnimationGroup { _ in
window.animator().setFrame(frame, display: true) window.setFrame(frame, display: true, animate: 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,
@@ -397,12 +396,7 @@ final class CompactWindowController: NSObject {
guard frameAnimating else { return } guard frameAnimating else { return }
frameAnimating = false frameAnimating = false
frameAnimationGeneration &+= 1 frameAnimationGeneration &+= 1
guard let window else { return } // Native resize animation has returned; invalidate only its pending completion.
// A zero-duration animator update cancels the native in-flight frame animation.
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) {