Zurück zum Blog
Architecture15 min read

Screen Mirror: Low-Latency-Casting-Architektur

Dieser Artikel behandelt den End-to-End-Entwurf von PlainApps Screen-Mirror-System: Wie Android mit MediaCodec H.264/Opus hardwarekodiert und über WebSocket per benutzerdefiniertem Binärprotokoll überträgt, wie die Webseite per WebCodecs dekodiert und per WebGL2 ohne CPU-Kopie rendert, und wie Verlusterkennung, Orientierungswechsel und Remote-Touch-Steuerung umgesetzt werden.

Table of Contents

High-Level-Architektur

PlainApp Screen Mirror ist ein End-to-End-Castingsystem mit niedriger Latenz: Das Android-Gerät zeichnet den Bildschirminhalt auf, codiert ihn hardwaregestützt in H.264-Video und Opus-Audio und überträgt ihn über WebSocket mittels eines benutzerdefinierten Binärprotokolls an den Web-Client. Der Web-Client dekodiert über die WebCodecs-API und rendert direkt per WebGL2 auf eine Canvas -- ohne CPU-Kopien. Eine transparente Touch-Overlay-Schicht schließt den Kreislauf, indem sie Zeigereingaben wieder in Gesten auf dem Telefon umwandelt.

Kein WebRTC, kein RTMP, kein Zwischenserver. Die gesamte Pipeline ist:

Android VirtualDisplay -> MediaCodec H.264 Encoder -> WebSocket ->
WebCodecs VideoDecoder -> WebGL2 Texture -> Canvas

Warum nicht WebRTC?

WebRTC ist für Echtzeitkommunikation ausgelegt. Seine ICE/STUN/TURN-Aushandlung, Überlastungsregelung und Jitter-Pufferung sind für LAN-Screen-Casting überdimensioniert. Der Anwendungsfall von PlainApp ist:

  • Gleiches LAN, Latenz < 5ms, keine NAT-Erreichbarkeit nötig
  • Streben nach extrem niedriger Latenz, keine Jitter-Pufferung
  • Hohe Qualitat, Bitrate kann hoch sein (8 Mbit/s)
  • Bildschirmsteuerung (Touch-Injektion), wobei WebRTCs DataChannel unnötige Komplexitat hinzufugt

Ein benutzerdefiniertes Binärprotokoll uber WebSocket ist im LAN-Szenario leichter und besser kontrollierbar.

Komponentenubersicht

Diagram 1
1

SchichtAndroidWeb
BildschirmaufnahmeMediaProjection + VirtualDisplay--
VideocodierungMediaCodec H.264 Hardware-Encoder--
AudiocodierungMediaCodec Opus Hardware-Encoder--
TransportWebSocket-BinareventsWebSocket-Empfanger
Videodecodierung--WebCodecs VideoDecoder
Audiodecodierung--WebCodecs AudioDecoder -> <audio>
Rendering--WebGL2 Textur-Direktrendering
SteuerungAccessibilityService GesteinjektionTouch-Overlay -> GraphQL-Mutation

Video und Audio fließen immer Gerat -> Browser uber dieselbe WebSocket-Verbindung; die Steuerung fließt in die entgegengesetzte Richtung uber GraphQL (sendScreenMirrorControl), die gleichzeitig als Seitenkanal fur die Codec-Konfiguration (screenMirrorVideoCodec-Abfrage) und Keyframe-Anforderungen (requestScreenMirrorKeyFrame-Mutation) dient.

Videocodierungspipeline (Android)

Codierungsparameter-Optimierung

Die Codierungsparameter wurden speziell fur Low-Latency-LAN-Screen-Casting optimiert:

ParameterWertAnmerkungen
KEY_FRAME_RATE6060fps fur flussige Darstellung
KEY_I_FRAME_INTERVAL10IDR-Intervall 10s, reduziert Keyframe-Overhead
KEY_BIT_RATE_MODEVBR (implizit, kein expliziter Modus gesetzt)Variable Bitrate, szenenadaptiv
KEY_PRIORITY0Echtzeit-Prioritat
KEY_LATENCY1Low-Latency-Modus

