Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit d0a8257a authored by Simon Chan's avatar Simon Chan
Browse files

feat(scrcpy): rotate device control message

ref #409
parent e8c7b599
Loading
Loading
Loading
Loading
+83 −11
Original line number Diff line number Diff line
import { CommandBar, Dialog, Dropdown, ICommandBarItemProps, Icon, IconButton, IDropdownOption, LayerHost, Position, ProgressIndicator, SpinButton, Stack, Toggle, TooltipHost } from "@fluentui/react";
import { useId } from "@fluentui/react-hooks";
import { makeStyles } from "@griffel/react";
import { action, autorun, makeAutoObservable, observable, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
@@ -207,6 +208,10 @@ class ScrcpyPageState {

    width = 0;
    height = 0;
    rotate = 0;

    get rotatedWidth() { return state.rotate & 1 ? state.height : state.width; }
    get rotatedHeight() { return state.rotate & 1 ? state.width : state.height; }

    client: ScrcpyClient | undefined = undefined;

@@ -252,10 +257,45 @@ class ScrcpyPageState {
            key: 'fullscreen',
            disabled: !this.running,
            iconProps: { iconName: Icons.FullScreenMaximize },
            iconOnly: true,
            text: 'Fullscreen',
            onClick: () => { this.deviceView?.enterFullscreen(); },
        });

        result.push({
            key: 'rotateDevice',
            disabled: !this.running,
            iconProps: { iconName: Icons.Orientation },
            iconOnly: true,
            text: 'Rotate Device',
            onClick: () => { this.client!.rotateDevice(); },
        });

        result.push({
            key: 'rotateVideoLeft',
            disabled: !this.running,
            iconProps: { iconName: Icons.RotateLeft },
            iconOnly: true,
            text: 'Rotate Video Left',
            onClick: () => {
                this.rotate -= 1;
                if (this.rotate < 0) {
                    this.rotate = 3;
                }
            }
        });

        result.push({
            key: 'rotateVideoRight',
            disabled: !this.running,
            iconProps: { iconName: Icons.RotateRight },
            iconOnly: true,
            text: 'Rotate Video Right',
            onClick: () => {
                this.rotate = (this.rotate + 1) & 3;
            },
        });

        return result;
    }

