feat: 初始化 iOS 远程控制端项目
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 连接鉴权弹窗:免密 / 动态验证码 / 固定密码(对应 Android 的鉴权对话框)。
|
||||
struct AuthSheetView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let onConfirm: (AuthType, String) -> Void
|
||||
|
||||
@State private var authType: AuthType = .none
|
||||
@State private var authValue: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("鉴权方式") {
|
||||
Picker("鉴权方式", selection: $authType) {
|
||||
ForEach(AuthType.allCases) { type in
|
||||
Text(type.title).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if authType != .none {
|
||||
Section(authType == .code ? "验证码" : "密码") {
|
||||
if authType == .code {
|
||||
TextField("请输入被控端显示的 6 位验证码", text: $authValue)
|
||||
.keyboardType(.numberPad)
|
||||
} else {
|
||||
SecureField("请输入被控端设置的连接密码", text: $authValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
onConfirm(authType, authValue)
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("开始连接")
|
||||
.fontWeight(.semibold)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(authType != .none && authValue.isEmpty)
|
||||
} footer: {
|
||||
Text(footerTips)
|
||||
}
|
||||
}
|
||||
.navigationTitle("连接鉴权")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
|
||||
private var footerTips: String {
|
||||
switch authType {
|
||||
case .none:
|
||||
return "免密连接:需要被控端手动确认或开启免验证。"
|
||||
case .code:
|
||||
return "动态验证码:输入被控端界面上显示的验证码,验证通过后自动接受连接。"
|
||||
case .password:
|
||||
return "固定密码:输入被控端预设的连接密码,验证通过后自动接受连接。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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 ? "连接中..." : "连接")
|
||||
.fontWeight(.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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.selectedResolution.title, systemImage: "rectangle.compress.vertical")
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
viewModel.disconnect()
|
||||
} label: {
|
||||
Text("断开")
|
||||
.font(.footnote)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
/// UIKit 触控捕获视图:将触摸转换为相对坐标(0.0~1.0)的远端输入指令。
|
||||
/// 与 Android 端 RemoteTouchView 行为一致:
|
||||
/// - 实时转发原始 MotionEvent(DOWN=0 / UP=1 / MOVE=2 / CANCEL=3),远端注入实现"跟手"
|
||||
/// - 手指抬起时若判定为滑动(时长 >= 200ms 或位移 >= 2%),额外发送一条 SWIPE 指令
|
||||
final class RemoteTouchView: UIView {
|
||||
|
||||
/// Android MotionEvent 动作常量
|
||||
enum MotionAction {
|
||||
static let down: Int32 = 0
|
||||
static let up: Int32 = 1
|
||||
static let move: Int32 = 2
|
||||
static let cancel: Int32 = 3
|
||||
}
|
||||
|
||||
var onMotionEvent: ((Int32, Double, Double) -> Void)?
|
||||
var onSwipe: ((Double, Double, Double, Double, Int64) -> Void)?
|
||||
|
||||
private var startX: Double = 0
|
||||
private var startY: Double = 0
|
||||
private var touchStartTime: TimeInterval = 0
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
isMultipleTouchEnabled = false
|
||||
backgroundColor = .clear
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
isMultipleTouchEnabled = false
|
||||
backgroundColor = .clear
|
||||
}
|
||||
|
||||
private func relativePoint(of touch: UITouch) -> (Double, Double) {
|
||||
let p = touch.location(in: self)
|
||||
let w = max(bounds.width, 1)
|
||||
let h = max(bounds.height, 1)
|
||||
let relX = min(max(Double(p.x / w), 0), 1)
|
||||
let relY = min(max(Double(p.y / h), 0), 1)
|
||||
return (relX, relY)
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
let (x, y) = relativePoint(of: touch)
|
||||
startX = x
|
||||
startY = y
|
||||
touchStartTime = Date().timeIntervalSince1970
|
||||
onMotionEvent?(MotionAction.down, x, y)
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
let (x, y) = relativePoint(of: touch)
|
||||
onMotionEvent?(MotionAction.move, x, y)
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
let (x, y) = relativePoint(of: touch)
|
||||
onMotionEvent?(MotionAction.up, x, y)
|
||||
|
||||
let duration = Int64((Date().timeIntervalSince1970 - touchStartTime) * 1000)
|
||||
let dx = abs(x - startX)
|
||||
let dy = abs(y - startY)
|
||||
// 非轻点:补发 SWIPE 高层指令(与 Android 端一致)
|
||||
if !(duration < 200 && dx < 0.02 && dy < 0.02) {
|
||||
onSwipe?(startX, startY, x, y, duration)
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
let (x, y) = relativePoint(of: touch)
|
||||
onMotionEvent?(MotionAction.cancel, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI 包装:把 UIKit 触控层嵌入 SwiftUI 视图树
|
||||
struct TouchOverlay: UIViewRepresentable {
|
||||
let onMotionEvent: (Int32, Double, Double) -> Void
|
||||
let onSwipe: (Double, Double, Double, Double, Int64) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> RemoteTouchView {
|
||||
let view = RemoteTouchView()
|
||||
view.onMotionEvent = onMotionEvent
|
||||
view.onSwipe = onSwipe
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: RemoteTouchView, context: Context) {
|
||||
uiView.onMotionEvent = onMotionEvent
|
||||
uiView.onSwipe = onSwipe
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
import WebRTC
|
||||
|
||||
/// SwiftUI 包装:WebRTC Metal 视频渲染视图(UIKit)。
|
||||
/// 渲染视图实例由 ViewModel 持有,保证视频轨道 attach 后跨 SwiftUI 刷新周期不丢失。
|
||||
struct RemoteVideoView: UIViewRepresentable {
|
||||
let videoView: RTCMTLVideoView
|
||||
|
||||
func makeUIView(context: Context) -> RTCMTLVideoView {
|
||||
videoView.videoContentMode = .scaleAspectFit
|
||||
return videoView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: RTCMTLVideoView, context: Context) {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
/// UIKit 视图:以 AVSampleBufferDisplayLayer 为 backing layer,
|
||||
/// 用于渲染"自编码"模式下的 H.264 硬解画面(对应 Android 端的 SurfaceView)。
|
||||
final class SampleBufferDisplayView: UIView {
|
||||
|
||||
override class var layerClass: AnyClass {
|
||||
AVSampleBufferDisplayLayer.self
|
||||
}
|
||||
|
||||
var sampleBufferLayer: AVSampleBufferDisplayLayer {
|
||||
layer as! AVSampleBufferDisplayLayer
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
sampleBufferLayer.videoGravity = .resizeAspect
|
||||
backgroundColor = .black
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
sampleBufferLayer.videoGravity = .resizeAspect
|
||||
backgroundColor = .black
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI 包装
|
||||
struct SelfCodecDisplayViewRepresentable: UIViewRepresentable {
|
||||
let view: SampleBufferDisplayView
|
||||
|
||||
func makeUIView(context: Context) -> SampleBufferDisplayView {
|
||||
view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SampleBufferDisplayView, context: Context) {}
|
||||
}
|
||||
Reference in New Issue
Block a user