在 Android、Web 及 Flutter 控制端新增帧率选择 UI,被控端按屏幕刷新率生成支持的帧率档位并上报,允许在保持分辨率不变的情况下仅切换帧率。 另附带更新信号服务器的数据库配置。
489 lines
17 KiB
Swift
489 lines
17 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 = ""
|
||
@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
|
||
}
|
||
}
|
||
}
|
||
}
|