feat: animate queue sidebar expansion and collapse
This commit is contained in:
+89
-16
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user