Files
crossdesk/apps/ios/CrossDeskMobile/RemoteSessionView.swift
T

1720 lines
68 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
import UIKit
import UniformTypeIdentifiers
private enum RemoteKeyboardPalette {
static let background = Color(red: 0.82, green: 0.84, blue: 0.87)
static let characterKey = Color.white
static let specialKey = Color(red: 0.68, green: 0.71, blue: 0.75)
static let accentKey = Color(red: 0.04, green: 0.48, blue: 1.00)
static let keyText = Color.black.opacity(0.88)
}
private enum RemoteKeyboardMetrics {
static let rowHeight: CGFloat = 31
static let keySpacing: CGFloat = 3
static let rowSpacing: CGFloat = 3
static let cornerRadius: CGFloat = 5
static let panelPadding: CGFloat = 5
static let dragHandleHeight: CGFloat = 10
}
private enum RemoteKeyboardMode: String {
case system
case computer
}
private struct RemoteViewportState: Equatable {
var scale: CGFloat = 1
var offset = CGSize.zero
}
struct RemoteSessionView: View {
@ObservedObject var session: RemoteSessionModel
@Environment(\.scenePhase) private var scenePhase
@State private var mouseInput = RemoteMouseInput()
@State private var virtualMouseVisible = false
@State private var keyboardInputVisible = false
@State private var keyboardMode: RemoteKeyboardMode = .system
@State private var viewport = RemoteViewportState()
@State private var showingFileImporter = false
@State private var disconnectConfirmationVisible = false
@State private var cursorPosition: CGPoint?
@State private var statusMenuVisible = false
@State private var statusOrbCenter: CGPoint?
@State private var keyboardPanelCenter: CGPoint?
@GestureState private var statusOrbDragTranslation = CGSize.zero
@GestureState private var keyboardDragTranslation = CGSize.zero
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
GeometryReader { proxy in
ZStack(alignment: .topLeading) {
if let videoRect = RemoteVideoGeometry.aspectFitRect(
containerSize: proxy.size,
videoSize: session.displayGeometrySize
) {
ZStack(alignment: .topLeading) {
NativeVideoView(
pixelBuffer: session.pixelBuffer,
frameID: session.currentVideoFrameID,
captureUptime: session.frameCaptureUptime,
onFrameSubmitted: session.recordVideoFrameSubmission
)
if let cursorPosition,
session.pixelBuffer != nil {
RemoteCursorOverlay(
normalizedPosition: cursorPosition,
normalizedVisualOffset:
session.remoteCursorVisualOffset,
contentSize: videoRect.size,
viewportScale: viewport.scale,
shape: visibleCursorShape
)
.allowsHitTesting(false)
}
}
.frame(width: videoRect.width,
height: videoRect.height)
.position(x: videoRect.midX, y: videoRect.midY)
.scaleEffect(viewport.scale, anchor: .center)
.offset(viewport.offset)
}
RemoteTouchInputView(
mouseInput: mouseInput,
videoSize: session.displayGeometrySize,
controlMode: session.mouseControlMode,
remoteCursorPosition: session.remoteCursorPosition,
remoteCursorPositionRevision: session.remoteCursorPositionRevision,
viewportScale: viewport.scale,
viewportOffset: viewport.offset,
viewportChanged: { scale, offset in
// Scale and offset describe one affine transform.
// Publish them atomically so all layers observe the
// same viewport.
viewport = RemoteViewportState(scale: scale,
offset: offset)
},
move: { x, y in
mouseInput.position(x: x, y: y)
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
session.bridge.sendPointer(x: x, y: y, action: .move)
},
leftDown: { x, y in
mouseInput.position(x: x, y: y)
mouseInput.setGestureLeft(true)
},
leftUp: { x, y in
mouseInput.position(x: x, y: y)
mouseInput.setGestureLeft(false)
},
rightClick: { x, y in
session.bridge.sendPointer(x: x, y: y, action: .rightDown)
session.bridge.sendPointer(x: x, y: y, action: .rightUp)
},
scroll: { x, y, dx, dy in
mouseInput.position(x: x, y: y)
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
session.bridge.sendScrollX(x, y: y,
deltaX: dx, deltaY: dy)
}
)
.frame(width: proxy.size.width, height: proxy.size.height)
.allowsHitTesting(session.pixelBuffer != nil)
}
.frame(width: proxy.size.width, height: proxy.size.height)
}
.ignoresSafeArea()
.transaction { transaction in
transaction.animation = nil
transaction.disablesAnimations = true
}
if session.pixelBuffer == nil {
VStack(spacing: 12) {
ProgressView()
.tint(.white)
Text(session.videoStatus)
.font(.callout.monospacedDigit())
.foregroundStyle(.white)
}
.padding(20)
.background(.black.opacity(0.55), in: RoundedRectangle(cornerRadius: 14))
.allowsHitTesting(true)
}
if keyboardInputVisible {
draggableKeyboard
.zIndex(20)
}
floatingStatusControls
.zIndex(30)
if disconnectConfirmationVisible {
disconnectConfirmationOverlay
.zIndex(50)
}
}
.alert("升级提示", isPresented: Binding(
get: { session.remoteUpdateMessage != nil },
set: { if !$0 { session.dismissRemoteUpdate() } }
)) {
Button("知道了", role: .cancel) { session.dismissRemoteUpdate() }
} message: {
Text(session.remoteUpdateMessage ?? "")
}
.onAppear {
mouseInput.send = { x, y, flag, wheel in
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
switch flag {
case 0: session.bridge.sendPointer(x: x, y: y, action: .move)
case 1: session.bridge.sendPointer(x: x, y: y, action: .leftDown)
case 2: session.bridge.sendPointer(x: x, y: y, action: .leftUp)
case 3: session.bridge.sendPointer(x: x, y: y, action: .rightDown)
case 4: session.bridge.sendPointer(x: x, y: y, action: .rightUp)
case 7: session.bridge.sendScrollX(x, y: y, deltaX: 0, deltaY: wheel)
default: break
}
}
if let point = session.remoteCursorPosition {
mouseInput.position(x: Float(point.x), y: Float(point.y))
}
}
.onDisappear {
mouseInput.releaseAll()
mouseInput.send = nil
virtualMouseVisible = false
keyboardInputVisible = false
viewport = RemoteViewportState()
cursorPosition = nil
statusMenuVisible = false
statusOrbCenter = nil
keyboardPanelCenter = nil
}
.onChange(of: session.selectedDisplay) { _ in
mouseInput.releaseAll()
mouseInput.position(x: 0.5, y: 0.5)
cursorPosition = nil
viewport = RemoteViewportState()
}
.onChange(of: session.mouseControlMode) { _ in
mouseInput.releaseAll()
cursorPosition = session.remoteCursorPosition
if let point = cursorPosition {
mouseInput.position(x: Float(point.x), y: Float(point.y))
}
}
.onChange(of: scenePhase) { phase in
if phase != .active { mouseInput.releaseAll() }
}
.onChange(of: session.isConnected) { connected in
if !connected { mouseInput.releaseAll() }
}
.onChange(of: session.remoteCursorPositionRevision) { _ in
guard !mouseInput.hasVirtualButton, let position = session.remoteCursorPosition else { return }
mouseInput.position(x: Float(position.x), y: Float(position.y))
cursorPosition = position
}
.fileImporter(isPresented: $showingFileImporter,
allowedContentTypes: [.item],
allowsMultipleSelection: false) { result in
if case let .success(urls) = result, let url = urls.first {
session.sendFile(url)
} else if case .failure = result {
session.fileImportFailed()
}
}
}
private var disconnectConfirmationOverlay: some View {
GeometryReader { proxy in
ZStack {
Color.black.opacity(0.28)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeOut(duration: 0.16)) {
disconnectConfirmationVisible = false
}
}
VStack(spacing: 0) {
VStack(spacing: 4) {
Text("断开远程连接?")
.font(.headline)
.foregroundStyle(Color.black.opacity(0.9))
Text("断开后将返回首页")
.font(.subheadline)
.foregroundStyle(Color.black.opacity(0.55))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
Divider()
Button {
disconnectConfirmationVisible = false
session.disconnect()
} label: {
Text("断开连接")
.font(.body.weight(.semibold))
.foregroundStyle(Color.red)
.frame(maxWidth: .infinity)
.frame(height: 45)
}
Divider()
Button {
withAnimation(.easeOut(duration: 0.16)) {
disconnectConfirmationVisible = false
}
} label: {
Text("取消")
.font(.body.weight(.semibold))
.foregroundStyle(Color.blue)
.frame(maxWidth: .infinity)
.frame(height: 45)
}
}
.background(
Color.white,
in: RoundedRectangle(cornerRadius: 16,
style: .continuous)
)
.clipShape(RoundedRectangle(cornerRadius: 16,
style: .continuous))
.shadow(color: .black.opacity(0.2), radius: 18, y: 7)
.frame(width: min(250, max(220, proxy.size.width - 32)))
.position(x: proxy.size.width / 2,
y: proxy.size.height / 2)
.transition(.scale(scale: 0.96).combined(with: .opacity))
}
.frame(width: proxy.size.width, height: proxy.size.height)
}
.ignoresSafeArea()
}
private var floatingStatusControls: some View {
// Keep the orb and expanded menu inside the safe area so their initial
// positions and drag bounds clear the Dynamic Island in either orientation.
GeometryReader { proxy in
let containerSize = proxy.size
let restingOrbCenter = boundedOrbCenter(in: containerSize)
let orbCenter = constrainedOrbCenter(
CGPoint(x: restingOrbCenter.x + statusOrbDragTranslation.width,
y: restingOrbCenter.y + statusOrbDragTranslation.height),
in: containerSize
)
let menuColumns = containerSize.height < 400 && containerSize.width >= 400 ? 4 : 3
let panelWidth = min(max(1, containerSize.width - 16), menuColumns == 4
? 380 : min(340, max(280, containerSize.width - 160)))
ZStack(alignment: .topLeading) {
if virtualMouseVisible && !statusMenuVisible && session.pixelBuffer != nil {
VirtualMouseBar(input: mouseInput)
.frame(width: 140, height: 48)
.position(virtualMouseCenter(orbCenter: orbCenter, in: containerSize))
}
if statusMenuVisible {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeOut(duration: 0.14)) {
statusMenuVisible = false
}
}
}
FloatingMenuLayout(orbCenter: orbCenter, panelWidth: panelWidth) {
if statusMenuVisible {
FloatingSessionMenu(
session: session,
columnCount: menuColumns,
virtualMouseVisible: virtualMouseVisible,
toggleMouse: {
mouseInput.releaseAll()
virtualMouseVisible.toggle()
statusMenuVisible = false
},
releaseMouse: mouseInput.releaseAll,
showKeyboard: {
statusMenuVisible = false
keyboardInputVisible.toggle()
},
chooseFile: {
statusMenuVisible = false
showingFileImporter = true
},
close: {
withAnimation(.easeOut(duration: 0.14)) {
statusMenuVisible = false
}
},
disconnect: {
mouseInput.releaseAll()
statusMenuVisible = false
keyboardInputVisible = false
withAnimation(.easeOut(duration: 0.18)) {
disconnectConfirmationVisible = true
}
}
)
.transition(.scale(scale: 0.94, anchor: .topTrailing)
.combined(with: .opacity))
}
}
.frame(width: containerSize.width, height: containerSize.height)
.allowsHitTesting(statusMenuVisible)
CrossDeskStatusOrb(isExpanded: statusMenuVisible)
.frame(width: 54, height: 54)
.contentShape(Circle())
.position(orbCenter)
.onTapGesture {
mouseInput.releaseAll()
withAnimation(.spring(response: 0.24,
dampingFraction: 0.84)) {
statusMenuVisible.toggle()
}
}
.gesture(
DragGesture(minimumDistance: 1)
.updating($statusOrbDragTranslation) { value, state, _ in
state = value.translation
}
.onChanged { _ in
mouseInput.releaseAll()
if statusMenuVisible {
statusMenuVisible = false
}
}
.onEnded { value in
statusOrbCenter = constrainedOrbCenter(
CGPoint(x: restingOrbCenter.x + value.translation.width,
y: restingOrbCenter.y + value.translation.height),
in: containerSize
)
}
)
}
.frame(width: containerSize.width, height: containerSize.height)
.onChange(of: containerSize) { _ in mouseInput.releaseAll() }
}
}
private func virtualMouseCenter(orbCenter: CGPoint, in size: CGSize) -> CGPoint {
let halfWidth: CGFloat = 70
let halfHeight: CGFloat = 24
let reach: CGFloat = 27 + 6 + halfWidth
var x = orbCenter.x - reach
var y = orbCenter.y
if x - halfWidth < 8 {
x = orbCenter.x + reach
if x + halfWidth > size.width - 8 {
x = orbCenter.x
y = orbCenter.y + 27 + 6 + halfHeight
if y + halfHeight > size.height - 8 {
y = orbCenter.y - 27 - 6 - halfHeight
}
}
}
x = min(max(x, halfWidth + 8), max(halfWidth + 8, size.width - halfWidth - 8))
if keyboardInputVisible {
let keyboardSize = keyboardPanelSize(in: size)
let resting = constrainedKeyboardCenter(keyboardPanelCenter ?? defaultKeyboardCenter(in: size, panelSize: keyboardSize), in: size, panelSize: keyboardSize)
let center = constrainedKeyboardCenter(CGPoint(x: resting.x + keyboardDragTranslation.width, y: resting.y + keyboardDragTranslation.height), in: size, panelSize: keyboardSize)
let keyboardRect = CGRect(x: center.x - keyboardSize.width / 2, y: center.y - keyboardSize.height / 2, width: keyboardSize.width, height: keyboardSize.height)
if keyboardRect.intersects(CGRect(x: x - halfWidth, y: y - halfHeight, width: 140, height: 48)) {
y = keyboardRect.minY - 6 - halfHeight
if y - halfHeight < 8 { y = keyboardRect.maxY + 6 + halfHeight }
}
}
return CGPoint(x: x, y: min(max(y, halfHeight + 8), max(halfHeight + 8, size.height - halfHeight - 8)))
}
private var draggableKeyboard: some View {
GeometryReader { proxy in
let containerSize = proxy.size
let panelSize = keyboardPanelSize(in: containerSize)
let restingCenter = constrainedKeyboardCenter(
keyboardPanelCenter ?? defaultKeyboardCenter(
in: containerSize,
panelSize: panelSize
),
in: containerSize,
panelSize: panelSize
)
let visibleCenter = constrainedKeyboardCenter(
CGPoint(
x: restingCenter.x + keyboardDragTranslation.width,
y: restingCenter.y + keyboardDragTranslation.height
),
in: containerSize,
panelSize: panelSize
)
remoteKeyboardPanel(containerSize: containerSize,
panelSize: panelSize)
.frame(width: panelSize.width, height: panelSize.height)
.position(restingCenter)
.offset(x: visibleCenter.x - restingCenter.x,
y: visibleCenter.y - restingCenter.y)
.transaction { transaction in
transaction.animation = nil
}
}
}
private func remoteKeyboardPanel(containerSize: CGSize,
panelSize: CGSize) -> some View {
VStack(spacing: RemoteKeyboardMetrics.rowSpacing) {
Capsule()
.fill(Color.black.opacity(0.28))
.frame(width: 38, height: 4)
.frame(maxWidth: .infinity)
.frame(height: RemoteKeyboardMetrics.dragHandleHeight)
.contentShape(Rectangle())
.gesture(keyboardDragGesture(containerSize: containerSize,
panelSize: panelSize))
.accessibilityLabel("拖动键盘")
Group {
if keyboardMode == .system {
RemoteSystemKeyboard(
session: session,
switchKeyboard: { keyboardMode = .computer },
dismiss: { keyboardInputVisible = false }
)
} else {
RemoteComputerKeyboard(
session: session,
switchKeyboard: { keyboardMode = .system },
dismiss: { keyboardInputVisible = false }
)
}
}
}
.padding(RemoteKeyboardMetrics.panelPadding)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(RemoteKeyboardPalette.background,
in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.shadow(color: .black.opacity(0.2), radius: 10, y: 4)
}
private func keyboardPanelSize(in containerSize: CGSize) -> CGSize {
let rowCount: CGFloat = keyboardMode == .system ? 4 : 6
let keyboardHeight = rowCount * RemoteKeyboardMetrics.rowHeight +
(rowCount - 1) * RemoteKeyboardMetrics.rowSpacing
let height = keyboardHeight +
RemoteKeyboardMetrics.dragHandleHeight +
RemoteKeyboardMetrics.rowSpacing +
RemoteKeyboardMetrics.panelPadding * 2
return CGSize(width: min(780, max(0, containerSize.width - 12)),
height: height)
}
private func defaultKeyboardCenter(in containerSize: CGSize,
panelSize: CGSize) -> CGPoint {
CGPoint(x: containerSize.width / 2,
y: containerSize.height - panelSize.height / 2 - 6)
}
private func constrainedKeyboardCenter(_ point: CGPoint,
in containerSize: CGSize,
panelSize: CGSize) -> CGPoint {
let margin: CGFloat = 6
let minimumX = panelSize.width / 2 + margin
let maximumX = max(minimumX,
containerSize.width - panelSize.width / 2 - margin)
let minimumY = panelSize.height / 2 + margin
let maximumY = max(minimumY,
containerSize.height - panelSize.height / 2 - margin)
return CGPoint(x: min(max(point.x, minimumX), maximumX),
y: min(max(point.y, minimumY), maximumY))
}
private func keyboardDragGesture(containerSize: CGSize,
panelSize: CGSize) -> some Gesture {
DragGesture(minimumDistance: 1, coordinateSpace: .global)
.updating($keyboardDragTranslation) { value, state, _ in
state = value.translation
}
.onEnded { value in
let restingCenter = constrainedKeyboardCenter(
keyboardPanelCenter ?? defaultKeyboardCenter(
in: containerSize,
panelSize: panelSize
),
in: containerSize,
panelSize: panelSize
)
keyboardPanelCenter = constrainedKeyboardCenter(
CGPoint(x: restingCenter.x + value.translation.width,
y: restingCenter.y + value.translation.height),
in: containerSize,
panelSize: panelSize
)
}
}
private func boundedOrbCenter(in size: CGSize) -> CGPoint {
let initial = statusOrbCenter ?? CGPoint(x: size.width - 39, y: 44)
return constrainedOrbCenter(initial, in: size)
}
private func constrainedOrbCenter(_ point: CGPoint, in size: CGSize) -> CGPoint {
let horizontalMargin: CGFloat = 39
let verticalMargin: CGFloat = 39
return CGPoint(
x: min(max(point.x, horizontalMargin),
max(horizontalMargin, size.width - horizontalMargin)),
y: min(max(point.y, verticalMargin),
max(verticalMargin, size.height - verticalMargin))
)
}
private var visibleCursorShape: Int {
guard session.hasRemoteCursorState,
session.remoteCursorVisible,
session.remoteCursorShape != 1 else {
// The desktop hides its native cursor from the captured frame and
// can therefore report a hidden cursor while remote input is
// active. Keep the iOS-side touch cursor visible in that state.
return 0
}
return session.remoteCursorShape
}
}
private enum RemoteSystemKeyboardPage {
case letters
case numbers
case symbols
}
private enum RemoteSystemKeyAction {
case text(String)
case shift
case backspace
case returnKey
case page(RemoteSystemKeyboardPage)
case switchKeyboard
case dismiss
}
private enum RemoteSystemKeyStyle {
case character
case special
case accent
}
private struct RemoteKeyboardKeyCap: View {
let title: String?
let symbol: String?
let style: RemoteSystemKeyStyle
let active: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Group {
if let symbol {
Image(systemName: symbol)
.font(.system(size: 14, weight: .semibold))
} else {
Text(title ?? "")
.font(.system(size: labelFontSize,
weight: .medium,
design: .rounded))
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.62)
}
}
.foregroundStyle(usesAccentColor
? Color.white : RemoteKeyboardPalette.keyText)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(backgroundColor,
in: RoundedRectangle(
cornerRadius: RemoteKeyboardMetrics.cornerRadius,
style: .continuous
))
.shadow(color: .black.opacity(0.14), radius: 0.4, y: 0.8)
}
.buttonStyle(RemoteKeyboardPressStyle())
}
private var labelFontSize: CGFloat {
guard let title else { return 14 }
if title.contains("\n") { return 9 }
switch title.count {
case 0...1: return 14
case 2...3: return 11.5
default: return 10
}
}
private var usesAccentColor: Bool {
if active { return true }
if case .accent = style { return true }
return false
}
private var backgroundColor: Color {
if usesAccentColor { return RemoteKeyboardPalette.accentKey }
switch style {
case .character: return RemoteKeyboardPalette.characterKey
case .special: return RemoteKeyboardPalette.specialKey
case .accent: return RemoteKeyboardPalette.accentKey
}
}
}
private struct RemoteKeyboardPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.brightness(configuration.isPressed ? -0.08 : 0)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
}
}
private struct RemoteSystemKey {
let title: String?
let symbol: String?
let width: CGFloat
let style: RemoteSystemKeyStyle
let action: RemoteSystemKeyAction
init(_ title: String,
width: CGFloat = 1,
style: RemoteSystemKeyStyle = .character,
action: RemoteSystemKeyAction) {
self.title = title
self.symbol = nil
self.width = width
self.style = style
self.action = action
}
init(symbol: String,
width: CGFloat = 1,
style: RemoteSystemKeyStyle = .special,
action: RemoteSystemKeyAction) {
self.title = nil
self.symbol = symbol
self.width = width
self.style = style
self.action = action
}
var isShift: Bool {
if case .shift = action { return true }
return false
}
var isLetter: Bool {
guard case let .text(value) = action else { return false }
return value.rangeOfCharacter(from: .letters) != nil
}
}
private struct RemoteSystemKeyboard: View {
@ObservedObject var session: RemoteSessionModel
let switchKeyboard: () -> Void
let dismiss: () -> Void
@State private var page: RemoteSystemKeyboardPage = .letters
@State private var shiftEnabled = false
var body: some View {
VStack(spacing: RemoteKeyboardMetrics.rowSpacing) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
RemoteSystemKeyboardRow(
keys: row,
shiftEnabled: shiftEnabled,
action: handleKey
)
}
}
}
private var rows: [[RemoteSystemKey]] {
switch page {
case .letters:
return letterRows
case .numbers:
return numberRows
case .symbols:
return symbolRows
}
}
private var letterRows: [[RemoteSystemKey]] {
[
textKeys("qwertyuiop"),
textKeys("asdfghjkl"),
[RemoteSystemKey(symbol: "shift",
width: 1.55,
action: .shift)] +
textKeys("zxcvbnm") +
[RemoteSystemKey(symbol: "delete.left",
width: 1.55,
action: .backspace)],
bottomRow(pageTitle: "123", targetPage: .numbers)
]
}
private var numberRows: [[RemoteSystemKey]] {
[
valueKeys(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]),
valueKeys(["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""]),
[RemoteSystemKey("#+=", width: 1.55, style: .special,
action: .page(.symbols))] +
valueKeys([".", ",", "?", "!", "'"]) +
[RemoteSystemKey(symbol: "delete.left", width: 1.55,
action: .backspace)],
bottomRow(pageTitle: "ABC", targetPage: .letters)
]
}
private var symbolRows: [[RemoteSystemKey]] {
[
valueKeys(["[", "]", "{", "}", "#", "%", "^", "*", "+", "="]),
valueKeys(["_", "\\", "|", "~", "<", ">", "$", "&", "@", "`"]),
[RemoteSystemKey("123", width: 1.55, style: .special,
action: .page(.numbers))] +
valueKeys([".", ",", "?", "!", "'"]) +
[RemoteSystemKey(symbol: "delete.left", width: 1.55,
action: .backspace)],
bottomRow(pageTitle: "ABC", targetPage: .letters)
]
}
private func textKeys(_ letters: String) -> [RemoteSystemKey] {
letters.map { character in
let value = String(character)
return RemoteSystemKey(value, action: .text(value))
}
}
private func valueKeys(_ values: [String]) -> [RemoteSystemKey] {
values.map { RemoteSystemKey($0, action: .text($0)) }
}
private func bottomRow(pageTitle: String,
targetPage: RemoteSystemKeyboardPage)
-> [RemoteSystemKey] {
[
RemoteSystemKey(pageTitle, width: 1.45, style: .special,
action: .page(targetPage)),
RemoteSystemKey(symbol: "arrow.left.arrow.right", width: 1.2,
action: .switchKeyboard),
RemoteSystemKey("空格", width: 5, action: .text(" ")),
RemoteSystemKey("回车", width: 1.55, style: .accent,
action: .returnKey),
RemoteSystemKey(symbol: "keyboard.chevron.compact.down", width: 1.2,
action: .dismiss)
]
}
private func handleKey(_ key: RemoteSystemKey) {
switch key.action {
case let .text(value):
let isLetter = value.rangeOfCharacter(from: .letters) != nil
session.bridge.sendText(shiftEnabled && isLetter
? value.uppercased() : value)
case .shift:
withTransaction(Transaction(animation: nil)) {
shiftEnabled.toggle()
}
case .backspace:
session.sendKeyStroke(0x08)
case .returnKey:
session.sendKeyStroke(0x0D)
case let .page(newPage):
page = newPage
case .switchKeyboard:
switchKeyboard()
case .dismiss:
dismiss()
}
}
}
private struct RemoteSystemKeyboardRow: View {
let keys: [RemoteSystemKey]
let shiftEnabled: Bool
let action: (RemoteSystemKey) -> Void
var body: some View {
GeometryReader { proxy in
let spacing = RemoteKeyboardMetrics.keySpacing
let totalWidth = keys.reduce(CGFloat.zero) { $0 + $1.width }
let keySpace = max(0, proxy.size.width -
CGFloat(max(0, keys.count - 1)) * spacing)
HStack(spacing: spacing) {
ForEach(Array(keys.enumerated()), id: \.offset) { _, key in
let isActive = key.isShift && shiftEnabled
let visibleTitle = shiftEnabled && key.isLetter
? key.title?.uppercased() : key.title
let visibleSymbol = isActive && key.isShift
? "shift.fill" : key.symbol
RemoteKeyboardKeyCap(
title: visibleTitle,
symbol: visibleSymbol,
style: key.style,
active: isActive
) {
action(key)
}
.id("\(visibleTitle ?? visibleSymbol ?? "key")-\(isActive)")
.frame(width: keySpace * key.width / totalWidth)
.accessibilityLabel(visibleTitle ?? "键盘操作")
}
}
}
.frame(height: RemoteKeyboardMetrics.rowHeight)
}
}
private enum RemoteComputerKeyAction {
case stroke(UInt)
case modifier(UInt)
case switchKeyboard
case dismiss
}
private struct RemoteComputerKey {
let title: String?
let symbol: String?
let width: CGFloat
let style: RemoteSystemKeyStyle
let action: RemoteComputerKeyAction
init(_ title: String,
_ keyCode: UInt,
width: CGFloat = 1,
isModifier: Bool = false,
style: RemoteSystemKeyStyle? = nil) {
self.title = title
self.symbol = nil
self.width = width
self.style = style ?? (isModifier ? .special : .character)
self.action = isModifier ? .modifier(keyCode) : .stroke(keyCode)
}
init(_ title: String,
width: CGFloat = 1,
style: RemoteSystemKeyStyle = .special,
action: RemoteComputerKeyAction) {
self.title = title
self.symbol = nil
self.width = width
self.style = style
self.action = action
}
init(symbol: String,
_ keyCode: UInt,
width: CGFloat = 1,
isModifier: Bool = false,
style: RemoteSystemKeyStyle? = nil) {
self.title = nil
self.symbol = symbol
self.width = width
self.style = style ?? (isModifier ? .special : .character)
self.action = isModifier ? .modifier(keyCode) : .stroke(keyCode)
}
init(symbol: String,
width: CGFloat = 1,
style: RemoteSystemKeyStyle = .special,
action: RemoteComputerKeyAction) {
self.title = nil
self.symbol = symbol
self.width = width
self.style = style
self.action = action
}
var modifierCode: UInt? {
if case let .modifier(keyCode) = action { return keyCode }
return nil
}
}
private struct RemoteComputerKeyboard: View {
@ObservedObject var session: RemoteSessionModel
let switchKeyboard: () -> Void
let dismiss: () -> Void
@State private var activeModifiers: Set<UInt> = []
private static let rows: [[RemoteComputerKey]] = [
[
.init("Esc", 0x1B, width: 1.2),
.init("F1", 0x70), .init("F2", 0x71), .init("F3", 0x72),
.init("F4", 0x73), .init("F5", 0x74), .init("F6", 0x75),
.init("F7", 0x76), .init("F8", 0x77), .init("F9", 0x78),
.init("F10", 0x79), .init("F11", 0x7A), .init("F12", 0x7B),
.init("Del", 0x2E, width: 1.2)
],
[
.init("~\n`", 0xC0),
.init("!\n1", 0x31), .init("@\n2", 0x32),
.init("#\n3", 0x33), .init("$\n4", 0x34),
.init("%\n5", 0x35), .init("^\n6", 0x36),
.init("&\n7", 0x37), .init("*\n8", 0x38),
.init("(\n9", 0x39), .init(")\n0", 0x30),
.init("_\n-", 0xBD), .init("+\n=", 0xBB),
.init(symbol: "delete.left", 0x08, width: 1.65,
style: .special)
],
[
.init("Tab", 0x09, width: 1.45, style: .special),
.init("Q", 0x51), .init("W", 0x57), .init("E", 0x45),
.init("R", 0x52), .init("T", 0x54), .init("Y", 0x59),
.init("U", 0x55), .init("I", 0x49), .init("O", 0x4F),
.init("P", 0x50), .init("{\n[", 0xDB),
.init("}\n]", 0xDD), .init("|\n\\", 0xDC, width: 1.25)
],
[
.init("Caps", 0x14, width: 1.7, style: .special),
.init("A", 0x41), .init("S", 0x53), .init("D", 0x44),
.init("F", 0x46), .init("G", 0x47), .init("H", 0x48),
.init("J", 0x4A), .init("K", 0x4B), .init("L", 0x4C),
.init(":\n;", 0xBA), .init("\"\n'", 0xDE),
.init("回车", 0x0D, width: 1.9, style: .accent)
],
[
.init(symbol: "shift", 0x10, width: 2.15, isModifier: true),
.init("Z", 0x5A), .init("X", 0x58), .init("C", 0x43),
.init("V", 0x56), .init("B", 0x42), .init("N", 0x4E),
.init("M", 0x4D), .init("<\n,", 0xBC),
.init(">\n.", 0xBE), .init("?\n/", 0xBF),
.init(symbol: "shift", 0x10, width: 2.15, isModifier: true)
],
[
.init("Ctrl", 0x11, width: 1.35, isModifier: true),
.init("Win", 0x5B, width: 1.25, isModifier: true),
.init("Alt", 0x12, width: 1.25, isModifier: true),
.init("空格", 0x20, width: 3.8),
.init("Alt", 0x12, width: 1.25, isModifier: true),
.init("Ctrl", 0x11, width: 1.35, isModifier: true),
.init("←", 0x25, style: .special),
.init("↑", 0x26, style: .special),
.init("↓", 0x28, style: .special),
.init("→", 0x27, style: .special),
.init(symbol: "arrow.left.arrow.right", width: 1.35,
action: .switchKeyboard),
.init(symbol: "keyboard.chevron.compact.down", width: 1.35,
action: .dismiss)
]
]
var body: some View {
VStack(spacing: RemoteKeyboardMetrics.rowSpacing) {
ForEach(Array(Self.rows.enumerated()), id: \.offset) { _, row in
RemoteComputerKeyboardRow(
keys: row,
activeModifiers: activeModifiers,
action: handleKey
)
}
}
.onDisappear(perform: releaseModifiers)
}
private func handleKey(_ key: RemoteComputerKey) {
switch key.action {
case let .modifier(keyCode):
if activeModifiers.contains(keyCode) {
session.sendKeyState(keyCode, isDown: false)
activeModifiers.remove(keyCode)
} else {
session.sendKeyState(keyCode, isDown: true)
activeModifiers.insert(keyCode)
}
case let .stroke(keyCode):
session.sendKeyStroke(keyCode)
case .switchKeyboard:
releaseModifiers()
switchKeyboard()
case .dismiss:
releaseModifiers()
dismiss()
}
}
private func releaseModifiers() {
for keyCode in activeModifiers {
session.sendKeyState(keyCode, isDown: false)
}
activeModifiers.removeAll()
}
}
private struct RemoteComputerKeyboardRow: View {
let keys: [RemoteComputerKey]
let activeModifiers: Set<UInt>
let action: (RemoteComputerKey) -> Void
var body: some View {
GeometryReader { proxy in
let spacing = RemoteKeyboardMetrics.keySpacing
let totalWidth = keys.reduce(CGFloat.zero) { $0 + $1.width }
let keySpace = max(0, proxy.size.width -
CGFloat(max(0, keys.count - 1)) * spacing)
HStack(spacing: spacing) {
ForEach(Array(keys.enumerated()), id: \.offset) { _, key in
let isActive = key.modifierCode.map(activeModifiers.contains) ?? false
let visibleSymbol = isActive && key.symbol == "shift"
? "shift.fill" : key.symbol
RemoteKeyboardKeyCap(
title: key.title,
symbol: visibleSymbol,
style: key.style,
active: isActive
) {
action(key)
}
.frame(width: keySpace * key.width / totalWidth)
.accessibilityLabel((key.title ?? "键盘操作")
.replacingOccurrences(of: "\n", with: " "))
}
}
}
.frame(height: RemoteKeyboardMetrics.rowHeight)
}
}
private struct CrossDeskStatusOrb: View {
let isExpanded: Bool
private static let appIcon: UIImage? = {
let primaryIcon = (Bundle.main.infoDictionary?["CFBundleIcons"]
as? [String: Any])?["CFBundlePrimaryIcon"] as? [String: Any]
let iconName = primaryIcon?["CFBundleIconName"] as? String
let iconFiles = primaryIcon?["CFBundleIconFiles"] as? [String]
return iconName.flatMap(UIImage.init(named:))
?? iconFiles?.last.flatMap(UIImage.init(named:))
?? UIImage(named: "AppIcon")
}()
var body: some View {
ZStack(alignment: .bottomTrailing) {
Circle()
.fill(Color(.secondarySystemBackground).opacity(0.96))
if let icon = Self.appIcon {
Image(uiImage: icon)
.resizable()
.scaledToFill()
.clipShape(Circle())
.padding(4)
} else {
Image(systemName: "desktopcomputer")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.blue)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.overlay {
Circle()
.stroke(Color.white.opacity(isExpanded ? 0.95 : 0.65),
lineWidth: isExpanded ? 2 : 1)
}
.compositingGroup()
.accessibilityLabel(isExpanded ? "收起远程控制菜单" : "展开远程控制菜单")
}
}
/// Measure the menu before positioning it; no fixed height or later-frame
/// preference update is needed when changing pages, text size or orientation.
private struct FloatingMenuLayout: Layout {
let orbCenter: CGPoint
let panelWidth: CGFloat
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews,
cache: inout ()) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
subviews: Subviews, cache: inout ()) {
guard let menu = subviews.first else { return }
let size = menu.sizeThatFits(ProposedViewSize(
width: panelWidth, height: max(0, bounds.height - 16)))
let preferredX = orbCenter.x > bounds.width / 2
? orbCenter.x - 39 - size.width / 2
: orbCenter.x + 39 + size.width / 2
let center = CGPoint(
x: bounds.minX + min(max(preferredX, size.width / 2 + 8),
bounds.width - size.width / 2 - 8),
y: bounds.minY + min(max(orbCenter.y, size.height / 2 + 8),
bounds.height - size.height / 2 - 8))
menu.place(at: center, anchor: .center, proposal: ProposedViewSize(size))
}
}
private struct FloatingSessionMenu: View {
@ObservedObject var session: RemoteSessionModel
let virtualMouseVisible: Bool
let toggleMouse: () -> Void
let releaseMouse: () -> Void
private enum Page { case controls, videoSettings, networkStats }
@State private var page: Page = .controls
@State private var displayedNetworkSnapshot: RemoteNetworkSnapshot
@State private var displayedFrameSize: CGSize
let showKeyboard: () -> Void
let chooseFile: () -> Void
let close: () -> Void
let disconnect: () -> Void
let columnCount: Int
private var columns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 7), count: columnCount)
}
init(session: RemoteSessionModel,
columnCount: Int,
virtualMouseVisible: Bool,
toggleMouse: @escaping () -> Void,
releaseMouse: @escaping () -> Void,
showKeyboard: @escaping () -> Void,
chooseFile: @escaping () -> Void,
close: @escaping () -> Void,
disconnect: @escaping () -> Void) {
self.session = session
self.columnCount = columnCount
self.virtualMouseVisible = virtualMouseVisible
self.toggleMouse = toggleMouse
self.releaseMouse = releaseMouse
self.showKeyboard = showKeyboard
self.chooseFile = chooseFile
self.close = close
self.disconnect = disconnect
// Render the first frame with real values instead of replacing empty
// placeholders while the panel's insertion animation is running.
_displayedNetworkSnapshot = State(initialValue: session.networkSnapshot)
_displayedFrameSize = State(initialValue: session.frameSize)
}
var body: some View {
// Prefer the full natural-height panel. Only use a scrolling body when
// the available safe-area height cannot contain all of its content.
ViewThatFits(in: .vertical) {
panel(scrolls: false).fixedSize(horizontal: false, vertical: true)
panel(scrolls: true)
}
.padding(12)
.background(Color.white,
in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color(.separator).opacity(0.24), lineWidth: 1)
}
.shadow(color: .black.opacity(0.34), radius: 18, y: 7)
.environment(\.colorScheme, .light)
.task(id: session.selectedDisplay) {
// This task survives ordinary video-frame redraws and is cancelled
// when the menu closes. Rendering only reads the sampled values.
while !Task.isCancelled {
displayedNetworkSnapshot = session.networkSnapshot
displayedFrameSize = session.frameSize
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
return
}
}
}
}
private func panel(scrolls: Bool) -> some View {
VStack(spacing: 9) {
menuHeader
if scrolls {
ScrollView { menuContent }
} else {
menuContent
}
disconnectButton
}
}
@ViewBuilder private var menuHeader: some View {
HStack(spacing: 8) {
if page != .controls {
Button {
page = .controls
} label: {
Image(systemName: "chevron.left")
.frame(width: 28, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("返回控制栏")
Text(page == .videoSettings ? "画面设置" : "网络状态")
.font(.subheadline.weight(.semibold))
} else {
Circle()
.fill(session.isConnected ? Color.green : Color.orange)
.frame(width: 9, height: 9)
VStack(alignment: .leading, spacing: 1) {
Text(session.connectionStatus)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(statusDetail)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
Button(action: close) {
Image(systemName: "xmark.circle.fill")
.font(.title3)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("关闭控制栏")
}
if page == .videoSettings, !session.videoSettingsFeedback.isEmpty {
Text(session.videoSettingsFeedback)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder private var menuContent: some View {
if page == .videoSettings {
FloatingVideoSettings(session: session)
} else if page == .networkStats {
FloatingNetworkStatistics(snapshot: displayedNetworkSnapshot,
frameSize: displayedFrameSize)
} else {
VStack(spacing: 9) {
LazyVGrid(columns: columns, spacing: 7) {
FloatingControlButton(title: "键盘", symbol: "keyboard",
action: showKeyboard)
FloatingControlButton(
title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标",
symbol: "computermouse",
action: {
session.mouseControlMode = session.mouseControlMode == .relative
? .absolute : .relative
}
)
FloatingControlButton(title: virtualMouseVisible ? "收起鼠标" : "虚拟鼠标",
symbol: "computermouse", action: toggleMouse)
Menu {
ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in
Button {
releaseMouse()
session.selectDisplay(index)
} label: {
if index == session.selectedDisplay {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
FloatingControlLabel(title: "显示器", symbol: "display")
}
.buttonStyle(.plain)
FloatingControlButton(title: "画面设置", symbol: "slider.horizontal.3") {
page = .videoSettings
}
FloatingControlButton(
title: session.audioEnabled ? "声音" : "静音",
symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash",
action: session.toggleAudio
)
FloatingControlButton(title: "发送文件",
symbol: "folder.badge.plus",
action: chooseFile)
FloatingControlButton(title: "网络状态", symbol: "chart.bar.xaxis") {
page = .networkStats
}
FloatingControlButton(title: "Ctrl+Alt+Del",
symbol: "lock.trianglebadge.exclamationmark",
action: session.sendSecureAttention)
FloatingControlButton(title: "剪贴板", symbol: "doc.on.clipboard",
action: session.sendClipboard)
}
if !session.controlStatus.isEmpty {
Text(session.controlStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.clipboardStatus.isEmpty {
Text(session.clipboardStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.transferStatus.isEmpty {
HStack(spacing: 8) {
Text(session.transferStatus)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 0)
if session.transferProgress > 0 && session.transferProgress < 1 {
ProgressView(value: session.transferProgress)
.frame(width: 60)
}
if let file = session.receivedFileURL {
ShareLink(item: file) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
}
}
}
private var disconnectButton: some View {
Button(role: .destructive, action: disconnect) {
Text("断开连接")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.frame(height: 34)
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
private var statusDetail: String {
let snapshot = displayedNetworkSnapshot
var components = [snapshot.connectionMode]
let frameSize = displayedFrameSize
if frameSize.width > 0 && frameSize.height > 0 {
components.append("\(Int(frameSize.width))×\(Int(frameSize.height))")
}
components.append(RemoteNetworkSnapshot.bitrate(snapshot.report?.total.inboundBitrate))
if let loss = snapshot.report?.video.lossRate, loss > 0 {
components.append("丢包 \(RemoteNetworkSnapshot.lossRate(loss))")
}
return components.joined(separator: " · ")
}
}
private struct FloatingNetworkStatistics: View {
let snapshot: RemoteNetworkSnapshot
let frameSize: CGSize
var body: some View {
VStack(spacing: 10) {
VStack(spacing: 0) {
trafficRow("", inbound: "接收", outbound: "发送", loss: "丢包率")
.foregroundStyle(.secondary)
trafficRow("视频", statistics: snapshot.report?.video)
trafficRow("音频", statistics: snapshot.report?.audio)
trafficRow("数据", statistics: snapshot.report?.data)
trafficRow("合计", statistics: snapshot.report?.total)
.fontWeight(.semibold)
.background(Color.blue.opacity(0.07), in: RoundedRectangle(cornerRadius: 5))
}
.font(.caption2.monospacedDigit())
Divider()
VStack(spacing: 7) {
detail("帧率", value: "\(snapshot.framesPerSecond) FPS")
detail("分辨率", value: resolution)
detail("画面延时", value: RemoteNetworkSnapshot.latency(snapshot.videoLatencyMilliseconds))
detail("连接延时(RTT)", value: RemoteNetworkSnapshot.latency(snapshot.rttMilliseconds))
detail("连接方式", value: snapshot.connectionMode)
HStack {
Text("媒体加密")
.foregroundStyle(.secondary)
Spacer(minLength: 8)
if let report = snapshot.report {
Label(report.srtpActive ? "SRTP 已启用" : "未启用",
systemImage: report.srtpActive ? "lock.shield" : "lock.open")
.foregroundStyle(report.srtpActive ? Color.green : Color.secondary)
} else {
Text("—")
}
}
.accessibilityElement(children: .combine)
}
.font(.caption.monospacedDigit())
}
.padding(.vertical, 2)
}
private var resolution: String {
guard frameSize.width > 0, frameSize.height > 0 else { return "—" }
return "\(Int(frameSize.width)) × \(Int(frameSize.height))"
}
private func detail(_ title: String, value: String) -> some View {
HStack {
Text(title).foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(value)
}
.accessibilityElement(children: .combine)
}
private func trafficRow(_ title: String, statistics: RemoteTrafficStatistics?) -> some View {
trafficRow(title,
inbound: RemoteNetworkSnapshot.bitrate(statistics?.inboundBitrate),
outbound: RemoteNetworkSnapshot.bitrate(statistics?.outboundBitrate),
loss: RemoteNetworkSnapshot.lossRate(statistics?.lossRate))
.accessibilityLabel("\(title),接收 \(RemoteNetworkSnapshot.bitrate(statistics?.inboundBitrate)),发送 \(RemoteNetworkSnapshot.bitrate(statistics?.outboundBitrate)),丢包率 \(RemoteNetworkSnapshot.lossRate(statistics?.lossRate))")
}
private func trafficRow(_ title: String, inbound: String,
outbound: String, loss: String) -> some View {
HStack(spacing: 4) {
Text(title).frame(width: 28, alignment: .leading)
Text(inbound).frame(maxWidth: .infinity, alignment: .trailing)
Text(outbound).frame(maxWidth: .infinity, alignment: .trailing)
Text(loss).frame(width: 46, alignment: .trailing)
}
.lineLimit(1)
.minimumScaleFactor(0.75)
.padding(.horizontal, 4)
.padding(.vertical, 5)
.accessibilityElement(children: .combine)
}
}
private struct FloatingVideoSettings: View {
@ObservedObject var session: RemoteSessionModel
var body: some View {
VStack(alignment: .leading, spacing: 12) {
settingRow("画面质量", options: ["低", "中", "高"],
values: [0, 1, 2], selection: Binding(
get: { session.videoSettings.selection.quality },
set: { session.updateVideoSettings(quality: $0) }
))
settingRow("画面采集帧率", options: ["30 fps", "60 fps"],
values: [30, 60], selection: Binding(
get: { session.videoSettings.selection.frameRate },
set: { session.updateVideoSettings(frameRate: $0) }
))
settingRow("画面偏好", options: VideoAdaptationPolicy.allCases.map(\.title),
values: VideoAdaptationPolicy.allCases.map { $0.bridgeValue.rawValue },
selection: Binding(
get: { session.videoSettings.selection.preference },
set: { session.updateVideoSettings(preference: $0) }
))
}
.padding(.vertical, 2)
}
private func settingRow(_ title: String, options: [String], values: [Int],
selection: Binding<Int>) -> some View {
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Picker(title, selection: selection) {
ForEach(options.indices, id: \.self) { index in
Text(options[index]).tag(values[index])
}
}
.pickerStyle(.segmented)
.disabled(!session.canChangeVideoSettings)
}
}
}
private struct FloatingControlButton: View {
let title: String
let symbol: String
let action: () -> Void
var body: some View {
Button(action: action) {
FloatingControlLabel(title: title, symbol: symbol)
}
.buttonStyle(.plain)
}
}
private struct FloatingControlLabel: View {
let title: String
let symbol: String
var body: some View {
VStack(spacing: 4) {
Image(systemName: symbol)
.font(.system(size: 16, weight: .semibold))
Text(title)
.font(.system(size: 9.5, weight: .medium))
.lineLimit(1)
.minimumScaleFactor(0.72)
}
.foregroundStyle(.primary)
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(Color.primary.opacity(0.07),
in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
private struct RemoteCursorOverlay: View {
let normalizedPosition: CGPoint
let normalizedVisualOffset: CGPoint
let contentSize: CGSize
let viewportScale: CGFloat
let shape: Int
var body: some View {
let scale = max(viewportScale, 0.0001)
let x = min(max(normalizedPosition.x + normalizedVisualOffset.x, 0), 1) *
contentSize.width
let y = min(max(normalizedPosition.y + normalizedVisualOffset.y, 0), 1) *
contentSize.height
let offset = RemoteCursorGlyph.hotspotOffset(for: shape)
ZStack(alignment: .topLeading) {
RemoteCursorGlyph(shape: shape)
// The cursor lives inside the exact same transformed view as
// the decoded frame, so its anchor cannot drift from video
// because of an independently reconstructed transform. Undo
// the outer zoom for the glyph itself and pre-divide the
// hotspot offset so the cursor remains a constant screen size.
.scaleEffect(1 / scale, anchor: .center)
.position(x: x + offset.x / scale,
y: y + offset.y / scale)
}
.frame(width: contentSize.width, height: contentSize.height,
alignment: .topLeading)
}
}
private struct RemoteCursorGlyph: View {
let shape: Int
var body: some View {
Group {
if shape == 0 {
ZStack {
RemoteCursorArrowShape()
.stroke(Color.black, lineWidth: 1.75)
RemoteCursorArrowShape()
.fill(Color.white)
}
.frame(width: 12, height: 15)
} else if shape == 7 {
ZStack {
RemoteIBeamShape()
.stroke(Color.black, lineWidth: 2.5)
RemoteIBeamShape()
.stroke(Color.white, lineWidth: 1)
}
.frame(width: 12, height: 15)
} else {
Image(systemName: availableSymbolName)
.font(.system(size: 10.5, weight: .black))
.symbolRenderingMode(.monochrome)
.foregroundStyle(.white)
.shadow(color: .black, radius: 0.75)
}
}
.frame(width: 15, height: 15)
.accessibilityHidden(true)
}
static func hotspotOffset(for shape: Int) -> CGPoint {
switch shape {
case 0:
// The arrow path's visual tip is near (2.5, 0.6) inside its
// 15-point container, rather than at the container origin.
return CGPoint(x: 5, y: 7)
case 3:
return CGPoint(x: 4.5, y: 4.5)
default:
return .zero
}
}
private var availableSymbolName: String {
let desired: String
switch shape {
case 2: desired = "questionmark.circle.fill"
case 3: desired = "hand.point.up.left.fill"
case 4: desired = "arrow.triangle.2.circlepath"
case 5: desired = "hourglass"
case 6: desired = "scope"
case 8: desired = "arrow.turn.up.right"
case 9: desired = "plus.square.on.square"
case 10: desired = "arrow.up.and.down.and.arrow.left.and.right"
case 11, 12: desired = "nosign"
case 13, 14: desired = "hand.raised.fill"
case 15, 25: desired = "arrow.left.and.right"
case 16, 26: desired = "arrow.up.and.down"
case 17: desired = "arrow.up"
case 18: desired = "arrow.right"
case 19: desired = "arrow.down"
case 20: desired = "arrow.left"
case 21: desired = "arrow.up.right"
case 22: desired = "arrow.up.left"
case 23: desired = "arrow.down.right"
case 24: desired = "arrow.down.left"
case 27: desired = "arrow.up.right.and.arrow.down.left"
case 28: desired = "arrow.up.left.and.arrow.down.right"
default: desired = "arrow.up.left"
}
return UIImage(systemName: desired) == nil ? "arrow.up.left" : desired
}
}
private struct RemoteCursorArrowShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX + rect.width * 0.08,
y: rect.minY + rect.height * 0.04))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.08,
y: rect.minY + rect.height * 0.78))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.34,
y: rect.minY + rect.height * 0.60))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.55,
y: rect.minY + rect.height * 0.94))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.73,
y: rect.minY + rect.height * 0.84))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.52,
y: rect.minY + rect.height * 0.51))
path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.88,
y: rect.minY + rect.height * 0.49))
path.closeSubpath()
return path
}
}
private struct RemoteIBeamShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let centerX = rect.midX
let top = rect.minY + 2
let bottom = rect.maxY - 2
path.move(to: CGPoint(x: centerX, y: top))
path.addLine(to: CGPoint(x: centerX, y: bottom))
path.move(to: CGPoint(x: rect.minX + 5, y: top))
path.addLine(to: CGPoint(x: rect.maxX - 5, y: top))
path.move(to: CGPoint(x: rect.minX + 5, y: bottom))
path.addLine(to: CGPoint(x: rect.maxX - 5, y: bottom))
return path
}
}