#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