Die Bitrate ist nach Qualitatsmodus gestaffelt -- hohere Bitraten (z. B. 24 Mbit/s) wurden getestet, verursachten aber Encoder/Decoder-Frame-Drops und erhohte End-to-End-Latenz ohne sichtbaren Qualitatsgewinn bei Bildschirminhalten:

ModusBitrateAufnahmeauflosung
HD8 Mbit/s1080p kurze Seite
Smooth4 Mbit/s1080p kurze Seite
Low2 Mbit/s720p kurze Seite

Encoder-Low-Latency-Konfiguration

MediaCodecVideoEncoder konfiguriert den Encoder einmalig bei der Erstellung:

MediaFormat.createVideoFormat(MIME, width, height).apply {
    setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
    setInteger(MediaFormat.KEY_BIT_RATE, bitrateBps)
    setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)          // 60
    setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameIntervalSec) // 10
    setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 100_000L)
    setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED)
    setInteger(MediaFormat.KEY_PRIORITY, 0)
    setInteger(MediaFormat.KEY_LATENCY, 1)
}

KEY_PRIORITY=0 und KEY_LATENCY=1 sind die Schlussel zur niedrigen Latenz -- sie teilen dem Encoder mit, dass Echtzeitcodierung uber das Kompressionsverhaltnis gestellt werden soll. Die Eingabe ist ein Surface, das von MediaCodec.createInputSurface() erstellt und direkt an VirtualDisplay ubergeben wird -- kein SurfaceTexture-Readback, keine I420-Konvertierung, keine CPU beruhrt die Pixel.

Aufnahmeauflosung

ScreenMirrorCaptureSize.compute() leitet die tatsachliche Aufnahmeauflosung aus der physischen BildschirmgroBe, dem Kurzseitenziel des Qualitatsmodus (720/1080) und den vom Encoder gemeldeten maxWidth/maxHeight- sowie Breiten/Hohen-Ausrichtungen (einmalig uber MediaCodecVideoEncoder.queryEncoderCaps() abgefragt) ab, sodass der Encoder niemals Dimensionen erhalt, die er nicht verarbeiten kann.

Keyframe-Anforderungen

Der Web-Client kann uber die GraphQL-Mutation requestScreenMirrorKeyFrame einen IDR-Frame anfordern, um sich von Paketverlusten zu erholen. Android antwortet uber MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME:

fun requestKeyFrame() {
    val b = Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 1) }
    codec?.setParameters(b)
}

SPS/PPS und Keyframe-Broadcast

Nachdem der Encoder gestartet ist, liefert INFO_OUTPUT_FORMAT_CHANGED csd-0/csd-1 (SPS/PPS), die ScreenMirrorPipeline zu einem einzelnen Annex-B-Konfigurationsblob zusammenfugt und zwischenspeichert (cachedConfig). Der erste darauffolgende IDR wird ebenfalls zwischengespeichert (cachedKeyFrame), sodass ein frisch verbundener Web-Client beide uber die GraphQL-Abfrage screenMirrorVideoCodec abrufen kann, ohne auf das nachste Keyframe-Intervall warten zu mussen. Wenn sich die Konfiguration gerade geandert hat (Orientierungs- oder Qualitatswechsel), sendet Android den neuen IDR nicht als normales Videopaket -- es bundelt SPS/PPS + IDR in ein einziges screen_mirror_video_codec-WebSocket-Event, sodass der Web-Client die Decoder-Neukonfiguration und die Erstframe-Decodierung in einem Durchgang abschlieBt, anstatt einen veralteten Decoder gegen einen neuen Bitstrom laufen zu lassen.

Manche OEM-Encoder (Qualcomm/Xiaomi) bundeln SPS+PPS+IDR in einen einzigen Ausgabepuffer, der sowohl BUFFER_FLAG_CODEC_CONFIG als auch BUFFER_FLAG_SYNC_FRAME tragt. Die Drain-Schleife uberspringt nur Puffer, die reine Konfiguration sind (isConfig && !isKey) -- das Uberspringen eines konfigurationsgekennzeichneten Puffers, der auch den Sync-Frame enthalt, wurde den IDR stillschweigend verwerfen und den Decoder nur mit P-Frames zurucklassen, was ein Mosaikbild erzeugt.

