[feat] add compact mobile virtual mouse controls

This commit is contained in:
dijunkun
2026-10-09 14:26:36 +08:00
parent 41aed0984f
commit d3ecbe2cc9
12 changed files with 961 additions and 205 deletions
+1
View File
@@ -12,6 +12,7 @@
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。
- 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意,后台切换会结束会话。
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
@@ -308,9 +308,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
public void mouse(){boolean relative=!preferences.getBoolean("relativeMouse",true);preferences.edit().putBoolean("relativeMouse",relative).apply();video.setRelative(relative);controls.setRelative(relative);}
public void videoSettings(int field,int value){RemoteVideoSettings.Values old=MainActivity.this.videoSettings.selection;updateVideoSettings(new RemoteVideoSettings.Values(field==0?value:old.quality,field==1?value:old.frameRate,field==2?value:old.preference));}
});controls.setRelative(preferences.getBoolean("relativeMouse",true));controls.videoSettings(videoSettings);root.addView(controls,new LinearLayout.LayoutParams(-1,-1));getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
controls.attachMouseBar(canvas,new VirtualMouseBar(ui,video.mouseInput));
}
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
private void end(String reason){
if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();
mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;receivedFile=null;remoteServiceAvailable=false;
resetRemoteVersionCheck();
resetVideoSettings();
@@ -619,6 +621,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
if(controls!=null)controls.post(controls::constrainPanels);
}
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
@Override protected void onPause(){if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
@Override protected void onStart(){super.onStart();foreground=true;appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();}
@Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
// The system document picker is part of this session's explicit file action.
@@ -0,0 +1,54 @@
package cn.crossdesk.mobile;
/** Shared button ownership and cursor position for the video surface and mouse bar. */
final class RemoteMouseInput {
interface Sender { void send(float x, float y, int flag, int wheel); }
private final Sender sender;
private float x = .5f, y = .5f;
private boolean gestureLeft, virtualLeft, virtualRight;
private long revision;
Runnable didCancel;
float viewportWidth, viewportHeight;
RemoteMouseInput(Sender sender) { this.sender = sender; }
boolean hasVirtualButton() { return virtualLeft || virtualRight; }
long revision() { return revision; }
void move(float x, float y) {
this.x = Math.max(0, Math.min(1, x)); this.y = Math.max(0, Math.min(1, y));
send(0, 0);
}
void drag(float dx, float dy) {
if (!hasVirtualButton() || viewportWidth <= 0 || viewportHeight <= 0) return;
move(x + dx / viewportWidth, y + dy / viewportHeight);
}
void gestureLeft(boolean down) {
if (down && hasVirtualButton()) return;
if (gestureLeft == down) return;
gestureLeft = down;
send(down ? 1 : 2, 0);
}
void click(int down, int up) {
if (hasVirtualButton()) return;
send(down, 0); send(up, 0);
}
void virtualButton(boolean left, boolean down) {
if ((left ? virtualLeft : virtualRight) == down) return;
if (down) {
gestureLeft(false);
revision++;
}
if (left) virtualLeft = down; else virtualRight = down;
send(left ? (down ? 1 : 2) : (down ? 3 : 4), 0);
}
void scroll(int direction) {
if (direction != 0) send(7, direction > 0 ? 1 : -1);
}
void releaseAll() {
gestureLeft(false);
virtualButton(true, false);
virtualButton(false, false);
revision++;
if (didCancel != null) didCancel.run();
}
private void send(int flag, int wheel) { sender.send(x, y, flag, wheel); }
}
@@ -23,6 +23,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
private final SurfaceView video;
private final Cursor cursor;
private final NativeSession session;
final RemoteMouseInput mouseInput;
private final GestureDetector gestures;
private final ScaleGestureDetector scales;
private final RemoteViewport viewport = new RemoteViewport();
@@ -30,9 +31,16 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
private float x = .5f, y = .5f, scrollY;
private boolean dragging, twoFinger, relative, viewportGesture, viewportPanActive;
private float lastX,lastY,twoFingerX,twoFingerY;
void setRelative(boolean value) { relative=value; }
private long touchRevision;
private boolean suppressPointerGesture;
void setRelative(boolean value) { releaseMouse(); relative=value; }
void releaseMouse() { mouseInput.releaseAll(); dragging=false; suppressPointerGesture=true; }
private boolean suppressPointerGesture() {
return suppressPointerGesture || mouseInput.hasVirtualButton() || touchRevision != mouseInput.revision();
}
RemoteVideoView(Context context, NativeSession session) {
super(context); this.session = session;
mouseInput = new RemoteMouseInput(this::sendMouse);
setBackgroundColor(Color.BLACK);
video = new SurfaceView(context); video.getHolder().addCallback(this);
addView(video, new LayoutParams(1, 1, android.view.Gravity.CENTER));
@@ -42,14 +50,14 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
gestures = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
@Override public boolean onDown(MotionEvent e) { return point(e); }
@Override public boolean onSingleTapUp(MotionEvent e) {
if (!twoFinger && point(e)) cursor.performClick(); return true;
if (!twoFinger && !suppressPointerGesture() && point(e)) cursor.performClick(); return true;
}
@Override public void onLongPress(MotionEvent e) { if (!twoFinger && !dragging && point(e)) click(3, 4); }
@Override public void onLongPress(MotionEvent e) { if (!twoFinger && !dragging && !suppressPointerGesture() && point(e)) click(3, 4); }
@Override public boolean onScroll(MotionEvent first, MotionEvent current, float dx, float dy) {
if (twoFinger) return true;
if (!relative && !dragging) {
if (!relative && !dragging && !suppressPointerGesture()) {
if (!point(first)) return true;
session.pointer(x, y, 1, 0); dragging = true;
mouseInput.gestureLeft(true); dragging = true;
}
point(current); return true;
}
@@ -58,6 +66,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
gestures.setOnDoubleTapListener(null);
scales = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
@Override public boolean onScaleBegin(ScaleGestureDetector detector) {
if (mouseInput.hasVirtualButton()) return false;
if (!viewport.contains(detector.getFocusX(), detector.getFocusY())) return false;
viewportGesture = true;
viewportPanActive = true;
@@ -89,7 +98,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
if (width <= 0 || height <= 0) return;
frameWidth = width; frameHeight = height; fit();
}
void resetViewport() { viewport.reset(); applyViewport(); }
void resetViewport() { releaseMouse(); viewport.reset(); applyViewport(); }
static Rect previewCrop(int width,int height){
int w=width,h=height;
if((long)width*RemotePreviewStore.HEIGHT>(long)height*RemotePreviewStore.WIDTH)w=Math.max(1,height*RemotePreviewStore.WIDTH/RemotePreviewStore.HEIGHT);
@@ -116,6 +125,12 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
// Transform the existing Surface without enlarging its decoder buffers.
video.setScaleX(viewport.scale()); video.setScaleY(viewport.scale());
video.setTranslationX(viewport.offsetX()); video.setTranslationY(viewport.offsetY());
mouseInput.viewportWidth = viewport.width(); mouseInput.viewportHeight = viewport.height();
cursor.invalidate();
}
private void sendMouse(float x, float y, int flag, int wheel) {
this.x = x; this.y = y;
session.pointer(x,y,flag,wheel);
cursor.invalidate();
}
@Override protected void onSizeChanged(int w,int h,int oldw,int oldh) { post(this::fit); }
@@ -133,17 +148,19 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
y=viewport.normalizedY(py);
}
lastX=px;lastY=py;
session.pointer(x,y,0,0); cursor.invalidate();
mouseInput.move(x,y); cursor.invalidate();
return true;
}
private void click(int down, int up) { session.pointer(x,y,down,0); session.pointer(x,y,up,0); }
private void click(int down, int up) { mouseInput.click(down,up); }
private boolean touch(MotionEvent e) {
int action = e.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
twoFinger = viewportGesture = viewportPanActive = false;
touchRevision = mouseInput.revision();
suppressPointerGesture = mouseInput.hasVirtualButton();
}
if (action == MotionEvent.ACTION_POINTER_DOWN) {
if (dragging) { session.pointer(x,y,2,0); dragging = false; }
if (dragging) { mouseInput.gestureLeft(false); dragging = false; }
twoFinger = true;
twoFingerX = focus(e,true,-1); twoFingerY = scrollY = focus(e,false,-1);
viewportPanActive = viewport.isZoomed() && viewport.contains(twoFingerX,twoFingerY);
@@ -152,6 +169,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
}
scales.onTouchEvent(e);
if (twoFinger && action == MotionEvent.ACTION_MOVE && e.getPointerCount() >= 2) {
if (mouseInput.hasVirtualButton()) return true;
float focusX = focus(e,true,-1), focusY = focus(e,false,-1);
if (viewport.isZoomed() || viewportGesture || scales.isInProgress()) {
viewportGesture = true;
@@ -179,7 +197,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
twoFingerY = scrollY = focus(e,false,e.getActionIndex());
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
if (dragging) session.pointer(x,y,2,0);
if (dragging) mouseInput.gestureLeft(false);
dragging = false;
viewport.finishGesture(); applyViewport();
viewportPanActive = false;
@@ -197,7 +215,8 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
@Override public boolean performClick() { super.performClick(); return true; }
@Override public void surfaceCreated(SurfaceHolder holder) { session.surface(holder.getSurface()); }
@Override public void surfaceChanged(SurfaceHolder holder,int format,int width,int height) { }
@Override public void surfaceDestroyed(SurfaceHolder holder) { session.surface(null); }
@Override public void surfaceDestroyed(SurfaceHolder holder) { releaseMouse(); session.surface(null); }
@Override protected void onDetachedFromWindow() { releaseMouse(); super.onDetachedFromWindow(); }
private final class Cursor extends View {
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path arrow = new Path();
@@ -6,7 +6,7 @@ import android.view.*;
import android.widget.*;
import static cn.crossdesk.mobile.MobileUi.*;
/** Safe-area overlay: a draggable 54 dp orb, a three-column menu and a floating keyboard. */
/** Safe-area overlay: a draggable 54 dp orb, an adaptive menu and a floating keyboard. */
@android.annotation.SuppressLint("ViewConstructor")
final class SessionControls extends FrameLayout {
interface Actions {
@@ -21,8 +21,13 @@ final class SessionControls extends FrameLayout {
private final LinearLayout waiting;
private View shield;
private LinearLayout menu,keyboard;
private VirtualMouseBar mouseBar;
private FrameLayout mouseHost;
private boolean mouseVisible;
private ScrollView keyboardKeys;
private ScrollView menuScroll;
private int keyboardPreferredHeight;
private int menuColumns=3;
private TextView networkLabel,videoFeedback;
private TextView transferLabel,saveFileButton;
private String transferStatus="";
@@ -49,8 +54,36 @@ final class SessionControls extends FrameLayout {
drag(orb,orb,()->{if(menu==null)openMenu("controls");else closeMenu();});
}
void setRelative(boolean value){relative=value;if(menu!=null&&menuPage.equals("controls"))openMenu("controls");}
void attachMouseBar(FrameLayout host, VirtualMouseBar bar) {
mouseHost=host;mouseBar=bar;
// A direct, tightly bounded sibling of the video is essential: otherwise
// an existing touch in this full-screen controls container captures the
// second finger before Android can dispatch it to the remote surface.
host.addView(bar,new FrameLayout.LayoutParams(ui.dp(140),ui.dp(48)));
bar.setVisibility(GONE);
}
void cancelMouseInput(){if(mouseBar!=null)mouseBar.cancelInput();}
private void toggleMouse(){cancelMouseInput();mouseVisible=!mouseVisible;closeMenu();positionMouse();}
private void positionMouse(){
if(mouseBar==null)return;
boolean visible=mouseVisible&&menu==null&&waiting.getVisibility()==GONE;
if(!visible){if(mouseBar.getVisibility()==VISIBLE)cancelMouseInput();mouseBar.setVisibility(GONE);return;}
int[] location=new int[2],hostLocation=new int[2];getLocationOnScreen(location);mouseHost.getLocationOnScreen(hostLocation);
float width=ui.dp(140),height=ui.dp(48),gap=ui.dp(6),margin=ui.dp(8);
float x=orb.getX()>=width+gap+margin?orb.getX()-gap-width:orb.getX()+orb.getWidth()+gap;
float y=orb.getY()+(orb.getHeight()-height)/2;
// When the orb is near the middle of a narrow phone, use the adjacent
// row rather than letting the two controls overlap.
if(x+width>getWidth()-margin){x=bound(orb.getX(),margin,getWidth()-width-margin);y=orb.getY()+orb.getHeight()+gap;if(y+height>getHeight()-margin)y=orb.getY()-gap-height;}
if(keyboard!=null&&x<keyboard.getX()+keyboard.getWidth()&&x+width>keyboard.getX()&&y<keyboard.getY()+keyboard.getHeight()&&y+height>keyboard.getY()){
y=keyboard.getY()-gap-height;if(y<margin)y=keyboard.getY()+keyboard.getHeight()+gap;
}
mouseBar.setX(location[0]-hostLocation[0]+bound(x,margin,getWidth()-width-margin));
mouseBar.setY(location[1]-hostLocation[1]+bound(y,margin,getHeight()-height-margin));
mouseBar.setVisibility(VISIBLE);
}
void setMuted(boolean value){muted=value;if(menu!=null&&menuPage.equals("controls"))openMenu("controls");}
void videoSize(int width,int height){waiting.setVisibility(GONE);}
void videoSize(int width,int height){waiting.setVisibility(GONE);positionMouse();}
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
@@ -59,8 +92,9 @@ final class SessionControls extends FrameLayout {
if(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);}
if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE);
}
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");}
private void closeMenu(){if(menu!=null){removeView(menu);menu=null;menuScroll=null;}if(shield!=null){removeView(shield);shield=null;}networkLabel=videoFeedback=transferLabel=saveFileButton=null;trafficCells=null;trafficRows=null;networkDetails=null;encryptionLock=encryptionOpen=null;settingRows.clear();orb.setContentDescription("展开远程控制菜单");positionMouse();}
private void openMenu(String page){
cancelMouseInput();
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
menu=ui.column();menu.setPadding(ui.dp(12),ui.dp(12),ui.dp(12),ui.dp(12));ui.card(menu,18);menu.setElevation(ui.dp(12));
LinearLayout header=ui.row();
@@ -68,18 +102,19 @@ final class SessionControls extends FrameLayout {
LinearLayout titles=ui.column();TextView title=ui.text(page.equals("controls")?"● 已连接":page.equals("video")?"画面设置":"网络状态",14,true);titles.addView(title);
if(page.equals("controls")){ui.gap(titles,3);networkLabel=ui.text(network.summary(),10,false);networkLabel.setFontFeatureSettings("tnum");networkLabel.setTextColor(SECONDARY);titles.addView(networkLabel);}header.addView(titles,new LinearLayout.LayoutParams(0,-2,1));header.addView(ui.iconButton("close","关闭控制栏",this::closeMenu),ui.size(30,32));menu.addView(header);ui.gap(menu,9);
if(page.equals("video")){videoFeedback=ui.text("",12,false);videoFeedback.setTextColor(SECONDARY);videoFeedback.setPadding(0,0,0,ui.dp(9));menu.addView(videoFeedback);}
ScrollView scroll=new ScrollView(getContext());menu.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));LinearLayout body=ui.column();scroll.addView(body);
ScrollView scroll=new ScrollView(getContext());menuScroll=scroll;menu.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));LinearLayout body=ui.column();scroll.addView(body);
if(page.equals("network")){
// Reserve the scrollbar's width plus a gap before right-aligned statistics.
scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);body.setPadding(0,0,ui.dp(6),0);
}
if(page.equals("controls")){
String[] labels={"键盘","显示器","画面设置",muted?"静音":"声音",relative?"相对鼠标":"绝对鼠标","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
String[] icons={"keyboard","display","sliders","audio","mouse","folder","chart","lock","clipboard"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::display,()->openMenu("video"),actions::mute,actions::mouse,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard};
for(int row=0;row<3;row++){LinearLayout line=ui.row();for(int col=0;col<3;col++){int index=row*3+col;LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
String[] labels={"键盘",relative?"相对鼠标":"绝对鼠标",mouseVisible?"收起鼠标":"虚拟鼠标","显示器","画面设置",muted?"静音":"声音","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard"};
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::mouse,this::toggleMouse,actions::display,()->openMenu("video"),actions::mute,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard};
menuColumns=controlColumns();
for(int row=0;row<(labels.length+menuColumns-1)/menuColumns;row++){LinearLayout line=ui.row();for(int col=0;col<menuColumns;col++){int index=row*menuColumns+col;if(index>=labels.length){View spacer=new View(getContext());LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(spacer,p);continue;}LinearLayout control=ui.column();control.setGravity(Gravity.CENTER);control.setBackground(ui.background(0xFFF0F0F2,10));control.addView(ui.icon(icons[index],INK),ui.size(19,19));ui.gap(control,4);TextView caption=ui.text(labels[index],10,false);caption.setSingleLine();caption.setGravity(Gravity.CENTER);control.addView(caption);control.setContentDescription(labels[index]);control.setClickable(true);control.setFocusable(true);control.setOnClickListener(v->clicks[index].run());
LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(52),1);if(col>0)p.leftMargin=ui.dp(7);line.addView(control,p);
}body.addView(line);if(row<2)ui.gap(body,7);}
}body.addView(line);if((row+1)*menuColumns<labels.length)ui.gap(body,7);}
ui.gap(body,7);transferLabel=ui.text(transferStatus,11,false);transferLabel.setTextColor(SECONDARY);transferLabel.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);body.addView(transferLabel);
saveFileButton=ui.action("保存收到的文件",BLUE,actions::saveFile);body.addView(saveFileButton);renderFileStatus();
}else if(page.equals("network")){
@@ -91,6 +126,7 @@ final class SessionControls extends FrameLayout {
}
ui.gap(menu,9);TextView disconnect=ui.action("断开连接",Color.WHITE,()->{closeMenu();closeKeyboard();actions.disconnect();});disconnect.setMinHeight(ui.dp(34));disconnect.setTextSize(14);disconnect.setBackground(ui.background(RED,8));menu.addView(disconnect,ui.size(-1,34));
addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription("收起远程控制菜单");
positionMouse();
}
private final class SettingRow {
final int field;final int[] values;final SegmentedControl group;
@@ -145,12 +181,24 @@ final class SessionControls extends FrameLayout {
for(int i=0;i<values.length;i++)networkDetails[i].setText(values[i]);networkDetails[5].setTextColor(network.report!=null&&network.report.srtp?GREEN:SECONDARY);
encryptionLock.setVisibility(network.report!=null&&network.report.srtp?VISIBLE:GONE);encryptionOpen.setVisibility(network.report!=null&&!network.report.srtp?VISIBLE:GONE);
}
private int panelWidth(int width){return Math.max(1,Math.min(width-ui.dp(16),Math.max(ui.dp(280),Math.min(ui.dp(340),width-ui.dp(160)))));}
private int panelHeight(int height){return Math.min(ui.dp(310),Math.max(1,height-ui.dp(16)));}
private int controlColumns(){return getMeasuredHeight()<ui.dp(400)&&getMeasuredWidth()>=ui.dp(400)?4:3;}
private int panelWidth(int width){return Math.max(1,Math.min(width-ui.dp(16),controlColumns()==4?ui.dp(380):Math.max(ui.dp(280),Math.min(ui.dp(340),width-ui.dp(160)))));}
private int panelHeight(int height){
// First measure every row at its natural height, then cap only to the
// safe viewport. The weighted ScrollView takes any remaining space.
LinearLayout.LayoutParams scrollParams=(LinearLayout.LayoutParams)menuScroll.getLayoutParams();
scrollParams.height=LayoutParams.WRAP_CONTENT;scrollParams.weight=0;
menu.measure(MeasureSpec.makeMeasureSpec(panelWidth(getMeasuredWidth()),MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(0,MeasureSpec.UNSPECIFIED));
int contentHeight=menu.getMeasuredHeight();
scrollParams.height=0;scrollParams.weight=1;
menu.forceLayout();menuScroll.forceLayout();
return Math.min(contentHeight,Math.max(1,height-ui.dp(16)));
}
private void positionMenu(){if(menu==null)return;float ox=orb.getX()+orb.getWidth()/2f;float x=ox>getWidth()/2f?orb.getX()-ui.dp(12)-menu.getWidth():orb.getX()+orb.getWidth()+ui.dp(12);menu.setX(bound(x,ui.dp(8),getWidth()-menu.getWidth()-ui.dp(8)));menu.setY(bound(orb.getY(),ui.dp(8),getHeight()-menu.getHeight()-ui.dp(8)));}
private int keyboardWidth(int width){return Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,width-ui.dp(16)));}
private int keyboardHeight(int height){return Math.min(keyboardPreferredHeight,Math.max(1,height-ui.dp(16)));}
private void positionKeyboard(){if(keyboard!=null){keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-keyboard.getWidth()-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));}}
private void positionKeyboard(){if(keyboard!=null){keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-keyboard.getWidth()-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));}positionMouse();}
private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;keyboardKeys=null;}}
private void toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();}
private void showKeyboard(){
@@ -185,8 +233,8 @@ final class SessionControls extends FrameLayout {
handle.setOnClickListener(v->tap.run());handle.setOnTouchListener(new View.OnTouchListener(){float startX,startY,originX,originY;boolean moved;
public boolean onTouch(View v,MotionEvent event){
switch(event.getActionMasked()){
case MotionEvent.ACTION_DOWN:startX=event.getRawX();startY=event.getRawY();originX=target.getX();originY=target.getY();moved=false;return true;
case MotionEvent.ACTION_MOVE:float dx=event.getRawX()-startX,dy=event.getRawY()-startY;if(Math.hypot(dx,dy)>ui.dp(4))moved=true;if(moved){if(target==orb)closeMenu();target.setX(bound(originX+dx,ui.dp(8),getWidth()-target.getWidth()-ui.dp(8)));target.setY(bound(originY+dy,ui.dp(8),getHeight()-target.getHeight()-ui.dp(8)));}return true;
case MotionEvent.ACTION_DOWN:if(target==orb)cancelMouseInput();startX=event.getRawX();startY=event.getRawY();originX=target.getX();originY=target.getY();moved=false;return true;
case MotionEvent.ACTION_MOVE:float dx=event.getRawX()-startX,dy=event.getRawY()-startY;if(Math.hypot(dx,dy)>ui.dp(4))moved=true;if(moved){if(target==orb)closeMenu();target.setX(bound(originX+dx,ui.dp(8),getWidth()-target.getWidth()-ui.dp(8)));target.setY(bound(originY+dy,ui.dp(8),getHeight()-target.getHeight()-ui.dp(8)));positionMouse();}return true;
case MotionEvent.ACTION_UP:if(!moved)v.performClick();return true;
case MotionEvent.ACTION_CANCEL:return true;
default:return false;
@@ -195,9 +243,12 @@ final class SessionControls extends FrameLayout {
});
}
void constrainPanels(){
cancelMouseInput();
if(menu!=null&&menuPage.equals("controls")&&menuColumns!=controlColumns())openMenu("controls");
orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8)));
if(menu!=null||keyboard!=null)requestLayout();
positionKeyboard();
positionMouse();
}
@Override protected void onMeasure(int widthSpec,int heightSpec){
super.onMeasure(widthSpec,heightSpec);
@@ -205,7 +256,9 @@ final class SessionControls extends FrameLayout {
if(menu!=null){
// Use this traversal's viewport, including rotation/inset changes, before drawing.
ViewGroup.LayoutParams p=menu.getLayoutParams();int width=panelWidth(getMeasuredWidth()),height=panelHeight(getMeasuredHeight());
if(p.width!=width||p.height!=height){p.width=width;p.height=height;resized=true;}
// panelHeight performed an unconstrained measurement; always
// measure again with the final cap, even when it did not change.
p.width=width;p.height=height;resized=true;
}
if(keyboard!=null){
ViewGroup.LayoutParams p=keyboard.getLayoutParams();int width=keyboardWidth(getMeasuredWidth()),height=keyboardHeight(getMeasuredHeight());
@@ -220,7 +273,9 @@ final class SessionControls extends FrameLayout {
// New/rebuilt menus have real dimensions now; position them in the same frame.
positionMenu();
positionKeyboard();
positionMouse();
}
@Override protected void onDetachedFromWindow(){cancelMouseInput();super.onDetachedFromWindow();}
@Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){super.onSizeChanged(width,height,oldWidth,oldHeight);post(this::constrainPanels);}
private static float bound(float value,float min,float max){return Math.max(min,Math.min(value,Math.max(min,max)));}
}
@@ -0,0 +1,153 @@
package cn.crossdesk.mobile;
import android.annotation.SuppressLint;
import android.graphics.Color;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.InsetDrawable;
import android.view.MotionEvent;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
/** A 140 × 48 dp bar. Touch ownership continues outside the original key bounds. */
@SuppressLint("ViewConstructor")
final class VirtualMouseBar extends LinearLayout {
private final RemoteMouseInput input;
private final MobileUi ui;
private final TextView[] keys = new TextView[3];
private int generation;
VirtualMouseBar(MobileUi ui, RemoteMouseInput input) {
super(ui.context); this.ui = ui; this.input = input;
setOrientation(HORIZONTAL);
setPadding(ui.dp(4), ui.dp(2), ui.dp(4), ui.dp(2));
GradientDrawable shell = ui.background(Color.WHITE, 18);
shell.setStroke(ui.dp(.5f), 0xFFD4DAE2);
setBackground(shell);
setMotionEventSplittingEnabled(true);
keys[0] = key("鼠标左键,按住并拖动,松开结束拖拽", 0);
keys[1] = key("鼠标滚轮,上下滑动,可滑出按键范围", 1);
keys[2] = key("鼠标右键,按住并拖动,松开结束拖拽", 2);
input.didCancel = this::resetTouches;
setContentDescription("虚拟鼠标");
}
@SuppressLint("ClickableViewAccessibility") // Touch sends real down/up; performClick serves accessibility only.
private TextView key(String description, int index) {
TextView key = index == 1 ? new WheelKey(ui) : new TextView(ui.context);
key.setGravity(android.view.Gravity.CENTER);
key.setContentDescription(description);
key.setClickable(true); key.setFocusable(true);
highlight(key, index, false);
addView(key, new LayoutParams(0, ui.dp(44), 1));
key.setOnClickListener(v -> {
if (index == 1) { ((WheelKey)key).advance(ui.dp(10)); input.scroll(-1); }
else { input.virtualButton(index == 0, true); input.virtualButton(index == 0, false); }
});
if (index == 1) {
key.setAccessibilityDelegate(new View.AccessibilityDelegate() {
@Override public void onInitializeAccessibilityNodeInfo(View host, android.view.accessibility.AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(host, info);
info.setScrollable(true);
info.addAction(android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD);
info.addAction(android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD);
}
@Override public boolean performAccessibilityAction(View host, int action, android.os.Bundle args) {
if (action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD || action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
int direction = action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD ? -1 : 1;
((WheelKey)key).advance(-direction * ui.dp(10));
input.scroll(direction); return true;
}
return super.performAccessibilityAction(host, action, args);
}
});
}
key.setOnTouchListener(new View.OnTouchListener() {
int pointer = -1, startedGeneration;
float lastX, lastY, remainder;
@Override public boolean onTouch(View v, MotionEvent event) {
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
pointer = event.getPointerId(0); startedGeneration = generation;
lastX = event.getX(); lastY = event.getY(); remainder = 0;
highlight(key, index, true);
if (index != 1) input.virtualButton(index == 0, true);
return true;
}
if (startedGeneration != generation || pointer == -1) return true;
if (action == MotionEvent.ACTION_MOVE) {
int p = event.findPointerIndex(pointer);
if (p < 0) return true;
float dx = event.getX(p) - lastX, dy = event.getY(p) - lastY;
lastX = event.getX(p); lastY = event.getY(p);
if (index != 1) { input.drag(dx, dy); return true; }
((WheelKey)key).advance(dy);
remainder += dy;
float step = ui.dp(10);
int ticks = (int)(remainder / step);
// Positive protocol wheel values scroll up; finger-down scrolls down.
for (int i = 0; i < Math.min(Math.abs(ticks), 32); i++) input.scroll(ticks > 0 ? -1 : 1);
remainder -= ticks * step;
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL ||
(action == MotionEvent.ACTION_POINTER_UP && event.getPointerId(event.getActionIndex()) == pointer)) {
if (index != 1) input.virtualButton(index == 0, false);
pointer = -1; remainder = 0; highlight(key, index, false);
}
return true;
}
});
return key;
}
private void highlight(TextView key, int index, boolean pressed) {
key.setPressed(pressed);
key.setTextColor(pressed ? Color.WHITE : 0xB8303640);
// Narrow only the wheel's visible face; its 44 × 44 dp target remains.
GradientDrawable face = ui.background(pressed ? MobileUi.BLUE : 0xFFEDF0F4, 6);
float left = ui.dp(index == 0 ? 14 : 6), right = ui.dp(index == 2 ? 14 : 6);
face.setCornerRadii(new float[]{left,left,right,right,right,right,left,left});
int horizontal = ui.dp(index == 1 ? 13 : 1), vertical = ui.dp(index == 1 ? 7 : 2);
key.setBackground(new InsetDrawable(face, horizontal, vertical, horizontal, vertical));
}
void cancelInput() {
input.releaseAll();
}
private void resetTouches() {
generation++;
for (int i=0;i<keys.length;i++) if (keys[i] != null) highlight(keys[i], i, false);
}
/** Flat, uniformly colored treads follow the finger, wrapping every 5 dp. */
private static final class WheelKey extends TextView {
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path clip = new Path();
private final float density;
private float phase = 2;
WheelKey(MobileUi ui) {
super(ui.context); density = getResources().getDisplayMetrics().density;
clip.addRoundRect(new RectF(0,0,12,24),2,2,Path.Direction.CW);
}
void advance(float distance) {
phase = (phase + distance / density * .65f) % 5;
invalidate();
}
@Override protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.save();
canvas.translate((getWidth()-12*density)/2f, (getHeight()-24*density)/2f);
canvas.scale(density,density);
canvas.clipPath(clip);
paint.setColor(getCurrentTextColor());
for (int i=-1;i<=6;i++) {
float y=i*5+phase;
canvas.drawRect(0,y,12,y+1.5f,paint);
}
canvas.restore();
}
}
@Override protected void onDetachedFromWindow() { cancelInput(); super.onDetachedFromWindow(); }
@Override public void onWindowFocusChanged(boolean focused) { super.onWindowFocusChanged(focused); if (!focused) cancelInput(); }
}
@@ -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))
}
}
}
+302 -178
View File
@@ -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]
+19
View File
@@ -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: