mirror of
https://github.com/yattee/yattee.git
synced 2026-11-01 17:37:18 +00:00
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:
@@ -34,6 +34,38 @@ extension ExpandedPlayerSheet {
|
||||
static let tabletopTopBarInset: CGFloat = 72
|
||||
static let tabletopBottomBarInset: CGFloat = 96
|
||||
|
||||
#if os(iOS)
|
||||
/// Reflection strength at the hinge line; it fades to nothing at
|
||||
/// `tabletopReflectionFadeEnd` of the mirrored video height.
|
||||
static let tabletopReflectionOpacity: Double = 0.4
|
||||
static let tabletopReflectionFadeEnd: CGFloat = 0.7
|
||||
/// Softens the mirrored frames so the reflection reads as a glow, not a second video.
|
||||
static let tabletopReflectionBlurRadius: CGFloat = 16
|
||||
|
||||
private var showsTabletopReflection: Bool {
|
||||
guard appEnvironment?.settingsManager.tabletopReflection ?? true,
|
||||
let playerState else { return false }
|
||||
return playerState.isFirstFrameReady && playerState.isBufferReady
|
||||
&& playerState.currentStream?.isAudioOnly != true
|
||||
&& playerState.pipState != .active
|
||||
}
|
||||
|
||||
/// Mirrored video frames sized like the fitted video, fading out downwards.
|
||||
private func tabletopReflection(backend: MPVBackend, width: CGFloat, height: CGFloat) -> some View {
|
||||
MPVReflectionView(backend: backend)
|
||||
.frame(width: width, height: height)
|
||||
.blur(radius: Self.tabletopReflectionBlurRadius)
|
||||
.mask(
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(Self.tabletopReflectionOpacity), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: UnitPoint(x: 0.5, y: Self.tabletopReflectionFadeEnd)
|
||||
)
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Layout Detection
|
||||
|
||||
/// Check if widescreen layout should be used based on available size.
|
||||
@@ -385,6 +417,19 @@ extension ExpandedPlayerSheet {
|
||||
.animation(isPanelDragging ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: videoYOffset)
|
||||
.animation(panelRevealOffset != 0 ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: isPortraitPanelVisible)
|
||||
|
||||
#if os(iOS)
|
||||
// Tabletop: mirror the video across the hinge line into the lower
|
||||
// half, like a screen standing on a glossy table. The mirrored
|
||||
// frames come from the render view; this only places and fades them.
|
||||
if isTabletop, showsTabletopReflection,
|
||||
let backend = playerService?.currentBackend as? MPVBackend {
|
||||
let hingeY = layoutHeight - safeAreaInsets.top
|
||||
tabletopReflection(backend: backend, width: videoWidth, height: videoHeight)
|
||||
.position(x: screenWidth / 2 + fullscreenPlayerXOffset, y: 2 * hingeY - videoY)
|
||||
.transition(.opacity)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Controls layer - positioned based on panel state
|
||||
// When panel visible: positioned to cover from top down to panel
|
||||
// When fullscreen: positioned exactly like playerView so they align
|
||||
|
||||
79
Yattee/Views/Player/MPVReflectionView.swift
Normal file
79
Yattee/Views/Player/MPVReflectionView.swift
Normal 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
|
||||
@@ -23,6 +23,7 @@ struct PlaybackSettingsView: View {
|
||||
QueueSection(settings: settings)
|
||||
#if os(iOS)
|
||||
OrientationSection(settings: settings)
|
||||
FoldableSection(settings: settings)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -362,6 +363,33 @@ private struct QueueSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Foldable Section (iOS)
|
||||
|
||||
#if os(iOS)
|
||||
/// iPhone Duo only: options for the tabletop (laptop-like) player layout.
|
||||
private struct FoldableSection: View {
|
||||
@Bindable var settings: SettingsManager
|
||||
@State private var hasHinge = HingeLayoutObserver.forceTabletop
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if hasHinge {
|
||||
SettingsFormSection(
|
||||
"settings.playback.foldable.header",
|
||||
footer: "settings.playback.foldable.tabletopReflection.footer"
|
||||
) {
|
||||
Toggle(
|
||||
String(localized: "settings.playback.foldable.tabletopReflection"),
|
||||
isOn: $settings.tabletopReflection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.observeHinge(hasHinge: $hasHinge)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Orientation Section (iOS)
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
Reference in New Issue
Block a user