fix: stabilize native window layout transitions
This commit is contained in:
@@ -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,6 +651,8 @@ 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_window_layout(root, app, fixtures)
|
||||||
|
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1":
|
||||||
check_cli_instances(root, app, fixtures)
|
check_cli_instances(root, app, fixtures)
|
||||||
finally:
|
finally:
|
||||||
os.close(descriptor)
|
os.close(descriptor)
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user