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,79 @@
//
// MPVReflectionView.swift
// Yattee
//
// Hosts the backend's mirrored frame layer below the video in the iPhone
// Duo tabletop layout, so the lower half shows a dim "reflection" of the
// upper half like a screen standing on a glossy table.
//
import SwiftUI
#if os(iOS)
import UIKit
/// Shows the mirrored video frames the MPV render view captures while the
/// view is on screen. Sized to the fitted video; place it directly below the
/// hinge line and fade it with `tabletopReflectionOverlay`.
struct MPVReflectionView: UIViewRepresentable {
let backend: MPVBackend
func makeUIView(context: Context) -> ReflectionHostView {
let view = ReflectionHostView()
view.backend = backend
view.attach(backend.reflectionLayer)
backend.setReflectionEnabled(true)
return view
}
func updateUIView(_ uiView: ReflectionHostView, context: Context) {
// The backend's player view can be recreated; re-attach the current layer.
uiView.backend = backend
uiView.attach(backend.reflectionLayer)
backend.setReflectionEnabled(true)
}
static func dismantleUIView(_ uiView: ReflectionHostView, coordinator: ()) {
uiView.backend?.setReflectionEnabled(false)
uiView.attach(nil)
}
final class ReflectionHostView: UIView {
private weak var hostedLayer: CALayer?
weak var backend: MPVBackend?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
func attach(_ newLayer: CALayer?) {
guard newLayer !== hostedLayer else { return }
hostedLayer?.removeFromSuperlayer()
hostedLayer = newLayer
if let newLayer {
newLayer.frame = bounds
layer.addSublayer(newLayer)
}
}
override func layoutSubviews() {
super.layoutSubviews()
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedLayer?.frame = bounds
CATransaction.commit()
}
deinit {
hostedLayer?.removeFromSuperlayer()
}
}
}
#endif