feat: 初始化 iOS 远程控制端项目
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
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]
|
||||
/// 远端视频宽高比(宽/高),用于让触控层与画面精确对齐
|
||||
@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
|
||||
|
||||
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]
|
||||
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)
|
||||
}
|
||||
|
||||
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 }
|
||||
if streamMode == .webrtc {
|
||||
videoAspect = CGFloat(width) / CGFloat(height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user