mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-21 03:40:40 +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。
|
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
|
||||||
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
|
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
|
||||||
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。
|
- 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。
|
||||||
|
- 悬浮菜单中的“虚拟鼠标”展开 140 × 48 dp 横向左键/滚轮/右键条,跟随悬浮球移动,可随时收起,不压缩远程画面。按住左键或右键后,同一根手指拖动即可拖拽,滑出按键范围仍继续,松开结束;也支持另一指在画面上移动光标。采用无文字的扁平分体按键,中间滚轮外观为 18 × 30 dp,三键均保留 44 × 44 dp 触控范围,通过留白分隔。无阴影、凹凸或按压位移,按压仅变色。等色横条随手指上下循环移动,松手停止;上下滑动可超出按键范围,在画面放大后仍控制远端滚动。切换模式、显示器、进入后台或取消触摸会释放按键。状态菜单按内容高度完整展开,只有安全区域空间不足时才滚动。
|
||||||
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
|
- 悬浮控制栏支持选择并发送文件、接收电脑端文件、查看确认进度及保存收到的文件;Ctrl+Alt+Del 使用远端 Windows 系统服务,未启用服务时显示提示。
|
||||||
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意,后台切换会结束会话。
|
- 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意,后台切换会结束会话。
|
||||||
- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。
|
- 与 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 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));}
|
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.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 confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
|
||||||
private void end(String reason){
|
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;
|
mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;receivedFile=null;remoteServiceAvailable=false;
|
||||||
resetRemoteVersionCheck();
|
resetRemoteVersionCheck();
|
||||||
resetVideoSettings();
|
resetVideoSettings();
|
||||||
@@ -619,6 +621,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
if(controls!=null)controls.post(controls::constrainPanels);
|
if(controls!=null)controls.post(controls::constrainPanels);
|
||||||
}
|
}
|
||||||
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
|
// 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 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();
|
@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.
|
// 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 SurfaceView video;
|
||||||
private final Cursor cursor;
|
private final Cursor cursor;
|
||||||
private final NativeSession session;
|
private final NativeSession session;
|
||||||
|
final RemoteMouseInput mouseInput;
|
||||||
private final GestureDetector gestures;
|
private final GestureDetector gestures;
|
||||||
private final ScaleGestureDetector scales;
|
private final ScaleGestureDetector scales;
|
||||||
private final RemoteViewport viewport = new RemoteViewport();
|
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 float x = .5f, y = .5f, scrollY;
|
||||||
private boolean dragging, twoFinger, relative, viewportGesture, viewportPanActive;
|
private boolean dragging, twoFinger, relative, viewportGesture, viewportPanActive;
|
||||||
private float lastX,lastY,twoFingerX,twoFingerY;
|
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) {
|
RemoteVideoView(Context context, NativeSession session) {
|
||||||
super(context); this.session = session;
|
super(context); this.session = session;
|
||||||
|
mouseInput = new RemoteMouseInput(this::sendMouse);
|
||||||
setBackgroundColor(Color.BLACK);
|
setBackgroundColor(Color.BLACK);
|
||||||
video = new SurfaceView(context); video.getHolder().addCallback(this);
|
video = new SurfaceView(context); video.getHolder().addCallback(this);
|
||||||
addView(video, new LayoutParams(1, 1, android.view.Gravity.CENTER));
|
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() {
|
gestures = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
|
||||||
@Override public boolean onDown(MotionEvent e) { return point(e); }
|
@Override public boolean onDown(MotionEvent e) { return point(e); }
|
||||||
@Override public boolean onSingleTapUp(MotionEvent 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) {
|
@Override public boolean onScroll(MotionEvent first, MotionEvent current, float dx, float dy) {
|
||||||
if (twoFinger) return true;
|
if (twoFinger) return true;
|
||||||
if (!relative && !dragging) {
|
if (!relative && !dragging && !suppressPointerGesture()) {
|
||||||
if (!point(first)) return true;
|
if (!point(first)) return true;
|
||||||
session.pointer(x, y, 1, 0); dragging = true;
|
mouseInput.gestureLeft(true); dragging = true;
|
||||||
}
|
}
|
||||||
point(current); return true;
|
point(current); return true;
|
||||||
}
|
}
|
||||||
@@ -58,6 +66,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
|
|||||||
gestures.setOnDoubleTapListener(null);
|
gestures.setOnDoubleTapListener(null);
|
||||||
scales = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
scales = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||||
@Override public boolean onScaleBegin(ScaleGestureDetector detector) {
|
@Override public boolean onScaleBegin(ScaleGestureDetector detector) {
|
||||||
|
if (mouseInput.hasVirtualButton()) return false;
|
||||||
if (!viewport.contains(detector.getFocusX(), detector.getFocusY())) return false;
|
if (!viewport.contains(detector.getFocusX(), detector.getFocusY())) return false;
|
||||||
viewportGesture = true;
|
viewportGesture = true;
|
||||||
viewportPanActive = true;
|
viewportPanActive = true;
|
||||||
@@ -89,7 +98,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
|
|||||||
if (width <= 0 || height <= 0) return;
|
if (width <= 0 || height <= 0) return;
|
||||||
frameWidth = width; frameHeight = height; fit();
|
frameWidth = width; frameHeight = height; fit();
|
||||||
}
|
}
|
||||||
void resetViewport() { viewport.reset(); applyViewport(); }
|
void resetViewport() { releaseMouse(); viewport.reset(); applyViewport(); }
|
||||||
static Rect previewCrop(int width,int height){
|
static Rect previewCrop(int width,int height){
|
||||||
int w=width,h=height;
|
int w=width,h=height;
|
||||||
if((long)width*RemotePreviewStore.HEIGHT>(long)height*RemotePreviewStore.WIDTH)w=Math.max(1,height*RemotePreviewStore.WIDTH/RemotePreviewStore.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.
|
// Transform the existing Surface without enlarging its decoder buffers.
|
||||||
video.setScaleX(viewport.scale()); video.setScaleY(viewport.scale());
|
video.setScaleX(viewport.scale()); video.setScaleY(viewport.scale());
|
||||||
video.setTranslationX(viewport.offsetX()); video.setTranslationY(viewport.offsetY());
|
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();
|
cursor.invalidate();
|
||||||
}
|
}
|
||||||
@Override protected void onSizeChanged(int w,int h,int oldw,int oldh) { post(this::fit); }
|
@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);
|
y=viewport.normalizedY(py);
|
||||||
}
|
}
|
||||||
lastX=px;lastY=py;
|
lastX=px;lastY=py;
|
||||||
session.pointer(x,y,0,0); cursor.invalidate();
|
mouseInput.move(x,y); cursor.invalidate();
|
||||||
return true;
|
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) {
|
private boolean touch(MotionEvent e) {
|
||||||
int action = e.getActionMasked();
|
int action = e.getActionMasked();
|
||||||
if (action == MotionEvent.ACTION_DOWN) {
|
if (action == MotionEvent.ACTION_DOWN) {
|
||||||
twoFinger = viewportGesture = viewportPanActive = false;
|
twoFinger = viewportGesture = viewportPanActive = false;
|
||||||
|
touchRevision = mouseInput.revision();
|
||||||
|
suppressPointerGesture = mouseInput.hasVirtualButton();
|
||||||
}
|
}
|
||||||
if (action == MotionEvent.ACTION_POINTER_DOWN) {
|
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;
|
twoFinger = true;
|
||||||
twoFingerX = focus(e,true,-1); twoFingerY = scrollY = focus(e,false,-1);
|
twoFingerX = focus(e,true,-1); twoFingerY = scrollY = focus(e,false,-1);
|
||||||
viewportPanActive = viewport.isZoomed() && viewport.contains(twoFingerX,twoFingerY);
|
viewportPanActive = viewport.isZoomed() && viewport.contains(twoFingerX,twoFingerY);
|
||||||
@@ -152,6 +169,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
|
|||||||
}
|
}
|
||||||
scales.onTouchEvent(e);
|
scales.onTouchEvent(e);
|
||||||
if (twoFinger && action == MotionEvent.ACTION_MOVE && e.getPointerCount() >= 2) {
|
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);
|
float focusX = focus(e,true,-1), focusY = focus(e,false,-1);
|
||||||
if (viewport.isZoomed() || viewportGesture || scales.isInProgress()) {
|
if (viewport.isZoomed() || viewportGesture || scales.isInProgress()) {
|
||||||
viewportGesture = true;
|
viewportGesture = true;
|
||||||
@@ -179,7 +197,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
|
|||||||
twoFingerY = scrollY = focus(e,false,e.getActionIndex());
|
twoFingerY = scrollY = focus(e,false,e.getActionIndex());
|
||||||
}
|
}
|
||||||
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
|
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
|
||||||
if (dragging) session.pointer(x,y,2,0);
|
if (dragging) mouseInput.gestureLeft(false);
|
||||||
dragging = false;
|
dragging = false;
|
||||||
viewport.finishGesture(); applyViewport();
|
viewport.finishGesture(); applyViewport();
|
||||||
viewportPanActive = false;
|
viewportPanActive = false;
|
||||||
@@ -197,7 +215,8 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
|
|||||||
@Override public boolean performClick() { super.performClick(); return true; }
|
@Override public boolean performClick() { super.performClick(); return true; }
|
||||||
@Override public void surfaceCreated(SurfaceHolder holder) { session.surface(holder.getSurface()); }
|
@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 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 class Cursor extends View {
|
||||||
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
private final Path arrow = new Path();
|
private final Path arrow = new Path();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import android.view.*;
|
|||||||
import android.widget.*;
|
import android.widget.*;
|
||||||
import static cn.crossdesk.mobile.MobileUi.*;
|
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")
|
@android.annotation.SuppressLint("ViewConstructor")
|
||||||
final class SessionControls extends FrameLayout {
|
final class SessionControls extends FrameLayout {
|
||||||
interface Actions {
|
interface Actions {
|
||||||
@@ -21,8 +21,13 @@ final class SessionControls extends FrameLayout {
|
|||||||
private final LinearLayout waiting;
|
private final LinearLayout waiting;
|
||||||
private View shield;
|
private View shield;
|
||||||
private LinearLayout menu,keyboard;
|
private LinearLayout menu,keyboard;
|
||||||
|
private VirtualMouseBar mouseBar;
|
||||||
|
private FrameLayout mouseHost;
|
||||||
|
private boolean mouseVisible;
|
||||||
private ScrollView keyboardKeys;
|
private ScrollView keyboardKeys;
|
||||||
|
private ScrollView menuScroll;
|
||||||
private int keyboardPreferredHeight;
|
private int keyboardPreferredHeight;
|
||||||
|
private int menuColumns=3;
|
||||||
private TextView networkLabel,videoFeedback;
|
private TextView networkLabel,videoFeedback;
|
||||||
private TextView transferLabel,saveFileButton;
|
private TextView transferLabel,saveFileButton;
|
||||||
private String transferStatus="";
|
private String transferStatus="";
|
||||||
@@ -49,8 +54,36 @@ final class SessionControls extends FrameLayout {
|
|||||||
drag(orb,orb,()->{if(menu==null)openMenu("controls");else closeMenu();});
|
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 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 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 resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
|
||||||
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
|
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
|
||||||
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
|
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(transferLabel!=null){transferLabel.setText(transferStatus);transferLabel.setVisibility(transferStatus.isEmpty()?GONE:VISIBLE);}
|
||||||
if(saveFileButton!=null)saveFileButton.setVisibility(hasReceivedFile?VISIBLE:GONE);
|
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){
|
private void openMenu(String page){
|
||||||
|
cancelMouseInput();
|
||||||
closeMenu();menuPage=page;shield=new View(getContext());shield.setOnClickListener(v->closeMenu());addView(shield,new LayoutParams(-1,-1));
|
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));
|
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();
|
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);
|
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("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);}
|
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")){
|
if(page.equals("network")){
|
||||||
// Reserve the scrollbar's width plus a gap before right-aligned statistics.
|
// 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);
|
scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_INSET);body.setPadding(0,0,ui.dp(6),0);
|
||||||
}
|
}
|
||||||
if(page.equals("controls")){
|
if(page.equals("controls")){
|
||||||
String[] labels={"键盘","显示器","画面设置",muted?"静音":"声音",relative?"相对鼠标":"绝对鼠标","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
|
String[] labels={"键盘",relative?"相对鼠标":"绝对鼠标",mouseVisible?"收起鼠标":"虚拟鼠标","显示器","画面设置",muted?"静音":"声音","发送文件","网络状态","Ctrl+Alt+Del","剪贴板"};
|
||||||
String[] icons={"keyboard","display","sliders","audio","mouse","folder","chart","lock","clipboard"};
|
String[] icons={"keyboard","mouse","mouse","display","sliders","audio","folder","chart","lock","clipboard"};
|
||||||
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::display,()->openMenu("video"),actions::mute,actions::mouse,actions::sendFile,()->openMenu("network"),actions::secureAttention,actions::clipboard};
|
Runnable[] clicks={()->{closeMenu();toggleKeyboard();},actions::mouse,this::toggleMouse,actions::display,()->openMenu("video"),actions::mute,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());
|
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);
|
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);
|
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();
|
saveFileButton=ui.action("保存收到的文件",BLUE,actions::saveFile);body.addView(saveFileButton);renderFileStatus();
|
||||||
}else if(page.equals("network")){
|
}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));
|
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("收起远程控制菜单");
|
addView(menu,new LayoutParams(panelWidth(getWidth()),panelHeight(getHeight())));orb.bringToFront();orb.setContentDescription("收起远程控制菜单");
|
||||||
|
positionMouse();
|
||||||
}
|
}
|
||||||
private final class SettingRow {
|
private final class SettingRow {
|
||||||
final int field;final int[] values;final SegmentedControl group;
|
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);
|
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);
|
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 controlColumns(){return getMeasuredHeight()<ui.dp(400)&&getMeasuredWidth()>=ui.dp(400)?4:3;}
|
||||||
private int panelHeight(int height){return Math.min(ui.dp(310),Math.max(1,height-ui.dp(16)));}
|
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 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 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 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 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 toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();}
|
||||||
private void 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;
|
handle.setOnClickListener(v->tap.run());handle.setOnTouchListener(new View.OnTouchListener(){float startX,startY,originX,originY;boolean moved;
|
||||||
public boolean onTouch(View v,MotionEvent event){
|
public boolean onTouch(View v,MotionEvent event){
|
||||||
switch(event.getActionMasked()){
|
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_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)));}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_UP:if(!moved)v.performClick();return true;
|
||||||
case MotionEvent.ACTION_CANCEL:return true;
|
case MotionEvent.ACTION_CANCEL:return true;
|
||||||
default:return false;
|
default:return false;
|
||||||
@@ -195,9 +243,12 @@ final class SessionControls extends FrameLayout {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
void constrainPanels(){
|
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)));
|
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();
|
if(menu!=null||keyboard!=null)requestLayout();
|
||||||
positionKeyboard();
|
positionKeyboard();
|
||||||
|
positionMouse();
|
||||||
}
|
}
|
||||||
@Override protected void onMeasure(int widthSpec,int heightSpec){
|
@Override protected void onMeasure(int widthSpec,int heightSpec){
|
||||||
super.onMeasure(widthSpec,heightSpec);
|
super.onMeasure(widthSpec,heightSpec);
|
||||||
@@ -205,7 +256,9 @@ final class SessionControls extends FrameLayout {
|
|||||||
if(menu!=null){
|
if(menu!=null){
|
||||||
// Use this traversal's viewport, including rotation/inset changes, before drawing.
|
// Use this traversal's viewport, including rotation/inset changes, before drawing.
|
||||||
ViewGroup.LayoutParams p=menu.getLayoutParams();int width=panelWidth(getMeasuredWidth()),height=panelHeight(getMeasuredHeight());
|
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){
|
if(keyboard!=null){
|
||||||
ViewGroup.LayoutParams p=keyboard.getLayoutParams();int width=keyboardWidth(getMeasuredWidth()),height=keyboardHeight(getMeasuredHeight());
|
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.
|
// New/rebuilt menus have real dimensions now; position them in the same frame.
|
||||||
positionMenu();
|
positionMenu();
|
||||||
positionKeyboard();
|
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);}
|
@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)));}
|
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 = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
100000000000000000000018 /* RecentConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000018 /* RecentConnection.swift */; };
|
||||||
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; };
|
100000000000000000000016 /* RemoteVideoSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000016 /* RemoteVideoSettings.swift */; };
|
||||||
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000017 /* RemoteNetworkStatistics.swift */; };
|
100000000000000000000017 /* RemoteNetworkStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000017 /* RemoteNetworkStatistics.swift */; };
|
||||||
@@ -34,6 +36,8 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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>"; };
|
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>"; };
|
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>"; };
|
200000000000000000000017 /* RemoteNetworkStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNetworkStatistics.swift; sourceTree = "<group>"; };
|
||||||
@@ -149,6 +153,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
20000000000000000000000A /* RemoteTouchInputView.swift */,
|
20000000000000000000000A /* RemoteTouchInputView.swift */,
|
||||||
|
20000000000000000000001D /* RemoteMouseInput.swift */,
|
||||||
|
20000000000000000000001E /* VirtualMouseBar.swift */,
|
||||||
);
|
);
|
||||||
path = Input;
|
path = Input;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -289,6 +295,8 @@
|
|||||||
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */,
|
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */,
|
||||||
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */,
|
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */,
|
||||||
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */,
|
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */,
|
||||||
|
10000000000000000000001D /* RemoteMouseInput.swift in Sources */,
|
||||||
|
10000000000000000000001E /* VirtualMouseBar.swift in Sources */,
|
||||||
100000000000000000000012 /* PrivacyPreferences.swift in Sources */,
|
100000000000000000000012 /* PrivacyPreferences.swift in Sources */,
|
||||||
100000000000000000000013 /* ThumbnailFileStore.swift in Sources */,
|
100000000000000000000013 /* ThumbnailFileStore.swift in Sources */,
|
||||||
100000000000000000000014 /* PrivacyViews.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 {
|
struct RemoteTouchInputView: UIViewRepresentable {
|
||||||
|
let mouseInput: RemoteMouseInput
|
||||||
let videoSize: CGSize
|
let videoSize: CGSize
|
||||||
let controlMode: MouseControlMode
|
let controlMode: MouseControlMode
|
||||||
let remoteCursorPosition: CGPoint?
|
let remoteCursorPosition: CGPoint?
|
||||||
@@ -73,6 +74,7 @@ struct RemoteTouchInputView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ view: RemoteTouchSurface, context: Context) {
|
func updateUIView(_ view: RemoteTouchSurface, context: Context) {
|
||||||
|
view.mouseInput = mouseInput
|
||||||
view.videoSize = videoSize
|
view.videoSize = videoSize
|
||||||
view.controlMode = controlMode
|
view.controlMode = controlMode
|
||||||
view.setViewport(scale: viewportScale, offset: viewportOffset)
|
view.setViewport(scale: viewportScale, offset: viewportOffset)
|
||||||
@@ -88,6 +90,8 @@ struct RemoteTouchInputView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||||
|
var mouseInput: RemoteMouseInput?
|
||||||
|
private var gestureRevisions: [ObjectIdentifier: UInt64] = [:]
|
||||||
var videoSize = CGSize.zero {
|
var videoSize = CGSize.zero {
|
||||||
didSet { setNeedsLayout() }
|
didSet { setNeedsLayout() }
|
||||||
}
|
}
|
||||||
@@ -108,7 +112,14 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
|||||||
var onScroll: ((Float, Float, Int, Int) -> Void)?
|
var onScroll: ((Float, Float, Int, Int) -> Void)?
|
||||||
var onViewportChanged: ((CGFloat, CGSize) -> 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 lastRemoteCursorPoint: (Float, Float)?
|
||||||
private var lastRemoteCursorRevision: UInt64?
|
private var lastRemoteCursorRevision: UInt64?
|
||||||
private var relativePanActive = false
|
private var relativePanActive = false
|
||||||
@@ -128,10 +139,12 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
|||||||
isMultipleTouchEnabled = true
|
isMultipleTouchEnabled = true
|
||||||
|
|
||||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||||
|
tap.delegate = self
|
||||||
tap.numberOfTouchesRequired = 1
|
tap.numberOfTouchesRequired = 1
|
||||||
addGestureRecognizer(tap)
|
addGestureRecognizer(tap)
|
||||||
|
|
||||||
let rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap(_:)))
|
let rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap(_:)))
|
||||||
|
rightTap.delegate = self
|
||||||
rightTap.numberOfTouchesRequired = 2
|
rightTap.numberOfTouchesRequired = 2
|
||||||
tap.require(toFail: rightTap)
|
tap.require(toFail: rightTap)
|
||||||
addGestureRecognizer(rightTap)
|
addGestureRecognizer(rightTap)
|
||||||
@@ -174,6 +187,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
|||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
mouseInput?.viewportSize = renderRect()?.size ?? .zero
|
||||||
let constrained = constrainedViewportOffset(viewportOffset,
|
let constrained = constrainedViewportOffset(viewportOffset,
|
||||||
scale: viewportScale)
|
scale: viewportScale)
|
||||||
guard constrained != viewportOffset else { return }
|
guard constrained != viewportOffset else { return }
|
||||||
@@ -185,6 +199,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
|||||||
let nextScale = min(max(scale, 1), maximumViewportScale)
|
let nextScale = min(max(scale, 1), maximumViewportScale)
|
||||||
viewportScale = nextScale
|
viewportScale = nextScale
|
||||||
viewportOffset = constrainedViewportOffset(offset, scale: nextScale)
|
viewportOffset = constrainedViewportOffset(offset, scale: nextScale)
|
||||||
|
mouseInput?.viewportSize = renderRect()?.size ?? .zero
|
||||||
}
|
}
|
||||||
|
|
||||||
func synchronizeRemoteCursor(_ position: CGPoint?, revision: UInt64) {
|
func synchronizeRemoteCursor(_ position: CGPoint?, revision: UInt64) {
|
||||||
@@ -203,12 +218,39 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
|||||||
)
|
)
|
||||||
lastRemoteCursorPoint = point
|
lastRemoteCursorPoint = point
|
||||||
guard controlMode == .relative,
|
guard controlMode == .relative,
|
||||||
|
mouseInput?.hasVirtualButton != true,
|
||||||
!relativePanActive,
|
!relativePanActive,
|
||||||
heldDragPoint == nil,
|
heldDragPoint == nil,
|
||||||
hoverLastLocation == nil else { return }
|
hoverLastLocation == nil else { return }
|
||||||
relativeCursorPoint = point
|
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,
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
||||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
let recognizers = [gestureRecognizer, otherGestureRecognizer]
|
let recognizers = [gestureRecognizer, otherGestureRecognizer]
|
||||||
@@ -223,6 +265,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleTap(_ recognizer: UITapGestureRecognizer) {
|
@objc private func handleTap(_ recognizer: UITapGestureRecognizer) {
|
||||||
|
guard allowsPointerGesture(recognizer) else { return }
|
||||||
let location = recognizer.location(in: self)
|
let location = recognizer.location(in: self)
|
||||||
guard recognizer.state == .ended,
|
guard recognizer.state == .ended,
|
||||||
let point = pointerPoint(for: location) else { return }
|
let point = pointerPoint(for: location) else { return }
|
||||||
@@ -240,6 +283,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleRightTap(_ recognizer: UITapGestureRecognizer) {
|
@objc private func handleRightTap(_ recognizer: UITapGestureRecognizer) {
|
||||||
|
guard allowsPointerGesture(recognizer) else { return }
|
||||||
guard recognizer.state == .ended,
|
guard recognizer.state == .ended,
|
||||||
let point = pointerPoint(for: recognizer.location(in: self)) else { return }
|
let point = pointerPoint(for: recognizer.location(in: self)) else { return }
|
||||||
onMove?(point.0, point.1)
|
onMove?(point.0, point.1)
|
||||||
@@ -262,6 +306,12 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
|
@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 {
|
if controlMode == .relative {
|
||||||
handleRelativeHeldDrag(recognizer)
|
handleRelativeHeldDrag(recognizer)
|
||||||
return
|
return
|
||||||
@@ -289,6 +339,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
|
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
|
||||||
|
guard mouseInput?.hasVirtualButton != true else { pinchActive = false; return }
|
||||||
let focus = recognizer.location(in: self)
|
let focus = recognizer.location(in: self)
|
||||||
switch recognizer.state {
|
switch recognizer.state {
|
||||||
case .began:
|
case .began:
|
||||||
@@ -327,6 +378,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleWheel(_ recognizer: UIPanGestureRecognizer) {
|
@objc private func handleWheel(_ recognizer: UIPanGestureRecognizer) {
|
||||||
|
guard mouseInput?.hasVirtualButton != true else { viewportPanActive = false; return }
|
||||||
if viewportScale > 1.001 || pinchActive {
|
if viewportScale > 1.001 || pinchActive {
|
||||||
switch recognizer.state {
|
switch recognizer.state {
|
||||||
case .began:
|
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 {
|
struct RemoteSessionView: View {
|
||||||
@ObservedObject var session: RemoteSessionModel
|
@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 keyboardInputVisible = false
|
||||||
@State private var keyboardMode: RemoteKeyboardMode = .system
|
@State private var keyboardMode: RemoteKeyboardMode = .system
|
||||||
@State private var viewport = RemoteViewportState()
|
@State private var viewport = RemoteViewportState()
|
||||||
@@ -81,6 +84,7 @@ struct RemoteSessionView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
RemoteTouchInputView(
|
RemoteTouchInputView(
|
||||||
|
mouseInput: mouseInput,
|
||||||
videoSize: session.displayGeometrySize,
|
videoSize: session.displayGeometrySize,
|
||||||
controlMode: session.mouseControlMode,
|
controlMode: session.mouseControlMode,
|
||||||
remoteCursorPosition: session.remoteCursorPosition,
|
remoteCursorPosition: session.remoteCursorPosition,
|
||||||
@@ -95,20 +99,24 @@ struct RemoteSessionView: View {
|
|||||||
offset: offset)
|
offset: offset)
|
||||||
},
|
},
|
||||||
move: { x, y in
|
move: { x, y in
|
||||||
|
mouseInput.position(x: x, y: y)
|
||||||
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
|
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
|
||||||
session.bridge.sendPointer(x: x, y: y, action: .move)
|
session.bridge.sendPointer(x: x, y: y, action: .move)
|
||||||
},
|
},
|
||||||
leftDown: { x, y in
|
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
|
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
|
rightClick: { x, y in
|
||||||
session.bridge.sendPointer(x: x, y: y, action: .rightDown)
|
session.bridge.sendPointer(x: x, y: y, action: .rightDown)
|
||||||
session.bridge.sendPointer(x: x, y: y, action: .rightUp)
|
session.bridge.sendPointer(x: x, y: y, action: .rightUp)
|
||||||
},
|
},
|
||||||
scroll: { x, y, dx, dy in
|
scroll: { x, y, dx, dy in
|
||||||
|
mouseInput.position(x: x, y: y)
|
||||||
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
|
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
|
||||||
session.bridge.sendScrollX(x, y: y,
|
session.bridge.sendScrollX(x, y: y,
|
||||||
deltaX: dx, deltaY: dy)
|
deltaX: dx, deltaY: dy)
|
||||||
@@ -160,7 +168,27 @@ struct RemoteSessionView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(session.remoteUpdateMessage ?? "")
|
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 {
|
.onDisappear {
|
||||||
|
mouseInput.releaseAll()
|
||||||
|
mouseInput.send = nil
|
||||||
|
virtualMouseVisible = false
|
||||||
keyboardInputVisible = false
|
keyboardInputVisible = false
|
||||||
viewport = RemoteViewportState()
|
viewport = RemoteViewportState()
|
||||||
cursorPosition = nil
|
cursorPosition = nil
|
||||||
@@ -169,14 +197,27 @@ struct RemoteSessionView: View {
|
|||||||
keyboardPanelCenter = nil
|
keyboardPanelCenter = nil
|
||||||
}
|
}
|
||||||
.onChange(of: session.selectedDisplay) { _ in
|
.onChange(of: session.selectedDisplay) { _ in
|
||||||
|
mouseInput.releaseAll()
|
||||||
|
mouseInput.position(x: 0.5, y: 0.5)
|
||||||
cursorPosition = nil
|
cursorPosition = nil
|
||||||
viewport = RemoteViewportState()
|
viewport = RemoteViewportState()
|
||||||
}
|
}
|
||||||
.onChange(of: session.mouseControlMode) { _ in
|
.onChange(of: session.mouseControlMode) { _ in
|
||||||
|
mouseInput.releaseAll()
|
||||||
cursorPosition = session.remoteCursorPosition
|
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
|
.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
|
cursorPosition = position
|
||||||
}
|
}
|
||||||
.fileImporter(isPresented: $showingFileImporter,
|
.fileImporter(isPresented: $showingFileImporter,
|
||||||
@@ -271,17 +312,16 @@ struct RemoteSessionView: View {
|
|||||||
y: restingOrbCenter.y + statusOrbDragTranslation.height),
|
y: restingOrbCenter.y + statusOrbDragTranslation.height),
|
||||||
in: containerSize
|
in: containerSize
|
||||||
)
|
)
|
||||||
let panelSize = CGSize(
|
let menuColumns = containerSize.height < 400 && containerSize.width >= 400 ? 4 : 3
|
||||||
width: min(340, max(280, containerSize.width - 160)),
|
let panelWidth = min(max(1, containerSize.width - 16), menuColumns == 4
|
||||||
height: min(310, max(0, containerSize.height - 16))
|
? 380 : min(340, max(280, containerSize.width - 160)))
|
||||||
)
|
|
||||||
let panelCenter = floatingPanelCenter(
|
|
||||||
orbCenter: orbCenter,
|
|
||||||
panelSize: panelSize,
|
|
||||||
containerSize: containerSize
|
|
||||||
)
|
|
||||||
|
|
||||||
ZStack(alignment: .topLeading) {
|
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 {
|
if statusMenuVisible {
|
||||||
Color.clear
|
Color.clear
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
@@ -292,13 +332,18 @@ struct RemoteSessionView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position a stable container, then animate only the panel
|
FloatingMenuLayout(orbCenter: orbCenter, panelWidth: panelWidth) {
|
||||||
// inside it. Scaling a view after .position also scales its
|
|
||||||
// full-screen positioning space and makes the panel drift.
|
|
||||||
ZStack {
|
|
||||||
if statusMenuVisible {
|
if statusMenuVisible {
|
||||||
FloatingSessionMenu(
|
FloatingSessionMenu(
|
||||||
session: session,
|
session: session,
|
||||||
|
columnCount: menuColumns,
|
||||||
|
virtualMouseVisible: virtualMouseVisible,
|
||||||
|
toggleMouse: {
|
||||||
|
mouseInput.releaseAll()
|
||||||
|
virtualMouseVisible.toggle()
|
||||||
|
statusMenuVisible = false
|
||||||
|
},
|
||||||
|
releaseMouse: mouseInput.releaseAll,
|
||||||
showKeyboard: {
|
showKeyboard: {
|
||||||
statusMenuVisible = false
|
statusMenuVisible = false
|
||||||
keyboardInputVisible.toggle()
|
keyboardInputVisible.toggle()
|
||||||
@@ -313,6 +358,7 @@ struct RemoteSessionView: View {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
disconnect: {
|
disconnect: {
|
||||||
|
mouseInput.releaseAll()
|
||||||
statusMenuVisible = false
|
statusMenuVisible = false
|
||||||
keyboardInputVisible = false
|
keyboardInputVisible = false
|
||||||
withAnimation(.easeOut(duration: 0.18)) {
|
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)
|
.transition(.scale(scale: 0.94, anchor: .topTrailing)
|
||||||
.combined(with: .opacity))
|
.combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: panelSize.width, height: panelSize.height)
|
.frame(width: containerSize.width, height: containerSize.height)
|
||||||
.position(panelCenter)
|
|
||||||
.allowsHitTesting(statusMenuVisible)
|
.allowsHitTesting(statusMenuVisible)
|
||||||
|
|
||||||
CrossDeskStatusOrb(isExpanded: statusMenuVisible)
|
CrossDeskStatusOrb(isExpanded: statusMenuVisible)
|
||||||
@@ -334,6 +378,7 @@ struct RemoteSessionView: View {
|
|||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
.position(orbCenter)
|
.position(orbCenter)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
|
mouseInput.releaseAll()
|
||||||
withAnimation(.spring(response: 0.24,
|
withAnimation(.spring(response: 0.24,
|
||||||
dampingFraction: 0.84)) {
|
dampingFraction: 0.84)) {
|
||||||
statusMenuVisible.toggle()
|
statusMenuVisible.toggle()
|
||||||
@@ -345,6 +390,7 @@ struct RemoteSessionView: View {
|
|||||||
state = value.translation
|
state = value.translation
|
||||||
}
|
}
|
||||||
.onChanged { _ in
|
.onChanged { _ in
|
||||||
|
mouseInput.releaseAll()
|
||||||
if statusMenuVisible {
|
if statusMenuVisible {
|
||||||
statusMenuVisible = false
|
statusMenuVisible = false
|
||||||
}
|
}
|
||||||
@@ -359,9 +405,40 @@ struct RemoteSessionView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(width: containerSize.width, height: containerSize.height)
|
.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 {
|
private var draggableKeyboard: some View {
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
let containerSize = proxy.size
|
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 {
|
private var visibleCursorShape: Int {
|
||||||
guard session.hasRemoteCursorState,
|
guard session.hasRemoteCursorState,
|
||||||
session.remoteCursorVisible,
|
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 {
|
private struct FloatingSessionMenu: View {
|
||||||
@ObservedObject var session: RemoteSessionModel
|
@ObservedObject var session: RemoteSessionModel
|
||||||
|
let virtualMouseVisible: Bool
|
||||||
|
let toggleMouse: () -> Void
|
||||||
|
let releaseMouse: () -> Void
|
||||||
private enum Page { case controls, videoSettings, networkStats }
|
private enum Page { case controls, videoSettings, networkStats }
|
||||||
@State private var page: Page = .controls
|
@State private var page: Page = .controls
|
||||||
@State private var displayedNetworkSnapshot: RemoteNetworkSnapshot
|
@State private var displayedNetworkSnapshot: RemoteNetworkSnapshot
|
||||||
@@ -1090,15 +1180,25 @@ private struct FloatingSessionMenu: View {
|
|||||||
let close: () -> Void
|
let close: () -> Void
|
||||||
let disconnect: () -> Void
|
let disconnect: () -> Void
|
||||||
|
|
||||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: 7),
|
let columnCount: Int
|
||||||
count: 3)
|
private var columns: [GridItem] {
|
||||||
|
Array(repeating: GridItem(.flexible(), spacing: 7), count: columnCount)
|
||||||
|
}
|
||||||
|
|
||||||
init(session: RemoteSessionModel,
|
init(session: RemoteSessionModel,
|
||||||
|
columnCount: Int,
|
||||||
|
virtualMouseVisible: Bool,
|
||||||
|
toggleMouse: @escaping () -> Void,
|
||||||
|
releaseMouse: @escaping () -> Void,
|
||||||
showKeyboard: @escaping () -> Void,
|
showKeyboard: @escaping () -> Void,
|
||||||
chooseFile: @escaping () -> Void,
|
chooseFile: @escaping () -> Void,
|
||||||
close: @escaping () -> Void,
|
close: @escaping () -> Void,
|
||||||
disconnect: @escaping () -> Void) {
|
disconnect: @escaping () -> Void) {
|
||||||
self.session = session
|
self.session = session
|
||||||
|
self.columnCount = columnCount
|
||||||
|
self.virtualMouseVisible = virtualMouseVisible
|
||||||
|
self.toggleMouse = toggleMouse
|
||||||
|
self.releaseMouse = releaseMouse
|
||||||
self.showKeyboard = showKeyboard
|
self.showKeyboard = showKeyboard
|
||||||
self.chooseFile = chooseFile
|
self.chooseFile = chooseFile
|
||||||
self.close = close
|
self.close = close
|
||||||
@@ -1110,145 +1210,11 @@ private struct FloatingSessionMenu: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 9) {
|
// Prefer the full natural-height panel. Only use a scrolling body when
|
||||||
HStack(spacing: 8) {
|
// the available safe-area height cannot contain all of its content.
|
||||||
if page != .controls {
|
ViewThatFits(in: .vertical) {
|
||||||
Button {
|
panel(scrolls: false).fixedSize(horizontal: false, vertical: true)
|
||||||
page = .controls
|
panel(scrolls: true)
|
||||||
} 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)
|
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(Color.white,
|
.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 {
|
private var statusDetail: String {
|
||||||
let snapshot = displayedNetworkSnapshot
|
let snapshot = displayedNetworkSnapshot
|
||||||
var components = [snapshot.connectionMode]
|
var components = [snapshot.connectionMode]
|
||||||
|
|||||||
@@ -327,6 +327,25 @@ Old previews saved without this choice are removed on upgrade.
|
|||||||
|
|
||||||
## Physical-device test checklist
|
## 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
|
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
|
build. Test the features in this order so a media problem is not confused with
|
||||||
a data-channel problem:
|
a data-channel problem:
|
||||||
|
|||||||
Reference in New Issue
Block a user