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
+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))