@@ -266,6 +306,7 @@ class ScrcpyPageState {
                iconProps: { iconName: Icons.PanelBottom },
                checked: this.navigationBarVisible,
                text: 'Navigation Bar',
                iconOnly: true,
                onClick: action(() => {
                    this.navigationBarVisible = !this.navigationBarVisible;
                }),
@@ -275,6 +316,7 @@ class ScrcpyPageState {
                iconProps: { iconName: Icons.TextGrammarError },
                checked: this.logVisible,
                text: 'Log',
                iconOnly: true,
                onClick: action(() => {
                    this.logVisible = !this.logVisible;
                }),
@@ -284,6 +326,7 @@ class ScrcpyPageState {
                iconProps: { iconName: Icons.Settings },
                checked: this.settingsVisible,
                text: 'Settings',
                iconOnly: true,
                onClick: action(() => {
                    this.settingsVisible = !this.settingsVisible;
                }),
@@ -293,6 +336,7 @@ class ScrcpyPageState {
                iconProps: { iconName: Icons.Wand },
                checked: this.demoModeVisible,
                text: 'Demo Mode',
                iconOnly: true,
                onClick: action(() => {
                    this.demoModeVisible = !this.demoModeVisible;
                }),
@@ -727,15 +771,29 @@ class ScrcpyPageState {
    };

    calculatePointerPosition(clientX: number, clientY: number) {
        const view = this.rendererContainer!.getBoundingClientRect();
        const pointerViewX = clientX - view.x;
        const pointerViewY = clientY - view.y;
        const pointerScreenX = clamp(pointerViewX / view.width, 0, 1) * this.width;
        const pointerScreenY = clamp(pointerViewY / view.height, 0, 1) * this.height;
        const viewRect = this.rendererContainer!.getBoundingClientRect();
        let pointerViewX = clamp((clientX - viewRect.x) / viewRect.width, 0, 1);
        let pointerViewY = clamp((clientY - viewRect.y) / viewRect.height, 0, 1);

        if (this.rotate & 1) {
            ([pointerViewX, pointerViewY] = [pointerViewY, pointerViewX]);
        }
        switch (this.rotate) {
            case 1:
                pointerViewY = 1 - pointerViewY;
                break;
            case 2:
                pointerViewX = 1 - pointerViewX;
                pointerViewY = 1 - pointerViewY;
                break;
            case 3:
                pointerViewX = 1 - pointerViewX;
                break;
        }

        return {
            x: pointerScreenX,
            y: pointerScreenY,
            x: pointerViewX * this.width,
            y: pointerViewY * this.height,
        };
    }

@@ -880,7 +938,7 @@ const ConnectionDialog = observer(() => {
    );
});

const NavigationBar = observer(({
const NavigationBar = observer(function NavigationBar({
    className,
    style,
    children
@@ -888,7 +946,7 @@ const NavigationBar = observer(({
    className: string;
    style: CSSProperties;
    children: ReactNode;
}) => {
    }) {
    if (!state.navigationBarVisible) {
        return null;
    }
@@ -920,7 +978,15 @@ const NavigationBar = observer(({
    );
});

const useClasses = makeStyles({
    video: {
        transformOrigin: 'center center',
    },
});

const Scrcpy: NextPage = () => {
    const classes = useClasses();

    return (
        <Stack {...RouteStackProps}>
            <Head>
@@ -932,13 +998,19 @@ const Scrcpy: NextPage = () => {
            <Stack horizontal grow styles={{ root: { height: 0 } }}>
                <DeviceView
                    ref={state.handleDeviceViewRef}
                    width={state.width}
                    height={state.height}
                    width={state.rotatedWidth}
                    height={state.rotatedHeight}
                    BottomElement={NavigationBar}
                >
                    <div
                        ref={state.handleRendererContainerRef}
                        tabIndex={-1}
                        className={classes.video}
                        style={{
                            width: state.width,
                            height: state.height,
                            transform: `translate(${(state.rotatedWidth - state.width) / 2}px, ${(state.rotatedHeight - state.height) / 2}px) rotate(${state.rotate * 90}deg)`
                        }}
                        onPointerDown={state.handlePointerDown}
                        onPointerMove={state.handlePointerMove}
                        onPointerUp={state.handlePointerUp}
+7 −1
Original line number Diff line number Diff line
import { registerIcons } from "@fluentui/react";
import { FilterRegular, AddCircleRegular, ArrowClockwiseRegular, ArrowSortDownRegular, ArrowSortUpRegular, BookmarkRegular, BoxRegular, BugRegular, CameraRegular, CheckmarkRegular, ChevronDownRegular, ChevronRightRegular, ChevronUpRegular, CircleRegular, CloudArrowDownRegular, CloudArrowUpRegular, CopyRegular, DeleteRegular, DocumentRegular, FolderRegular, FullScreenMaximizeRegular, InfoRegular, MoreHorizontalRegular, NavigationRegular, PanelBottomRegular, PersonFeedbackRegular, PhoneLaptopRegular, PhoneRegular, PlayRegular, PlugConnectedRegular, PlugDisconnectedRegular, PowerRegular, SaveRegular, SearchRegular, SettingsRegular, StopRegular, TextGrammarErrorRegular, WandRegular, WarningRegular, WifiSettingsRegular, WindowConsoleRegular } from '@fluentui/react-icons';
import { AddCircleRegular, ArrowClockwiseRegular, ArrowRotateClockwiseRegular, ArrowRotateCounterclockwiseRegular, ArrowSortDownRegular, ArrowSortUpRegular, BookmarkRegular, BoxRegular, BugRegular, CameraRegular, CheckmarkRegular, ChevronDownRegular, ChevronRightRegular, ChevronUpRegular, CircleRegular, CloudArrowDownRegular, CloudArrowUpRegular, CopyRegular, DeleteRegular, DocumentRegular, FilterRegular, FolderRegular, FullScreenMaximizeRegular, InfoRegular, MoreHorizontalRegular, NavigationRegular, OrientationRegular, PanelBottomRegular, PersonFeedbackRegular, PhoneLaptopRegular, PhoneRegular, PlayRegular, PlugConnectedRegular, PlugDisconnectedRegular, PowerRegular, SaveRegular, SearchRegular, SettingsRegular, StopRegular, TextGrammarErrorRegular, WandRegular, WarningRegular, WifiSettingsRegular, WindowConsoleRegular } from '@fluentui/react-icons';

const STYLE = {};

@@ -26,6 +26,7 @@ export function register() {
            FullScreenMaximize: <FullScreenMaximizeRegular style={STYLE} />,
            Info: <InfoRegular style={STYLE} />,
            Navigation: <NavigationRegular style={STYLE} />,
            Orientation: <OrientationRegular style={STYLE} />,
            PanelBottom: <PanelBottomRegular style={STYLE} />,
            PersonFeedback: <PersonFeedbackRegular style={STYLE} />,
            Phone: <PhoneRegular style={STYLE} />,
@@ -34,6 +35,8 @@ export function register() {
            PlugConnected: <PlugConnectedRegular style={STYLE} />,
            PlugDisconnected: <PlugDisconnectedRegular style={STYLE} />,
            Power: <PowerRegular style={STYLE} />,
            RotateLeft: <ArrowRotateCounterclockwiseRegular style={STYLE} />,
            RotateRight: <ArrowRotateClockwiseRegular style={STYLE} />,
            Save: <SaveRegular style={STYLE} />,
            Settings: <SettingsRegular style={STYLE} />,
            Stop: <StopRegular style={STYLE} />,
@@ -80,6 +83,7 @@ export default {
    FullScreenMaximize: 'FullScreenMaximize',
    Info: 'Info',
    Navigation: 'Navigation',
    Orientation: 'Orientation',
    PanelBottom: 'PanelBottom',
    PersonFeedback: 'PersonFeedback',
    Phone: 'Phone',
@@ -88,6 +92,8 @@ export default {
    PlugConnected: 'PlugConnected',
    PlugDisconnected: 'PlugDisconnected',
    Power: 'Power',
    RotateLeft: 'RotateLeft',
    RotateRight: 'RotateRight',
    Save: 'Save',
    Settings: 'Settings',
    Stop: 'Stop',
+27 −6
Original line number Diff line number Diff line
import { AdbBufferedStream, AdbSubprocessNoneProtocol, DecodeUtf8Stream, InspectStream, TransformStream, WritableStream, type Adb, type AdbSocket, type AdbSubprocessProtocol, type ReadableStream, type WritableStreamDefaultWriter } from '@yume-chan/adb';
import { EventEmitter } from '@yume-chan/event';
import Struct from '@yume-chan/struct';
import { AndroidMotionEventAction, ScrcpyControlMessageType, ScrcpyInjectKeyCodeControlMessage, ScrcpyInjectTextControlMessage, ScrcpyInjectTouchControlMessage, type AndroidKeyEventAction } from './message.js';
import { AndroidMotionEventAction, ScrcpyControlMessageType, ScrcpyInjectKeyCodeControlMessage, ScrcpyInjectTextControlMessage, ScrcpyInjectTouchControlMessage, ScrcpySimpleControlMessage, type AndroidKeyEventAction } from './message.js';
import type { ScrcpyInjectScrollControlMessage1_22, ScrcpyOptions, VideoStreamPacket } from "./options/index.js";

function* splitLines(text: string): Generator<string, void, void> {
@@ -195,12 +195,21 @@ export class ScrcpyClient {
        return this._controlStreamWriter;
    }

    private getControlMessageTypeValue(type: ScrcpyControlMessageType) {
        const list = this.options.getControlMessageTypes();
        const index = list.indexOf(type);
        if (index === -1) {
            throw new Error('Not supported');
        }
        return index;
    }

    public async injectKeyCode(message: Omit<ScrcpyInjectKeyCodeControlMessage, 'type'>) {
        const controlStream = this.checkControlStream('injectKeyCode');

        await controlStream.write(ScrcpyInjectKeyCodeControlMessage.serialize({
            ...message,
            type: ScrcpyControlMessageType.InjectKeycode,
            type: this.getControlMessageTypeValue(ScrcpyControlMessageType.InjectKeycode),
        }));
    }

@@ -208,7 +217,7 @@ export class ScrcpyClient {
        const controlStream = this.checkControlStream('injectText');

        await controlStream.write(ScrcpyInjectTextControlMessage.serialize({
            type: ScrcpyControlMessageType.InjectText,
            type: this.getControlMessageTypeValue(ScrcpyControlMessageType.InjectText),
            text,
        }));
    }
@@ -234,7 +243,7 @@ export class ScrcpyClient {
        this.lastTouchMessage = now;
        await controlStream.write(ScrcpyInjectTouchControlMessage.serialize({
            ...message,
            type: ScrcpyControlMessageType.InjectTouch,
            type: this.getControlMessageTypeValue(ScrcpyControlMessageType.InjectTouch),
            screenWidth: this.screenWidth,
            screenHeight: this.screenHeight,
        }));
@@ -249,7 +258,7 @@ export class ScrcpyClient {

        const buffer = this.options!.serializeInjectScrollControlMessage({
            ...message,
            type: ScrcpyControlMessageType.InjectScroll,
            type: this.getControlMessageTypeValue(ScrcpyControlMessageType.InjectScroll),
            screenWidth: this.screenWidth,
            screenHeight: this.screenHeight,
        });
@@ -260,7 +269,7 @@ export class ScrcpyClient {
        const controlStream = this.checkControlStream('pressBackOrTurnOnScreen');

        const buffer = this.options!.serializeBackOrScreenOnControlMessage({
            type: ScrcpyControlMessageType.BackOrScreenOn,
            type: this.getControlMessageTypeValue(ScrcpyControlMessageType.BackOrScreenOn),
            action,
        });
        if (buffer) {
@@ -268,6 +277,18 @@ export class ScrcpyClient {
        }
    }

    private async sendSimpleControlMessage(type: ScrcpyControlMessageType, name: string) {
        const controlStream = this.checkControlStream(name);
        const buffer = ScrcpySimpleControlMessage.serialize({
            type: this.getControlMessageTypeValue(type),
        });
        await controlStream.write(buffer);
    }

    public async rotateDevice() {
        await this.sendSimpleControlMessage(ScrcpyControlMessageType.RotateDevice, 'rotateDevice');
    }

    public async close() {
        // No need to close streams. Kill the process will destroy them from the other side.
        await this.process?.kill();
+10 −3
Original line number Diff line number Diff line
import Struct, { placeholder } from '@yume-chan/struct';

// https://github.com/Genymobile/scrcpy/blob/fa5b2a29e983a46b49531def9cf3d80c40c3de37/app/src/control_msg.h#L23
// For their message bodies, see https://github.com/Genymobile/scrcpy/blob/5c62f3419d252d10cd8c9cbb7c918b358b81f2d0/app/src/control_msg.c#L92
export enum ScrcpyControlMessageType {
    InjectKeycode,
    InjectText,
@@ -7,6 +9,7 @@ export enum ScrcpyControlMessageType {
    InjectScroll,
    BackOrScreenOn,
    ExpandNotificationPanel,
    ExpandSettingPanel,
    CollapseNotificationPanel,
    GetClipboard,
    SetClipboard,
@@ -31,9 +34,13 @@ export enum AndroidMotionEventAction {
    ButtonRelease,
}

export const ScrcpySimpleControlMessage =
    new Struct()
        .uint8('type');

export const ScrcpyInjectTouchControlMessage =
    new Struct()
        .uint8('type', ScrcpyControlMessageType.InjectTouch as const)
        .fields(ScrcpySimpleControlMessage)
        .uint8('action', placeholder<AndroidMotionEventAction>())
        .uint64('pointerId')
        .uint32('pointerX')
@@ -47,7 +54,7 @@ export type ScrcpyInjectTouchControlMessage = typeof ScrcpyInjectTouchControlMes

export const ScrcpyInjectTextControlMessage =
    new Struct()
        .uint8('type', ScrcpyControlMessageType.InjectText as const)
        .fields(ScrcpySimpleControlMessage)
        .uint32('length')
        .string('text', { lengthField: 'length' });

@@ -95,7 +102,7 @@ export enum AndroidKeyCode {

export const ScrcpyInjectKeyCodeControlMessage =
    new Struct()
        .uint8('type', ScrcpyControlMessageType.InjectKeycode as const)
        .fields(ScrcpySimpleControlMessage)
        .uint8('action', placeholder<AndroidKeyEventAction>())
        .uint32('keyCode')
        .uint32('repeat')
+20 −5
Original line number Diff line number Diff line
import { StructDeserializeStream, TransformStream, type Adb } from "@yume-chan/adb";
import Struct, { placeholder } from "@yume-chan/struct";
import Struct from "@yume-chan/struct";
import type { AndroidCodecLevel, AndroidCodecProfile } from "../../codec.js";
import { ScrcpyClientConnection, ScrcpyClientForwardConnection, ScrcpyClientReverseConnection, type ScrcpyClientConnectionOptions } from "../../connection.js";
import { AndroidKeyEventAction, ScrcpyControlMessageType } from "../../message.js";
import { AndroidKeyEventAction, ScrcpyControlMessageType, ScrcpySimpleControlMessage } from "../../message.js";
import type { ScrcpyBackOrScreenOnEvent1_18 } from "../1_18.js";
import type { ScrcpyInjectScrollControlMessage1_22 } from "../1_22.js";
import { toScrcpyOptionValue, type ScrcpyOptions, type ScrcpyOptionValue, type VideoStreamPacket } from "../common.js";
@@ -119,12 +119,11 @@ export const VideoPacket =
export const NO_PTS = BigInt(1) << BigInt(63);

export const ScrcpyBackOrScreenOnEvent1_16 =
    new Struct()
        .uint8('type', placeholder<ScrcpyControlMessageType.BackOrScreenOn>());
    ScrcpySimpleControlMessage;

export const ScrcpyInjectScrollControlMessage1_16 =
    new Struct()
        .uint8('type', ScrcpyControlMessageType.InjectScroll as const)
        .fields(ScrcpySimpleControlMessage)
        .uint32('pointerX')
        .uint32('pointerY')
        .uint16('screenWidth')
@@ -297,6 +296,22 @@ export class ScrcpyOptions1_16<T extends ScrcpyOptionsInit1_16 = ScrcpyOptionsIn
        };
    }

    public getControlMessageTypes(): ScrcpyControlMessageType[] {
        return [
            /*  0 */ ScrcpyControlMessageType.InjectKeycode,
            /*  1 */ ScrcpyControlMessageType.InjectText,
            /*  2 */ ScrcpyControlMessageType.InjectTouch,
            /*  3 */ ScrcpyControlMessageType.InjectScroll,
            /*  4 */ ScrcpyControlMessageType.BackOrScreenOn,
            /*  5 */ ScrcpyControlMessageType.ExpandNotificationPanel,
            /*  6 */ ScrcpyControlMessageType.CollapseNotificationPanel,
            /*  7 */ ScrcpyControlMessageType.GetClipboard,
            /*  8 */ ScrcpyControlMessageType.SetClipboard,
            /*  9 */ ScrcpyControlMessageType.SetScreenPowerMode,
            /* 10 */ ScrcpyControlMessageType.RotateDevice,
        ];
    }

    public serializeBackOrScreenOnControlMessage(
        message: ScrcpyBackOrScreenOnEvent1_18,
    ) {
Loading