- 在Xcode项目中添加LoginView.swift文件引用 - 修复TokenStore中Keychain查询字典类型为CFString - 将DispatchQueue.main.async替换为MainActor.run,避免弱引用丢失 - 修复绑定设备信息时的线程安全与变量命名
718 lines
26 KiB
Swift
718 lines
26 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|
||
}
|