Torna al blog
Architecture15 min read

Screen Mirror: Architettura di Casting a Bassa Latenza

Questo articolo illustra la progettazione end-to-end del sistema di screen mirror di PlainApp: come Android cattura e codifica via hardware H.264/Opus con MediaCodec, come i frame viaggiano su WebSocket tramite un protocollo binario personalizzato, come il lato web decodifica con WebCodecs e renderizza con WebGL2 senza copie CPU, come vengono gestiti la perdita di pacchetti, i cambi di orientamento e il controllo touch remoto, e come viene mantenuto sincronizzato il ciclo di vita del sistema MediaProjection.

Table of Contents

Architettura di Alto Livello

Lo screen mirror di PlainApp è un sistema di casting end-to-end a bassa latenza: il dispositivo Android cattura il contenuto dello schermo, lo codifica via hardware in video H.264 e audio Opus, e lo invia tramite WebSocket usando un protocollo binario personalizzato al client web; il client web decodifica tramite l'API WebCodecs e renderizza direttamente su una Canvas tramite WebGL2, senza copie CPU in tutto il percorso. Un overlay tattile trasparente chiude il ciclo, trasformando l'input del puntatore in gesture sul telefono.

Niente WebRTC, niente RTMP, niente server intermedio. L'intera pipeline è:

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

Perché non WebRTC?

WebRTC è progettato per la comunicazione in tempo reale. La sua negoziazione ICE/STUN/TURN, il controllo della congestione e il buffer di jitter sono eccessivi per il casting su LAN. Il caso d'uso di PlainApp è:

  • Stessa LAN, latenza < 5ms, nessun attraversamento NAT necessario
  • Ricerca di latenza ultra-bassa, nessun buffer di jitter
  • Alta qualità, il bitrate può essere elevato (8 Mbps)
  • Controllo dello schermo (iniezione tattile), dove il DataChannel di WebRTC aggiungerebbe complessità inutile

Un protocollo binario personalizzato su WebSocket è più leggero e più controllabile per lo scenario LAN.

Mappa dei componenti

Diagram 1
1

LivelloAndroidWeb
Acquisizione schermoMediaProjection + VirtualDisplay
Codifica videoMediaCodec codec hardware H.264
Codifica audioMediaCodec codec hardware Opus
TrasportoEventi WebSocket binariRicevitore WebSocket
Decodifica videoWebCodecs VideoDecoder
Decodifica audioWebCodecs AudioDecoder<audio>
RenderingWebGL2 texture direct-render
ControlloAccessibilityService iniezione gestureOverlay tattile → mutazione GraphQL

Video e audio fluiscono sempre dispositivo → browser sulla stessa connessione WebSocket; il controllo fluisce nella direzione opposta tramite GraphQL (sendScreenMirrorControl), che funge anche da canale laterale per la configurazione del codec (query screenMirrorVideoCodec) e le richieste di keyframe (mutazione requestScreenMirrorKeyFrame).

Pipeline di Codifica Video (Android)

Ottimizzazione dei Parametri di Codifica

I parametri di codifica sono stati ottimizzati specificamente per il casting su LAN a bassa latenza:

ParametroValoreNote
KEY_FRAME_RATE6060fps per fluidità
KEY_I_FRAME_INTERVAL10Intervallo IDR 10s, riduce l'overhead dei keyframe
KEY_BIT_RATE_MODEVBR (implicito, nessuna modalità esplicita impostata)Bitrate variabile, adattivo alla scena
KEY_PRIORITY0Priorità in tempo reale
KEY_LATENCY1Modalità a bassa latenza

Il bitrate è suddiviso in livelli in base alla modalità di qualità — bitrate più elevati (es. 24 Mbps) sono stati testati e hanno causato cadute di frame nel codificatore/decodificatore e un aumento della latenza end-to-end senza un miglioramento visibile della qualità per i contenuti dello schermo:

ModalitàBitrateRisoluzione di acquisizione
HD8 Mbps1080p lato corto
Smooth4 Mbps1080p lato corto
Low2 Mbps720p lato corto

Configurazione a Bassa Latenza del Codificatore

MediaCodecVideoEncoder configura il codificatore una volta al momento della creazione:

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 e KEY_LATENCY=1 sono le chiavi per la bassa latenza — dicono al codificatore di dare priorità alla codifica in tempo reale rispetto al rapporto di compressione. L'input è un Surface creato da MediaCodec.createInputSurface() e alimentato direttamente a VirtualDisplay — nessun SurfaceTexture readback, nessuna conversione I420, la CPU non tocca i pixel.

