diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index 887461bf..f1659807 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -70,6 +70,7 @@ enum SettingsKey: String, CaseIterable { case inAppOrientationLock case rotateToMatchAspectRatio case preferPortraitBrowsing + case tabletopReflection // Home case homeShortcutOrder diff --git a/Yattee/Core/Settings/SettingsManager+Player.swift b/Yattee/Core/Settings/SettingsManager+Player.swift index b02bee44..30023df9 100644 --- a/Yattee/Core/Settings/SettingsManager+Player.swift +++ b/Yattee/Core/Settings/SettingsManager+Player.swift @@ -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) diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index a23f8987..ab3200d0 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -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 diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 2716b198..3a41e253 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Services/Player/MPV/MPVReflectionLayer.swift b/Yattee/Services/Player/MPV/MPVReflectionLayer.swift new file mode 100644 index 00000000..dc848666 --- /dev/null +++ b/Yattee/Services/Player/MPV/MPVReflectionLayer.swift @@ -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 diff --git a/Yattee/Services/Player/MPV/MPVRenderView.swift b/Yattee/Services/Player/MPV/MPVRenderView.swift index 7299731b..6b0b0ced 100644 --- a/Yattee/Services/Player/MPV/MPVRenderView.swift +++ b/Yattee/Services/Player/MPV/MPVRenderView.swift @@ -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 diff --git a/Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift b/Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift index 50977496..4924355e 100644 --- a/Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift +++ b/Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift @@ -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 diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 1134a2c6..94bc0979 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -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") diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index 4567cd92..d2aa40fc 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -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 diff --git a/Yattee/Views/Player/MPVReflectionView.swift b/Yattee/Views/Player/MPVReflectionView.swift new file mode 100644 index 00000000..eccb9d33 --- /dev/null +++ b/Yattee/Views/Player/MPVReflectionView.swift @@ -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 diff --git a/Yattee/Views/Settings/PlaybackSettingsView.swift b/Yattee/Views/Settings/PlaybackSettingsView.swift index ea314acc..0392772a 100644 --- a/Yattee/Views/Settings/PlaybackSettingsView.swift +++ b/Yattee/Views/Settings/PlaybackSettingsView.swift @@ -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)