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

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

feat(demo): improve Scrcpy command bar on small screen

parent 31268420
Loading
Loading
Loading
Loading
+20 −0
Original line number Diff line number Diff line
import { useEffect, useState } from "react";

export function CommandBarSpacerItem() {
    const [container, setContainer] = useState<HTMLDivElement | null>(null);

    useEffect(() => {
        if (!container) {
            return;
        }

        const parent = container.parentElement!;
        const originalFlexGrow = parent.style.flexGrow;
        parent.style.flexGrow = "1";
        return () => {
            parent.style.flexGrow = originalFlexGrow;
        };
    }, [container]);

    return <div ref={setContainer} />;
}
+294 −0
Original line number Diff line number Diff line
import {
    CommandBar,
    ContextualMenuItemType,
    ICommandBarItemProps,
} from "@fluentui/react";
import {
    AndroidKeyCode,
    AndroidKeyEventAction,
    AndroidScreenPowerMode,
} from "@yume-chan/scrcpy";
import { action, computed } from "mobx";
import { observer } from "mobx-react-lite";
import { GlobalState } from "../../state";
import { Icons } from "../../utils";
import { ExternalLink } from "../external-link";
import { CommandBarSpacerItem } from "./command-bar-spacer-item";
import { Recorder, STATE } from "./state";

