feat(scrcpy): mouse and touch control

This commit is contained in:
Simon Chan 2020-10-03 19:57:27 +08:00
parent dc073a5d34
commit 1f6caf9604
23 changed files with 487 additions and 148 deletions

View file

@ -2,6 +2,7 @@ import AsyncOperationManager from '@yume-chan/async-operation-manager';
import { AutoDisposable, EventEmitter } from '@yume-chan/event';
import { AdbBackend } from '../backend';
import { AdbCommand, AdbPacket } from '../packet';
import { AutoResetEvent } from '../utils';
import { AdbStreamController } from './controller';
import { AdbStream } from './stream';
@ -24,6 +25,7 @@ export class AdbPacketDispatcher extends AutoDisposable {
// (0 means open failed)
private readonly initializers = new AsyncOperationManager(1);
private readonly streams = new Map<number, AdbStreamController>();
private readonly sendLock = new AutoResetEvent();
public readonly backend: AdbBackend;
@ -170,7 +172,7 @@ export class AdbPacketDispatcher extends AutoDisposable {
arg1: number,
payload?: string | ArrayBuffer
): Promise<void>;
public sendPacket(
public async sendPacket(
packetOrCommand: AdbPacket | AdbCommand,
arg0?: number,
arg1?: number,
@ -192,7 +194,12 @@ export class AdbPacketDispatcher extends AutoDisposable {
}, this.backend);
}
return AdbPacket.write(packet, this.backend);
try {
await this.sendLock.wait();
await AdbPacket.write(packet, this.backend);
} finally {
this.sendLock.notify();
}
}
public dispose() {

View file

@ -82,9 +82,9 @@
}
},
"@microsoft/load-themed-styles": {
"version": "1.10.101",
"resolved": "https://registry.npmjs.org/@microsoft/load-themed-styles/-/load-themed-styles-1.10.101.tgz",
"integrity": "sha512-TERGxGXEmQ6lIQqgR/1MMMJCMXwk5sTN/Nt4PJnw28ZKa7T6mGAzxRDp6QLCxJCqST1YquTJoayv6ELhQSPFlQ=="
"version": "1.10.105",
"resolved": "https://registry.npmjs.org/@microsoft/load-themed-styles/-/load-themed-styles-1.10.105.tgz",
"integrity": "sha512-0X0WXDFrdSFMKkGNZ7e9iti1znGOAlCpg5HEWwDmA+ma+KMd2rUkDXWDySdESRYdJQ6W2/r0Zu2UGxtomK90aQ=="
},
"@nodelib/fs.scandir": {
"version": "2.1.3",
@ -2143,20 +2143,20 @@
}
},
"es-abstract": {
"version": "1.17.6",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz",
"integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==",
"version": "1.17.7",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.7.tgz",
"integrity": "sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g==",
"dev": true,
"requires": {
"es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.1",
"is-callable": "^1.2.0",
"is-regex": "^1.1.0",
"object-inspect": "^1.7.0",
"is-callable": "^1.2.2",
"is-regex": "^1.1.1",
"object-inspect": "^1.8.0",
"object-keys": "^1.1.1",
"object.assign": "^4.1.0",
"object.assign": "^4.1.1",
"string.prototype.trimend": "^1.0.1",
"string.prototype.trimstart": "^1.0.1"
}
@ -4555,13 +4555,35 @@
"dev": true
},
"object-is": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.2.tgz",
"integrity": "sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.3.tgz",
"integrity": "sha512-teyqLvFWzLkq5B9ki8FVWA902UER2qkxmdA4nLf+wjOLAWgxzCWZNCxpDq9MvE8MmhWNr+I8w3BN49Vx36Y6Xg==",
"dev": true,
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5"
"es-abstract": "^1.18.0-next.1"
},
"dependencies": {
"es-abstract": {
"version": "1.18.0-next.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz",
"integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==",
"dev": true,
"requires": {
"es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.1",
"is-callable": "^1.2.2",
"is-negative-zero": "^2.0.0",
"is-regex": "^1.1.1",
"object-inspect": "^1.8.0",
"object-keys": "^1.1.1",
"object.assign": "^4.1.1",
"string.prototype.trimend": "^1.0.1",
"string.prototype.trimstart": "^1.0.1"
}
}
}
},
"object-keys": {
@ -4592,21 +4614,21 @@
},
"dependencies": {
"es-abstract": {
"version": "1.18.0-next.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.0.tgz",
"integrity": "sha512-elZXTZXKn51hUBdJjSZGYRujuzilgXo8vSPQzjGYXLvSlGiCo8VO8ZGV3kjo9a0WNJJ57hENagwbtlRuHuzkcQ==",
"version": "1.18.0-next.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz",
"integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==",
"dev": true,
"requires": {
"es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.1",
"is-callable": "^1.2.0",
"is-callable": "^1.2.2",
"is-negative-zero": "^2.0.0",
"is-regex": "^1.1.1",
"object-inspect": "^1.8.0",
"object-keys": "^1.1.1",
"object.assign": "^4.1.0",
"object.assign": "^4.1.1",
"string.prototype.trimend": "^1.0.1",
"string.prototype.trimstart": "^1.0.1"
}
@ -4639,9 +4661,9 @@
"dev": true
},
"office-ui-fabric-react": {
"version": "7.144.1",
"resolved": "https://registry.npmjs.org/office-ui-fabric-react/-/office-ui-fabric-react-7.144.1.tgz",
"integrity": "sha512-lsJgKXEM4t16nuR4IVoYX5p8I4myV5p4NHMddnBeBlh/vEL8hdqRqAEnm8f7kgwZymXVxurD0FDHmU02VRfR0g==",
"version": "7.144.2",
"resolved": "https://registry.npmjs.org/office-ui-fabric-react/-/office-ui-fabric-react-7.144.2.tgz",
"integrity": "sha512-okn1iubHJJmSETIZW89pV4xMVRiVeD9k7qxH+xlNmMDgofE2NAv6XwxdeaR0rXMYMVkRNGgc/B+2iSM24rEXhg==",
"requires": {
"@fluentui/date-time-utilities": "^7.9.0",
"@fluentui/react-focus": "^7.16.9",

View file

@ -42,6 +42,7 @@
"@uifabric/react-hooks": "7.13.5",
"@yume-chan/adb": "^0.0.1",
"@yume-chan/adb-backend-web": "^0.0.1",
"@yume-chan/event": "^0.0.1",
"@yume-chan/struct": "^0.0.0",
"jmuxer": "1.2.0",
"react": "16.13.1",

View file

@ -13,7 +13,7 @@ interface ConnectProps {
onDeviceChange: (device: Adb | undefined) => void;
}
export default withDisplayName('Connect', ({
export default withDisplayName('Connect')(({
device,
onDeviceChange,
}: ConnectProps): JSX.Element | null => {
@ -154,7 +154,7 @@ export default withDisplayName('Connect', ({
<Dialog
hidden={!connecting}
dialogContentProps={{
title: 'Connecting',
title: 'Connecting...',
subText: 'Please authorize the connection on your device'
}}
>

View file

@ -11,7 +11,7 @@ export const ErrorDialogContext = React.createContext<ErrorDialogContext>({
show() { }
});
export default withDisplayName('ErrorDialogProvider', (props: PropsWithChildren<{}>) => {
export default withDisplayName('ErrorDialogProvider')((props: PropsWithChildren<{}>) => {
const [errorDialogVisible, { setTrue: showErrorDialog, setFalse: hideErrorDialog }] = useBoolean(false);
const [errorMessage, setErrorMessage] = useState<string | undefined>();

View file

@ -18,7 +18,7 @@ export interface CacheRouteProps extends RouteProps {
noCache?: boolean;
}
export const CacheRoute = withDisplayName('CacheRoute', (props: CacheRouteProps) => {
export const CacheRoute = withDisplayName('CacheRoute')((props: CacheRouteProps) => {
const match = useRouteMatch(props);
const everMatched = useRef(!!match);
@ -53,7 +53,7 @@ export interface CacheSwitchProps {
children: React.ReactNodeArray;
}
export const CacheSwitch = withDisplayName('CacheSwitch', (props: CacheSwitchProps) => {
export const CacheSwitch = withDisplayName('CacheSwitch')((props: CacheSwitchProps) => {
const location = useLocation();
let contextMatch = useRouteMatch();

View file

@ -6,7 +6,7 @@ import path from 'path';
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import StreamSaver from 'streamsaver';
import { ErrorDialogContext } from '../error-dialog';
import { CommandBar, withDisplayName } from '../utils';
import { CommandBar, delay, formatSize, formatSpeed, useSpeed, withDisplayName } from '../utils';
import { RouteProps } from './type';
initializeFileTypeIcons();
@ -29,25 +29,6 @@ const classNames = mergeStyleSets({
},
});
const units = [' B', ' KB', ' MB', ' GB'];
function formatSize(value: number): string {
let index = 0;
while (index < units.length && value > 1024) {
index += 1;
value /= 1024;
}
return value.toLocaleString(undefined, { maximumFractionDigits: 2 }) + units[index];
}
function extensionName(fileName: string): string {
const index = fileName.lastIndexOf('.');
if (index === -1) {
return '';
} else {
return fileName.slice(index);
}
}
const renderDetailsHeader: IRenderFunction<IDetailsHeaderProps> = (props?, defaultRender?) => {
if (!props || !defaultRender) {
return null;
@ -59,12 +40,6 @@ const renderDetailsHeader: IRenderFunction<IDetailsHeaderProps> = (props?, defau
});
};
function delay(time: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, time);
});
}
function createReadableStreamFromBufferIterator(
iterator: AsyncIterator<ArrayBuffer>
): ReadableStream<Uint8Array> {
@ -96,7 +71,7 @@ async function* chunkFile(file: File): AsyncGenerator<ArrayBuffer, void, void> {
}
}
export const FileManager = withDisplayName('FileManager', ({
export const FileManager = withDisplayName('FileManager')(({
device,
}: RouteProps): JSX.Element | null => {
const { show: showErrorDialog } = useContext(ErrorDialogContext);
@ -246,7 +221,7 @@ export const FileManager = withDisplayName('FileManager', ({
case LinuxFileType.Directory:
return <Icon {...getFileTypeIconProps({ size: 20, type: FileIconType.folder })} />;
case LinuxFileType.File:
return <Icon {...getFileTypeIconProps({ size: 20, extension: extensionName(item.name!) })} />;
return <Icon {...getFileTypeIconProps({ size: 20, extension: path.extname(item.name!) })} />;
default:
return <Icon {...getFileTypeIconProps({ size: 20, extension: 'txt' })} />;
}
@ -337,7 +312,7 @@ export const FileManager = withDisplayName('FileManager', ({
setCurrentPath(path.resolve(currentPath, item.name!));
break;
case LinuxFileType.File:
switch (extensionName(item.name!)) {
switch (path.extname(item.name!)) {
case '.jpg':
case '.png':
case '.svg':
@ -359,19 +334,10 @@ export const FileManager = withDisplayName('FileManager', ({
const [uploading, setUploading] = useState(false);
const [uploadPath, setUploadPath] = useState('');
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadedSize, setUploadedSize] = useState(0);
const [uploadTotalSize, setUploadTotalSize] = useState(0);
const [uploadSpeed, setUploadSpeed] = useState(0);
const [debouncedUploadedSize, uploadSpeed] = useSpeed(uploadedSize, uploadTotalSize);
const upload = useCallback(async (file: File) => {
let lastSecondUploadedSize = 0;
let currentUploadedSize = 0;
const intervalId = window.setInterval(() => {
setUploadedSize(currentUploadedSize);
setUploadSpeed(currentUploadedSize - lastSecondUploadedSize);
lastSecondUploadedSize = currentUploadedSize;
}, 1000);
const sync = await device!.sync();
try {
const itemPath = path.resolve(currentPath, file.name);
@ -383,10 +349,7 @@ export const FileManager = withDisplayName('FileManager', ({
chunkFile(file),
(LinuxFileType.File << 12) | 0o666,
file.lastModified / 1000,
(uploadedSize) => {
setUploadProgress(uploadedSize / file.size);
currentUploadedSize = uploadedSize;
},
setUploadedSize,
);
} catch (e) {
showErrorDialog(e.message);
@ -394,7 +357,6 @@ export const FileManager = withDisplayName('FileManager', ({
sync.dispose();
load();
setUploading(false);
window.clearInterval(intervalId);
}
}, [currentPath, device]);
@ -565,8 +527,8 @@ export const FileManager = withDisplayName('FileManager', ({
}}
>
<ProgressIndicator
description={`${formatSize(uploadedSize)} / ${formatSize(uploadTotalSize)} at ${formatSize(uploadSpeed)}/s`}
percentComplete={uploadProgress}
description={formatSpeed(debouncedUploadedSize, uploadTotalSize, uploadSpeed)}
percentComplete={uploadedSize / uploadTotalSize}
/>
</Dialog>
</StackItem>

View file

@ -3,7 +3,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react';
import { CommandBar, DeviceView, withDisplayName } from '../utils';
import { RouteProps } from './type';
export const FrameBuffer = withDisplayName('FrameBuffer', ({
export const FrameBuffer = withDisplayName('FrameBuffer')(({
device
}: RouteProps): JSX.Element | null => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);

View file

@ -15,7 +15,7 @@ interface CopyLinkProps {
href: string;
}
const CopyLink = withDisplayName('CopyLink', ({
const CopyLink = withDisplayName('CopyLink')(({
href,
}: CopyLinkProps) => {
const calloutTarget = useRef<HTMLButtonElement | null>(null);
@ -44,7 +44,7 @@ const CopyLink = withDisplayName('CopyLink', ({
);
});
export const Intro = withDisplayName('Intro', () => {
export const Intro = withDisplayName('Intro')(() => {
return (
<>
<Text block>

View file

@ -0,0 +1,55 @@
import { Struct, placeholder, StructInitType } from '@yume-chan/struct';
export const enum ScrcpyControlMessageType {
InjectKeycode,
InjectText,
InjectTouch,
InjectScroll,
BackOrScreenOn,
ExpandNotificationPanel,
CollapseNotificationPanel,
GetClipboard,
SetClipboard,
SetScreenPowerMode,
RotateDevice,
}
export const ScrcpySimpleControlMessage =
new Struct()
.uint8('type', undefined, placeholder<ScrcpyControlMessageType.BackOrScreenOn>());
export type ScrcpySimpleControlMessage = StructInitType<typeof ScrcpySimpleControlMessage>;
export const enum AndroidMotionEventAction {
Down,
Up,
Move,
Cancel,
Outside,
PointerDown,
PointerUp,
HoverMove,
Scroll,
HoverEnter,
HoverExit,
ButtonPress,
ButtonRelease,
}
export const ScrcpyInjectTouchControlMessage =
new Struct()
.uint8('type', undefined, ScrcpyControlMessageType.InjectTouch as const)
.uint8('action', undefined, placeholder<AndroidMotionEventAction>())
.uint64('pointerId')
.uint32('pointerX')
.uint32('pointerY')
.uint16('screenWidth')
.uint16('screenHeight')
.uint16('pressure')
.uint32('buttons');
export type ScrcpyInjectTouchControlMessage = StructInitType<typeof ScrcpyInjectTouchControlMessage>;
export type ScrcpyControlMessage =
ScrcpySimpleControlMessage |
ScrcpyInjectTouchControlMessage;

View file

@ -0,0 +1,60 @@
import { EventEmitter } from "@yume-chan/event";
import serverUrl from 'file-loader!./scrcpy-server';
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;
export function fetchServer(onProgress?: (e: [downloaded: number, total: number]) => void) {
if (!cachedValue) {
cachedValue = new FetchWithProgress(serverUrl);
}
if (onProgress) {
cachedValue.onProgress(onProgress);
onProgress([cachedValue.downloaded, cachedValue.total]);
}
return cachedValue.promise;
}

View file

@ -1,19 +1,13 @@
import { ICommandBarItemProps } from '@fluentui/react';
import { Dialog, ICommandBarItemProps, ProgressIndicator, Stack, StackItem } from '@fluentui/react';
import { AdbBufferedStream, AdbStream, EventQueue } from '@yume-chan/adb';
import { Struct } from '@yume-chan/struct';
import serverUrl from 'file-loader!./scrcpy-server';
import JMuxer from 'jmuxer';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { CommandBar, DeviceView, ExternalLink, withDisplayName } from '../../utils';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CommonStackTokens } from '../../styles';
import { CommandBar, DeviceView, DeviceViewRef, ExternalLink, formatSpeed, useSpeed, withDisplayName } from '../../utils';
import { RouteProps } from '../type';
let cachedServerBinary: Promise<ArrayBuffer> | undefined;
function getServerBinary() {
if (!cachedServerBinary) {
cachedServerBinary = fetch(serverUrl).then(response => response.arrayBuffer());
}
return cachedServerBinary;
}
import { AndroidMotionEventAction, ScrcpyControlMessage, ScrcpyControlMessageType, ScrcpyInjectTouchControlMessage, ScrcpySimpleControlMessage } from './control';
import { fetchServer } from './fetch-server';
const DeviceServerPath = '/data/local/tmp/scrcpy-server.jar';
@ -59,6 +53,7 @@ async function receiveVideo(stream: AdbBufferedStream, jmuxer: JMuxer) {
});
}
} catch (e) {
jmuxer.destroy();
return;
}
}
@ -86,6 +81,26 @@ async function receiveControl(stream: AdbBufferedStream) {
}
}
async function sendControl(stream: AdbBufferedStream, queue: EventQueue<ScrcpyControlMessage>) {
try {
while (true) {
const message = await queue.next();
let buffer: ArrayBuffer;
switch (message.type) {
case ScrcpyControlMessageType.InjectTouch:
buffer = ScrcpyInjectTouchControlMessage.serialize(message, stream);
break;
default:
buffer = ScrcpySimpleControlMessage.serialize(message, stream);
break;
}
await stream.write(buffer);
}
} catch (e) {
return;
}
}
export const enum ScrcpyLogLevel {
Debug = 'debug',
Info = 'info',
@ -101,7 +116,7 @@ export const enum ScrcpyScreenOrientation {
LandscapeFlipped = 3,
}
export const Scrcpy = withDisplayName('Scrcpy', ({
export const Scrcpy = withDisplayName('Scrcpy')(({
device
}: RouteProps): JSX.Element | null => {
const [running, setRunning] = useState(false);
@ -109,18 +124,38 @@ export const Scrcpy = withDisplayName('Scrcpy', ({
const videoRef = useRef<HTMLVideoElement | null>(null);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const handleVideoRef = useCallback((value: HTMLVideoElement | null) => {
videoRef.current = value;
if (value) {
value.onresize = () => {
setWidth(value.videoWidth);
setHeight(value.videoHeight);
const handleVideoRef = useCallback((video: HTMLVideoElement | null) => {
videoRef.current = video;
if (video) {
video.onresize = () => {
setWidth(video.videoWidth);
setHeight(video.videoHeight);
};
video.addEventListener('touchmove', e => {
e.preventDefault();
});
video.addEventListener('pause', () => {
console.log('???');
video.play();
});
}
}, []);
const serverRef = useRef<AdbStream | undefined>();
const controlStreamRef = useRef<AdbBufferedStream | undefined>();
const [connecting, setConnecting] = useState(false);
const [serverTotalSize, setServerTotalSize] = useState(0);
const [serverDownloadedSize, setServerDownloadedSize] = useState(0);
const [debouncedServerDownloadedSize, serverDownloadSpeed] = useSpeed(serverDownloadedSize, serverTotalSize);
const [serverUploadedSize, setServerUploadedSize] = useState(0);
const [debouncedServerUploadedSize, serverUploadSpeed] = useSpeed(serverUploadedSize, serverTotalSize);
const serverRef = useRef<AdbStream>();
const eventQueueRef = useRef<EventQueue<ScrcpyControlMessage>>();
const controlStreamRef = useRef<AdbBufferedStream>();
const start = useCallback(() => {
if (!device) {
@ -128,10 +163,24 @@ export const Scrcpy = withDisplayName('Scrcpy', ({
}
(async () => {
const serverBuffer = await getServerBinary();
setServerTotalSize(0);
setServerDownloadedSize(0);
setServerUploadedSize(0);
setConnecting(true);
const serverBuffer = await fetchServer(([downloaded, total]) => {
setServerDownloadedSize(downloaded);
setServerTotalSize(total);
});
const sync = await device.sync();
await sync.write(DeviceServerPath, serverBuffer);
await sync.write(
DeviceServerPath,
serverBuffer,
undefined,
undefined,
setServerUploadedSize
);
const listener = new EventQueue<AdbStream>();
const reverseDeviceAddress = await device.reverse.add('localabstract:scrcpy', 27183, {
@ -187,41 +236,65 @@ export const Scrcpy = withDisplayName('Scrcpy', ({
});
serverRef.current = server;
setConnecting(false);
setRunning(true);
eventQueueRef.current = new EventQueue<ScrcpyControlMessage>();
await Promise.all([
receiveVideo(videoStream, jmuxer),
receiveControl(controlStream),
sendControl(controlStream, eventQueueRef.current),
]);
jmuxer.destroy();
await server.close();
serverRef.current = undefined;
setRunning(false);
stop();
})();
}, [device]);
const stop = useCallback(() => {
serverRef.current!.close();
if (!serverRef.current) {
return;
}
eventQueueRef.current!.end();
serverRef.current.close();
serverRef.current = undefined;
setRunning(false);
}, []);
const deviceViewRef = useRef<DeviceViewRef | null>(null);
const commandBarItems = useMemo((): ICommandBarItemProps[] => {
const reuslt: ICommandBarItemProps[] = [];
if (running) {
return [{
reuslt.push({
key: 'stop',
iconProps: { iconName: 'Stop' },
text: 'Stop',
onClick: stop,
}];
});
} else {
return [{
reuslt.push({
key: 'start',
disabled: !device,
iconProps: { iconName: 'Play' },
text: 'Start',
onClick: start,
}];
});
}
reuslt.push({
key: 'fullscreen',
disabled: !running,
iconProps: { iconName: 'Fullscreen' },
text: 'Fullscreen',
onClick: () => { deviceViewRef.current?.enterFullscreen(); },
});
return reuslt;
}, [device, running, start]);
const commandBarFarItems = useMemo((): ICommandBarItemProps[] => [
@ -248,41 +321,121 @@ export const Scrcpy = withDisplayName('Scrcpy', ({
}
], []);
const handleTouchStart = useCallback((e: React.TouchEvent<HTMLVideoElement>) => {
controlStreamRef.current!.write(new ArrayBuffer(10));
}, []);
const injectTouch = useCallback((
action: AndroidMotionEventAction,
e: React.PointerEvent<HTMLVideoElement>
) => {
e.preventDefault();
e.stopPropagation();
const handleTouchMove = useCallback((e: React.TouchEvent<HTMLVideoElement>) => {
const view = e.currentTarget.getBoundingClientRect();
const pointerViewX = e.clientX - view.x;
const pointerViewY = e.clientY - view.y;
const pointerScreenX = pointerViewX / view.width * width;
const pointerScreenY = pointerViewY / view.height * height;
}, []);
eventQueueRef.current!.push({
type: ScrcpyControlMessageType.InjectTouch,
action,
buttons: 0,
pointerId: BigInt(e.pointerId),
pointerX: pointerScreenX,
pointerY: pointerScreenY,
pressure: e.pressure * 65535,
screenWidth: width,
screenHeight: height,
});
}, [width, height]);
const handleTouchEnd = useCallback((e: React.TouchEvent<HTMLVideoElement>) => {
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLVideoElement>) => {
injectTouch(AndroidMotionEventAction.Down, e);
}, [injectTouch]);
}, []);
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLVideoElement>) => {
if (e.pressure > 0) {
injectTouch(AndroidMotionEventAction.Move, e);
}
}, [injectTouch]);
const handlePointerUp = useCallback((e: React.PointerEvent<HTMLVideoElement>) => {
injectTouch(AndroidMotionEventAction.Up, e);
}, [injectTouch]);
const handleKeyPress = useCallback((e: React.KeyboardEvent<HTMLVideoElement>) => {
}, []);
const handleCanPlay = useCallback(() => {
videoRef.current!.play();
}, []);
// useEffect(() => {
// function handlePageShow() {
// if (document.visibilityState !== 'visible') {
// return;
// }
// const video = videoRef.current;
// if (!video || video.buffered.length === 0) {
// return;
// }
// video.currentTime = video.buffered.end(0);
// }
// document.addEventListener('visibilitychange', handlePageShow);
// return () => {
// document.removeEventListener('visibilitychange', handlePageShow);
// };
// }, []);
return (
<>
<CommandBar items={commandBarItems} farItems={commandBarFarItems} />
<DeviceView width={width} height={height}>
<DeviceView ref={deviceViewRef} width={width} height={height}>
<video
ref={handleVideoRef}
autoPlay
width={width}
height={height}
onCanPlay={handleCanPlay}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onKeyPress={handleKeyPress}
/>
</DeviceView>
<Dialog
hidden={!connecting}
dialogContentProps={{
title: 'Connecting...'
}}
>
<Stack tokens={CommonStackTokens}>
<StackItem>
<ProgressIndicator
label="1. Downloading scrcpy server..."
progressHidden={serverTotalSize === 0}
percentComplete={serverDownloadedSize / serverTotalSize}
description={formatSpeed(debouncedServerDownloadedSize, serverTotalSize, serverDownloadSpeed)}
/>
</StackItem>
<StackItem>
<ProgressIndicator
label="2. Pushing scrcpy server to device..."
progressHidden={serverTotalSize === 0 || serverDownloadedSize !== serverTotalSize}
percentComplete={serverUploadedSize / serverTotalSize}
description={formatSpeed(debouncedServerUploadedSize, serverTotalSize, serverUploadSpeed)}
/>
</StackItem>
<StackItem>
<ProgressIndicator
label="3. Starting scrcpy server on device..."
progressHidden={serverTotalSize === 0 || serverUploadedSize !== serverTotalSize}
/>
</StackItem>
</Stack>
</Dialog>
</>
);
});

View file

@ -16,7 +16,7 @@ const ResizeObserverStyle: CSSProperties = {
const UpIconProps = { iconName: 'ChevronUp' };
const DownIconProps = { iconName: 'ChevronDown' };
export const Shell = withDisplayName('Shell', ({
export const Shell = withDisplayName('Shell')(({
device,
}: RouteProps): JSX.Element | null => {
const [findKeyword, setFindKeyword] = useState('');

View file

@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { CommandBar, withDisplayName } from '../utils';
import { RouteProps } from './type';
export const TcpIp = withDisplayName('TcpIp', ({
export const TcpIp = withDisplayName('TcpIp')(({
device
}: RouteProps): JSX.Element | null => {
const [serviceListenAddrs, setServiceListenAddrs] = useState<string[] | undefined>();

View file

@ -9,7 +9,7 @@ const ContainerStyles = {
}
} as const;
export const CommandBar = withDisplayName('CommandBar', (props: ICommandBarProps) => {
export const CommandBar = withDisplayName('CommandBar')((props: ICommandBarProps) => {
return (
<StackItem styles={ContainerStyles}>
<FluentCommandBar {...props} />

View file

@ -1,7 +1,7 @@
import { StackItem } from '@fluentui/react';
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import React, { ReactNode, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { ResizeObserver } from './resize-observer';
import { withDisplayName } from './with-display-name';
import { forwardRef } from './with-display-name';
export interface DeviceViewProps {
width: number;
@ -11,7 +11,15 @@ export interface DeviceViewProps {
children: ReactNode;
}
export const DeviceView = withDisplayName('DeviceView', ({ width, height, children }: DeviceViewProps) => {
export interface DeviceViewRef {
enterFullscreen(): void;
}
export const DeviceView = forwardRef<DeviceViewRef>('DeviceView')(({
width,
height,
children,
}: DeviceViewProps, ref) => {
const [containerWidth, setContainerWidth] = useState(0);
const [containerHeight, setContainerHeight] = useState(0);
const [scale, setScale] = useState(1);
@ -36,9 +44,15 @@ export const DeviceView = withDisplayName('DeviceView', ({ width, height, childr
}
}, [width, height, containerWidth, containerHeight]);
const containerRef = useRef<HTMLDivElement | null>(null);
useImperativeHandle(ref, () => ({
enterFullscreen() { containerRef.current!.requestFullscreen(); },
}), []);
return (
<StackItem grow>
<ResizeObserver
ref={containerRef}
style={{
position: 'relative',
width: '100%',

View file

@ -12,7 +12,7 @@ export interface ExternalLinkProps {
children?: ReactNode;
}
export const ExternalLink = withDisplayName('ExternalLink', ({
export const ExternalLink = withDisplayName('ExternalLink')(({
href,
spaceBefore,
spaceAfter,

View file

@ -0,0 +1,48 @@
import { useRef } from 'react';
const units = [' B', ' KB', ' MB', ' GB'];
export function formatSize(value: number): string {
let index = 0;
while (index < units.length && value > 1024) {
index += 1;
value /= 1024;
}
return value.toLocaleString(undefined, { maximumFractionDigits: 2 }) + units[index];
}
export function formatSpeed(completed: number, total: number, speed: number): string | undefined {
if (total === 0) {
return undefined;
}
return `${formatSize(completed)} of ${formatSize(total)} (${formatSize(speed)}/s)`;
}
export function useDebounced<T>(value: T, interval = 1000): T {
const startTime = useRef(0);
const startValue = useRef(value);
const now = Date.now();
if (now - startTime.current > interval) {
startTime.current = now;
startValue.current = value;
}
return startValue.current;
}
export function useSpeed(completed: number, total: number): [completed: number, speed: number] {
const debouncedCompleted = useDebounced(completed);
if (completed === total) {
return [completed, completed - debouncedCompleted];
} else {
return [debouncedCompleted, completed - debouncedCompleted];
}
}
export function delay(time: number): Promise<void> {
return new Promise(resolve => {
window.setTimeout(resolve, time);
});
}

View file

@ -1,5 +1,6 @@
export * from './command-bar';
export * from './device-view';
export * from './external-link';
export * from './file-size';
export * from './resize-observer';
export * from './with-display-name';

View file

@ -15,7 +15,7 @@ const iframeStyle: CSSProperties = {
visibility: 'hidden',
};
export const ResizeObserver = forwardRef('ResizeObserver', ({
export const ResizeObserver = forwardRef<HTMLDivElement>('ResizeObserver')(({
onResize,
style,
children,
@ -25,7 +25,7 @@ export const ResizeObserver = forwardRef('ResizeObserver', ({
onResizeRef.current = onResize;
const containerRef = useRef<HTMLDivElement | null>(null);
const mergedRef = createMergedRef()(ref, containerRef);
const mergedRef = createMergedRef<HTMLDivElement | null>()(ref, containerRef);
const handleResize = useCallback(() => {
const { width, height } = containerRef.current!.getBoundingClientRect();

View file

@ -1,18 +1,14 @@
import React from 'react';
import { memo, NamedExoticComponent } from 'react';
import React, { memo } from 'react';
export function withDisplayName<P extends object>(
name: string,
Component: React.FunctionComponent<P>
): NamedExoticComponent<P> {
Component.displayName = name;
return memo(Component);
export function withDisplayName(name: string) {
return <P extends object>(Component: React.FunctionComponent<P>) => {
Component.displayName = name;
return memo(Component);
};
}
export function forwardRef<P extends object>(
name: string,
Component: React.ForwardRefRenderFunction<unknown, P>
) {
Component.displayName = name;
return memo(React.forwardRef(Component));
export function forwardRef<T>(name: string) {
return <P extends object>(Component: React.ForwardRefRenderFunction<T, P>) => {
return withDisplayName(name)(React.forwardRef(Component));
};
}

View file

@ -9,6 +9,7 @@ export namespace Number {
number;
export const enum SubType {
Uint8,
Uint16,
Int32,
Uint32,
@ -17,6 +18,7 @@ export namespace Number {
}
export const SizeMap: Record<SubType, number> = {
[SubType.Uint8]: 1,
[SubType.Uint16]: 2,
[SubType.Int32]: 4,
[SubType.Uint32]: 4,
@ -25,6 +27,7 @@ export namespace Number {
};
export const DataViewGetterMap = {
[SubType.Uint8]: 'getUint8',
[SubType.Uint16]: 'getUint16',
[SubType.Int32]: 'getInt32',
[SubType.Uint32]: 'getUint32',
@ -33,6 +36,7 @@ export namespace Number {
} as const;
export const DataViewSetterMap = {
[SubType.Uint8]: 'setUint8',
[SubType.Uint16]: 'setUint16',
[SubType.Int32]: 'setInt32',
[SubType.Uint32]: 'setUint32',

View file

@ -192,6 +192,22 @@ export default class Struct<
});
}
public uint8<
TName extends string,
TTypeScriptType = Number.TypeScriptType<Number.SubType.Uint8>
>(
name: TName,
options: FieldDescriptorBaseOptions = {},
_typescriptType?: TTypeScriptType,
) {
return this.number(
name,
Number.SubType.Uint8,
options,
_typescriptType
);
}
public uint16<
TName extends string,
TTypeScriptType = Number.TypeScriptType<Number.SubType.Uint16>