Table of Contents
- High-Level-Architektur
- Videocodierungspipeline (Android)
- VideoPacket-Protokolldesign
- Videodecodierungspipeline (Web)
- WebGL2-Rendering
- Verlusterkennung und Fehlerbehebung
- Orientierungswechsel
- System-MediaProjection-Lebenszyklus
- Fernsteuerung: Touch-Injektion
- Audio-Pipeline
- Leistungsoptimierungen
- Entwurfsmuster-Rückblick
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
| Schicht | Android | Web |
|---|---|---|
| Bildschirmaufnahme | MediaProjection + VirtualDisplay | -- |
| Videocodierung | MediaCodec H.264 Hardware-Encoder | -- |
| Audiocodierung | MediaCodec Opus Hardware-Encoder | -- |
| Transport | WebSocket-Binarevents | WebSocket-Empfanger |
| Videodecodierung | -- | WebCodecs VideoDecoder |
| Audiodecodierung | -- | WebCodecs AudioDecoder -> <audio> |
| Rendering | -- | WebGL2 Textur-Direktrendering |
| Steuerung | AccessibilityService Gesteinjektion | Touch-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:
| Parameter | Wert | Anmerkungen |
|---|---|---|
KEY_FRAME_RATE | 60 | 60fps fur flussige Darstellung |
KEY_I_FRAME_INTERVAL | 10 | IDR-Intervall 10s, reduziert Keyframe-Overhead |
KEY_BIT_RATE_MODE | VBR (implizit, kein expliziter Modus gesetzt) | Variable Bitrate, szenenadaptiv |
KEY_PRIORITY | 0 | Echtzeit-Prioritat |
KEY_LATENCY | 1 | Low-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:
| Modus | Bitrate | Aufnahmeauflosung |
|---|---|---|
| HD | 8 Mbit/s | 1080p kurze Seite |
| Smooth | 4 Mbit/s | 1080p kurze Seite |
| Low | 2 Mbit/s | 720p 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 -/
| Feld | GroBe | Beschreibung |
|---|---|---|
MAGIC | 1 Byte | Fix 0x56 ('V'), zur Validierung |
FLAGS | 1 Byte | 0x01=Keyframe, 0x02=Config, 0x04=Audio |
FRAME_ID | 4 Bytes | Monoton steigende Framenummer, uint32 big-endian |
TIMESTAMP | 8 Bytes | Encoder-PTS in Mikrosekunden, big-endian |
DATA | variabel | H.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.
Entwurfsanmerkungen
FRAME_IDvorzeichenlose Parsung:((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0-- muss>>> 0verwenden, um vorzeichenlos zu garantieren; andernfalls wirdframeId > 2^31als negativ geparst, was eine falsche Verlusterkennung auslost.FRAME_IDwird nie zuruckgesetzt: Wenn der Encoder wegen eines Orientierungswechsels neu aufgebaut wird, inkrementiertframeIdweiter (es lebt inScreenMirrorPipeline, nicht im Encoder). Dadurch kann die Webseite Frame-Verluste wahrend der Rotation anhand von frameId-Lucken erkennen.TIMESTAMPverwendet 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-PufferunghardwareAcceleration: 'prefer-hardware'-- GPU-Decodierung bevorzugenavc: { 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:
| Zustand | Verhalten |
|---|---|
NORMAL | Alle Frames normal decodieren |
WAITING_FOR_IDR | Alle P-Frames verwerfen, nur IDR-Frames decodieren; zurucksetzen auf NORMAL bei IDR-Eintreffen |
Szenarien, die den Ubergang in WAITING_FOR_IDR auslosen:
- Beim Start: veralteten GraphQL-Keyframe uberspringen, auf echten IDR warten
- Bei Paketverlust: nicht decodierbare P-Frames verwerfen, auf IDR-Wiederherstellung warten
- Bei Decoder-Fehler: Decoder zurucksetzen, auf IDR warten
- 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.
rebuildEncoderAndResize():
- Neuen Encoder mit den neuen Dimensionen erstellen (z. B. Querformat 1920x1080)
VirtualDisplay.surfaceauf den Eingabe-Surfacedes neuen Encoders umschalten- Alten Encoder stoppen
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:
- Decoder mit den neuen SPS/PPS neu konfigurieren
- Den gebundelten IDR-Frame sofort decodieren
video.requestIdr()aufrufen, um restliche P-Frames vom alten Encoder, die noch unterwegs sind, zu verwerfenrequestKeyFrame()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)
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.
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
| Pfad | Methode |
|---|---|
| VirtualDisplay -> Encoder Surface | GPU direkt, Surface-Durchleitung |
| VideoDecoder -> VideoFrame -> WebGL-Textur | gl.texImage2D(VideoFrame), GPU direkt |
| WebSocket-Empfang -> VideoPacket-Parsing | Uint8Array.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
| Muster | Ort | Grund |
|---|---|---|
| Zustandsautomat | waitingForIdr-Flag | Explizite P-Frame-Verwerfungs-/Wiederherstellungs-Zustandsubergange |
| Rekursionssicherung | if (!running) return in stop() | Verhindert onStop -> stop -> onDestroy -> pipeline.stop -> projection.stop -> onStop-Rekursion |
| Zero-Copy-Pipeline | VideoFrame -> gl.texImage2D | GPU-Direkttextur-Upload, keine CPU-Kopie |
| Zweipassiger Scan | avccToAnnexB | GroBe vorberechnen, einmalige Allokation + Bulk-Kopie, eliminiert Boxung |
| Gebundeltes Event | SPS/PPS + IDR in einem Event | Konfigurationswechsel schlieBt Neukonfiguration + Erstframe-Decodierung in einem Event ab |
| Callback-Trennung | onFirstFrameRendered vs. onDisconnected vs. onScreenMirrorOff | Klare Unterscheidung zwischen Erstframe-Rendering, Transportfehler und geratesseitigem Stopp |
| Sicherheitsnetz | requestIdr() + requestKeyFrame() | Restliche Frames verwerfen + sauberen IDR nach Konfigurationswechsel anfordern |
| PTS-Deduplizierung | timestamp < lastRenderedPts | AuBer-der-Reihenfolge-Frames verwerfen |
| FrameId-Lucke | frameId > lastFrameId + 1 | ACK-freie Paketverlusterkennung |
| desynchronized Context | WebGL2 desynchronized: true | Compositor umgehen, 1 Frame Latenz einsparen |
| Explizites Fail Fast | sendScreenMirrorControl wirft GraphQLError | Macht "Accessibility deaktiviert" sichtbar, anstatt Eingabe stillschweigend zu verwerfen |
Weiterfuhrende Literatur
- WebCodecs API -- MDN-Dokumentation zu den
VideoDecoder/AudioDecoder-Schnittstellen.