const ITEMS = computed(() => {
    const result: ICommandBarItemProps[] = [];

    if (!STATE.running) {
        result.push({
            key: "start",
            disabled: !GlobalState.device,
            iconProps: { iconName: Icons.Play },
            text: "Start",
            onClick: STATE.start as VoidFunction,
        });
    } else {
        result.push({
            key: "stop",
            iconProps: { iconName: Icons.Stop },
            text: "Stop",
            onClick: STATE.stop as VoidFunction,
        });
    }

    result.push({
        key: "Record",
        disabled: !STATE.running,
        iconProps: { iconName: Icons.Record },
        text: "Record",
        onClick: () => Recorder.start(),
    });

    result.push({
        key: "fullscreen",
        disabled: !STATE.running,
        iconProps: { iconName: Icons.FullScreenMaximize },
        iconOnly: true,
        text: "Fullscreen",
        onClick: () => {
            STATE.deviceView?.enterFullscreen();
        },
    });

    result.push(
        {
            key: "volumeUp",
            disabled: !STATE.running,
            iconProps: { iconName: Icons.Speaker2 },
            iconOnly: true,
            text: "Volume Up",
            onClick: (async () => {
                // TODO: Auto repeat when holding
                await STATE.client?.controlMessageSerializer!.injectKeyCode({
                    action: AndroidKeyEventAction.Down,
                    keyCode: AndroidKeyCode.VolumeUp,
                    repeat: 0,
                    metaState: 0,
                });
                await STATE.client?.controlMessageSerializer!.injectKeyCode({
                    action: AndroidKeyEventAction.Up,
                    keyCode: AndroidKeyCode.VolumeUp,
                    repeat: 0,
                    metaState: 0,
                });
            }) as () => void,
        },
        {
            key: "volumeDown",
            disabled: !STATE.running,
            iconProps: { iconName: Icons.Speaker1 },
            iconOnly: true,
            text: "Volume Down",
            onClick: (async () => {
                await STATE.client?.controlMessageSerializer!.injectKeyCode({
                    action: AndroidKeyEventAction.Down,
                    keyCode: AndroidKeyCode.VolumeDown,
                    repeat: 0,
                    metaState: 0,
                });
                await STATE.client?.controlMessageSerializer!.injectKeyCode({
                    action: AndroidKeyEventAction.Up,
                    keyCode: AndroidKeyCode.VolumeDown,
                    repeat: 0,
                    metaState: 0,
                });
            }) as () => void,
        },
        {
            key: "volumeMute",
            disabled: !STATE.running,
            iconProps: { iconName: Icons.SpeakerOff },
            iconOnly: true,
            text: "Toggle Mute",
            onClick: (async () => {
                await STATE.client?.controlMessageSerializer!.injectKeyCode({
                    action: AndroidKeyEventAction.Down,
                    keyCode: AndroidKeyCode.VolumeMute,
                    repeat: 0,
                    metaState: 0,
                });
                await STATE.client?.controlMessageSerializer!.injectKeyCode({
                    action: AndroidKeyEventAction.Up,
                    keyCode: AndroidKeyCode.VolumeMute,
                    repeat: 0,
                    metaState: 0,
                });
            }) as () => void,
        }
    );

    result.push(
        {
            key: "rotateDevice",
            disabled: !STATE.running,
            iconProps: { iconName: Icons.Orientation },
            iconOnly: true,
            text: "Rotate Device",
            onClick: () => {
                STATE.client!.controlMessageSerializer!.rotateDevice();
            },
        },
        {
            key: "rotateVideoLeft",
            disabled: !STATE.running,
            iconProps: { iconName: Icons.RotateLeft },
            iconOnly: true,
            text: "Rotate Video Left",
            onClick: action(() => {
                STATE.rotation -= 1;
                if (STATE.rotation < 0) {
                    STATE.rotation = 3;
                }
            }),
        },
        {
            key: "rotateVideoRight",
            disabled: !STATE.running,
            iconProps: { iconName: Icons.RotateRight },
            iconOnly: true,
            text: "Rotate Video Right",
            onClick: action(() => {
                STATE.rotation = (STATE.rotation + 1) & 3;
            }),
        }
    );

    result.push(
        {
            key: "turnScreenOff",
            disabled: !STATE.running,
            iconProps: { iconName: Icons.Lightbulb },
            iconOnly: true,
            text: "Turn Screen Off",
            onClick: () => {
                STATE.client!.controlMessageSerializer!.setScreenPowerMode(
                    AndroidScreenPowerMode.Off
                );
            },
        },
        {
            key: "turnScreenOn",
            disabled: !STATE.running,
            iconProps: { iconName: Icons.LightbulbFilament },
            iconOnly: true,
            text: "Turn Screen On",
            onClick: () => {
                STATE.client!.controlMessageSerializer!.setScreenPowerMode(
                    AndroidScreenPowerMode.Normal
                );
            },
        }
    );

    if (STATE.running) {
        result.push({
            key: "fps",
            text: `FPS: ${STATE.fps}`,
            disabled: true,
        });
    }

    result.push(
        {
            // HACK: make CommandBar overflow on far items
            // https://github.com/microsoft/fluentui/issues/11842
            key: "spacer",
            onRender: () => <CommandBarSpacerItem />,
        },
        {
            // HACK: add a separator in CommandBar
            // https://github.com/microsoft/fluentui/issues/10035
            key: "separator",
            disabled: true,
            itemType: ContextualMenuItemType.Divider,
        }
    );

    result.push(
        {
            key: "NavigationBar",
            iconProps: { iconName: Icons.PanelBottom },
            canCheck: true,
            checked: STATE.navigationBarVisible,
            text: "Navigation Bar",
            iconOnly: true,
            onClick: action(() => {
                STATE.navigationBarVisible = !STATE.navigationBarVisible;
            }),
        },
        {
            key: "Log",
            iconProps: { iconName: Icons.TextGrammarError },
            canCheck: true,
            checked: STATE.logVisible,
            text: "Log",
            iconOnly: true,
            onClick: action(() => {
                STATE.logVisible = !STATE.logVisible;
            }),
        },
        {
            key: "Settings",
            iconProps: { iconName: Icons.Settings },
            canCheck: true,
            checked: STATE.settingsVisible,
            text: "Settings",
            iconOnly: true,
            onClick: action(() => {
                STATE.settingsVisible = !STATE.settingsVisible;
            }),
        },
        {
            key: "DemoMode",
            iconProps: { iconName: Icons.Wand },
            canCheck: true,
            checked: STATE.demoModeVisible,
            text: "Demo Mode",
            iconOnly: true,
            onClick: action(() => {
                STATE.demoModeVisible = !STATE.demoModeVisible;
            }),
        },
        {
            key: "info",
            iconProps: { iconName: Icons.Info },
            iconOnly: true,
            text: "About",
            tooltipHostProps: {
                content: (
                    <>
                        <p>
                            <ExternalLink
                                href="https://github.com/Genymobile/scrcpy"
                                spaceAfter
                            >
                                Scrcpy
                            </ExternalLink>
                            developed by Genymobile can display the screen with
                            low latency (1~2 frames) and control the device, all
                            without root access.
                        </p>
                        <p>
                            This is a TypeScript implementation of the client
                            part. Paired with official pre-built server binary.
                        </p>
                    </>
                ),
                calloutProps: {
                    calloutMaxWidth: 300,
                },
            },
        }
    );

    return result;
});

