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 selfMuted = false var selfDeafened = false var serverMuted = false var serverDeafened = false var inputMode: VoiceCatInputMode = .voiceActivation var vadThreshold: Float = 0.025 var level: Float = 0.0 var currentDeviceId: String? var localStreamId: 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] = [] init(client: VoiceCatClient, selfUserId: UInt32, permissions: Permissions) { self.client = client self.selfUserId = selfUserId self.permissions = permissions AudioSessionManager.shared.client = client 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 } } } deinit { MainActor.assumeIsolated { AudioSessionManager.shared.client = nil } } // MARK: - Event dispatch func handleEvent(_ ev: VoiceCatEvent) { switch ev.type { case .channelList: refreshChannels() syncSelfChannel() case .userJoined, .userLeft: 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" messages.append(ChatMessage( timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000), senderName: sender, text: ev.text ?? "", scope: ev.textScope)) case .talkState: let talking = ev.u32a != 0 let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)" addActivity(talking ? "\(who) started talking" : "\(who) stopped talking") case .streamStarted: // A remote user started a stream — ensure the audio session is active so we can // hear them even if we haven't joined voice ourselves. Previously the session was // only activated when the user pressed Join Voice, so remote audio was silent. if ev.userId != selfUserId { do { try AudioSessionManager.shared.ensureSessionActive() } catch { addActivity("Audio session activate failed: \(error)") } } addActivity("Stream started (user \(ev.userId))") case .streamStopped: addActivity("Stream stopped (user \(ev.userId))") case .joinResult: if ev.result == .ok { currentChannelId = ev.channelId addActivity("Joined channel") refreshUsers() } else { addActivity("Join failed: \(ev.result.description)") } 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() 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 startMicStream() { AVAudioApplication.requestRecordPermission { [weak self] granted in DispatchQueue.main.async { guard let self else { return } if granted { self.doStartMicStream() } 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") let (result, streamId) = client.startStream(desc) if result == .ok { voiceState.micActive = true voiceState.localStreamId = streamId // Apply the user's capture channel selection (mono/stereo) from IOSAudioRouter. // The core opens the capture device via miniaudio on the next engine start; // vc_set_capture_channels tells it to open in stereo (2) or mono (1). let channels = IOSAudioRouter.shared.captureChannels.channelCount if channels != 1 { client.setCaptureChannels(streamId: streamId, channels: channels) } } else { addActivity("Failed to start mic: \(result.description)") } } func stopMicStream() { if voiceState.localStreamId != 0 { client.stopStream(voiceState.localStreamId) voiceState.localStreamId = 0 } voiceState.micActive = false voiceState.level = 0 // Do NOT deactivate the AVAudioSession here — the user may still want to hear // remote audio (other people talking). The session is deactivated only when // disconnecting from the server (see AppState.disconnect / .disconnected event). } 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 } func setVadThreshold(_ threshold: Float) { client.setVadThreshold(threshold) voiceState.vadThreshold = threshold } func setPushToTalk(_ active: Bool) { client.setPushToTalk(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(name: String, topic: String) { let info = ChannelEdit(id: 0, parentId: 0, name: name, topic: topic, passwordProtected: false, password: nil, maxUsers: 0, sortOrder: 0, audio: AudioConfig()) client.createChannel(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) } }