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; 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: ( <>

Scrcpy developed by Genymobile can display the screen with low latency (1~2 frames) and control the device, all without root access.

This is a TypeScript implementation of the client part. Paired with official pre-built server binary.

), 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) => { if (e.button !== 0) { return; } e.currentTarget.setPointerCapture(e.pointerId); this.client!.pressBackOrTurnOnScreen(AndroidKeyEventAction.Down); }; handleBackPointerUp = (e: React.PointerEvent) => { if (e.button !== 0) { return; } this.client!.pressBackOrTurnOnScreen(AndroidKeyEventAction.Up); }; handleHomePointerDown = (e: React.PointerEvent) => { if (e.button !== 0) { return; } e.currentTarget.setPointerCapture(e.pointerId); this.homeKeyRepeater?.press(); }; handleHomePointerUp = (e: React.PointerEvent) => { if (e.button !== 0) { return; } this.homeKeyRepeater?.release(); }; handleAppSwitchPointerDown = (e: React.PointerEvent) => { if (e.button !== 0) { return; } e.currentTarget.setPointerCapture(e.pointerId); this.appSwitchKeyRepeater?.press(); }; handleAppSwitchPointerUp = (e: React.PointerEvent) => { 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, 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 ) => { 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) => { this.rendererContainer!.focus(); e.preventDefault(); e.currentTarget.setPointerCapture(e.pointerId); this.injectTouch(AndroidMotionEventAction.Down, e); }; handlePointerMove = (e: React.PointerEvent) => { this.injectTouch( e.buttons === 0 ? AndroidMotionEventAction.HoverMove : AndroidMotionEventAction.Move, e ); }; handlePointerUp = (e: React.PointerEvent) => { 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) => { e.preventDefault(); }; handleKeyDown = async (e: React.KeyboardEvent) => { 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)[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 ( <> ); }); const Scrcpy: NextPage = () => { const bottomElement = useMemo(() => state.navigationBarVisible && ( ), [state.navigationBarVisible] ); return ( Scrcpy - WebADB
{state.log.map((line, index) => (
{line}
))}
Changes will take effect on next connection
({ key: item, text: item }))} selectedKey={state.selectedEncoder} placeholder="Connect once to retrieve encoder list" onChange={state.handleCurrentEncoderChange} /> {state.decoders.length > 1 && ( ({ key: item.name, text: item.name, data: item }))} selectedKey={state.selectedDecoder.name} onChange={state.handleSelectedDecoderChange} /> )} Use forward connection{' '} } checked={state.tunnelForward} onChange={state.handleTunnelForwardChange} />
); }; export default observer(Scrcpy);