Zum Inhalt

apple/PhysarumApp/Sources/Player/MetalView.swift

import SwiftUI
import MetalKit
import PhysarumEngine

#if os(macOS)
typealias ViewRepresentable = NSViewRepresentable
typealias PlatformContainer = NSView
#else
typealias ViewRepresentable = UIViewRepresentable
typealias PlatformContainer = UIView
#endif

/// SwiftUI wrapper around an MTKView driven by a RenderCoordinator. The MTKView is
/// pinned to fill a plain container view — a bare MTKView inside a representable keeps
/// its 320×480 default frame instead of filling the SwiftUI layout, which letterboxes
/// the render. With the container it fills the screen; the renderer then cover-crops
/// the square sim into the resulting (non-square) drawable via a viewport.
struct MetalView: ViewRepresentable {
    let initialField: FieldSize
    let selectedPreset: Preset
    let editedParams: PresetParams
    var cover: Bool = false
    var format: SimFormat = .auto
    var tier: FieldTier = .current
    var paused: Bool = false
    var audioInput: AudioInput? = nil
    var audioReactive: AudioReactive = AudioReactive(enabled: false, gain: 1, autoGain: true, routes: [])
    var audioTuning: AudioTuning = AudioTuning()
    var remoteAudioFactorsSource: (() -> AudioFactorFrame?)? = nil   // T2b: tvOS sink for phone-streamed factors
    var snapshotProvider: LiveSnapshotProvider? = nil

    func makeCoordinator() -> RenderCoordinator? {
        RenderCoordinator(initialField: initialField, tier: tier, initialPreset: selectedPreset, live: editedParams)
    }

    private func makeContainer(_ coordinator: RenderCoordinator?) -> PlatformContainer {
        let container = PlatformContainer()
        let mtk = MTKView()
        if let c = coordinator {
            mtk.device = c.device
            mtk.delegate = c
        }
        mtk.colorPixelFormat = .bgra8Unorm
        mtk.preferredFramesPerSecond = 60
        mtk.translatesAutoresizingMaskIntoConstraints = false
        container.addSubview(mtk)
        NSLayoutConstraint.activate([
            mtk.leadingAnchor.constraint(equalTo: container.leadingAnchor),
            mtk.trailingAnchor.constraint(equalTo: container.trailingAnchor),
            mtk.topAnchor.constraint(equalTo: container.topAnchor),
            mtk.bottomAnchor.constraint(equalTo: container.bottomAnchor),
        ])
        return container
    }

    private func sync(_ coordinator: RenderCoordinator?, container: PlatformContainer) {
        // Pausing stops the MTKView display link entirely — no sim, no render,
        // no GPU work while a covering sheet hides the canvas or the scene is
        // inactive. Audio analysis keeps running; factors re-apply on resume.
        if let mtk = container.subviews.first as? MTKView {
            if mtk.isPaused != paused {
                mtk.isPaused = paused
                #if os(tvOS)
                // Finish in-flight GPU work before tvOS suspends the app — pausing the
                // display link alone doesn't drain the up-to-3 frames already submitted.
                if paused { coordinator?.drain() }
                #endif
            }
            #if os(iOS)
            // UIKit resets contentScaleFactor to the screen's native scale on
            // window attach, so re-cap on every sync (idempotent, cheap).
            let capped = cappedDrawableScale(mtk.contentScaleFactor, tier: tier)
            if mtk.contentScaleFactor != capped { mtk.contentScaleFactor = capped }
            #endif
        }
        guard let c = coordinator else { return }
        snapshotProvider?.coordinator = c
        c.cover = cover
        c.format = format
        c.audioInput = audioInput
        c.audioReactive = audioReactive
        c.audioTuning = audioTuning
        c.remoteAudioFactorsSource = remoteAudioFactorsSource
        c.switchPreset(selectedPreset, live: editedParams)   // re-seed on preset change
        c.setParams(editedParams)                            // live edits + debounced respawn
    }

#if os(macOS)
    func makeNSView(context: Context) -> NSView { makeContainer(context.coordinator) }
    func updateNSView(_ nsView: NSView, context: Context) { sync(context.coordinator, container: nsView) }
#else
    func makeUIView(context: Context) -> UIView { makeContainer(context.coordinator) }
    func updateUIView(_ uiView: UIView, context: Context) { sync(context.coordinator, container: uiView) }
#endif
}