feat(scrcpy): video stream works

This commit is contained in:
Simon Chan 2020-10-01 16:11:29 +08:00
parent 870a5b969e
commit 28fe85ab80
14 changed files with 232 additions and 54 deletions

View file

@ -5,14 +5,17 @@
"CNXN", "CNXN",
"Callout", "Callout",
"Deserialization", "Deserialization",
"Muxer",
"PKCS", "PKCS",
"RSASSA", "RSASSA",
"Scrcpy", "Scrcpy",
"WRTE", "WRTE",
"addrs", "addrs",
"brotli",
"fluentui", "fluentui",
"genymobile", "genymobile",
"getprop", "getprop",
"jmuxer",
"killforward", "killforward",
"lapo", "lapo",
"localabstract", "localabstract",
@ -32,6 +35,7 @@
"webusb", "webusb",
"wifi", "wifi",
"wirelessly", "wirelessly",
"yume" "yume",
"zstd"
] ]
} }

View file

@ -17,7 +17,8 @@ export enum AdbPropKey {
export class Adb { export class Adb {
private packetDispatcher: AdbPacketDispatcher; private packetDispatcher: AdbPacketDispatcher;
private backend: AdbBackend; public readonly backend: AdbBackend;
public get onDisconnected() { return this.backend.onDisconnected; } public get onDisconnected() { return this.backend.onDisconnected; }
private _connected = false; private _connected = false;

View file

@ -65,15 +65,13 @@ export class AdbReverseCommand extends AutoDisposable {
const success = this.dispatcher.backend.decodeUtf8(await buffered.read(4)) === 'OKAY'; const success = this.dispatcher.backend.decodeUtf8(await buffered.read(4)) === 'OKAY';
if (success) { if (success) {
const response = await AdbReverseStringResponse.deserialize(buffered); if (deviceAddress.startsWith('tcp:')) {
const response = await AdbReverseStringResponse.deserialize(buffered);
if (deviceAddress === 'tcp:0') {
deviceAddress = `tcp:${Number.parseInt(response.content!, 10)}`; deviceAddress = `tcp:${Number.parseInt(response.content!, 10)}`;
} }
this.localPortToHandler.set(localPort, handler); this.localPortToHandler.set(localPort, handler);
this.deviceAddressToLocalPort.set(deviceAddress, localPort); this.deviceAddressToLocalPort.set(deviceAddress, localPort);
return deviceAddress; return deviceAddress;
} else { } else {
return await AdbReverseErrorResponse.deserialize(buffered); return await AdbReverseErrorResponse.deserialize(buffered);

View file

@ -37,15 +37,28 @@ export class BufferedStream<T extends Stream> {
index = buffer.byteLength; index = buffer.byteLength;
this.buffer = undefined; this.buffer = undefined;
} else { } else {
const buffer = await this.stream.read(length);
if (buffer.byteLength === length) {
return buffer;
}
if (buffer.byteLength > length) {
this.buffer = new Uint8Array(buffer, length);
return buffer.slice(0, length);
}
array = new Uint8Array(length); array = new Uint8Array(length);
index = 0; array.set(new Uint8Array(buffer), 0);
index = buffer.byteLength;
} }
while (index < length) { while (index < length) {
const buffer = await this.stream.read(length - index); const left = length - index;
if (buffer.byteLength > length - index) {
array.set(new Uint8Array(buffer, 0, length), index); const buffer = await this.stream.read(left);
this.buffer = new Uint8Array(buffer, length); if (buffer.byteLength > left) {
array.set(new Uint8Array(buffer, 0, left), index);
this.buffer = new Uint8Array(buffer, left);
return array.buffer; return array.buffer;
} }

View file

@ -3858,6 +3858,11 @@
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=",
"dev": true "dev": true
}, },
"jmuxer": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/jmuxer/-/jmuxer-1.2.0.tgz",
"integrity": "sha512-ekJ/3D/poGdkNtwcpe4hXMOFTJuki9dkT19dmZec5eXB3nlAk1+DEtOjigwthx1d5+SA87llPKzxGiqBip2qrw=="
},
"js-tokens": { "js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View file

@ -43,6 +43,7 @@
"@yume-chan/adb": "^0.0.1", "@yume-chan/adb": "^0.0.1",
"@yume-chan/adb-backend-web": "^0.0.1", "@yume-chan/adb-backend-web": "^0.0.1",
"@yume-chan/struct": "^0.0.0", "@yume-chan/struct": "^0.0.0",
"jmuxer": "^1.2.0",
"react": "16.13.1", "react": "16.13.1",
"react-dom": "16.13.1", "react-dom": "16.13.1",
"react-router-dom": "5.2.0", "react-router-dom": "5.2.0",

View file

@ -8,7 +8,7 @@ import Connect from './connect';
import ErrorDialogProvider from './error-dialog'; import ErrorDialogProvider from './error-dialog';
import './index.css'; import './index.css';
import { CacheRoute, CacheSwitch } from './router'; import { CacheRoute, CacheSwitch } from './router';
import { FileManager, FrameBuffer, Intro, Shell, TcpIp } from './routes'; import { FileManager, FrameBuffer, Intro, Scrcpy, Shell, TcpIp } from './routes';
initializeIcons(); initializeIcons();
@ -87,6 +87,14 @@ function App(): JSX.Element | null {
<FrameBuffer device={device} /> <FrameBuffer device={device} />
), ),
}, },
{
path: '/scrcpy',
name: 'Scrcpy',
noCache: true,
children: (
<Scrcpy device={device} />
),
},
], [device]); ], [device]);
return ( return (

View file

@ -1,6 +1,7 @@
export * from './file-manager'; export * from './file-manager';
export * from './framebuffer'; export * from './framebuffer';
export * from './intro'; export * from './intro';
export * from './scrcpy';
export * from './shell'; export * from './shell';
export * from './tcpip'; export * from './tcpip';
export * from './type'; export * from './type';

View file

@ -1,27 +1,42 @@
import { PrimaryButton } from '@fluentui/react'; import { PrimaryButton, Stack, StackItem } from '@fluentui/react';
import { AdbBufferedStream, AdbStream, EventQueue } from '@yume-chan/adb'; import { AdbBufferedStream, AdbStream, EventQueue } from '@yume-chan/adb';
import { Struct } from '@yume-chan/struct'; import { Struct } from '@yume-chan/struct';
import serverUrl from 'file-loader!./scrcpy-server'; import serverUrl from 'file-loader!./scrcpy-server';
import JMuxer from 'jmuxer';
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import { withDisplayName } from '../../utils'; import { ResizeObserver, withDisplayName } from '../../utils';
import { RouteProps } from '../type'; import { RouteProps } from '../type';
const DeviceServerPath = '/data/local/tmp/scrcpy-server.jar'; const DeviceServerPath = '/data/local/tmp/scrcpy-server.jar';
const Size = const Size =
new Struct({ littleEndian: true }) new Struct()
.uint16('width') .uint16('width')
.uint16('height'); .uint16('height');
const VideoPacket = const VideoPacket =
new Struct() new Struct()
.uint64('pts') .int64('pts')
.uint32('size') .uint32('size')
.arrayBuffer('data', { lengthField: 'size' }); .arrayBuffer('data', { lengthField: 'size' });
async function receiveVideo(stream: AdbBufferedStream) { const NoPts = BigInt(-1);
async function receiveVideo(stream: AdbBufferedStream, jmuxer: JMuxer) {
let lastPts = BigInt(0);
while (true) { while (true) {
await VideoPacket.deserialize(stream); const { pts, data } = await VideoPacket.deserialize(stream);
let duration: number | undefined;
if (pts !== NoPts) {
duration = Number(pts - lastPts) / 1000;
lastPts = pts;
}
jmuxer.feed({
video: new Uint8Array(data!),
duration,
});
} }
} }
@ -44,16 +59,34 @@ async function receiveControl(stream: AdbBufferedStream) {
} }
} }
export const enum ScrcpyLogLevel {
Debug = 'debug',
Info = 'info',
Warn = 'warn',
Error = 'error',
}
export const enum ScrcpyScreenOrientation {
Unlocked = -1,
Portrait = 0,
Landscape = 1,
PortraitFlipped = 2,
LandscapeFlipped = 3,
}
export const Scrcpy = withDisplayName('Scrcpy', ({ export const Scrcpy = withDisplayName('Scrcpy', ({
device device
}: RouteProps): JSX.Element | null => { }: RouteProps): JSX.Element | null => {
const [width, setWidth] = useState(0); const [running, setRunning] = useState(false);
const [height, setHeight] = useState(0);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [videoWidth, setVideoWidth] = useState(0);
const [videoHeight, setVideoHeight] = useState(0);
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
const controlStreamRef = useRef<AdbBufferedStream | undefined>(); const controlStreamRef = useRef<AdbBufferedStream | undefined>();
const connect = useCallback(async () => { const start = useCallback(async () => {
if (!device) { if (!device) {
return; return;
} }
@ -76,11 +109,11 @@ export const Scrcpy = withDisplayName('Scrcpy', ({
'/', // unused '/', // unused
'com.genymobile.scrcpy.Server', 'com.genymobile.scrcpy.Server',
'1.16', // SCRCPY_VERSION '1.16', // SCRCPY_VERSION
'error', // log_level ScrcpyLogLevel.Debug,
'0', // max_size (0: unlimited) '0', // max_size (0: unlimited)
'8000000', // bit_rate '8000000', // bit_rate
'0', // max_fps '0', // max_fps
'-1', // lock_video_orientation (-1: unlocked) ScrcpyScreenOrientation.Unlocked.toString(), // lock_video_orientation (-1: unlocked)
'false', // tunnel_forward 'false', // tunnel_forward
'-', // crop '-', // crop
'true', // always send frame meta (packet boundaries + timestamp) 'true', // always send frame meta (packet boundaries + timestamp)
@ -90,61 +123,105 @@ export const Scrcpy = withDisplayName('Scrcpy', ({
'true', // stay_awake 'true', // stay_awake
'-', // codec_options '-', // codec_options
); );
server.onData(data => {
console.log(device.backend.decodeUtf8(data));
});
server.onClose(() => {
console.log('server stopped');
});
const videoStream = new AdbBufferedStream(await listener.next()); const videoStream = new AdbBufferedStream(await listener.next());
const controlStream = new AdbBufferedStream(await listener.next()); const controlStream = new AdbBufferedStream(await listener.next());
controlStreamRef.current = controlStream; controlStreamRef.current = controlStream;
await device.reverse.remove(reverseDeviceAddress); device.reverse.remove(reverseDeviceAddress);
// device name, we have already knew it from adb // device name, we have already knew it from adb
await videoStream.read(64); await videoStream.read(64);
const { width, height } = await Size.deserialize(videoStream); const { width, height } = await Size.deserialize(videoStream);
setWidth(width); setVideoWidth(width);
setHeight(height); setVideoHeight(height);
const jmuxer = new JMuxer({
node: videoRef.current!,
mode: 'video',
flushingTime: 0,
});
await Promise.all([ await Promise.all([
receiveVideo(videoStream), receiveVideo(videoStream, jmuxer),
receiveControl(controlStream), receiveControl(controlStream),
]); ]);
jmuxer.destroy();
await server.close(); await server.close();
}, [device]); }, [device]);
const handleTouchStart = useCallback((e: React.TouchEvent<HTMLDivElement>) => { const handleTouchStart = useCallback((e: React.TouchEvent<HTMLVideoElement>) => {
controlStreamRef.current!.write(new ArrayBuffer(10)); controlStreamRef.current!.write(new ArrayBuffer(10));
}, []); }, []);
const handleTouchMove = useCallback((e: React.TouchEvent<HTMLDivElement>) => { const handleTouchMove = useCallback((e: React.TouchEvent<HTMLVideoElement>) => {
}, []); }, []);
const handleTouchEnd = useCallback((e: React.TouchEvent<HTMLDivElement>) => { const handleTouchEnd = useCallback((e: React.TouchEvent<HTMLVideoElement>) => {
}, []); }, []);
const handleKeyPress = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => { const handleKeyPress = useCallback((e: React.KeyboardEvent<HTMLVideoElement>) => {
}, []); }, []);
const handleCanPlay = useCallback(() => {
videoRef.current!.play();
}, []);
const handleResize = useCallback((width: number, height: number) => {
if (videoWidth === 0) {
setScale(1);
return;
}
const videoRatio = videoWidth / videoHeight;
const containerRatio = width / height;
if (videoRatio > containerRatio) {
setScale(width / videoWidth);
} else {
setScale(height / videoHeight);
}
}, [videoWidth, videoHeight]);
return ( return (
<> <>
<PrimaryButton <StackItem>
content="Connect" <Stack horizontal>
onClick={connect} <PrimaryButton
/> text="Start"
<video disabled={!device}
width={width} onClick={start}
height={height} />
style={{ transform: `scale(${scale})` }} </Stack>
/> </StackItem>
<div <StackItem grow>
onTouchStart={handleTouchStart} <ResizeObserver
onTouchMove={handleTouchMove} style={{ position: 'relative', width: '100%', height: '100%' }}
onTouchEnd={handleTouchEnd} onResize={handleResize}
onKeyPress={handleKeyPress} >
/> <video
ref={videoRef}
width={videoWidth}
height={videoHeight}
style={{ position: 'absolute', transform: `scale(${scale})`, transformOrigin: 'top left' }}
onCanPlay={handleCanPlay}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onKeyPress={handleKeyPress}
/>
</ResizeObserver>
</StackItem>
</> </>
); );
}); });

View file

@ -41,3 +41,37 @@ declare module 'streamsaver' {
} }
declare module 'file-loader!*'; declare module 'file-loader!*';
declare module 'jmuxer' {
export interface JMuxerOptions {
node: string | HTMLVideoElement;
mode?: 'video' | 'audio' | 'both';
flushingTime?: number;
clearBuffer?: boolean;
fps?: number;
onReady?: () => void;
debug?: boolean;
}
export interface JMuxerData {
video?: Uint8Array;
audio?: Uint8Array;
duration?: number;
}
export default class JMuxer {
constructor(options: JMuxerOptions);
feed(data: JMuxerData): void;
destroy(): void;
}
}

View file

@ -1,8 +1,9 @@
import { createMergedRef } from '@fluentui/react';
import React, { CSSProperties, HTMLAttributes, PropsWithChildren, useCallback, useRef } from 'react'; import React, { CSSProperties, HTMLAttributes, PropsWithChildren, useCallback, useRef } from 'react';
import { withDisplayName } from './with-display-name'; import { forwardRef } from './with-display-name';
export interface ResizeObserverProps extends HTMLAttributes<HTMLDivElement>, PropsWithChildren<{}> { export interface ResizeObserverProps extends HTMLAttributes<HTMLDivElement>, PropsWithChildren<{}> {
onResize: () => void; onResize: (width: number, height: number) => void;
} }
const iframeStyle: CSSProperties = { const iframeStyle: CSSProperties = {
@ -14,17 +15,21 @@ const iframeStyle: CSSProperties = {
visibility: 'hidden', visibility: 'hidden',
}; };
export const ResizeObserver = withDisplayName('ResizeObserver', ({ export const ResizeObserver = forwardRef('ResizeObserver', ({
onResize, onResize,
style, style,
children, children,
...rest ...rest
}: ResizeObserverProps): JSX.Element | null => { }: ResizeObserverProps, ref): JSX.Element | null => {
const onResizeRef = useRef<() => void>(onResize); const onResizeRef = useRef<(width: number, height: number) => void>(onResize);
onResizeRef.current = onResize; onResizeRef.current = onResize;
const containerRef = useRef<HTMLDivElement | null>(null);
const mergedRef = createMergedRef()(ref, containerRef);
const handleResize = useCallback(() => { const handleResize = useCallback(() => {
onResizeRef.current(); const { width, height } = containerRef.current!.getBoundingClientRect();
onResizeRef.current(width, height);
}, []); }, []);
const handleIframeRef = useCallback((element: HTMLIFrameElement | null) => { const handleIframeRef = useCallback((element: HTMLIFrameElement | null) => {
@ -46,7 +51,7 @@ export const ResizeObserver = withDisplayName('ResizeObserver', ({
}, [style]); }, [style]);
return ( return (
<div style={containerStyle} {...rest}> <div ref={mergedRef} style={containerStyle} {...rest}>
<iframe ref={handleIframeRef} style={iframeStyle} /> <iframe ref={handleIframeRef} style={iframeStyle} />
{children} {children}
</div> </div>

View file

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

View file

@ -4,13 +4,16 @@ import { FieldDescriptorBase, FieldDescriptorBaseOptions, FieldType } from './de
export namespace Number { export namespace Number {
export type TypeScriptType<T extends SubType> = export type TypeScriptType<T extends SubType> =
T extends SubType.Uint64 ? bigint : number; T extends SubType.Uint64 ? bigint :
T extends SubType.Int64 ? bigint :
number;
export const enum SubType { export const enum SubType {
Uint16, Uint16,
Int32, Int32,
Uint32, Uint32,
Uint64, Uint64,
Int64,
} }
export const SizeMap: Record<SubType, number> = { export const SizeMap: Record<SubType, number> = {
@ -18,6 +21,7 @@ export namespace Number {
[SubType.Int32]: 4, [SubType.Int32]: 4,
[SubType.Uint32]: 4, [SubType.Uint32]: 4,
[SubType.Uint64]: 8, [SubType.Uint64]: 8,
[SubType.Int64]: 8,
}; };
export const DataViewGetterMap = { export const DataViewGetterMap = {
@ -25,6 +29,7 @@ export namespace Number {
[SubType.Int32]: 'getInt32', [SubType.Int32]: 'getInt32',
[SubType.Uint32]: 'getUint32', [SubType.Uint32]: 'getUint32',
[SubType.Uint64]: 'getBigUint64', [SubType.Uint64]: 'getBigUint64',
[SubType.Int64]: 'getBigInt64',
} as const; } as const;
export const DataViewSetterMap = { export const DataViewSetterMap = {
@ -32,6 +37,7 @@ export namespace Number {
[SubType.Int32]: 'setInt32', [SubType.Int32]: 'setInt32',
[SubType.Uint32]: 'setUint32', [SubType.Uint32]: 'setUint32',
[SubType.Uint64]: 'setBigUint64', [SubType.Uint64]: 'setBigUint64',
[SubType.Int64]: 'setBigInt64',
} as const; } as const;
} }

View file

@ -256,6 +256,22 @@ export default class Struct<
); );
} }
public int64<
TName extends string,
TTypeScriptType = Number.TypeScriptType<Number.SubType.Int64>
>(
name: TName,
options: FieldDescriptorBaseOptions = {},
_typescriptType?: TTypeScriptType,
) {
return this.number(
name,
Number.SubType.Int64,
options,
_typescriptType
);
}
private array: AddArrayFieldDescriptor<TResult, TInit, TExtra, TAfterParsed> = ( private array: AddArrayFieldDescriptor<TResult, TInit, TExtra, TAfterParsed> = (
name: string, name: string,
type: Array.SubType, type: Array.SubType,