export const ScrcpyCommandBar = observer(function ScrcpyCommandBar() {
    return <CommandBar items={ITEMS.get()} />;
});
+83 −0
Original line number Diff line number Diff line
import { EventEmitter } from "@yume-chan/event";

const SERVER_URL = new URL(
    "@yume-chan/scrcpy/bin/scrcpy-server?url",
    import.meta.url
);

class FetchWithProgress {
    public readonly promise: Promise<Uint8Array>;

    private _downloaded = 0;
    public get downloaded() {
        return this._downloaded;
    }

    private _total = 0;
    public get total() {
        return this._total;
    }

    private progressEvent = new EventEmitter<
        [download: number, total: number]
    >();
    public get onProgress() {
        return this.progressEvent.event;
    }

    public constructor(url: string | URL) {
        this.promise = this.fetch(url);
    }

    private async fetch(url: string | URL) {
        const response = await window.fetch(url);
        this._total = Number.parseInt(
            response.headers.get("Content-Length") ?? "0",
            10
        );
        this.progressEvent.fire([this._downloaded, this._total]);

        const reader = response.body!.getReader();
        const chunks: Uint8Array[] = [];
        while (true) {
            const result = await reader.read();
            if (result.done) {
                break;
            }
            chunks.push(result.value);
            this._downloaded += result.value.byteLength;
            this.progressEvent.fire([this._downloaded, this._total]);
        }

        this._total = chunks.reduce(
            (result, item) => result + item.byteLength,
            0
        );
        const result = new Uint8Array(this._total);
        let position = 0;
        for (const chunk of chunks) {
            result.set(chunk, position);
            position += chunk.byteLength;
        }
        return result;
    }
}

let cachedValue: FetchWithProgress | undefined;
export function fetchServer(
    onProgress?: (e: [downloaded: number, total: number]) => void
) {
    if (!cachedValue) {
        cachedValue = new FetchWithProgress(SERVER_URL);
        cachedValue.promise.catch((e) => {
            cachedValue = undefined;
        });
    }

    if (onProgress) {
        cachedValue.onProgress(onProgress);
        onProgress([cachedValue.downloaded, cachedValue.total]);
    }

    return cachedValue.promise;
}
+6 −0
Original line number Diff line number Diff line
export * from "./command-bar";
export * from "./fetch-server";
export * from "./navigation-bar";
export * from "./settings";
export * from "./state";
export * from "./video-container";
+179 −0
Original line number Diff line number Diff line
import { IconButton, Stack } from "@fluentui/react";
import { makeStyles, mergeClasses } from "@griffel/react";
import { AndroidKeyCode, AndroidKeyEventAction } from "@yume-chan/scrcpy";
import { observer } from "mobx-react-lite";
import { CSSProperties, PointerEvent, ReactNode } from "react";
import { Icons } from "../../utils";
import { STATE } from "./state";

const useClasses = makeStyles({
    container: {
        height: "40px",
        backgroundColor: "#999",
    },
    bar: {
        width: "100%",
        maxWidth: "300px",
    },
    icon: {
        color: "white",
    },
    back: {
        transform: "rotate(180deg)",
    },
});

