mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-16 17:10:20 +08:00
[fix] align Android viewport zoom and pan with iOS
This commit is contained in:
@@ -68,7 +68,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private AlertDialog remoteUpdateDialog;
|
||||
private RemoteVersionCheck remoteVersionCheck;
|
||||
private String host, connectedRemote="", hostPlatform="", remoteClipboard="", pendingPassword="", page="home";
|
||||
private int port;
|
||||
private int port,selectedDisplay;
|
||||
private JSONArray displays=new JSONArray();
|
||||
private boolean muted,ready,rememberPassword;
|
||||
private RemoteVideoSettings videoSettings=new RemoteVideoSettings(1);
|
||||
@@ -246,7 +246,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
resetRemoteVersionCheck();
|
||||
resetVideoSettings();
|
||||
previewCapture=previews.begin(serverKey(),id);previewCopying=false;previewAttempts=0;
|
||||
connectedRemote=id;pendingPassword=rememberPassword?secret:"";ready=false;muted=false;hostPlatform="";remoteClipboard="";displays=new JSONArray();preferences.edit().putString("lastRemote",id).apply();
|
||||
connectedRemote=id;pendingPassword=rememberPassword?secret:"";ready=false;muted=false;selectedDisplay=0;hostPlatform="";remoteClipboard="";displays=new JSONArray();preferences.edit().putString("lastRemote",id).apply();
|
||||
ensureSignaling();
|
||||
if(signaling==null){pendingPassword="";toast("请先完成联网授权");return;}
|
||||
session=signaling;showProgress();session.connect(id,secret);
|
||||
@@ -376,7 +376,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}}
|
||||
private void shortcut(int code){if(session!=null){int modifier=hostPlatform.equals("macos")?0x5B:0x11;session.key(modifier,true);tapKey(code);session.key(modifier,false);}}
|
||||
private void selectDisplay(){
|
||||
if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast("等待远端屏幕信息");return;}String[] labels=new String[count];for(int i=0;i<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?"显示器":display.optString("name","显示器"));}new AlertDialog.Builder(this).setTitle("显示器").setItems(labels,(d,w)->{if(session!=null){session.control(4,w);if(controls!=null)controls.resetVideoStatistics();}}).show();
|
||||
if(!ready)return;int count=Math.min(displays.length(),8);if(count==0){toast("等待远端屏幕信息");return;}String[] labels=new String[count];for(int i=0;i<count;i++){JSONObject display=displays.optJSONObject(i);labels[i]=(i+1)+" · "+(display==null?"显示器":display.optString("name","显示器"));}new AlertDialog.Builder(this).setTitle("显示器").setItems(labels,(d,w)->{if(session!=null){if(w!=selectedDisplay&&video!=null)video.resetViewport();selectedDisplay=w;session.control(4,w);if(controls!=null)controls.resetVideoStatistics();}}).show();
|
||||
}
|
||||
private void clipboard(){
|
||||
if(!ready)return;
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.os.Looper;
|
||||
import android.view.PixelCopy;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.ScaleGestureDetector;
|
||||
import android.view.SurfaceHolder;
|
||||
import android.view.SurfaceView;
|
||||
import android.view.View;
|
||||
@@ -23,10 +24,12 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
|
||||
private final Cursor cursor;
|
||||
private final NativeSession session;
|
||||
private final GestureDetector gestures;
|
||||
private final ScaleGestureDetector scales;
|
||||
private final RemoteViewport viewport = new RemoteViewport();
|
||||
private int frameWidth = 16, frameHeight = 9;
|
||||
private float x = .5f, y = .5f, scrollY;
|
||||
private boolean dragging, twoFinger, relative;
|
||||
private float lastX,lastY;
|
||||
private boolean dragging, twoFinger, relative, viewportGesture, viewportPanActive;
|
||||
private float lastX,lastY,twoFingerX,twoFingerY;
|
||||
void setRelative(boolean value) { relative=value; }
|
||||
RemoteVideoView(Context context, NativeSession session) {
|
||||
super(context); this.session = session;
|
||||
@@ -34,30 +37,57 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
|
||||
video = new SurfaceView(context); video.getHolder().addCallback(this);
|
||||
addView(video, new LayoutParams(1, 1, android.view.Gravity.CENTER));
|
||||
cursor = new Cursor(context);
|
||||
addView(cursor, new LayoutParams(1, 1, android.view.Gravity.CENTER));
|
||||
// Keep input in container coordinates so transforms never move its hit area.
|
||||
addView(cursor, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
|
||||
gestures = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
|
||||
@Override public boolean onDown(MotionEvent e) { point(e); return true; }
|
||||
@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 && 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 && point(e)) click(3, 4); }
|
||||
@Override public boolean onScroll(MotionEvent first, MotionEvent current, float dx, float dy) {
|
||||
if (twoFinger) return true;
|
||||
if (!relative && !dragging) { point(first); session.pointer(x, y, 1, 0); dragging = true; }
|
||||
if (!relative && !dragging) {
|
||||
if (!point(first)) return true;
|
||||
session.pointer(x, y, 1, 0); dragging = true;
|
||||
}
|
||||
point(current); return true;
|
||||
}
|
||||
});
|
||||
scales = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
@Override public boolean onScaleBegin(ScaleGestureDetector detector) {
|
||||
if (!viewport.contains(detector.getFocusX(), detector.getFocusY())) return false;
|
||||
viewportGesture = true;
|
||||
viewportPanActive = true;
|
||||
return true;
|
||||
}
|
||||
@Override public boolean onScale(ScaleGestureDetector detector) {
|
||||
viewport.zoomBy(detector.getScaleFactor(), detector.getFocusX(), detector.getFocusY());
|
||||
applyViewport();
|
||||
return true;
|
||||
}
|
||||
@Override public void onScaleEnd(ScaleGestureDetector detector) {
|
||||
viewport.finishGesture();
|
||||
applyViewport();
|
||||
}
|
||||
});
|
||||
scales.setQuickScaleEnabled(false);
|
||||
scales.setStylusScaleEnabled(false);
|
||||
cursor.setOnGenericMotionListener((v, event) -> {
|
||||
point(event);
|
||||
if (!point(event)) return true;
|
||||
if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) {
|
||||
float delta = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
|
||||
if (delta != 0) session.pointer(x, y, 7, delta > 0 ? 1 : -1);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
setContentDescription("远程桌面:轻触点击,拖动移动,长按右键,双指上下滑动滚动");
|
||||
setContentDescription("远程桌面:轻触点击,拖动移动,长按右键,双指捏合缩放,放大后双指拖动画面,原始比例下双指上下滑动滚动");
|
||||
}
|
||||
void videoSize(int width, int height) { frameWidth = width; frameHeight = height; fit(); }
|
||||
void videoSize(int width, int height) {
|
||||
if (width <= 0 || height <= 0) return;
|
||||
frameWidth = width; frameHeight = height; fit();
|
||||
}
|
||||
void resetViewport() { 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);
|
||||
@@ -76,54 +106,92 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
|
||||
}
|
||||
private void fit() {
|
||||
if (getWidth() == 0 || getHeight() == 0) return;
|
||||
double scale = Math.min((double)getWidth()/frameWidth, (double)getHeight()/frameHeight);
|
||||
int w = Math.max(1,(int)(frameWidth*scale)), h = Math.max(1,(int)(frameHeight*scale));
|
||||
video.setLayoutParams(new LayoutParams(w,h,android.view.Gravity.CENTER));
|
||||
cursor.setLayoutParams(new LayoutParams(w,h,android.view.Gravity.CENTER));
|
||||
viewport.fit(getWidth(), getHeight(), frameWidth, frameHeight);
|
||||
video.setLayoutParams(new LayoutParams(viewport.baseWidth(),viewport.baseHeight(),android.view.Gravity.CENTER));
|
||||
applyViewport();
|
||||
}
|
||||
private void applyViewport() {
|
||||
// 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());
|
||||
cursor.invalidate();
|
||||
}
|
||||
@Override protected void onSizeChanged(int w,int h,int oldw,int oldh) { post(this::fit); }
|
||||
private void point(MotionEvent e) {
|
||||
private boolean point(MotionEvent e) {
|
||||
boolean touchpad = relative && e.getToolType(0) != MotionEvent.TOOL_TYPE_MOUSE;
|
||||
float px=e.getX(),py=e.getY();
|
||||
if (!touchpad && !viewport.contains(px,py)) return false;
|
||||
if(touchpad){
|
||||
if(e.getActionMasked()==MotionEvent.ACTION_MOVE){
|
||||
x=Math.max(0,Math.min(1,x+(px-lastX)/Math.max(1,cursor.getWidth())));
|
||||
y=Math.max(0,Math.min(1,y+(py-lastY)/Math.max(1,cursor.getHeight())));
|
||||
x=Math.max(0,Math.min(1,x+(px-lastX)/Math.max(1,viewport.width())));
|
||||
y=Math.max(0,Math.min(1,y+(py-lastY)/Math.max(1,viewport.height())));
|
||||
}
|
||||
}else{
|
||||
x=Math.max(0,Math.min(1,px/Math.max(1,cursor.getWidth())));
|
||||
y=Math.max(0,Math.min(1,py/Math.max(1,cursor.getHeight())));
|
||||
x=viewport.normalizedX(px);
|
||||
y=viewport.normalizedY(py);
|
||||
}
|
||||
lastX=px;lastY=py;
|
||||
session.pointer(x,y,0,0); cursor.invalidate();
|
||||
return true;
|
||||
}
|
||||
private void click(int down, int up) { session.pointer(x,y,down,0); session.pointer(x,y,up,0); }
|
||||
private boolean touch(MotionEvent e) {
|
||||
int action = e.getActionMasked();
|
||||
if (action == MotionEvent.ACTION_DOWN) twoFinger = false;
|
||||
if (action == MotionEvent.ACTION_DOWN) {
|
||||
twoFinger = viewportGesture = viewportPanActive = false;
|
||||
}
|
||||
if (action == MotionEvent.ACTION_POINTER_DOWN) {
|
||||
if (dragging) { session.pointer(x,y,2,0); dragging = false; }
|
||||
twoFinger = true; scrollY = e.getY();
|
||||
twoFinger = true;
|
||||
twoFingerX = focus(e,true,-1); twoFingerY = scrollY = focus(e,false,-1);
|
||||
viewportPanActive = viewport.isZoomed() && viewport.contains(twoFingerX,twoFingerY);
|
||||
MotionEvent cancel = MotionEvent.obtain(e); cancel.setAction(MotionEvent.ACTION_CANCEL);
|
||||
gestures.onTouchEvent(cancel); cancel.recycle();
|
||||
}
|
||||
if (twoFinger && action == MotionEvent.ACTION_MOVE) {
|
||||
float delta = e.getY() - scrollY;
|
||||
if (Math.abs(delta) > 16 * getResources().getDisplayMetrics().density) {
|
||||
session.pointer(x,y,7,delta > 0 ? 1 : -1); scrollY = e.getY();
|
||||
scales.onTouchEvent(e);
|
||||
if (twoFinger && action == MotionEvent.ACTION_MOVE && e.getPointerCount() >= 2) {
|
||||
float focusX = focus(e,true,-1), focusY = focus(e,false,-1);
|
||||
if (viewport.isZoomed() || viewportGesture || scales.isInProgress()) {
|
||||
viewportGesture = true;
|
||||
if (!viewportPanActive) viewportPanActive = viewport.contains(focusX,focusY);
|
||||
if (viewportPanActive) {
|
||||
viewport.panBy(focusX-twoFingerX,focusY-twoFingerY);
|
||||
applyViewport();
|
||||
}
|
||||
} else if (viewport.contains(focusX,focusY)) {
|
||||
float delta = focusY - scrollY;
|
||||
if (Math.abs(delta) > 16 * getResources().getDisplayMetrics().density) {
|
||||
session.pointer(x,y,7,delta > 0 ? 1 : -1); scrollY = focusY;
|
||||
}
|
||||
}
|
||||
twoFingerX = focusX; twoFingerY = focusY;
|
||||
} else if (!twoFinger) {
|
||||
if ((e.getButtonState() & MotionEvent.BUTTON_SECONDARY) != 0 && action == MotionEvent.ACTION_DOWN) {
|
||||
point(e); click(3,4); return true;
|
||||
if (point(e)) click(3,4); return true;
|
||||
}
|
||||
gestures.onTouchEvent(e);
|
||||
}
|
||||
if (action == MotionEvent.ACTION_POINTER_UP) {
|
||||
// Rebase when a finger lifts; never resume a click/drag until all lift.
|
||||
twoFingerX = focus(e,true,e.getActionIndex());
|
||||
twoFingerY = scrollY = focus(e,false,e.getActionIndex());
|
||||
}
|
||||
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
|
||||
if (dragging) session.pointer(x,y,2,0);
|
||||
dragging = false;
|
||||
viewport.finishGesture(); applyViewport();
|
||||
viewportPanActive = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private static float focus(MotionEvent event, boolean horizontal, int excludedIndex) {
|
||||
float total = 0; int count = 0;
|
||||
for (int i=0;i<event.getPointerCount();i++) {
|
||||
if (i==excludedIndex) continue;
|
||||
total += horizontal ? event.getX(i) : event.getY(i); count++;
|
||||
}
|
||||
return total / Math.max(1,count);
|
||||
}
|
||||
@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) { }
|
||||
@@ -150,7 +218,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
|
||||
@Override public boolean performClick() { super.performClick(); click(1, 2); return true; }
|
||||
@Override protected void onDraw(Canvas canvas) {
|
||||
int saved=canvas.save();
|
||||
canvas.translate(x*getWidth(),y*getHeight());
|
||||
canvas.translate(viewport.left()+x*viewport.width(),viewport.top()+y*viewport.height());
|
||||
float density=getResources().getDisplayMetrics().density;canvas.scale(density,density);
|
||||
// Anchor the arrow tip to the same normalized coordinate sent to the desktop.
|
||||
canvas.translate(-arrowWidth*.08f,-arrowHeight*.04f);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package cn.crossdesk.mobile;
|
||||
|
||||
/** Geometry shared by the video Surface, cursor and remote pointer input. */
|
||||
final class RemoteViewport {
|
||||
private static final float MAXIMUM_SCALE = 10;
|
||||
private int baseWidth, baseHeight, baseLeft, baseTop;
|
||||
private float scale = 1, offsetX, offsetY;
|
||||
|
||||
void fit(int width, int height, int videoWidth, int videoHeight) {
|
||||
if (width <= 0 || height <= 0 || videoWidth <= 0 || videoHeight <= 0) return;
|
||||
double fitScale = Math.min((double) width / videoWidth, (double) height / videoHeight);
|
||||
baseWidth = Math.max(1, (int) (videoWidth * fitScale));
|
||||
baseHeight = Math.max(1, (int) (videoHeight * fitScale));
|
||||
// Match the integer positioning of Gravity.CENTER, including odd margins.
|
||||
baseLeft = (width - baseWidth) / 2;
|
||||
baseTop = (height - baseHeight) / 2;
|
||||
constrainOffset();
|
||||
}
|
||||
|
||||
int baseWidth() { return baseWidth; }
|
||||
int baseHeight() { return baseHeight; }
|
||||
float scale() { return scale; }
|
||||
float offsetX() { return offsetX; }
|
||||
float offsetY() { return offsetY; }
|
||||
float width() { return baseWidth * scale; }
|
||||
float height() { return baseHeight * scale; }
|
||||
float left() { return baseLeft + baseWidth * (1 - scale) / 2 + offsetX; }
|
||||
float top() { return baseTop + baseHeight * (1 - scale) / 2 + offsetY; }
|
||||
boolean isZoomed() { return scale > 1.001f; }
|
||||
|
||||
boolean contains(float x, float y) {
|
||||
return baseWidth > 0 && baseHeight > 0 &&
|
||||
x >= left() && x < left() + width() && y >= top() && y < top() + height();
|
||||
}
|
||||
|
||||
float normalizedX(float x) { return clamp((x - left()) / Math.max(1, width()), 0, 1); }
|
||||
float normalizedY(float y) { return clamp((y - top()) / Math.max(1, height()), 0, 1); }
|
||||
|
||||
void zoomBy(float factor, float focusX, float focusY) {
|
||||
if (baseWidth == 0 || baseHeight == 0 || !Float.isFinite(factor) || factor <= 0) return;
|
||||
float nextScale = clamp(scale * factor, 1, MAXIMUM_SCALE);
|
||||
float ratio = nextScale / scale;
|
||||
float focusFromCenterX = focusX - (baseLeft + baseWidth / 2f);
|
||||
float focusFromCenterY = focusY - (baseTop + baseHeight / 2f);
|
||||
offsetX = focusFromCenterX - (focusFromCenterX - offsetX) * ratio;
|
||||
offsetY = focusFromCenterY - (focusFromCenterY - offsetY) * ratio;
|
||||
scale = nextScale;
|
||||
constrainOffset();
|
||||
}
|
||||
|
||||
void panBy(float dx, float dy) {
|
||||
offsetX += dx;
|
||||
offsetY += dy;
|
||||
constrainOffset();
|
||||
}
|
||||
|
||||
void finishGesture() {
|
||||
if (!isZoomed()) reset();
|
||||
}
|
||||
|
||||
void reset() { scale = 1; offsetX = offsetY = 0; }
|
||||
|
||||
private void constrainOffset() {
|
||||
// Keep the original fitted area covered, as on iOS, even with letterboxing.
|
||||
float maximumX = baseWidth * (scale - 1) / 2;
|
||||
float maximumY = baseHeight * (scale - 1) / 2;
|
||||
offsetX = clamp(offsetX, -maximumX, maximumX);
|
||||
offsetY = clamp(offsetY, -maximumY, maximumY);
|
||||
}
|
||||
|
||||
private static float clamp(float value, float minimum, float maximum) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user