mirror of
https://github.com/yume-chan/ya-webadb.git
synced 2025-10-06 03:50:18 +02:00
925 lines
31 KiB
TypeScript
925 lines
31 KiB
TypeScript
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 { ADB_SYNC_MAX_PACKET_SIZE, ChunkStream, TransformStream } from '@yume-chan/adb';
|
|
import { EventEmitter } from "@yume-chan/event";
|
|
import { AndroidKeyCode, AndroidKeyEventAction, AndroidMotionEventAction, CodecOptions, DEFAULT_SERVER_PATH, H264Decoder, H264DecoderConstructor, pushServer, ScrcpyClient, ScrcpyLogLevel, ScrcpyOptions1_22, ScrcpyScreenOrientation, TinyH264Decoder, WebCodecsDecoder } from "@yume-chan/scrcpy";
|
|
import SCRCPY_SERVER_VERSION from '@yume-chan/scrcpy/bin/version';
|
|
import { action, autorun, makeAutoObservable, observable, runInAction } from "mobx";
|
|
import { observer } from "mobx-react-lite";
|
|
import { NextPage } from "next";
|
|
import Head from "next/head";
|
|
import React, { useEffect, useMemo, useState } from "react";
|
|
import { DemoModePanel, DeviceView, DeviceViewRef, ExternalLink } from "../components";
|
|
import { globalState } from "../state";
|
|
import { CommonStackTokens, formatSpeed, Icons, RouteStackProps } from "../utils";
|
|
import { ProgressStream } from "./file-manager";
|
|
|
|
const SERVER_URL = new URL('@yume-chan/scrcpy/bin/scrcpy-server?url', import.meta.url).toString();
|
|
|
|
class FetchWithProgress {
|
|
public readonly promise: Promise<ArrayBuffer>;
|
|
|
|
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) {
|
|
this.promise = this.fetch(url);
|
|
}
|
|
|
|
private async fetch(url: string) {
|
|
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.buffer;
|
|
}
|
|
}
|
|
|
|
let cachedValue: FetchWithProgress | undefined;
|
|
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;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
if (value < min) {
|
|
return min;
|
|
}
|
|
|
|
if (value > max) {
|
|
return max;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
class KeyRepeater {
|
|
key: AndroidKeyCode;
|
|
client: ScrcpyClient;
|
|
|
|
delay: number;
|
|
interval: number;
|
|
|
|
onRelease: VoidFunction | undefined;
|
|
|
|
constructor(key: AndroidKeyCode, client: ScrcpyClient, delay = 0, interval = 0) {
|
|
this.key = key;
|
|
this.client = client;
|
|
|
|
this.delay = delay;
|
|
this.interval = interval;
|
|
}
|
|
|
|
async press() {
|
|
await this.client.injectKeyCode({
|
|
action: AndroidKeyEventAction.Down,
|
|
keyCode: this.key,
|
|
repeat: 0,
|
|
metaState: 0,
|
|
});
|
|
|
|
if (this.delay === 0) {
|
|
return;
|
|
}
|
|
|
|
const timeoutId = setTimeout(async () => {
|
|
await this.client.injectKeyCode({
|
|
action: AndroidKeyEventAction.Down,
|
|
keyCode: this.key,
|
|
repeat: 1,
|
|
metaState: 0,
|
|
});
|
|
|
|
if (this.interval === 0) {
|
|
return;
|
|
}
|
|
|
|
const intervalId = setInterval(async () => {
|
|
await this.client.injectKeyCode({
|
|
action: AndroidKeyEventAction.Down,
|
|
keyCode: this.key,
|
|
repeat: 1,
|
|
metaState: 0,
|
|
});
|
|
}, this.interval);
|
|
this.onRelease = () => clearInterval(intervalId);
|
|
}, this.delay);
|
|
this.onRelease = () => clearTimeout(timeoutId);
|
|
}
|
|
|
|
async release() {
|
|
this.onRelease?.();
|
|
|
|
await this.client.injectKeyCode({
|
|
action: AndroidKeyEventAction.Up,
|
|
keyCode: this.key,
|
|
repeat: 0,
|
|
metaState: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
class ScrcpyPageState {
|
|
running = false;
|
|
|
|
deviceView: DeviceViewRef | null = null;
|
|
rendererContainer: HTMLDivElement | null = null;
|
|
|
|
logVisible = false;
|
|
log: string[] = [];
|
|
settingsVisible = false;
|
|
demoModeVisible = false;
|
|
navigationBarVisible = true;
|
|
|
|
width = 0;
|
|
height = 0;
|
|
|
|
client: ScrcpyClient | undefined = undefined;
|
|
|
|
encoders: string[] = [];
|
|
selectedEncoder: string | undefined = undefined;
|
|
|
|
decoders: { name: string; factory: H264DecoderConstructor; }[] = [{
|
|
name: 'TinyH264 (Software)',
|
|
factory: TinyH264Decoder,
|
|
}];
|
|
selectedDecoder: { name: string, factory: H264DecoderConstructor; } = this.decoders[0];
|
|
decoder: H264Decoder | undefined = undefined;
|
|
|
|
resolution = 1080;
|
|
bitRate = 4_000_000;
|
|
tunnelForward = false;
|
|
|
|
connecting = false;
|
|
serverTotalSize = 0;
|
|
serverDownloadedSize = 0;
|
|
debouncedServerDownloadedSize = 0;
|
|
serverDownloadSpeed = 0;
|
|
serverUploadedSize = 0;
|
|
debouncedServerUploadedSize = 0;
|
|
serverUploadSpeed = 0;
|
|
|
|
homeKeyRepeater: KeyRepeater | undefined = undefined;
|
|
appSwitchKeyRepeater: KeyRepeater | undefined = undefined;
|
|
|
|
get commandBarItems() {
|
|
const result: ICommandBarItemProps[] = [];
|
|
|
|
if (!this.running) {
|
|
result.push({
|
|
key: 'start',
|
|
disabled: !globalState.device,
|
|
iconProps: { iconName: Icons.Play },
|
|
text: 'Start',
|
|
onClick: this.start as VoidFunction,
|
|
});
|
|
} else {
|
|
result.push({
|
|
key: 'stop',
|
|
iconProps: { iconName: Icons.Stop },
|
|
text: 'Stop',
|
|
onClick: this.stop,
|
|
});
|
|
}
|
|
|
|
result.push({
|
|
key: 'fullscreen',
|
|
disabled: !this.running,
|
|
iconProps: { iconName: Icons.FullScreenMaximize },
|
|
text: 'Fullscreen',
|
|
onClick: () => { this.deviceView?.enterFullscreen(); },
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
get commandBarFarItems(): ICommandBarItemProps[] {
|
|
return [
|
|
{
|
|
key: 'NavigationBar',
|
|
iconProps: { iconName: Icons.PanelBottom },
|
|
checked: this.navigationBarVisible,
|
|
text: 'Navigation Bar',
|
|
onClick: action(() => {
|
|
this.navigationBarVisible = !this.navigationBarVisible;
|
|
}),
|
|
},
|
|
{
|
|
key: 'Log',
|
|
iconProps: { iconName: Icons.TextGrammarError },
|
|
checked: this.logVisible,
|
|
text: 'Log',
|
|
onClick: action(() => {
|
|
this.logVisible = !this.logVisible;
|
|
}),
|
|
},
|
|
{
|
|
key: 'Settings',
|
|
iconProps: { iconName: Icons.Settings },
|
|
checked: this.settingsVisible,
|
|
text: 'Settings',
|
|
onClick: action(() => {
|
|
this.settingsVisible = !this.settingsVisible;
|
|
}),
|
|
},
|
|
{
|
|
key: 'DemoMode',
|
|
iconProps: { iconName: Icons.Wand },
|
|
checked: this.demoModeVisible,
|
|
text: 'Demo Mode',
|
|
onClick: action(() => {
|
|
this.demoModeVisible = !this.demoModeVisible;
|
|
}),
|
|
},
|
|
{
|
|
key: 'info',
|
|
iconProps: { iconName: Icons.Info },
|
|
iconOnly: true,
|
|
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,
|
|
}
|
|
},
|
|
}
|
|
];
|
|
}
|
|
|
|
constructor() {
|
|
makeAutoObservable(this, {
|
|
decoders: observable.shallow,
|
|
selectedDecoder: observable.ref,
|
|
start: false,
|
|
stop: action.bound,
|
|
handleDeviceViewRef: action.bound,
|
|
handleRendererContainerRef: action.bound,
|
|
handleBackPointerDown: false,
|
|
handleBackPointerUp: false,
|
|
handleHomePointerDown: false,
|
|
handleHomePointerUp: false,
|
|
handleAppSwitchPointerDown: false,
|
|
handleAppSwitchPointerUp: false,
|
|
handleCurrentEncoderChange: action.bound,
|
|
handleSelectedDecoderChange: action.bound,
|
|
handleResolutionChange: action.bound,
|
|
handleTunnelForwardChange: action.bound,
|
|
handleBitRateChange: action.bound,
|
|
calculatePointerPosition: false,
|
|
injectTouch: false,
|
|
handlePointerDown: false,
|
|
handlePointerMove: false,
|
|
handlePointerUp: false,
|
|
handleWheel: false,
|
|
handleContextMenu: false,
|
|
handleKeyDown: false,
|
|
homeKeyRepeater: false,
|
|
appSwitchKeyRepeater: false,
|
|
});
|
|
|
|
autorun(() => {
|
|
if (globalState.device) {
|
|
runInAction(() => {
|
|
this.encoders = [];
|
|
this.selectedEncoder = undefined;
|
|
});
|
|
} else {
|
|
this.stop();
|
|
}
|
|
});
|
|
|
|
autorun(() => {
|
|
if (this.rendererContainer && this.decoder) {
|
|
while (this.rendererContainer.firstChild) {
|
|
this.rendererContainer.firstChild.remove();
|
|
}
|
|
this.rendererContainer.appendChild(this.decoder.renderer);
|
|
}
|
|
});
|
|
|
|
autorun(() => {
|
|
if (this.client) {
|
|
this.homeKeyRepeater = new KeyRepeater(AndroidKeyCode.Home, this.client);
|
|
this.appSwitchKeyRepeater = new KeyRepeater(AndroidKeyCode.AppSwitch, this.client);
|
|
} else {
|
|
this.homeKeyRepeater = undefined;
|
|
this.appSwitchKeyRepeater = undefined;
|
|
}
|
|
});
|
|
|
|
if (typeof window !== 'undefined' && typeof window.VideoDecoder === 'function') {
|
|
setTimeout(action(() => {
|
|
this.decoders.unshift({
|
|
name: 'WebCodecs',
|
|
factory: WebCodecsDecoder,
|
|
});
|
|
this.selectedDecoder = this.decoders[0];
|
|
}), 0);
|
|
}
|
|
}
|
|
|
|
start = async () => {
|
|
if (!globalState.device) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (!state.selectedDecoder) {
|
|
throw new Error('No available decoder');
|
|
}
|
|
|
|
runInAction(() => {
|
|
this.serverTotalSize = 0;
|
|
this.serverDownloadedSize = 0;
|
|
this.debouncedServerDownloadedSize = 0;
|
|
this.serverUploadedSize = 0;
|
|
this.debouncedServerUploadedSize = 0;
|
|
this.connecting = true;
|
|
});
|
|
|
|
let intervalId = setInterval(action(() => {
|
|
this.serverDownloadSpeed = this.serverDownloadedSize - this.debouncedServerDownloadedSize;
|
|
this.debouncedServerDownloadedSize = this.serverDownloadedSize;
|
|
}), 1000);
|
|
|
|
let serverBuffer: ArrayBuffer;
|
|
|
|
try {
|
|
serverBuffer = await fetchServer(action(([downloaded, total]) => {
|
|
this.serverDownloadedSize = downloaded;
|
|
this.serverTotalSize = total;
|
|
}));
|
|
runInAction(() => {
|
|
this.serverDownloadSpeed = this.serverDownloadedSize - this.debouncedServerDownloadedSize;
|
|
this.debouncedServerDownloadedSize = this.serverDownloadedSize;
|
|
});
|
|
} finally {
|
|
clearInterval(intervalId);
|
|
}
|
|
|
|
intervalId = setInterval(action(() => {
|
|
this.serverUploadSpeed = this.serverUploadedSize - this.debouncedServerUploadedSize;
|
|
this.debouncedServerUploadedSize = this.serverUploadedSize;
|
|
}), 1000);
|
|
|
|
try {
|
|
await new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(serverBuffer);
|
|
controller.close();
|
|
},
|
|
})
|
|
.pipeThrough(new ChunkStream(ADB_SYNC_MAX_PACKET_SIZE))
|
|
.pipeThrough(new ProgressStream((progress) => {
|
|
this.serverUploadedSize = progress;
|
|
}))
|
|
.pipeTo(pushServer(globalState.device));
|
|
|
|
runInAction(() => {
|
|
this.serverUploadSpeed = this.serverUploadedSize - this.debouncedServerUploadedSize;
|
|
this.debouncedServerUploadedSize = this.serverUploadedSize;
|
|
});
|
|
} finally {
|
|
clearInterval(intervalId);
|
|
}
|
|
|
|
const encoders = await ScrcpyClient.getEncoders(
|
|
globalState.device,
|
|
DEFAULT_SERVER_PATH,
|
|
SCRCPY_SERVER_VERSION,
|
|
new ScrcpyOptions1_22({
|
|
logLevel: ScrcpyLogLevel.Debug,
|
|
bitRate: 4_000_000,
|
|
tunnelForward: this.tunnelForward,
|
|
sendDeviceMeta: false,
|
|
sendDummyByte: false,
|
|
})
|
|
);
|
|
if (encoders.length === 0) {
|
|
throw new Error('No available encoder found');
|
|
}
|
|
|
|
runInAction(() => {
|
|
this.encoders = encoders;
|
|
});
|
|
|
|
// Run scrcpy once will delete the server file
|
|
// Re-push it
|
|
await new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(serverBuffer);
|
|
controller.close();
|
|
},
|
|
})
|
|
.pipeTo(pushServer(globalState.device));
|
|
|
|
const factory = this.selectedDecoder.factory;
|
|
const decoder = new factory();
|
|
runInAction(() => {
|
|
this.decoder = decoder;
|
|
});
|
|
|
|
const options = new ScrcpyOptions1_22({
|
|
logLevel: ScrcpyLogLevel.Debug,
|
|
maxSize: this.resolution,
|
|
bitRate: this.bitRate,
|
|
lockVideoOrientation: ScrcpyScreenOrientation.Unlocked,
|
|
tunnelForward: this.tunnelForward,
|
|
encoderName: this.selectedEncoder ?? encoders[0],
|
|
sendDeviceMeta: false,
|
|
sendDummyByte: false,
|
|
codecOptions: new CodecOptions({
|
|
profile: decoder.maxProfile,
|
|
level: decoder.maxLevel,
|
|
}),
|
|
});
|
|
|
|
runInAction(() => {
|
|
this.log = [];
|
|
this.log.push(`[client] Server version: ${SCRCPY_SERVER_VERSION}`);
|
|
this.log.push(`[client] Server arguments: ${options.formatServerArguments().join(' ')}`);
|
|
});
|
|
|
|
const client = await ScrcpyClient.start(
|
|
globalState.device,
|
|
DEFAULT_SERVER_PATH,
|
|
SCRCPY_SERVER_VERSION,
|
|
options
|
|
);
|
|
|
|
client.stdout.pipeTo(new WritableStream({
|
|
write: (line) => {
|
|
this.log.push(line);
|
|
}
|
|
}));
|
|
|
|
client.close().then(() => this.stop());
|
|
|
|
client.videoStream.pipeThrough(new TransformStream({
|
|
transform: (chunk, controller) => {
|
|
if (chunk.type === 'configuration') {
|
|
const { croppedWidth, croppedHeight, } = chunk.data;
|
|
this.log.push(`[client] Video size changed: ${croppedWidth}x${croppedHeight}`);
|
|
|
|
this.width = croppedWidth;
|
|
this.height = croppedHeight;
|
|
}
|
|
controller.enqueue(chunk);
|
|
}
|
|
})).pipeTo(decoder.writable);
|
|
|
|
client.onClipboardChange(content => {
|
|
window.navigator.clipboard.writeText(content);
|
|
});
|
|
|
|
runInAction(() => {
|
|
this.client = client;
|
|
this.running = true;
|
|
});
|
|
} catch (e: any) {
|
|
globalState.showErrorDialog(e.message);
|
|
} finally {
|
|
runInAction(() => {
|
|
this.connecting = false;
|
|
});
|
|
}
|
|
};
|
|
|
|
stop() {
|
|
this.decoder?.dispose();
|
|
this.decoder = undefined;
|
|
|
|
this.client?.close();
|
|
this.client = undefined;
|
|
|
|
this.running = false;
|
|
}
|
|
|
|
handleDeviceViewRef(element: DeviceViewRef | null) {
|
|
this.deviceView = element;
|
|
}
|
|
|
|
handleRendererContainerRef(element: HTMLDivElement | null) {
|
|
this.rendererContainer = element;
|
|
this.rendererContainer?.addEventListener('wheel', this.handleWheel, { passive: false });
|
|
};
|
|
|
|
handleBackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (e.button !== 0) {
|
|
return;
|
|
}
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
this.client!.pressBackOrTurnOnScreen(AndroidKeyEventAction.Down);
|
|
};
|
|
|
|
handleBackPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (e.button !== 0) {
|
|
return;
|
|
}
|
|
this.client!.pressBackOrTurnOnScreen(AndroidKeyEventAction.Up);
|
|
};
|
|
|
|
handleHomePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (e.button !== 0) {
|
|
return;
|
|
}
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
this.homeKeyRepeater?.press();
|
|
};
|
|
|
|
handleHomePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (e.button !== 0) {
|
|
return;
|
|
}
|
|
this.homeKeyRepeater?.release();
|
|
};
|
|
|
|
handleAppSwitchPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (e.button !== 0) {
|
|
return;
|
|
}
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
this.appSwitchKeyRepeater?.press();
|
|
};
|
|
|
|
handleAppSwitchPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (e.button !== 0) {
|
|
return;
|
|
}
|
|
this.appSwitchKeyRepeater?.release();
|
|
};
|
|
|
|
handleCurrentEncoderChange(e?: any, option?: IDropdownOption) {
|
|
if (!option) {
|
|
return;
|
|
}
|
|
|
|
this.selectedEncoder = option.text;
|
|
}
|
|
|
|
handleSelectedDecoderChange(e?: any, option?: IDropdownOption) {
|
|
if (!option) {
|
|
return;
|
|
}
|
|
|
|
this.selectedDecoder = option.data;
|
|
}
|
|
|
|
handleResolutionChange(e: any, value?: string) {
|
|
if (value === undefined) {
|
|
return;
|
|
}
|
|
this.resolution = +value;
|
|
}
|
|
|
|
handleBitRateChange(e: any, value?: string) {
|
|
if (value === undefined) {
|
|
return;
|
|
}
|
|
this.bitRate = +value;
|
|
}
|
|
|
|
handleTunnelForwardChange(event: React.MouseEvent<HTMLElement>, checked?: boolean) {
|
|
if (checked === undefined) {
|
|
return;
|
|
}
|
|
|
|
this.tunnelForward = checked;
|
|
};
|
|
|
|
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;
|
|
|
|
return {
|
|
x: pointerScreenX,
|
|
y: pointerScreenY,
|
|
};
|
|
}
|
|
|
|
injectTouch = (
|
|
action: AndroidMotionEventAction,
|
|
e: React.PointerEvent<HTMLDivElement>
|
|
) => {
|
|
if (!this.client) {
|
|
return;
|
|
}
|
|
|
|
const { x, y } = this.calculatePointerPosition(e.clientX, e.clientY);
|
|
this.client.injectTouch({
|
|
action,
|
|
pointerId: e.pointerType === "mouse" ? BigInt(-1) : BigInt(e.pointerId),
|
|
pointerX: x,
|
|
pointerY: y,
|
|
pressure: e.pressure * 65535,
|
|
buttons: e.buttons,
|
|
});
|
|
};
|
|
|
|
handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
this.rendererContainer!.focus();
|
|
e.preventDefault();
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
this.injectTouch(AndroidMotionEventAction.Down, e);
|
|
};
|
|
|
|
handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
this.injectTouch(
|
|
e.buttons === 0 ? AndroidMotionEventAction.HoverMove : AndroidMotionEventAction.Move,
|
|
e
|
|
);
|
|
};
|
|
|
|
handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
this.injectTouch(AndroidMotionEventAction.Up, e);
|
|
};
|
|
|
|
handleWheel = (e: WheelEvent) => {
|
|
if (!this.client) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const { x, y } = this.calculatePointerPosition(e.clientX, e.clientY);
|
|
this.client.injectScroll({
|
|
pointerX: x,
|
|
pointerY: y,
|
|
scrollX: -Math.sign(e.deltaX),
|
|
scrollY: -Math.sign(e.deltaY),
|
|
buttons: 0,
|
|
});
|
|
};
|
|
|
|
handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
};
|
|
|
|
handleKeyDown = async (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
if (!this.client) {
|
|
return;
|
|
}
|
|
|
|
const { key, code } = e;
|
|
if (key.match(/^[a-z0-9]$/i)) {
|
|
this.client!.injectText(key);
|
|
return;
|
|
}
|
|
|
|
const keyCode = ({
|
|
Backspace: AndroidKeyCode.Delete,
|
|
Space: AndroidKeyCode.Space,
|
|
} as Record<string, AndroidKeyCode | undefined>)[code];
|
|
|
|
if (keyCode) {
|
|
await this.client.injectKeyCode({
|
|
action: AndroidKeyEventAction.Down,
|
|
keyCode,
|
|
metaState: 0,
|
|
repeat: 0,
|
|
});
|
|
await this.client.injectKeyCode({
|
|
action: AndroidKeyEventAction.Up,
|
|
keyCode,
|
|
metaState: 0,
|
|
repeat: 0,
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
const state = new ScrcpyPageState();
|
|
|
|
const ConnectionDialog = observer(() => {
|
|
const layerHostId = useId('layerHost');
|
|
|
|
const [isClient, setIsClient] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setIsClient(true);
|
|
}, []);
|
|
|
|
if (!isClient) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<LayerHost id={layerHostId} style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, margin: 0, pointerEvents: 'none' }} />
|
|
|
|
<Dialog
|
|
hidden={!state.connecting}
|
|
modalProps={{ layerProps: { hostId: layerHostId } }}
|
|
dialogContentProps={{ title: 'Connecting...' }}
|
|
>
|
|
<Stack tokens={CommonStackTokens}>
|
|
<ProgressIndicator
|
|
label="1. Downloading scrcpy server..."
|
|
percentComplete={state.serverTotalSize ? state.serverDownloadedSize / state.serverTotalSize : undefined}
|
|
description={formatSpeed(state.debouncedServerDownloadedSize, state.serverTotalSize, state.serverDownloadSpeed)}
|
|
/>
|
|
|
|
<ProgressIndicator
|
|
label="2. Pushing scrcpy server to device..."
|
|
progressHidden={state.serverTotalSize === 0 || state.serverDownloadedSize !== state.serverTotalSize}
|
|
percentComplete={state.serverUploadedSize / state.serverTotalSize}
|
|
description={formatSpeed(state.debouncedServerUploadedSize, state.serverTotalSize, state.serverUploadSpeed)}
|
|
/>
|
|
|
|
<ProgressIndicator
|
|
label="3. Starting scrcpy server on device..."
|
|
progressHidden={state.serverTotalSize === 0 || state.serverUploadedSize !== state.serverTotalSize}
|
|
/>
|
|
</Stack>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
});
|
|
|
|
const Scrcpy: NextPage = () => {
|
|
const bottomElement = useMemo(() =>
|
|
state.navigationBarVisible && (
|
|
<Stack verticalFill horizontalAlign="center" style={{ height: '40px', background: '#999' }}>
|
|
<Stack verticalFill horizontal style={{ width: '100%', maxWidth: 300 }} horizontalAlign="space-evenly" verticalAlign="center">
|
|
<IconButton
|
|
iconProps={{ iconName: Icons.Play }}
|
|
style={{ transform: 'rotate(180deg)', color: 'white' }}
|
|
onPointerDown={state.handleBackPointerDown}
|
|
onPointerUp={state.handleBackPointerUp}
|
|
/>
|
|
<IconButton
|
|
iconProps={{ iconName: Icons.Circle }}
|
|
style={{ color: 'white' }}
|
|
onPointerDown={state.handleHomePointerDown}
|
|
onPointerUp={state.handleHomePointerUp}
|
|
/>
|
|
<IconButton
|
|
iconProps={{ iconName: Icons.Stop }}
|
|
style={{ color: 'white' }}
|
|
onPointerDown={state.handleAppSwitchPointerDown}
|
|
onPointerUp={state.handleAppSwitchPointerUp}
|
|
/>
|
|
</Stack>
|
|
</Stack>
|
|
),
|
|
[state.navigationBarVisible]
|
|
);
|
|
|
|
return (
|
|
<Stack {...RouteStackProps}>
|
|
<Head>
|
|
<title>Scrcpy - WebADB</title>
|
|
</Head>
|
|
|
|
<CommandBar items={state.commandBarItems} farItems={state.commandBarFarItems} />
|
|
|
|
<Stack horizontal grow styles={{ root: { height: 0 } }}>
|
|
<DeviceView
|
|
ref={state.handleDeviceViewRef}
|
|
width={state.width}
|
|
height={state.height}
|
|
bottomElement={bottomElement}
|
|
>
|
|
<div
|
|
ref={state.handleRendererContainerRef}
|
|
tabIndex={-1}
|
|
onPointerDown={state.handlePointerDown}
|
|
onPointerMove={state.handlePointerMove}
|
|
onPointerUp={state.handlePointerUp}
|
|
onPointerCancel={state.handlePointerUp}
|
|
onKeyDown={state.handleKeyDown}
|
|
onContextMenu={state.handleContextMenu}
|
|
/>
|
|
</DeviceView>
|
|
|
|
<div style={{
|
|
padding: 12,
|
|
overflow: 'hidden auto',
|
|
display: state.logVisible ? 'block' : 'none',
|
|
width: 500,
|
|
fontFamily: 'monospace',
|
|
overflowY: 'auto',
|
|
whiteSpace: 'pre-wrap',
|
|
wordWrap: 'break-word',
|
|
}}>
|
|
{state.log.map((line, index) => (
|
|
<div key={index}>
|
|
{line}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div style={{ padding: 12, overflow: 'hidden auto', display: state.settingsVisible ? 'block' : 'none', width: 300 }}>
|
|
<div>Changes will take effect on next connection</div>
|
|
|
|
<Dropdown
|
|
label="Encoder"
|
|
options={state.encoders.map(item => ({ key: item, text: item }))}
|
|
selectedKey={state.selectedEncoder}
|
|
placeholder="Connect once to retrieve encoder list"
|
|
onChange={state.handleCurrentEncoderChange}
|
|
/>
|
|
|
|
{state.decoders.length > 1 && (
|
|
<Dropdown
|
|
label="Decoder"
|
|
options={state.decoders.map(item => ({ key: item.name, text: item.name, data: item }))}
|
|
selectedKey={state.selectedDecoder.name}
|
|
onChange={state.handleSelectedDecoderChange}
|
|
/>
|
|
)}
|
|
|
|
<SpinButton
|
|
label="Max Resolution (longer side, 0 = unlimited)"
|
|
labelPosition={Position.top}
|
|
value={state.resolution.toString()}
|
|
min={0}
|
|
max={2560}
|
|
step={100}
|
|
onChange={state.handleResolutionChange}
|
|
/>
|
|
|
|
<SpinButton
|
|
label="Max Bit Rate"
|
|
labelPosition={Position.top}
|
|
value={state.bitRate.toString()}
|
|
min={100}
|
|
max={10_000_000}
|
|
step={100}
|
|
onChange={state.handleBitRateChange}
|
|
/>
|
|
|
|
<Toggle
|
|
label={
|
|
<>
|
|
<span>Use forward connection{' '}</span>
|
|
<TooltipHost content="Old Android devices may not support reverse connection when using ADB over WiFi">
|
|
<Icon iconName={Icons.Info} />
|
|
</TooltipHost>
|
|
</>
|
|
}
|
|
checked={state.tunnelForward}
|
|
onChange={state.handleTunnelForwardChange}
|
|
/>
|
|
</div>
|
|
|
|
<DemoModePanel
|
|
style={{ display: state.demoModeVisible ? 'block' : 'none' }}
|
|
/>
|
|
|
|
<ConnectionDialog />
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export default observer(Scrcpy);
|