Zum Inhalt

apple/PhysarumApp/Sources/Ambient/AmbientController.swift

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
    }
}