VideoPacket-Protokolldesign

Sowohl Video- als auch Audioframes werden im einheitlichen VideoPacket-Binärprotokoll fur den WebSocket-Transport verpackt.

Protokollformat

+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| MAGIC  | FLAGS  |           FRAME_ID (4 bytes, big-endian)            |              TIMESTAMP (8 bytes, BE)              |  DATA  |
| 0x56   |        |   byte2   |   byte3   |   byte4   |   byte5   |  byte6  |  byte7  | ...  |  byte13 |        payload...        |
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
 \- 1B -/ \- 1B -/ \-------------------- 4B ----------------------/ \----------------------- 8B ------------------------/ \- var -/
FeldGroBeBeschreibung
MAGIC1 ByteFix 0x56 ('V'), zur Validierung
FLAGS1 Byte0x01=Keyframe, 0x02=Config, 0x04=Audio
FRAME_ID4 BytesMonoton steigende Framenummer, uint32 big-endian
TIMESTAMP8 BytesEncoder-PTS in Mikrosekunden, big-endian
DATAvariabelH.264-NAL-Einheit oder Opus-Daten

Sowohl die VideoPacket.encode() von Android (in commonMain, sodass ihr Drahtformat durch JVM-Unit-Tests ohne Android-Abhangigkeit abgedeckt ist) als auch die parseVideoPacket() des Webs implementieren dieses Format unabhangig voneinander -- es gibt keine gemeinsame Serialisierungsbibliothek, nur eine Spezifikation, die beide Seiten einhalten.

Diagram 2
2

