feat: animate queue sidebar expansion and collapse

This commit is contained in:
2026-10-18 21:48:06 +02:00
parent e4504ea5c4
commit 22f13dd08d
6 changed files with 183 additions and 31 deletions
+89 -16
View File
@@ -34,21 +34,14 @@ final class PlayerModel: ObservableObject {
@Published private(set) var queue = PlaybackQueue()
@Published var showsQueue = false {
didSet {
guard showsQueue != oldValue, let window, !isFullScreen,
!isCompact, !compactWindow.isTransitioning else { return }
// Setting aspectRatio to zero leaves AppKit's resize increments at zero.
window.resizeIncrements = NSSize(width: 1, height: 1)
var frame = window.frame
frame.size.width += showsQueue ? 260 : -260
if let screen = window.screen?.visibleFrame {
frame.origin.x = max(screen.minX, min(frame.minX, screen.maxX - frame.width))
}
window.setFrame(frame, display: true)
if !showsQueue, state.has_video, state.video_aspect_ratio > 0 {
window.aspectRatio = NSSize(width: state.video_aspect_ratio, height: 1)
}
if showsQueue != oldValue { transitionQueue() }
}
}
@Published private(set) var queueWidth: CGFloat = 0
private var queueAnimation: Timer?
private var queueAnimationCompletion: (() -> Void)?
private var queueWindowVideoWidth: CGFloat?
var isQueueTransitioning: Bool { queueAnimation != nil }
private var queuePlaybackRevision: UInt64?
private var queueCompletionHandled = false
private var queueCastDevice: CastDevice?
@@ -759,8 +752,75 @@ final class PlayerModel: ObservableObject {
perform(muteAction)
}
private func transitionQueue() {
queueAnimation?.invalidate()
queueAnimation = nil
queueAnimationCompletion = nil
let targetWidth: CGFloat = showsQueue ? 260 : 0
guard let window, !isCompact, !compactWindow.isTransitioning else {
queueWidth = targetWidth
queueWindowVideoWidth = nil
return
}
let startWidth = queueWidth
let startFrame = window.frame
var targetFrame = startFrame
if !isFullScreen {
// Setting aspectRatio to zero leaves AppKit's resize increments at zero.
window.resizeIncrements = NSSize(width: 1, height: 1)
let videoWidth = queueWindowVideoWidth ?? startFrame.width - startWidth
queueWindowVideoWidth = videoWidth
targetFrame.size.width = videoWidth + targetWidth
if let screen = window.screen?.visibleFrame {
targetFrame.origin.x = max(screen.minX, min(startFrame.minX, screen.maxX - targetFrame.width))
}
}
// One clock drives the reveal and native frame so the video never shrinks between them.
let update: @MainActor (Double) -> Void = { [weak self, weak window] progress in
guard let self, let window else { return }
self.queueWidth = startWidth + (targetWidth - startWidth) * progress
if targetFrame != startFrame {
window.setFrame(NSRect(x: startFrame.minX + (targetFrame.minX - startFrame.minX) * progress,
y: startFrame.minY,
width: startFrame.width + (targetFrame.width - startFrame.width) * progress,
height: startFrame.height), display: true)
}
}
queueAnimationCompletion = { [weak self, weak window] in
update(1)
if let self, let window, !self.showsQueue, !self.isFullScreen,
self.state.has_video, self.state.video_aspect_ratio > 0 {
window.aspectRatio = NSSize(width: self.state.video_aspect_ratio, height: 1)
}
}
guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else {
finishQueueTransition()
return
}
let start = ContinuousClock.now
let animation = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in
MainActor.assumeIsolated {
let progress = min(1, start.duration(to: .now) / .milliseconds(250))
if progress >= 1 { self?.finishQueueTransition() }
else { update(progress * progress * (3 - 2 * progress)) }
}
}
queueAnimation = animation
RunLoop.main.add(animation, forMode: .common)
}
func finishQueueTransition() {
queueAnimation?.invalidate()
queueAnimation = nil
let completion = queueAnimationCompletion
queueAnimationCompletion = nil
queueWindowVideoWidth = nil
completion?()
}
func attachWindow(_ window: NSWindow) {
guard self.window !== window else { return }
finishQueueTransition()
self.window = window
window.title = fileName ?? "Terebi"
window.contentMinSize = NSSize(width: 400, height: 240)
@@ -853,6 +913,7 @@ final class PlayerModel: ObservableObject {
case .stepFrameForward: return stepFrame(1)
case .stepFrameBackward: return stepFrame(-1)
case .enterFullscreen, .exitFullscreen:
finishQueueTransition()
if isCompact {
compactIntent = false
compactWindow.restore(resumeFullScreen: false, animated: false)
@@ -861,6 +922,7 @@ final class PlayerModel: ObservableObject {
window?.toggleFullScreen(nil)
revealControls()
case .enterCompact:
finishQueueTransition()
compactIntent = true
compactWindow.toggle(aspect: state.video_aspect_ratio)
controlsHovered = false
@@ -970,7 +1032,7 @@ final class PlayerModel: ObservableObject {
private func fitVideoWindow() {
guard needsVideoFit, canTogglePlayback, let window, !isFullScreen,
!compactWindow.isTransitioning else { return }
!compactWindow.isTransitioning, !isQueueTransitioning else { return }
let aspect = state.video_aspect_ratio
if isCompact {
compactWindow.updateAspect(state.video_aspect_ratio)
@@ -1094,6 +1156,7 @@ final class PlayerModel: ObservableObject {
}
func shutdown() {
finishQueueTransition()
saveProgress()
if castDevice != nil { stopCasting(resume: false) }
castDiscovery.stop()
@@ -2440,11 +2503,15 @@ private struct PlayerContent: View {
var body: some View {
HStack(spacing: 0) {
videoContent
if model.showsQueue && !model.isCompact && !model.compactWindow.isTransitioning {
if !model.isCompact && !model.compactWindow.isTransitioning && model.queueWidth > 0 {
queueSidebar
.frame(width: model.queueWidth, alignment: .leading)
.clipped()
.allowsHitTesting(model.showsQueue)
.accessibilityHidden(!model.showsQueue)
}
}
.frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : (model.showsQueue ? 660 : 400),
.frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : 400 + model.queueWidth,
minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea()
.preferredColorScheme(.dark)
@@ -2758,6 +2825,12 @@ private struct WindowChrome: NSViewRepresentable {
window.acceptsMouseMovedEvents = true
window.collectionBehavior.insert(.fullScreenPrimary)
window.appearance = NSAppearance(named: .darkAqua)
for name in [NSWindow.willEnterFullScreenNotification, NSWindow.willStartLiveResizeNotification,
NSWindow.willCloseNotification] {
observers.append(NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.model.finishQueueTransition() }
})
}
for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification,
NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification] {
observers.append(NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in