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 = "981964879" @Published var statusText: String = "未连接" /// 是否显示控制界面(对应 Android 的 setupPanel/controlPanel 切换) @Published var isControlling: Bool = false @Published var isConnecting: Bool = false @Published var statsText: String = "" /// 统计信息面板是否展开(默认收起,仅显示概要) @Published var statsExpanded: Bool = false @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? /// 是否正在录制远端视频 @Published var isRecording: Bool = false /// 最近一次录制保存的文件路径(沙盒内) @Published var lastRecordingPath: String? /// 本机设备ID:初始为空,连接后由服务端 REGISTER_SUCCESS 下发(fromDeviceId)。 @Published var myDeviceId: String = "" // MARK: - 登录态 @Published var isLoggedIn: Bool = false @Published var username: String = "" @Published var loginError: String? @Published var isLoggingIn: Bool = false /// 当前 accessToken(Bearer 握手用),内存持有;refreshToken 存 Keychain。 private var accessToken: String? // MARK: - UIKit 渲染视图(由 ViewModel 持有,SwiftUI 通过 Representable 嵌入) let remoteVideoView = RTCMTLVideoView() let selfCodecView = SampleBufferDisplayView() // MARK: - 内部组件 private var signalingClient: SignalingClient? private var webRTCClient: WebRTCClient? private let selfCodecDecoder = SelfCodecDecoder() /// 远端视频录制器(MP4),跟随两种串流模式工作 private let videoRecorder = VideoRecorder() 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 restoreLoginState() 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) } // 自编码模式:把硬解出的 AVCC 帧回传给录制器写盘 selfCodecDecoder.onDecodedSampleBuffer = { [weak self] sample, isKey in self?.videoRecorder.appendDecodedSampleBuffer(sample, isKey: isKey) } // 录制状态/结果回调 videoRecorder.onStateChange = { [weak self] state in self?.isRecording = (state == .recording) } videoRecorder.onSaved = { [weak self] path in self?.lastRecordingPath = path self?.alertMessage = "录制已保存:\n\(path)" } videoRecorder.onSavedEmpty = { [weak self] in self?.alertMessage = "未录到任何画面" } videoRecorder.onError = { [weak self] msg in self?.alertMessage = "录制失败: \(msg)" } } // MARK: - 连接 / 断开 /// 用户点击"连接":先确保已登录(Bearer token),再弹出鉴权选择 func requestConnect() { let target = targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !serverUrl.trimmingCharacters(in: .whitespaces).isEmpty else { alertMessage = "请输入信令服务器地址" return } guard !target.isEmpty else { alertMessage = "请输入目标设备 ID" return } guard isLoggedIn else { alertMessage = "请先在右上角登录账号" return } showAuthSheet = true } /// 鉴权弹窗确认后开始连接(已确保 accessToken 有效)。 func connect(authType: AuthType, authValue: String) { showAuthSheet = false pendingAuthType = authType pendingAuthValue = authValue isConnecting = true statusText = "正在连接信令服务器..." let signaling = SignalingClient( serverUrl: serverUrl.trimmingCharacters(in: .whitespaces), token: accessToken) signaling.delegate = self signalingClient = signaling signaling.connect() } func disconnect() { stopStatsTimer() videoRecorder.cancel() isRecording = false 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: - 录制 /// 开始录制远端画面(WebRTC 与自编码两种模式均支持)。 /// 需在已连接后调用;若尚未建立连接会提示。 func startRecording() { guard isControlling else { alertMessage = "请先连接被控设备" return } guard !isRecording else { return } videoRecorder.start() } /// 停止录制并保存 MP4 到沙盒 Documents/WebRTCRecordings。 func stopRecording() { guard isRecording else { return } videoRecorder.stop() } // MARK: - 私有 fileprivate func startWebRTC() { guard let signaling = signalingClient else { return } guard !myDeviceId.isEmpty else { failConnection("未获取到本机设备ID,连接中止") return } statusText = "正在建立 WebRTC 连接..." let client = WebRTCClient(signaling: signaling, myDeviceId: myDeviceId) client.delegate = self client.selfCodecDecoder = selfCodecDecoder client.extraRenderer = videoRecorder 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: - 登录 / 令牌 /// 应用启动时从 Keychain 恢复登录态(accessToken / refreshToken)。 private func restoreLoginState() { let at = TokenStore.loadAccessToken() let rt = TokenStore.loadRefreshToken() if at != nil, rt != nil { accessToken = at username = TokenStore.loadUsername() ?? "" isLoggedIn = true } } /// 登录:调用 /api/auth/login,保存令牌并恢复登录态。 func login(username: String, password: String) async { isLoggingIn = true loginError = nil do { let data = try await ApiClient.login(username: username, password: password) guard let at = data["accessToken"] as? String, let rt = data["refreshToken"] as? String else { throw ApiError(message: "登录返回缺失") } TokenStore.saveAccessToken(at) TokenStore.saveRefreshToken(rt) TokenStore.saveUsername(username) accessToken = at self.username = username isLoggedIn = true } catch { loginError = (error as? ApiError)?.message ?? error.localizedDescription } isLoggingIn = false } /// 退出登录:清空令牌与状态。 func logout() { TokenStore.clear() accessToken = nil username = "" isLoggedIn = false if isConnecting || isControlling { disconnect() } } /// 确保 accessToken 有效:若已有则校验,失效则用 refreshToken 刷新。 private func ensureAccessToken() async throws -> String { if let existing = accessToken { do { _ = try await ApiClient.verify(accessToken: existing) return existing } catch { // 校验失败可能是过期,走刷新。 } } guard let rt = TokenStore.loadRefreshToken() else { throw ApiError(message: "无 refreshToken,请重新登录") } let data = try await ApiClient.refresh(refreshToken: rt) guard let newAt = data["accessToken"] as? String else { throw ApiError(message: "刷新失败") } TokenStore.saveAccessToken(newAt) if let newRt = data["refreshToken"] as? String { TokenStore.saveRefreshToken(newRt) } accessToken = newAt return newAt } /// 令牌失效后刷新并重连。 private func refreshAndReconnect() async { do { let newAt = try await ensureAccessToken() accessToken = newAt await MainActor.run { [weak self] in guard let self else { return } self.signalingClient?.disconnect() self.signalingClient = nil // 用新令牌重建连接(REGISTER_SUCCESS 会再次触发 startWebRTC)。 let signaling = SignalingClient( serverUrl: self.serverUrl.trimmingCharacters(in: .whitespaces), token: newAt) signaling.delegate = self self.signalingClient = signaling signaling.connect() } } catch { await MainActor.run { [weak self] in guard let self else { return } self.logout() self.alertMessage = "令牌刷新失败,请重新登录" } } } /// 拉取本机可连接的被控端绑定列表(仅已绑定设备),用于提示。 private func loadBindings() { guard let at = accessToken else { return } Task { do { let data = try await ApiClient.bindings(accessToken: at) guard let list = data["bindings"] as? [[String: Any]], !list.isEmpty else { return } let uids = list.compactMap { entry -> String? in if let u = entry["deviceUid"] as? String, !u.isEmpty { return u } return entry["deviceId"] as? String }.filter { !$0.isEmpty } if !uids.isEmpty { let message = "已绑定设备: " + uids.joined(separator: ", ") Task { @MainActor [weak self] in self?.alertMessage = message } } } catch { /* 忽略 */ } } } /// 拉取 TURN 短期凭证,覆盖默认 ICE(由 WebRTCClient 读取)。 private func loadTurnCredentials() async { guard let at = accessToken else { return } do { let data = try await ApiClient.turnCredentials(accessToken: at) guard let servers = data["iceServers"] as? [[String: Any]], !servers.isEmpty else { return } var mapped: [[String: Any]] = [] for s in servers { var entry: [String: Any] = [:] if let urls = s["urls"] as? String { entry["urls"] = urls } if let user = s["username"] as? String { entry["username"] = user } if let cred = s["credential"] as? String { entry["credential"] = cred } mapped.append(entry) } if !mapped.isEmpty { WebRTCClient.iceServersOverride = mapped } } catch { /* 服务端未开启 TURN,忽略 */ } } // 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) Task { @MainActor in 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 = "信令已连接,等待注册..." } func signalingDidDisconnect() { guard isConnecting || isControlling else { return } failConnection("信令服务器连接断开") } func signaling(didFail error: String) { guard isConnecting || isControlling else { return } failConnection("信令连接失败: \(error)") } /// 令牌失效(关闭码 4001):刷新令牌后重连。 func signalingTokenExpired() { Task { await refreshAndReconnect() } } /// 强制下线(关闭码 4003):清空登录态,跳回登录。 func signalingForceLogout() { DispatchQueue.main.async { self.logout() self.alertMessage = "账号已在其他位置登录,已强制下线" } } func signaling(didReceive message: SignalMessage) { // 服务端下发本机 deviceId(CONTROLLER),随后发起 WebRTC Offer。 if (message.type ?? "").uppercased() == "REGISTER_SUCCESS", let from = message.fromDeviceId, !from.isEmpty { myDeviceId = from statusText = "已注册 (\(from)),正在发起会话..." startWebRTC() loadBindings() Task { await loadTurnCredentials() } return } 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 } } } }