import Foundation import AVFoundation import VoiceCatCore // MARK: - Helper types struct ChatMessage: Identifiable { let id = UUID() let timestamp: Date let senderName: String let text: String let scope: VoiceCatTextScope } struct ActivityEntry: Identifiable { let id = UUID() let timestamp: Date let text: String } struct VoiceState { var micActive = false var voiceSubscribed = false var selfMuted = false var selfDeafened = false var serverMuted = false var serverDeafened = false var inputMode: VoiceCatInputMode = .voiceActivation var vadThreshold: Float = 0.025 var inputGain: Float = 1.0 var inputNoiseReduction: Bool = false var level: Float = 0.0 var currentDeviceId: String? var localStreamId: UInt32 = 0 var screenSharing = false var screenStreamId: UInt32 = 0 } // MARK: - SessionState @Observable @MainActor final class SessionState { let client: VoiceCatClient let selfUserId: UInt32 var channels: [Channel] = [] var users: [User] = [] var currentChannelId: UInt32 = 0 var messages: [ChatMessage] = [] var activityLog: [ActivityEntry] = [] var voiceState = VoiceState() var permissions: Permissions var accounts: [Account] = [] var devices: [Device] = [] /// Back-reference to the app state. Once `SessionState.init` overwrites `client.onEvent` /// (`SessionState.swift:87`), `AppState.handleConnectEvent` no longer receives per-session /// events — so the `.disconnected` event for a LIVE session arrives here in `handleEvent`, /// not in AppState. This weak ref lets us hand the disconnect back to AppState (which owns /// the reconnect state machine) so the auto-reconnect fires. Set by AppState on auth success. weak var appState: AppState? // MARK: - Reconnect restore state // // When the iOS client auto-reconnects after a network drop, AppState captures the prior // session's channel + voice/mic state and asks the new SessionState (created on auth success) // to restore it. We rejoin the channel explicitly (the server auto-placed us in Lobby on // auth), and on the resulting `.joinResult` we re-arm voice subscription + mute/deafen. The // drive is here, not in AppState, because once SessionState is created it owns // `client.onEvent` and AppState no longer sees per-session events. private struct RestoreRequest { let channelId: UInt32 let voiceSubscribed: Bool let micMuted: Bool let deafened: Bool } private var pendingRestore: RestoreRequest? private var didIssueRestoreJoin = false /// Host side of iOS screen-audio sharing — drains the broadcast extension's App Group ring /// and feeds the SCREEN_AUDIO stream this session owns. See BroadcastAudioPump. private let broadcastPump = BroadcastAudioPump() init(client: VoiceCatClient, selfUserId: UInt32, permissions: Permissions) { self.client = client self.selfUserId = selfUserId self.permissions = permissions loadAndApplyVoiceSettings() refreshChannels() refreshUsers() syncSelfChannel() refreshDevices() client.onEvent = { [weak self] ev in Task { @MainActor [weak self] in self?.handleEvent(ev) } } client.onLevel = { [weak self] _, rms in Task { @MainActor [weak self] in self?.voiceState.level = rms } } broadcastPump.onBroadcastStarted = { [weak self] in self?.startScreenShare() } broadcastPump.onBroadcastFinished = { [weak self] in self?.stopScreenShare() } broadcastPump.start() } deinit { broadcastPump.stop() } // MARK: - Event dispatch func handleEvent(_ ev: VoiceCatEvent) { switch ev.type { case .channelList: refreshChannels() syncSelfChannel() case .userJoined: // ev.text = nickname, ev.channelId = the channel they joined (per voicecat.h). if ev.userId != selfUserId && ev.channelId == currentChannelId { EventFeedback.shared.play(.channelJoin) EventFeedback.shared.speak("\(ev.text ?? "Someone") joined") } refreshUsers() syncSelfChannel() case .userLeft: // Capture the leaving user's prior nickname/channel before refreshUsers() drops them. if ev.userId != selfUserId, let gone = users.first(where: { $0.id == ev.userId }), gone.channelId == currentChannelId { EventFeedback.shared.play(.channelLeave) EventFeedback.shared.speak("\(gone.nickname) left") } refreshUsers() syncSelfChannel() case .userUpdated: refreshUsers() syncSelfChannel() if let me = users.first(where: { $0.id == selfUserId }) { applyServerMuteState(muted: me.serverMuted, deafened: me.serverDeafened) } case .textMessage: let sender = users.first(where: { $0.id == ev.userId })?.nickname ?? "Unknown" let body = ev.text ?? "" let isSelf = ev.userId == selfUserId let isPrivate = ev.textScope == .private messages.append(ChatMessage( timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000), senderName: sender, text: body, scope: ev.textScope)) EventFeedback.shared.play(isPrivate ? (isSelf ? .pmSent : .pmRecv) : (isSelf ? .channelSent : .channelRecv)) if !isSelf { EventFeedback.shared.speak(isPrivate ? "Private message from \(sender): \(body)" : "\(sender): \(body)") } case .talkState: let talking = ev.u32a != 0 if ev.userId == selfUserId { EventFeedback.shared.play(talking ? .vaStart : .vaStop) } let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)" addActivity(talking ? "\(who) started talking" : "\(who) stopped talking") case .streamStarted: // Our own SCREEN_AUDIO stream is live — begin draining the broadcast ring into it, // in the stream's effective channel mode (downmix to mono if the channel is mono). if ev.userId == selfUserId && ev.streamId == voiceState.screenStreamId { let sid = voiceState.screenStreamId let (r, cfg) = client.getStreamAudioConfig(userId: selfUserId, streamId: sid) let channels: UInt32 = (r == .ok && cfg?.stereo == true) ? 2 : 1 let c = client broadcastPump.beginFeeding(streamChannels: channels) { pcm, samples, ch in c.feedPcm(streamId: sid, pcm: pcm, samplesPerChannel: samples, channels: ch) } addActivity("Sharing screen audio (\(channels == 2 ? "stereo" : "mono"))") break } if ev.userId != selfUserId { do { try AudioSessionManager.shared.ensureSessionActive() } catch { addActivity("Audio session activate failed: \(error)") } } AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))") addActivity("Stream started (user \(ev.userId))") case .streamStopped: if ev.userId == selfUserId { if ev.streamId == voiceState.localStreamId { voiceState.localStreamId = 0 voiceState.micActive = false voiceState.level = 0 } } else { addActivity("Stream stopped (user \(ev.userId))") } case .voiceState: let subscribed = ev.u32a != 0 voiceState.voiceSubscribed = subscribed if subscribed { doStartMicStream() } else { voiceState.micActive = false voiceState.level = 0 EventFeedback.shared.play(.voiceOff) } case .joinResult: if ev.result == .ok { currentChannelId = ev.channelId addActivity("Joined channel") refreshUsers() // Reconnect restore: this was our restore-join. Now that the server has // processed the channel move, re-arm voice subscription (if the user was // transmitting before the drop) and re-apply the local mute/deafen state. // The server returns ok even when joining the channel we're already in, so // this fires reliably for the Lobby-too case. if didIssueRestoreJoin, let r = pendingRestore, r.channelId == ev.channelId { didIssueRestoreJoin = false completeRestore() } } else { addActivity("Join failed: \(ev.result.description)") // Restore-join failed (channel was deleted, became password-protected or // full while we were away). Give up on the voice/mute restore cleanly so we // don't leave dangling state or attempt voice without being in a channel. if didIssueRestoreJoin { didIssueRestoreJoin = false pendingRestore = nil } } case .error: addActivity("Error: \(ev.text ?? ev.result.description)") case .genericResult: if ev.result != .ok { addActivity("Operation failed: \(ev.result.description)") } case .accountList: accounts = client.listAccounts() case .disconnected: // Audible cue, then hand the disconnect back to AppState so its reconnect state // machine fires. This is the ONLY way AppState learns a live session dropped — // after auth success, `SessionState.init` overwrites `client.onEvent`, so // `AppState.handleConnectEvent` never sees this event. (Without this callback, a // network drop on a live session would just play the cue and leave the session as a // zombie — the user would have to tap Disconnect manually.) EventFeedback.shared.play(ev.result == .ok ? .logout : .connectionLost) EventFeedback.shared.speak(ev.result == .ok ? "Disconnected" : "Connection lost") appState?.onLiveSessionDisconnected() default: break } } private func addActivity(_ text: String) { activityLog.append(ActivityEntry(timestamp: Date(), text: text)) if activityLog.count > 500 { activityLog.removeFirst() } } // MARK: - Self-channel / server-mute sync /// Sync currentChannelId from the self user's channelId in the user list. Mirrors macOS /// MainWindowController.swift:461,491,522. The server auto-places every authed user into /// the Lobby (channel 1) on connect, but without this sync currentChannelId stays 0 and /// the mic button (gated on currentChannelId == 0) stays permanently dimmed. private func syncSelfChannel() { if let me = users.first(where: { $0.id == selfUserId }) { currentChannelId = me.channelId } } /// Apply server-side mute/deafen state — mirrors macOS MainWindowController.swift:693-700. /// iOS was previously ignoring server mute/deafen entirely. private func applyServerMuteState(muted: Bool, deafened: Bool) { if muted && !voiceState.serverMuted { addActivity("You have been server-muted") } if deafened && !voiceState.serverDeafened { addActivity("You have been server-deafened") } if !muted && voiceState.serverMuted { addActivity("Server mute cleared") } if !deafened && voiceState.serverDeafened { addActivity("Server deafen cleared") } voiceState.serverMuted = muted voiceState.serverDeafened = deafened } // MARK: - Data refresh func refreshChannels() { channels = client.listChannels() } func refreshUsers() { users = client.listUsers() } func refreshDevices() { devices = client.listDevices(.input) } // MARK: - Voice controls func joinChannel(_ channelId: UInt32, password: String = "") { client.joinChannel(channelId, password: password.isEmpty ? nil : password) } func leaveChannel() { client.leaveChannel() currentChannelId = 0 } func joinVoice() { AVAudioApplication.requestRecordPermission { [weak self] granted in DispatchQueue.main.async { guard let self else { return } if granted { let result = self.client.joinVoice() if result != .ok { self.addActivity("Failed to join voice: \(result.description)") } } else { self.addActivity("Microphone permission denied — grant in Settings > Privacy > Microphone") } } } } private func doStartMicStream() { do { try AudioSessionManager.shared.ensureSessionActive() } catch { addActivity("AVAudioSession activate failed: \(error)") return } let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic", externalFeed: true) let (result, streamId) = client.startStream(desc) guard result == .ok else { addActivity("Failed to start mic: \(result.description)") return } voiceState.micActive = true voiceState.localStreamId = streamId EventFeedback.shared.play(.voiceOn) let channels = IOSAudioRouter.shared.captureChannels.channelCount if channels != 1 { client.setCaptureChannels(streamId: streamId, channels: channels) } IOSAudioEngine.shared.startMic(streamId: streamId, channels: channels) } func leaveVoice() { if voiceState.screenStreamId != 0 { stopScreenShare() } client.setPushToTalk(false) IOSAudioEngine.shared.stopMic() client.leaveVoice() } // MARK: - Reconnect restore /// Called by `AppState` after a reconnect's auth success to rejoin the prior channel and /// re-enable the prior voice/mic state. Drives the restore through the `.joinResult` event /// so we re-arm voice only AFTER the server processed the join — joining voice before the /// channel move would be rejected server-side. `micMuted`/`deafened` are the user's LOCAL /// mute/deafen state at the moment of the drop; the server resets those on a fresh auth, so /// we re-push them via `setMute` after the channel is restored. func requestRestore(channelId: UInt32, voiceSubscribed: Bool, micMuted: Bool, deafened: Bool) { pendingRestore = RestoreRequest(channelId: channelId, voiceSubscribed: voiceSubscribed, micMuted: micMuted, deafened: deafened) didIssueRestoreJoin = false if channelId != 0 { // The server auto-placed us in the Lobby on auth; join our prior channel explicitly. // `vc_join_channel` is idempotent server-side (joining the channel you're already in // returns ok), so this is safe even if the prior channel was the Lobby. client.joinChannel(channelId) didIssueRestoreJoin = true } else { // No prior channel — go straight to the voice/mute restore. (voiceSubscribed with // channelId == 0 is contradictory; `completeRestore` further guards on // currentChannelId != 0 before subscribing to voice.) completeRestore() } } /// Finish the restore after the channel is in place (or there was no channel to restore): /// re-subscribe to voice if the user was transmitting, and re-apply the local mute/deafen /// state. Safe to call once per `pendingRestore`; clears it. private func completeRestore() { guard let r = pendingRestore else { return } if r.voiceSubscribed && currentChannelId != 0 { joinVoice() } setMute(r.micMuted, deafened: r.deafened) addActivity("Restored to channel \(currentChannelId)" + (r.voiceSubscribed ? " with voice" : "")) pendingRestore = nil } // MARK: - Screen audio share /// Called when the broadcast extension becomes active. Announces the SCREEN_AUDIO stream; /// feeding begins on the resulting `.streamStarted` event (see handleEvent). The actual /// system-audio capture happens in the ReplayKit upload extension (a separate process). private func startScreenShare() { guard voiceState.screenStreamId == 0 else { return } guard currentChannelId != 0 else { addActivity("Screen audio ignored — join a channel first") return } let (result, streamId) = client.startStream( StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Screen audio")) if result == .ok { voiceState.screenStreamId = streamId voiceState.screenSharing = true addActivity("Screen audio share starting…") } else { addActivity("Failed to start screen audio: \(result.description)") } } /// Called when the broadcast ends (or on disconnect). Stops feeding and the stream. private func stopScreenShare() { broadcastPump.endFeeding() if voiceState.screenStreamId != 0 { client.stopStream(voiceState.screenStreamId) voiceState.screenStreamId = 0 } if voiceState.screenSharing { voiceState.screenSharing = false addActivity("Stopped sharing screen audio") } } func setMute(_ muted: Bool, deafened: Bool) { client.setSelfMute(micMuted: muted, deafened: deafened) voiceState.selfMuted = muted voiceState.selfDeafened = deafened } func setInputMode(_ mode: VoiceCatInputMode) { client.setInputMode(mode) voiceState.inputMode = mode UserDefaults.standard.set(Int(mode.rawValue), forKey: DefaultsKey.inputMode) } func setVadThreshold(_ threshold: Float) { client.setVadThreshold(threshold) voiceState.vadThreshold = threshold UserDefaults.standard.set(threshold, forKey: DefaultsKey.vadThreshold) } func setInputGain(_ gain: Float) { client.setInputGain(gain) voiceState.inputGain = gain UserDefaults.standard.set(gain, forKey: DefaultsKey.inputGain) } func setInputNoiseReduction(_ on: Bool) { client.setInputNoiseReduction(on) voiceState.inputNoiseReduction = on UserDefaults.standard.set(on, forKey: DefaultsKey.inputNoiseReduction) } // MARK: - Persisted input settings private enum DefaultsKey { static let inputMode = "voice.inputMode" static let vadThreshold = "voice.vadThreshold" static let inputGain = "voice.inputGain" static let inputNoiseReduction = "voice.inputNoiseReduction" } /// Restore the saved input mode / VAD threshold / mic gain and push them into the core so a /// relaunch keeps the user's transmission settings instead of resetting to VAD defaults. private func loadAndApplyVoiceSettings() { let d = UserDefaults.standard if d.object(forKey: DefaultsKey.inputMode) != nil { let raw = UInt32(d.integer(forKey: DefaultsKey.inputMode)) voiceState.inputMode = VoiceCatInputMode(rawValue: raw) ?? .voiceActivation } if d.object(forKey: DefaultsKey.vadThreshold) != nil { voiceState.vadThreshold = d.float(forKey: DefaultsKey.vadThreshold) } if d.object(forKey: DefaultsKey.inputGain) != nil { voiceState.inputGain = d.float(forKey: DefaultsKey.inputGain) } if d.object(forKey: DefaultsKey.inputNoiseReduction) != nil { voiceState.inputNoiseReduction = d.bool(forKey: DefaultsKey.inputNoiseReduction) } client.setInputMode(voiceState.inputMode) client.setVadThreshold(voiceState.vadThreshold) client.setInputGain(voiceState.inputGain) client.setInputNoiseReduction(voiceState.inputNoiseReduction) } private var pttEngaged = false func setPushToTalk(_ active: Bool) { client.setPushToTalk(active) // Play the PTT cue only on the press transition (the gesture fires repeatedly while held). if active && !pttEngaged { EventFeedback.shared.play(.ptt) } pttEngaged = active } // MARK: - Text func sendText(_ text: String, scope: VoiceCatTextScope, targetId: UInt32 = 0) { client.sendText(scope: scope, targetId: targetId, text: text) } // MARK: - Admin func kickUser(_ userId: UInt32, reason: String) { client.kickUser(userId, reason: reason.isEmpty ? nil : reason) } func banUser(_ userId: UInt32, reason: String, expiresUnixMs: UInt64) { client.banUser(userId, reason: reason.isEmpty ? nil : reason, expiresUnixMs: expiresUnixMs) } func moveUser(_ userId: UInt32, toChannel channelId: UInt32) { client.moveUser(userId, toChannel: channelId) } func setPermissions(_ userId: UInt32, perms: Permissions) { client.setPermission(userId, perms: perms) } func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) { client.setServerMute(userId, muted: muted, deafened: deafened) } func createChannel(_ info: ChannelEdit) { client.createChannel(info) } func editChannel(_ info: ChannelEdit) { client.editChannel(info) } func deleteChannel(_ channelId: UInt32) { client.deleteChannel(channelId) } func fetchAccountList() { client.requestAccountList() } func createAccount(username: String, password: String) { client.createAccount(username, password: password) } func deleteAccount(username: String) { client.deleteAccount(username) } func resetPassword(username: String, newPassword: String) { client.resetPassword(username, newPassword: newPassword) } }