feat(scrcpy): improve support for scroll and hover

This commit is contained in:
Simon Chan 2022-12-23 07:33:20 +08:00
parent a066bb4482
commit 076d67e210
6 changed files with 105 additions and 26 deletions

View file

@ -40,9 +40,11 @@ import {
AndroidKeyEventAction, AndroidKeyEventAction,
AndroidMotionEventAction, AndroidMotionEventAction,
AndroidScreenPowerMode, AndroidScreenPowerMode,
clamp,
CodecOptions, CodecOptions,
DEFAULT_SERVER_PATH, DEFAULT_SERVER_PATH,
ScrcpyDeviceMessageType, ScrcpyDeviceMessageType,
ScrcpyHoverHelper,
ScrcpyLogLevel, ScrcpyLogLevel,
ScrcpyOptions1_25, ScrcpyOptions1_25,
ScrcpyOptionsInit1_24, ScrcpyOptionsInit1_24,
@ -157,18 +159,6 @@ function fetchServer(
return cachedValue.promise; return cachedValue.promise;
} }
function clamp(value: number, min: number, max: number): number {
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
}
export interface H264Decoder extends Disposable { export interface H264Decoder extends Disposable {
readonly maxProfile: AndroidCodecProfile | undefined; readonly maxProfile: AndroidCodecProfile | undefined;
readonly maxLevel: AndroidCodecLevel | undefined; readonly maxLevel: AndroidCodecLevel | undefined;
@ -258,6 +248,7 @@ const useClasses = makeStyles({
}, },
video: { video: {
transformOrigin: "center center", transformOrigin: "center center",
touchAction: "none",
}, },
}); });
@ -369,6 +360,7 @@ class ScrcpyPageState {
} }
client: AdbScrcpyClient | undefined = undefined; client: AdbScrcpyClient | undefined = undefined;
hoverHelper: ScrcpyHoverHelper | undefined = undefined;
async pushServer() { async pushServer() {
const serverBuffer = await fetchServer(); const serverBuffer = await fetchServer();
@ -891,6 +883,7 @@ class ScrcpyPageState {
handlePointerDown: false, handlePointerDown: false,
handlePointerMove: false, handlePointerMove: false,
handlePointerUp: false, handlePointerUp: false,
handlePointerLeave: false,
handleWheel: false, handleWheel: false,
handleContextMenu: false, handleContextMenu: false,
handleKeyDown: false, handleKeyDown: false,
@ -1117,6 +1110,7 @@ class ScrcpyPageState {
runInAction(() => { runInAction(() => {
this.client = client; this.client = client;
this.hoverHelper = new ScrcpyHoverHelper();
this.running = true; this.running = true;
}); });
} catch (e: any) { } catch (e: any) {
@ -1301,37 +1295,46 @@ class ScrcpyPageState {
const { pointerType } = e; const { pointerType } = e;
let pointerId: bigint; let pointerId: bigint;
let { pressure } = e;
if (pointerType === "mouse") { if (pointerType === "mouse") {
// ScrcpyPointerId.Mouse doesn't work with Chrome browser // ScrcpyPointerId.Mouse doesn't work with Chrome browser
// https://github.com/Genymobile/scrcpy/issues/3635 // https://github.com/Genymobile/scrcpy/issues/3635
pointerId = ScrcpyPointerId.Finger; pointerId = ScrcpyPointerId.Finger;
pressure = pressure === 0 ? 0 : 1;
} else { } else {
pointerId = BigInt(e.pointerId); pointerId = BigInt(e.pointerId);
} }
const { x, y } = this.calculatePointerPosition(e.clientX, e.clientY); const { x, y } = this.calculatePointerPosition(e.clientX, e.clientY);
this.client!.controlMessageSerializer!.injectTouch({
const messages = this.hoverHelper!.process({
action, action,
pointerId, pointerId,
screenWidth: this.client!.screenWidth!, screenWidth: this.client.screenWidth!,
screenHeight: this.client!.screenHeight!, screenHeight: this.client.screenHeight!,
pointerX: x, pointerX: x,
pointerY: y, pointerY: y,
pressure, pressure: e.pressure,
buttons: e.buttons, buttons: e.buttons,
}); });
for (const message of messages) {
this.client.controlMessageSerializer!.injectTouch(message);
}
}; };
handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => { handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
this.rendererContainer!.focus(); this.rendererContainer!.focus();
e.preventDefault(); e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
this.injectTouch(AndroidMotionEventAction.Down, e); this.injectTouch(AndroidMotionEventAction.Down, e);
}; };
handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => { handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!this.client) {
return;
}
e.preventDefault();
e.stopPropagation();
this.injectTouch( this.injectTouch(
e.buttons === 0 e.buttons === 0
? AndroidMotionEventAction.HoverMove ? AndroidMotionEventAction.HoverMove
@ -1341,6 +1344,16 @@ class ScrcpyPageState {
}; };
handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => { handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
this.injectTouch(AndroidMotionEventAction.Up, e);
};
handlePointerLeave = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// Prevent hover state on device from "stucking" at the last position
this.injectTouch(AndroidMotionEventAction.HoverExit, e);
this.injectTouch(AndroidMotionEventAction.Up, e); this.injectTouch(AndroidMotionEventAction.Up, e);
}; };
@ -1358,8 +1371,8 @@ class ScrcpyPageState {
screenHeight: this.client!.screenHeight!, screenHeight: this.client!.screenHeight!,
pointerX: x, pointerX: x,
pointerY: y, pointerY: y,
scrollX: e.deltaX / 100, scrollX: -e.deltaX / 100,
scrollY: e.deltaY / 100, scrollY: -e.deltaY / 100,
buttons: 0, buttons: 0,
}); });
}; };
@ -1591,6 +1604,7 @@ const Scrcpy: NextPage = () => {
onPointerMove={state.handlePointerMove} onPointerMove={state.handlePointerMove}
onPointerUp={state.handlePointerUp} onPointerUp={state.handlePointerUp}
onPointerCancel={state.handlePointerUp} onPointerCancel={state.handlePointerUp}
onPointerLeave={state.handlePointerLeave}
onKeyDown={state.handleKeyDown} onKeyDown={state.handleKeyDown}
onContextMenu={state.handleContextMenu} onContextMenu={state.handleContextMenu}
/> />

View file

@ -0,0 +1,51 @@
import {
AndroidMotionEventAction,
type ScrcpyInjectTouchControlMessage,
} from "./inject-touch.js";
import { ScrcpyControlMessageType } from "./type.js";
/**
* On Android, touching the screen with a finger will disable mouse cursor.
* However, Scrcpy doesn't do that, and can inject two pointers at the same time.
* This can cause finger events to be "ignored" because mouse is still the primary pointer.
*
* This helper class injects an extra `ACTION_UP` event,
* so Scrcpy server can remove the previously hovering pointer.
*/
export class ScrcpyHoverHelper {
// AFAIK, only mouse and pen can have hover state
// and you can't have two mouses or pens.
private lastHoverMessage: ScrcpyInjectTouchControlMessage | undefined;
public process(
message: Omit<ScrcpyInjectTouchControlMessage, "type">
): ScrcpyInjectTouchControlMessage[] {
const result: ScrcpyInjectTouchControlMessage[] = [];
// A different pointer appeared,
// Cancel previously hovering pointer so Scrcpy server can free up the pointer ID.
if (
this.lastHoverMessage &&
this.lastHoverMessage.pointerId !== message.pointerId
) {
// TODO: Inject MotionEvent.ACTION_HOVER_EXIT
// From testing, it seems no App cares about this event.
result.push({
...this.lastHoverMessage,
action: AndroidMotionEventAction.Up,
});
this.lastHoverMessage = undefined;
}
if (message.action === AndroidMotionEventAction.HoverMove) {
// TODO: Inject MotionEvent.ACTION_HOVER_ENTER
this.lastHoverMessage = message as ScrcpyInjectTouchControlMessage;
}
(message as ScrcpyInjectTouchControlMessage).type =
ScrcpyControlMessageType.InjectTouch;
result.push(message as ScrcpyInjectTouchControlMessage);
return result;
}
}

View file

@ -1,4 +1,5 @@
export * from "./back-or-screen-on.js"; export * from "./back-or-screen-on.js";
export * from "./hover-helper.js";
export * from "./inject-keycode.js"; export * from "./inject-keycode.js";
export * from "./inject-scroll.js"; export * from "./inject-scroll.js";
export * from "./inject-text.js"; export * from "./inject-text.js";

View file

@ -31,6 +31,18 @@ export namespace ScrcpyPointerId {
export const VirtualFinger = BigInt(-4); export const VirtualFinger = BigInt(-4);
} }
export function clamp(value: number, min: number, max: number): number {
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
}
const Uint16Max = (1 << 16) - 1; const Uint16Max = (1 << 16) - 1;
const ScrcpyFloatToUint16NumberType: NumberFieldType = { const ScrcpyFloatToUint16NumberType: NumberFieldType = {
@ -41,7 +53,7 @@ const ScrcpyFloatToUint16NumberType: NumberFieldType = {
return value / Uint16Max; return value / Uint16Max;
}, },
serialize(dataView, offset, value, littleEndian) { serialize(dataView, offset, value, littleEndian) {
value = value * Uint16Max; value = clamp(value, 0, 1) * Uint16Max;
NumberFieldType.Uint16.serialize(dataView, offset, value, littleEndian); NumberFieldType.Uint16.serialize(dataView, offset, value, littleEndian);
}, },
}; };

