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