feat(ios): 添加远端视频录制功能并优化信令连接稳定性
- 新增 VideoRecorder 组件,支持 WebRTC 和自编码两种模式录制远端视频为 MP4 - 优化 SignalingClient 连接管理,修复 WebSocket 握手前 receive 导致的断开问题 - 重构控制面板 UI,支持统计信息折叠展开和录制控制按钮 - 添加录制状态指示、保存路径提示及断开自动取消录制逻辑 - 更新项目配置和文档,补充录制功能说明
This commit is contained in:
@@ -33,6 +33,8 @@ class VideoRecorder {
|
||||
throw Exception('当前没有可用的远端视频轨道,无法录制');
|
||||
}
|
||||
final path = await _buildOutputPath();
|
||||
// ignore: avoid_print
|
||||
print('[VideoRecorder] 录制开始,保存地址:$path');
|
||||
_recorder = MediaRecorder(albumName: 'WebRTCRecordings');
|
||||
await _recorder!.start(path, videoTrack: videoTracks.first);
|
||||
_currentPath = path;
|
||||
@@ -55,6 +57,8 @@ class VideoRecorder {
|
||||
}
|
||||
final p = _currentPath;
|
||||
_currentPath = null;
|
||||
// ignore: avoid_print
|
||||
print('[VideoRecorder] 录制结束,已保存至:$p');
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,8 +47,20 @@ web_rtc_controller_ios/
|
||||
- 导航键:返回 / 主页 / 多任务(Android keyCode 4 / 3 / 187)
|
||||
- 分辨率切换:原始画质 / 1080P / 720P / 480P
|
||||
- 串流模式切换:WebRTC(SRTP 视频轨)/ 自编码(DataChannel H.264 裸流 + VideoToolbox 硬解)
|
||||
- 远端视频录制:连接后可将收到的画面录制为 MP4(两种串流模式均支持)
|
||||
- 实时统计:分辨率、帧率、码率、RTT、链路类型(P2P/TURN)、编解码器等
|
||||
|
||||
## 录制说明
|
||||
|
||||
控制面板顶部「录制 / 停止」按钮用于录制远端画面,行为对齐 Android 端 `VideoRecorder`:
|
||||
|
||||
- **WebRTC 模式**:`WebRTCClient` 把远端视频轨 fan-out 一份给 `VideoRecorder`(实现 `RTCVideoRenderer`),
|
||||
拿到解码后的 `CVPixelBuffer`,用 `AVAssetWriter` + H.264 硬编码封装为 MP4。
|
||||
- **自编码模式**:`SelfCodecDecoder` 用 VideoToolbox 硬解出 AVCC 帧后回传给 `VideoRecorder`,
|
||||
复用已解码的 H.264 帧写盘。
|
||||
- 录制文件保存在 App 沙盒 `Documents/WebRTCRecordings/rec_YYYYMMDD_HHmmss.mp4`,停止后弹出保存路径提示。
|
||||
- 断开连接时若仍在录制会自动取消(丢弃未保存内容)。
|
||||
|
||||
## 运行
|
||||
|
||||
1. 用 Xcode 打开 `web_rtc_controller_ios.xcodeproj`,首次打开等待 SPM 解析 WebRTC 依赖。
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
FB000000000000000000000E /* DeviceUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000E /* DeviceUtils.swift */; };
|
||||
FB000000000000000000000F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000F /* Assets.xcassets */; };
|
||||
FB0000000000000000000010 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = FE0000000000000000000002 /* WebRTC */; };
|
||||
FB0000000000000000000012 /* VideoRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000012 /* VideoRecorder.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -43,6 +44,7 @@
|
||||
FA000000000000000000000F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
FA0000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
FA0000000000000000000011 /* web_rtc_controller_ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = web_rtc_controller_ios.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FA0000000000000000000012 /* VideoRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRecorder.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -120,6 +122,7 @@
|
||||
children = (
|
||||
FA0000000000000000000006 /* WebRTCClient.swift */,
|
||||
FA0000000000000000000007 /* SelfCodecDecoder.swift */,
|
||||
FA0000000000000000000012 /* VideoRecorder.swift */,
|
||||
);
|
||||
path = WebRTC;
|
||||
sourceTree = "<group>";
|
||||
@@ -183,7 +186,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1500;
|
||||
LastUpgradeCheck = 1500;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
FD0000000000000000000002 = {
|
||||
CreatedOnToolsVersion = 15.0;
|
||||
@@ -235,6 +238,7 @@
|
||||
FB0000000000000000000005 /* ControlMessage.swift in Sources */,
|
||||
FB0000000000000000000006 /* WebRTCClient.swift in Sources */,
|
||||
FB0000000000000000000007 /* SelfCodecDecoder.swift in Sources */,
|
||||
FB0000000000000000000012 /* VideoRecorder.swift in Sources */,
|
||||
FB0000000000000000000008 /* RemoteTouchView.swift in Sources */,
|
||||
FB0000000000000000000009 /* RemoteVideoView.swift in Sources */,
|
||||
FB000000000000000000000A /* SelfCodecDisplayView.swift in Sources */,
|
||||
@@ -253,6 +257,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -306,6 +311,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -316,6 +322,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -362,6 +369,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
@@ -444,8 +452,8 @@
|
||||
repositoryURL = "https://github.com/stasel/WebRTC.git";
|
||||
requirement = {
|
||||
kind = versionRange;
|
||||
minimumVersion = 120.0.0;
|
||||
maximumVersion = 200.0.0;
|
||||
minimumVersion = 120.0.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "7ca243407a3b132834ec3b117a3aea85a8f743151ef868a872c8bbf8082921f6",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "webrtc",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/stasel/WebRTC.git",
|
||||
"state" : {
|
||||
"revision" : "6ed87f05368632f71dc95c89c14c051561710925",
|
||||
"version" : "150.0.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
<?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>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>web_rtc_controller_ios.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -19,6 +19,10 @@ final class SignalingClient: NSObject {
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionWebSocketTask?
|
||||
private var manuallyClosed = false
|
||||
/// WebSocket 握手是否已完成(didOpen 后置为 true)
|
||||
private var isOpened = false
|
||||
/// 是否已上报过断开/失败,避免重复回调
|
||||
private var didNotifyClosure = false
|
||||
|
||||
init(serverUrl: String, deviceId: String) {
|
||||
self.serverUrl = serverUrl
|
||||
@@ -32,16 +36,20 @@ final class SignalingClient: NSObject {
|
||||
return
|
||||
}
|
||||
manuallyClosed = false
|
||||
isOpened = false
|
||||
didNotifyClosure = false
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 15
|
||||
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
task = session?.webSocketTask(with: url)
|
||||
// 接收循环在 didOpenWithProtocol(握手完成)后再启动,
|
||||
// 避免在 socket 未真正连接时调用 receive 触发 Code 57。
|
||||
task?.resume()
|
||||
receiveLoop()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
manuallyClosed = true
|
||||
isOpened = false
|
||||
task?.cancel(with: .normalClosure, reason: "Disconnecting".data(using: .utf8))
|
||||
task = nil
|
||||
session?.invalidateAndCancel()
|
||||
@@ -85,9 +93,31 @@ final class SignalingClient: NSObject {
|
||||
}
|
||||
self.receiveLoop()
|
||||
case .failure(let error):
|
||||
if !self.manuallyClosed {
|
||||
self.notifyError(error.localizedDescription)
|
||||
}
|
||||
self.handleReceiveFailure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理接收失败:区分"正常断开"与"连接失败"。
|
||||
/// Socket 已断开(Code 57 等)视为断开而非致命错误。
|
||||
private func handleReceiveFailure(_ error: Error) {
|
||||
guard !manuallyClosed, !didNotifyClosure else { return }
|
||||
didNotifyClosure = true
|
||||
|
||||
let nsError = error as NSError
|
||||
// NSPOSIXErrorDomain Code 57: Socket is not connected(连接已断开)
|
||||
let isDisconnect = (nsError.domain == NSPOSIXErrorDomain && nsError.code == 57)
|
||||
|| (nsError.domain == NSURLErrorDomain
|
||||
&& (nsError.code == NSURLErrorNetworkConnectionLost
|
||||
|| nsError.code == NSURLErrorCancelled))
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if self.isOpened || isDisconnect {
|
||||
// 连接已建立过后再断开,按"断开"处理
|
||||
self.delegate?.signalingDidDisconnect()
|
||||
} else {
|
||||
// 从未成功建立连接,按"连接失败"处理
|
||||
self.delegate?.signaling(didFail: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +136,9 @@ extension SignalingClient: URLSessionWebSocketDelegate {
|
||||
func urlSession(_ session: URLSession,
|
||||
webSocketTask: URLSessionWebSocketTask,
|
||||
didOpenWithProtocol protocol: String?) {
|
||||
isOpened = true
|
||||
// 握手完成后再启动接收循环,避免 socket 未连接时 receive 报错
|
||||
receiveLoop()
|
||||
registerDevice()
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.signalingDidConnect()
|
||||
@@ -116,7 +149,9 @@ extension SignalingClient: URLSessionWebSocketDelegate {
|
||||
webSocketTask: URLSessionWebSocketTask,
|
||||
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
||||
reason: Data?) {
|
||||
guard !manuallyClosed else { return }
|
||||
isOpened = false
|
||||
guard !manuallyClosed, !didNotifyClosure else { return }
|
||||
didNotifyClosure = true
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.signalingDidDisconnect()
|
||||
}
|
||||
|
||||
@@ -48,12 +48,14 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
// MARK: - UI 状态
|
||||
|
||||
@Published var serverUrl: String = "wss://www.ttstd.com/signal"
|
||||
@Published var targetDeviceId: String = ""
|
||||
@Published var targetDeviceId: String = "981964879"
|
||||
@Published var statusText: String = "未连接"
|
||||
/// 是否显示控制界面(对应 Android 的 setupPanel/controlPanel 切换)
|
||||
@Published var isControlling: Bool = false
|
||||
@Published var isConnecting: Bool = false
|
||||
@Published var statsText: String = ""
|
||||
/// 统计信息面板是否展开(默认收起,仅显示概要)
|
||||
@Published var statsExpanded: Bool = false
|
||||
@Published var streamMode: StreamMode = .webrtc
|
||||
@Published var selectedResolution: ResolutionOption = ResolutionOption.all[0]
|
||||
/// 帧率档位:默认常用档位,收到被控端上报的 supported_fps 后以上报列表为准
|
||||
@@ -65,6 +67,11 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
@Published var showAuthSheet: Bool = false
|
||||
@Published var alertMessage: String?
|
||||
|
||||
/// 是否正在录制远端视频
|
||||
@Published var isRecording: Bool = false
|
||||
/// 最近一次录制保存的文件路径(沙盒内)
|
||||
@Published var lastRecordingPath: String?
|
||||
|
||||
let myDeviceId: String = DeviceUtils.deviceId()
|
||||
|
||||
// MARK: - UIKit 渲染视图(由 ViewModel 持有,SwiftUI 通过 Representable 嵌入)
|
||||
@@ -77,6 +84,8 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
private var signalingClient: SignalingClient?
|
||||
private var webRTCClient: WebRTCClient?
|
||||
private let selfCodecDecoder = SelfCodecDecoder()
|
||||
/// 远端视频录制器(MP4),跟随两种串流模式工作
|
||||
private let videoRecorder = VideoRecorder()
|
||||
|
||||
private var statsTimer: Timer?
|
||||
private var connectionStartTime: Date?
|
||||
@@ -98,6 +107,24 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
guard let self, self.streamMode == .selfCodec, w > 0, h > 0 else { return }
|
||||
self.videoAspect = CGFloat(w) / CGFloat(h)
|
||||
}
|
||||
// 自编码模式:把硬解出的 AVCC 帧回传给录制器写盘
|
||||
selfCodecDecoder.onDecodedSampleBuffer = { [weak self] sample, isKey in
|
||||
self?.videoRecorder.appendDecodedSampleBuffer(sample, isKey: isKey)
|
||||
}
|
||||
// 录制状态/结果回调
|
||||
videoRecorder.onStateChange = { [weak self] state in
|
||||
self?.isRecording = (state == .recording)
|
||||
}
|
||||
videoRecorder.onSaved = { [weak self] path in
|
||||
self?.lastRecordingPath = path
|
||||
self?.alertMessage = "录制已保存:\n\(path)"
|
||||
}
|
||||
videoRecorder.onSavedEmpty = { [weak self] in
|
||||
self?.alertMessage = "未录到任何画面"
|
||||
}
|
||||
videoRecorder.onError = { [weak self] msg in
|
||||
self?.alertMessage = "录制失败: \(msg)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 连接 / 断开
|
||||
@@ -134,6 +161,8 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
|
||||
func disconnect() {
|
||||
stopStatsTimer()
|
||||
videoRecorder.cancel()
|
||||
isRecording = false
|
||||
selfCodecDecoder.setEnabled(false)
|
||||
webRTCClient?.close()
|
||||
webRTCClient = nil
|
||||
@@ -225,6 +254,25 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
selfCodecDecoder.setEnabled(mode == .selfCodec)
|
||||
}
|
||||
|
||||
// MARK: - 录制
|
||||
|
||||
/// 开始录制远端画面(WebRTC 与自编码两种模式均支持)。
|
||||
/// 需在已连接后调用;若尚未建立连接会提示。
|
||||
func startRecording() {
|
||||
guard isControlling else {
|
||||
alertMessage = "请先连接被控设备"
|
||||
return
|
||||
}
|
||||
guard !isRecording else { return }
|
||||
videoRecorder.start()
|
||||
}
|
||||
|
||||
/// 停止录制并保存 MP4 到沙盒 Documents/WebRTCRecordings。
|
||||
func stopRecording() {
|
||||
guard isRecording else { return }
|
||||
videoRecorder.stop()
|
||||
}
|
||||
|
||||
// MARK: - 私有
|
||||
|
||||
fileprivate func startWebRTC() {
|
||||
@@ -233,6 +281,7 @@ final class ControllerViewModel: NSObject, ObservableObject {
|
||||
let client = WebRTCClient(signaling: signaling, myDeviceId: myDeviceId)
|
||||
client.delegate = self
|
||||
client.selfCodecDecoder = selfCodecDecoder
|
||||
client.extraRenderer = videoRecorder
|
||||
webRTCClient = client
|
||||
client.createOffer(
|
||||
targetDeviceId: targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
|
||||
@@ -67,7 +67,7 @@ private struct SetupPanelView: View {
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
Text(viewModel.isConnecting ? "连接中..." : "连接")
|
||||
.fontWeight(.semibold)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
@@ -101,84 +101,150 @@ private struct ControlPanelView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
statsPanel
|
||||
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)
|
||||
}
|
||||
}
|
||||
/// 统计信息面板:置于最上方,点击可展开/收起详情
|
||||
private var statsPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.statsExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.selectedResolution.title, systemImage: "rectangle.compress.vertical")
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
// 帧率选择(档位来自被控端 REPORT_RESOLUTION 上报)
|
||||
Menu {
|
||||
ForEach(viewModel.fpsOptions, id: \.self) { fps in
|
||||
Button {
|
||||
viewModel.selectFps(fps)
|
||||
} label: {
|
||||
if fps == viewModel.currentFps {
|
||||
Label("\(fps)fps", systemImage: "checkmark")
|
||||
} else {
|
||||
Text("\(fps)fps")
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
.font(.system(size: 11))
|
||||
Text(statsSummary)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer()
|
||||
Image(systemName: viewModel.statsExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.currentFps > 0 ? "\(viewModel.currentFps)fps" : "帧率",
|
||||
systemImage: "speedometer")
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color(white: 0.8))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.disconnect()
|
||||
} label: {
|
||||
Text("断开")
|
||||
.font(.footnote)
|
||||
.fontWeight(.semibold)
|
||||
if viewModel.statsExpanded {
|
||||
Text(viewModel.statsText.isEmpty ? "暂无统计数据" : viewModel.statsText)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(Color(white: 0.75))
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(white: 0.08))
|
||||
}
|
||||
|
||||
/// 概要行:取统计信息首行内容,收起时显示
|
||||
private var statsSummary: String {
|
||||
if let first = viewModel.statsText.split(separator: "\n").first {
|
||||
return String(first)
|
||||
}
|
||||
return "统计信息"
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
Text(viewModel.statusText)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.green)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
|
||||
// 串流模式切换(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)
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
// 帧率选择(档位来自被控端 REPORT_RESOLUTION 上报)
|
||||
Menu {
|
||||
ForEach(viewModel.fpsOptions, id: \.self) { fps in
|
||||
Button {
|
||||
viewModel.selectFps(fps)
|
||||
} label: {
|
||||
if fps == viewModel.currentFps {
|
||||
Label("\(fps)fps", systemImage: "checkmark")
|
||||
} else {
|
||||
Text("\(fps)fps")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.currentFps > 0 ? "\(viewModel.currentFps)fps" : "帧率",
|
||||
systemImage: "speedometer")
|
||||
.font(.footnote)
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
// 录制远端视频(MP4)
|
||||
Button {
|
||||
if viewModel.isRecording {
|
||||
viewModel.stopRecording()
|
||||
} else {
|
||||
viewModel.startRecording()
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.isRecording ? "停止" : "录制",
|
||||
systemImage: viewModel.isRecording ? "stop.circle.fill" : "circle.circle")
|
||||
.font(Font.footnote.weight(.semibold))
|
||||
.fixedSize()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(viewModel.isRecording ? .red : .orange)
|
||||
.controlSize(.small)
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.disconnect()
|
||||
} label: {
|
||||
Text("断开")
|
||||
.font(Font.footnote.weight(.semibold))
|
||||
.fixedSize()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.background(Color(white: 0.1))
|
||||
}
|
||||
|
||||
@@ -205,23 +271,30 @@ private struct ControlPanelView: View {
|
||||
}
|
||||
.aspectRatio(viewModel.videoAspect, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
// 录制中指示(左上角红点)
|
||||
if viewModel.isRecording {
|
||||
VStack {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color.red)
|
||||
.frame(width: 10, height: 10)
|
||||
Text("REC")
|
||||
.font(Font.caption2.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.black.opacity(0.5))
|
||||
.cornerRadius(6)
|
||||
Spacer()
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
|
||||
@@ -20,6 +20,8 @@ final class SelfCodecDecoder {
|
||||
weak var displayLayer: AVSampleBufferDisplayLayer?
|
||||
/// 分辨率变化回调(主线程)
|
||||
var onResolutionUpdate: ((Int, Int) -> Void)?
|
||||
/// 自编码模式录制回调:解码出的 AVCC CMSampleBuffer 会回传(由 VideoRecorder 写盘)
|
||||
var onDecodedSampleBuffer: ((CMSampleBuffer, Bool) -> Void)?
|
||||
|
||||
private(set) var videoWidth = 0
|
||||
private(set) var videoHeight = 0
|
||||
@@ -268,6 +270,9 @@ final class SelfCodecDecoder {
|
||||
layer.flush()
|
||||
}
|
||||
layer.enqueue(sampleBuffer)
|
||||
|
||||
// 自编码模式录制:把解码后的 AVCC 帧回传给录制器写盘
|
||||
onDecodedSampleBuffer?(sampleBuffer, isKey)
|
||||
}
|
||||
|
||||
// MARK: - Annex-B 拆分
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import WebRTC
|
||||
|
||||
/// 远端视频录制器:把主控端收到的远程画面编码为 MP4 保存到 App 沙盒 Documents/WebRTCRecordings。
|
||||
///
|
||||
/// 与 Android 端 `VideoRecorder` 行为对齐,支持两种串流模式:
|
||||
/// - WebRTC 模式:实现 `RTCVideoRenderer`,由 `WebRTCClient` 把远端视频轨 fan-out 一份过来,
|
||||
/// 直接拿到解码后的 `CVPixelBuffer`,用 `AVAssetWriter` + H.264 硬编码封装为 MP4。
|
||||
/// - 自编码模式:由 `SelfCodecDecoder` 在硬解出 `CMSampleBuffer`(AVCC)后调用
|
||||
/// `appendDecodedSampleBuffer(_:isKey:)`,复用已解码的 H.264 帧写盘。
|
||||
///
|
||||
/// 编码与写盘在独立串行队列执行,避免阻塞 WebRTC 网络/解码线程。
|
||||
final class VideoRecorder: NSObject {
|
||||
|
||||
enum RecorderError: Error {
|
||||
case alreadyRecording
|
||||
case notRecording
|
||||
case noTrackYet
|
||||
case writerInitFailed(String)
|
||||
}
|
||||
|
||||
enum RecorderState {
|
||||
case idle
|
||||
case recording
|
||||
}
|
||||
|
||||
/// 录制状态/结果回调(均回到主线程)
|
||||
var onStateChange: ((RecorderState) -> Void)?
|
||||
/// 停止并保存成功,savedPath 为完整文件路径
|
||||
var onSaved: ((String) -> Void)?
|
||||
/// 停止时未写入任何画面(例如未收到帧即停止)
|
||||
var onSavedEmpty: (() -> Void)?
|
||||
/// 真正错误(如 AVAssetWriter 初始化失败)
|
||||
var onError: ((String) -> Void)?
|
||||
|
||||
private(set) var isRecording = false
|
||||
|
||||
// 仅由录制队列访问
|
||||
private let recordQueue = DispatchQueue(label: "com.ttstd.video.recorder")
|
||||
private var assetWriter: AVAssetWriter?
|
||||
private var videoInput: AVAssetWriterInput?
|
||||
private var outputURL: URL?
|
||||
private var startedTime: CMTime?
|
||||
private var framesWritten = 0
|
||||
|
||||
// MARK: - 公开控制
|
||||
|
||||
/// 开始录制(WebRTC 模式需要 track 已经收到才能确定分辨率并开始写;
|
||||
/// 自编码模式通过 appendDecodedSampleBuffer 在收到首帧时自动起写)。
|
||||
/// 返回 true 表示已进入录制态;若当前正在录制返回 false。
|
||||
@discardableResult
|
||||
func start() -> Bool {
|
||||
if isRecording { return false }
|
||||
isRecording = true
|
||||
DispatchQueue.main.async { self.onStateChange?(.recording) }
|
||||
return true
|
||||
}
|
||||
|
||||
/// 停止录制并保存。
|
||||
func stop() {
|
||||
guard isRecording else { return }
|
||||
isRecording = false
|
||||
recordQueue.async { [weak self] in
|
||||
self?.finalizeLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/// 释放(断开连接 / 页面销毁时调用),未保存内容会丢弃。
|
||||
func cancel() {
|
||||
guard isRecording else { return }
|
||||
isRecording = false
|
||||
recordQueue.async { [weak self] in
|
||||
self?.teardownLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WebRTC 模式入口(RTCVideoRenderer)
|
||||
|
||||
/// 由 WebRTCClient 在收到远端视频轨后 add 此 renderer。
|
||||
/// 这里仅记录帧并投递到录制队列,真正的写盘在 ensureWriter 时启动。
|
||||
func handleWebRTCFrame(_ frame: RTCVideoFrame) {
|
||||
guard isRecording else { return }
|
||||
// buffer 为 RTCVideoFrameBuffer 协议,只有 RTCCVPixelBuffer 子类暴露 pixelBuffer
|
||||
guard let cvBuffer = frame.buffer as? RTCCVPixelBuffer else { return }
|
||||
let width = Int(cvBuffer.width)
|
||||
let height = Int(cvBuffer.height)
|
||||
guard width > 0, height > 0 else { return }
|
||||
let pts = CMTime(value: frame.timeStampNs, timescale: 1_000_000_000)
|
||||
|
||||
recordQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.appendPixelBuffer(cvBuffer.pixelBuffer, width: width, height: height, pts: pts)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 自编码模式入口
|
||||
|
||||
/// 自编码模式:SelfCodecDecoder 硬解出帧后调用。
|
||||
/// 首帧时依据 sampleBuffer 的分辨率启动写盘,AVAssetWriter 会重新编码为 H.264。
|
||||
func appendDecodedSampleBuffer(_ sample: CMSampleBuffer, isKey: Bool) {
|
||||
guard isRecording else { return }
|
||||
recordQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.appendSampleBuffer(sample, isKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 写入实现
|
||||
|
||||
/// 把 CVPixelBuffer 包装成带时间戳的 CMSampleBuffer(WebRTC 模式写盘用)
|
||||
private func makeSampleBuffer(from pixelBuffer: CVPixelBuffer, pts: CMTime) -> CMSampleBuffer? {
|
||||
var formatDescription: CMVideoFormatDescription?
|
||||
guard CMVideoFormatDescriptionCreateForImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: pixelBuffer,
|
||||
formatDescriptionOut: &formatDescription) == noErr,
|
||||
let formatDescription else { return nil }
|
||||
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
var timing = CMSampleTimingInfo(
|
||||
duration: CMTime.invalid,
|
||||
presentationTimeStamp: pts,
|
||||
decodeTimeStamp: CMTime.invalid)
|
||||
let status = CMSampleBufferCreateForImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: pixelBuffer,
|
||||
dataReady: true,
|
||||
makeDataReadyCallback: nil,
|
||||
refcon: nil,
|
||||
formatDescription: formatDescription,
|
||||
sampleTiming: &timing,
|
||||
sampleBufferOut: &sampleBuffer)
|
||||
guard status == noErr else { return nil }
|
||||
return sampleBuffer
|
||||
}
|
||||
|
||||
private func appendPixelBuffer(_ pixelBuffer: CVPixelBuffer,
|
||||
width: Int, height: Int, pts: CMTime) {
|
||||
ensureWriterForPixelBuffer(width: width, height: height)
|
||||
guard let input = videoInput, input.isReadyForMoreMediaData else { return }
|
||||
var finalPts = pts
|
||||
if let start = startedTime {
|
||||
finalPts = CMTimeSubtract(pts, start)
|
||||
} else {
|
||||
startedTime = pts
|
||||
finalPts = .zero
|
||||
}
|
||||
guard let sample = makeSampleBuffer(from: pixelBuffer, pts: finalPts) else { return }
|
||||
let success = input.append(sample)
|
||||
if success { framesWritten += 1 }
|
||||
}
|
||||
|
||||
private func appendSampleBuffer(_ sample: CMSampleBuffer, _ isKey: Bool = false) {
|
||||
ensureWriterForSampleBuffer(sample)
|
||||
guard let input = videoInput, input.isReadyForMoreMediaData else { return }
|
||||
if startedTime == nil {
|
||||
startedTime = CMSampleBufferGetPresentationTimeStamp(sample)
|
||||
}
|
||||
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
|
||||
let finalPts = CMTimeSubtract(pts, startedTime ?? pts)
|
||||
// 用偏移后的时间戳构造新的 CMSampleBuffer 再写入
|
||||
guard let shifted = makeShiftedSampleBuffer(from: sample, pts: finalPts) else { return }
|
||||
let success = input.append(shifted)
|
||||
if success { framesWritten += 1 }
|
||||
}
|
||||
|
||||
/// 复制 sampleBuffer 并把其 presentation/decode 时间戳替换为新的 pts(自编码模式写盘用)
|
||||
private func makeShiftedSampleBuffer(from sample: CMSampleBuffer, pts: CMTime) -> CMSampleBuffer? {
|
||||
var newSample: CMSampleBuffer?
|
||||
var timing = CMSampleTimingInfo(
|
||||
duration: CMTime.invalid,
|
||||
presentationTimeStamp: pts,
|
||||
decodeTimeStamp: CMTime.invalid)
|
||||
let status = CMSampleBufferCreateCopyWithNewTiming(
|
||||
allocator: kCFAllocatorDefault,
|
||||
sampleBuffer: sample,
|
||||
sampleTimingEntryCount: 1,
|
||||
sampleTimingArray: &timing,
|
||||
sampleBufferOut: &newSample)
|
||||
guard status == noErr else { return nil }
|
||||
return newSample
|
||||
}
|
||||
|
||||
/// WebRTC 模式:用分辨率(NV12/ARGB 像素缓冲)初始化 AVAssetWriter
|
||||
private func ensureWriterForPixelBuffer(width: Int, height: Int) {
|
||||
guard assetWriter == nil else { return }
|
||||
guard let url = Self.buildOutputURL() else { return }
|
||||
outputURL = url
|
||||
do {
|
||||
let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
|
||||
let settings: [String: Any] = [
|
||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||
AVVideoWidthKey: width,
|
||||
AVVideoHeightKey: height,
|
||||
AVVideoCompressionPropertiesKey: [
|
||||
AVVideoAverageBitRateKey: min(max(width * height * 2, 1_000_000), 6_000_000),
|
||||
AVVideoMaxKeyFrameIntervalKey: 30
|
||||
]
|
||||
]
|
||||
let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
guard writer.canAdd(input) else {
|
||||
DispatchQueue.main.async {
|
||||
self.onError?("AVAssetWriter 不支持该视频配置")
|
||||
}
|
||||
self.teardownLocked()
|
||||
return
|
||||
}
|
||||
writer.add(input)
|
||||
guard writer.startWriting() else {
|
||||
DispatchQueue.main.async {
|
||||
self.onError?("AVAssetWriter 启动失败:\(writer.error?.localizedDescription ?? "未知错误")")
|
||||
}
|
||||
self.teardownLocked()
|
||||
return
|
||||
}
|
||||
writer.startSession(atSourceTime: .zero)
|
||||
assetWriter = writer
|
||||
videoInput = input
|
||||
} catch {
|
||||
DispatchQueue.main.async { self.onError?("录制初始化失败: \(error.localizedDescription)") }
|
||||
self.teardownLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/// 自编码模式:从解码后的 sampleBuffer 取分辨率(其格式描述),首帧时启动写盘。
|
||||
/// AVAssetWriterInput 配置为 H.264 压缩,会对解码后的帧重新编码并写盘,
|
||||
/// 无需手动解析 SPS/PPS。
|
||||
private func ensureWriterForSampleBuffer(_ sample: CMSampleBuffer) {
|
||||
if assetWriter != nil { return }
|
||||
guard let desc = CMSampleBufferGetFormatDescription(sample) else {
|
||||
NSLog("[VideoRecorder] 无法从 sampleBuffer 获取格式描述")
|
||||
return
|
||||
}
|
||||
let dims = CMVideoFormatDescriptionGetDimensions(desc)
|
||||
ensureWriterForSampleBuffer(width: Int(dims.width), height: Int(dims.height))
|
||||
}
|
||||
|
||||
private func ensureWriterForSampleBuffer(width: Int, height: Int) {
|
||||
guard assetWriter == nil else { return }
|
||||
guard let url = Self.buildOutputURL() else { return }
|
||||
outputURL = url
|
||||
do {
|
||||
let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
|
||||
let settings: [String: Any] = [
|
||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||
AVVideoWidthKey: width,
|
||||
AVVideoHeightKey: height,
|
||||
AVVideoCompressionPropertiesKey: [
|
||||
AVVideoAverageBitRateKey: min(max(width * height * 2, 1_000_000), 6_000_000),
|
||||
AVVideoMaxKeyFrameIntervalKey: 30
|
||||
]
|
||||
]
|
||||
let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
guard writer.canAdd(input) else {
|
||||
DispatchQueue.main.async { self.onError?("AVAssetWriter 不支持该视频配置") }
|
||||
self.teardownLocked()
|
||||
return
|
||||
}
|
||||
writer.add(input)
|
||||
guard writer.startWriting() else {
|
||||
DispatchQueue.main.async {
|
||||
self.onError?("AVAssetWriter 启动失败:\(writer.error?.localizedDescription ?? "未知错误")")
|
||||
}
|
||||
self.teardownLocked()
|
||||
return
|
||||
}
|
||||
writer.startSession(atSourceTime: .zero)
|
||||
assetWriter = writer
|
||||
videoInput = input
|
||||
} catch {
|
||||
DispatchQueue.main.async { self.onError?("录制初始化失败: \(error.localizedDescription)") }
|
||||
self.teardownLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private func finalizeLocked() {
|
||||
let written = framesWritten
|
||||
let url = outputURL
|
||||
videoInput?.markAsFinished()
|
||||
assetWriter?.finishWriting { [weak self] in
|
||||
guard let self else { return }
|
||||
self.teardownLocked()
|
||||
DispatchQueue.main.async {
|
||||
self.onStateChange?(.idle)
|
||||
if written > 0, let url {
|
||||
self.onSaved?(url.path)
|
||||
} else {
|
||||
self.onSavedEmpty?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func teardownLocked() {
|
||||
assetWriter = nil
|
||||
videoInput = nil
|
||||
outputURL = nil
|
||||
startedTime = nil
|
||||
framesWritten = 0
|
||||
}
|
||||
|
||||
// MARK: - 输出路径
|
||||
|
||||
private static func buildOutputURL() -> URL? {
|
||||
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
||||
return nil
|
||||
}
|
||||
let dir = documents.appendingPathComponent("WebRTCRecordings", isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
NSLog("[VideoRecorder] 创建目录失败: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let name = "rec_\(df.string(from: Date())).mp4"
|
||||
return dir.appendingPathComponent(name)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RTCVideoRenderer(WebRTC 模式)
|
||||
|
||||
extension VideoRecorder: RTCVideoRenderer {
|
||||
func setSize(_ size: CGSize) {
|
||||
// 写盘分辨率在收到首帧 CVPixelBuffer 时确定,这里无需处理
|
||||
}
|
||||
|
||||
func renderFrame(_ frame: RTCVideoFrame?) {
|
||||
guard let frame else { return }
|
||||
handleWebRTCFrame(frame)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ final class WebRTCClient: NSObject {
|
||||
private var videoChannel: RTCDataChannel?
|
||||
private weak var remoteRenderer: RTCVideoRenderer?
|
||||
private var remoteVideoTrack: RTCVideoTrack?
|
||||
/// 额外的渲染器(用于把远端视频帧 fan-out 给录制器,WebRTC 模式)
|
||||
weak var extraRenderer: RTCVideoRenderer?
|
||||
private var connected = false
|
||||
|
||||
init(signaling: SignalingClient, myDeviceId: String) {
|
||||
@@ -208,6 +210,9 @@ final class WebRTCClient: NSObject {
|
||||
if let track = remoteVideoTrack, let renderer = remoteRenderer {
|
||||
track.remove(renderer)
|
||||
}
|
||||
if let track = remoteVideoTrack, let extra = extraRenderer {
|
||||
track.remove(extra)
|
||||
}
|
||||
remoteVideoTrack = nil
|
||||
peerConnection?.close()
|
||||
peerConnection = nil
|
||||
@@ -289,6 +294,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
||||
if let renderer = self.remoteRenderer {
|
||||
track.add(renderer)
|
||||
}
|
||||
if let extra = self.extraRenderer {
|
||||
track.add(extra)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user