Reflect the video below the hinge in the tabletop layout

The iPhone Duo tabletop layout mirrors every presented frame into the
lower half, blurred and fading out, like a screen standing on a glossy
table. The render view blits a quarter-resolution copy of the display
framebuffer into a ring of IOSurface-backed buffers (no Y flip, so the
surface is already mirrored) and hands it to a CALayer hosted by
MPVReflectionView; the simulator's software renderer feeds the same
layer a flipped CGImage. Off during PiP, for audio-only streams, and
via the new Playback → Foldable → "Reflection Below Video" setting.
This commit is contained in:
Arkadiusz Fal
2026-10-01 20:59:48 +02:00
parent 2fd52fff27
commit 83895a1fd9
11 changed files with 431 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
//
// MPVReflectionLayer.swift
// Yattee
//
// Layer that shows a vertically mirrored copy of the rendered video frame,
// used for the "reflection" below the video in the iPhone Duo tabletop
// layout. The render view feeds it every presented frame.
//
import Foundation
import QuartzCore
import IOSurface
#if canImport(UIKit)
import UIKit
final class MPVReflectionLayer: CALayer {
override init() {
super.init()
contentsGravity = .resizeAspect
isOpaque = false
// Frame updates must not trigger implicit fades.
actions = ["contents": NSNull(), "bounds": NSNull(), "position": NSNull(), "transform": NSNull()]
}
override init(layer: Any) {
super.init(layer: layer)
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
/// Show a surface whose rows are already mirrored (GL blit without Y flip).
func display(_ surface: IOSurface) {
contents = surface
}
/// Show an upright image; the layer mirrors it itself.
func displayMirrored(_ image: CGImage) {
if CATransform3DIsIdentity(transform) {
transform = CATransform3DMakeScale(1, -1, 1)
}
contents = image
}
func clear() {
contents = nil
}
}
#endif

View File

@@ -176,6 +176,44 @@ final class MPVRenderView: UIView {
/// Scale factor for PiP capture (1.0 = full res, 0.5 = half res)
private let pipCaptureScale: CGFloat = 1.0
// MARK: - Reflection Capture
/// Layer that receives a vertically mirrored, quarter-resolution copy of
/// every presented frame while `isReflectionEnabled` is set. Hosted by
/// `MPVReflectionView` (iPhone Duo tabletop layout); contents are already
/// mirrored, so the host applies no transform.
let reflectionLayer = MPVReflectionLayer()
/// Whether to mirror frames into `reflectionLayer` (set by the backend).
var isReflectionEnabled = false {
didSet {
guard isReflectionEnabled != oldValue, !isReflectionEnabled else { return }
renderQueue.async { [weak self] in
guard let self, let eaglContext else { return }
EAGLContext.setCurrent(eaglContext)
destroyReflectionCapture()
}
reflectionLayer.clear()
}
}
/// Ring of IOSurface-backed targets so CoreAnimation never reads the
/// buffer GL is currently writing.
private var reflectionTargets: [ReflectionTarget] = []
private var reflectionTargetIndex = 0
private var reflectionTextureCache: CVOpenGLESTextureCache?
private var reflectionCaptureWidth = 0
private var reflectionCaptureHeight = 0
/// Downscale factor for the reflection; the low resolution doubles as a cheap blur.
private let reflectionCaptureScale: CGFloat = 0.25
private struct ReflectionTarget {
let pixelBuffer: CVPixelBuffer
let texture: CVOpenGLESTexture
var framebuffer: GLuint
}
// MARK: - Full Resolution Rendering for PiP
/// Offscreen framebuffer for full-resolution rendering when PiP capture is active.
@@ -379,6 +417,7 @@ final class MPVRenderView: UIView {
EAGLContext.setCurrent(eaglContext)
destroyFullResFramebuffer()
destroyPiPCapture()
destroyReflectionCapture()
destroyFramebufferResources()
}
eaglContext = nil
@@ -968,6 +1007,11 @@ final class MPVRenderView: UIView {
width: renderWidth, height: renderHeight, success: ctxSet)
}
// Mirror the presented frame into the reflection layer if enabled
if isReflectionEnabled && mpvHasFrameReady && !isPiPActive {
captureFrameForReflection()
}
// Capture frame for PiP if enabled
if captureFramesForPiP && mpvHasFrameReady {
pipFrameCounter += 1
@@ -1302,6 +1346,118 @@ final class MPVRenderView: UIView {
}
}
// MARK: - Reflection Capture (Zero-Copy)
/// Blit the display framebuffer, downscaled and without the usual Y flip,
/// into the next IOSurface target and hand it to the reflection layer. GL's
/// bottom-left origin lands in the top-left-origin surface upside down,
/// which is exactly the mirrored image a reflection needs.
private func captureFrameForReflection() {
guard EAGLContext.current() === eaglContext, renderWidth > 0, renderHeight > 0 else { return }
setupReflectionCapture(
width: max(1, Int(CGFloat(renderWidth) * reflectionCaptureScale)),
height: max(1, Int(CGFloat(renderHeight) * reflectionCaptureScale))
)
guard !reflectionTargets.isEmpty else { return }
let target = reflectionTargets[reflectionTargetIndex]
reflectionTargetIndex = (reflectionTargetIndex + 1) % reflectionTargets.count
glBindFramebuffer(GLenum(GL_READ_FRAMEBUFFER), framebuffer)
glBindFramebuffer(GLenum(GL_DRAW_FRAMEBUFFER), target.framebuffer)
glBlitFramebuffer(
0, 0, renderWidth, renderHeight,
0, 0, GLint(reflectionCaptureWidth), GLint(reflectionCaptureHeight),
GLbitfield(GL_COLOR_BUFFER_BIT),
GLenum(GL_LINEAR)
)
glBindFramebuffer(GLenum(GL_FRAMEBUFFER), framebuffer)
glFlush()
guard let surface = CVPixelBufferGetIOSurface(target.pixelBuffer)?.takeUnretainedValue() else { return }
let layer = reflectionLayer
DispatchQueue.main.async {
layer.display(surface)
}
}
private func setupReflectionCapture(width: Int, height: Int) {
guard let eaglContext else { return }
if width == reflectionCaptureWidth && height == reflectionCaptureHeight && !reflectionTargets.isEmpty {
return
}
destroyReflectionCapture()
var cache: CVOpenGLESTextureCache?
guard CVOpenGLESTextureCacheCreate(kCFAllocatorDefault, nil, eaglContext, nil, &cache) == kCVReturnSuccess,
let cache else {
LoggingService.shared.warning("MPV Reflection: Failed to create texture cache", category: .mpv)
return
}
reflectionTextureCache = cache
let attributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as [String: Any],
kCVPixelBufferOpenGLESCompatibilityKey as String: true
]
var targets: [ReflectionTarget] = []
for _ in 0..<3 {
var pixelBuffer: CVPixelBuffer?
guard CVPixelBufferCreate(kCFAllocatorDefault, width, height, kCVPixelFormatType_32BGRA,
attributes as CFDictionary, &pixelBuffer) == kCVReturnSuccess,
let pixelBuffer else { break }
var texture: CVOpenGLESTexture?
guard CVOpenGLESTextureCacheCreateTextureFromImage(
kCFAllocatorDefault, cache, pixelBuffer, nil, GLenum(GL_TEXTURE_2D), GL_RGBA,
GLsizei(width), GLsizei(height), GLenum(GL_BGRA), GLenum(GL_UNSIGNED_BYTE), 0, &texture
) == kCVReturnSuccess, let texture else { break }
var fbo: GLuint = 0
glGenFramebuffers(1, &fbo)
glBindFramebuffer(GLenum(GL_FRAMEBUFFER), fbo)
glFramebufferTexture2D(GLenum(GL_FRAMEBUFFER), GLenum(GL_COLOR_ATTACHMENT0), GLenum(GL_TEXTURE_2D),
CVOpenGLESTextureGetName(texture), 0)
guard glCheckFramebufferStatus(GLenum(GL_FRAMEBUFFER)) == GL_FRAMEBUFFER_COMPLETE else {
glDeleteFramebuffers(1, &fbo)
break
}
targets.append(ReflectionTarget(pixelBuffer: pixelBuffer, texture: texture, framebuffer: fbo))
}
glBindFramebuffer(GLenum(GL_FRAMEBUFFER), framebuffer)
guard targets.count == 3 else {
LoggingService.shared.warning("MPV Reflection: Failed to create capture targets (\(targets.count)/3)", category: .mpv)
reflectionTargets = targets
destroyReflectionCapture()
return
}
reflectionTargets = targets
reflectionTargetIndex = 0
reflectionCaptureWidth = width
reflectionCaptureHeight = height
LoggingService.shared.debug("MPV Reflection: capture setup \(width)x\(height)", category: .mpv)
}
/// Must run on the render queue with the context current.
private func destroyReflectionCapture() {
for var target in reflectionTargets where target.framebuffer != 0 {
glDeleteFramebuffers(1, &target.framebuffer)
}
reflectionTargets = []
if let cache = reflectionTextureCache {
CVOpenGLESTextureCacheFlush(cache, 0)
}
reflectionTextureCache = nil
reflectionCaptureWidth = 0
reflectionCaptureHeight = 0
}
}
#endif

