import SwiftUI
import PhysarumEngine
/// What the transient ambient HUD is currently showing. Owned by `AmbientController`
/// (the intent sink that knows *when* and *what*); the tvOS view only reflects it.
enum HUDContent: Equatable {
case cycle(Bool) // true = now auto-cycling, false = pinned/paused
case preset(String) // display name of the target preset (manual switch only)
}
/// Owns the tvOS ambient playback: which preset is showing, whether the playlist is
/// auto-cycling, and any in-progress preset transition. It is the single sink for
/// `ControlIntent`s (Siri Remote now, phone controller later) and the single driver of
/// `AppState` (the model SSOT the MetalView renders from). It never touches the
/// RenderCoordinator directly — it mutates `AppState` selection exactly like the live
/// editor does, so the existing MetalView → RenderCoordinator path applies everything.
///
/// A preset switch is a **crossfade-to-black**: the visual dims to black (`transitionDim`
/// 0→1), at full black the FULL target preset is adopted via `AppState.select` (a normal
/// re-seed — invisible behind black, so the whole target structure: density / spawnPattern /
/// species-ratio is taken), then the visual fades back in (1→0). `blackHold` keeps it black
/// briefly so the freshly seeded sim warms up before it's revealed.
@Observable
final class AmbientController {
private let appState: AppState
/// The lean-back rotation, derived LIVE from the slot model: the pinned filled slots (nothing
/// pinned → all filled). Computed (not a captured snapshot) so pinning/iCloud changes take
/// effect on the next tick without rebuilding the controller.
private var playlist: [Preset] { appState.slotStore.slots.rotation }
let fadeDuration: Double // each way (out, then in)
let blackHold: Double // warmup time held at full black before fading in
let dwell: Double
private(set) var isCycling: Bool = true
private(set) var overlayVisible: Bool = false
private(set) var hud: HUDContent?
private var hudElapsed: Double = 0
let hudDuration: Double
private(set) var hintVisible: Bool = true
private var hintElapsed: Double = 0
let hintDuration: Double
// `adoptParams` are a remote's live params re-applied right after THIS transition's reseed (the
// reseed resets `edited` to the preset baseline). Binding them to the transition — not a separate
// field — guarantees they only affect their own reseed and vanish when the transition clears, so
// a swipe-back cancel or a mid-flight retarget can never leak them onto an unrelated preset.
private struct Transition { let toID: String; var elapsed: Double; var reseeded: Bool; var adoptParams: PresetParams? = nil }
private var transition: Transition?
private var dwellElapsed: Double = 0
// T2b: the latest phone-streamed audio factors the tvOS renderer applies (nil = neutral). Held here
// (not pushed into the renderer reactively) so the render loop pulls the latest each frame without a
// 30 Hz SwiftUI re-render. Relaxed to neutral on stream drop (staleness) and on disconnect.
private(set) var remoteAudio: AudioFactorFrame?
private var audioSilence: Double = .greatestFiniteMagnitude
let audioStaleAfter: Double
init(appState: AppState, fadeDuration: Double = 0.7, blackHold: Double = 0.3,
dwell: Double = 60.0, hudDuration: Double = 2.0, hintDuration: Double = 5.0,
audioStaleAfter: Double = 0.5) {
self.appState = appState
self.fadeDuration = fadeDuration
self.blackHold = blackHold
self.dwell = dwell
self.hudDuration = hudDuration
self.hintDuration = hintDuration
self.audioStaleAfter = audioStaleAfter
}
/// 0 = clear, 1 = full black. The view drives a black overlay's opacity from this.
var transitionDim: Double {
guard let t = transition else { return 0 }
if t.elapsed < fadeDuration { return t.elapsed / fadeDuration } // fade out
if t.elapsed < fadeDuration + blackHold { return 1 } // hold black
return max(0, 1 - (t.elapsed - fadeDuration - blackHold) / fadeDuration) // fade in
}
/// The rotation index `step` away from where we are now (the in-flight target mid-fade, else the
/// current selection). nil when the rotation is empty. When the current selection isn't in the
/// rotation (a phone jumped to an unpinned slot), enter at the start (forward) / end (backward).
private func rotationStep(_ step: Int) -> Int? {
let count = playlist.count
guard count > 0 else { return nil }
guard let cur = playlist.firstIndex(where: { $0.id == (transition?.toID ?? appState.selectedID) })
else { return step > 0 ? 0 : count - 1 }
return PlaylistDirector.nextIndex(current: cur, count: count, step: step)
}
// MARK: Intent sink (Siri Remote now, phone later — same entry point)
func apply(_ intent: ControlIntent) {
switch intent {
case .next:
if let i = rotationStep(1), beginTransition(toIndex: i) { setHUD(.preset(playlist[i].name)) }
case .previous:
if let i = rotationStep(-1), beginTransition(toIndex: i) { setHUD(.preset(playlist[i].name)) }
case .togglePlaylist:
isCycling.toggle()
dwellElapsed = 0
setHUD(.cycle(isCycling))
case .jumpTo(let id):
// A jump addresses ANY filled slot, not just the pinned rotation — so a phone-remote
// selection of an unpinned slot still crossfades + re-seeds the ATV (WYSIWYG). Only the
// autonomous next/previous/dwell cycling is confined to the rotation.
if let p = appState.slotStore.slots.filled.first(where: { $0.id == id }), beginTransition(to: p) {
setHUD(.preset(p.name))
}
case .showOverlay:
overlayVisible = true
case .dismissOverlay:
overlayVisible = false
}
hintVisible = false // first interaction retires the startup hint
}
private func setHUD(_ content: HUDContent) { hud = content; hudElapsed = 0 }
// MARK: Remote adoption (iPhone/iPad editor-remote — T2a)
/// Adopt a remote editor's full session on connect: crossfade to its preset (so the whole
/// structure re-seeds behind black), preserve its live param edits across that re-seed, and
/// match its cycle state. When the preset is already showing, the params apply live (no fade).
func adoptRemote(selectedID: String, params: PresetParams, cycling: Bool) {
if selectedID != appState.selectedID {
apply(.jumpTo(selectedID))
if transition != nil {
transition?.adoptParams = params // re-applied right after THIS transition's reseed
} else {
appState.edited = params // jumpTo was a no-op (unknown id / empty playlist)
}
} else {
appState.edited = params
}
if cycling != isCycling { apply(.togglePlaylist) }
}
/// A live param edit streamed from the remote. Applies immediately; if a crossfade is in flight,
/// it also updates the pending adopt params so the reseed re-applies THIS (latest) value rather
/// than a stale hello payload — the live edit always wins.
func setLiveParams(_ params: PresetParams) {
appState.edited = params
if transition != nil { transition?.adoptParams = params }
}
/// A frame of phone-streamed mic factors (T2b). Held for the render loop to apply; resets the
/// staleness timer so a steady stream keeps the visuals reacting.
func receiveAudioFactors(_ frame: AudioFactorFrame) {
remoteAudio = frame
audioSilence = 0
}
/// On disconnect the ATV returns to autonomous T1 cycling. Idempotent: a no-op if already cycling.
/// Also drops any streamed audio so the visual relaxes to neutral rather than freezing on the last beat.
func resumeAutonomousCycle() {
remoteAudio = nil
if !isCycling { apply(.togglePlaylist) }
}
// MARK: Time driver — called ~30 Hz by the view
func tick(_ dt: Double) {
if var t = transition {
t.elapsed += dt
// Re-seed on the first tick that reaches full black. `blackHold` must stay > the
// view's max tick dt (0.1) so this tick lands inside the hold-black window and the
// rebuild is never visible (defaults 0.3; tests 0.2).
if !t.reseeded && t.elapsed >= fadeDuration {
appState.select(id: t.toID) // full re-seed at black: density/spawn/species all adopted
if let p = t.adoptParams { appState.edited = p } // remote's live edits land on the fresh seed
t.reseeded = true
}
if t.elapsed >= 2 * fadeDuration + blackHold {
transition = nil
dwellElapsed = 0
} else {
transition = t
}
} else if isCycling {
dwellElapsed += dt
if PlaylistDirector.shouldAdvance(dwellElapsed: dwellElapsed, dwell: dwell), let i = rotationStep(1) {
beginTransition(toIndex: i)
}
}
if hud != nil {
hudElapsed += dt
if hudElapsed >= hudDuration { hud = nil }
}
if hintVisible {
hintElapsed += dt
if hintElapsed >= hintDuration { hintVisible = false }
}
// Stream drop: no frame for `audioStaleAfter` → relax to neutral (never freeze on the last beat).
// We only accumulate while a frame is held; `receiveAudioFactors` zeroes `audioSilence` on every
// fresh frame, so a steady stream keeps resetting this and it only fires once the stream stops.
if remoteAudio != nil {
audioSilence += dt
if audioSilence > audioStaleAfter { remoteAudio = nil }
}
}
// MARK: Internals
/// Begin (or mid-flight retarget) a fade-to-black transition to `playlist[index]`. Returns
/// whether a real switch started (gates the preset-name HUD). No-op when we're already on or
/// heading to that preset. A retarget mid-fade resumes from the current darkness — a second
/// skip never flashes the visual back to full brightness — and a swipe back to the preset we
/// haven't left yet cancels by fading straight back in (no black dropout, no re-seed).
@discardableResult
private func beginTransition(toIndex index: Int) -> Bool {
guard playlist.indices.contains(index) else { return false }
return beginTransition(to: playlist[index])
}
/// Crossfade to a specific preset. Used directly by `jumpTo` (which addresses any filled slot,
/// pinned or not) and indirectly by the rotation stepper via `beginTransition(toIndex:)`.
@discardableResult
private func beginTransition(to target: Preset) -> Bool {
guard target.id != (transition?.toID ?? appState.selectedID) else { return false } // already heading there
if let t = transition, !t.reseeded, target.id == appState.selectedID {
// Swipe back to the preset we're fading away from but haven't actually left yet:
// fade straight back in from the current darkness instead of completing the dropout.
transition = Transition(toID: appState.selectedID,
elapsed: fadeDuration + blackHold + (1 - transitionDim) * fadeDuration,
reseeded: true)
return false
}
// Start fresh (elapsed 0) or, mid-flight, resume on the fade-out branch at the current
// dim so darkness is continuous across a retarget.
let resumeElapsed = transition != nil ? transitionDim * fadeDuration : 0
transition = Transition(toID: target.id, elapsed: resumeElapsed, reseeded: false)
return true
}
}