feat: animate compact window transitions

This commit is contained in:
2026-10-12 09:58:33 +02:00
parent e54cb2b549
commit f495c8b719
4 changed files with 401 additions and 111 deletions
+288 -77
View File
@@ -1,4 +1,5 @@
import AppKit import AppKit
import CoreFoundation
import Foundation import Foundation
@main @main
@@ -93,87 +94,182 @@ 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)) }
content.addSubview(sentinel) self.content = content
let sentinelFrame = sentinel.frame sentinel = NSView(frame: NSRect(x: 5, y: 7, width: 12, height: 14))
window.minSize = NSSize(width: 300, height: 180) content.addSubview(sentinel)
window.contentMinSize = NSSize(width: 220, height: 120) sentinelFrame = sentinel.frame
window.aspectRatio = NSSize(width: 4, height: 3) window.minSize = NSSize(width: 300, height: 180)
window.collectionBehavior = [.managed, .fullScreenPrimary] window.contentMinSize = NSSize(width: 220, height: 120)
window.level = .normal window.aspectRatio = NSSize(width: 4, height: 3)
window.hidesOnDeactivate = true window.collectionBehavior = [.managed, .fullScreenPrimary]
window.isMovableByWindowBackground = false window.level = .normal
let originalFrame = window.frame window.hidesOnDeactivate = true
let originalMinSize = window.minSize window.isMovableByWindowBackground = false
let originalContentMinSize = window.contentMinSize original = WindowState(window)
let originalAspect = window.aspectRatio identity = ObjectIdentifier(window)
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
"compact mode preserves content and attached sentinel without reparenting") && CompactWindowChecks.sameFrame(sentinel.frame, sentinelFrame),
try require(!window.isVisible, "checks never display a window") "compact mode preserves content and attached sentinel without reparenting")
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()
} }
controller.toggle(aspect: 16.0 / 9.0) func compact(aspect: Double) throws {
try require(controller.isCompact && !controller.isTransitioning, "windowed toggle enters compact synchronously") try CompactWindowChecks.require(window.level == .floating && !window.hidesOnDeactivate,
try require(window.level == .floating && !window.hidesOnDeactivate, "compact floats and remains visible on deactivate") "compact floats and remains visible on deactivate")
try require(window.collectionBehavior.contains([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]), try CompactWindowChecks.require(window.collectionBehavior.contains(
"compact can join Spaces, other-app fullscreen, and Stage Manager") [.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
try require(window.collectionBehavior.intersection([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty, "compact can join Spaces, other-app fullscreen, and Stage Manager")
"compact removes incompatible primary and auxiliary policies") try CompactWindowChecks.require(window.collectionBehavior.intersection(
try require(window.contentMinSize == NSSize(width: 160, height: 90), "compact uses 160x90 content minimum") [.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
try require(window.isMovableByWindowBackground, "compact allows background dragging") "compact removes incompatible primary and auxiliary policies")
try require(window.canBecomeKey, "compact window still accepts keyboard playback controls") try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90),
try preservedContent() "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()
}
}
let visible = (window.screen ?? screen).visibleFrame // A later real native round-trip gives canceled animations a completion opportunity without
try require(close(window.frame.maxX, visible.maxX - 16) // guessing elapsed animation time. Watch the interrupted window throughout and after it.
&& close(window.frame.maxY, visible.maxY - 16) static func nativeCompletionOpportunity(checking: () throws -> Void) throws {
&& close(window.frame.width / window.frame.height, 16.0 / 9.0), let fixture = try WindowFixture()
"actual compact window enters at inset top right with playback aspect") 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)
try require(controller.isCompact && controller.isTransitioning,
"entry presents the compact destination while native geometry settles")
try fixture.preservedContent()
try settled(controller, compact: true)
try fixture.compact(aspect: 16.0 / 9.0)
let visible = (window.screen ?? NSScreen.main!).visibleFrame
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")
} }
} }
+5 -1
View File
@@ -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.
+75 -19
View File
@@ -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
View File
@@ -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))