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

Unverified Commit 5535280d authored by Simon Chan's avatar Simon Chan
Browse files

feat(scrcpy): add method to get screen list

ref #409
parent 52bbc55f
Loading
Loading
Loading
Loading
+171 −57
Original line number Diff line number Diff line
@@ -9,7 +9,7 @@ import { CSSProperties, ReactNode, useEffect, useState } from "react";

import { ADB_SYNC_MAX_PACKET_SIZE, ChunkStream, InspectStream, ReadableStream, WritableStream } from '@yume-chan/adb';
import { EventEmitter } from "@yume-chan/event";
import { AndroidKeyCode, AndroidKeyEventAction, AndroidMotionEventAction, CodecOptions, DEFAULT_SERVER_PATH, pushServer, ScrcpyClient, ScrcpyLogLevel, ScrcpyOptions1_24, ScrcpyScreenOrientation, TinyH264Decoder, WebCodecsDecoder, type H264Decoder, type H264DecoderConstructor, type VideoStreamPacket } from "@yume-chan/scrcpy";
import { AndroidKeyCode, AndroidKeyEventAction, AndroidMotionEventAction, CodecOptions, DEFAULT_SERVER_PATH, pushServer, ScrcpyClient, ScrcpyLogLevel, ScrcpyOptions1_24, ScrcpyVideoOrientation, TinyH264Decoder, WebCodecsDecoder, type H264Decoder, type H264DecoderConstructor, type VideoStreamPacket } from "@yume-chan/scrcpy";
import SCRCPY_SERVER_VERSION from '@yume-chan/scrcpy/bin/version';

import { DemoModePanel, DeviceView, DeviceViewRef, ExternalLink } from "../components";
@@ -101,15 +101,19 @@ interface Settings {
    maxSize: number;
    bitRate: number;
    tunnelForward?: boolean;
    encoder?: string;
    encoderName?: string;
    decoder?: string;
    ignoreDecoderCodecArgs?: boolean;
    lockVideoOrientation?: ScrcpyVideoOrientation;
    displayId?: number;
    crop: string;
}

interface SettingDefinitionBase {
    key: keyof Settings;
    type: string;
    label: string;
    labelExtra?: JSX.Element;
    description?: string;
}

@@ -141,30 +145,39 @@ interface SettingItemProps {
    onChange: (key: keyof Settings, value: any) => void;
}

const useClasses = makeStyles({
    labelRight: {
        marginLeft: '4px',
    },
    video: {
        transformOrigin: 'center center',
    },
});

