Zum Inhalt

apple/PhysarumApp/Sources/TV/TVPlayerView.swift

#if os(tvOS)
import SwiftUI
import PhysarumEngine

/// Full-bleed render + Siri-Remote control + the 30 Hz transition tick. Pauses the MTKView
/// when the scene is inactive/background (tvOS rejects GPU work in the background).
struct TVPlayerView: View {
    let appState: AppState
    let controller: AmbientController

    @Environment(\.scenePhase) private var scenePhase
    @FocusState private var focused: Bool
    @State private var lastTick = Date()

    private let ticker = Timer.publish(every: 1.0 / 30.0, on: .main, in: .common).autoconnect()

    var body: some View {
        ZStack {
            // The player layer owns directional/play-pause input — but only while the
            // overlay is hidden. When the overlay is up it yields focus (focusable=false)
            // so the focus engine moves into the carousel; because the carousel is a
            // ZStack *sibling* (not a descendant), this view's onMoveCommand no longer
            // swallows the swipes the carousel needs to navigate.
            MetalView(
                initialField: FieldSize(width: 1280, height: 720),
                selectedPreset: appState.selectedPreset,
                editedParams: appState.edited,
                cover: true,
                format: .auto,
                tier: .tv,
                paused: scenePhase != .active,
                remoteAudioFactorsSource: { [weak controller] in controller?.remoteAudio }   // T2b: phone-streamed mic reactivity
            )
            .ignoresSafeArea()   // render stays full-bleed; HUD/Hint (below) keep the safe area
            .focusable(!controller.overlayVisible)
            .focused($focused)
            .onMoveCommand { dir in
                guard !controller.overlayVisible else { return }
                if let intent = remoteMoveIntent(dir) { withAnimation(.easeInOut) { controller.apply(intent) } }
            }
            .onPlayPauseCommand { if !controller.overlayVisible { controller.apply(.togglePlaylist) } }
            // Click (select) opens the focus-driven controls overlay — the reliable, discrete way
            // in (no swipe imprecision). The dedicated Play/Pause button toggles the cycle.
            .onTapGesture { if !controller.overlayVisible { withAnimation { controller.apply(.showOverlay) } } }
            // VoiceOver: announce the render as the focused element (mirrors the iOS canvas).
            .accessibilityLabel(A11y.canvasLabel)
            .accessibilityValue(A11y.canvasValue(presetName: appState.selectedPreset.name))
            .accessibilityAddTraits(.updatesFrequently)

            // Crossfade-to-black: dims the render while the preset re-seeds at full black.
            // Below the overlay/HUD/hint so only the visual is blacked, not the controls.
            Color.black
                .opacity(controller.transitionDim)
                .ignoresSafeArea()
                .allowsHitTesting(false)

            if controller.overlayVisible {
                TVOverlayView(appState: appState, controller: controller)
                    .transition(.move(edge: .bottom).combined(with: .opacity))
            }

            TVHUDView(controller: controller)
            TVHintView(controller: controller)
        }
        // Menu dismisses the overlay when up; nil lets the system exit to Home otherwise
        // (App Review 2.4.3). On the ZStack so it fires whichever child holds focus.
        .onExitCommand(perform: controller.overlayVisible ? { withAnimation { controller.apply(.dismissOverlay) } } : nil)
        .onAppear { focused = true; lastTick = Date() }
        .onChange(of: controller.overlayVisible) { _, visible in
            if !visible { focused = true }   // return focus to the player when the overlay closes
        }
        .onChange(of: scenePhase) { _, phase in
            // Re-assert on resume: the idle-timer flag can reset across a background cycle on tvOS.
            if phase == .active { UIApplication.shared.isIdleTimerDisabled = true }
            else { appState.flushSync() }   // push pending slot changes to iCloud-KVS on background
        }
        .onReceive(ticker) { now in
            let dt = now.timeIntervalSince(lastTick)
            lastTick = now
            controller.tick(min(dt, 0.1))   // clamp scheduling hitches
        }
    }
}
#endif