View File

@@ -64,6 +64,15 @@ final class MPVSoftwareRenderView: UIView {
/// Callback when view is added to window (for PiP setup)
var onDidMoveToWindow: ((UIView) -> Void)?
/// Mirrored copy of every frame for the tabletop reflection (see `MPVRenderView`).
let reflectionLayer = MPVReflectionLayer()
var isReflectionEnabled = false {
didSet {
if !isReflectionEnabled { reflectionLayer.clear() }
}
}
/// Dedicated queue for rendering operations
private let renderQueue = DispatchQueue(label: "stream.yattee.mpv.software-render", qos: .userInitiated)
@@ -434,6 +443,9 @@ final class MPVSoftwareRenderView: UIView {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.layer.contents = image
if self.isReflectionEnabled {
self.reflectionLayer.displayMirrored(image)
}
}
// Notify on first frame rendered

View File

@@ -176,6 +176,16 @@ final class MPVBackend: PlayerBackend {
/// Set by PlayerService to collapse the player sheet.
var onPiPDidStart: (() -> Void)?
/// Layer mirroring the rendered frames for the tabletop reflection
/// (`MPVReflectionView`). Frames flow only while `setReflectionEnabled(true)`.
var reflectionLayer: CALayer? { _playerView?.reflectionLayer }
func setReflectionEnabled(_ enabled: Bool) {
guard let view = _playerView, view.isReflectionEnabled != enabled else { return }
MPVLogging.log("MPVBackend.setReflectionEnabled", details: "enabled:\(enabled)")
view.isReflectionEnabled = enabled
}
/// Pause video rendering (for smooth panscan animation)
func pauseRendering() {
MPVLogging.log("MPVBackend.pauseRendering called")