const SettingItem = observer(function SettingItem({
    definition,
    settings,
    onChange,
}: SettingItemProps) {
    let label: string | JSX.Element;
    if (definition.description) {
        label = (
            <>
                <span>{definition.label}{' '}</span>
    const classes = useClasses();

    let label: JSX.Element = (
        <Stack horizontal verticalAlign="center">
            <span>{definition.label}</span>
            {!!definition.description && (
                <TooltipHost content={definition.description}>
                    <Icon iconName={Icons.Info} />
                    <Icon className={classes.labelRight} iconName={Icons.Info} />
                </TooltipHost>
            </>
            )}
            {definition.labelExtra}
        </Stack>
    );
    } else {
        label = definition.label;
    }

    switch (definition.type) {
        case 'dropdown':
            return (
                <Dropdown
                    label={definition.label}
                    label={label as any}
                    options={definition.options}
                    placeholder={definition.placeholder}
                    selectedKey={settings[definition.key]}
@@ -215,7 +228,44 @@ class ScrcpyPageState {

    client: ScrcpyClient | undefined = undefined;

    async pushServer() {
        const serverBuffer = await fetchServer();

        await new ReadableStream<Uint8Array>({
            start(controller) {
                controller.enqueue(serverBuffer);
                controller.close();
            },
        })
            .pipeTo(pushServer(globalState.device!));
    }

    encoders: string[] = [];
    updateEncoders = async () => {
        try {
            await this.pushServer();

            const encoders = await ScrcpyClient.getEncoders(
                globalState.device!,
                DEFAULT_SERVER_PATH,
                SCRCPY_SERVER_VERSION,
                new ScrcpyOptions1_24({
                    logLevel: ScrcpyLogLevel.Debug,
                    tunnelForward: this.settings.tunnelForward,
                })
            );

            runInAction(() => {
                this.encoders = encoders;
                if (!this.settings.encoderName ||
                    !this.encoders.includes(this.settings.encoderName)) {
                    this.settings.encoderName = this.encoders[0];
                }
            });
        } catch (e: any) {
            globalState.showErrorDialog(e);
        }
    };

    decoders: DecoderDefinition[] = [{
        key: 'tinyh264',
@@ -224,6 +274,33 @@ class ScrcpyPageState {
    }];
    decoder: H264Decoder | undefined = undefined;

    displays: number[] = [];
    updateDisplays = async () => {
        try {
            await this.pushServer();

            const displays = await ScrcpyClient.getDisplays(
                globalState.device!,
                DEFAULT_SERVER_PATH,
                SCRCPY_SERVER_VERSION,
                new ScrcpyOptions1_24({
                    logLevel: ScrcpyLogLevel.Debug,
                    tunnelForward: this.settings.tunnelForward,
                })
            );

            runInAction(() => {
                this.displays = displays;
                if (!this.settings.displayId ||
                    !this.displays.includes(this.settings.displayId)) {
                    this.settings.displayId = this.displays[0];
                }
            });
        } catch (e: any) {
            globalState.showErrorDialog(e);
        }
    };

    connecting = false;
    serverTotalSize = 0;
    serverDownloadedSize = 0;
@@ -368,16 +445,27 @@ class ScrcpyPageState {
    settings: Settings = {
        maxSize: 1080,
        bitRate: 4_000_000,
        lockVideoOrientation: ScrcpyVideoOrientation.Unlocked,
        displayId: 0,
        crop: '',
    };

    get settingDefinitions() {
        const result: SettingDefinition[] = [];

        result.push({
            key: 'encoder',
            key: 'encoderName',
            type: 'dropdown',
            label: 'Encoder',
            placeholder: 'Connect once to retrieve encoder list',
            placeholder: 'Press refresh to update available encoders',
            labelExtra: (
                <IconButton
                    iconProps={{ iconName: Icons.ArrowClockwise }}
                    disabled={!globalState.device}
                    text="Refresh"
                    onClick={this.updateEncoders}
                />
            ),
            options: this.encoders.map(item => ({
                key: item,
                text: item,
@@ -429,6 +517,57 @@ class ScrcpyPageState {
            description: 'Android before version 9 has a bug that prevents reverse tunneling when using ADB over WiFi.'
        });

        result.push({
            key: 'lockVideoOrientation',
            type: 'dropdown',
            label: 'Lock Video Orientation',
            options: [
                {
                    key: ScrcpyVideoOrientation.Unlocked,
                    text: 'Unlocked',
                },
                {
                    key: ScrcpyVideoOrientation.Initial,
                    text: 'Current',
                },
                {
                    key: ScrcpyVideoOrientation.Portrait,
                    text: 'Portrait',
                },
                {
                    key: ScrcpyVideoOrientation.Landscape,
                    text: 'Landscape',
                },
                {
                    key: ScrcpyVideoOrientation.PortraitFlipped,
                    text: 'Portrait (Flipped)',
                },
                {
                    key: ScrcpyVideoOrientation.LandscapeFlipped,
                    text: 'Landscape (Flipped)',
                },
            ],
        });

        result.push({
            key: 'displayId',
            type: 'dropdown',
            label: 'Display',
            placeholder: 'Press refresh to update available displays',
            labelExtra: (
                <IconButton
                    iconProps={{ iconName: Icons.ArrowClockwise }}
                    disabled={!globalState.device}
                    text="Refresh"
                    onClick={this.updateDisplays}
                />
            ),
            options: this.displays.map(item => ({
                key: item,
                text: item.toString(),
            })),
        });

        return result;
    }

@@ -438,6 +577,7 @@ class ScrcpyPageState {
            settings: observable.deep,
            start: false,
            stop: action.bound,
            dispose: action.bound,
            handleDeviceViewRef: action.bound,
            handleRendererContainerRef: action.bound,
            handleBackPointerDown: false,
@@ -460,10 +600,13 @@ class ScrcpyPageState {
            if (globalState.device) {
                runInAction(() => {
                    this.encoders = [];
                    this.settings.encoder = undefined;
                    this.settings.encoderName = undefined;

                    this.displays = [];
                    this.settings.displayId = undefined;
                });
            } else {
                this.stop();
                this.dispose();
            }
        });

@@ -556,30 +699,6 @@ class ScrcpyPageState {
                clearInterval(intervalId);
            }

            const encoders = await ScrcpyClient.getEncoders(
                globalState.device,
                DEFAULT_SERVER_PATH,
                SCRCPY_SERVER_VERSION,
                new ScrcpyOptions1_24({
                    logLevel: ScrcpyLogLevel.Debug,
                    bitRate: 4_000_000,
                    tunnelForward: this.settings.tunnelForward,
                    sendDeviceMeta: false,
                    sendDummyByte: false,
                    control: false,
                    // Don't cleanup when getting encoders,
                    // so doesn't need to push server binary again
                    cleanup: false,
                })
            );
            if (encoders.length === 0) {
                throw new Error('No available encoder found');
            }

            runInAction(() => {
                this.encoders = encoders;
            });

            const decoderDefinition = this.decoders.find(x => x.key === this.settings.decoder) ?? this.decoders[0];
            const decoder = new decoderDefinition.Constructor();
            runInAction(() => {
@@ -589,8 +708,6 @@ class ScrcpyPageState {
            const options = new ScrcpyOptions1_24({
                logLevel: ScrcpyLogLevel.Debug,
                ...this.settings,
                lockVideoOrientation: ScrcpyScreenOrientation.Unlocked,
                encoderName: this.settings.encoder ?? encoders[0],
                sendDeviceMeta: false,
                sendDummyByte: false,
                codecOptions: !this.settings.ignoreDecoderCodecArgs
@@ -633,7 +750,7 @@ class ScrcpyPageState {
                .pipeTo(decoder.writable)
                .catch(() => { });

            client.exit.then(() => this.stop());
            client.exit.then(this.dispose);

            client.onClipboardChange(content => {
                window.navigator.clipboard.writeText(content);
@@ -655,15 +772,16 @@ class ScrcpyPageState {
    async stop() {
        // Request to close client first
        await this.client?.close();
        this.dispose();
    }

    dispose() {
        // Otherwise some packets may still arrive at decoder
        this.decoder?.dispose();
        this.decoder = undefined;

        runInAction(() => {
        this.client = undefined;
            this.decoder = undefined;
        this.running = false;
        });
    }

    handleDeviceViewRef(element: DeviceViewRef | null) {
@@ -948,7 +1066,9 @@ const NavigationBar = observer(function NavigationBar({
    children: ReactNode;
    }) {
    if (!state.navigationBarVisible) {
        return null;
        return (
            <div className={className} style={style}>{children}</div>
        );
    }

    return (
@@ -978,12 +1098,6 @@ const NavigationBar = observer(function NavigationBar({
    );
});

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

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

+154 −15
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 { AbortController, AdbBufferedStream, AdbSubprocessNoneProtocol, DecodeUtf8Stream, InspectStream, ReadableStream, TransformStream, WritableStream, type Adb, type AdbSocket, type AdbSubprocessProtocol, type WritableStreamDefaultWriter } from '@yume-chan/adb';
import { EventEmitter } from '@yume-chan/event';
import Struct from '@yume-chan/struct';
import { AndroidMotionEventAction, ScrcpyControlMessageType, ScrcpyInjectKeyCodeControlMessage, ScrcpyInjectTextControlMessage, ScrcpyInjectTouchControlMessage, ScrcpySimpleControlMessage, type AndroidKeyEventAction } from './message.js';
@@ -20,12 +20,87 @@ function* splitLines(text: string): Generator<string, void, void> {
    }
}

class SplitLinesStream extends TransformStream<string, string>{
    constructor() {
        super({
            transform(chunk, controller) {
                for (const line of splitLines(chunk)) {
                    if (line === '') {
                        continue;
                    }
                    controller.enqueue(line);
                }
            },
        });
    }
}

class ArrayToStream<T> extends ReadableStream<T>{
    private array!: T[];
    private index = 0;

    constructor(array: T[]) {
        super({
            start: async () => {
                await Promise.resolve();
                this.array = array;
            },
            pull: (controller) => {
                if (this.index < this.array.length) {
                    controller.enqueue(this.array[this.index]!);
                    this.index += 1;
                } else {
                    controller.close();
                }
            },
        });
    }
}

class ConcatStream<T> extends ReadableStream<T>{
    private streams!: ReadableStream<T>[];
    private index = 0;
    private reader!: ReadableStreamDefaultReader<T>;

    constructor(...streams: ReadableStream<T>[]) {
        super({
            start: async (controller) => {
                await Promise.resolve();

                this.streams = streams;
                this.advance(controller);
            },
            pull: async (controller) => {
                const result = await this.reader.read();
                if (!result.done) {
                    controller.enqueue(result.value);
                    return;
                }
                this.advance(controller);
            }
        });
    }

    private advance(controller: ReadableStreamDefaultController<T>) {
        if (this.index < this.streams.length) {
            this.reader = this.streams[this.index]!.getReader();
            this.index += 1;
        } else {
            controller.close();
        }
    }
}

const ClipboardMessage =
    new Struct()
        .uint32('length')
        .string('content', { lengthField: 'length' });

export class ScrcpyClient {
    /**
     * This method will modify the given `options`,
     * so don't reuse it elsewhere.
     */
    public static async getEncoders(
        adb: Adb,
        path: string,
@@ -37,6 +112,8 @@ export class ScrcpyClient {
        options.value.encoderName = '_';
        // Disable control for faster connection in 1.22+
        options.value.control = false;
        options.value.sendDeviceMeta = false;
        options.value.sendDummyByte = false;

        // Scrcpy server will open connections, before initializing encoder
        // Thus although an invalid encoder name is given, the start process will success
@@ -56,6 +133,45 @@ export class ScrcpyClient {
        return encoders;
    }

    /**
     * This method will modify the given `options`,
     * so don't reuse it elsewhere.
     */
    public static async getDisplays(
        adb: Adb,
        path: string,
        version: string,
        options: ScrcpyOptions<any>
    ): Promise<number[]> {
        // Similar to `getEncoders`, pass an invalid option and parse the output
        options.value.displayId = -1;

        options.value.control = false;
        options.value.sendDeviceMeta = false;
        options.value.sendDummyByte = false;

        try {
            // Server will exit before opening connections when an invalid display id was given.
            await ScrcpyClient.start(adb, path, version, options);
        } catch (e) {
            if (e instanceof Error) {
                const output = (e as any).output as string[];

                const displayIdRegex = /\s+scrcpy --display (\d+)/;
                const displays: number[] = [];
                for (const line of output) {
                    const match = line.match(displayIdRegex);
                    if (match) {
                        displays.push(Number.parseInt(match[1]!, 10));
                    }
                }
                return displays;
            }
        }

        throw new Error('failed to get displays');
    }

    public static async start(
        adb: Adb,
        path: string,
@@ -85,17 +201,50 @@ export class ScrcpyClient {
                }
            );

            const stdout = process.stdout
                .pipeThrough(new DecodeUtf8Stream())
                .pipeThrough(new SplitLinesStream());

            // Read stdout, otherwise `process.exit` won't resolve.
            const output: string[] = [];
            const abortController = new AbortController();
            const pipe = stdout
                .pipeTo(new WritableStream({
                    write(chunk) {
                        output.push(chunk);
                    }
                }), {
                    signal: abortController.signal,
                    preventCancel: true,
                })
                .catch(() => { });

            const result = await Promise.race([
                process.exit,
                connection.getStreams(),
            ]);

            if (typeof result === 'number') {
                throw new Error('scrcpy server exited prematurely');
                const error = new Error('scrcpy server exited prematurely');
                (error as any).output = output;
                throw error;
            }

            abortController.abort();
            await pipe;

            const [videoStream, controlStream] = result;
            return new ScrcpyClient(adb, options, process, videoStream, controlStream);
            return new ScrcpyClient(
                adb,
                options,
                process,
                new ConcatStream(
                    new ArrayToStream(output),
                    stdout,
                ),
                videoStream,
                controlStream
            );
        } catch (e) {
            await process?.kill();
            throw e;
@@ -135,6 +284,7 @@ export class ScrcpyClient {
        adb: Adb,
        options: ScrcpyOptions<any>,
        process: AdbSubprocessProtocol,
        stdout: ReadableStream<string>,
        videoStream: AdbSocket,
        controlStream: AdbSocket | undefined,
    ) {
@@ -142,18 +292,7 @@ export class ScrcpyClient {
        this.options = options;
        this.process = process;

        this._stdout = process.stdout
            .pipeThrough(new DecodeUtf8Stream())
            .pipeThrough(new TransformStream({
                transform(chunk, controller) {
                    for (const line of splitLines(chunk)) {
                        if (line === '') {
                            continue;
                        }
                        controller.enqueue(line);
                    }
                },
            }));
        this._stdout = stdout;

        this._videoStream = videoStream.readable
            .pipeThrough(options.createVideoStreamTransformer())
+5 −5
Original line number Diff line number Diff line
@@ -16,7 +16,7 @@ export enum ScrcpyLogLevel {
    Error = 'error',
}

export enum ScrcpyScreenOrientation {
export enum ScrcpyVideoOrientation {
    Initial = -2,
    Unlocked = -1,
    Portrait = 0,
@@ -75,7 +75,7 @@ export interface ScrcpyOptionsInit1_16 {
     * It will not keep the device screen in specific orientation,
     * only the captured video will in this orientation.
     */
    lockVideoOrientation: ScrcpyScreenOrientation;
    lockVideoOrientation: ScrcpyVideoOrientation;

    tunnelForward: boolean;

@@ -141,8 +141,8 @@ export class ScrcpyOptions1_16<T extends ScrcpyOptionsInit1_16 = ScrcpyOptionsIn
        }

        if (new.target === ScrcpyOptions1_16 &&
            value.lockVideoOrientation === ScrcpyScreenOrientation.Initial) {
            value.lockVideoOrientation = ScrcpyScreenOrientation.Unlocked;
            value.lockVideoOrientation === ScrcpyVideoOrientation.Initial) {
            value.lockVideoOrientation = ScrcpyVideoOrientation.Unlocked;
        }

        this.value = value as Partial<T>;
@@ -173,7 +173,7 @@ export class ScrcpyOptions1_16<T extends ScrcpyOptionsInit1_16 = ScrcpyOptionsIn
            maxSize: 0,
            bitRate: 8_000_000,
            maxFps: 0,
            lockVideoOrientation: ScrcpyScreenOrientation.Unlocked,
            lockVideoOrientation: ScrcpyVideoOrientation.Unlocked,
            tunnelForward: false,
            crop: '-',
            sendFrameMeta: true,
+6 −8
Original line number Diff line number Diff line
import type { Adb } from "@yume-chan/adb";
import Struct from "@yume-chan/struct";
import { ScrcpyClientForwardConnection, ScrcpyClientReverseConnection, type ScrcpyClientConnection, type ScrcpyClientConnectionOptions } from "../connection.js";
import { ScrcpyClientForwardConnection, ScrcpyClientReverseConnection, type ScrcpyClientConnection } from "../connection.js";
import { ScrcpyInjectScrollControlMessage1_16 } from "./1_16/index.js";
import { ScrcpyOptions1_21, type ScrcpyOptionsInit1_21 } from "./1_21.js";

@@ -8,14 +8,14 @@ export interface ScrcpyOptionsInit1_22 extends ScrcpyOptionsInit1_21 {
    downsizeOnError: boolean;

    /**
     * Send device name and size
     * Send device name and size at start of video stream.
     *
     * @default true
     */
    sendDeviceMeta: boolean;

    /**
     * Write a byte on start to detect connection issues
     * Send a `0` byte on start of video stream to detect connection issues
     *
     * @default true
     */
@@ -59,11 +59,9 @@ export class ScrcpyOptions1_22<T extends ScrcpyOptionsInit1_22 = ScrcpyOptionsIn
    }

    public override createConnection(device: Adb): ScrcpyClientConnection {
        const defaultValue = this.getDefaultValue();
        const options: ScrcpyClientConnectionOptions = {
            control: this.value.control ?? defaultValue.control,
            sendDummyByte: this.value.sendDummyByte ?? defaultValue.sendDummyByte,
            sendDeviceMeta: this.value.sendDeviceMeta ?? defaultValue.sendDeviceMeta,
        const options = {
            ...this.getDefaultValue(),
            ...this.value,
        };
        if (this.value.tunnelForward) {
            return new ScrcpyClientForwardConnection(device, options);