import Foundation import Combine import UIKit import WebRTC /// 鉴权方式(与被控端约定一致) enum AuthType: String, CaseIterable, Identifiable { case none = "NONE" case code = "CODE" case password = "PASSWORD" var id: String { rawValue } var title: String { switch self { case .none: return "免密连接" case .code: return "动态验证码" case .password: return "固定密码" } } } /// 串流模式 enum StreamMode: Int { case webrtc = 0 case selfCodec = 1 } /// 分辨率档位(与 Android 端一致,0 表示原始画质) struct ResolutionOption: Identifiable, Equatable { let id: Int let title: String let width: Int32 let height: Int32 static let all: [ResolutionOption] = [ ResolutionOption(id: 0, title: "原始画质", width: 0, height: 0), ResolutionOption(id: 1, title: "1080P", width: 1920, height: 1080), ResolutionOption(id: 2, title: "720P", width: 1280, height: 720), ResolutionOption(id: 3, title: "480P", width: 854, height: 480) ] } /// 主控端 ViewModel:串联信令、WebRTC、自编码解码与 UI 状态。 /// 所有回调均已在主线程触发,@Published 属性直接驱动 SwiftUI。 final class ControllerViewModel: NSObject, ObservableObject { // MARK: - UI 状态 @Published var serverUrl: String = "wss://www.ttstd.com/signal" @Published var targetDeviceId: String = "" @Published var statusText: String = "未连接" /// 是否显示控制界面(对应 Android 的 setupPanel/controlPanel 切换) @Published var isControlling: Bool = false @Published var isConnecting: Bool = false @Published var statsText: String = "" @Published var streamMode: StreamMode = .webrtc @Published var selectedResolution: ResolutionOption = ResolutionOption.all[0] /// 帧率档位:默认常用档位,收到被控端上报的 supported_fps 后以上报列表为准 @Published var fpsOptions: [Int] = [15, 24, 30, 60] /// 被控端当前采集帧率(0 表示尚未收到上报) @Published var currentFps: Int = 0 /// 远端视频宽高比(宽/高),用于让触控层与画面精确对齐 @Published var videoAspect: CGFloat = 9.0 / 16.0 @Published var showAuthSheet: Bool = false @Published var alertMessage: String? let myDeviceId: String = DeviceUtils.deviceId() // MARK: - UIKit 渲染视图(由 ViewModel 持有,SwiftUI 通过 Representable 嵌入) let remoteVideoView = RTCMTLVideoView() let selfCodecView = SampleBufferDisplayView() // MARK: - 内部组件 private var signalingClient: SignalingClient? private var webRTCClient: WebRTCClient? private let selfCodecDecoder = SelfCodecDecoder() private var statsTimer: Timer? private var connectionStartTime: Date? private var lastBytesReceived: Double = 0 private var lastStatsTime: Date? private var pendingAuthType: AuthType = .none private var pendingAuthValue: String = "" /// 是否为本端主动切换串流模式(避免上报回环再次发送) private var suppressStreamModeSend = false /// 被控端最近上报的实际采集尺寸(仅切帧率时保持分辨率不变,0 表示原生) private var lastReportedWidth: Int32 = 0 private var lastReportedHeight: Int32 = 0 override init() { super.init() remoteVideoView.delegate = self selfCodecDecoder.displayLayer = selfCodecView.sampleBufferLayer selfCodecDecoder.onResolutionUpdate = { [weak self] w, h in guard let self, self.streamMode == .selfCodec, w > 0, h > 0 else { return } self.videoAspect = CGFloat(w) / CGFloat(h) } } // MARK: - 连接 / 断开 /// 用户点击"连接":弹出鉴权选择 func requestConnect() { let target = targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !serverUrl.trimmingCharacters(in: .whitespaces).isEmpty else { alertMessage = "请输入信令服务器地址" return } guard !target.isEmpty else { alertMessage = "请输入目标设备 ID" return } showAuthSheet = true } /// 鉴权弹窗确认后开始连接 func connect(authType: AuthType, authValue: String) { showAuthSheet = false pendingAuthType = authType pendingAuthValue = authValue isConnecting = true statusText = "正在连接信令服务器..." let signaling = SignalingClient( serverUrl: serverUrl.trimmingCharacters(in: .whitespaces), deviceId: myDeviceId) signaling.delegate = self signalingClient = signaling signaling.connect() } func disconnect() { stopStatsTimer() selfCodecDecoder.setEnabled(false) webRTCClient?.close() webRTCClient = nil signalingClient?.disconnect() signalingClient = nil isConnecting = false isControlling = false statusText = "未连接" statsText = "" streamMode = .webrtc selectedResolution = ResolutionOption.all[0] fpsOptions = [15, 24, 30, 60] currentFps = 0 lastReportedWidth = 0 lastReportedHeight = 0 videoAspect = 9.0 / 16.0 } // MARK: - 远程输入 func sendMotionEvent(action: Int32, x: Double, y: Double) { var msg = ControlMessage() msg.action = .motionEvent msg.motionAction = action msg.x = x msg.y = y webRTCClient?.sendControlCommand(msg) } func sendSwipe(x1: Double, y1: Double, x2: Double, y2: Double, duration: Int64) { var msg = ControlMessage() msg.action = .swipe msg.x1 = x1 msg.y1 = y1 msg.x2 = x2 msg.y2 = y2 msg.duration = duration webRTCClient?.sendControlCommand(msg) } /// 发送按键(Android KeyEvent keyCode),完整的按下+抬起 func sendKey(_ keyCode: Int32) { var down = ControlMessage() down.action = .key down.keyCode = keyCode down.keyAction = 0 webRTCClient?.sendControlCommand(down) var up = ControlMessage() up.action = .key up.keyCode = keyCode up.keyAction = 1 webRTCClient?.sendControlCommand(up) } /// Android 按键常量 enum AndroidKey { static let back: Int32 = 4 static let home: Int32 = 3 static let appSwitch: Int32 = 187 } // MARK: - 分辨率 / 串流模式 func selectResolution(_ option: ResolutionOption) { selectedResolution = option webRTCClient?.requestResolutionChange(width: option.width, height: option.height, fps: 0) } /// 仅切换帧率:分辨率沿用被控端最近上报的实际采集尺寸(0 表示原生) func selectFps(_ fps: Int) { guard fps > 0, fps != currentFps else { return } webRTCClient?.requestResolutionChange(width: lastReportedWidth, height: lastReportedHeight, fps: Int32(fps)) } func toggleStreamMode(_ selfCodecOn: Bool) { let newMode: StreamMode = selfCodecOn ? .selfCodec : .webrtc guard newMode != streamMode else { return } streamMode = newMode applyStreamModeLocally(newMode) if !suppressStreamModeSend { webRTCClient?.sendStreamMode(Int32(newMode.rawValue)) } } private func applyStreamModeLocally(_ mode: StreamMode) { selfCodecDecoder.setEnabled(mode == .selfCodec) } // MARK: - 私有 fileprivate func startWebRTC() { guard let signaling = signalingClient else { return } statusText = "正在建立 WebRTC 连接..." let client = WebRTCClient(signaling: signaling, myDeviceId: myDeviceId) client.delegate = self client.selfCodecDecoder = selfCodecDecoder webRTCClient = client client.createOffer( targetDeviceId: targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines), renderer: remoteVideoView, authType: pendingAuthType.rawValue, authValue: pendingAuthType == .none ? nil : pendingAuthValue) } fileprivate func failConnection(_ reason: String) { alertMessage = reason disconnect() } // MARK: - 统计 private func startStatsTimer() { connectionStartTime = Date() lastBytesReceived = 0 lastStatsTime = nil statsTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.collectStats() } } private func stopStatsTimer() { statsTimer?.invalidate() statsTimer = nil } private func collectStats() { webRTCClient?.stats { [weak self] report in guard let self else { return } let text = self.buildStatsText(report) DispatchQueue.main.async { self.statsText = text } } } private func buildStatsText(_ report: RTCStatisticsReport) -> String { var width = 0, height = 0 var fps: Double = 0 var bytesReceived: Double = 0 var framesDropped: Double = 0 var codecId: String? var codecName = "" var decoderImpl = "" var rttMs: Double = -1 var localCandidateId: String? var remoteCandidateId: String? for (_, stat) in report.statistics { switch stat.type { case "inbound-rtp": guard (stat.values["kind"] as? String) == "video" else { continue } width = (stat.values["frameWidth"] as? NSNumber)?.intValue ?? width height = (stat.values["frameHeight"] as? NSNumber)?.intValue ?? height fps = (stat.values["framesPerSecond"] as? NSNumber)?.doubleValue ?? fps bytesReceived = (stat.values["bytesReceived"] as? NSNumber)?.doubleValue ?? bytesReceived framesDropped = (stat.values["framesDropped"] as? NSNumber)?.doubleValue ?? framesDropped codecId = stat.values["codecId"] as? String decoderImpl = (stat.values["decoderImplementation"] as? String) ?? decoderImpl case "candidate-pair": let nominated = (stat.values["nominated"] as? NSNumber)?.boolValue ?? false let state = stat.values["state"] as? String if nominated && state == "succeeded" { rttMs = ((stat.values["currentRoundTripTime"] as? NSNumber)?.doubleValue ?? 0) * 1000 localCandidateId = stat.values["localCandidateId"] as? String remoteCandidateId = stat.values["remoteCandidateId"] as? String } default: break } } if let codecId, let codecStat = report.statistics[codecId] { codecName = (codecStat.values["mimeType"] as? String)? .replacingOccurrences(of: "video/", with: "") ?? "" } // 码率(与 Android 一致:按字节增量/时间差计算) var bitrateText = "--" let now = Date() if let last = lastStatsTime, bytesReceived >= lastBytesReceived { let interval = now.timeIntervalSince(last) if interval > 0 { let bps = (bytesReceived - lastBytesReceived) * 8 / interval bitrateText = Self.formatBitrate(bps) } } lastBytesReceived = bytesReceived lastStatsTime = now // 连接类型 var connType = "--" if let lid = localCandidateId, let rid = remoteCandidateId, let local = report.statistics[lid], let remote = report.statistics[rid] { let localType = (local.values["candidateType"] as? String) ?? "" let remoteType = (remote.values["candidateType"] as? String) ?? "" let proto = ((local.values["protocol"] as? String) ?? "").uppercased() if localType == "relay" || remoteType == "relay" { connType = "TURN 中继(\(proto))" } else if localType == "srflx" || remoteType == "srflx" { connType = "P2P 公网直连(\(proto))" } else if localType == "host" && remoteType == "host" { connType = "P2P 局域网直连(\(proto))" } else { connType = "\(localType)/\(remoteType)(\(proto))" } } var duration = "" if let start = connectionStartTime { let secs = Int(Date().timeIntervalSince(start)) duration = String(format: "%02d:%02d", secs / 60, secs % 60) } let modeText = streamMode == .selfCodec ? "自编码" : "WebRTC" var lines: [String] = [] lines.append("模式: \(modeText) 时长: \(duration)") if streamMode == .selfCodec { let w = selfCodecDecoder.videoWidth let h = selfCodecDecoder.videoHeight lines.append("分辨率: \(w > 0 ? "\(w)x\(h)" : "--") 解码: VideoToolbox(H264)") } else { lines.append("分辨率: \(width > 0 ? "\(width)x\(height)" : "--") 帧率: \(Int(fps))fps 丢帧: \(Int(framesDropped))") lines.append("编码: \(codecName.isEmpty ? "--" : codecName) 解码器: \(decoderImpl.isEmpty ? "--" : decoderImpl)") } lines.append("码率: \(bitrateText) 延迟: \(rttMs >= 0 ? "\(Int(rttMs))ms" : "--")") lines.append("链路: \(connType)") return lines.joined(separator: "\n") } private static func formatBitrate(_ bps: Double) -> String { if bps >= 1_000_000 { return String(format: "%.1f Mbps", bps / 1_000_000) } if bps >= 1_000 { return String(format: "%.0f Kbps", bps / 1_000) } return String(format: "%.0f bps", bps) } } // MARK: - SignalingClientDelegate extension ControllerViewModel: SignalingClientDelegate { func signalingDidConnect() { statusText = "信令已连接,正在发起会话..." startWebRTC() } func signalingDidDisconnect() { guard isConnecting || isControlling else { return } failConnection("信令服务器连接断开") } func signaling(didFail error: String) { guard isConnecting || isControlling else { return } failConnection("信令连接失败: \(error)") } func signaling(didReceive message: SignalMessage) { switch (message.type ?? "").uppercased() { case "ANSWER": if let sdp = message.payloadJSON()?["sdp"] as? String { statusText = "收到应答,正在协商..." webRTCClient?.handleAnswer(sdp: sdp) } case "ICE_CANDIDATE": if let json = message.payloadJSON(), let candidate = json["candidate"] as? String { let sdpMid = json["sdpMid"] as? String ?? "" let index = Int32((json["sdpMLineIndex"] as? NSNumber)?.intValue ?? 0) webRTCClient?.addIceCandidate(sdpMid: sdpMid, sdpMLineIndex: index, candidate: candidate) } case "TARGET_OFFLINE": failConnection("目标设备不在线") case "CONNECTION_REJECTED": let reason = message.payloadJSON()?["reason"] as? String failConnection(reason ?? "对方拒绝了连接请求") case "REQUEST_ERROR": let reason = message.payloadJSON()?["reason"] as? String failConnection(reason ?? "请求错误") case "REQUEST_TIMEOUT": failConnection("连接请求超时,对方未响应") default: break } } } // MARK: - WebRTCClientDelegate extension ControllerViewModel: WebRTCClientDelegate { func webRTCClientDidConnect() { isConnecting = false isControlling = true statusText = "已连接: \(targetDeviceId)" applyStreamModeLocally(streamMode) startStatsTimer() } func webRTCClientDidDisconnect() { guard isControlling else { return } alertMessage = "连接已断开" disconnect() } func webRTCClient(didFail error: String) { failConnection(error) } func webRTCClient(didReportStreamMode mode: Int) { let reported = StreamMode(rawValue: mode) ?? .webrtc guard reported != streamMode else { return } // 与被控端实际模式同步(不再回发 SET_STREAM_MODE) suppressStreamModeSend = true streamMode = reported applyStreamModeLocally(reported) suppressStreamModeSend = false } func webRTCClient(didReportResolution width: Int, height: Int) { guard width > 0, height > 0 else { return } lastReportedWidth = Int32(width) lastReportedHeight = Int32(height) if streamMode == .webrtc { videoAspect = CGFloat(width) / CGFloat(height) } } func webRTCClient(didReportFps fps: Int, supportedFps: [Int]) { if fps > 0 { currentFps = fps } if !supportedFps.isEmpty { fpsOptions = supportedFps } } } // MARK: - RTCVideoViewDelegate(WebRTC 模式下画面尺寸变化) extension ControllerViewModel: RTCVideoViewDelegate { func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) { DispatchQueue.main.async { guard size.width > 0, size.height > 0 else { return } if self.streamMode == .webrtc { self.videoAspect = size.width / size.height } } } }