将远端视频渲染视图和内容视图的视频内容模式从 `.fit` 改为 `.fill`,并调整控制面板布局,将录制与断开按钮单独成行,提升界面利用率与操作体验。
338 lines
13 KiB
Swift
338 lines
13 KiB
Swift
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 {
|
||
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)
|
||
})
|
||
}
|
||
.aspectRatio(viewModel.videoAspect, contentMode: .fill)
|
||
.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)
|
||
}
|
||
}
|
||
}
|