diff --git a/Yattee/Services/Player/MPV/MPVPiPBridge.swift b/Yattee/Services/Player/MPV/MPVPiPBridge.swift index b0ed059f..9d724737 100644 --- a/Yattee/Services/Player/MPV/MPVPiPBridge.swift +++ b/Yattee/Services/Player/MPV/MPVPiPBridge.swift @@ -222,21 +222,40 @@ final class MPVPiPBridge: NSObject { /// - Parameter aspectRatio: Video width divided by height (e.g., 16/9 = 1.777...) func updateVideoAspectRatio(_ aspectRatio: CGFloat) { guard aspectRatio > 0 else { return } + #if os(macOS) + let previousAspectRatio = videoAspectRatio + #endif videoAspectRatio = aspectRatio #if os(macOS) - // On macOS, update layer bounds to match aspect ratio - // This helps AVKit size the PiP window correctly - let currentBounds = sampleBufferLayer.bounds - let newHeight = currentBounds.width / aspectRatio - let newBounds = CGRect(x: 0, y: 0, width: currentBounds.width, height: newHeight) + if isPiPActive { + // Video changed while PiP is active. AVKit sizes the PiP window from + // the format of the initially enqueued frames and doesn't resize it + // when frames with new dimensions arrive, leaving empty space around + // the new video. Flush stale frames, force a fresh format description + // so AVKit re-reads the new dimensions, and resize the PiP window to + // the new aspect ratio ourselves. + let ratioChange = abs(aspectRatio - previousAspectRatio) / previousAspectRatio + if ratioChange > 0.01 { + sampleBufferLayer.sampleBufferRenderer.flush() + currentFormatDescription = nil + resizePiPWindow(toAspectRatio: aspectRatio) + LoggingService.shared.debug("MPVPiPBridge: Aspect ratio changed during PiP \(previousAspectRatio) -> \(aspectRatio), flushed buffer and resized PiP window", category: .mpv) + } + } else { + // On macOS, update layer bounds to match aspect ratio + // This helps AVKit size the PiP window correctly + let currentBounds = sampleBufferLayer.bounds + let newHeight = currentBounds.width / aspectRatio + let newBounds = CGRect(x: 0, y: 0, width: currentBounds.width, height: newHeight) - CATransaction.begin() - CATransaction.setDisableActions(true) - sampleBufferLayer.bounds = newBounds - CATransaction.commit() + CATransaction.begin() + CATransaction.setDisableActions(true) + sampleBufferLayer.bounds = newBounds + CATransaction.commit() - LoggingService.shared.debug("MPVPiPBridge: Updated aspect ratio to \(aspectRatio), layer bounds: \(newBounds)", category: .mpv) + LoggingService.shared.debug("MPVPiPBridge: Updated aspect ratio to \(aspectRatio), layer bounds: \(newBounds)", category: .mpv) + } #else // On iOS, don't modify bounds when PiP is inactive - this causes frame misalignment // (negative Y offset) which breaks the system's PiP restore UI positioning. @@ -857,6 +876,29 @@ extension MPVPiPBridge { return nil } + /// Resize the PiP window to match a new video aspect ratio, keeping the + /// current width and top edge. AVKit doesn't resize the window itself when + /// the enqueued frame dimensions change (e.g. switching videos during PiP). + func resizePiPWindow(toAspectRatio aspectRatio: CGFloat) { + guard aspectRatio > 0, let pipWindow = findPiPWindow() else { + LoggingService.shared.debug("MPVPiPBridge: resizePiPWindow - PiP window not found", category: .mpv) + return + } + + let contentRect = pipWindow.contentRect(forFrameRect: pipWindow.frame) + guard contentRect.width > 0 else { return } + let newContentHeight = contentRect.width / aspectRatio + let heightDelta = newContentHeight - contentRect.height + guard abs(heightDelta) >= 1 else { return } + + var frame = pipWindow.frame + frame.size.height += heightDelta + frame.origin.y -= heightDelta // keep the top edge in place + pipWindow.setFrame(frame, display: true, animate: false) + + LoggingService.shared.debug("MPVPiPBridge: Resized PiP window for aspect \(aspectRatio): \(frame)", category: .mpv) + } + /// Recursively log view hierarchy for debugging private func logViewHierarchy(_ view: NSView, depth: Int) { let indent = String(repeating: " ", count: depth) diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 421c64b4..27ba16eb 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -127,6 +127,8 @@ final class MPVBackend: PlayerBackend { // Video dimensions for aspect ratio detection private var videoWidth: Int = 0 private var videoHeight: Int = 0 + // Coalesces separate width/height property events into one PiP size update + private var pipVideoSizeUpdateTask: Task? // Cached video FPS to avoid sync fetch on main thread private var containerFps: Double = 0 // Cached cache state to avoid sync fetch on main thread @@ -1732,17 +1734,28 @@ extension MPVBackend: MPVClientDelegate { LoggingService.shared.debug("MPV: Video size detected: \(videoWidth)x\(videoHeight)", category: .mpv) delegate?.backend(self, didUpdateVideoSize: videoWidth, height: videoHeight) - // Update PiP bridge with video aspect ratio for proper window sizing + // Update PiP capture dimensions and aspect ratio, coalesced. + // Width and height arrive as separate MPV property events and the old + // values are kept across video switches, so right after a switch one of + // them can still be stale (e.g. new width paired with old height). + // Debounce so capture buffers and the PiP window never see that + // transient mixed size. #if os(iOS) || os(macOS) - let aspectRatio = CGFloat(videoWidth) / CGFloat(videoHeight) - pipBridge?.updateVideoAspectRatio(aspectRatio) - #endif - - // Update render view with video content dimensions for accurate PiP capture - // (avoids capturing letterbox/pillarbox black bars) - #if os(iOS) || os(macOS) - renderView?.videoContentWidth = videoWidth - renderView?.videoContentHeight = videoHeight + pipVideoSizeUpdateTask?.cancel() + pipVideoSizeUpdateTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(100)) + guard let self, !Task.isCancelled else { return } + let width = self.videoWidth + let height = self.videoHeight + guard width > 0, height > 0 else { return } + // Content dimensions must update before the aspect ratio: on macOS + // the aspect update flushes the PiP sample buffer during active PiP, + // and a stale-sized frame captured afterwards would make AVKit + // re-read the old dimensions. + self.renderView?.videoContentWidth = width + self.renderView?.videoContentHeight = height + self.pipBridge?.updateVideoAspectRatio(CGFloat(width) / CGFloat(height)) + } #endif // Update render view with video FPS for display link frame rate matching