Entwurfsanmerkungen

  • FRAME_ID vorzeichenlose Parsung: ((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0 -- muss >>> 0 verwenden, um vorzeichenlos zu garantieren; andernfalls wird frameId > 2^31 als negativ geparst, was eine falsche Verlusterkennung auslost.
  • FRAME_ID wird nie zuruckgesetzt: Wenn der Encoder wegen eines Orientierungswechsels neu aufgebaut wird, inkrementiert frameId weiter (es lebt in ScreenMirrorPipeline, nicht im Encoder). Dadurch kann die Webseite Frame-Verluste wahrend der Rotation anhand von frameId-Lucken erkennen.
  • TIMESTAMP verwendet Encoder-PTS: Keine Abhangigkeit von der Uhr des Web-Clients, wodurch Taktabweichungen, die zu A/V-Desynchronisation fuhren, vermieden werden.
  • Zero-Copy-Parsing: Der Web-Parser schneidet die Nutzdaten mit Uint8Array.subarray() aus -- eine Sicht auf das ursprungliche WebSocket-ArrayBuffer, keine Kopie.

Videodecodierungspipeline (Web)

WebCodecs VideoDecoder

Der Web-Client verwendet VideoDecoder der WebCodecs API fur die Hardwaredecodierung. Im Vergleich zu MediaSource Extensions oder WebRTC bietet WebCodecs eine feinkornige Kontrolle uber den Decodierungsprozess -- kein Jitter-Puffer, keine Containerschicht, und decodierte VideoFrame-Objekte konnen direkt als WebGL-Texturen hochgeladen werden.

const decoder = new VideoDecoder({
    output: (frame) => this.renderFrame(frame),
    error: (e) => {
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.onError?.(e)
    },
})
decoder.configure({
    codec,                              // z. B. 'avc1.42c01e', aus dem SPS-NAL gelesen
    avc: { format: 'annexb' },
    optimizeForLatency: true,
    hardwareAcceleration: 'prefer-hardware',
})

Wichtige Konfigurationen:

  • optimizeForLatency: true -- teilt dem Decoder mit, niedrige Latenz zu priorisieren, keine Frame-Pufferung
  • hardwareAcceleration: 'prefer-hardware' -- GPU-Decodierung bevorzugen
  • avc: { format: 'annexb' } -- Annex-B-Format mit inline SPS/PPS vor jedem IDR verwenden
  • Der Codec-String selbst ist nicht hartcodiert -- extractAvc1CodecString() liest Profile/Compatibility/Level-Bytes direkt aus dem ersten SPS-NAL im Konfigurationsblob

Grunbild-Problem und Startsequenz

Der Encoder erzeugt seinen ersten IDR-Frame, bevor VirtualDisplay echte Bildschirminhalte gerendert hat -- es ist ein leerer (gruner) Frame. Wenn die Webseite diesen Frame decodiert, sieht der Benutzer einen grunen Blitz, bis sich der Bildschirminhalt andert und einen neuen Frame auslost.

Losung: Beim Start ruft die Webseite die zwischengespeicherte Konfiguration uber die screenMirrorVideoCodec-GraphQL-Abfrage ab, decodiert den gebundelten Keyframe jedoch nicht. Stattdessen ruft sie video.requestIdr() auf, um waitingForIdr = true zu setzen (alle P-Frames werden verworfen, bis ein IDR eintrifft), und ruft dann requestKeyFrame() auf, um einen frischen IDR uber dieselbe Mutation anzufordern, die auch fur die Verlustwiederherstellung verwendet wird. Wenn der neue IDR eintrifft, hat VirtualDisplay echte Bildschirminhalte.

video.requestIdr()      // P-Frames verwerfen, auf IDR warten
await requestKeyFrame() // frischen IDR uber GraphQL-Mutation anfordern

Der onFirstFrameRendered-Callback ist an renderFrame() gebunden, nicht an handleVideo(), sodass die UI erst aktualisiert wird, nachdem ein echter Frame gerendert wurde -- nicht bloB empfangen.

WebGL2-Rendering

Zero-Copy-GPU-Direktrendering

Decodierte VideoFrame-Objekte werden direkt als WebGL2-Texturen hochgeladen, ohne den CPU-Pfad zu durchlaufen:

VideoDecoder -> VideoFrame -> gl.texImage2D(VideoFrame) -> Canvas

gl.texImage2D akzeptiert VideoFrame als Pixelquelle. Der Browser fuhrt die YUV->RGB-Konvertierung und den GPU-Upload intern durch -- keine ImageData-CPU-Kopie. MirrorGLRenderer fallt auf Canvas 2D drawImage() zuruck, wenn getContext('webgl2', ...) fehlschlagt, sodass altere Browser immer noch ein (geringfugig latenzreicheres) Bild erhalten.

desynchronized Context

const gl = canvas.getContext('webgl2', {
    alpha: false,
    desynchronized: true,        // Compositor umgehen, direkt auf den Bildschirm schreiben
    preserveDrawingBuffer: true, // Puffer fur Screenshots erhalten
    powerPreference: 'high-performance',
    antialias: false,
    depth: false,
    stencil: false,
    premultipliedAlpha: false,
})

desynchronized: true umgeht den Browser-Compositor und schreibt direkt auf den Bildschirm, was etwa 1 Frame Anzeigelatenz einspart (~16ms bei 60fps).

preserveDrawingBuffer: true erhalt den Zeichenpuffer, sodass canvas.toDataURL()-Screenshots den Inhalt lesen konnen. Bei der Standardeinstellung false wird der Puffer nach dem Compositing geloscht, was schwarze Screenshots erzeugt.

Der Shader selbst ist bewusst minimal gehalten -- ein Fullscreen-Triangle-Vertex-Shader und ein einzeiliger Fragment-Shader, der die Textur abtastet -- denn die einzige Arbeit, die pro Frame notig ist, ist "diese Textur auf den Bildschirm bringen."

Canvas-Auto-Fit

Die GroBe des Canvas-Backing-Stores wird von VideoFrame.displayWidth/Height ubernommen, sobald sich diese andern. Die CSS-GroBe wird dann von fitCanvasToWrapper() an den Wrapper-Container angepasst, unter Beibehaltung des Seitenverhaltnisses (Letterboxing oder Pillarboxing nach Bedarf). Ein ResizeObserver auf dem ubergeordneten Element des Canvas fuhrt diese Anpassung erneut aus, sobald sich der Container andert, sodass das Video nie gestreckt wird.

Verlusterkennung und Fehlerbehebung

FrameId-Luckenerkennung

Jeder Videoframe tragt eine monoton steigende frameId. Der Decoder verfolgt lastFrameId; wenn die frameId eines neuen Frames > lastFrameId + 1 ist, wurden Frames verloren:

if (!this.waitingForIdr && this.lastFrameId > 0
    && packet.frameId > this.lastFrameId + 1) {
    if (!packet.isKeyFrame) {
        // Verlust: nachfolgende P-Frames verwerfen, neuen IDR anfordern
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.lastFrameId = packet.frameId
        return
    }
}

waitingForIdr-Zustandsautomat

waitingForIdr ist ein einfacher Zwei-Zustands-Automat:

Diagram 3
3

ZustandVerhalten
NORMALAlle Frames normal decodieren
WAITING_FOR_IDRAlle P-Frames verwerfen, nur IDR-Frames decodieren; zurucksetzen auf NORMAL bei IDR-Eintreffen

Szenarien, die den Ubergang in WAITING_FOR_IDR auslosen:

  1. Beim Start: veralteten GraphQL-Keyframe uberspringen, auf echten IDR warten
  2. Bei Paketverlust: nicht decodierbare P-Frames verwerfen, auf IDR-Wiederherstellung warten
  3. Bei Decoder-Fehler: Decoder zurucksetzen, auf IDR warten
  4. Bei Konfigurationsanderung: restliche P-Frames nach Orientierungs-/Qualitatswechsel verwerfen

Decoder-Fehlerbehebung

Wenn VideoDecoder.onerror ausgelost wird, wird in der Pipeline-Schicht (screen-mirror-pipeline.ts) decoderNeedsReset = true gesetzt. Beim nachsten IDR-Frame wird der Decoder mit den zwischengespeicherten SPS/PPS neu konfiguriert, anstatt erneut GraphQL zu durchlaufen:

if (decoderNeedsReset) {
    if (!packet.isKeyFrame || !cachedConfig) return
    video.configure(cachedConfig)
    decoderNeedsReset = false
}

Gegendruck und Zeitstempel-Deduplizierung

Wenn decoder.decodeQueueSize > 5, werden eingehende P-Frames verworfen, anstatt in die Warteschlange gestellt zu werden -- der Schwellwert von 5 (statt 2) toleriert die Startlatenz des Hardware-Decoders, ohne unnotiges Stocken zu verursachen. Nach dem Rendern wird lastRenderedPts aufgezeichnet; ein Frame mit alterem Zeitstempel (auBer-der-Reihenfolge-Eintreffen) wird verworfen, es sei denn, es handelt sich um einen Keyframe:

if (packet.timestamp < this.lastRenderedPts && !packet.isKeyFrame) {
    return
}

Orientierungswechsel

Encoder-Neubau

Ein OrientationEventListener in ScreenMirrorService vergleicht bei jedem Sensor-Callback die rotation des Displays mit dem zwischengespeicherten isPortrait-Flag; nur ein echter Hoch-/Querformatwechsel ruft pipeline.onOrientationChanged() auf und macht den Accessibility-BildschirmgroBen-Cache (der fur die Touch-Koordinatenskalierung verwendet wird) ungultig.

Diagram 4
4

rebuildEncoderAndResize():

  1. Neuen Encoder mit den neuen Dimensionen erstellen (z. B. Querformat 1920x1080)
  2. VirtualDisplay.surface auf den Eingabe-Surface des neuen Encoders umschalten
  3. Alten Encoder stoppen
  4. VirtualDisplay.resize() auf die neuen Dimensionen

Der Surface-Wechsel erfolgt vor der GroBenanderung -- so wird sichergestellt, dass der neue Encoder zuerst Frames empfangt und der alte Encoder gestoppt wird, bevor er Frames mit falschen Dimensionen erhalten kann. Wenn virtualDisplay?.surface = ... fehlschlagt, wird der Neubau abgebrochen und der alte Encoder lauft weiter, anstatt die Pipeline ohne Encoder zuruckzulassen.

Konfigurationsanderungsbenachrichtigung

Wenn der neue Encoder erstmals SPS/PPS ausgibt, wird pendingConfigBroadcast in der Pipeline gesetzt. Wenn der erste IDR vom neuen Encoder eintrifft, wird er zusammen mit dieser Konfiguration in einem einzigen screen_mirror_video_codec-Event gebundelt, anstatt als gewohnliches Videopaket gesendet zu werden.

Der Web-Client fuhrt dann in handleConfig() folgende Schritte aus:

  1. Decoder mit den neuen SPS/PPS neu konfigurieren
  2. Den gebundelten IDR-Frame sofort decodieren
  3. video.requestIdr() aufrufen, um restliche P-Frames vom alten Encoder, die noch unterwegs sind, zu verwerfen
  4. requestKeyFrame() aufrufen, um einen sauberen, frischen IDR anzufordern

Die Schritte 3-4 sind ein Sicherheitsnetz -- selbst wenn der erste IDR des neuen Encoders falsche Dimensionen hat (wahrend des asynchronen Resize-Fensters), erholt sich der Web-Client schnell auf die korrekten Dimensionen. handleConfig() bricht auch fruhzeitig ab, wenn die eingehende Konfiguration byte-identisch mit der zwischengespeicherten ist, da das Neukonfigurieren des Decoders mit unveranderten Bytes ein No-Op ist, das dennoch einen IDR zur Wiederherstellung kostet.

System-MediaProjection-Lebenszyklus

Das Problem

Benutzer konnen das systemweite Screen-Cast (MediaProjection) uber die Android-Systembenachrichtigungsleiste schlieBen, anstatt uber die App-Oberflache. In diesem Fall weiB ScreenMirrorService nicht, dass das Casting gestoppt wurde -- running bleibt true, der Web-Client fragt screenMirrorState ab und erhalt true, aber es treffen keine Videoframes ein, und die Seite bleibt beim Laden hangen.

MediaProjection.Callback

MediaProjection stellt einen Callback.onStop()-Callback bereit, der ausgelost wird, wenn das System das Casting stoppt. ScreenMirrorPipeline.startEncoders() registriert diesen Callback und ruft in onStop() ScreenMirrorService.instance?.stop() auf:

projection.registerCallback(object : MediaProjection.Callback() {
    override fun onStop() {
        ScreenMirrorService.instance?.stop()
    }
}, null)

Diagram 5
5

Service.stop()-Verantwortlichkeiten

stop() ist der explizite Stoppunkt, der den Web-Client benachrichtigt und den Service stoppt:

fun stop() {
    if (!running) return  // Rekursion verhindern
    running = false
    sendEvent(WebSocketEvent(EventType.SCREEN_MIRRORING, """{"running":false}"""))
    stopForeground(STOP_FOREGROUND_REMOVE)
    stopSelf()
}

Die if (!running) return-Sicherung verhindert Rekursion: onStop() -> stop() -> stopSelf() -> onDestroy() -> pipeline.stop() -> projection.stop() -> onStop() -> stop() (zu diesem Zeitpunkt ist running=false, kehrt sofort zuruck).

Behandlung auf der Web-Seite

Wenn der Web-Client das {"running":false}-Event empfangt, setzt er auf den Leerlaufzustand zuruck und zeigt den Start-Button an:

const onScreenMirroring = (data: any) => {
    if (data?.running === false) {
        cleanupFn()
        fullReset()
        return
    }
    // running=true -> mit Stream verbinden
}

Fernsteuerung: Touch-Injektion

Screen Mirroring ist standardmaBig unidirektional (nur Video/Audio); die Fernsteuerung ist optional und erfordert, dass der Benutzer den Accessibility-Dienst von PlainApp einmalig aktiviert, da Android keine offentliche API zum Injizieren beliebiger Touch-Ereignisse auBerhalb von AccessibilityService.dispatchGesture() bereitstellt.

Diagram 6
6

Koordinatennormalisierung (Web)

Eine transparente Uberlagerung befindet sich uber dem <canvas> und erfasst Zeigerereignisse. normalizeCoords() wandelt rohe clientX/clientY-Koordinaten in [0,1]-Koordinaten relativ zum tatsachlichen Videoinhaltsbereich um -- nicht zum Begrenzungsrahmen der Uberlagerung -- indem der Letterbox-/Pillarbox-Offset aus dem Seitenverhaltnis des Canvas-Backing-Stores im Vergleich zum Seitenverhaltnis des gerenderten Containers berechnet wird:

if (videoAspect > containerAspect) {
    // Letterboxed oben/unten
    renderW = containerW
    renderH = containerW / videoAspect
    offsetY = (containerH - renderH) / 2
} else {
    // Pillarboxed links/rechts
    renderH = containerH
    renderW = containerH * videoAspect
    offsetX = (containerW - renderW) / 2
}

Ein Zeigerdruck startet einen GestureState, der Startposition/-zeit verfolgt; ein 500ms langes Halten mit < 10px Bewegung wird zu LONG_PRESS, eine Bewegung uber diesen Schwellwert wird zu einem SWIPE, und ein schnelles Loslassen ist ein TAP. Ein visueller Touch-Indikator (ein wachsender/verblassender Punkt) gibt dem Bediener Ruckmeldung, welche Geste erkannt wurde, bevor das Telefon uberhaupt reagiert.

GraphQL -> AccessibilityService

Jede erkannte Geste wird als eine sendScreenMirrorControl(input)-Mutation gesendet, die eine action (TAP/LONG_PRESS/SWIPE/SCROLL/BACK/HOME/RECENTS/LOCK_SCREEN/KEY) sowie normalisierte Koordinaten enthalt. Der Resolver ruft dispatchScreenMirrorControl() auf, das die normalisierten Koordinaten mit der tatsachlichen BildschirmgroBe (von PlainAccessibilityService.getScreenSize(), bei jedem Orientierungswechsel ungultig gemacht) multipliziert und an PlainAccessibilityService.dispatchControl() delegiert:

private fun dispatchTap(x: Float, y: Float) {
    val path = Path().apply { moveTo(x, y) }
    val stroke = GestureDescription.StrokeDescription(path, 0, 50)
    dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
}

SWIPE und LONG_PRESS bauen dieselbe GestureDescription mit einer langeren Strichdauer oder einem Linienpfad anstelle eines einzelnen Punktes auf; SCROLL wird als synthetisches Wischen von (x, y) zu (x, y + deltaY) implementiert, begrenzt auf +/-500px. Die vier globalen Aktionen (BACK/HOME/RECENTS/LOCK_SCREEN) uberspringen die Gestenverteilung vollstandig und rufen direkt performGlobalAction() auf. Wenn der Accessibility-Dienst nicht aktiviert ist, wirft der Resolver einen GraphQLError anstatt die Eingabe stillschweigend zu verwerfen, sodass die Web-UI den Benutzer zur Aktivierung auffordern kann.

Audio-Pipeline

Android-Opus-Codierung

MediaCodecAudioEncoder verwendet AudioPlaybackCaptureConfiguration (aus demselben MediaProjection aufgebaut), um Systemaudio uber AudioRecord aufzuzeichnen, und speist rohe PCM-Daten in einen MediaCodec-Opus-Encoder ein. Dies erfordert Android 10+ und die RECORD_AUDIO-Berechtigung -- auf alteren Geraten oder ohne die Berechtigung protokolliert start() eine Warnung und uberspringt Audio vollstandig (Video funktioniert weiterhin). Codierte Opus-Pakete werden im selben VideoPacket-Protokoll verpackt (mit gesetztem FLAG_AUDIO) und teilen sich den SCREEN_MIRROR_AUDIO-WebSocket-Kanal des Videopakets.

Web-Opus-Decodierung

ScreenMirrorAudioPipeline verwendet WebCodecs AudioDecoder zum Decodieren von Opus-Daten und gibt AudioData aus, das an ein <audio>-Element weitergeleitet wird. Der Audio-Frame-timestamp wird fur die A/V-Synchronisation verwendet -- er teilt dieselbe Zeitbasis (Encoder-PTS in Mikrosekunden) wie Videoframes, sodass keine separate Taktaushandlung zwischen den beiden Streams erforderlich ist.

Leistungsoptimierungen

Zero-Copy-Pfade

PfadMethode
VirtualDisplay -> Encoder SurfaceGPU direkt, Surface-Durchleitung
VideoDecoder -> VideoFrame -> WebGL-Texturgl.texImage2D(VideoFrame), GPU direkt
WebSocket-Empfang -> VideoPacket-ParsingUint8Array.subarray() ist eine Sicht, keine Kopie

avccToAnnexB-Optimierung

Einige Android-Encoder geben das AVCC-Format aus (4-Byte-Langenprefix), das fur die WebCodecs-Decodierung in das Annex-B-Format (00 00 00 01-Startcode) konvertiert werden muss.

Die fruhe Implementierung verwendete ArrayList<Byte> mit byte-weiser Boxung -- ein 50KB-IDR-Frame erzeugte 50.000 java.lang.Byte-Boxungsoperationen, was massiven GC-Druck verursachte. Die Optimierung verwendet einen zweipassigen Scan + copyInto (das auf JVM auf das System.arraycopy-Intrinsic abbildet):

// Erster Durchlauf: AusgabegroBe berechnen
var outSize = 0
// Zweiter Durchlauf: Bulk-Kopie
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)

P-Frame-Verwerfungsstrategie

Der Decoder kann wahrend der Initialisierung langsam sein. Wenn die P-Frame-Warteschlange zu lang wird, akkumuliert sich Latenz. Der Schwellwert fur die Decode-Queue-GroBe ist auf > 5 (statt > 2) gesetzt, um ubermaBigen Frame-Verlust wahrend der Hardware-Decoder-Initialisierung zu vermeiden.

IDR-Anforderungsdeduplizierung

Die waitingForIdr-Sicherung stellt sicher, dass pro Verlustereignis nur eine IDR-Anforderung gesendet wird, wodurch doppelte Anforderungen wahrend des Wartens auf einen IDR verhindert werden.

Entwurfsmuster-Ruckblick

MusterOrtGrund
ZustandsautomatwaitingForIdr-FlagExplizite P-Frame-Verwerfungs-/Wiederherstellungs-Zustandsubergange
Rekursionssicherungif (!running) return in stop()Verhindert onStop -> stop -> onDestroy -> pipeline.stop -> projection.stop -> onStop-Rekursion
Zero-Copy-PipelineVideoFrame -> gl.texImage2DGPU-Direkttextur-Upload, keine CPU-Kopie
Zweipassiger ScanavccToAnnexBGroBe vorberechnen, einmalige Allokation + Bulk-Kopie, eliminiert Boxung
Gebundeltes EventSPS/PPS + IDR in einem EventKonfigurationswechsel schlieBt Neukonfiguration + Erstframe-Decodierung in einem Event ab
Callback-TrennungonFirstFrameRendered vs. onDisconnected vs. onScreenMirrorOffKlare Unterscheidung zwischen Erstframe-Rendering, Transportfehler und geratesseitigem Stopp
SicherheitsnetzrequestIdr() + requestKeyFrame()Restliche Frames verwerfen + sauberen IDR nach Konfigurationswechsel anfordern
PTS-Deduplizierungtimestamp < lastRenderedPtsAuBer-der-Reihenfolge-Frames verwerfen
FrameId-LuckeframeId > lastFrameId + 1ACK-freie Paketverlusterkennung
desynchronized ContextWebGL2 desynchronized: trueCompositor umgehen, 1 Frame Latenz einsparen
Explizites Fail FastsendScreenMirrorControl wirft GraphQLErrorMacht "Accessibility deaktiviert" sichtbar, anstatt Eingabe stillschweigend zu verwerfen

Weiterfuhrende Literatur

  • WebCodecs API -- MDN-Dokumentation zu den VideoDecoder/AudioDecoder-Schnittstellen.