feat: animate compact window transitions
This commit is contained in:
+276
-65
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import CoreFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -93,21 +94,85 @@ struct CompactWindowChecks {
|
|||||||
"target width shrinks to usable bounds without distorting ordinary aspect")
|
"target width shrinks to usable bounds without distorting ordinary aspect")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func windowBehavior() throws {
|
// Run the real AppKit completion callbacks. The monotonic deadline is only a hang watchdog.
|
||||||
_ = NSApplication.shared
|
static func settled(_ controller: CompactWindowController, compact: Bool,
|
||||||
guard let screen = NSScreen.main else {
|
checking: () throws -> Void = {}) throws {
|
||||||
throw Failure(description: "FAIL: native window checks require a macOS GUI session with a screen")
|
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||||
|
while controller.isTransitioning {
|
||||||
|
try checking()
|
||||||
|
try require(ContinuousClock.now < deadline, "native compact transition completes")
|
||||||
|
CFRunLoopRunInMode(.defaultMode, 0.01, false)
|
||||||
}
|
}
|
||||||
// Creating a window does not display it: never orderFront, makeKey, or enter native fullscreen.
|
try checking()
|
||||||
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
try require(controller.isCompact == compact, "settled mode matches the latest destination")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct WindowState {
|
||||||
|
let frame: NSRect
|
||||||
|
let minSize: NSSize
|
||||||
|
let contentMinSize: NSSize
|
||||||
|
let aspect: NSSize
|
||||||
|
let style: NSWindow.StyleMask
|
||||||
|
let titleVisibility: NSWindow.TitleVisibility
|
||||||
|
let transparentTitlebar: Bool
|
||||||
|
let collection: NSWindow.CollectionBehavior
|
||||||
|
let level: NSWindow.Level
|
||||||
|
let hides: Bool
|
||||||
|
let drag: Bool
|
||||||
|
|
||||||
|
init(_ window: NSWindow) {
|
||||||
|
frame = window.frame
|
||||||
|
minSize = window.minSize
|
||||||
|
contentMinSize = window.contentMinSize
|
||||||
|
aspect = window.aspectRatio
|
||||||
|
style = window.styleMask
|
||||||
|
titleVisibility = window.titleVisibility
|
||||||
|
transparentTitlebar = window.titlebarAppearsTransparent
|
||||||
|
collection = window.collectionBehavior
|
||||||
|
level = window.level
|
||||||
|
hides = window.hidesOnDeactivate
|
||||||
|
drag = window.isMovableByWindowBackground
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireMatches(_ window: NSWindow) throws {
|
||||||
|
try CompactWindowChecks.require(CompactWindowChecks.sameFrame(window.frame, frame),
|
||||||
|
"restore recovers original normal geometry")
|
||||||
|
try CompactWindowChecks.require(window.minSize == minSize && window.contentMinSize == contentMinSize
|
||||||
|
&& window.aspectRatio == aspect, "restore recovers aspect and both minima")
|
||||||
|
try CompactWindowChecks.require(window.styleMask == style && window.collectionBehavior == collection
|
||||||
|
&& window.level == level, "restore recovers style, Spaces policy, and level")
|
||||||
|
try CompactWindowChecks.require(window.titleVisibility == titleVisibility
|
||||||
|
&& window.titlebarAppearsTransparent == transparentTitlebar,
|
||||||
|
"restore recovers original native chrome")
|
||||||
|
try CompactWindowChecks.require(window.hidesOnDeactivate == hides
|
||||||
|
&& window.isMovableByWindowBackground == drag,
|
||||||
|
"restore recovers deactivate and background drag behavior")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WindowFixture {
|
||||||
|
let window: NSWindow
|
||||||
|
let content: NSView
|
||||||
|
let sentinel: NSView
|
||||||
|
let sentinelFrame: NSRect
|
||||||
|
let original: WindowState
|
||||||
|
let identity: ObjectIdentifier
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
// Never orderFront, makeKey, or create an actual native fullscreen Space.
|
||||||
|
window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||||
backing: .buffered, defer: false)
|
backing: .buffered, defer: false)
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
defer { window.close() }
|
guard let content = window.contentView else {
|
||||||
guard let content = window.contentView else { throw Failure(description: "FAIL: missing content view") }
|
throw Failure(description: "FAIL: missing content view")
|
||||||
let sentinel = NSView(frame: NSRect(x: 5, y: 7, width: 12, height: 14))
|
}
|
||||||
|
self.content = content
|
||||||
|
sentinel = NSView(frame: NSRect(x: 5, y: 7, width: 12, height: 14))
|
||||||
content.addSubview(sentinel)
|
content.addSubview(sentinel)
|
||||||
let sentinelFrame = sentinel.frame
|
sentinelFrame = sentinel.frame
|
||||||
window.minSize = NSSize(width: 300, height: 180)
|
window.minSize = NSSize(width: 300, height: 180)
|
||||||
window.contentMinSize = NSSize(width: 220, height: 120)
|
window.contentMinSize = NSSize(width: 220, height: 120)
|
||||||
window.aspectRatio = NSSize(width: 4, height: 3)
|
window.aspectRatio = NSSize(width: 4, height: 3)
|
||||||
@@ -115,65 +180,96 @@ struct CompactWindowChecks {
|
|||||||
window.level = .normal
|
window.level = .normal
|
||||||
window.hidesOnDeactivate = true
|
window.hidesOnDeactivate = true
|
||||||
window.isMovableByWindowBackground = false
|
window.isMovableByWindowBackground = false
|
||||||
let originalFrame = window.frame
|
original = WindowState(window)
|
||||||
let originalMinSize = window.minSize
|
identity = ObjectIdentifier(window)
|
||||||
let originalContentMinSize = window.contentMinSize
|
}
|
||||||
let originalAspect = window.aspectRatio
|
|
||||||
let originalStyle = window.styleMask
|
|
||||||
let originalTitleVisibility = window.titleVisibility
|
|
||||||
let originalTransparentTitlebar = window.titlebarAppearsTransparent
|
|
||||||
let originalCollection = window.collectionBehavior
|
|
||||||
let originalLevel = window.level
|
|
||||||
let originalHides = window.hidesOnDeactivate
|
|
||||||
let originalDrag = window.isMovableByWindowBackground
|
|
||||||
let identity = ObjectIdentifier(window)
|
|
||||||
let controller = CompactWindowController()
|
|
||||||
defer { controller.restore(resumeFullScreen: false) }
|
|
||||||
controller.attach(window)
|
|
||||||
try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary")
|
|
||||||
try require(controller.snappingEnabled, "fresh isolated preferences default snapping on")
|
|
||||||
|
|
||||||
func preservedContent() throws {
|
func preservedContent() throws {
|
||||||
try require(ObjectIdentifier(window) == identity && sentinel.window === window,
|
try CompactWindowChecks.require(ObjectIdentifier(window) == identity && sentinel.window === window,
|
||||||
"compact mode keeps the actual rendering window")
|
"compact mode keeps the actual rendering window")
|
||||||
try require(window.contentView === content && sentinel.superview === content
|
try CompactWindowChecks.require(window.contentView === content && sentinel.superview === content
|
||||||
&& content.subviews.count == 1 && sameFrame(sentinel.frame, sentinelFrame),
|
&& content.subviews.count == 1
|
||||||
|
&& CompactWindowChecks.sameFrame(sentinel.frame, sentinelFrame),
|
||||||
"compact mode preserves content and attached sentinel without reparenting")
|
"compact mode preserves content and attached sentinel without reparenting")
|
||||||
try require(!window.isVisible, "checks never display a window")
|
try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow,
|
||||||
|
"checks never display or key a window")
|
||||||
}
|
}
|
||||||
|
|
||||||
func restored() throws {
|
func restored() throws {
|
||||||
try require(!controller.isCompact && !controller.isTransitioning, "restore finishes in ordinary mode")
|
try original.requireMatches(window)
|
||||||
try require(sameFrame(window.frame, originalFrame), "restore recovers original normal geometry")
|
|
||||||
try require(window.minSize == originalMinSize && window.contentMinSize == originalContentMinSize
|
|
||||||
&& window.aspectRatio == originalAspect, "restore recovers aspect and both minima")
|
|
||||||
try require(window.styleMask == originalStyle && window.collectionBehavior == originalCollection
|
|
||||||
&& window.level == originalLevel, "restore recovers style, Spaces policy, and level")
|
|
||||||
try require(window.titleVisibility == originalTitleVisibility
|
|
||||||
&& window.titlebarAppearsTransparent == originalTransparentTitlebar,
|
|
||||||
"restore recovers original native chrome")
|
|
||||||
try require(window.hidesOnDeactivate == originalHides
|
|
||||||
&& window.isMovableByWindowBackground == originalDrag,
|
|
||||||
"restore recovers deactivate and background drag behavior")
|
|
||||||
try preservedContent()
|
try preservedContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compact(aspect: Double) throws {
|
||||||
|
try CompactWindowChecks.require(window.level == .floating && !window.hidesOnDeactivate,
|
||||||
|
"compact floats and remains visible on deactivate")
|
||||||
|
try CompactWindowChecks.require(window.collectionBehavior.contains(
|
||||||
|
[.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
|
||||||
|
"compact can join Spaces, other-app fullscreen, and Stage Manager")
|
||||||
|
try CompactWindowChecks.require(window.collectionBehavior.intersection(
|
||||||
|
[.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
|
||||||
|
"compact removes incompatible primary and auxiliary policies")
|
||||||
|
try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90),
|
||||||
|
"compact uses 160x90 content minimum")
|
||||||
|
try CompactWindowChecks.require(window.isMovableByWindowBackground,
|
||||||
|
"compact allows background dragging")
|
||||||
|
try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls")
|
||||||
|
try CompactWindowChecks.require(window.titleVisibility == .hidden
|
||||||
|
&& window.titlebarAppearsTransparent,
|
||||||
|
"compact hides native title chrome")
|
||||||
|
let visible = (window.screen ?? NSScreen.main!).visibleFrame
|
||||||
|
try CompactWindowChecks.require(CompactWindowChecks.close(window.frame.maxX, visible.maxX - 16)
|
||||||
|
&& CompactWindowChecks.close(window.frame.maxY, visible.maxY - 16)
|
||||||
|
&& CompactWindowChecks.close(window.frame.width / window.frame.height,
|
||||||
|
CGFloat(aspect)),
|
||||||
|
"compact enters at inset top right with playback aspect")
|
||||||
|
try preservedContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later real native round-trip gives canceled animations a completion opportunity without
|
||||||
|
// guessing elapsed animation time. Watch the interrupted window throughout and after it.
|
||||||
|
static func nativeCompletionOpportunity(checking: () throws -> Void) throws {
|
||||||
|
let fixture = try WindowFixture()
|
||||||
|
let controller = CompactWindowController(reduceMotion: { false })
|
||||||
|
defer {
|
||||||
|
controller.restore(resumeFullScreen: false, animated: false)
|
||||||
|
fixture.window.close()
|
||||||
|
}
|
||||||
|
controller.attach(fixture.window)
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
try require(controller.isTransitioning, "native completion barrier is an actual animated entry")
|
||||||
|
try settled(controller, compact: true, checking: checking)
|
||||||
|
try fixture.compact(aspect: 16.0 / 9.0)
|
||||||
|
controller.restore(resumeFullScreen: false)
|
||||||
|
try settled(controller, compact: false, checking: checking)
|
||||||
|
try fixture.restored()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func windowBehavior() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
guard NSScreen.main != nil else {
|
||||||
|
throw Failure(description: "FAIL: native window checks require a macOS GUI session with a screen")
|
||||||
|
}
|
||||||
|
let fixture = try WindowFixture()
|
||||||
|
let window = fixture.window
|
||||||
|
let controller = CompactWindowController(reduceMotion: { false })
|
||||||
|
defer {
|
||||||
|
controller.restore(resumeFullScreen: false, animated: false)
|
||||||
|
window.close()
|
||||||
|
}
|
||||||
|
controller.attach(window)
|
||||||
|
try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary")
|
||||||
|
try require(controller.snappingEnabled, "isolated controller defaults snapping on")
|
||||||
|
|
||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
try require(controller.isCompact && !controller.isTransitioning, "windowed toggle enters compact synchronously")
|
try require(controller.isCompact && controller.isTransitioning,
|
||||||
try require(window.level == .floating && !window.hidesOnDeactivate, "compact floats and remains visible on deactivate")
|
"entry presents the compact destination while native geometry settles")
|
||||||
try require(window.collectionBehavior.contains([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
|
try fixture.preservedContent()
|
||||||
"compact can join Spaces, other-app fullscreen, and Stage Manager")
|
try settled(controller, compact: true)
|
||||||
try require(window.collectionBehavior.intersection([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
|
try fixture.compact(aspect: 16.0 / 9.0)
|
||||||
"compact removes incompatible primary and auxiliary policies")
|
|
||||||
try require(window.contentMinSize == NSSize(width: 160, height: 90), "compact uses 160x90 content minimum")
|
|
||||||
try require(window.isMovableByWindowBackground, "compact allows background dragging")
|
|
||||||
try require(window.canBecomeKey, "compact window still accepts keyboard playback controls")
|
|
||||||
try preservedContent()
|
|
||||||
|
|
||||||
let visible = (window.screen ?? screen).visibleFrame
|
let visible = (window.screen ?? NSScreen.main!).visibleFrame
|
||||||
try require(close(window.frame.maxX, visible.maxX - 16)
|
|
||||||
&& close(window.frame.maxY, visible.maxY - 16)
|
|
||||||
&& close(window.frame.width / window.frame.height, 16.0 / 9.0),
|
|
||||||
"actual compact window enters at inset top right with playback aspect")
|
|
||||||
let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27,
|
let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27,
|
||||||
width: window.frame.width, height: window.frame.height)
|
width: window.frame.width, height: window.frame.height)
|
||||||
window.setFrame(nearEdge, display: false)
|
window.setFrame(nearEdge, display: false)
|
||||||
@@ -182,13 +278,13 @@ struct CompactWindowChecks {
|
|||||||
try require(close(window.frame.minX, visible.minX + 16) && close(window.frame.minY, visible.minY + 16)
|
try require(close(window.frame.minX, visible.minX + 16) && close(window.frame.minY, visible.minY + 16)
|
||||||
&& window.frame.size == nearEdge.size, "enabled preference snaps the actual moved window")
|
&& window.frame.size == nearEdge.size, "enabled preference snaps the actual moved window")
|
||||||
controller.snappingEnabled = false
|
controller.snappingEnabled = false
|
||||||
let second = CompactWindowController(snappingEnabled: false)
|
let second = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
|
||||||
try require(!second.snappingEnabled, "configuration can disable snapping for a new controller")
|
try require(!second.snappingEnabled, "configuration can disable snapping for a new controller")
|
||||||
window.setFrame(nearEdge, display: false)
|
window.setFrame(nearEdge, display: false)
|
||||||
controller.snapAfterDrag()
|
controller.snapAfterDrag()
|
||||||
try require(sameFrame(window.frame, nearEdge), "disabled preference leaves an actual moved window untouched")
|
try require(sameFrame(window.frame, nearEdge), "disabled preference leaves an actual moved window untouched")
|
||||||
second.snappingEnabled = true
|
second.snappingEnabled = true
|
||||||
let third = CompactWindowController(snappingEnabled: false)
|
let third = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
|
||||||
try require(!third.snappingEnabled, "a session override does not change the next controller's settings")
|
try require(!third.snappingEnabled, "a session override does not change the next controller's settings")
|
||||||
controller.snappingEnabled = true
|
controller.snappingEnabled = true
|
||||||
let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135)
|
let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135)
|
||||||
@@ -200,26 +296,141 @@ struct CompactWindowChecks {
|
|||||||
try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode")
|
try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode")
|
||||||
try require(close(window.frame.width / window.frame.height, 4.0 / 3.0),
|
try require(close(window.frame.width / window.frame.height, 4.0 / 3.0),
|
||||||
"actual compact window follows changed playback aspect")
|
"actual compact window follows changed playback aspect")
|
||||||
try preservedContent()
|
try fixture.preservedContent()
|
||||||
controller.toggle(aspect: 4.0 / 3.0)
|
controller.toggle(aspect: 4.0 / 3.0)
|
||||||
try restored()
|
try settled(controller, compact: false)
|
||||||
|
try fixture.restored()
|
||||||
|
|
||||||
|
// Enter -> exit before entry completes: the original normal snapshot must survive.
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
try require(controller.isTransitioning, "entry is in flight before its destination changes")
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
try settled(controller, compact: false)
|
||||||
|
try fixture.restored()
|
||||||
|
|
||||||
|
// Exit -> enter keeps the newest aspect and must not save an intermediate restore frame.
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
try settled(controller, compact: true)
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
try require(!controller.isCompact && controller.isTransitioning,
|
||||||
|
"exit presents the ordinary destination while native geometry settles")
|
||||||
|
controller.toggle(aspect: 4.0 / 3.0)
|
||||||
|
try settled(controller, compact: true)
|
||||||
|
try fixture.compact(aspect: 4.0 / 3.0)
|
||||||
|
controller.restore(resumeFullScreen: false)
|
||||||
|
try settled(controller, compact: false)
|
||||||
|
try fixture.restored()
|
||||||
|
|
||||||
|
// Alternating requests within one native animation settle only at the latest destination.
|
||||||
|
for compactDestination in [false, true] {
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
try require(controller.isTransitioning, "alternating requests interrupt native entry")
|
||||||
|
for _ in 0..<(compactDestination ? 6 : 5) {
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
}
|
||||||
|
try settled(controller, compact: compactDestination)
|
||||||
|
if compactDestination {
|
||||||
|
try fixture.compact(aspect: 16.0 / 9.0)
|
||||||
|
controller.restore(resumeFullScreen: false)
|
||||||
|
try settled(controller, compact: false)
|
||||||
|
}
|
||||||
|
try fixture.restored()
|
||||||
|
}
|
||||||
|
|
||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
controller.restore(resumeFullScreen: false)
|
controller.restore(resumeFullScreen: false)
|
||||||
try restored()
|
try settled(controller, compact: false)
|
||||||
|
try fixture.restored()
|
||||||
controller.restore(resumeFullScreen: false)
|
controller.restore(resumeFullScreen: false)
|
||||||
try restored()
|
try settled(controller, compact: false)
|
||||||
|
try fixture.restored()
|
||||||
|
|
||||||
|
// Urgent teardown is immediate; old native completion cannot touch its restored snapshot.
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
try require(controller.isTransitioning, "urgent restore interrupts native entry")
|
||||||
|
controller.restore(resumeFullScreen: false, animated: false)
|
||||||
|
try require(!controller.isCompact && !controller.isTransitioning, "urgent restore settles immediately")
|
||||||
|
try fixture.restored()
|
||||||
|
try nativeCompletionOpportunity {
|
||||||
|
try require(!controller.isCompact && !controller.isTransitioning,
|
||||||
|
"stale completion cannot revive an urgently restored session")
|
||||||
|
try fixture.restored()
|
||||||
|
}
|
||||||
|
|
||||||
|
let replacement = try WindowFixture()
|
||||||
|
defer { replacement.window.close() }
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
try require(controller.isTransitioning, "attachment interrupts native entry")
|
||||||
|
controller.attach(replacement.window)
|
||||||
|
try require(!controller.isCompact && !controller.isTransitioning, "replacement starts ordinary")
|
||||||
|
try fixture.restored()
|
||||||
|
controller.toggle(aspect: 4.0 / 3.0)
|
||||||
|
try settled(controller, compact: true) { try fixture.restored() }
|
||||||
|
try replacement.compact(aspect: 4.0 / 3.0)
|
||||||
|
controller.restore(resumeFullScreen: false)
|
||||||
|
try settled(controller, compact: false) { try fixture.restored() }
|
||||||
|
try replacement.restored()
|
||||||
|
|
||||||
|
controller.attach(window)
|
||||||
// Replay AppKit's failure lifecycle without creating a visible fullscreen Space.
|
// Replay AppKit's failure lifecycle without creating a visible fullscreen Space.
|
||||||
NotificationCenter.default.post(name: NSWindow.willEnterFullScreenNotification, object: window)
|
NotificationCenter.default.post(name: NSWindow.willEnterFullScreenNotification, object: window)
|
||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
try require(controller.isTransitioning && !controller.isCompact,
|
try require(controller.isTransitioning && !controller.isCompact,
|
||||||
"compact entry waits for an in-flight native fullscreen transition")
|
"compact entry waits for an in-flight native fullscreen transition")
|
||||||
window.delegate?.windowDidFailToEnterFullScreen?(window)
|
window.delegate?.windowDidFailToEnterFullScreen?(window)
|
||||||
try restored()
|
try settled(controller, compact: false)
|
||||||
|
try fixture.restored()
|
||||||
|
NotificationCenter.default.post(name: NSWindow.willExitFullScreenNotification, object: window)
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
window.delegate?.windowDidFailToExitFullScreen?(window)
|
||||||
|
try settled(controller, compact: false)
|
||||||
|
try fixture.restored()
|
||||||
|
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
try require(controller.isTransitioning, "close interrupts native entry")
|
||||||
|
window.close()
|
||||||
|
try require(!controller.isCompact && !controller.isTransitioning, "closed window ends its session")
|
||||||
|
let closedState = WindowState(window)
|
||||||
|
controller.attach(replacement.window)
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
func closedWindowUnchanged() throws {
|
||||||
|
try closedState.requireMatches(window)
|
||||||
|
try fixture.preservedContent()
|
||||||
|
}
|
||||||
|
try settled(controller, compact: true, checking: closedWindowUnchanged)
|
||||||
|
try replacement.compact(aspect: 16.0 / 9.0)
|
||||||
|
controller.restore(resumeFullScreen: false)
|
||||||
|
try settled(controller, compact: false, checking: closedWindowUnchanged)
|
||||||
|
try replacement.restored()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func reducedMotion() throws {
|
||||||
|
let fixture = try WindowFixture()
|
||||||
|
let controller = CompactWindowController(reduceMotion: { true })
|
||||||
|
defer {
|
||||||
|
controller.restore(resumeFullScreen: false, animated: false)
|
||||||
|
fixture.window.close()
|
||||||
|
}
|
||||||
|
controller.attach(fixture.window)
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
try require(controller.isCompact && !controller.isTransitioning,
|
||||||
|
"Reduce Motion enters with geometry and controls already settled")
|
||||||
|
try fixture.compact(aspect: 16.0 / 9.0)
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
try require(!controller.isCompact && !controller.isTransitioning,
|
||||||
|
"Reduce Motion restores without a pending frame animation")
|
||||||
|
try fixture.restored()
|
||||||
|
try nativeCompletionOpportunity {
|
||||||
|
try require(!controller.isCompact && !controller.isTransitioning,
|
||||||
|
"Reduce Motion leaves no completion that can revive compact state")
|
||||||
|
try fixture.restored()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func main() throws {
|
static func main() throws {
|
||||||
try geometry()
|
try geometry()
|
||||||
try windowBehavior()
|
try windowBehavior()
|
||||||
|
try reducedMotion()
|
||||||
print("Compact window checks passed")
|
print("Compact window checks passed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ Opening video fits the window to its display aspect ratio, including non-square
|
|||||||
|
|
||||||
**Compact mode** uses the same window, Metal view, and playback session—not a second window or macOS system PiP. Use the PiP-shaped button in the normal playback controls, **⌃⌘P**, or **Playback Actions → Enter Compact Mode**. The compact-mode button replaces the fullscreen button; fullscreen remains available through its menu, keyboard shortcuts, and double-clicking normal-mode video. Compact mode starts at the current screen's top-right corner with a 16-point inset and a preferred width of 320 points, preserving display aspect where usable screen bounds and the 160 × 90-point minimum allow; extreme ratios retain letterboxing. Compact mode floats above ordinary windows, joins all Spaces, and is configured to join eligible other-app fullscreen spaces and Stage Manager sets. Small hover controls provide play/pause, restore, and Playback Actions; keyboard playback controls remain available.
|
**Compact mode** uses the same window, Metal view, and playback session—not a second window or macOS system PiP. Use the PiP-shaped button in the normal playback controls, **⌃⌘P**, or **Playback Actions → Enter Compact Mode**. The compact-mode button replaces the fullscreen button; fullscreen remains available through its menu, keyboard shortcuts, and double-clicking normal-mode video. Compact mode starts at the current screen's top-right corner with a 16-point inset and a preferred width of 320 points, preserving display aspect where usable screen bounds and the 160 × 90-point minimum allow; extreme ratios retain letterboxing. Compact mode floats above ordinary windows, joins all Spaces, and is configured to join eligible other-app fullscreen spaces and Stage Manager sets. Small hover controls provide play/pause, restore, and Playback Actions; keyboard playback controls remain available.
|
||||||
|
|
||||||
|
Entry and restoration animate the same window's position and size over **250 ms** with ease-in/ease-out timing while normal and compact controls cross-fade; playback and the Metal view stay in place. macOS **Reduce Motion** disables both animations. Rapid toggles keep the latest destination and finish the current native frame animation before changing direction; native fullscreen transitions are also serialized. Media close, errors, shutdown, and explicit fullscreen handoff restore immediately rather than leave a frame animation pending.
|
||||||
|
|
||||||
Drag the compact video to move the window; on release, each axis snaps independently when within 24 points of an inset usable-screen edge. **Snap to Screen Edges** is enabled by default, configurable with `[ui].compact_snap_to_edges`, and its toggle is session-only. Disabling snapping permits free placement. Exiting compact mode restores the previous normal geometry and window behavior, including keep-on-top; entry from fullscreen waits for the native exit animation, and restoring that session returns to fullscreen. Double-clicking compact video also restores the previous mode. Video replacement updates the compact aspect; audio-only replacement, playback errors, and closing the file leave compact mode. Closing the window still quits the app.
|
Drag the compact video to move the window; on release, each axis snaps independently when within 24 points of an inset usable-screen edge. **Snap to Screen Edges** is enabled by default, configurable with `[ui].compact_snap_to_edges`, and its toggle is session-only. Disabling snapping permits free placement. Exiting compact mode restores the previous normal geometry and window behavior, including keep-on-top; entry from fullscreen waits for the native exit animation, and restoring that session returns to fullscreen. Double-clicking compact video also restores the previous mode. Video replacement updates the compact aspect; audio-only replacement, playback errors, and closing the file leave compact mode. Closing the window still quits the app.
|
||||||
|
|
||||||
Default shortcuts and their explicit action names are listed in **Configuration**. Bindings apply only to the playback window and do not intercept text editing, file/URL dialogs, plain Tab navigation, or VoiceOver's Control+Option commands. Configured modifier combinations work in playback. Holding a playback/window/settings key performs it once; seek, volume, and frame-step keys may repeat. Player menus show the first configured chord and use the same explicit actions rather than retaining hidden fixed player shortcuts; all configured chords work from playback. Return/Space navigation in an open menu activates its selected action.
|
Default shortcuts and their explicit action names are listed in **Configuration**. Bindings apply only to the playback window and do not intercept text editing, file/URL dialogs, plain Tab navigation, or VoiceOver's Control+Option commands. Configured modifier combinations work in playback. Holding a playback/window/settings key performs it once; seek, volume, and frame-step keys may repeat. Player menus show the first configured chord and use the same explicit actions rather than retaining hidden fixed player shortcuts; all configured chords work from playback. Return/Space navigation in an open menu activates its selected action.
|
||||||
@@ -221,7 +223,7 @@ Playback terms are defined in [CONTEXT.md](CONTEXT.md).
|
|||||||
- `Sources/App/AutomaticQuality.swift`: clock-injected quality decisions using reserve/stall hysteresis, stepwise resolution choices, cooldown and extraction-failure backoff. Ordinary state revisions do not reset health; source/seek/pause transitions do.
|
- `Sources/App/AutomaticQuality.swift`: clock-injected quality decisions using reserve/stall hysteresis, stepwise resolution choices, cooldown and extraction-failure backoff. Ordinary state revisions do not reset health; source/seek/pause transitions do.
|
||||||
- `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions, supplies saved YouTube positions as backend open intent, and queues local restoration against the ready source.
|
- `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions, supplies saved YouTube positions as backend open intent, and queues local restoration against the ready source.
|
||||||
- `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup.
|
- `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup.
|
||||||
- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, and magnetic edge snapping with a session-only toggle.
|
- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, serialized frame/fullscreen animations with cancellation, Reduce Motion and failure recovery, all-Spaces/floating behavior, and magnetic edge snapping with a session-only toggle.
|
||||||
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, strict bundled yt-dlp/Deno discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation.
|
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, strict bundled yt-dlp/Deno discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation.
|
||||||
- `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Fresh instances receive media/config/reporting startup arguments in one launch; default reuse delivers the selected URL to an existing app. The app owns Apple events to prevent SwiftUI's default external-document routing from replacing its singleton window.
|
- `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Fresh instances receive media/config/reporting startup arguments in one launch; default reuse delivers the selected URL to an existing app. The app owns Apple events to prevent SwiftUI's default external-document routing from replacing its singleton window.
|
||||||
- `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared bundled-library preflight and the app's FFmpeg-independent entrypoint. The bootstrap loads the player library and calls its exported `terebi_app_main` entry point without replacing the executable, consuming initial launch events, or losing macOS app identity. `Contents/Resources/ffmpeg-libraries.txt` records the five versioned library filenames relative to `Contents/Frameworks`.
|
- `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared bundled-library preflight and the app's FFmpeg-independent entrypoint. The bootstrap loads the player library and calls its exported `terebi_app_main` entry point without replacing the executable, consuming initial launch events, or losing macOS app identity. `Contents/Resources/ffmpeg-libraries.txt` records the five versioned library filenames relative to `Contents/Frameworks`.
|
||||||
@@ -242,6 +244,8 @@ Generates short fixtures and exercises the real playback interface and native ou
|
|||||||
|
|
||||||
`make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, configured/session-only snapping, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app.
|
`make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, configured/session-only snapping, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app.
|
||||||
|
|
||||||
|
The native window check also covers queued entry/exit reversals, exact snapshot recovery, urgent nonanimated restoration, close/reattachment ignoring stale completions, and injected Reduce Motion. Animation smoothness and control cross-fades still need visual verification in the app.
|
||||||
|
|
||||||
`make check-config` checks isolated XDG/fallback paths, missing-file defaults without creation, partial/quoted/commented nested TOML, invalid syntax/types/keys/UTF-8, shortcut replacement/unbinding, chord normalization, inverse-pair sharing and unrelated conflicts, invalid action/quality names, duplicate keys, read errors, explicit path delivery, `--no-config` bypass, and unchanged file bytes/session reset. It needs neither FFmpeg nor a graphical session and never reads the user's configuration.
|
`make check-config` checks isolated XDG/fallback paths, missing-file defaults without creation, partial/quoted/commented nested TOML, invalid syntax/types/keys/UTF-8, shortcut replacement/unbinding, chord normalization, inverse-pair sharing and unrelated conflicts, invalid action/quality names, duplicate keys, read errors, explicit path delivery, `--no-config` bypass, and unchanged file bytes/session reset. It needs neither FFmpeg nor a graphical session and never reads the user's configuration.
|
||||||
|
|
||||||
`make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, real subprocess output, bounded pipe draining, cancellation, request isolation, and damaged/moved helper discovery using isolated fixtures. It also runs the actual bundled extractor against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K; no external network access is required.
|
`make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, real subprocess output, bounded pipe draining, cancellation, request isolation, and damaged/moved helper discovery using isolated fixtures. It also runs the actual bundled extractor against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K; no external network access is required.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import QuartzCore
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CompactWindowController: NSObject {
|
final class CompactWindowController: NSObject {
|
||||||
var onChange: (() -> Void)?
|
var onChange: (() -> Void)?
|
||||||
private(set) var isCompact = false
|
private(set) var isCompact = false
|
||||||
private(set) var isTransitioning = false
|
private(set) var isTransitioning = false
|
||||||
|
static let transitionDuration: TimeInterval = 0.25
|
||||||
|
|
||||||
var snappingEnabled: Bool {
|
var snappingEnabled: Bool {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -54,9 +56,14 @@ final class CompactWindowController: NSObject {
|
|||||||
private var originatedFullScreen = false
|
private var originatedFullScreen = false
|
||||||
private var videoAspect = 16.0 / 9.0
|
private var videoAspect = 16.0 / 9.0
|
||||||
private var fittedAspect: Double?
|
private var fittedAspect: Double?
|
||||||
|
private let reduceMotion: () -> Bool
|
||||||
|
private var frameAnimating = false
|
||||||
|
private var frameAnimationGeneration: UInt64 = 0
|
||||||
|
|
||||||
init(snappingEnabled: Bool = true) {
|
init(snappingEnabled: Bool = true,
|
||||||
|
reduceMotion: @escaping () -> Bool = { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }) {
|
||||||
self.snappingEnabled = snappingEnabled
|
self.snappingEnabled = snappingEnabled
|
||||||
|
self.reduceMotion = reduceMotion
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +71,7 @@ final class CompactWindowController: NSObject {
|
|||||||
|
|
||||||
func attach(_ window: NSWindow) {
|
func attach(_ window: NSWindow) {
|
||||||
guard window !== self.window else { return }
|
guard window !== self.window else { return }
|
||||||
|
stopFrameAnimation()
|
||||||
if let oldWindow = self.window, fullScreenTransition == nil,
|
if let oldWindow = self.window, fullScreenTransition == nil,
|
||||||
!oldWindow.styleMask.contains(.fullScreen) {
|
!oldWindow.styleMask.contains(.fullScreen) {
|
||||||
restoreSnapshot(to: oldWindow)
|
restoreSnapshot(to: oldWindow)
|
||||||
@@ -105,11 +113,12 @@ final class CompactWindowController: NSObject {
|
|||||||
reconcile()
|
reconcile()
|
||||||
}
|
}
|
||||||
|
|
||||||
func restore(resumeFullScreen: Bool = true) {
|
func restore(resumeFullScreen: Bool = true, animated: Bool = true) {
|
||||||
guard sessionActive else { return }
|
guard sessionActive else { return }
|
||||||
wantsCompact = false
|
wantsCompact = false
|
||||||
wantsFullScreen = resumeFullScreen && originatedFullScreen
|
wantsFullScreen = resumeFullScreen && originatedFullScreen
|
||||||
reconcile()
|
if !animated { stopFrameAnimation() }
|
||||||
|
reconcile(animated: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateAspect(_ aspect: Double) {
|
func updateAspect(_ aspect: Double) {
|
||||||
@@ -137,6 +146,7 @@ final class CompactWindowController: NSObject {
|
|||||||
fullScreenTransition = nil
|
fullScreenTransition = nil
|
||||||
reconcile()
|
reconcile()
|
||||||
case NSWindow.willCloseNotification:
|
case NSWindow.willCloseNotification:
|
||||||
|
stopFrameAnimation()
|
||||||
restoreDelegate()
|
restoreDelegate()
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
window = nil
|
window = nil
|
||||||
@@ -204,8 +214,8 @@ final class CompactWindowController: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A toggle changes the destination, never reverses an in-flight native animation.
|
// A toggle changes the destination, never reverses an in-flight native animation.
|
||||||
private func reconcile() {
|
private func reconcile(animated: Bool = true) {
|
||||||
guard let window, fullScreenTransition == nil else { return }
|
guard let window, fullScreenTransition == nil, !frameAnimating else { return }
|
||||||
guard sessionActive else {
|
guard sessionActive else {
|
||||||
setState(compact: isCompact, transitioning: false)
|
setState(compact: isCompact, transitioning: false)
|
||||||
return
|
return
|
||||||
@@ -214,7 +224,7 @@ final class CompactWindowController: NSObject {
|
|||||||
if window.styleMask.contains(.fullScreen) {
|
if window.styleMask.contains(.fullScreen) {
|
||||||
toggleNativeFullScreen(window, transition: .exiting)
|
toggleNativeFullScreen(window, transition: .exiting)
|
||||||
} else if !isCompact {
|
} else if !isCompact {
|
||||||
enterCompact(window)
|
enterCompact(window, animated: animated)
|
||||||
} else {
|
} else {
|
||||||
if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) }
|
if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) }
|
||||||
setState(compact: true, transitioning: false)
|
setState(compact: true, transitioning: false)
|
||||||
@@ -229,10 +239,17 @@ final class CompactWindowController: NSObject {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if snapshot != nil {
|
if let snapshot {
|
||||||
setState(compact: isCompact, transitioning: true)
|
setState(compact: false, transitioning: animated)
|
||||||
restoreSnapshot(to: window)
|
window.aspectRatio = .zero
|
||||||
setState(compact: false, transitioning: wantsFullScreen)
|
window.minSize = .zero
|
||||||
|
window.contentMinSize = .zero
|
||||||
|
animateFrame(snapshot.frame, of: window, animated: animated) { [weak self, weak window] in
|
||||||
|
guard let self, let window else { return }
|
||||||
|
self.restoreSnapshot(to: window)
|
||||||
|
self.reconcile()
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if wantsFullScreen {
|
if wantsFullScreen {
|
||||||
toggleNativeFullScreen(window, transition: .entering)
|
toggleNativeFullScreen(window, transition: .entering)
|
||||||
@@ -247,10 +264,10 @@ final class CompactWindowController: NSObject {
|
|||||||
window.toggleFullScreen(nil)
|
window.toggleFullScreen(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enterCompact(_ window: NSWindow) {
|
private func enterCompact(_ window: NSWindow, animated: Bool) {
|
||||||
setState(compact: false, transitioning: true)
|
|
||||||
// After didExitFullScreen, this is the normal frame, not the fullscreen frame.
|
// After didExitFullScreen, this is the normal frame, not the fullscreen frame.
|
||||||
snapshot = Snapshot(window)
|
snapshot = Snapshot(window)
|
||||||
|
setState(compact: true, transitioning: animated)
|
||||||
window.aspectRatio = .zero
|
window.aspectRatio = .zero
|
||||||
window.minSize = .zero
|
window.minSize = .zero
|
||||||
window.contentMinSize = .zero
|
window.contentMinSize = .zero
|
||||||
@@ -267,8 +284,7 @@ final class CompactWindowController: NSObject {
|
|||||||
window.level = .floating
|
window.level = .floating
|
||||||
window.hidesOnDeactivate = false
|
window.hidesOnDeactivate = false
|
||||||
window.isMovableByWindowBackground = true
|
window.isMovableByWindowBackground = true
|
||||||
fitCompactWindow(window, initial: true)
|
fitCompactWindow(window, initial: true, animated: animated)
|
||||||
setState(compact: true, transitioning: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restoreSnapshot(to window: NSWindow) {
|
private func restoreSnapshot(to window: NSWindow) {
|
||||||
@@ -298,6 +314,42 @@ final class CompactWindowController: NSObject {
|
|||||||
setState(compact: false, transitioning: false)
|
setState(compact: false, transitioning: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func animateFrame(_ frame: NSRect, of window: NSWindow, animated: Bool,
|
||||||
|
completion: @escaping @MainActor () -> Void) {
|
||||||
|
guard animated, !reduceMotion(), window.frame != frame else {
|
||||||
|
window.setFrame(frame, display: true)
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
frameAnimationGeneration &+= 1
|
||||||
|
let generation = frameAnimationGeneration
|
||||||
|
frameAnimating = true
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = Self.transitionDuration
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
|
window.animator().setFrame(frame, display: true)
|
||||||
|
} completionHandler: { [weak self, weak window] in
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
guard let self, let window, self.window === window, self.frameAnimating,
|
||||||
|
self.frameAnimationGeneration == generation else { return }
|
||||||
|
self.frameAnimating = false
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopFrameAnimation() {
|
||||||
|
guard frameAnimating else { return }
|
||||||
|
frameAnimating = false
|
||||||
|
frameAnimationGeneration &+= 1
|
||||||
|
guard let window else { return }
|
||||||
|
// 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) {
|
||||||
guard isCompact != compact || isTransitioning != transitioning else { return }
|
guard isCompact != compact || isTransitioning != transitioning else { return }
|
||||||
isCompact = compact
|
isCompact = compact
|
||||||
@@ -309,9 +361,10 @@ final class CompactWindowController: NSObject {
|
|||||||
(window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
|
(window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fitCompactWindow(_ window: NSWindow, initial: Bool) {
|
private func fitCompactWindow(_ window: NSWindow, initial: Bool, animated: Bool = false) {
|
||||||
let visibleFrame = visibleFrame(for: window)
|
let visibleFrame = visibleFrame(for: window)
|
||||||
let usable = Self.usableFrame(visibleFrame)
|
let usable = Self.usableFrame(visibleFrame)
|
||||||
|
let aspect = videoAspect
|
||||||
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame)
|
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame)
|
||||||
if !initial {
|
if !initial {
|
||||||
frame.size = Self.compactSize(aspect: videoAspect, width: window.frame.width, available: usable.size)
|
frame.size = Self.compactSize(aspect: videoAspect, width: window.frame.width, available: usable.size)
|
||||||
@@ -319,11 +372,14 @@ final class CompactWindowController: NSObject {
|
|||||||
frame.origin.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height)
|
frame.origin.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height)
|
||||||
}
|
}
|
||||||
window.aspectRatio = .zero
|
window.aspectRatio = .zero
|
||||||
window.setFrame(frame, display: true)
|
animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in
|
||||||
if Self.canFitAspect(videoAspect, available: usable.size) {
|
guard let self, let window else { return }
|
||||||
window.aspectRatio = NSSize(width: videoAspect, height: 1)
|
if Self.canFitAspect(aspect, available: usable.size) {
|
||||||
|
window.aspectRatio = NSSize(width: aspect, height: 1)
|
||||||
|
}
|
||||||
|
self.fittedAspect = aspect
|
||||||
|
if initial { self.reconcile() }
|
||||||
}
|
}
|
||||||
fittedAspect = videoAspect
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect) -> NSRect {
|
nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect) -> NSRect {
|
||||||
|
|||||||
+33
-14
@@ -410,7 +410,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
transition.closeMedia()
|
transition.closeMedia()
|
||||||
compactIntent = nil
|
compactIntent = nil
|
||||||
compactWindow.restore(resumeFullScreen: false)
|
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||||
fileName = nil
|
fileName = nil
|
||||||
fileIcon = nil
|
fileIcon = nil
|
||||||
youtubeHeights = []
|
youtubeHeights = []
|
||||||
@@ -509,7 +509,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
return window != nil && fullScreenIntent == nil && !compactWindow.isTransitioning &&
|
return window != nil && fullScreenIntent == nil && !compactWindow.isTransitioning &&
|
||||||
(action == .enterFullscreen ? !isFullScreen : isFullScreen)
|
(action == .enterFullscreen ? !isFullScreen : isFullScreen)
|
||||||
case .enterCompact:
|
case .enterCompact:
|
||||||
return window != nil && !(compactIntent ?? isCompact) && !compactWindow.isTransitioning &&
|
return window != nil && !(compactIntent ?? isCompact) &&
|
||||||
canTogglePlayback && state.has_video &&
|
canTogglePlayback && state.has_video &&
|
||||||
state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0
|
state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0
|
||||||
case .exitCompact: return window != nil && (compactIntent ?? isCompact)
|
case .exitCompact: return window != nil && (compactIntent ?? isCompact)
|
||||||
@@ -553,7 +553,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
case .enterFullscreen, .exitFullscreen:
|
case .enterFullscreen, .exitFullscreen:
|
||||||
if isCompact {
|
if isCompact {
|
||||||
compactIntent = false
|
compactIntent = false
|
||||||
compactWindow.restore(resumeFullScreen: false)
|
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||||
}
|
}
|
||||||
fullScreenIntent = action == .enterFullscreen
|
fullScreenIntent = action == .enterFullscreen
|
||||||
window?.toggleFullScreen(nil)
|
window?.toggleFullScreen(nil)
|
||||||
@@ -707,7 +707,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
if let playing = observation.playback { setPlayback(playing: playing) }
|
if let playing = observation.playback { setPlayback(playing: playing) }
|
||||||
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
|
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
|
||||||
(!isBusy && !next.has_video) {
|
(!isBusy && !next.has_video) {
|
||||||
compactWindow.restore(resumeFullScreen: false)
|
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||||
}
|
}
|
||||||
if !isSeeking && !transition.seekPending {
|
if !isSeeking && !transition.seekPending {
|
||||||
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
|
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
|
||||||
@@ -755,7 +755,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
cancelYouTubeResolution()
|
cancelYouTubeResolution()
|
||||||
systemPlayback?.shutdown()
|
systemPlayback?.shutdown()
|
||||||
systemPlayback = nil
|
systemPlayback = nil
|
||||||
compactWindow.restore(resumeFullScreen: false)
|
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
timer = nil
|
timer = nil
|
||||||
hideDeadline = nil
|
hideDeadline = nil
|
||||||
@@ -1166,6 +1166,7 @@ private struct CompactDragSurface: NSViewRepresentable {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private struct PlayerContent: View {
|
private struct PlayerContent: View {
|
||||||
@ObservedObject var model: PlayerModel
|
@ObservedObject var model: PlayerModel
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@State private var showsRemainingTime = false
|
@State private var showsRemainingTime = false
|
||||||
@State private var dropTargeted = false
|
@State private var dropTargeted = false
|
||||||
@State private var controlSize = CGSize(width: 440, height: 68)
|
@State private var controlSize = CGSize(width: 440, height: 68)
|
||||||
@@ -1177,6 +1178,11 @@ private struct PlayerContent: View {
|
|||||||
private var controlHeight: CGFloat { showsBufferReadout ? 88 : 68 }
|
private var controlHeight: CGFloat { showsBufferReadout ? 88 : 68 }
|
||||||
private var compactControlHeight: CGFloat { showsBufferReadout ? 52 : 40 }
|
private var compactControlHeight: CGFloat { showsBufferReadout ? 52 : 40 }
|
||||||
|
|
||||||
|
private var compactChromeAnimation: Animation? {
|
||||||
|
reduceMotion || !model.compactWindow.isTransitioning
|
||||||
|
? nil : .easeInOut(duration: CompactWindowController.transitionDuration)
|
||||||
|
}
|
||||||
|
|
||||||
private var bufferedStatus: String? {
|
private var bufferedStatus: String? {
|
||||||
model.bufferedRange.map {
|
model.bufferedRange.map {
|
||||||
let behind = max(0, model.state.position - $0.lowerBound)
|
let behind = max(0, model.state.position - $0.lowerBound)
|
||||||
@@ -1282,7 +1288,7 @@ private struct PlayerContent: View {
|
|||||||
model.effectivePlaying ? "Pause" : "Play", action: model.togglePlayback)
|
model.effectivePlaying ? "Pause" : "Play", action: model.togglePlayback)
|
||||||
.disabled(!model.canTogglePlayback)
|
.disabled(!model.canTogglePlayback)
|
||||||
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
|
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
|
||||||
.disabled(model.compactWindow.isTransitioning)
|
.disabled(!model.canToggleCompact)
|
||||||
actionsMenu
|
actionsMenu
|
||||||
}
|
}
|
||||||
if showsBufferReadout { bufferReadout }
|
if showsBufferReadout { bufferReadout }
|
||||||
@@ -1446,7 +1452,7 @@ private struct PlayerContent: View {
|
|||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if model.isCompact {
|
ZStack(alignment: .top) {
|
||||||
Text(model.fileName ?? "terebi")
|
Text(model.fileName ?? "terebi")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -1459,11 +1465,21 @@ private struct PlayerContent: View {
|
|||||||
.accessibilityHidden(!model.controlsVisible)
|
.accessibilityHidden(!model.controlsVisible)
|
||||||
compactControls
|
compactControls
|
||||||
.position(x: geometry.size.width / 2, y: geometry.size.height - compactControlHeight / 2 - 8)
|
.position(x: geometry.size.width / 2, y: geometry.size.height - compactControlHeight / 2 - 8)
|
||||||
} else {
|
|
||||||
titlebar
|
|
||||||
}
|
}
|
||||||
|
.animation(compactChromeAnimation) { content in
|
||||||
|
content.opacity(model.isCompact ? 1 : 0)
|
||||||
|
}
|
||||||
|
.allowsHitTesting(model.isCompact)
|
||||||
|
.accessibilityHidden(!model.isCompact)
|
||||||
|
|
||||||
if model.canTogglePlayback && !model.isCompact {
|
titlebar
|
||||||
|
.animation(compactChromeAnimation) { content in
|
||||||
|
content.opacity(model.isCompact ? 0 : 1)
|
||||||
|
}
|
||||||
|
.allowsHitTesting(!model.isCompact && model.controlsVisible)
|
||||||
|
.accessibilityHidden(model.isCompact || !model.controlsVisible)
|
||||||
|
|
||||||
|
if model.canTogglePlayback {
|
||||||
playbackControls
|
playbackControls
|
||||||
.frame(width: 440, height: controlHeight)
|
.frame(width: 440, height: controlHeight)
|
||||||
.background {
|
.background {
|
||||||
@@ -1487,8 +1503,11 @@ private struct PlayerContent: View {
|
|||||||
.onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 }
|
.onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 }
|
||||||
.position(controllerPosition(in: geometry.size))
|
.position(controllerPosition(in: geometry.size))
|
||||||
.opacity(model.controlsVisible ? 1 : 0)
|
.opacity(model.controlsVisible ? 1 : 0)
|
||||||
.allowsHitTesting(model.controlsVisible)
|
.animation(compactChromeAnimation) { content in
|
||||||
.accessibilityHidden(!model.controlsVisible)
|
content.opacity(model.isCompact ? 0 : 1)
|
||||||
|
}
|
||||||
|
.allowsHitTesting(!model.isCompact && model.controlsVisible)
|
||||||
|
.accessibilityHidden(model.isCompact || !model.controlsVisible)
|
||||||
}
|
}
|
||||||
if dropTargeted {
|
if dropTargeted {
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
@@ -1514,8 +1533,8 @@ private struct PlayerContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: model.isCompact ? 160 : 400,
|
.frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : 400,
|
||||||
minHeight: model.isCompact ? model.compactContentMinimumHeight : 240)
|
minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.background(WindowChrome(model: model))
|
.background(WindowChrome(model: model))
|
||||||
|
|||||||
Reference in New Issue
Block a user