Risoluzione di Acquisizione

ScreenMirrorCaptureSize.compute() deriva la dimensione effettiva di acquisizione dalle dimensioni fisiche dello schermo, dal target del lato corto della modalità di qualità (720/1080), e dal maxWidth/maxHeight e allineamento larghezza/altezza riportati dal codificatore (interrogati una volta tramite MediaCodecVideoEncoder.queryEncoderCaps()), così il codificatore non riceve mai dimensioni che non può accettare.

Richieste di Keyframe

Il client web può richiedere un frame IDR tramite la mutazione GraphQL requestScreenMirrorKeyFrame per recuperare dalla perdita di pacchetti. Android risponde tramite 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 e Broadcast di Keyframe

Dopo l'avvio del codificatore, INFO_OUTPUT_FORMAT_CHANGED fornisce csd-0/csd-1 (SPS/PPS), che ScreenMirrorPipeline unisce in un unico blob di configurazione Annex-B e memorizza nella cache (cachedConfig). Anche il primo IDR successivo viene memorizzato nella cache (cachedKeyFrame) in modo che un client web appena connesso possa recuperarli entrambi tramite la query GraphQL screenMirrorVideoCodec senza attendere il prossimo intervallo di keyframe. Quando la configurazione cambia (cambio di orientamento o qualità), Android non invia il nuovo IDR come un normale pacchetto video — raggruppa SPS/PPS + IDR in un unico evento WebSocket screen_mirror_video_codec, così il client web completa la riconfigurazione del decodificatore e la decodifica del primo frame in un colpo solo, invece di far correre un decodificatore obsoleto contro un nuovo bitstream.

Alcuni codificatori OEM (Qualcomm/Xiaomi) raggruppano SPS+PPS+IDR in un singolo buffer di output che porta sia BUFFER_FLAG_CODEC_CONFIG che BUFFER_FLAG_SYNC_FRAME. Il ciclo di drenaggio salta solo i buffer che sono puramente di configurazione (isConfig && !isKey) — saltare un buffer marcato come config che trasporta anche il sync frame eliminerebbe silenziosamente l'IDR e lascerebbe il decodificatore con soli P-frame, producendo un'immagine a mosaico.

Progettazione del Protocollo VideoPacket

Sia i frame video che audio sono incapsulati nel protocollo binario unificato VideoPacket per il trasporto WebSocket.

Formato del Protocollo

+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| 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 -/
CampoDimensioneDescrizione
MAGIC1 byteFisso 0x56 ('V'), per validazione
FLAGS1 byte0x01=keyframe, 0x02=config, 0x04=audio
FRAME_ID4 byteNumero di frame monotonico crescente, uint32 big-endian
TIMESTAMP8 bytePTS del codificatore in microsecondi, big-endian
DATAvariabileUnità NAL H.264 o dati Opus

Sia VideoPacket.encode() di Android (in commonMain, quindi il suo formato wire è coperto da test unitari JVM senza alcuna dipendenza Android) che parseVideoPacket() del web implementano questo formato indipendentemente — non esiste una libreria di serializzazione condivisa, solo una specifica che entrambi i lati rispettano.

Diagram 2
2

