Files
VibeCoding/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift
TongTongStudio 2cac572702 fix(iOS): 修复视频显示模式为填充
将远端视频渲染视图和内容视图的视频内容模式从 `.fit` 改为 `.fill`,并调整控制面板布局,将录制与断开按钮单独成行,提升界面利用率与操作体验。
2026-08-01 04:04:19 +08:00

338 lines
13 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 {
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)
}
}
}