mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-15 00:09:39 +08:00
[feat] add compact mobile virtual mouse controls
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ private struct RemoteViewportState: Equatable {
|
||||
|
||||
struct RemoteSessionView: View {
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var mouseInput = RemoteMouseInput()
|
||||
@State private var virtualMouseVisible = false
|
||||
@State private var keyboardInputVisible = false
|
||||
@State private var keyboardMode: RemoteKeyboardMode = .system
|
||||
@State private var viewport = RemoteViewportState()
|
||||
@@ -81,6 +84,7 @@ struct RemoteSessionView: View {
|
||||
}
|
||||
|
||||
RemoteTouchInputView(
|
||||
mouseInput: mouseInput,
|
||||
videoSize: session.displayGeometrySize,
|
||||
controlMode: session.mouseControlMode,
|
||||
remoteCursorPosition: session.remoteCursorPosition,
|
||||
@@ -95,20 +99,24 @@ struct RemoteSessionView: View {
|
||||
offset: offset)
|
||||
},
|
||||
move: { x, y in
|
||||
mouseInput.position(x: x, y: y)
|
||||
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
|
||||
session.bridge.sendPointer(x: x, y: y, action: .move)
|
||||
},
|
||||
leftDown: { x, y in
|
||||
session.bridge.sendPointer(x: x, y: y, action: .leftDown)
|
||||
mouseInput.position(x: x, y: y)
|
||||
mouseInput.setGestureLeft(true)
|
||||
},
|
||||
leftUp: { x, y in
|
||||
session.bridge.sendPointer(x: x, y: y, action: .leftUp)
|
||||
mouseInput.position(x: x, y: y)
|
||||
mouseInput.setGestureLeft(false)
|
||||
},
|
||||
rightClick: { x, y in
|
||||
session.bridge.sendPointer(x: x, y: y, action: .rightDown)
|
||||
session.bridge.sendPointer(x: x, y: y, action: .rightUp)
|
||||
},
|
||||
scroll: { x, y, dx, dy in
|
||||
mouseInput.position(x: x, y: y)
|
||||
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
|
||||
session.bridge.sendScrollX(x, y: y,
|
||||
deltaX: dx, deltaY: dy)
|
||||
@@ -160,7 +168,27 @@ struct RemoteSessionView: View {
|
||||
} message: {
|
||||
Text(session.remoteUpdateMessage ?? "")
|
||||
}
|
||||
.onAppear {
|
||||
mouseInput.send = { x, y, flag, wheel in
|
||||
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
|
||||
switch flag {
|
||||
case 0: session.bridge.sendPointer(x: x, y: y, action: .move)
|
||||
case 1: session.bridge.sendPointer(x: x, y: y, action: .leftDown)
|
||||
case 2: session.bridge.sendPointer(x: x, y: y, action: .leftUp)
|
||||
case 3: session.bridge.sendPointer(x: x, y: y, action: .rightDown)
|
||||
case 4: session.bridge.sendPointer(x: x, y: y, action: .rightUp)
|
||||
case 7: session.bridge.sendScrollX(x, y: y, deltaX: 0, deltaY: wheel)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
if let point = session.remoteCursorPosition {
|
||||
mouseInput.position(x: Float(point.x), y: Float(point.y))
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
mouseInput.releaseAll()
|
||||
mouseInput.send = nil
|
||||
virtualMouseVisible = false
|
||||
keyboardInputVisible = false
|
||||
viewport = RemoteViewportState()
|
||||
cursorPosition = nil
|
||||
@@ -169,14 +197,27 @@ struct RemoteSessionView: View {
|
||||
keyboardPanelCenter = nil
|
||||
}
|
||||
.onChange(of: session.selectedDisplay) { _ in
|
||||
mouseInput.releaseAll()
|
||||
mouseInput.position(x: 0.5, y: 0.5)
|
||||
cursorPosition = nil
|
||||
viewport = RemoteViewportState()
|
||||
}
|
||||
.onChange(of: session.mouseControlMode) { _ in
|
||||
mouseInput.releaseAll()
|
||||
cursorPosition = session.remoteCursorPosition
|
||||
if let point = cursorPosition {
|
||||
mouseInput.position(x: Float(point.x), y: Float(point.y))
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
if phase != .active { mouseInput.releaseAll() }
|
||||
}
|
||||
.onChange(of: session.isConnected) { connected in
|
||||
if !connected { mouseInput.releaseAll() }
|
||||
}
|
||||
.onChange(of: session.remoteCursorPositionRevision) { _ in
|
||||
guard let position = session.remoteCursorPosition else { return }
|
||||
guard !mouseInput.hasVirtualButton, let position = session.remoteCursorPosition else { return }
|
||||
mouseInput.position(x: Float(position.x), y: Float(position.y))
|
||||
cursorPosition = position
|
||||
}
|
||||
.fileImporter(isPresented: $showingFileImporter,
|
||||
@@ -271,17 +312,16 @@ struct RemoteSessionView: View {
|
||||
y: restingOrbCenter.y + statusOrbDragTranslation.height),
|
||||
in: containerSize
|
||||
)
|
||||
let panelSize = CGSize(
|
||||
width: min(340, max(280, containerSize.width - 160)),
|
||||
height: min(310, max(0, containerSize.height - 16))
|
||||
)
|
||||
let panelCenter = floatingPanelCenter(
|
||||
orbCenter: orbCenter,
|
||||
panelSize: panelSize,
|
||||
containerSize: containerSize
|
||||
)
|
||||
let menuColumns = containerSize.height < 400 && containerSize.width >= 400 ? 4 : 3
|
||||
let panelWidth = min(max(1, containerSize.width - 16), menuColumns == 4
|
||||
? 380 : min(340, max(280, containerSize.width - 160)))
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
if virtualMouseVisible && !statusMenuVisible && session.pixelBuffer != nil {
|
||||
VirtualMouseBar(input: mouseInput)
|
||||
.frame(width: 140, height: 48)
|
||||
.position(virtualMouseCenter(orbCenter: orbCenter, in: containerSize))
|
||||
}
|
||||
if statusMenuVisible {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
@@ -292,13 +332,18 @@ struct RemoteSessionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Position a stable container, then animate only the panel
|
||||
// inside it. Scaling a view after .position also scales its
|
||||
// full-screen positioning space and makes the panel drift.
|
||||
ZStack {
|
||||
FloatingMenuLayout(orbCenter: orbCenter, panelWidth: panelWidth) {
|
||||
if statusMenuVisible {
|
||||
FloatingSessionMenu(
|
||||
session: session,
|
||||
columnCount: menuColumns,
|
||||
virtualMouseVisible: virtualMouseVisible,
|
||||
toggleMouse: {
|
||||
mouseInput.releaseAll()
|
||||
virtualMouseVisible.toggle()
|
||||
statusMenuVisible = false
|
||||
},
|
||||
releaseMouse: mouseInput.releaseAll,
|
||||
showKeyboard: {
|
||||
statusMenuVisible = false
|
||||
keyboardInputVisible.toggle()
|
||||
@@ -313,6 +358,7 @@ struct RemoteSessionView: View {
|
||||
}
|
||||
},
|
||||
disconnect: {
|
||||
mouseInput.releaseAll()
|
||||
statusMenuVisible = false
|
||||
keyboardInputVisible = false
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
@@ -320,13 +366,11 @@ struct RemoteSessionView: View {
|
||||
}
|
||||
}
|
||||
)
|
||||
.frame(width: panelSize.width, height: panelSize.height)
|
||||
.transition(.scale(scale: 0.94, anchor: .topTrailing)
|
||||
.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.frame(width: panelSize.width, height: panelSize.height)
|
||||
.position(panelCenter)
|
||||
.frame(width: containerSize.width, height: containerSize.height)
|
||||
.allowsHitTesting(statusMenuVisible)
|
||||
|
||||
CrossDeskStatusOrb(isExpanded: statusMenuVisible)
|
||||
@@ -334,6 +378,7 @@ struct RemoteSessionView: View {
|
||||
.contentShape(Circle())
|
||||
.position(orbCenter)
|
||||
.onTapGesture {
|
||||
mouseInput.releaseAll()
|
||||
withAnimation(.spring(response: 0.24,
|
||||
dampingFraction: 0.84)) {
|
||||
statusMenuVisible.toggle()
|
||||
@@ -345,6 +390,7 @@ struct RemoteSessionView: View {
|
||||
state = value.translation
|
||||
}
|
||||
.onChanged { _ in
|
||||
mouseInput.releaseAll()
|
||||
if statusMenuVisible {
|
||||
statusMenuVisible = false
|
||||
}
|
||||
@@ -359,9 +405,40 @@ struct RemoteSessionView: View {
|
||||
)
|
||||
}
|
||||
.frame(width: containerSize.width, height: containerSize.height)
|
||||
.onChange(of: containerSize) { _ in mouseInput.releaseAll() }
|
||||
}
|
||||
}
|
||||
|
||||
private func virtualMouseCenter(orbCenter: CGPoint, in size: CGSize) -> CGPoint {
|
||||
let halfWidth: CGFloat = 70
|
||||
let halfHeight: CGFloat = 24
|
||||
let reach: CGFloat = 27 + 6 + halfWidth
|
||||
var x = orbCenter.x - reach
|
||||
var y = orbCenter.y
|
||||
if x - halfWidth < 8 {
|
||||
x = orbCenter.x + reach
|
||||
if x + halfWidth > size.width - 8 {
|
||||
x = orbCenter.x
|
||||
y = orbCenter.y + 27 + 6 + halfHeight
|
||||
if y + halfHeight > size.height - 8 {
|
||||
y = orbCenter.y - 27 - 6 - halfHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
x = min(max(x, halfWidth + 8), max(halfWidth + 8, size.width - halfWidth - 8))
|
||||
if keyboardInputVisible {
|
||||
let keyboardSize = keyboardPanelSize(in: size)
|
||||
let resting = constrainedKeyboardCenter(keyboardPanelCenter ?? defaultKeyboardCenter(in: size, panelSize: keyboardSize), in: size, panelSize: keyboardSize)
|
||||
let center = constrainedKeyboardCenter(CGPoint(x: resting.x + keyboardDragTranslation.width, y: resting.y + keyboardDragTranslation.height), in: size, panelSize: keyboardSize)
|
||||
let keyboardRect = CGRect(x: center.x - keyboardSize.width / 2, y: center.y - keyboardSize.height / 2, width: keyboardSize.width, height: keyboardSize.height)
|
||||
if keyboardRect.intersects(CGRect(x: x - halfWidth, y: y - halfHeight, width: 140, height: 48)) {
|
||||
y = keyboardRect.minY - 6 - halfHeight
|
||||
if y - halfHeight < 8 { y = keyboardRect.maxY + 6 + halfHeight }
|
||||
}
|
||||
}
|
||||
return CGPoint(x: x, y: min(max(y, halfHeight + 8), max(halfHeight + 8, size.height - halfHeight - 8)))
|
||||
}
|
||||
|
||||
private var draggableKeyboard: some View {
|
||||
GeometryReader { proxy in
|
||||
let containerSize = proxy.size
|
||||
@@ -503,24 +580,6 @@ struct RemoteSessionView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func floatingPanelCenter(orbCenter: CGPoint,
|
||||
panelSize: CGSize,
|
||||
containerSize: CGSize) -> CGPoint {
|
||||
let gap: CGFloat = 12
|
||||
let orbRadius: CGFloat = 27
|
||||
let horizontalPadding: CGFloat = 8
|
||||
let verticalPadding: CGFloat = 8
|
||||
let preferredX = orbCenter.x > containerSize.width / 2
|
||||
? orbCenter.x - orbRadius - gap - panelSize.width / 2
|
||||
: orbCenter.x + orbRadius + gap + panelSize.width / 2
|
||||
return CGPoint(
|
||||
x: min(max(preferredX, panelSize.width / 2 + horizontalPadding),
|
||||
containerSize.width - panelSize.width / 2 - horizontalPadding),
|
||||
y: min(max(orbCenter.y, panelSize.height / 2 + verticalPadding),
|
||||
containerSize.height - panelSize.height / 2 - verticalPadding)
|
||||
)
|
||||
}
|
||||
|
||||
private var visibleCursorShape: Int {
|
||||
guard session.hasRemoteCursorState,
|
||||
session.remoteCursorVisible,
|
||||
@@ -1079,8 +1138,39 @@ private struct CrossDeskStatusOrb: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the menu before positioning it; no fixed height or later-frame
|
||||
/// preference update is needed when changing pages, text size or orientation.
|
||||
private struct FloatingMenuLayout: Layout {
|
||||
let orbCenter: CGPoint
|
||||
let panelWidth: CGFloat
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews,
|
||||
cache: inout ()) -> CGSize {
|
||||
proposal.replacingUnspecifiedDimensions()
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
|
||||
subviews: Subviews, cache: inout ()) {
|
||||
guard let menu = subviews.first else { return }
|
||||
let size = menu.sizeThatFits(ProposedViewSize(
|
||||
width: panelWidth, height: max(0, bounds.height - 16)))
|
||||
let preferredX = orbCenter.x > bounds.width / 2
|
||||
? orbCenter.x - 39 - size.width / 2
|
||||
: orbCenter.x + 39 + size.width / 2
|
||||
let center = CGPoint(
|
||||
x: bounds.minX + min(max(preferredX, size.width / 2 + 8),
|
||||
bounds.width - size.width / 2 - 8),
|
||||
y: bounds.minY + min(max(orbCenter.y, size.height / 2 + 8),
|
||||
bounds.height - size.height / 2 - 8))
|
||||
menu.place(at: center, anchor: .center, proposal: ProposedViewSize(size))
|
||||
}
|
||||
}
|
||||
|
||||
private struct FloatingSessionMenu: View {
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
let virtualMouseVisible: Bool
|
||||
let toggleMouse: () -> Void
|
||||
let releaseMouse: () -> Void
|
||||
private enum Page { case controls, videoSettings, networkStats }
|
||||
@State private var page: Page = .controls
|
||||
@State private var displayedNetworkSnapshot: RemoteNetworkSnapshot
|
||||
@@ -1090,15 +1180,25 @@ private struct FloatingSessionMenu: View {
|
||||
let close: () -> Void
|
||||
let disconnect: () -> Void
|
||||
|
||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: 7),
|
||||
count: 3)
|
||||
let columnCount: Int
|
||||
private var columns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 7), count: columnCount)
|
||||
}
|
||||
|
||||
init(session: RemoteSessionModel,
|
||||
columnCount: Int,
|
||||
virtualMouseVisible: Bool,
|
||||
toggleMouse: @escaping () -> Void,
|
||||
releaseMouse: @escaping () -> Void,
|
||||
showKeyboard: @escaping () -> Void,
|
||||
chooseFile: @escaping () -> Void,
|
||||
close: @escaping () -> Void,
|
||||
disconnect: @escaping () -> Void) {
|
||||
self.session = session
|
||||
self.columnCount = columnCount
|
||||
self.virtualMouseVisible = virtualMouseVisible
|
||||
self.toggleMouse = toggleMouse
|
||||
self.releaseMouse = releaseMouse
|
||||
self.showKeyboard = showKeyboard
|
||||
self.chooseFile = chooseFile
|
||||
self.close = close
|
||||
@@ -1110,145 +1210,11 @@ private struct FloatingSessionMenu: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 9) {
|
||||
HStack(spacing: 8) {
|
||||
if page != .controls {
|
||||
Button {
|
||||
page = .controls
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.frame(width: 28, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("返回控制栏")
|
||||
Text(page == .videoSettings ? "画面设置" : "网络状态")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
} else {
|
||||
Circle()
|
||||
.fill(session.isConnected ? Color.green : Color.orange)
|
||||
.frame(width: 9, height: 9)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(session.connectionStatus)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(statusDetail)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button(action: close) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("关闭控制栏")
|
||||
}
|
||||
|
||||
if page == .videoSettings, !session.videoSettingsFeedback.isEmpty {
|
||||
Text(session.videoSettingsFeedback)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
if page == .videoSettings {
|
||||
FloatingVideoSettings(session: session)
|
||||
} else if page == .networkStats {
|
||||
FloatingNetworkStatistics(snapshot: displayedNetworkSnapshot,
|
||||
frameSize: displayedFrameSize)
|
||||
} else {
|
||||
VStack(spacing: 9) {
|
||||
LazyVGrid(columns: columns, spacing: 7) {
|
||||
FloatingControlButton(title: "键盘", symbol: "keyboard",
|
||||
action: showKeyboard)
|
||||
|
||||
Menu {
|
||||
ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in
|
||||
Button {
|
||||
session.selectDisplay(index)
|
||||
} label: {
|
||||
if index == session.selectedDisplay {
|
||||
Label(name, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
FloatingControlLabel(title: "显示器", symbol: "display")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
FloatingControlButton(title: "画面设置", symbol: "slider.horizontal.3") {
|
||||
page = .videoSettings
|
||||
}
|
||||
FloatingControlButton(
|
||||
title: session.audioEnabled ? "声音" : "静音",
|
||||
symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash",
|
||||
action: session.toggleAudio
|
||||
)
|
||||
FloatingControlButton(
|
||||
title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标",
|
||||
symbol: "computermouse",
|
||||
action: {
|
||||
session.mouseControlMode = session.mouseControlMode == .relative
|
||||
? .absolute : .relative
|
||||
}
|
||||
)
|
||||
FloatingControlButton(title: "发送文件",
|
||||
symbol: "folder.badge.plus",
|
||||
action: chooseFile)
|
||||
FloatingControlButton(title: "网络状态", symbol: "chart.bar.xaxis") {
|
||||
page = .networkStats
|
||||
}
|
||||
FloatingControlButton(title: "Ctrl+Alt+Del",
|
||||
symbol: "lock.trianglebadge.exclamationmark",
|
||||
action: session.sendSecureAttention)
|
||||
FloatingControlButton(title: "剪贴板", symbol: "doc.on.clipboard",
|
||||
action: session.sendClipboard)
|
||||
}
|
||||
|
||||
if !session.controlStatus.isEmpty {
|
||||
Text(session.controlStatus).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
if !session.clipboardStatus.isEmpty {
|
||||
Text(session.clipboardStatus).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !session.transferStatus.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
Text(session.transferStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
if session.transferProgress > 0 && session.transferProgress < 1 {
|
||||
ProgressView(value: session.transferProgress)
|
||||
.frame(width: 60)
|
||||
}
|
||||
if let file = session.receivedFileURL {
|
||||
ShareLink(item: file) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive, action: disconnect) {
|
||||
Text("断开连接")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 34)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
// Prefer the full natural-height panel. Only use a scrolling body when
|
||||
// the available safe-area height cannot contain all of its content.
|
||||
ViewThatFits(in: .vertical) {
|
||||
panel(scrolls: false).fixedSize(horizontal: false, vertical: true)
|
||||
panel(scrolls: true)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.white,
|
||||
@@ -1274,6 +1240,164 @@ private struct FloatingSessionMenu: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func panel(scrolls: Bool) -> some View {
|
||||
VStack(spacing: 9) {
|
||||
menuHeader
|
||||
if scrolls {
|
||||
ScrollView { menuContent }
|
||||
} else {
|
||||
menuContent
|
||||
}
|
||||
disconnectButton
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var menuHeader: some View {
|
||||
HStack(spacing: 8) {
|
||||
if page != .controls {
|
||||
Button {
|
||||
page = .controls
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.frame(width: 28, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("返回控制栏")
|
||||
Text(page == .videoSettings ? "画面设置" : "网络状态")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
} else {
|
||||
Circle()
|
||||
.fill(session.isConnected ? Color.green : Color.orange)
|
||||
.frame(width: 9, height: 9)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(session.connectionStatus)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(statusDetail)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button(action: close) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("关闭控制栏")
|
||||
}
|
||||
|
||||
if page == .videoSettings, !session.videoSettingsFeedback.isEmpty {
|
||||
Text(session.videoSettingsFeedback)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var menuContent: some View {
|
||||
if page == .videoSettings {
|
||||
FloatingVideoSettings(session: session)
|
||||
} else if page == .networkStats {
|
||||
FloatingNetworkStatistics(snapshot: displayedNetworkSnapshot,
|
||||
frameSize: displayedFrameSize)
|
||||
} else {
|
||||
VStack(spacing: 9) {
|
||||
LazyVGrid(columns: columns, spacing: 7) {
|
||||
FloatingControlButton(title: "键盘", symbol: "keyboard",
|
||||
action: showKeyboard)
|
||||
FloatingControlButton(
|
||||
title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标",
|
||||
symbol: "computermouse",
|
||||
action: {
|
||||
session.mouseControlMode = session.mouseControlMode == .relative
|
||||
? .absolute : .relative
|
||||
}
|
||||
)
|
||||
FloatingControlButton(title: virtualMouseVisible ? "收起鼠标" : "虚拟鼠标",
|
||||
symbol: "computermouse", action: toggleMouse)
|
||||
|
||||
Menu {
|
||||
ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in
|
||||
Button {
|
||||
releaseMouse()
|
||||
session.selectDisplay(index)
|
||||
} label: {
|
||||
if index == session.selectedDisplay {
|
||||
Label(name, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
FloatingControlLabel(title: "显示器", symbol: "display")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
FloatingControlButton(title: "画面设置", symbol: "slider.horizontal.3") {
|
||||
page = .videoSettings
|
||||
}
|
||||
FloatingControlButton(
|
||||
title: session.audioEnabled ? "声音" : "静音",
|
||||
symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash",
|
||||
action: session.toggleAudio
|
||||
)
|
||||
FloatingControlButton(title: "发送文件",
|
||||
symbol: "folder.badge.plus",
|
||||
action: chooseFile)
|
||||
FloatingControlButton(title: "网络状态", symbol: "chart.bar.xaxis") {
|
||||
page = .networkStats
|
||||
}
|
||||
FloatingControlButton(title: "Ctrl+Alt+Del",
|
||||
symbol: "lock.trianglebadge.exclamationmark",
|
||||
action: session.sendSecureAttention)
|
||||
FloatingControlButton(title: "剪贴板", symbol: "doc.on.clipboard",
|
||||
action: session.sendClipboard)
|
||||
}
|
||||
|
||||
if !session.controlStatus.isEmpty {
|
||||
Text(session.controlStatus).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
if !session.clipboardStatus.isEmpty {
|
||||
Text(session.clipboardStatus).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !session.transferStatus.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
Text(session.transferStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
if session.transferProgress > 0 && session.transferProgress < 1 {
|
||||
ProgressView(value: session.transferProgress)
|
||||
.frame(width: 60)
|
||||
}
|
||||
if let file = session.receivedFileURL {
|
||||
ShareLink(item: file) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var disconnectButton: some View {
|
||||
Button(role: .destructive, action: disconnect) {
|
||||
Text("断开连接")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 34)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
}
|
||||
|
||||
private var statusDetail: String {
|
||||
let snapshot = displayedNetworkSnapshot
|
||||
var components = [snapshot.connectionMode]
|
||||
|
||||
@@ -327,6 +327,25 @@ Old previews saved without this choice are removed on upgrade.
|
||||
|
||||
## Physical-device test checklist
|
||||
|
||||
The floating control menu includes **虚拟鼠标** (virtual mouse), a 140 × 48 pt
|
||||
horizontal left-button / wheel / right-button bar attached to the status orb.
|
||||
Drag the orb to reposition it, or use **收起鼠标** to hide it. The bar overlays
|
||||
the video without resizing it and avoids the floating keyboard. Hold either mouse
|
||||
button and drag the same finger, including beyond the key bounds, to drag at the
|
||||
remote cursor; lifting the finger releases that button. Moving another finger on
|
||||
the video while holding a button also works. The flat split design has unlabelled
|
||||
left/right paddles and an 18 × 30 pt wheel face, separated by clear gutters;
|
||||
all three touch targets remain 44 × 44 pt. There are no shadows, bevels or press
|
||||
displacement: pressing changes only the face color. Uniform horizontal wheel
|
||||
treads cycle vertically with the finger and stop when it lifts.
|
||||
Swipe the wheel up/down, including beyond its bounds, to scroll at any zoom
|
||||
level. Check both pointer modes, button cancellation, display changes, rotation,
|
||||
and backgrounding on a device; each must leave remote buttons released.
|
||||
The expanded status menu sizes to all of its content, including all ten actions;
|
||||
short, wide screens use four columns, and only content that exceeds the available
|
||||
safe area scrolls. Check this in both
|
||||
orientations, with larger text and while switching menu pages.
|
||||
|
||||
Run the app from Xcode on a physical device and connect to a current desktop
|
||||
build. Test the features in this order so a media problem is not confused with
|
||||
a data-channel problem:
|
||||
|
||||
Reference in New Issue
Block a user