Note di Progettazione

  • Parsing unsigned di FRAME_ID: ((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0 — deve usare >>> 0 per garantire unsigned, altrimenti frameId > 2^31 viene interpretato come negativo, causando falsi rilevamenti di perdita.
  • FRAME_ID non viene mai resettato: Quando il codificatore viene ricostruito per un cambio di orientamento, frameId continua a incrementare (risiede in ScreenMirrorPipeline, non nel codificatore). Questo permette al lato web di rilevare la perdita di frame durante la rotazione tramite gap in frameId.
  • TIMESTAMP usa il PTS del codificatore: Nessuna dipendenza dall'orologio del client web, evitando che la deriva dell'orologio causi desincronizzazione A/V.
  • Parsing zero-copy: il parser web suddivide il payload con Uint8Array.subarray() — una vista nell'ArrayBuffer WebSocket originale, non una copia.

Pipeline di Decodifica Video (Web)

WebCodecs VideoDecoder

Il client web utilizza VideoDecoder dell'API WebCodecs per la decodifica hardware. Rispetto a MediaSource Extensions o WebRTC, WebCodecs fornisce un controllo granulare sul processo di decodifica — nessun buffer di jitter, nessun livello contenitore, e gli oggetti VideoFrame decodificati possono essere caricati direttamente come texture WebGL.

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

Configurazioni chiave:

  • optimizeForLatency: true — dice al decodificatore di dare priorità alla bassa latenza, nessun buffer di frame
  • hardwareAcceleration: 'prefer-hardware' — preferisce la decodifica GPU
  • avc: { format: 'annexb' } — usa il formato Annex-B con SPS/PPS inline prima di ogni IDR
  • la stringa del codec non è hardcodata — extractAvc1CodecString() legge i byte profile/compat/level direttamente dal primo NAL SPS nel blob di configurazione

Problema dello Schermo Verde e Sequenza di Avvio

Il codificatore produce il suo primo frame IDR prima che VirtualDisplay abbia renderizzato il contenuto reale dello schermo — è un frame vuoto (verde). Se il lato web decodifica questo frame, l'utente vede un flash verde fino a quando il contenuto dello schermo cambia e attiva un nuovo frame.

Soluzione: All'avvio, il lato web recupera la configurazione cache tramite la query GraphQL screenMirrorVideoCodec ma non decodifica il keyframe incluso. Invece, chiama video.requestIdr() per impostare waitingForIdr = true (scartando tutti i P-frame finché non arriva un IDR), poi chiama requestKeyFrame() per richiedere un IDR fresco tramite la stessa mutazione usata per il recupero dalla perdita. Quando il nuovo IDR arriva, VirtualDisplay ha già contenuto reale dello schermo.

video.requestIdr()      // scarta P-frame, attendi IDR
await requestKeyFrame() // richiedi IDR fresco tramite mutazione GraphQL

Il callback onFirstFrameRendered è legato a renderFrame() piuttosto che a handleVideo(), assicurando che l'UI si aggiorni solo dopo che un frame reale è stato renderizzato — non semplicemente ricevuto.

Rendering WebGL2

Render Diretto GPU Zero-Copy

Gli oggetti VideoFrame decodificati vengono caricati direttamente come texture WebGL2, senza mai passare attraverso la CPU:

VideoDecoder → VideoFrame → gl.texImage2D(VideoFrame) → Canvas

gl.texImage2D accetta VideoFrame come sorgente di pixel. Il browser gestisce internamente la conversione YUV→RGB e il caricamento GPU — nessuna copia CPU di ImageData. MirrorGLRenderer ricade su Canvas 2D drawImage() se getContext('webgl2', ...) fallisce, quindi i browser più vecchi ricevono comunque un'immagine (con latenza leggermente superiore).

Contesto desynchronized

const gl = canvas.getContext('webgl2', {
    alpha: false,
    desynchronized: true,        // bypassa il compositor, scrive direttamente sullo schermo
    preserveDrawingBuffer: true, // preserva il buffer per screenshot
    powerPreference: 'high-performance',
    antialias: false,
    depth: false,
    stencil: false,
    premultipliedAlpha: false,
})

desynchronized: true bypassa il compositor del browser, scrivendo direttamente sullo schermo, risparmiando circa 1 frame di latenza di visualizzazione (~16ms @ 60fps).

preserveDrawingBuffer: true preserva il buffer di disegno così gli screenshot con canvas.toDataURL() possono leggere il contenuto. Con il valore predefinito false, il buffer viene cancellato dopo il compositing, producendo screenshot neri.

Lo shader stesso è volutamente minimale — un vertex shader fullscreen-triangle e un fragment shader di una riga che campiona la texture — perché l'unico lavoro necessario per frame è "mettere questa texture sullo schermo."

Adattamento Automatico della Canvas

La dimensione del backing store della canvas è impostata da VideoFrame.displayWidth/Height ogni volta che cambia. La dimensione CSS viene quindi adattata al contenitore wrapper da fitCanvasToWrapper() preservando l'aspect ratio (letterboxing o pillarboxing secondo necessità). Un ResizeObserver sull'elemento genitore della canvas ripete questo adattamento ogni volta che il contenitore si ridimensiona, così il video non si allunga mai.

Rilevamento Perdita e Recupero Errori

Rilevamento Gap di FrameId

Ogni frame video trasporta un frameId monotonico crescente. Il decodificatore tiene traccia di lastFrameId; se il frameId di un nuovo frame è > lastFrameId + 1, alcuni frame sono stati persi:

if (!this.waitingForIdr && this.lastFrameId > 0
    && packet.frameId > this.lastFrameId + 1) {
    if (!packet.isKeyFrame) {
        // Perdita: scarta i successivi P-frame, richiedi nuovo IDR
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.lastFrameId = packet.frameId
        return
    }
}

Macchina a Stati waitingForIdr

waitingForIdr è una semplice macchina a due stati:

Diagram 3
3

StatoComportamento
NORMALDecodifica tutti i frame normalmente
WAITING_FOR_IDRScarta tutti i P-frame, decodifica solo i frame IDR; torna a NORMAL quando arriva un IDR

Scenari che attivano la transizione a WAITING_FOR_IDR:

  1. All'avvio: salta il keyframe obsoleto di GraphQL, attendi un IDR reale
  2. In caso di perdita pacchetti: scarta i P-frame non decodificabili, attendi il recupero IDR
  3. In caso di errore del decodificatore: resetta il decodificatore, attendi IDR
  4. In caso di cambio configurazione: scarta i P-frame residui dopo un cambio di orientamento/qualità

Recupero da Errore del Decodificatore

Quando VideoDecoder.onerror si attiva, decoderNeedsReset = true viene impostato nel livello pipeline (screen-mirror-pipeline.ts). Al prossimo frame IDR, il decodificatore viene riconfigurato con gli SPS/PPS memorizzati nella cache invece di fare un nuovo round-trip a GraphQL:

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

Contropressione e Deduplicazione dei Timestamp

Se decoder.decodeQueueSize > 5, i P-frame in arrivo vengono scartati invece di essere accodati — la soglia di 5 (invece di 2) tollera la latenza di avvio del decodificatore hardware senza causare inutili arresti. Separatamente, dopo il rendering, viene registrato lastRenderedPts; un frame con timestamp più vecchio (arrivo fuori ordine) viene scartato a meno che non sia un keyframe:

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

Gestione del Cambio di Orientamento

Ricostruzione del Codificatore

Un OrientationEventListener in ScreenMirrorService confronta la rotation del display con il flag isPortrait memorizzato nella cache a ogni callback del sensore; solo un reale capovolgimento portrait/landscape chiama pipeline.onOrientationChanged() e invalida la cache delle dimensioni dello schermo dell'accessibilità usata per il ridimensionamento delle coordinate tattili.

Diagram 4
4

rebuildEncoderAndResize():

  1. Crea un nuovo codificatore con le nuove dimensioni (es. landscape 1920×1080)
  2. Cambia VirtualDisplay.surface con il Surface di input del nuovo codificatore
  3. Ferma il vecchio codificatore
  4. VirtualDisplay.resize() alle nuove dimensioni

Il cambio di Surface avviene prima del resize — assicurando che il nuovo codificatore riceva prima i frame, e il vecchio codificatore venga fermato prima di poter ricevere frame con dimensioni errate. Se virtualDisplay?.surface = ... lancia un'eccezione, la ricostruzione viene interrotta e mantiene il vecchio codificatore in esecuzione piuttosto che lasciare la pipeline senza alcun codificatore.

Notifica di Cambio Configurazione

Quando il nuovo codificatore emette per la prima volta SPS/PPS, pendingConfigBroadcast viene impostato sulla pipeline. Quando arriva il primo IDR dal nuovo codificatore, viene raggruppato con quella configurazione in un unico evento screen_mirror_video_codec invece di essere inviato come un normale pacchetto video.

Il client web quindi, in handleConfig():

  1. Riconfigura il decodificatore con i nuovi SPS/PPS
  2. Decodifica immediatamente il frame IDR raggruppato
  3. Chiama video.requestIdr() per scartare eventuali P-frame residui del vecchio codificatore ancora in transito
  4. Chiama requestKeyFrame() per richiedere un IDR fresco e pulito

I passi 3-4 sono una rete di sicurezza — anche se il primo IDR del nuovo codificatore ha dimensioni errate (durante la finestra di resize asincrono), il client web si ripristina rapidamente alle dimensioni corrette. handleConfig() fa anche un short-circuit se la configurazione in arrivo è identica byte per byte a quella memorizzata nella cache, poiché riconfigurare il decodificatore con byte invariati è un no-op che costa comunque un IDR per il recupero.

Ciclo di Vita del MediaProjection di Sistema

Il Problema

Gli utenti possono chiudere il cast dello schermo a livello di sistema (MediaProjection) tramite la barra di notifica di Android, invece che attraverso l'UI dell'app. In questo caso, ScreenMirrorService non sa che il casting è stato interrotto — running rimane true, il client web interroga screenMirrorState e riceve true, ma nessun frame video arriva, e la pagina rimane bloccata sul caricamento.

MediaProjection.Callback

MediaProjection fornisce un callback Callback.onStop() che si attiva quando il sistema ferma il casting. ScreenMirrorPipeline.startEncoders() registra questo callback e chiama ScreenMirrorService.instance?.stop() in onStop():

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

Diagram 5
5

Responsabilità di Service.stop()

stop() è il punto di arresto esplicito, responsabile di notificare il client web e fermare il servizio:

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

Il controllo if (!running) return previene la ricorsione: onStop()stop()stopSelf()onDestroy()pipeline.stop()projection.stop()onStop()stop() (a questo punto running=false, ritorna immediatamente).

Gestione sul Lato Web

Quando il client web riceve l'evento {"running":false}, si resetta allo stato idle e mostra il pulsante di avvio:

const onScreenMirroring = (data: any) => {
    if (data?.running === false) {
        cleanupFn()
        fullReset()
        return
    }
    // running=true → connettiti allo stream
}

Controllo Remoto: Iniezione Tattile

Lo screen mirroring è unidirezionale per impostazione predefinita (solo video/audio); il controllo remoto è opzionale e richiede che l'utente abiliti il Servizio di Accessibilità di PlainApp una volta, poiché Android non ha un'API pubblica per iniettare eventi tattili arbitrari al di fuori di AccessibilityService.dispatchGesture().

Diagram 6
6

Normalizzazione delle Coordinate (Web)

Un overlay trasparente si trova sopra la <canvas> e cattura gli eventi del puntatore. normalizeCoords() converte clientX/clientY grezzi in coordinate [0,1] relative all'area di contenuto video effettiva — non al bounding box dell'overlay — calcolando l'offset di letterbox/pillarbox dall'aspect ratio del backing store della canvas rispetto all'aspect ratio del suo contenitore renderizzato:

if (videoAspect > containerAspect) {
    // Letterboxed top/bottom
    renderW = containerW
    renderH = containerW / videoAspect
    offsetY = (containerH - renderH) / 2
} else {
    // Pillarboxed left/right
    renderH = containerH
    renderW = containerH * videoAspect
    offsetX = (containerW - renderW) / 2
}

Una pressione del puntatore avvia un GestureState che tiene traccia della posizione/tempo di inizio; una pressione di 500ms con movimento < 10px scala a LONG_PRESS, il movimento oltre quella soglia diventa uno SWIPE, e un rilascio rapido è un TAP. Un indicatore tattile visivo (un punto che cresce/sfuma) fornisce feedback all'operatore su quale gesto è stato riconosciuto, prima ancora che il telefono risponda.

GraphQL → AccessibilityService

Ogni gesto riconosciuto viene inviato come una mutazione sendScreenMirrorControl(input) che trasporta un action (TAP/LONG_PRESS/SWIPE/SCROLL/BACK/HOME/RECENTS/LOCK_SCREEN/KEY) più le coordinate normalizzate. Il resolver chiama dispatchScreenMirrorControl(), che moltiplica le coordinate normalizzate per la dimensione reale dello schermo (da PlainAccessibilityService.getScreenSize(), invalidata a ogni cambio di orientamento) e delega a PlainAccessibilityService.dispatchControl():

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 e LONG_PRESS costruiscono lo stesso GestureDescription con una durata del tratto più lunga o un percorso di linea invece di un singolo punto; SCROLL è implementato come uno swipe sintetico da (x, y) a (x, y + deltaY) bloccato a ±500px. Le quattro azioni globali (BACK/HOME/RECENTS/LOCK_SCREEN) saltano completamente l'invio del gesto e chiamano direttamente performGlobalAction(). Se il Servizio di Accessibilità non è abilitato, il resolver lancia un GraphQLError invece di ignorare silenziosamente l'input, così l'UI web può chiedere all'utente di abilitarlo.

Pipeline Audio

Codifica Opus su Android

MediaCodecAudioEncoder utilizza AudioPlaybackCaptureConfiguration (costruito dallo stesso MediaProjection) per catturare l'audio di sistema tramite AudioRecord, alimentando il PCM grezzo in un codificatore Opus MediaCodec. Questo richiede Android 10+ e il permesso RECORD_AUDIO — su dispositivi più vecchi o senza il permesso, start() registra un avviso e salta del tutto l'audio (il video continua a funzionare). I pacchetti Opus codificati sono incapsulati nello stesso protocollo VideoPacket (con FLAG_AUDIO impostato) e condividono il canale WebSocket SCREEN_MIRROR_AUDIO dei pacchetti video.

Decodifica Opus sul Web

ScreenMirrorAudioPipeline utilizza WebCodecs AudioDecoder per decodificare i dati Opus, producendo AudioData instradato a un elemento <audio>. Il timestamp dei frame audio viene utilizzato per la sincronizzazione A/V — condividendo la stessa base temporale (PTS del codificatore, in microsecondi) dei frame video, quindi non è necessaria una negoziazione separata dell'orologio tra i due flussi.

Ottimizzazioni delle Prestazioni

Percorsi Zero-Copy

PercorsoMetodo
VirtualDisplay → Surface del codificatoreGPU direct, Surface passthrough
VideoDecoder → VideoFrame → texture WebGLgl.texImage2D(VideoFrame), GPU direct
Ricezione WebSocket → parsing VideoPacketUint8Array.subarray() è una vista, nessuna copia

Ottimizzazione avccToAnnexB

Alcuni codificatori Android emettono in formato AVCC (prefisso di 4 byte di lunghezza), che richiede la conversione al formato Annex-B (codice di inizio 00 00 00 01) per la decodifica WebCodecs.

L'implementazione iniziale usava ArrayList<Byte> con boxing per byte — un frame IDR di 50KB produceva 50.000 operazioni di boxing java.lang.Byte, creando un'enorme pressione sul GC. L'ottimizzazione utilizza scansione a due passate + copyInto (che mappa all'intrinseco System.arraycopy su JVM):

// Prima passata: calcola la dimensione dell'output
var outSize = 0
// Seconda passata: copia bulk
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)

