mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-19 02:13:03 +08:00
[feat] add compact mobile virtual mouse controls
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
10000000000000000000001D /* RemoteMouseInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001D /* RemoteMouseInput.swift */; };
|
||||
10000000000000000000001E /* VirtualMouseBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000001E /* VirtualMouseBar.swift */; };
|
||||
100000000000000000000018 /* RecentConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000018 /* RecentConnection.swift */; };
|
||||
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; };
|
||||
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000017 /* RemoteNetworkStatistics.swift */; };
|
||||
@@ -34,6 +36,8 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
20000000000000000000001D /* RemoteMouseInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMouseInput.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000001E /* VirtualMouseBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMouseBar.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000018 /* RecentConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentConnection.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000016 /* RemoteVideoSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoSettings.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000017 /* RemoteNetworkStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNetworkStatistics.swift; sourceTree = "<group>"; };
|
||||
@@ -149,6 +153,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
20000000000000000000000A /* RemoteTouchInputView.swift */,
|
||||
20000000000000000000001D /* RemoteMouseInput.swift */,
|
||||
20000000000000000000001E /* VirtualMouseBar.swift */,
|
||||
);
|
||||
path = Input;
|
||||
sourceTree = "<group>";
|
||||
@@ -289,6 +295,8 @@
|
||||
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */,
|
||||
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */,
|
||||
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */,
|
||||
10000000000000000000001D /* RemoteMouseInput.swift in Sources */,
|
||||
10000000000000000000001E /* VirtualMouseBar.swift in Sources */,
|
||||
100000000000000000000012 /* PrivacyPreferences.swift in Sources */,
|
||||
100000000000000000000013 /* ThumbnailFileStore.swift in Sources */,
|
||||
100000000000000000000014 /* PrivacyViews.swift in Sources */,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
/// Shared by the video surface and the virtual buttons. Coordinates always
|
||||
/// refer to the remote cursor, never to the floating controls' screen location.
|
||||
final class RemoteMouseInput {
|
||||
var send: ((Float, Float, Int, Int) -> Void)?
|
||||
var didCancel: (() -> Void)?
|
||||
var viewportSize = CGSize(width: 0, height: 0)
|
||||
private(set) var x: Float = 0.5
|
||||
private(set) var y: Float = 0.5
|
||||
private(set) var revision: UInt64 = 0
|
||||
private var gestureLeft = false
|
||||
private var virtualLeft = false
|
||||
private var virtualRight = false
|
||||
var hasVirtualButton: Bool { virtualLeft || virtualRight }
|
||||
|
||||
func position(x: Float, y: Float) {
|
||||
self.x = min(max(x, 0), 1)
|
||||
self.y = min(max(y, 0), 1)
|
||||
}
|
||||
|
||||
func drag(dx: Float, dy: Float) {
|
||||
guard hasVirtualButton, viewportSize.width > 0, viewportSize.height > 0 else { return }
|
||||
position(x: x + dx / Float(viewportSize.width) * 1.35,
|
||||
y: y + dy / Float(viewportSize.height) * 1.35)
|
||||
emit(0)
|
||||
}
|
||||
|
||||
func setGestureLeft(_ down: Bool) {
|
||||
guard !(down && hasVirtualButton), gestureLeft != down else { return }
|
||||
gestureLeft = down
|
||||
emit(down ? 1 : 2)
|
||||
}
|
||||
|
||||
func setVirtualButton(left: Bool, down: Bool) {
|
||||
guard (left ? virtualLeft : virtualRight) != down else { return }
|
||||
if down {
|
||||
setGestureLeft(false)
|
||||
revision &+= 1
|
||||
}
|
||||
if left { virtualLeft = down } else { virtualRight = down }
|
||||
emit(left ? (down ? 1 : 2) : (down ? 3 : 4))
|
||||
}
|
||||
|
||||
func scroll(_ direction: Int) {
|
||||
guard direction != 0 else { return }
|
||||
emit(7, wheel: direction > 0 ? 1 : -1)
|
||||
}
|
||||
|
||||
func releaseAll() {
|
||||
setGestureLeft(false)
|
||||
setVirtualButton(left: true, down: false)
|
||||
setVirtualButton(left: false, down: false)
|
||||
revision &+= 1
|
||||
didCancel?()
|
||||
}
|
||||
|
||||
private func emit(_ flag: Int, wheel: Int = 0) { send?(x, y, flag, wheel) }
|
||||
}
|
||||
@@ -55,6 +55,7 @@ enum RemoteVideoGeometry {
|
||||
}
|
||||
|
||||
struct RemoteTouchInputView: UIViewRepresentable {
|
||||
let mouseInput: RemoteMouseInput
|
||||
let videoSize: CGSize
|
||||
let controlMode: MouseControlMode
|
||||
let remoteCursorPosition: CGPoint?
|
||||
@@ -73,6 +74,7 @@ struct RemoteTouchInputView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func updateUIView(_ view: RemoteTouchSurface, context: Context) {
|
||||
view.mouseInput = mouseInput
|
||||
view.videoSize = videoSize
|
||||
view.controlMode = controlMode
|
||||
view.setViewport(scale: viewportScale, offset: viewportOffset)
|
||||
@@ -88,6 +90,8 @@ struct RemoteTouchInputView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
var mouseInput: RemoteMouseInput?
|
||||
private var gestureRevisions: [ObjectIdentifier: UInt64] = [:]
|
||||
var videoSize = CGSize.zero {
|
||||
didSet { setNeedsLayout() }
|
||||
}
|
||||
@@ -108,7 +112,14 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
var onScroll: ((Float, Float, Int, Int) -> Void)?
|
||||
var onViewportChanged: ((CGFloat, CGSize) -> Void)?
|
||||
|
||||
private var relativeCursorPoint: (Float, Float) = (0.5, 0.5)
|
||||
private var fallbackCursorPoint: (Float, Float) = (0.5, 0.5)
|
||||
private var relativeCursorPoint: (Float, Float) {
|
||||
get { mouseInput.map { ($0.x, $0.y) } ?? fallbackCursorPoint }
|
||||
set {
|
||||
fallbackCursorPoint = newValue
|
||||
mouseInput?.position(x: newValue.0, y: newValue.1)
|
||||
}
|
||||
}
|
||||
private var lastRemoteCursorPoint: (Float, Float)?
|
||||
private var lastRemoteCursorRevision: UInt64?
|
||||
private var relativePanActive = false
|
||||
@@ -128,10 +139,12 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
isMultipleTouchEnabled = true
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
tap.delegate = self
|
||||
tap.numberOfTouchesRequired = 1
|
||||
addGestureRecognizer(tap)
|
||||
|
||||
let rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap(_:)))
|
||||
rightTap.delegate = self
|
||||
rightTap.numberOfTouchesRequired = 2
|
||||
tap.require(toFail: rightTap)
|
||||
addGestureRecognizer(rightTap)
|
||||
@@ -174,6 +187,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
mouseInput?.viewportSize = renderRect()?.size ?? .zero
|
||||
let constrained = constrainedViewportOffset(viewportOffset,
|
||||
scale: viewportScale)
|
||||
guard constrained != viewportOffset else { return }
|
||||
@@ -185,6 +199,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
let nextScale = min(max(scale, 1), maximumViewportScale)
|
||||
viewportScale = nextScale
|
||||
viewportOffset = constrainedViewportOffset(offset, scale: nextScale)
|
||||
mouseInput?.viewportSize = renderRect()?.size ?? .zero
|
||||
}
|
||||
|
||||
func synchronizeRemoteCursor(_ position: CGPoint?, revision: UInt64) {
|
||||
@@ -203,12 +218,39 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
)
|
||||
lastRemoteCursorPoint = point
|
||||
guard controlMode == .relative,
|
||||
mouseInput?.hasVirtualButton != true,
|
||||
!relativePanActive,
|
||||
heldDragPoint == nil,
|
||||
hoverLastLocation == nil else { return }
|
||||
relativeCursorPoint = point
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldReceive touch: UITouch) -> Bool {
|
||||
if gestureRecognizer is UITapGestureRecognizer || gestureRecognizer is UILongPressGestureRecognizer {
|
||||
guard mouseInput?.hasVirtualButton != true else { return false }
|
||||
gestureRevisions[ObjectIdentifier(gestureRecognizer)] = mouseInput?.revision ?? 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func allowsPointerGesture(_ recognizer: UIGestureRecognizer) -> Bool {
|
||||
mouseInput?.hasVirtualButton != true &&
|
||||
gestureRevisions[ObjectIdentifier(recognizer)] == (mouseInput?.revision ?? 0)
|
||||
}
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer is UITapGestureRecognizer || gestureRecognizer is UILongPressGestureRecognizer {
|
||||
// Decline recognition before a stale long press can cancel the pan
|
||||
// used by the other finger to move a held virtual mouse button.
|
||||
return allowsPointerGesture(gestureRecognizer)
|
||||
}
|
||||
if gestureRecognizer is UIPinchGestureRecognizer {
|
||||
return mouseInput?.hasVirtualButton != true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
let recognizers = [gestureRecognizer, otherGestureRecognizer]
|
||||
@@ -223,6 +265,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ recognizer: UITapGestureRecognizer) {
|
||||
guard allowsPointerGesture(recognizer) else { return }
|
||||
let location = recognizer.location(in: self)
|
||||
guard recognizer.state == .ended,
|
||||
let point = pointerPoint(for: location) else { return }
|
||||
@@ -240,6 +283,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
|
||||
@objc private func handleRightTap(_ recognizer: UITapGestureRecognizer) {
|
||||
guard allowsPointerGesture(recognizer) else { return }
|
||||
guard recognizer.state == .ended,
|
||||
let point = pointerPoint(for: recognizer.location(in: self)) else { return }
|
||||
onMove?(point.0, point.1)
|
||||
@@ -262,6 +306,12 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
|
||||
@objc private func handleHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
|
||||
guard allowsPointerGesture(recognizer) else {
|
||||
// A virtual button took ownership while the surface gesture was in flight.
|
||||
heldDragPoint = nil
|
||||
heldDragLastLocation = nil
|
||||
return
|
||||
}
|
||||
if controlMode == .relative {
|
||||
handleRelativeHeldDrag(recognizer)
|
||||
return
|
||||
@@ -289,6 +339,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
|
||||
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
|
||||
guard mouseInput?.hasVirtualButton != true else { pinchActive = false; return }
|
||||
let focus = recognizer.location(in: self)
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
@@ -327,6 +378,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
|
||||
@objc private func handleWheel(_ recognizer: UIPanGestureRecognizer) {
|
||||
guard mouseInput?.hasVirtualButton != true else { viewportPanActive = false; return }
|
||||
if viewportScale > 1.001 || pinchActive {
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct VirtualMouseBar: UIViewRepresentable {
|
||||
let input: RemoteMouseInput
|
||||
|
||||
func makeUIView(context: Context) -> VirtualMouseBarView {
|
||||
VirtualMouseBarView(input: input)
|
||||
}
|
||||
|
||||
func updateUIView(_ view: VirtualMouseBarView, context: Context) {}
|
||||
|
||||
static func dismantleUIView(_ view: VirtualMouseBarView, coordinator: ()) {
|
||||
view.input.releaseAll()
|
||||
}
|
||||
}
|
||||
|
||||
final class VirtualMouseBarView: UIView {
|
||||
let input: RemoteMouseInput
|
||||
private let keys: [VirtualMouseKey]
|
||||
|
||||
init(input: RemoteMouseInput) {
|
||||
self.input = input
|
||||
keys = [VirtualMouseKey(kind: .left, input: input),
|
||||
VirtualMouseKey(kind: .wheel, input: input),
|
||||
VirtualMouseKey(kind: .right, input: input)]
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 18
|
||||
layer.borderWidth = 0.5
|
||||
layer.borderColor = UIColor(red: 212 / 255, green: 218 / 255, blue: 226 / 255, alpha: 1).cgColor
|
||||
isMultipleTouchEnabled = true
|
||||
keys.forEach(addSubview)
|
||||
input.didCancel = { [weak self] in self?.keys.forEach { $0.cancelTracking() } }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let width = max(0, bounds.width - 8) / 3
|
||||
for (index, key) in keys.enumerated() {
|
||||
key.frame = CGRect(x: 4 + CGFloat(index) * width, y: 2,
|
||||
width: width, height: max(0, bounds.height - 4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class VirtualMouseKey: UIView {
|
||||
enum Kind { case left, wheel, right }
|
||||
private let kind: Kind
|
||||
private let input: RemoteMouseInput
|
||||
private let face = UIView()
|
||||
private let faceMask = CAShapeLayer()
|
||||
private let wheelIcon = VirtualMouseWheelView()
|
||||
private var trackedTouch: UITouch?
|
||||
private var lastPoint = CGPoint.zero
|
||||
private var remainder: CGFloat = 0
|
||||
|
||||
init(kind: Kind, input: RemoteMouseInput) {
|
||||
self.kind = kind
|
||||
self.input = input
|
||||
super.init(frame: .zero)
|
||||
isMultipleTouchEnabled = false
|
||||
isAccessibilityElement = true
|
||||
accessibilityTraits = kind == .wheel ? [.adjustable] : [.button]
|
||||
accessibilityLabel = kind == .left ? "鼠标左键" : kind == .right ? "鼠标右键" : "鼠标滚轮"
|
||||
accessibilityHint = kind == .wheel ? "上下滑动,可滑出按键范围" : "按住并拖动,可滑出按键范围,松开结束拖拽"
|
||||
face.isUserInteractionEnabled = false
|
||||
face.layer.mask = faceMask
|
||||
addSubview(face)
|
||||
wheelIcon.isHidden = kind != .wheel
|
||||
wheelIcon.isUserInteractionEnabled = false
|
||||
addSubview(wheelIcon)
|
||||
highlight(false)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
// All three controls keep a 44 × 44 pt hit area; only the wheel's face
|
||||
// narrows to 18 pt. Empty gutters separate it from the two paddles.
|
||||
face.frame = kind == .wheel
|
||||
? CGRect(x: (bounds.width - 18) / 2, y: (bounds.height - 30) / 2, width: 18, height: 30)
|
||||
: bounds.insetBy(dx: 1, dy: 2)
|
||||
let leftRadius: CGFloat = kind == .left ? 14 : 6
|
||||
let rightRadius: CGFloat = kind == .right ? 14 : 6
|
||||
faceMask.frame = face.bounds
|
||||
faceMask.path = facePath(in: face.bounds, leftRadius: leftRadius, rightRadius: rightRadius).cgPath
|
||||
wheelIcon.frame = face.frame.insetBy(dx: 3, dy: 3)
|
||||
}
|
||||
|
||||
private func facePath(in rect: CGRect, leftRadius: CGFloat, rightRadius: CGFloat) -> UIBezierPath {
|
||||
let path = UIBezierPath()
|
||||
let left = min(leftRadius, rect.height / 2, rect.width / 2)
|
||||
let right = min(rightRadius, rect.height / 2, rect.width / 2)
|
||||
path.move(to: CGPoint(x: rect.minX + left, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - right, y: rect.minY))
|
||||
path.addArc(withCenter: CGPoint(x: rect.maxX - right, y: rect.minY + right),
|
||||
radius: right, startAngle: -.pi / 2, endAngle: 0, clockwise: true)
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - right))
|
||||
path.addArc(withCenter: CGPoint(x: rect.maxX - right, y: rect.maxY - right),
|
||||
radius: right, startAngle: 0, endAngle: .pi / 2, clockwise: true)
|
||||
path.addLine(to: CGPoint(x: rect.minX + left, y: rect.maxY))
|
||||
path.addArc(withCenter: CGPoint(x: rect.minX + left, y: rect.maxY - left),
|
||||
radius: left, startAngle: .pi / 2, endAngle: .pi, clockwise: true)
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + left))
|
||||
path.addArc(withCenter: CGPoint(x: rect.minX + left, y: rect.minY + left),
|
||||
radius: left, startAngle: .pi, endAngle: .pi * 1.5, clockwise: true)
|
||||
path.close()
|
||||
return path
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard trackedTouch == nil, let touch = touches.first else { return }
|
||||
trackedTouch = touch
|
||||
lastPoint = touch.location(in: window)
|
||||
remainder = 0
|
||||
highlight(true)
|
||||
if kind != .wheel { input.setVirtualButton(left: kind == .left, down: true) }
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = trackedTouch, touches.contains(touch) else { return }
|
||||
let point = touch.location(in: window)
|
||||
let delta = CGPoint(x: point.x - lastPoint.x, y: point.y - lastPoint.y)
|
||||
lastPoint = point
|
||||
if kind != .wheel {
|
||||
input.drag(dx: Float(delta.x), dy: Float(delta.y))
|
||||
return
|
||||
}
|
||||
wheelIcon.advance(by: delta.y)
|
||||
remainder += delta.y
|
||||
let ticks = Int(remainder / 10)
|
||||
if ticks != 0 {
|
||||
// UIKit keeps delivering this touch outside the 44 pt key. Finger
|
||||
// down maps to negative wheel units (scroll down) on the desktop.
|
||||
for _ in 0..<min(abs(ticks), 32) { input.scroll(ticks > 0 ? -1 : 1) }
|
||||
remainder -= CGFloat(ticks) * 10
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = trackedTouch, touches.contains(touch) else { return }
|
||||
cancelTracking()
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { cancelTracking() }
|
||||
|
||||
func cancelTracking() {
|
||||
if trackedTouch != nil, kind != .wheel {
|
||||
input.setVirtualButton(left: kind == .left, down: false)
|
||||
}
|
||||
trackedTouch = nil
|
||||
remainder = 0
|
||||
highlight(false)
|
||||
}
|
||||
|
||||
override func accessibilityActivate() -> Bool {
|
||||
if kind == .wheel { accessibilityIncrement() }
|
||||
else {
|
||||
input.setVirtualButton(left: kind == .left, down: true)
|
||||
input.setVirtualButton(left: kind == .left, down: false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
override func accessibilityIncrement() {
|
||||
if kind == .wheel { wheelIcon.advance(by: 10); input.scroll(-1) }
|
||||
}
|
||||
override func accessibilityDecrement() {
|
||||
if kind == .wheel { wheelIcon.advance(by: -10); input.scroll(1) }
|
||||
}
|
||||
|
||||
private func highlight(_ pressed: Bool) {
|
||||
face.backgroundColor = pressed ? UIColor(red: 46 / 255, green: 122 / 255, blue: 219 / 255, alpha: 1)
|
||||
: UIColor(red: 237 / 255, green: 240 / 255, blue: 244 / 255, alpha: 1)
|
||||
wheelIcon.strokeColor = pressed ? .white
|
||||
: UIColor(red: 48 / 255, green: 54 / 255, blue: 64 / 255, alpha: 0.72)
|
||||
}
|
||||
}
|
||||
|
||||
/// A clipped conveyor of wheel treads: follows touch movement and stops on lift.
|
||||
private final class VirtualMouseWheelView: UIView {
|
||||
var strokeColor = UIColor.black { didSet { setNeedsDisplay() } }
|
||||
private var phase: CGFloat = 2
|
||||
private let pitch: CGFloat = 5
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
isOpaque = false
|
||||
backgroundColor = .clear
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func advance(by distance: CGFloat) {
|
||||
phase = (phase + distance * 0.65).truncatingRemainder(dividingBy: pitch)
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext() else { return }
|
||||
UIBezierPath(roundedRect: bounds, cornerRadius: 2).addClip()
|
||||
context.setFillColor(strokeColor.cgColor)
|
||||
for index in -1...6 {
|
||||
let y = CGFloat(index) * pitch + phase
|
||||
context.fill(CGRect(x: 0, y: y, width: bounds.width, height: 1.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ private struct RemoteViewportState: Equatable {
|
||||
|
||||
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()
|
||||
@@ -81,6 +84,7 @@ struct RemoteSessionView: View {
|
||||
}
|
||||
|
||||
RemoteTouchInputView(
|
||||
mouseInput: mouseInput,
|
||||
videoSize: session.displayGeometrySize,
|
||||
controlMode: session.mouseControlMode,
|
||||
remoteCursorPosition: session.remoteCursorPosition,
|
||||
@@ -95,20 +99,24 @@ struct RemoteSessionView: View {
|
||||
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
|
||||
session.bridge.sendPointer(x: x, y: y, action: .leftDown)
|
||||
mouseInput.position(x: x, y: y)
|
||||
mouseInput.setGestureLeft(true)
|
||||
},
|
||||
leftUp: { x, y in
|
||||
session.bridge.sendPointer(x: x, y: y, action: .leftUp)
|
||||
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)
|
||||
@@ -160,7 +168,27 @@ struct RemoteSessionView: View {
|
||||
} 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
|
||||
@@ -169,14 +197,27 @@ struct RemoteSessionView: View {
|
||||
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 let position = session.remoteCursorPosition else { return }
|
||||
guard !mouseInput.hasVirtualButton, let position = session.remoteCursorPosition else { return }
|
||||
mouseInput.position(x: Float(position.x), y: Float(position.y))
|
||||
cursorPosition = position
|
||||
}
|
||||
.fileImporter(isPresented: $showingFileImporter,
|
||||
@@ -271,17 +312,16 @@ struct RemoteSessionView: View {
|
||||
y: restingOrbCenter.y + statusOrbDragTranslation.height),
|
||||
in: containerSize
|
||||
)
|
||||
let panelSize = CGSize(
|
||||
width: min(340, max(280, containerSize.width - 160)),
|
||||
height: min(310, max(0, containerSize.height - 16))
|
||||
)
|
||||
let panelCenter = floatingPanelCenter(
|
||||
orbCenter: orbCenter,
|
||||
panelSize: panelSize,
|
||||
containerSize: 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())
|
||||
@@ -292,13 +332,18 @@ struct RemoteSessionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Position a stable container, then animate only the panel
|
||||
// inside it. Scaling a view after .position also scales its
|
||||
// full-screen positioning space and makes the panel drift.
|
||||
ZStack {
|
||||
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()
|
||||
@@ -313,6 +358,7 @@ struct RemoteSessionView: View {
|
||||
}
|
||||
},
|
||||
disconnect: {
|
||||
mouseInput.releaseAll()
|
||||
statusMenuVisible = false
|
||||
keyboardInputVisible = false
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
@@ -320,13 +366,11 @@ struct RemoteSessionView: View {
|
||||
}
|
||||
}
|
||||
)
|
||||
.frame(width: panelSize.width, height: panelSize.height)
|
||||
.transition(.scale(scale: 0.94, anchor: .topTrailing)
|
||||
.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.frame(width: panelSize.width, height: panelSize.height)
|
||||
.position(panelCenter)
|
||||
.frame(width: containerSize.width, height: containerSize.height)
|
||||
.allowsHitTesting(statusMenuVisible)
|
||||
|
||||
CrossDeskStatusOrb(isExpanded: statusMenuVisible)
|
||||
@@ -334,6 +378,7 @@ struct RemoteSessionView: View {
|
||||
.contentShape(Circle())
|
||||
.position(orbCenter)
|
||||
.onTapGesture {
|
||||
mouseInput.releaseAll()
|
||||
withAnimation(.spring(response: 0.24,
|
||||
dampingFraction: 0.84)) {
|
||||
statusMenuVisible.toggle()
|
||||
@@ -345,6 +390,7 @@ struct RemoteSessionView: View {
|
||||
state = value.translation
|
||||
}
|
||||
.onChanged { _ in
|
||||
mouseInput.releaseAll()
|
||||
if statusMenuVisible {
|
||||
statusMenuVisible = false
|
||||
}
|
||||
@@ -359,9 +405,40 @@ struct RemoteSessionView: View {
|
||||
)
|
||||
}
|
||||
.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
|
||||
@@ -503,24 +580,6 @@ struct RemoteSessionView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func floatingPanelCenter(orbCenter: CGPoint,
|
||||
panelSize: CGSize,
|
||||
containerSize: CGSize) -> CGPoint {
|
||||
let gap: CGFloat = 12
|
||||
let orbRadius: CGFloat = 27
|
||||
let horizontalPadding: CGFloat = 8
|
||||
let verticalPadding: CGFloat = 8
|
||||
let preferredX = orbCenter.x > containerSize.width / 2
|
||||
? orbCenter.x - orbRadius - gap - panelSize.width / 2
|
||||
: orbCenter.x + orbRadius + gap + panelSize.width / 2
|
||||
return CGPoint(
|
||||
x: min(max(preferredX, panelSize.width / 2 + horizontalPadding),
|
||||
containerSize.width - panelSize.width / 2 - horizontalPadding),
|
||||
y: min(max(orbCenter.y, panelSize.height / 2 + verticalPadding),
|
||||
containerSize.height - panelSize.height / 2 - verticalPadding)
|
||||
)
|
||||
}
|
||||
|
||||
private var visibleCursorShape: Int {
|
||||
guard session.hasRemoteCursorState,
|
||||
session.remoteCursorVisible,
|
||||
@@ -1079,8 +1138,39 @@ private struct CrossDeskStatusOrb: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -1090,15 +1180,25 @@ private struct FloatingSessionMenu: View {
|
||||
let close: () -> Void
|
||||
let disconnect: () -> Void
|
||||
|
||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: 7),
|
||||
count: 3)
|
||||
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
|
||||
@@ -1110,145 +1210,11 @@ private struct FloatingSessionMenu: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 9) {
|
||||
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)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
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)
|
||||
|
||||
Menu {
|
||||
ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in
|
||||
Button {
|
||||
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: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标",
|
||||
symbol: "computermouse",
|
||||
action: {
|
||||
session.mouseControlMode = session.mouseControlMode == .relative
|
||||
? .absolute : .relative
|
||||
}
|
||||
)
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive, action: disconnect) {
|
||||
Text("断开连接")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 34)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
// 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,
|
||||
@@ -1274,6 +1240,164 @@ private struct FloatingSessionMenu: View {
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
@@ -327,6 +327,25 @@ Old previews saved without this choice are removed on upgrade.
|
||||
|
||||
## Physical-device test checklist
|
||||
|
||||
The floating control menu includes **虚拟鼠标** (virtual mouse), a 140 × 48 pt
|
||||
horizontal left-button / wheel / right-button bar attached to the status orb.
|
||||
Drag the orb to reposition it, or use **收起鼠标** to hide it. The bar overlays
|
||||
the video without resizing it and avoids the floating keyboard. Hold either mouse
|
||||
button and drag the same finger, including beyond the key bounds, to drag at the
|
||||
remote cursor; lifting the finger releases that button. Moving another finger on
|
||||
the video while holding a button also works. The flat split design has unlabelled
|
||||
left/right paddles and an 18 × 30 pt wheel face, separated by clear gutters;
|
||||
all three touch targets remain 44 × 44 pt. There are no shadows, bevels or press
|
||||
displacement: pressing changes only the face color. Uniform horizontal wheel
|
||||
treads cycle vertically with the finger and stop when it lifts.
|
||||
Swipe the wheel up/down, including beyond its bounds, to scroll at any zoom
|
||||
level. Check both pointer modes, button cancellation, display changes, rotation,
|
||||
and backgrounding on a device; each must leave remote buttons released.
|
||||
The expanded status menu sizes to all of its content, including all ten actions;
|
||||
short, wide screens use four columns, and only content that exceeds the available
|
||||
safe area scrolls. Check this in both
|
||||
orientations, with larger text and while switching menu pages.
|
||||
|
||||
Run the app from Xcode on a physical device and connect to a current desktop
|
||||
build. Test the features in this order so a media problem is not confused with
|
||||
a data-channel problem:
|
||||
|
||||
Reference in New Issue
Block a user