View file

@ -40,18 +40,18 @@ export class ScrcpyScrollController1_16 implements ScrcpyScrollController {
let scrollY = 0; let scrollY = 0;
if (this.accumulatedX >= 1) { if (this.accumulatedX >= 1) {
scrollX = 1; scrollX = 1;
this.accumulatedX -= 1; this.accumulatedX = 0;
} else if (this.accumulatedX <= -1) { } else if (this.accumulatedX <= -1) {
scrollX = -1; scrollX = -1;
this.accumulatedX += 1; this.accumulatedX = 0;
} }
if (this.accumulatedY >= 1) { if (this.accumulatedY >= 1) {
scrollY = 1; scrollY = 1;
this.accumulatedY -= 1; this.accumulatedY = 0;
} else if (this.accumulatedY <= -1) { } else if (this.accumulatedY <= -1) {
scrollY = -1; scrollY = -1;
this.accumulatedY += 1; this.accumulatedY = 0;
} }
if (scrollX === 0 && scrollY === 0) { if (scrollX === 0 && scrollY === 0) {

View file

@ -4,6 +4,7 @@ import Struct, {
} from "@yume-chan/struct"; } from "@yume-chan/struct";
import { import {
clamp,
ScrcpyControlMessageType, ScrcpyControlMessageType,
type ScrcpyInjectScrollControlMessage, type ScrcpyInjectScrollControlMessage,
} from "../../control/index.js"; } from "../../control/index.js";
@ -19,7 +20,7 @@ const ScrcpyFloatToInt16NumberType: NumberFieldType = {
return value / Int16Max; return value / Int16Max;
}, },
serialize(dataView, offset, value, littleEndian) { serialize(dataView, offset, value, littleEndian) {
value = value * Int16Max; value = clamp(value, -1, 1) * Int16Max;
NumberFieldType.Int16.serialize(dataView, offset, value, littleEndian); NumberFieldType.Int16.serialize(dataView, offset, value, littleEndian);
}, },
}; };