function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
    if (!STATE.client) {
        return false;
    }

    if (e.button !== 0) {
        return false;
    }

    e.currentTarget.setPointerCapture(e.pointerId);
    return true;
}

function handlePointerUp(e: PointerEvent<HTMLDivElement>) {
    if (!STATE.client) {
        return false;
    }

    if (e.button !== 0) {
        return false;
    }

    return true;
}

function handleBackPointerDown(e: PointerEvent<HTMLDivElement>) {
    if (!handlePointerDown(e)) {
        return;
    }

    STATE.client!.controlMessageSerializer!.backOrScreenOn(
        AndroidKeyEventAction.Down
    );
}

function handleBackPointerUp(e: PointerEvent<HTMLDivElement>) {
    if (!handlePointerUp(e)) {
        return;
    }

    STATE.client!.controlMessageSerializer!.backOrScreenOn(
        AndroidKeyEventAction.Up
    );
}

function handleHomePointerDown(e: PointerEvent<HTMLDivElement>) {
    if (!handlePointerDown(e)) {
        return;
    }

    STATE.client!.controlMessageSerializer!.injectKeyCode({
        action: AndroidKeyEventAction.Down,
        keyCode: AndroidKeyCode.Home,
        repeat: 0,
        metaState: 0,
    });
}

function handleHomePointerUp(e: PointerEvent<HTMLDivElement>) {
    if (!handlePointerUp(e)) {
        return;
    }

    STATE.client!.controlMessageSerializer!.injectKeyCode({
        action: AndroidKeyEventAction.Up,
        keyCode: AndroidKeyCode.Home,
        repeat: 0,
        metaState: 0,
    });
}

function handleAppSwitchPointerDown(e: PointerEvent<HTMLDivElement>) {
    if (!handlePointerDown(e)) {
        return;
    }

    STATE.client!.controlMessageSerializer!.injectKeyCode({
        action: AndroidKeyEventAction.Down,
        keyCode: AndroidKeyCode.AppSwitch,
        repeat: 0,
        metaState: 0,
    });
}

function handleAppSwitchPointerUp(e: PointerEvent<HTMLDivElement>) {
    if (!handlePointerUp(e)) {
        return;
    }

    STATE.client!.controlMessageSerializer!.injectKeyCode({
        action: AndroidKeyEventAction.Up,
        keyCode: AndroidKeyCode.AppSwitch,
        repeat: 0,
        metaState: 0,
    });
}

export const NavigationBar = observer(function NavigationBar({
    className,
    style,
    children,
}: {
    className: string;
    style: CSSProperties;
    children: ReactNode;
}) {
    const classes = useClasses();

    if (!STATE.navigationBarVisible) {
        return (
            <div className={className} style={style}>
                {children}
            </div>
        );
    }

    return (
        <Stack
            className={mergeClasses(classes.container, className)}
            verticalFill
            horizontalAlign="center"
            style={style}
        >
            {children}

            <Stack
                className={classes.bar}
                verticalFill
                horizontal
                horizontalAlign="space-evenly"
                verticalAlign="center"
            >
                <IconButton
                    className={mergeClasses(classes.back, classes.icon)}
                    iconProps={{ iconName: Icons.Play }}
                    onPointerDown={handleBackPointerDown}
                    onPointerUp={handleBackPointerUp}
                />
                <IconButton
                    className={classes.icon}
                    iconProps={{ iconName: Icons.Circle }}
                    onPointerDown={handleHomePointerDown}
                    onPointerUp={handleHomePointerUp}
                />
                <IconButton
                    className={classes.icon}
                    iconProps={{ iconName: Icons.Stop }}
                    onPointerDown={handleAppSwitchPointerDown}
                    onPointerUp={handleAppSwitchPointerUp}
                />
            </Stack>
        </Stack>
    );
});
Loading