mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +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:
@@ -70,6 +70,7 @@ enum SettingsKey: String, CaseIterable {
|
||||
case inAppOrientationLock
|
||||
case rotateToMatchAspectRatio
|
||||
case preferPortraitBrowsing
|
||||
case tabletopReflection
|
||||
|
||||
// Home
|
||||
case homeShortcutOrder
|
||||
|
||||
@@ -65,6 +65,19 @@ extension SettingsManager {
|
||||
set(newValue, for: .preferPortraitBrowsing)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the iPhone Duo tabletop layout mirrors the video into the lower
|
||||
/// half as a dim reflection below the hinge. Default is `true`.
|
||||
var tabletopReflection: Bool {
|
||||
get {
|
||||
if let cached = _tabletopReflection { return cached }
|
||||
return bool(for: .tabletopReflection, default: true)
|
||||
}
|
||||
set {
|
||||
_tabletopReflection = newValue
|
||||
set(newValue, for: .tabletopReflection)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
@@ -78,6 +78,7 @@ final class SettingsManager {
|
||||
var _inAppOrientationLock: Bool?
|
||||
var _rotateToMatchAspectRatio: Bool?
|
||||
var _preferPortraitBrowsing: Bool?
|
||||
var _tabletopReflection: Bool?
|
||||
#endif
|
||||
#if os(macOS)
|
||||
var _macPlayerSeparateWindow: Bool?
|
||||
@@ -465,6 +466,7 @@ final class SettingsManager {
|
||||
_inAppOrientationLock = nil
|
||||
_rotateToMatchAspectRatio = nil
|
||||
_preferPortraitBrowsing = nil
|
||||
_tabletopReflection = nil
|
||||
#endif
|
||||
_iCloudSyncEnabled = nil
|
||||
_lastSyncTime = nil
|
||||
|
||||
@@ -13532,6 +13532,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.playback.foldable.header" : {
|
||||
"comment" : "Header for foldable iPhone player settings section",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Foldable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.playback.foldable.tabletopReflection" : {
|
||||
"comment" : "Toggle for mirroring the video as a reflection below the hinge in the tabletop layout",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Reflection Below Video"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.playback.foldable.tabletopReflection.footer" : {
|
||||
"comment" : "Footer explaining the tabletop reflection option",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "When the phone stands half-open like a laptop, the lower half shows a dim mirrored copy of the video."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.playback.macOS.autoResizePlayer" : {
|
||||
"comment" : "Toggle for automatically resizing player window to match video aspect ratio",
|
||||
"localizations" : {
|
||||
|
||||
52
Yattee/Services/Player/MPV/MPVReflectionLayer.swift
Normal file
52
Yattee/Services/Player/MPV/MPVReflectionLayer.swift
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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