import SwiftUI
import Network
import PhysarumEngine
/// The phone side: owns the link to the ATV and forwards the local editing session — selection and
/// intents immediately, edited params throttled (~20 Hz via `RemoteSendGate`). It is the *source*,
/// so it ignores inbound messages; it only receives to keep the connection healthy. `@Observable` so
/// the UI can reflect `state`. The view drives it from `AppState` change hooks (`onChange`).
@Observable
final class RemoteController {
enum ConnectionState: Equatable { case disconnected, connecting, connected, failed }
private(set) var state: ConnectionState = .disconnected
private var link: RemoteLink?
private var gate = RemoteSendGate(minParamInterval: 0.05)
private var audioGate = RemoteSendGate(minParamInterval: 0.030) // ≈33 Hz cap; the 30 Hz view timer paces it
private var streamAutoGain = AutoGainState() // own loudness follower for the streamed factors
/// Monotonic clock for the throttle (injectable for completeness; the gate itself is host-tested).
private let now: () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }
/// Dial the chosen ATV and, once ready, send the full current session so the ATV mirrors the phone.
func connect(_ connection: NWConnection, initialState: RemoteState) {
link?.cancel()
state = .connecting
let link = RemoteLink(connection: connection)
link.onStateChange = { [weak self] nwState in
guard let self else { return }
switch nwState {
case .ready:
self.state = .connected
self.link?.send(.hello(initialState))
case .failed:
self.state = .failed
self.link = nil
case .cancelled:
self.state = .disconnected
self.link = nil
default:
break
}
}
link.onMessage = { _ in } // the phone is the source; ignore inbound
self.link = link
link.start()
}
func disconnect() {
link?.cancel()
link = nil
state = .disconnected
}
/// A preset selection (or its live params) — sent as a full `hello` so the ATV crossfades to the
/// new structure AND adopts the phone's current params (WYSIWYG), not just the preset baseline.
func sendSelection(id: String, params: PresetParams, cycling: Bool) {
guard state == .connected else { return }
link?.send(.hello(.init(selectedID: id, params: params, cycling: cycling)))
}
func sendIntent(_ intent: ControlIntent) {
guard state == .connected else { return }
link?.send(.intent(intent))
}
/// A live param edit — throttled so a slider drag doesn't flood the LAN.
func sendParams(_ params: PresetParams) {
guard state == .connected, gate.allowParams(at: now()) else { return }
link?.send(.params(params))
}
/// Stream the current mic reactivity to the ATV as the 10 audio factors (throttled, ~30 Hz). The
/// factors are recomputed here via the pure `evaluateAudioFactors` (decoupled from the phone's own
/// render loop) using a private autoGain follower, so the ATV reacts with the phone's full feel.
func sendAudioFactors(bands: AudioBands, reactive: AudioReactive, tuning: AudioTuning) {
guard state == .connected, audioGate.allowParams(at: now()) else { return }
// evaluateAudioFactors mutates the loudness EMA in `streamAutoGain`; called on main (the 30 Hz
// view timer), and the only other call site (RenderCoordinator.applyAudio) is also main-only.
let f = evaluateAudioFactors(bands: bands, reactive: reactive, autoGain: &streamAutoGain, tuning: tuning)
link?.send(.audioFactors(AudioFactorFrame(f)))
}
/// One neutral frame on a clean stop (audio toggled off) so the ATV relaxes immediately rather than
/// holding the last beat until its staleness timer fires.
func sendAudioFactorsNeutral() {
guard state == .connected else { return }
link?.send(.audioFactors(.neutral))
}
}