From d3ecbe2cc9824e8ea2d5268c67a9d678d71ae570 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 9 Oct 2026 14:26:36 +0800 Subject: [PATCH] [feat] add compact mobile virtual mouse controls --- apps/android/README.md | 1 + .../cn/crossdesk/mobile/MainActivity.java | 3 + .../cn/crossdesk/mobile/RemoteMouseInput.java | 54 ++ .../cn/crossdesk/mobile/RemoteVideoView.java | 41 +- .../cn/crossdesk/mobile/SessionControls.java | 85 +++- .../cn/crossdesk/mobile/VirtualMouseBar.java | 153 ++++++ .../CrossDeskMobile.xcodeproj/project.pbxproj | 8 + .../Input/RemoteMouseInput.swift | 59 +++ .../Input/RemoteTouchInputView.swift | 54 +- .../Input/VirtualMouseBar.swift | 209 ++++++++ .../CrossDeskMobile/RemoteSessionView.swift | 480 +++++++++++------- apps/ios/README.md | 19 + 12 files changed, 961 insertions(+), 205 deletions(-) create mode 100644 apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteMouseInput.java create mode 100644 apps/android/app/src/main/java/cn/crossdesk/mobile/VirtualMouseBar.java create mode 100644 apps/ios/CrossDeskMobile/Input/RemoteMouseInput.swift create mode 100644 apps/ios/CrossDeskMobile/Input/VirtualMouseBar.swift diff --git a/apps/android/README.md b/apps/android/README.md index 37f82531..8796ad9c 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -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 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。 diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java index 1e5498a3..1513abac 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java @@ -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. diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteMouseInput.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteMouseInput.java new file mode 100644 index 00000000..42de343a --- /dev/null +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteMouseInput.java @@ -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); } +} diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVideoView.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVideoView.java index b99f966e..7c58e064 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVideoView.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteVideoView.java @@ -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(); diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java index c7b7267a..7592edb0 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java @@ -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&&xkeyboard.getX()&&ykeyboard.getY()){ + y=keyboard.getY()-gap-height;if(ycloseMenu());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=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{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=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)));} } diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/VirtualMouseBar.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/VirtualMouseBar.java new file mode 100644 index 00000000..b1f82fa4 --- /dev/null +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/VirtualMouseBar.java @@ -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 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) } +} diff --git a/apps/ios/CrossDeskMobile/Input/RemoteTouchInputView.swift b/apps/ios/CrossDeskMobile/Input/RemoteTouchInputView.swift index 298bd6f8..867a1046 100644 --- a/apps/ios/CrossDeskMobile/Input/RemoteTouchInputView.swift +++ b/apps/ios/CrossDeskMobile/Input/RemoteTouchInputView.swift @@ -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: diff --git a/apps/ios/CrossDeskMobile/Input/VirtualMouseBar.swift b/apps/ios/CrossDeskMobile/Input/VirtualMouseBar.swift new file mode 100644 index 00000000..4bcf7051 --- /dev/null +++ b/apps/ios/CrossDeskMobile/Input/VirtualMouseBar.swift @@ -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, 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, 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.. 0 ? -1 : 1) } + remainder -= CGFloat(ticks) * 10 + } + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let touch = trackedTouch, touches.contains(touch) else { return } + cancelTracking() + } + + override func touchesCancelled(_ touches: Set, 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)) + } + } +} diff --git a/apps/ios/CrossDeskMobile/RemoteSessionView.swift b/apps/ios/CrossDeskMobile/RemoteSessionView.swift index e224420e..fc5c78b9 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionView.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionView.swift @@ -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] diff --git a/apps/ios/README.md b/apps/ios/README.md index 3bb08e45..bc6fcf0a 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -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: