ya-webadb/apps/demo/pages/scrcpy.tsx
2022-02-18 18:19:28 +08:00

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);