[feat] add vertical pointer edge panning on mobile

This commit is contained in:
dijunkun
2026-10-18 22:02:17 +08:00
parent b948422bfe
commit acd56dfc29
7 changed files with 138 additions and 21 deletions
@@ -3,6 +3,8 @@ 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); }
interface RelativeMotion { void move(float dx, float dy); }
RelativeMotion relativeMotion;
private final Sender sender;
private float x = .5f, y = .5f;
private boolean gestureLeft, virtualLeft, virtualRight;
@@ -19,7 +21,8 @@ final class RemoteMouseInput {
}
void drag(float dx, float dy) {
if (!hasVirtualButton() || viewportWidth <= 0 || viewportHeight <= 0) return;
move(x + dx / viewportWidth, y + dy / viewportHeight);
if (relativeMotion != null) relativeMotion.move(dx, dy);
else move(x + dx / viewportWidth, y + dy / viewportHeight);
}
void gestureLeft(boolean down) {
if (down && hasVirtualButton()) return;
@@ -51,6 +51,7 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
RemoteVideoView(Context context, NativeSession session) {
super(context); this.session = session;
mouseInput = new RemoteMouseInput(this::sendMouse);
mouseInput.relativeMotion = this::moveRelative;
setBackgroundColor(Color.BLACK);
video = new SurfaceView(context); video.getHolder().addCallback(this);
addView(video, new LayoutParams(1, 1, android.view.Gravity.CENTER));
@@ -147,14 +148,21 @@ final class RemoteVideoView extends FrameLayout implements SurfaceHolder.Callbac
cursor.invalidate();
}
@Override protected void onSizeChanged(int w,int h,int oldw,int oldh) { post(this::fit); }
private void moveRelative(float dx, float dy) {
if (pictureInPicture || viewport.width() <= 0 || viewport.height() <= 0) return;
viewport.panForPointer(y, dy);
applyViewport();
mouseInput.move(x + dx / viewport.width(), y + dy / viewport.height());
}
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,viewport.width())));
y=Math.max(0,Math.min(1,y+(py-lastY)/Math.max(1,viewport.height())));
moveRelative(px-lastX,py-lastY);
lastX=px;lastY=py;
return true;
}
}else{
x=viewport.normalizedX(px);
@@ -3,11 +3,14 @@ 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 static final float MINIMUM_VISIBLE_HEIGHT_FRACTION = .2f;
private int baseWidth, baseHeight, baseLeft, baseTop, containerHeight;
private boolean edgePanned;
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;
containerHeight = height;
double fitScale = Math.min((double) width / videoWidth, (double) height / videoHeight);
baseWidth = Math.max(1, (int) (videoWidth * fitScale));
baseHeight = Math.max(1, (int) (videoHeight * fitScale));
@@ -54,18 +57,46 @@ final class RemoteViewport {
constrainOffset();
}
void finishGesture() {
if (!isZoomed()) reset();
void panForPointer(float y, float dy) {
if (baseHeight == 0 || !Float.isFinite(dy)) return;
float overflowY = pointerOverflow(top() + y * height(), dy,
Math.max(0, top()), Math.min(containerHeight, top() + height()));
if (overflowY == 0) return;
edgePanned = true;
panBy(0, -overflowY);
}
void reset() { scale = 1; offsetX = offsetY = 0; }
// Only the outward portion of this movement pans; an offscreen cursor must
// not make a tiny movement jump the viewport by its entire hidden distance.
private static float pointerOverflow(float position, float delta, float low, float high) {
if (delta > 0) return Math.min(delta, Math.max(0, position + delta - high));
if (delta < 0) return Math.max(delta, Math.min(0, position + delta - low));
return 0;
}
void finishGesture() {
if (!isZoomed()) {
scale = 1;
constrainOffset();
}
}
void reset() { scale = 1; offsetX = offsetY = 0; edgePanned = false; }
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;
float minimumY = -baseHeight * (scale - 1) / 2;
float maximumY = -minimumY;
if (edgePanned) {
// Reserve 20% of the viewport height regardless of zoom. If the
// whole image is shorter than that, keep its full height visible.
float visibleHeight = Math.min(height(), containerHeight * MINIMUM_VISIBLE_HEIGHT_FRACTION);
float centerY = baseTop + baseHeight / 2f;
minimumY = visibleHeight - centerY - height() / 2;
maximumY = containerHeight - visibleHeight - centerY + height() / 2;
}
offsetX = clamp(offsetX, -maximumX, maximumX);
offsetY = clamp(offsetY, -maximumY, maximumY);
offsetY = clamp(offsetY, minimumY, maximumY);
}
private static float clamp(float value, float minimum, float maximum) {