Files
VibeCoding/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift
tongtongstudio a73f61f366 feat(ios): 添加远端视频录制功能并优化信令连接稳定性
- 新增 VideoRecorder 组件,支持 WebRTC 和自编码两种模式录制远端视频为 MP4
- 优化 SignalingClient 连接管理,修复 WebSocket 握手前 receive 导致的断开问题
- 重构控制面板 UI,支持统计信息折叠展开和录制控制按钮
- 添加录制状态指示、保存路径提示及断开自动取消录制逻辑
- 更新项目配置和文档,补充录制功能说明
2026-08-01 03:59:43 +08:00

328 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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 ? "连接中..." : "连接")
.font(.system(size: 15, weight: .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) {
statsPanel
topBar
videoArea
navButtons
}
.background(Color.black.ignoresSafeArea())
}
/// /
private var statsPanel: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.statsExpanded.toggle()
}
} label: {
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))
}
.foregroundColor(Color(white: 0.8))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
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)
}
}
.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))
}
///
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)
//
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 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)
}
}
}