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
+75 -19
View File
@@ -1,10 +1,12 @@
import AppKit
import QuartzCore
@MainActor
final class CompactWindowController: NSObject {
var onChange: (() -> Void)?
private(set) var isCompact = false
private(set) var isTransitioning = false
static let transitionDuration: TimeInterval = 0.25
var snappingEnabled: Bool {
didSet {
@@ -54,9 +56,14 @@ final class CompactWindowController: NSObject {
private var originatedFullScreen = false
private var videoAspect = 16.0 / 9.0
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.reduceMotion = reduceMotion
super.init()
}
@@ -64,6 +71,7 @@ final class CompactWindowController: NSObject {
func attach(_ window: NSWindow) {
guard window !== self.window else { return }
stopFrameAnimation()
if let oldWindow = self.window, fullScreenTransition == nil,
!oldWindow.styleMask.contains(.fullScreen) {
restoreSnapshot(to: oldWindow)
@@ -105,11 +113,12 @@ final class CompactWindowController: NSObject {
reconcile()
}
func restore(resumeFullScreen: Bool = true) {
func restore(resumeFullScreen: Bool = true, animated: Bool = true) {
guard sessionActive else { return }
wantsCompact = false
wantsFullScreen = resumeFullScreen && originatedFullScreen
reconcile()
if !animated { stopFrameAnimation() }
reconcile(animated: animated)
}
func updateAspect(_ aspect: Double) {
@@ -137,6 +146,7 @@ final class CompactWindowController: NSObject {
fullScreenTransition = nil
reconcile()
case NSWindow.willCloseNotification:
stopFrameAnimation()
restoreDelegate()
NotificationCenter.default.removeObserver(self)
window = nil
@@ -204,8 +214,8 @@ final class CompactWindowController: NSObject {
}
// A toggle changes the destination, never reverses an in-flight native animation.
private func reconcile() {
guard let window, fullScreenTransition == nil else { return }
private func reconcile(animated: Bool = true) {
guard let window, fullScreenTransition == nil, !frameAnimating else { return }
guard sessionActive else {
setState(compact: isCompact, transitioning: false)
return
@@ -214,7 +224,7 @@ final class CompactWindowController: NSObject {
if window.styleMask.contains(.fullScreen) {
toggleNativeFullScreen(window, transition: .exiting)
} else if !isCompact {
enterCompact(window)
enterCompact(window, animated: animated)
} else {
if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) }
setState(compact: true, transitioning: false)
@@ -229,10 +239,17 @@ final class CompactWindowController: NSObject {
}
return
}
if snapshot != nil {
setState(compact: isCompact, transitioning: true)
restoreSnapshot(to: window)
setState(compact: false, transitioning: wantsFullScreen)
if let snapshot {
setState(compact: false, transitioning: animated)
window.aspectRatio = .zero
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 {
toggleNativeFullScreen(window, transition: .entering)
@@ -247,10 +264,10 @@ final class CompactWindowController: NSObject {
window.toggleFullScreen(nil)
}
private func enterCompact(_ window: NSWindow) {
setState(compact: false, transitioning: true)
private func enterCompact(_ window: NSWindow, animated: Bool) {
// After didExitFullScreen, this is the normal frame, not the fullscreen frame.
snapshot = Snapshot(window)
setState(compact: true, transitioning: animated)
window.aspectRatio = .zero
window.minSize = .zero
window.contentMinSize = .zero
@@ -267,8 +284,7 @@ final class CompactWindowController: NSObject {
window.level = .floating
window.hidesOnDeactivate = false
window.isMovableByWindowBackground = true
fitCompactWindow(window, initial: true)
setState(compact: true, transitioning: false)
fitCompactWindow(window, initial: true, animated: animated)
}
private func restoreSnapshot(to window: NSWindow) {
@@ -298,6 +314,42 @@ final class CompactWindowController: NSObject {
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) {
guard isCompact != compact || isTransitioning != transitioning else { return }
isCompact = compact
@@ -309,9 +361,10 @@ final class CompactWindowController: NSObject {
(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 usable = Self.usableFrame(visibleFrame)
let aspect = videoAspect
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame)
if !initial {
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)
}
window.aspectRatio = .zero
window.setFrame(frame, display: true)
if Self.canFitAspect(videoAspect, available: usable.size) {
window.aspectRatio = NSSize(width: videoAspect, height: 1)
animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in
guard let self, let window else { return }
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 {
+33 -14
View File
@@ -410,7 +410,7 @@ final class PlayerModel: ObservableObject {
}
transition.closeMedia()
compactIntent = nil
compactWindow.restore(resumeFullScreen: false)
compactWindow.restore(resumeFullScreen: false, animated: false)
fileName = nil
fileIcon = nil
youtubeHeights = []
@@ -509,7 +509,7 @@ final class PlayerModel: ObservableObject {
return window != nil && fullScreenIntent == nil && !compactWindow.isTransitioning &&
(action == .enterFullscreen ? !isFullScreen : isFullScreen)
case .enterCompact:
return window != nil && !(compactIntent ?? isCompact) && !compactWindow.isTransitioning &&
return window != nil && !(compactIntent ?? isCompact) &&
canTogglePlayback && state.has_video &&
state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0
case .exitCompact: return window != nil && (compactIntent ?? isCompact)
@@ -553,7 +553,7 @@ final class PlayerModel: ObservableObject {
case .enterFullscreen, .exitFullscreen:
if isCompact {
compactIntent = false
compactWindow.restore(resumeFullScreen: false)
compactWindow.restore(resumeFullScreen: false, animated: false)
}
fullScreenIntent = action == .enterFullscreen
window?.toggleFullScreen(nil)
@@ -707,7 +707,7 @@ final class PlayerModel: ObservableObject {
if let playing = observation.playback { setPlayback(playing: playing) }
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
(!isBusy && !next.has_video) {
compactWindow.restore(resumeFullScreen: false)
compactWindow.restore(resumeFullScreen: false, animated: false)
}
if !isSeeking && !transition.seekPending {
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
@@ -755,7 +755,7 @@ final class PlayerModel: ObservableObject {
cancelYouTubeResolution()
systemPlayback?.shutdown()
systemPlayback = nil
compactWindow.restore(resumeFullScreen: false)
compactWindow.restore(resumeFullScreen: false, animated: false)
timer?.invalidate()
timer = nil
hideDeadline = nil
@@ -1166,6 +1166,7 @@ private struct CompactDragSurface: NSViewRepresentable {
@MainActor
private struct PlayerContent: View {
@ObservedObject var model: PlayerModel
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var showsRemainingTime = false
@State private var dropTargeted = false
@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 compactControlHeight: CGFloat { showsBufferReadout ? 52 : 40 }
private var compactChromeAnimation: Animation? {
reduceMotion || !model.compactWindow.isTransitioning
? nil : .easeInOut(duration: CompactWindowController.transitionDuration)
}
private var bufferedStatus: String? {
model.bufferedRange.map {
let behind = max(0, model.state.position - $0.lowerBound)
@@ -1282,7 +1288,7 @@ private struct PlayerContent: View {
model.effectivePlaying ? "Pause" : "Play", action: model.togglePlayback)
.disabled(!model.canTogglePlayback)
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
.disabled(model.compactWindow.isTransitioning)
.disabled(!model.canToggleCompact)
actionsMenu
}
if showsBufferReadout { bufferReadout }
@@ -1446,7 +1452,7 @@ private struct PlayerContent: View {
.allowsHitTesting(false)
}
if model.isCompact {
ZStack(alignment: .top) {
Text(model.fileName ?? "terebi")
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
@@ -1459,11 +1465,21 @@ private struct PlayerContent: View {
.accessibilityHidden(!model.controlsVisible)
compactControls
.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
.frame(width: 440, height: controlHeight)
.background {
@@ -1487,8 +1503,11 @@ private struct PlayerContent: View {
.onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 }
.position(controllerPosition(in: geometry.size))
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(model.controlsVisible)
.accessibilityHidden(!model.controlsVisible)
.animation(compactChromeAnimation) { content in
content.opacity(model.isCompact ? 0 : 1)
}
.allowsHitTesting(!model.isCompact && model.controlsVisible)
.accessibilityHidden(model.isCompact || !model.controlsVisible)
}
if dropTargeted {
RoundedRectangle(cornerRadius: 10)
@@ -1514,8 +1533,8 @@ private struct PlayerContent: View {
}
}
}
.frame(minWidth: model.isCompact ? 160 : 400,
minHeight: model.isCompact ? model.compactContentMinimumHeight : 240)
.frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : 400,
minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea()
.preferredColorScheme(.dark)
.background(WindowChrome(model: model))