Files
VibeCoding/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift
TongTongStudio 362b9f238a feat: 支持手动切换采集帧率
在 Android、Web 及 Flutter 控制端新增帧率选择 UI,被控端按屏幕刷新率生成支持的帧率档位并上报,允许在保持分辨率不变的情况下仅切换帧率。

另附带更新信号服务器的数据库配置。
2026-07-31 03:26:40 +08:00

489 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
]
}
/// ViewModelWebRTC 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: - RTCVideoViewDelegateWebRTC
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
}
}
}
}