Strategia di Scarto dei P-Frame

Il decodificatore potrebbe essere lento durante l'inizializzazione. Se la coda dei P-frame è troppo lunga, la latenza si accumula. La soglia della dimensione della coda di decodifica è impostata a > 5 (invece di > 2) per evitare un'eccessiva perdita di frame durante l'inizializzazione del decodificatore hardware.

Deduplicazione delle Richieste IDR

Il controllo waitingForIdr garantisce una sola richiesta IDR per evento di perdita, prevenendo richieste duplicate mentre si attende l'arrivo di un IDR.

Riepilogo dei Pattern di Progettazione

PatternDovePerché
Macchina a Statiflag waitingForIdrTransizioni di stato esplicite per scarto/recupero P-frame
Guardia di Ricorsioneif (!running) return in stop()Previene la ricorsione onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop
Pipeline Zero-CopyVideoFrame → gl.texImage2DCaricamento texture GPU direct, nessuna copia CPU
Scansione a Due PassateavccToAnnexBPre-calcolo dimensione, allocazione singola + copia bulk, elimina il boxing
Evento RaggruppatoSPS/PPS + IDR in un unico eventoIl cambio configurazione completa riconfigurazione + decodifica primo frame in un evento
Separazione dei CallbackonFirstFrameRendered vs onDisconnected vs onScreenMirrorOffChiara distinzione tra rendering primo frame, fallimento trasporto e arresto lato telefono
Rete di SicurezzarequestIdr() + requestKeyFrame()Scarta frame residui + richiedi IDR pulito dopo cambio configurazione
Deduplicazione PTStimestamp < lastRenderedPtsScarta frame fuori ordine
Gap FrameIdframeId > lastFrameId + 1Rilevamento perdita pacchetti senza ACK
Contesto desynchronizedWebGL2 desynchronized: trueBypassa il compositor, risparmia 1 frame di latenza
Fallimento Veloce EsplicitosendScreenMirrorControl lancia GraphQLErrorSegnala "accessibilità disabilitata" invece di ignorare silenziosamente l'input

Letture Consigliate

  • WebCodecs API — Documentazione MDN che copre le interfacce VideoDecoder/AudioDecoder.