Loading apps/demo/src/pages/scrcpy.tsx +171 −57 Original line number Diff line number Diff line Loading @@ -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"; Loading Loading @@ -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; } Loading Loading @@ -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]} Loading Loading @@ -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', Loading @@ -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; Loading Loading @@ -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, Loading Loading @@ -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; } Loading @@ -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, Loading @@ -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(); } }); Loading Loading @@ -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(() => { Loading @@ -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 Loading Loading @@ -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); Loading @@ -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) { Loading Loading @@ -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 ( Loading Loading @@ -978,12 +1098,6 @@ const NavigationBar = observer(function NavigationBar({ ); }); const useClasses = makeStyles({ video: { transformOrigin: 'center center', }, }); const Scrcpy: NextPage = () => { const classes = useClasses(); Loading libraries/scrcpy/src/client.ts +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'; Loading @@ -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, Loading @@ -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 Loading @@ -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, Loading Loading @@ -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; Loading Loading @@ -135,6 +284,7 @@ export class ScrcpyClient { adb: Adb, options: ScrcpyOptions<any>, process: AdbSubprocessProtocol, stdout: ReadableStream<string>, videoStream: AdbSocket, controlStream: AdbSocket | undefined, ) { Loading @@ -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()) Loading libraries/scrcpy/src/options/1_16/index.ts +5 −5 Original line number Diff line number Diff line Loading @@ -16,7 +16,7 @@ export enum ScrcpyLogLevel { Error = 'error', } export enum ScrcpyScreenOrientation { export enum ScrcpyVideoOrientation { Initial = -2, Unlocked = -1, Portrait = 0, Loading Loading @@ -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; Loading Loading @@ -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>; Loading Loading @@ -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, Loading libraries/scrcpy/src/options/1_22.ts +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"; Loading @@ -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 */ Loading Loading @@ -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); Loading Loading
apps/demo/src/pages/scrcpy.tsx +171 −57 Original line number Diff line number Diff line Loading @@ -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"; Loading Loading @@ -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; } Loading Loading @@ -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]} Loading Loading @@ -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', Loading @@ -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; Loading Loading @@ -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, Loading Loading @@ -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; } Loading @@ -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, Loading @@ -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(); } }); Loading Loading @@ -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(() => { Loading @@ -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 Loading Loading @@ -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); Loading @@ -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) { Loading Loading @@ -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 ( Loading Loading @@ -978,12 +1098,6 @@ const NavigationBar = observer(function NavigationBar({ ); }); const useClasses = makeStyles({ video: { transformOrigin: 'center center', }, }); const Scrcpy: NextPage = () => { const classes = useClasses(); Loading
libraries/scrcpy/src/client.ts +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'; Loading @@ -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, Loading @@ -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 Loading @@ -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, Loading Loading @@ -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; Loading Loading @@ -135,6 +284,7 @@ export class ScrcpyClient { adb: Adb, options: ScrcpyOptions<any>, process: AdbSubprocessProtocol, stdout: ReadableStream<string>, videoStream: AdbSocket, controlStream: AdbSocket | undefined, ) { Loading @@ -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()) Loading
libraries/scrcpy/src/options/1_16/index.ts +5 −5 Original line number Diff line number Diff line Loading @@ -16,7 +16,7 @@ export enum ScrcpyLogLevel { Error = 'error', } export enum ScrcpyScreenOrientation { export enum ScrcpyVideoOrientation { Initial = -2, Unlocked = -1, Portrait = 0, Loading Loading @@ -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; Loading Loading @@ -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>; Loading Loading @@ -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, Loading
libraries/scrcpy/src/options/1_22.ts +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"; Loading @@ -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 */ Loading Loading @@ -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); Loading