import SwiftUI /// 控制面板:连接成功后显示,承载远端画面、统计信息与控制操作。 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 { VStack(spacing: 6) { // 第一行:状态 + 串流模式切换 + 分辨率 + 帧率 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() } } .padding(.horizontal, 12) .padding(.vertical, 6) } // 第二行:录制 + 断开(当前行下方) HStack(spacing: 12) { // 录制远端视频(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) Spacer() } .padding(.horizontal, 12) .padding(.bottom, 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) }) } .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) } } }