mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Morph the expanded player into the mini player on drag-to-dismiss
Releasing the dismiss drag now shrinks the player window onto its video and flies it into the mini player's preview instead of sliding it off screen and showing the mini player afterwards. The mini player stays mounted under the expanded player and reports its preview frame, and the window is masked down to the video while its root view is transformed onto that frame (with the safe area the transform drops added back, so the content doesn't jump). The handoff of the shared MPV view is made seamless: a freshly made player container waits for its first layout before taking a view that is on screen elsewhere, sizes and redraws it there, and the window hides in that same frame. Resizing the render view draws the current frame into the recreated framebuffer right away instead of leaving it blank until the next tick. Falls back to the slide when no preview target is available (rotated session, video preview hidden, different window size).
This commit is contained in:
@@ -192,7 +192,9 @@ struct AdaptiveTabView: View {
|
||||
private var shouldShowExpandPendingSpinner: Bool {
|
||||
let nav = appEnvironment.navigationCoordinator
|
||||
// Show spinner when expand is requested but window isn't visible yet
|
||||
// (a gesture dismiss hides the window before un-expanding — not pending)
|
||||
return nav.isPlayerExpanded && !nav.isPlayerWindowVisible && !nav.isPlayerSheetAnimating
|
||||
&& !nav.isPlayerCollapsing && !nav.isPlayerMorphingToMini
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -255,9 +257,10 @@ struct AdaptiveTabView: View {
|
||||
private var miniPlayerOverlay: some View {
|
||||
let playerState = appEnvironment.playerService.state
|
||||
let hasActiveVideo = playerState.currentVideo != nil
|
||||
let isExpanded = appEnvironment.navigationCoordinator.isPlayerExpanded
|
||||
|
||||
if hasActiveVideo && !isExpanded {
|
||||
// Stays mounted under the expanded player: the dismiss gesture morphs
|
||||
// the video into its preview, so it must already be in place
|
||||
if hasActiveVideo {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
MiniPlayerView()
|
||||
|
||||
@@ -155,6 +155,15 @@ final class NavigationCoordinator {
|
||||
/// Comments overlay frame in screen coordinates (for gesture conflict resolution).
|
||||
var commentsFrame: CGRect = .zero
|
||||
|
||||
/// Mini player video preview frame in the main window's coordinates and its
|
||||
/// corner radius — the target the expanded player morphs into on dismiss.
|
||||
var miniPlayerVideoFrame: CGRect = .zero
|
||||
var miniPlayerVideoCornerRadius: CGFloat = 0
|
||||
|
||||
/// Whether the expanded player is morphing into the mini player. The mini
|
||||
/// player keeps its thumbnail hidden so the arriving video isn't covered.
|
||||
var isPlayerMorphingToMini = false
|
||||
|
||||
/// Current panscan value from UIKit pinch gesture (0.0 = fit, 1.0 = fill).
|
||||
/// Updated by ExpandedPlayerWindow's pinch gesture handler.
|
||||
var pinchPanscan: Double = 0.0
|
||||
|
||||
@@ -506,6 +506,15 @@ final class MPVRenderView: UIView {
|
||||
renderQueue.sync {
|
||||
destroyFramebuffer()
|
||||
createFramebuffer()
|
||||
|
||||
// The new layer storage is empty until something is presented into it;
|
||||
// waiting for the next display link tick shows a blank blink (e.g. the
|
||||
// expanded player handing its view to the mini player). Draw the current
|
||||
// frame now so the resize lands with content.
|
||||
if framebuffer != 0, !isInBackground, mpvHasFrameReady {
|
||||
performRender()
|
||||
EAGLContext.setCurrent(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -281,10 +281,11 @@ struct UnifiedTabView: View {
|
||||
/// rows of every list then clear it, as they clear the tab accessory.
|
||||
private var miniPlayerInset: some View {
|
||||
let hasActiveVideo = appEnvironment?.playerService.state.currentVideo != nil
|
||||
let isExpanded = appEnvironment?.navigationCoordinator.isPlayerExpanded ?? false
|
||||
|
||||
// Stays mounted under the expanded player: the dismiss gesture morphs
|
||||
// the video into its preview, so it must already be in place
|
||||
return VStack(spacing: 0) {
|
||||
if hasActiveVideo && !isExpanded {
|
||||
if hasActiveVideo {
|
||||
MiniPlayerView()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
@@ -264,16 +264,9 @@ private final class DragToDismissGestureHandler: NSObject, UIGestureRecognizerDe
|
||||
// Trigger haptic immediately on release
|
||||
onHapticFeedback?()
|
||||
|
||||
// Animate off screen then dismiss - fast and smooth without bounce
|
||||
// Main window scale reset is handled by hide() which is called via onDismiss
|
||||
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0, options: []) {
|
||||
window.frame.origin.y = window.bounds.height
|
||||
// Continue scaling main window to fully normal during dismiss animation
|
||||
self.onMainWindowScaleChanged?(0)
|
||||
} completion: { [weak self] _ in
|
||||
self?.onDismissGestureStateChanged?(false)
|
||||
self?.onDismiss?()
|
||||
}
|
||||
// The window manager owns the dismiss animation (morph into
|
||||
// the mini player, or slide off screen) from the current position
|
||||
onDismiss?()
|
||||
} else {
|
||||
// Snap back - restore main window scale to fully scaled
|
||||
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0) {
|
||||
@@ -500,6 +493,12 @@ final class ExpandedPlayerWindowManager {
|
||||
private var expandedWindow: UIWindow?
|
||||
private weak var appEnvironment: AppEnvironment?
|
||||
private var dragHandler: DragToDismissGestureHandler?
|
||||
/// Whether a hide is in flight (window still up, cleanup pending).
|
||||
private var isHiding = false
|
||||
/// Whether the morph has ended and the window waits for the mini player to
|
||||
/// adopt the video view before hiding (any hide meanwhile must not animate).
|
||||
private var isHandingOffToMiniPlayer = false
|
||||
private var pendingHandOffHideCompletions: [() -> Void] = []
|
||||
|
||||
// MARK: - Window Scaling Effect (Apple Music-style)
|
||||
|
||||
@@ -832,10 +831,7 @@ final class ExpandedPlayerWindowManager {
|
||||
self?.appEnvironment?.settingsManager.triggerHapticFeedback(for: .playerDismiss)
|
||||
}
|
||||
handler.onDismiss = { [weak self] in
|
||||
// Set collapsing state BEFORE isPlayerExpanded=false to ensure mini player
|
||||
// shows video immediately when it appears (it checks isPlayerCollapsing)
|
||||
self?.appEnvironment?.navigationCoordinator.isPlayerCollapsing = true
|
||||
self?.appEnvironment?.navigationCoordinator.isPlayerExpanded = false
|
||||
self?.dismissFromGesture()
|
||||
}
|
||||
handler.onDismissGestureStateChanged = { [weak self] isActive in
|
||||
self?.appEnvironment?.navigationCoordinator.isPlayerDismissGestureActive = isActive
|
||||
@@ -974,6 +970,227 @@ final class ExpandedPlayerWindowManager {
|
||||
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show complete")
|
||||
}
|
||||
|
||||
/// Drag-to-dismiss released past the threshold: morph the video into the
|
||||
/// mini player when its preview is on screen, otherwise slide off screen.
|
||||
private func dismissFromGesture() {
|
||||
guard let nav = appEnvironment?.navigationCoordinator else { return }
|
||||
|
||||
if morphIntoMiniPlayer(completion: { [weak self] in
|
||||
self?.handOffToMiniPlayer()
|
||||
}) {
|
||||
return
|
||||
}
|
||||
|
||||
// Slide off: hide() animates from the dragged position and marks the
|
||||
// window not visible before the gesture flag clears (no black overlay
|
||||
// frame). isPlayerExpanded flips last, so the mini player doesn't take
|
||||
// the video view from the sliding window.
|
||||
expandedWindow?.isUserInteractionEnabled = false
|
||||
hide(animated: true) {
|
||||
nav.isPlayerExpanded = false
|
||||
}
|
||||
nav.isPlayerDismissGestureActive = false
|
||||
}
|
||||
|
||||
/// Ends the morph: un-expands so the mini player mounts its video view, and
|
||||
/// hides the window only once that view has actually moved into the mini
|
||||
/// player. SwiftUI may mount it a frame later; hiding first left a frame
|
||||
/// with neither (a blank preview).
|
||||
private func handOffToMiniPlayer() {
|
||||
guard let nav = appEnvironment?.navigationCoordinator else { return }
|
||||
let playerView = (appEnvironment?.playerService.currentBackend as? MPVBackend)?.playerView
|
||||
|
||||
nonisolated(unsafe) var observer: (any NSObjectProtocol)?
|
||||
let finish: () -> Void = { [weak self] in
|
||||
if let observer {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
observer = nil
|
||||
nav.isPlayerMorphingToMini = false
|
||||
guard let self, self.isHandingOffToMiniPlayer else { return }
|
||||
self.isHandingOffToMiniPlayer = false
|
||||
// Hide now, in the same pass the mini player adopts the view; the full
|
||||
// cleanup (layout passes, orientation) runs outside that SwiftUI update
|
||||
self.expandedWindow?.isHidden = true
|
||||
let completions = self.pendingHandOffHideCompletions
|
||||
self.pendingHandOffHideCompletions = []
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.hide(animated: false) {
|
||||
completions.forEach { $0() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isHandingOffToMiniPlayer = true
|
||||
if let playerView, playerView.window === expandedWindow {
|
||||
// Adopting isn't enough: the view keeps its old size and the new
|
||||
// container is still zero-sized until the next layout pass, so the
|
||||
// preview is blank for a frame. Wait until it's laid out there.
|
||||
observer = NotificationCenter.default.addObserver(
|
||||
forName: .mpvPlayerViewDidChangeContainer, object: playerView, queue: nil
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
if let observer {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
observer = nil
|
||||
self?.waitUntilLaidOutInMiniPlayer(playerView, then: finish)
|
||||
}
|
||||
}
|
||||
// Fallback when the mini player never takes the view (video hidden in it)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { finish() }
|
||||
} else {
|
||||
DispatchQueue.main.async { finish() }
|
||||
}
|
||||
|
||||
nav.isPlayerDismissGestureActive = false
|
||||
nav.isPlayerCollapsing = true
|
||||
nav.isPlayerExpanded = false
|
||||
}
|
||||
|
||||
/// Calls `then` on the first display frame after the player view sits,
|
||||
/// sized, in a container outside the expanded window: by then that layout
|
||||
/// pass has re-rendered it into its resized framebuffer.
|
||||
private func waitUntilLaidOutInMiniPlayer(_ playerView: UIView, then: @escaping () -> Void) {
|
||||
let isLaidOut = { [weak self] in
|
||||
let size = playerView.bounds.size
|
||||
return playerView.window != nil
|
||||
&& playerView.window !== self?.expandedWindow
|
||||
&& size.width > 1 && size.height > 1
|
||||
&& playerView.superview?.bounds.size == size
|
||||
}
|
||||
// The container adopts it already sized and redrawn: hide right away,
|
||||
// in the same frame (the window has a black hole where the video was)
|
||||
if isLaidOut() {
|
||||
then()
|
||||
return
|
||||
}
|
||||
var wasLaidOut = false
|
||||
let ticker = DisplayLinkTicker()
|
||||
ticker.start { [weak self] in
|
||||
guard let self, self.isHandingOffToMiniPlayer else {
|
||||
ticker.stop()
|
||||
return
|
||||
}
|
||||
if wasLaidOut {
|
||||
ticker.stop()
|
||||
then()
|
||||
}
|
||||
wasLaidOut = isLaidOut()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shrinks the expanded window onto its video and flies it into the mini
|
||||
/// player's video preview. Returns false (doing nothing) when no target is
|
||||
/// available, so the caller falls back to sliding off screen.
|
||||
private func morphIntoMiniPlayer(completion: @escaping () -> Void) -> Bool {
|
||||
guard let window = expandedWindow,
|
||||
let hostView = window.rootViewController?.view,
|
||||
let nav = appEnvironment?.navigationCoordinator,
|
||||
!didRotateDuringSession,
|
||||
let playerView = (appEnvironment?.playerService.currentBackend as? MPVBackend)?.playerView,
|
||||
playerView.window === window,
|
||||
mainWindow.map({ $0.bounds.size == window.bounds.size }) == true else {
|
||||
return false
|
||||
}
|
||||
|
||||
// The mini player frame is a layout frame in the main window, unaffected by
|
||||
// the scale transform that animates back to identity alongside the morph
|
||||
let target = nav.miniPlayerVideoFrame
|
||||
let renderRect = playerView.convert(playerView.bounds, to: window)
|
||||
let source = visibleVideoRect(in: renderRect, panelFrame: hostView.convert(nav.portraitPanelFrame, to: window), nav: nav)
|
||||
guard target.width > 1, target.height > 1, window.bounds.contains(target),
|
||||
source.width > 1, source.height > 1 else {
|
||||
return false
|
||||
}
|
||||
|
||||
// The mask doesn't stop hit testing: keep stray touches off the hidden controls
|
||||
window.isUserInteractionEnabled = false
|
||||
hostView.layer.removeAllAnimations()
|
||||
nav.isPlayerMorphingToMini = true
|
||||
nav.isPlayerWindowVisible = false
|
||||
|
||||
// Only the video morphs: the window mask keeps the video opaque as it
|
||||
// flies to the target and fades everything around it (title, panels) out.
|
||||
// The window keeps its drag offset and animates back to 0 alongside the
|
||||
// morph; content and mask share that offset, so both are expressed in
|
||||
// un-offset window coordinates. (Transforming the UIWindow itself stops
|
||||
// it rendering.)
|
||||
let mask = UIView(frame: window.bounds)
|
||||
let surroundingMask = UIView(frame: window.bounds)
|
||||
surroundingMask.backgroundColor = .black
|
||||
let videoMask = UIView(frame: source)
|
||||
videoMask.backgroundColor = .black
|
||||
videoMask.layer.cornerCurve = .continuous
|
||||
mask.addSubview(surroundingMask)
|
||||
mask.addSubview(videoMask)
|
||||
window.mask = mask
|
||||
|
||||
// Map the source rect onto the target rect (a view transform applies about its center)
|
||||
let center = hostView.center
|
||||
let scaleX = target.width / source.width
|
||||
let scaleY = target.height / source.height
|
||||
let finalTransform = CGAffineTransform(
|
||||
a: scaleX, b: 0, c: 0, d: scaleY,
|
||||
tx: target.midX - center.x - scaleX * (source.midX - center.x),
|
||||
ty: target.midY - center.y - scaleY * (source.midY - center.y)
|
||||
)
|
||||
|
||||
// UIKit derives a view's safe area from its transformed frame: once scaled
|
||||
// toward the mini player the host no longer overlaps the status bar, its
|
||||
// top inset drops to 0 and SwiftUI re-lays the player out higher — out
|
||||
// from under the mask. Capture the insets to pin them below.
|
||||
let insetsBefore = hostView.safeAreaInsets
|
||||
|
||||
UIView.animate(withDuration: 0.15) {
|
||||
surroundingMask.alpha = 0
|
||||
}
|
||||
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0, options: []) {
|
||||
window.frame.origin.y = 0
|
||||
hostView.transform = finalTransform
|
||||
videoMask.frame = target
|
||||
videoMask.layer.cornerRadius = nav.miniPlayerVideoCornerRadius
|
||||
self.scaleMainWindow(progress: 0)
|
||||
} completion: { _ in
|
||||
completion()
|
||||
}
|
||||
|
||||
// Pin the safe area: add back whatever the transform took away
|
||||
if let hostingController = window.rootViewController {
|
||||
window.layoutIfNeeded()
|
||||
let insetsAfter = hostView.safeAreaInsets
|
||||
let additional = hostingController.additionalSafeAreaInsets
|
||||
hostingController.additionalSafeAreaInsets = UIEdgeInsets(
|
||||
top: additional.top + insetsBefore.top - insetsAfter.top,
|
||||
left: additional.left + insetsBefore.left - insetsAfter.left,
|
||||
bottom: additional.bottom + insetsBefore.bottom - insetsAfter.bottom,
|
||||
right: additional.right + insetsBefore.right - insetsAfter.right
|
||||
)
|
||||
hostView.layoutIfNeeded()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// The part of the render view that shows video: the aspect-fit picture,
|
||||
/// minus whatever the portrait details panel covers.
|
||||
private func visibleVideoRect(in renderRect: CGRect, panelFrame panel: CGRect, nav: NavigationCoordinator) -> CGRect {
|
||||
let aspect = CGFloat(appEnvironment?.playerService.state.displayAspectRatio ?? 16.0 / 9.0)
|
||||
var rect = renderRect
|
||||
if aspect > 0 {
|
||||
if renderRect.width / renderRect.height > aspect {
|
||||
let width = renderRect.height * aspect
|
||||
rect = CGRect(x: renderRect.midX - width / 2, y: renderRect.minY, width: width, height: renderRect.height)
|
||||
} else {
|
||||
let height = renderRect.width / aspect
|
||||
rect = CGRect(x: renderRect.minX, y: renderRect.midY - height / 2, width: renderRect.width, height: height)
|
||||
}
|
||||
}
|
||||
if nav.isPortraitPanelVisible, panel.height > 0, panel.minY < rect.maxY, panel.minY > rect.minY {
|
||||
rect.size.height = panel.minY - rect.minY
|
||||
}
|
||||
return rect
|
||||
}
|
||||
|
||||
func hide(animated: Bool = true, completion: (() -> Void)? = nil) {
|
||||
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] hide called, expandedWindow=\(expandedWindow != nil), animated=\(animated)")
|
||||
|
||||
@@ -982,19 +1199,37 @@ final class ExpandedPlayerWindowManager {
|
||||
showRetryCount = 0
|
||||
pendingScaleApplication = false
|
||||
|
||||
// Mark window as not visible immediately and start collapsing animation
|
||||
appEnvironment?.navigationCoordinator.isPlayerWindowVisible = false
|
||||
appEnvironment?.navigationCoordinator.isPlayerCollapsing = true
|
||||
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] hide: isPlayerWindowVisible=false, isPlayerCollapsing=true")
|
||||
|
||||
guard let window = expandedWindow else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
|
||||
// The morphed window sits on the mini player until the mini player has
|
||||
// laid out the video; the handoff hides it then
|
||||
if isHandingOffToMiniPlayer {
|
||||
if let completion {
|
||||
pendingHandOffHideCompletions.append(completion)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A hide is already running (e.g. the gesture's hide followed by the
|
||||
// isPlayerExpanded onChange); a second one would clean up twice
|
||||
guard !isHiding else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
isHiding = true
|
||||
// Start collapsing animation
|
||||
appEnvironment?.navigationCoordinator.isPlayerCollapsing = true
|
||||
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] hide: isPlayerWindowVisible=false, isPlayerCollapsing=true")
|
||||
|
||||
let cleanup: () -> Void = { [weak self] in
|
||||
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] hide: cleanup")
|
||||
|
||||
self?.isHiding = false
|
||||
|
||||
// Always ensure main window transform is fully reset (safeguard against race conditions)
|
||||
self?.resetMainWindowImmediate()
|
||||
|
||||
@@ -1092,3 +1327,29 @@ final class ExpandedPlayerWindowManager {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Runs a closure once per display frame until stopped.
|
||||
@MainActor
|
||||
private final class DisplayLinkTicker: NSObject {
|
||||
private var displayLink: CADisplayLink?
|
||||
private var tick: (() -> Void)?
|
||||
|
||||
func start(_ tick: @escaping () -> Void) {
|
||||
self.tick = tick
|
||||
let link = CADisplayLink(target: self, selector: #selector(fire))
|
||||
link.add(to: .main, forMode: .common)
|
||||
displayLink = link
|
||||
}
|
||||
|
||||
func stop() {
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
tick = nil
|
||||
}
|
||||
|
||||
@objc private func fire() {
|
||||
tick?()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -52,6 +52,13 @@ struct MPVRenderViewRepresentable: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted (synchronously, object: the player view) when a container adopts
|
||||
/// the shared player view — lets the expanded player hide its window in the
|
||||
/// same pass the mini player takes the video over.
|
||||
static let mpvPlayerViewDidChangeContainer = Notification.Name("mpvPlayerViewDidChangeContainer")
|
||||
}
|
||||
|
||||
/// Container view that properly manages player view swapping
|
||||
private class MPVContainerView: UIView {
|
||||
private weak var currentPlayerView: UIView?
|
||||
@@ -81,7 +88,7 @@ private class MPVContainerView: UIView {
|
||||
if container !== self && container.window != nil {
|
||||
MPVLogging.log("MPVContainerView deinit: transferring player view to surviving container",
|
||||
details: "from:\(containerID.uuidString.prefix(8)) to:\(container.containerID.uuidString.prefix(8))")
|
||||
container.setPlayerView(playerView)
|
||||
container.setPlayerView(playerView, force: true)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -99,7 +106,19 @@ private class MPVContainerView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
func setPlayerView(_ playerView: UIView) {
|
||||
/// A player view waiting for this container's first layout (see setPlayerView)
|
||||
private weak var pendingPlayerView: UIView?
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
if let playerView = pendingPlayerView, !bounds.isEmpty {
|
||||
pendingPlayerView = nil
|
||||
setPlayerView(playerView, force: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// - Parameter force: adopt now even while this container is still unsized.
|
||||
func setPlayerView(_ playerView: UIView, force: Bool = false) {
|
||||
// Skip only if same view AND actually our subview
|
||||
// (weak ref can point to a view that's been stolen by another container)
|
||||
if playerView === currentPlayerView && playerView.superview === self {
|
||||
@@ -107,6 +126,17 @@ private class MPVContainerView: UIView {
|
||||
return
|
||||
}
|
||||
|
||||
// A freshly made container is zero-sized until SwiftUI lays it out.
|
||||
// Taking a view that's on screen elsewhere now would show it unsized
|
||||
// (blank) there and leave a black hole where it was, so wait for the
|
||||
// first layout, where it is sized and redrawn in the same frame.
|
||||
if !force && bounds.isEmpty && playerView.window != nil {
|
||||
pendingPlayerView = playerView
|
||||
setNeedsLayout()
|
||||
return
|
||||
}
|
||||
pendingPlayerView = nil
|
||||
|
||||
// Check if player view is in another container
|
||||
let isInAnotherContainer = playerView.superview is MPVContainerView && playerView.superview !== self
|
||||
|
||||
@@ -128,8 +158,12 @@ private class MPVContainerView: UIView {
|
||||
playerView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
playerView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||
])
|
||||
|
||||
currentPlayerView = playerView
|
||||
// Size it (and re-render its framebuffer) before anyone reacts to the move
|
||||
if !bounds.isEmpty {
|
||||
layoutIfNeeded()
|
||||
}
|
||||
NotificationCenter.default.post(name: .mpvPlayerViewDidChangeContainer, object: playerView)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ struct MiniPlayerView: View {
|
||||
return isVideoReady && !isPlayerActive
|
||||
}
|
||||
|
||||
/// Whether the thumbnail is hidden: live video shows instead, or the
|
||||
/// expanded player is morphing its video into this preview.
|
||||
private var isThumbnailHidden: Bool {
|
||||
if navigationCoordinator?.isPlayerMorphingToMini == true { return true }
|
||||
return shouldShowVideoPreview && !isPiPActive
|
||||
}
|
||||
|
||||
/// Whether we should include the MPVRenderView in the hierarchy (even if hidden)
|
||||
/// During collapse, we want the view mounted so it can receive the player view immediately
|
||||
private var shouldMountVideoView: Bool {
|
||||
@@ -56,7 +63,9 @@ struct MiniPlayerView: View {
|
||||
guard miniPlayerSettings.showVideo else { return false }
|
||||
// Mount when: not expanding AND (collapsing OR should show preview)
|
||||
// This ensures the container exists during collapse to receive the player view
|
||||
return !nav.isPlayerExpanding && (nav.isPlayerCollapsing || shouldShowVideoPreview)
|
||||
// (not while still expanded: the mini player stays mounted under the
|
||||
// player window, and a slide-off dismiss collapses before un-expanding)
|
||||
return !nav.isPlayerExpanding && ((nav.isPlayerCollapsing && !nav.isPlayerExpanded) || shouldShowVideoPreview)
|
||||
}
|
||||
|
||||
/// The title to display, preferring DeArrow title if available.
|
||||
@@ -176,6 +185,7 @@ struct MiniPlayerView: View {
|
||||
videoPreviewView
|
||||
.frame(width: 64, height: 36)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.reportsMiniPlayerVideoFrame(cornerRadius: 6, navigationCoordinator: navigationCoordinator)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
handleVideoPreviewTap()
|
||||
@@ -233,6 +243,7 @@ struct MiniPlayerView: View {
|
||||
videoPreviewView
|
||||
.frame(width: capsulePreviewSize.width, height: capsulePreviewSize.height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.reportsMiniPlayerVideoFrame(cornerRadius: 4, navigationCoordinator: navigationCoordinator)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
handleVideoPreviewTap()
|
||||
@@ -340,7 +351,7 @@ struct MiniPlayerView: View {
|
||||
// Thumbnail layer - on top so it can cover the black MPV view during PiP
|
||||
// Shown when video not ready, during expand animation, or during PiP
|
||||
thumbnailView
|
||||
.opacity(shouldShowVideoPreview && !isPiPActive ? 0 : 1)
|
||||
.opacity(isThumbnailHidden ? 0 : 1)
|
||||
.animation(.easeInOut(duration: 0.15), value: shouldShowVideoPreview)
|
||||
.animation(.easeInOut(duration: 0.25), value: isPiPActive)
|
||||
.allowsHitTesting(false)
|
||||
@@ -662,6 +673,28 @@ struct MiniPlayerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Frame Reporting
|
||||
|
||||
private extension View {
|
||||
/// Publishes the video preview's frame as the expanded player's dismiss
|
||||
/// morph target (iOS only; macOS's player is a separate window or overlay).
|
||||
@ViewBuilder
|
||||
func reportsMiniPlayerVideoFrame(cornerRadius: CGFloat, navigationCoordinator: NavigationCoordinator?) -> some View {
|
||||
#if os(iOS)
|
||||
onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
|
||||
guard let navigationCoordinator, navigationCoordinator.miniPlayerVideoFrame != frame else { return }
|
||||
navigationCoordinator.miniPlayerVideoFrame = frame
|
||||
navigationCoordinator.miniPlayerVideoCornerRadius = cornerRadius
|
||||
}
|
||||
.onDisappear {
|
||||
navigationCoordinator?.miniPlayerVideoFrame = .zero
|
||||
}
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Context Menu Modifier
|
||||
|
||||
/// A view modifier that conditionally applies the video context menu.
|
||||
|
||||
Reference in New Issue
Block a user