feat: 初始化 iOS 远程控制端项目
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import UIKit
|
||||
|
||||
/// UIKit 应用入口(UIKit 生命周期 + SwiftUI 内容视图混合架构)
|
||||
@main
|
||||
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// 远程控制期间保持屏幕常亮
|
||||
application.isIdleTimerDisabled = true
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - UISceneSession Lifecycle
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
let config = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
config.delegateClass = SceneDelegate.self
|
||||
return config
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
/// 场景代理:以 UIHostingController 承载 SwiftUI 根视图(UIKit 与 SwiftUI 混用)
|
||||
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
let hosting = UIHostingController(rootView: ContentView())
|
||||
hosting.view.backgroundColor = .black
|
||||
window.rootViewController = hosting
|
||||
window.makeKeyAndVisible()
|
||||
self.window = window
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
|
||||
/// 通过 RTCDataChannel 传输的控制指令。
|
||||
/// 与 control_message.proto (proto3) 二进制格式完全兼容(手写极简 protobuf 编解码,
|
||||
/// 免去 SwiftProtobuf 依赖与代码生成)。
|
||||
/// 坐标 x/y/x1/y1/x2/y2 均为相对屏幕的百分比,取值范围 0.0 ~ 1.0。
|
||||
struct ControlMessage {
|
||||
|
||||
enum Action: Int {
|
||||
case unknown = 0
|
||||
case touch = 1
|
||||
case swipe = 2
|
||||
case key = 3
|
||||
case longPress = 4
|
||||
case motionEvent = 5
|
||||
case setResolution = 6 // 控制端请求被控端切换屏幕采集分辨率
|
||||
case reportResolution = 7 // 被控端上报当前实际采集分辨率
|
||||
case setStreamMode = 8 // 控制端请求被控端切换屏幕串流模式
|
||||
case reportStreamMode = 9 // 被控端上报当前生效的串流模式
|
||||
}
|
||||
|
||||
var action: Action = .unknown // field 1, varint
|
||||
var x: Double = 0 // field 2, fixed64
|
||||
var y: Double = 0 // field 3, fixed64
|
||||
var x1: Double = 0 // field 4, fixed64
|
||||
var y1: Double = 0 // field 5, fixed64
|
||||
var x2: Double = 0 // field 6, fixed64
|
||||
var y2: Double = 0 // field 7, fixed64
|
||||
var duration: Int64 = 0 // field 8, varint
|
||||
var keyCode: Int32 = 0 // field 9, varint
|
||||
var keyAction: Int32 = 0 // field 10, varint (0=按下 1=抬起)
|
||||
var motionAction: Int32 = 0 // field 11, varint (0=DOWN 1=UP 2=MOVE 3=CANCEL)
|
||||
var width: Int32 = 0 // field 12, varint
|
||||
var height: Int32 = 0 // field 13, varint
|
||||
var fps: Int32 = 0 // field 14, varint
|
||||
var streamMode: Int32 = 0 // field 15, varint (0=WebRTC 1=自编码)
|
||||
|
||||
// MARK: - Encode
|
||||
|
||||
func serializedData() -> Data {
|
||||
var w = ProtoWriter()
|
||||
w.writeVarintField(1, UInt64(action.rawValue))
|
||||
w.writeDoubleField(2, x)
|
||||
w.writeDoubleField(3, y)
|
||||
w.writeDoubleField(4, x1)
|
||||
w.writeDoubleField(5, y1)
|
||||
w.writeDoubleField(6, x2)
|
||||
w.writeDoubleField(7, y2)
|
||||
w.writeVarintField(8, UInt64(bitPattern: duration))
|
||||
w.writeVarintField(9, UInt64(bitPattern: Int64(keyCode)))
|
||||
w.writeVarintField(10, UInt64(bitPattern: Int64(keyAction)))
|
||||
w.writeVarintField(11, UInt64(bitPattern: Int64(motionAction)))
|
||||
w.writeVarintField(12, UInt64(bitPattern: Int64(width)))
|
||||
w.writeVarintField(13, UInt64(bitPattern: Int64(height)))
|
||||
w.writeVarintField(14, UInt64(bitPattern: Int64(fps)))
|
||||
w.writeVarintField(15, UInt64(bitPattern: Int64(streamMode)))
|
||||
return w.data
|
||||
}
|
||||
|
||||
// MARK: - Decode
|
||||
|
||||
static func parse(from data: Data) -> ControlMessage? {
|
||||
var msg = ControlMessage()
|
||||
var r = ProtoReader(data: data)
|
||||
while let (field, wire) = r.readTag() {
|
||||
switch (field, wire) {
|
||||
case (1, 0):
|
||||
guard let v = r.readVarint() else { return nil }
|
||||
msg.action = Action(rawValue: Int(v)) ?? .unknown
|
||||
case (2, 1): guard let v = r.readDouble() else { return nil }; msg.x = v
|
||||
case (3, 1): guard let v = r.readDouble() else { return nil }; msg.y = v
|
||||
case (4, 1): guard let v = r.readDouble() else { return nil }; msg.x1 = v
|
||||
case (5, 1): guard let v = r.readDouble() else { return nil }; msg.y1 = v
|
||||
case (6, 1): guard let v = r.readDouble() else { return nil }; msg.x2 = v
|
||||
case (7, 1): guard let v = r.readDouble() else { return nil }; msg.y2 = v
|
||||
case (8, 0): guard let v = r.readVarint() else { return nil }; msg.duration = Int64(bitPattern: v)
|
||||
case (9, 0): guard let v = r.readVarint() else { return nil }; msg.keyCode = Int32(truncatingIfNeeded: Int64(bitPattern: v))
|
||||
case (10, 0): guard let v = r.readVarint() else { return nil }; msg.keyAction = Int32(truncatingIfNeeded: Int64(bitPattern: v))
|
||||
case (11, 0): guard let v = r.readVarint() else { return nil }; msg.motionAction = Int32(truncatingIfNeeded: Int64(bitPattern: v))
|
||||
case (12, 0): guard let v = r.readVarint() else { return nil }; msg.width = Int32(truncatingIfNeeded: Int64(bitPattern: v))
|
||||
case (13, 0): guard let v = r.readVarint() else { return nil }; msg.height = Int32(truncatingIfNeeded: Int64(bitPattern: v))
|
||||
case (14, 0): guard let v = r.readVarint() else { return nil }; msg.fps = Int32(truncatingIfNeeded: Int64(bitPattern: v))
|
||||
case (15, 0): guard let v = r.readVarint() else { return nil }; msg.streamMode = Int32(truncatingIfNeeded: Int64(bitPattern: v))
|
||||
default:
|
||||
// 未知字段按 wire type 跳过
|
||||
if !r.skip(wireType: wire) { return nil }
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 极简 protobuf 读写器
|
||||
|
||||
private struct ProtoWriter {
|
||||
var data = Data()
|
||||
|
||||
mutating func writeVarint(_ value: UInt64) {
|
||||
var v = value
|
||||
while v >= 0x80 {
|
||||
data.append(UInt8((v & 0x7F) | 0x80))
|
||||
v >>= 7
|
||||
}
|
||||
data.append(UInt8(v))
|
||||
}
|
||||
|
||||
/// proto3:默认值(0)不编码
|
||||
mutating func writeVarintField(_ field: Int, _ value: UInt64) {
|
||||
guard value != 0 else { return }
|
||||
writeVarint(UInt64(field << 3 | 0))
|
||||
writeVarint(value)
|
||||
}
|
||||
|
||||
mutating func writeDoubleField(_ field: Int, _ value: Double) {
|
||||
guard value != 0 else { return }
|
||||
writeVarint(UInt64(field << 3 | 1))
|
||||
var bits = value.bitPattern.littleEndian
|
||||
withUnsafeBytes(of: &bits) { data.append(contentsOf: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProtoReader {
|
||||
let data: Data
|
||||
var offset: Int
|
||||
|
||||
init(data: Data) {
|
||||
self.data = data
|
||||
self.offset = data.startIndex
|
||||
}
|
||||
|
||||
mutating func readTag() -> (Int, Int)? {
|
||||
guard offset < data.endIndex, let key = readVarint() else { return nil }
|
||||
return (Int(key >> 3), Int(key & 0x7))
|
||||
}
|
||||
|
||||
mutating func readVarint() -> UInt64? {
|
||||
var result: UInt64 = 0
|
||||
var shift: UInt64 = 0
|
||||
while offset < data.endIndex {
|
||||
let byte = data[offset]
|
||||
offset += 1
|
||||
result |= UInt64(byte & 0x7F) << shift
|
||||
if byte & 0x80 == 0 { return result }
|
||||
shift += 7
|
||||
if shift >= 64 { return nil }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
mutating func readDouble() -> Double? {
|
||||
guard offset + 8 <= data.endIndex else { return nil }
|
||||
var bits: UInt64 = 0
|
||||
for i in (0..<8).reversed() {
|
||||
bits = (bits << 8) | UInt64(data[offset + i])
|
||||
}
|
||||
offset += 8
|
||||
return Double(bitPattern: bits)
|
||||
}
|
||||
|
||||
mutating func skip(wireType: Int) -> Bool {
|
||||
switch wireType {
|
||||
case 0: return readVarint() != nil
|
||||
case 1:
|
||||
guard offset + 8 <= data.endIndex else { return false }
|
||||
offset += 8
|
||||
return true
|
||||
case 2:
|
||||
guard let len = readVarint(), offset + Int(len) <= data.endIndex else { return false }
|
||||
offset += Int(len)
|
||||
return true
|
||||
case 5:
|
||||
guard offset + 4 <= data.endIndex else { return false }
|
||||
offset += 4
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
65
webrtc_controller_ios/web_rtc_controller_ios/Info.plist
Normal file
65
webrtc_controller_ios/web_rtc_controller_ios/Info.plist
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>WebRTC控制端</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
/// 信令消息(JSON),与 WebRTCSignalServer 的 SignalMessage 字段一一对应。
|
||||
/// type: REGISTER / OFFER / ANSWER / ICE_CANDIDATE / TARGET_OFFLINE /
|
||||
/// CONNECTION_REJECTED / REQUEST_ERROR / REQUEST_TIMEOUT / REGISTER_SUCCESS 等
|
||||
struct SignalMessage: Codable {
|
||||
var type: String?
|
||||
var fromDeviceId: String?
|
||||
var toDeviceId: String?
|
||||
var deviceType: String?
|
||||
/// payload 为字符串形式的 JSON(如 {"sdp": "..."}),与服务端约定一致
|
||||
var payload: String?
|
||||
var authType: String?
|
||||
var authValue: String?
|
||||
|
||||
init(type: String? = nil,
|
||||
fromDeviceId: String? = nil,
|
||||
toDeviceId: String? = nil,
|
||||
deviceType: String? = nil,
|
||||
payload: String? = nil,
|
||||
authType: String? = nil,
|
||||
authValue: String? = nil) {
|
||||
self.type = type
|
||||
self.fromDeviceId = fromDeviceId
|
||||
self.toDeviceId = toDeviceId
|
||||
self.deviceType = deviceType
|
||||
self.payload = payload
|
||||
self.authType = authType
|
||||
self.authValue = authValue
|
||||
}
|
||||
|
||||
/// 解析 payload 字符串为 JSON 字典
|
||||
func payloadJSON() -> [String: Any]? {
|
||||
guard let payload, let data = payload.data(using: .utf8) else { return nil }
|
||||
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||
}
|
||||
|
||||
/// 将字典序列化为 payload 字符串
|
||||
static func encodePayload(_ dict: [String: Any]) -> String? {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: dict) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import Foundation
|
||||
|
||||
protocol SignalingClientDelegate: AnyObject {
|
||||
func signalingDidConnect()
|
||||
func signalingDidDisconnect()
|
||||
func signaling(didFail error: String)
|
||||
func signaling(didReceive message: SignalMessage)
|
||||
}
|
||||
|
||||
/// 信令 WebSocket 客户端(URLSessionWebSocketTask 实现)。
|
||||
/// 连接成功后自动发送 REGISTER(deviceType = CONTROLLER),
|
||||
/// 所有回调均已切换到主线程。
|
||||
final class SignalingClient: NSObject {
|
||||
|
||||
weak var delegate: SignalingClientDelegate?
|
||||
|
||||
private let serverUrl: String
|
||||
private let deviceId: String
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionWebSocketTask?
|
||||
private var manuallyClosed = false
|
||||
|
||||
init(serverUrl: String, deviceId: String) {
|
||||
self.serverUrl = serverUrl
|
||||
self.deviceId = deviceId
|
||||
super.init()
|
||||
}
|
||||
|
||||
func connect() {
|
||||
guard let url = URL(string: serverUrl) else {
|
||||
notifyError("无效的服务器地址: \(serverUrl)")
|
||||
return
|
||||
}
|
||||
manuallyClosed = false
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 15
|
||||
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
task = session?.webSocketTask(with: url)
|
||||
task?.resume()
|
||||
receiveLoop()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
manuallyClosed = true
|
||||
task?.cancel(with: .normalClosure, reason: "Disconnecting".data(using: .utf8))
|
||||
task = nil
|
||||
session?.invalidateAndCancel()
|
||||
session = nil
|
||||
}
|
||||
|
||||
func send(_ message: SignalMessage) {
|
||||
guard let task else { return }
|
||||
guard let data = try? JSONEncoder().encode(message),
|
||||
let json = String(data: data, encoding: .utf8) else { return }
|
||||
task.send(.string(json)) { error in
|
||||
if let error {
|
||||
NSLog("[Signaling] send error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isConnected: Bool { task != nil }
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func registerDevice() {
|
||||
var msg = SignalMessage()
|
||||
msg.type = "REGISTER"
|
||||
msg.fromDeviceId = deviceId
|
||||
msg.deviceType = "CONTROLLER"
|
||||
send(msg)
|
||||
}
|
||||
|
||||
private func receiveLoop() {
|
||||
task?.receive { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let wsMessage):
|
||||
if case .string(let text) = wsMessage,
|
||||
let data = text.data(using: .utf8),
|
||||
let message = try? JSONDecoder().decode(SignalMessage.self, from: data) {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.signaling(didReceive: message)
|
||||
}
|
||||
}
|
||||
self.receiveLoop()
|
||||
case .failure(let error):
|
||||
if !self.manuallyClosed {
|
||||
self.notifyError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyError(_ text: String) {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.signaling(didFail: text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSessionWebSocketDelegate
|
||||
|
||||
extension SignalingClient: URLSessionWebSocketDelegate {
|
||||
|
||||
func urlSession(_ session: URLSession,
|
||||
webSocketTask: URLSessionWebSocketTask,
|
||||
didOpenWithProtocol protocol: String?) {
|
||||
registerDevice()
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.signalingDidConnect()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession,
|
||||
webSocketTask: URLSessionWebSocketTask,
|
||||
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
||||
reason: Data?) {
|
||||
guard !manuallyClosed else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.signalingDidDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 设备 ID 工具:生成并持久化本机设备标识(对应 Android 端 DeviceUtils)。
|
||||
/// 格式: CTRL-XXXXXXXX(8 位大写十六进制)
|
||||
enum DeviceUtils {
|
||||
|
||||
private static let key = "com.ttstd.webrtccontroller.deviceId"
|
||||
|
||||
static func deviceId() -> String {
|
||||
let defaults = UserDefaults.standard
|
||||
if let saved = defaults.string(forKey: key), !saved.isEmpty {
|
||||
return saved
|
||||
}
|
||||
let seed = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
|
||||
let suffix = seed.replacingOccurrences(of: "-", with: "").prefix(8).uppercased()
|
||||
let id = "CTRL-\(suffix)"
|
||||
defaults.set(id, forKey: key)
|
||||
return id
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 连接鉴权弹窗:免密 / 动态验证码 / 固定密码(对应 Android 的鉴权对话框)。
|
||||
struct AuthSheetView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let onConfirm: (AuthType, String) -> Void
|
||||
|
||||
@State private var authType: AuthType = .none
|
||||
@State private var authValue: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("鉴权方式") {
|
||||
Picker("鉴权方式", selection: $authType) {
|
||||
ForEach(AuthType.allCases) { type in
|
||||
Text(type.title).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if authType != .none {
|
||||
Section(authType == .code ? "验证码" : "密码") {
|
||||
if authType == .code {
|
||||
TextField("请输入被控端显示的 6 位验证码", text: $authValue)
|
||||
.keyboardType(.numberPad)
|
||||
} else {
|
||||
SecureField("请输入被控端设置的连接密码", text: $authValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
onConfirm(authType, authValue)
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("开始连接")
|
||||
.fontWeight(.semibold)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(authType != .none && authValue.isEmpty)
|
||||
} footer: {
|
||||
Text(footerTips)
|
||||
}
|
||||
}
|
||||
.navigationTitle("连接鉴权")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
|
||||
private var footerTips: String {
|
||||
switch authType {
|
||||
case .none:
|
||||
return "免密连接:需要被控端手动确认或开启免验证。"
|
||||
case .code:
|
||||
return "动态验证码:输入被控端界面上显示的验证码,验证通过后自动接受连接。"
|
||||
case .password:
|
||||
return "固定密码:输入被控端预设的连接密码,验证通过后自动接受连接。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 主界面:未连接时显示设置面板,连接成功后显示远程控制面板。
|
||||
struct ContentView: View {
|
||||
@StateObject private var viewModel = ControllerViewModel()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if viewModel.isControlling {
|
||||
ControlPanelView(viewModel: viewModel)
|
||||
} else {
|
||||
SetupPanelView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showAuthSheet) {
|
||||
AuthSheetView { authType, authValue in
|
||||
viewModel.connect(authType: authType, authValue: authValue)
|
||||
}
|
||||
}
|
||||
.alert("提示", isPresented: Binding(
|
||||
get: { viewModel.alertMessage != nil },
|
||||
set: { if !$0 { viewModel.alertMessage = nil } })) {
|
||||
Button("确定", role: .cancel) { viewModel.alertMessage = nil }
|
||||
} message: {
|
||||
Text(viewModel.alertMessage ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 设置面板
|
||||
|
||||
private struct SetupPanelView: View {
|
||||
@ObservedObject var viewModel: ControllerViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("本机信息") {
|
||||
HStack {
|
||||
Text("本机设备 ID")
|
||||
Spacer()
|
||||
Text(viewModel.myDeviceId)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
Section("连接设置") {
|
||||
TextField("信令服务器地址 (wss://...)", text: $viewModel.serverUrl)
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
TextField("目标设备 ID", text: $viewModel.targetDeviceId)
|
||||
.autocapitalization(.allCharacters)
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
viewModel.requestConnect()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
if viewModel.isConnecting {
|
||||
ProgressView()
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
Text(viewModel.isConnecting ? "连接中..." : "连接")
|
||||
.fontWeight(.semibold)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isConnecting)
|
||||
|
||||
if viewModel.isConnecting {
|
||||
Button(role: .destructive) {
|
||||
viewModel.disconnect()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("取消")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(viewModel.statusText)
|
||||
}
|
||||
}
|
||||
.navigationTitle("WebRTC 控制端")
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 控制面板
|
||||
|
||||
private struct ControlPanelView: View {
|
||||
@ObservedObject var viewModel: ControllerViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
videoArea
|
||||
statsView
|
||||
navButtons
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(viewModel.statusText)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.green)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 串流模式切换(WebRTC / 自编码)
|
||||
Toggle(isOn: Binding(
|
||||
get: { viewModel.streamMode == .selfCodec },
|
||||
set: { viewModel.toggleStreamMode($0) })) {
|
||||
Text("自编码")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.fixedSize()
|
||||
|
||||
// 分辨率选择
|
||||
Menu {
|
||||
ForEach(ResolutionOption.all) { option in
|
||||
Button {
|
||||
viewModel.selectResolution(option)
|
||||
} label: {
|
||||
if option == viewModel.selectedResolution {
|
||||
Label(option.title, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(option.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.selectedResolution.title, systemImage: "rectangle.compress.vertical")
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.disconnect()
|
||||
} label: {
|
||||
Text("断开")
|
||||
.font(.footnote)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(white: 0.1))
|
||||
}
|
||||
|
||||
/// 视频区域:按远端画面宽高比自适应,触控层与画面精确对齐
|
||||
private var videoArea: some View {
|
||||
GeometryReader { _ in
|
||||
ZStack {
|
||||
Color.black
|
||||
ZStack {
|
||||
// WebRTC 视频渲染(UIKit RTCMTLVideoView)
|
||||
RemoteVideoView(videoView: viewModel.remoteVideoView)
|
||||
.opacity(viewModel.streamMode == .webrtc ? 1 : 0)
|
||||
// 自编码 H.264 硬解渲染(UIKit AVSampleBufferDisplayLayer)
|
||||
SelfCodecDisplayViewRepresentable(view: viewModel.selfCodecView)
|
||||
.opacity(viewModel.streamMode == .selfCodec ? 1 : 0)
|
||||
// UIKit 触控捕获层
|
||||
TouchOverlay(
|
||||
onMotionEvent: { action, x, y in
|
||||
viewModel.sendMotionEvent(action: action, x: x, y: y)
|
||||
},
|
||||
onSwipe: { x1, y1, x2, y2, duration in
|
||||
viewModel.sendSwipe(x1: x1, y1: y1, x2: x2, y2: y2, duration: duration)
|
||||
})
|
||||
}
|
||||
.aspectRatio(viewModel.videoAspect, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statsView: some View {
|
||||
HStack {
|
||||
Text(viewModel.statsText)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(Color(white: 0.75))
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color(white: 0.08))
|
||||
}
|
||||
|
||||
private var navButtons: some View {
|
||||
HStack(spacing: 24) {
|
||||
navButton(title: "返回", systemImage: "arrow.uturn.backward") {
|
||||
viewModel.sendKey(ControllerViewModel.AndroidKey.back)
|
||||
}
|
||||
navButton(title: "主页", systemImage: "circle") {
|
||||
viewModel.sendKey(ControllerViewModel.AndroidKey.home)
|
||||
}
|
||||
navButton(title: "多任务", systemImage: "square.on.square") {
|
||||
viewModel.sendKey(ControllerViewModel.AndroidKey.appSwitch)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(white: 0.1))
|
||||
}
|
||||
|
||||
private func navButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 18))
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
/// UIKit 触控捕获视图:将触摸转换为相对坐标(0.0~1.0)的远端输入指令。
|
||||
/// 与 Android 端 RemoteTouchView 行为一致:
|
||||
/// - 实时转发原始 MotionEvent(DOWN=0 / UP=1 / MOVE=2 / CANCEL=3),远端注入实现"跟手"
|
||||
/// - 手指抬起时若判定为滑动(时长 >= 200ms 或位移 >= 2%),额外发送一条 SWIPE 指令
|
||||
final class RemoteTouchView: UIView {
|
||||
|
||||
/// Android MotionEvent 动作常量
|
||||
enum MotionAction {
|
||||
static let down: Int32 = 0
|
||||
static let up: Int32 = 1
|
||||
static let move: Int32 = 2
|
||||
static let cancel: Int32 = 3
|
||||
}
|
||||
|
||||
var onMotionEvent: ((Int32, Double, Double) -> Void)?
|
||||
var onSwipe: ((Double, Double, Double, Double, Int64) -> Void)?
|
||||
|
||||
private var startX: Double = 0
|
||||
private var startY: Double = 0
|
||||
private var touchStartTime: TimeInterval = 0
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
isMultipleTouchEnabled = false
|
||||
backgroundColor = .clear
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
isMultipleTouchEnabled = false
|
||||
backgroundColor = .clear
|
||||
}
|
||||
|
||||
private func relativePoint(of touch: UITouch) -> (Double, Double) {
|
||||
let p = touch.location(in: self)
|
||||
let w = max(bounds.width, 1)
|
||||
let h = max(bounds.height, 1)
|
||||
let relX = min(max(Double(p.x / w), 0), 1)
|
||||
let relY = min(max(Double(p.y / h), 0), 1)
|
||||
return (relX, relY)
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
let (x, y) = relativePoint(of: touch)
|
||||
startX = x
|
||||
startY = y
|
||||
touchStartTime = Date().timeIntervalSince1970
|
||||
onMotionEvent?(MotionAction.down, x, y)
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
let (x, y) = relativePoint(of: touch)
|
||||
onMotionEvent?(MotionAction.move, x, y)
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
let (x, y) = relativePoint(of: touch)
|
||||
onMotionEvent?(MotionAction.up, x, y)
|
||||
|
||||
let duration = Int64((Date().timeIntervalSince1970 - touchStartTime) * 1000)
|
||||
let dx = abs(x - startX)
|
||||
let dy = abs(y - startY)
|
||||
// 非轻点:补发 SWIPE 高层指令(与 Android 端一致)
|
||||
if !(duration < 200 && dx < 0.02 && dy < 0.02) {
|
||||
onSwipe?(startX, startY, x, y, duration)
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
let (x, y) = relativePoint(of: touch)
|
||||
onMotionEvent?(MotionAction.cancel, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI 包装:把 UIKit 触控层嵌入 SwiftUI 视图树
|
||||
struct TouchOverlay: UIViewRepresentable {
|
||||
let onMotionEvent: (Int32, Double, Double) -> Void
|
||||
let onSwipe: (Double, Double, Double, Double, Int64) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> RemoteTouchView {
|
||||
let view = RemoteTouchView()
|
||||
view.onMotionEvent = onMotionEvent
|
||||
view.onSwipe = onSwipe
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: RemoteTouchView, context: Context) {
|
||||
uiView.onMotionEvent = onMotionEvent
|
||||
uiView.onSwipe = onSwipe
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
import WebRTC
|
||||
|
||||
/// SwiftUI 包装:WebRTC Metal 视频渲染视图(UIKit)。
|
||||
/// 渲染视图实例由 ViewModel 持有,保证视频轨道 attach 后跨 SwiftUI 刷新周期不丢失。
|
||||
struct RemoteVideoView: UIViewRepresentable {
|
||||
let videoView: RTCMTLVideoView
|
||||
|
||||
func makeUIView(context: Context) -> RTCMTLVideoView {
|
||||
videoView.videoContentMode = .scaleAspectFit
|
||||
return videoView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: RTCMTLVideoView, context: Context) {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
/// UIKit 视图:以 AVSampleBufferDisplayLayer 为 backing layer,
|
||||
/// 用于渲染"自编码"模式下的 H.264 硬解画面(对应 Android 端的 SurfaceView)。
|
||||
final class SampleBufferDisplayView: UIView {
|
||||
|
||||
override class var layerClass: AnyClass {
|
||||
AVSampleBufferDisplayLayer.self
|
||||
}
|
||||
|
||||
var sampleBufferLayer: AVSampleBufferDisplayLayer {
|
||||
layer as! AVSampleBufferDisplayLayer
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
sampleBufferLayer.videoGravity = .resizeAspect
|
||||
backgroundColor = .black
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
sampleBufferLayer.videoGravity = .resizeAspect
|
||||
backgroundColor = .black
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI 包装
|
||||
struct SelfCodecDisplayViewRepresentable: UIViewRepresentable {
|
||||
let view: SampleBufferDisplayView
|
||||
|
||||
func makeUIView(context: Context) -> SampleBufferDisplayView {
|
||||
view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SampleBufferDisplayView, context: Context) {}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
|
||||
/// “自编码”串流模式解码器:
|
||||
/// 从 video_channel DataChannel 接收被控端 MediaCodec 编码的 H.264 裸流,
|
||||
/// 重组分片 -> 解析单元 -> 转换为 AVCC 格式 -> 通过 AVSampleBufferDisplayLayer 硬解渲染。
|
||||
///
|
||||
/// 二进制协议(与被控端 SelfCodecEncoder 保持一致,均为大端序):
|
||||
/// 分片: MAGIC(0xAB,1B) + seq(int32) + total(int16) + idx(int16) + len(int32) + payload
|
||||
/// 单元: type(1B: 1=CONFIG 2=FRAME) + pts(uint32,ms) + isKey(1B) + len(int32) + H.264 Annex-B 数据
|
||||
final class SelfCodecDecoder {
|
||||
|
||||
// ---- 必须与被控端 SelfCodecEncoder 保持一致 ----
|
||||
private static let magic: UInt8 = 0xAB
|
||||
private static let unitTypeConfig: UInt8 = 1
|
||||
private static let unitTypeFrame: UInt8 = 2
|
||||
|
||||
/// 渲染图层(由 SelfCodecDisplayView 提供)
|
||||
weak var displayLayer: AVSampleBufferDisplayLayer?
|
||||
/// 分辨率变化回调(主线程)
|
||||
var onResolutionUpdate: ((Int, Int) -> Void)?
|
||||
|
||||
private(set) var videoWidth = 0
|
||||
private(set) var videoHeight = 0
|
||||
|
||||
private let queue = DispatchQueue(label: "com.ttstd.selfcodec.decoder")
|
||||
private var enabled = false
|
||||
|
||||
// 分片重组
|
||||
private final class ChunkBuffer {
|
||||
var total = 0
|
||||
var received = 0
|
||||
var chunks: [Data?] = []
|
||||
}
|
||||
private var assembling: [Int32: ChunkBuffer] = [:]
|
||||
private var lastSeq: Int32 = -1
|
||||
|
||||
// H.264 参数集与格式
|
||||
private var sps: Data?
|
||||
private var pps: Data?
|
||||
private var formatDescription: CMVideoFormatDescription?
|
||||
|
||||
// MARK: - 开关
|
||||
|
||||
func setEnabled(_ on: Bool) {
|
||||
queue.async {
|
||||
self.enabled = on
|
||||
if !on {
|
||||
self.resetLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func release() {
|
||||
queue.async {
|
||||
self.enabled = false
|
||||
self.resetLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private func resetLocked() {
|
||||
assembling.removeAll()
|
||||
lastSeq = -1
|
||||
sps = nil
|
||||
pps = nil
|
||||
formatDescription = nil
|
||||
videoWidth = 0
|
||||
videoHeight = 0
|
||||
displayLayer?.flushAndRemoveImage()
|
||||
}
|
||||
|
||||
// MARK: - 入口:DataChannel 二进制消息
|
||||
|
||||
func onBinaryMessage(_ raw: Data) {
|
||||
queue.async {
|
||||
self.handleChunk(raw)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleChunk(_ raw: Data) {
|
||||
guard enabled, raw.count >= 13 else { return }
|
||||
var r = BigEndianReader(data: raw)
|
||||
guard r.readByte() == Self.magic else { return }
|
||||
guard let seq = r.readInt32() else { return }
|
||||
|
||||
// 过期检查:同一单元的所有分片 seq 相同,只丢弃明显更旧的单元
|
||||
if lastSeq != -1 && seq < lastSeq && (lastSeq - seq) < 1000 {
|
||||
return
|
||||
}
|
||||
guard let total = r.readInt16(), let idx = r.readInt16(),
|
||||
let len = r.readInt32(), let chunk = r.readData(Int(len)),
|
||||
total > 0 else { return }
|
||||
|
||||
let cb: ChunkBuffer
|
||||
if let existing = assembling[seq] {
|
||||
cb = existing
|
||||
} else {
|
||||
cb = ChunkBuffer()
|
||||
cb.total = Int(total)
|
||||
cb.chunks = Array(repeating: nil, count: Int(total))
|
||||
assembling[seq] = cb
|
||||
if assembling.count > 24, let minKey = assembling.keys.min() {
|
||||
assembling.removeValue(forKey: minKey)
|
||||
}
|
||||
}
|
||||
|
||||
let i = Int(idx)
|
||||
if i >= 0 && i < cb.total && cb.chunks[i] == nil {
|
||||
cb.chunks[i] = chunk
|
||||
cb.received += 1
|
||||
}
|
||||
if cb.received == cb.total {
|
||||
assembling.removeValue(forKey: seq)
|
||||
if seq > lastSeq { lastSeq = seq }
|
||||
var unit = Data()
|
||||
for c in cb.chunks where c != nil { unit.append(c!) }
|
||||
handleUnit(unit)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 单元处理
|
||||
|
||||
private func handleUnit(_ unit: Data) {
|
||||
var r = BigEndianReader(data: unit)
|
||||
guard let type = r.readByte(),
|
||||
let ptsRaw = r.readInt32(),
|
||||
let isKey = r.readByte(),
|
||||
let len = r.readInt32(),
|
||||
let data = r.readData(Int(len)) else { return }
|
||||
let ptsMs = Int64(UInt32(bitPattern: ptsRaw))
|
||||
|
||||
if type == Self.unitTypeConfig {
|
||||
handleConfig(data)
|
||||
} else if type == Self.unitTypeFrame {
|
||||
handleFrame(data, ptsMs: ptsMs, isKey: isKey != 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// CONFIG 单元:MediaCodec 的 csd-0(SPS)/csd-1(PPS),Annex-B 格式
|
||||
private func handleConfig(_ data: Data) {
|
||||
for nal in Self.annexBNalUnits(in: data) {
|
||||
guard let first = nal.first else { continue }
|
||||
switch first & 0x1F {
|
||||
case 7: if sps != nal { sps = nal; formatDescription = nil }
|
||||
case 8: if pps != nal { pps = nal; formatDescription = nil }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
rebuildFormatDescriptionIfNeeded()
|
||||
}
|
||||
|
||||
private func rebuildFormatDescriptionIfNeeded() {
|
||||
guard formatDescription == nil, let sps, let pps else { return }
|
||||
var desc: CMVideoFormatDescription?
|
||||
let status = sps.withUnsafeBytes { spsPtr -> OSStatus in
|
||||
pps.withUnsafeBytes { ppsPtr -> OSStatus in
|
||||
let paramSets: [UnsafePointer<UInt8>] = [
|
||||
spsPtr.bindMemory(to: UInt8.self).baseAddress!,
|
||||
ppsPtr.bindMemory(to: UInt8.self).baseAddress!
|
||||
]
|
||||
let sizes: [Int] = [sps.count, pps.count]
|
||||
return CMVideoFormatDescriptionCreateFromH264ParameterSets(
|
||||
allocator: kCFAllocatorDefault,
|
||||
parameterSetCount: 2,
|
||||
parameterSetPointers: paramSets,
|
||||
parameterSetSizes: sizes,
|
||||
nalUnitHeaderLength: 4,
|
||||
formatDescriptionOut: &desc)
|
||||
}
|
||||
}
|
||||
guard status == noErr, let desc else {
|
||||
NSLog("[SelfCodec] create format description failed: \(status)")
|
||||
return
|
||||
}
|
||||
formatDescription = desc
|
||||
displayLayer?.flush()
|
||||
|
||||
let dims = CMVideoFormatDescriptionGetDimensions(desc)
|
||||
let w = Int(dims.width), h = Int(dims.height)
|
||||
if w != videoWidth || h != videoHeight {
|
||||
videoWidth = w
|
||||
videoHeight = h
|
||||
DispatchQueue.main.async {
|
||||
self.onResolutionUpdate?(w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// FRAME 单元:Annex-B -> AVCC(4 字节大端长度前缀),封装 CMSampleBuffer 后送显
|
||||
private func handleFrame(_ data: Data, ptsMs: Int64, isKey: Bool) {
|
||||
// 帧内可能携带 SPS/PPS(部分编码器关键帧前重发参数集)
|
||||
var vclData = Data()
|
||||
for nal in Self.annexBNalUnits(in: data) {
|
||||
guard let first = nal.first else { continue }
|
||||
let nalType = first & 0x1F
|
||||
if nalType == 7 {
|
||||
if sps != nal { sps = nal; formatDescription = nil }
|
||||
continue
|
||||
}
|
||||
if nalType == 8 {
|
||||
if pps != nal { pps = nal; formatDescription = nil }
|
||||
continue
|
||||
}
|
||||
var lenBE = UInt32(nal.count).bigEndian
|
||||
withUnsafeBytes(of: &lenBE) { vclData.append(contentsOf: $0) }
|
||||
vclData.append(nal)
|
||||
}
|
||||
rebuildFormatDescriptionIfNeeded()
|
||||
guard enabled, !vclData.isEmpty, let formatDescription, let layer = displayLayer else { return }
|
||||
|
||||
// 构造 CMBlockBuffer
|
||||
var blockBuffer: CMBlockBuffer?
|
||||
var status = CMBlockBufferCreateWithMemoryBlock(
|
||||
allocator: kCFAllocatorDefault,
|
||||
memoryBlock: nil,
|
||||
blockLength: vclData.count,
|
||||
blockAllocator: kCFAllocatorDefault,
|
||||
customBlockSource: nil,
|
||||
offsetToData: 0,
|
||||
dataLength: vclData.count,
|
||||
flags: 0,
|
||||
blockBufferOut: &blockBuffer)
|
||||
guard status == kCMBlockBufferNoErr, let blockBuffer else { return }
|
||||
status = vclData.withUnsafeBytes { ptr in
|
||||
CMBlockBufferReplaceDataBytes(
|
||||
with: ptr.baseAddress!,
|
||||
blockBuffer: blockBuffer,
|
||||
offsetIntoDestination: 0,
|
||||
dataLength: vclData.count)
|
||||
}
|
||||
guard status == kCMBlockBufferNoErr else { return }
|
||||
|
||||
// 构造 CMSampleBuffer(立即显示,不依赖时间轴)
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
var timing = CMSampleTimingInfo(
|
||||
duration: .invalid,
|
||||
presentationTimeStamp: CMTime(value: ptsMs, timescale: 1000),
|
||||
decodeTimeStamp: .invalid)
|
||||
var sampleSize = vclData.count
|
||||
status = CMSampleBufferCreateReady(
|
||||
allocator: kCFAllocatorDefault,
|
||||
dataBuffer: blockBuffer,
|
||||
formatDescription: formatDescription,
|
||||
sampleCount: 1,
|
||||
sampleTimingEntryCount: 1,
|
||||
sampleTimingArray: &timing,
|
||||
sampleSizeEntryCount: 1,
|
||||
sampleSizeArray: &sampleSize,
|
||||
sampleBufferOut: &sampleBuffer)
|
||||
guard status == noErr, let sampleBuffer else { return }
|
||||
|
||||
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: true) as? [CFMutableDictionary],
|
||||
let dict = attachments.first {
|
||||
CFDictionarySetValue(
|
||||
dict,
|
||||
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
|
||||
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||||
if !isKey {
|
||||
CFDictionarySetValue(
|
||||
dict,
|
||||
Unmanaged.passUnretained(kCMSampleAttachmentKey_NotSync).toOpaque(),
|
||||
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||||
}
|
||||
}
|
||||
|
||||
if layer.status == .failed || layer.requiresFlushToResumeDecoding {
|
||||
layer.flush()
|
||||
}
|
||||
layer.enqueue(sampleBuffer)
|
||||
}
|
||||
|
||||
// MARK: - Annex-B 拆分
|
||||
|
||||
/// 拆分 Annex-B 码流(00 00 01 / 00 00 00 01 起始码)为 NAL 单元数组(不含起始码)
|
||||
static func annexBNalUnits(in data: Data) -> [Data] {
|
||||
var result: [Data] = []
|
||||
let bytes = [UInt8](data)
|
||||
let count = bytes.count
|
||||
var starts: [Int] = []
|
||||
var i = 0
|
||||
while i + 2 < count {
|
||||
if bytes[i] == 0 && bytes[i + 1] == 0 && bytes[i + 2] == 1 {
|
||||
starts.append(i + 3)
|
||||
i += 3
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
guard !starts.isEmpty else { return data.isEmpty ? [] : [data] }
|
||||
for (idx, start) in starts.enumerated() {
|
||||
var end = count
|
||||
if idx + 1 < starts.count {
|
||||
end = starts[idx + 1] - 3
|
||||
// 兼容 4 字节起始码(00 00 00 01)
|
||||
if end > start && bytes[end - 1] == 0 { end -= 1 }
|
||||
}
|
||||
if end > start {
|
||||
result.append(Data(bytes[start..<end]))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 大端序读取器
|
||||
|
||||
private struct BigEndianReader {
|
||||
let data: Data
|
||||
private var offset: Int
|
||||
|
||||
init(data: Data) {
|
||||
self.data = data
|
||||
self.offset = data.startIndex
|
||||
}
|
||||
|
||||
mutating func readByte() -> UInt8? {
|
||||
guard offset < data.endIndex else { return nil }
|
||||
defer { offset += 1 }
|
||||
return data[offset]
|
||||
}
|
||||
|
||||
mutating func readInt16() -> Int16? {
|
||||
guard offset + 2 <= data.endIndex else { return nil }
|
||||
let v = (UInt16(data[offset]) << 8) | UInt16(data[offset + 1])
|
||||
offset += 2
|
||||
return Int16(bitPattern: v)
|
||||
}
|
||||
|
||||
mutating func readInt32() -> Int32? {
|
||||
guard offset + 4 <= data.endIndex else { return nil }
|
||||
var v: UInt32 = 0
|
||||
for i in 0..<4 { v = (v << 8) | UInt32(data[offset + i]) }
|
||||
offset += 4
|
||||
return Int32(bitPattern: v)
|
||||
}
|
||||
|
||||
mutating func readData(_ length: Int) -> Data? {
|
||||
guard length >= 0, offset + length <= data.endIndex else { return nil }
|
||||
defer { offset += length }
|
||||
return data.subdata(in: offset..<(offset + length))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import Foundation
|
||||
import WebRTC
|
||||
|
||||
protocol WebRTCClientDelegate: AnyObject {
|
||||
/// ICE 连接建立(可以开始远程控制)
|
||||
func webRTCClientDidConnect()
|
||||
/// 连接断开(远端断开或网络故障)
|
||||
func webRTCClientDidDisconnect()
|
||||
/// 连接失败
|
||||
func webRTCClient(didFail error: String)
|
||||
/// 被控端上报当前串流模式(0=WebRTC 1=自编码)
|
||||
func webRTCClient(didReportStreamMode mode: Int)
|
||||
/// 被控端上报当前采集分辨率
|
||||
func webRTCClient(didReportResolution width: Int, height: Int)
|
||||
}
|
||||
|
||||
/// WebRTC 客户端:负责 PeerConnection 的创建、Offer/Answer 协商、
|
||||
/// ICE 候选交换、控制/视频 DataChannel 管理与统计信息获取。
|
||||
/// 与 Android 端 WebRtcClient 行为保持一致。
|
||||
final class WebRTCClient: NSObject {
|
||||
|
||||
static let streamModeWebRTC: Int32 = 0
|
||||
static let streamModeSelfCodec: Int32 = 1
|
||||
|
||||
private static let controlChannelLabel = "control_channel"
|
||||
private static let videoChannelLabel = "video_channel"
|
||||
|
||||
private static let factory: RTCPeerConnectionFactory = {
|
||||
RTCInitializeSSL()
|
||||
return RTCPeerConnectionFactory(
|
||||
encoderFactory: RTCDefaultVideoEncoderFactory(),
|
||||
decoderFactory: RTCDefaultVideoDecoderFactory())
|
||||
}()
|
||||
|
||||
weak var delegate: WebRTCClientDelegate?
|
||||
/// 自编码模式的 H.264 裸流解码器(video_channel 二进制数据直接转发给它)
|
||||
var selfCodecDecoder: SelfCodecDecoder?
|
||||
|
||||
private let signaling: SignalingClient
|
||||
private let myDeviceId: String
|
||||
private var targetDeviceId: String = ""
|
||||
private var peerConnection: RTCPeerConnection?
|
||||
private var controlChannel: RTCDataChannel?
|
||||
private var videoChannel: RTCDataChannel?
|
||||
private weak var remoteRenderer: RTCVideoRenderer?
|
||||
private var remoteVideoTrack: RTCVideoTrack?
|
||||
private var connected = false
|
||||
|
||||
init(signaling: SignalingClient, myDeviceId: String) {
|
||||
self.signaling = signaling
|
||||
self.myDeviceId = myDeviceId
|
||||
super.init()
|
||||
}
|
||||
|
||||
var isDataChannelOpen: Bool {
|
||||
controlChannel?.readyState == .open
|
||||
}
|
||||
|
||||
// MARK: - 连接流程
|
||||
|
||||
/// 创建 PeerConnection 与 DataChannel,并向目标设备发送 Offer(携带鉴权信息)。
|
||||
func createOffer(targetDeviceId: String,
|
||||
renderer: RTCVideoRenderer,
|
||||
authType: String?,
|
||||
authValue: String?) {
|
||||
self.targetDeviceId = targetDeviceId
|
||||
self.remoteRenderer = renderer
|
||||
|
||||
let config = RTCConfiguration()
|
||||
config.iceServers = [
|
||||
RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"]),
|
||||
RTCIceServer(urlStrings: ["stun:www.ttstd.com:3478"]),
|
||||
RTCIceServer(urlStrings: ["turn:www.ttstd.com:3478"],
|
||||
username: "ttstd",
|
||||
credential: "ttstd123")
|
||||
]
|
||||
config.sdpSemantics = .unifiedPlan
|
||||
config.continualGatheringPolicy = .gatherContinually
|
||||
config.iceCandidatePoolSize = 10
|
||||
config.iceTransportPolicy = .all
|
||||
|
||||
let pcConstraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
|
||||
guard let pc = Self.factory.peerConnection(with: config, constraints: pcConstraints, delegate: self) else {
|
||||
notifyFail("PeerConnection 创建失败")
|
||||
return
|
||||
}
|
||||
peerConnection = pc
|
||||
|
||||
// 只接收视频,不发送本地媒体
|
||||
let transceiverInit = RTCRtpTransceiverInit()
|
||||
transceiverInit.direction = .recvOnly
|
||||
pc.addTransceiver(of: .video, init: transceiverInit)
|
||||
|
||||
// 控制通道:可靠、有序
|
||||
let controlConfig = RTCDataChannelConfiguration()
|
||||
controlConfig.isOrdered = true
|
||||
controlChannel = pc.dataChannel(forLabel: Self.controlChannelLabel, configuration: controlConfig)
|
||||
controlChannel?.delegate = self
|
||||
|
||||
// 自编码视频通道:不可靠、低延迟(丢帧优先)
|
||||
let videoConfig = RTCDataChannelConfiguration()
|
||||
videoConfig.isOrdered = false
|
||||
videoConfig.maxRetransmits = 0
|
||||
videoChannel = pc.dataChannel(forLabel: Self.videoChannelLabel, configuration: videoConfig)
|
||||
videoChannel?.delegate = self
|
||||
|
||||
let offerConstraints = RTCMediaConstraints(
|
||||
mandatoryConstraints: [
|
||||
"OfferToReceiveVideo": "true",
|
||||
"OfferToReceiveAudio": "false"
|
||||
],
|
||||
optionalConstraints: nil)
|
||||
|
||||
pc.offer(for: offerConstraints) { [weak self] sdp, error in
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
self.notifyFail("创建 Offer 失败: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
guard let sdp else {
|
||||
self.notifyFail("创建 Offer 失败: SDP 为空")
|
||||
return
|
||||
}
|
||||
pc.setLocalDescription(sdp) { [weak self] error in
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
self.notifyFail("设置本地 SDP 失败: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
self.sendOffer(sdp: sdp.sdp, authType: authType, authValue: authValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendOffer(sdp: String, authType: String?, authValue: String?) {
|
||||
var msg = SignalMessage()
|
||||
msg.type = "OFFER"
|
||||
msg.fromDeviceId = myDeviceId
|
||||
msg.toDeviceId = targetDeviceId
|
||||
msg.payload = SignalMessage.encodePayload(["sdp": sdp])
|
||||
msg.authType = authType
|
||||
msg.authValue = authValue
|
||||
signaling.send(msg)
|
||||
}
|
||||
|
||||
/// 处理被控端返回的 Answer
|
||||
func handleAnswer(sdp: String) {
|
||||
let desc = RTCSessionDescription(type: .answer, sdp: sdp)
|
||||
peerConnection?.setRemoteDescription(desc) { [weak self] error in
|
||||
if let error {
|
||||
self?.notifyFail("设置远端 SDP 失败: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加远端 ICE 候选
|
||||
func addIceCandidate(sdpMid: String, sdpMLineIndex: Int32, candidate: String) {
|
||||
let ice = RTCIceCandidate(sdp: candidate, sdpMLineIndex: sdpMLineIndex, sdpMid: sdpMid)
|
||||
peerConnection?.add(ice) { error in
|
||||
if let error {
|
||||
NSLog("[WebRTC] addIceCandidate error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 控制指令
|
||||
|
||||
func sendControlCommand(_ message: ControlMessage) {
|
||||
guard let channel = controlChannel, channel.readyState == .open else { return }
|
||||
let buffer = RTCDataBuffer(data: message.serializedData(), isBinary: true)
|
||||
channel.sendData(buffer)
|
||||
}
|
||||
|
||||
/// 请求被控端切换采集分辨率(width=0 表示恢复原生分辨率)
|
||||
func requestResolutionChange(width: Int32, height: Int32, fps: Int32) {
|
||||
var msg = ControlMessage()
|
||||
msg.action = .setResolution
|
||||
msg.width = width
|
||||
msg.height = height
|
||||
msg.fps = fps
|
||||
sendControlCommand(msg)
|
||||
}
|
||||
|
||||
/// 请求被控端切换串流模式(0=WebRTC 1=自编码)
|
||||
func sendStreamMode(_ mode: Int32) {
|
||||
var msg = ControlMessage()
|
||||
msg.action = .setStreamMode
|
||||
msg.streamMode = mode
|
||||
sendControlCommand(msg)
|
||||
}
|
||||
|
||||
// MARK: - 统计
|
||||
|
||||
func stats(_ completion: @escaping (RTCStatisticsReport) -> Void) {
|
||||
peerConnection?.statistics(completionHandler: completion)
|
||||
}
|
||||
|
||||
// MARK: - 关闭
|
||||
|
||||
func close() {
|
||||
connected = false
|
||||
controlChannel?.close()
|
||||
videoChannel?.close()
|
||||
controlChannel = nil
|
||||
videoChannel = nil
|
||||
if let track = remoteVideoTrack, let renderer = remoteRenderer {
|
||||
track.remove(renderer)
|
||||
}
|
||||
remoteVideoTrack = nil
|
||||
peerConnection?.close()
|
||||
peerConnection = nil
|
||||
}
|
||||
|
||||
private func notifyFail(_ text: String) {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.webRTCClient(didFail: text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RTCPeerConnectionDelegate
|
||||
|
||||
extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
|
||||
func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {}
|
||||
|
||||
func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {}
|
||||
|
||||
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
|
||||
|
||||
func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
|
||||
|
||||
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
|
||||
DispatchQueue.main.async {
|
||||
switch newState {
|
||||
case .connected, .completed:
|
||||
if !self.connected {
|
||||
self.connected = true
|
||||
self.delegate?.webRTCClientDidConnect()
|
||||
}
|
||||
case .disconnected, .failed, .closed:
|
||||
if self.connected {
|
||||
self.connected = false
|
||||
self.delegate?.webRTCClientDidDisconnect()
|
||||
} else if newState == .failed {
|
||||
self.delegate?.webRTCClient(didFail: "ICE 连接失败")
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {}
|
||||
|
||||
func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {
|
||||
var msg = SignalMessage()
|
||||
msg.type = "ICE_CANDIDATE"
|
||||
msg.fromDeviceId = myDeviceId
|
||||
msg.toDeviceId = targetDeviceId
|
||||
msg.payload = SignalMessage.encodePayload([
|
||||
"sdpMid": candidate.sdpMid ?? "",
|
||||
"sdpMLineIndex": Int(candidate.sdpMLineIndex),
|
||||
"candidate": candidate.sdp
|
||||
])
|
||||
signaling.send(msg)
|
||||
}
|
||||
|
||||
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {}
|
||||
|
||||
func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {
|
||||
// 作为 Offer 方,通道由本端创建;此回调用于兜底远端创建的通道
|
||||
dataChannel.delegate = self
|
||||
if dataChannel.label == Self.videoChannelLabel {
|
||||
videoChannel = dataChannel
|
||||
} else if dataChannel.label == Self.controlChannelLabel {
|
||||
controlChannel = dataChannel
|
||||
}
|
||||
}
|
||||
|
||||
func peerConnection(_ peerConnection: RTCPeerConnection,
|
||||
didAdd rtpReceiver: RTCRtpReceiver,
|
||||
streams mediaStreams: [RTCMediaStream]) {
|
||||
guard let track = rtpReceiver.track as? RTCVideoTrack else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.remoteVideoTrack = track
|
||||
if let renderer = self.remoteRenderer {
|
||||
track.add(renderer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RTCDataChannelDelegate
|
||||
|
||||
extension WebRTCClient: RTCDataChannelDelegate {
|
||||
|
||||
func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) {
|
||||
NSLog("[WebRTC] DataChannel \(dataChannel.label) state: \(dataChannel.readyState.rawValue)")
|
||||
}
|
||||
|
||||
func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) {
|
||||
if dataChannel.label == Self.videoChannelLabel {
|
||||
// 自编码 H.264 裸流分片
|
||||
selfCodecDecoder?.onBinaryMessage(buffer.data)
|
||||
return
|
||||
}
|
||||
// 控制通道:被控端上报(串流模式 / 分辨率)
|
||||
guard buffer.isBinary, let msg = ControlMessage.parse(from: buffer.data) else { return }
|
||||
DispatchQueue.main.async {
|
||||
switch msg.action {
|
||||
case .reportStreamMode:
|
||||
self.delegate?.webRTCClient(didReportStreamMode: Int(msg.streamMode))
|
||||
case .reportResolution:
|
||||
self.delegate?.webRTCClient(didReportResolution: Int(msg.width), height: Int(msg.height))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user