Bloga dön
Architecture15 min read

Ekran Yansıtma: Düşük Gecikmeli Yayın Mimarisi

Bu makale, PlainApp'in ekran yansıtma sisteminin uçtan uca tasarımını kapsar: Android'in MediaCodec ile H.264/Opus'u nasıl donanım kodlayıp yakaladığı, karelerin özel bir ikili protokol ile WebSocket üzerinden nasıl taşındığı, web tarafının WebCodecs ile kod çözüp WebGL2 ile sıfır CPU kopyası ile nasıl render ettiği, kayıp algılama, yön değişikliği ve uzaktan dokunma kontrolünün nasıl ele alındığı ve sistem MediaProjection yaşam döngüsünün nasıl senkronize tutulduğu.

Table of Contents

High-Level Architecture

PlainApp ekran yansıtma, uçtan uca düşük gecikmeli bir yayın sistemidir: Android cihaz ekran içeriğini yakalar, H.264 video ve Opus ses olarak donanım kodlar ve özel bir ikili protokol ile WebSocket üzerinden web istemcisine gönderir; web istemcisi WebCodecs API ile kod çözer ve WebGL2 aracılığıyla doğrudan Canvas'a render eder, tüm süreç boyunca sıfır CPU kopyası ile. Şeffaf bir dokunma katmanı döngüyü kapatır ve işaretçi girdilerini telefon üzerinde jestlere dönüştürür.

WebRTC yok, RTMP yok, ara sunucu yok. Tüm hat şu şekildedir:

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

Neden WebRTC değil?

WebRTC, gerçek zamanlı iletişim için tasarlanmıştır. ICE/STUN/TURN müzakeresi, tıkanıklık kontrolü ve titreşim tamponlama, LAN ekran yayını için gereksizdir. PlainApp'in kullanım durumu şudur:

  • Aynı LAN, gecikme < 5ms, NAT geçişi gerekmez
  • Aşırı düşük gecikme hedeflenir, titreşim tamponlama yok
  • Yüksek kalite, bit hızı yüksek olabilir (8 Mbps)
  • Ekran kontrolü (dokunma enjeksiyonu), WebRTC'nin DataChannel'ı gereksiz karmaşıklık ekler

WebSocket üzerinde özel bir ikili protokol, LAN senaryosu için daha hafif ve daha kontrol edilebilirdir.

Bileşen haritası

Diagram 1
1

KatmanAndroidWeb
Ekran yakalamaMediaProjection + VirtualDisplay—
Video kodlamaMediaCodec H.264 donanım kodlayıcı—
Ses kodlamaMediaCodec Opus donanım kodlayıcı—
TaşımaWebSocket ikili olaylarWebSocket alıcı
Video kod çözme—WebCodecs VideoDecoder
Ses kod çözme—WebCodecs AudioDecoder → <audio>
Render—WebGL2 doku doğrudan render
KontrolAccessibilityService jest enjeksiyonuDokunma katmanı → GraphQL mutation

Video ve ses her zaman cihazdan tarayıcıya aynı WebSocket bağlantısı üzerinden akar; kontrol, GraphQL (sendScreenMirrorControl) üzerinden ters yönde akar ve bu aynı zamanda codec yapılandırması (screenMirrorVideoCodec sorgusu) ve anahtar kare istekleri (requestScreenMirrorKeyFrame mutation) için yan kanal olarak da işlev görür.

Video Encoding Pipeline (Android)

Kodlama Parametre Ayarı

Kodlama parametreleri, düşük gecikmeli LAN ekran yayını için özel olarak ayarlanmıştır:

ParametreDeğerNotlar
KEY_FRAME_RATE6060fps akıcılık için
KEY_I_FRAME_INTERVAL10IDR aralığı 10s, anahtar kare yükünü azaltır
KEY_BIT_RATE_MODEVBR (örtük, açık mod ayarlanmamıştır)Değişken bit hızı, sahne uyarlamalı
KEY_PRIORITY0Gerçek zamanlı öncelik
KEY_LATENCY1Düşük gecikme modu

Bit hızı, kalite moduna göre kademelendirilir. Daha yüksek bit hızları (örn. 24 Mbps) test edilmiş ancak kodlayıcı/kod çözücü kare düşüşlerine ve uçtan uca gecikmede artışa neden olmuş, ekran içeriği için görünür kalite kazancı sağlamamıştır:

ModBit HızıYakalama çözünürlüğü
HD8 Mbps1080p kısa kenar
Smooth4 Mbps1080p kısa kenar
Low2 Mbps720p kısa kenar

Kodlayıcı Düşük Gecikme Yapılandırması

MediaCodecVideoEncoder, kodlayıcıyı oluşturma anında bir kez yapılandırır:

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 ve KEY_LATENCY=1 düşük gecikmenin anahtarıdır -- kodlayıcıya sıkıştırma oranı yerine gerçek zamanlı kodlamayı önceliklendirmesini söyler. Giriş, MediaCodec.createInputSurface() tarafından oluşturulan ve doğrudan VirtualDisplay'e beslenen bir Surface'dir -- SurfaceTexture geri okuma, I420 dönüşümü yok, CPU piksellere dokunmaz.

Yakalama Çözünürlüğü

ScreenMirrorCaptureSize.compute(), fiziksel ekran boyutundan, kalite modunun kısa kenar hedefinden (720/1080) ve kodlayıcının bildirdiği maxWidth/maxHeight ile genişlik/yükseklik hizalamasından (bir kez MediaCodecVideoEncoder.queryEncoderCaps() ile sorgulanır) gerçek yakalama boyutunu türetir, böylece kodlayıcı asla kabul edemeyeceği boyutlar almaz.

Anahtar Kare İstekleri

Web istemcisi, paket kaybından kurtulmak için GraphQL requestScreenMirrorKeyFrame mutation'ı aracılığıyla bir IDR karesi talep edebilir. Android, MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME ile yanıt verir:

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

SPS/PPS ve Anahtar Kare Yayını

Kodlayıcı başladıktan sonra, INFO_OUTPUT_FORMAT_CHANGED csd-0/csd-1 (SPS/PPS) gönderir. ScreenMirrorPipeline bunları tek bir Annex-B yapılandırma blob'unda birleştirir ve önbelleğe alır (cachedConfig). Ardından gelen ilk IDR de önbelleğe alınır (cachedKeyFrame), böylece yeni bağlanan bir web istemcisi, bir sonraki anahtar kare aralığını beklemeden her ikisini de screenMirrorVideoCodec GraphQL sorgusu aracılığıyla çekebilir. Yapılandırma değiştiğinde (yön veya kalite değişimi), Android yeni IDR'yi normal bir video paketi olarak göndermez -- SPS/PPS + IDR'yi tek bir screen_mirror_video_codec WebSocket olayında birleştirir, böylece web istemcisi kod çözücü yeniden yapılandırmasını ve ilk kare kod çözmeyi tek seferde tamamlar, eski bir kod çözücü ile yeni bir bit akışı arasında yarışmak yerine.

Bazı OEM kodlayıcıları (Qualcomm/Xiaomi), SPS+PPS+IDR'yi hem BUFFER_FLAG_CODEC_CONFIG hem de BUFFER_FLAG_SYNC_FRAME taşıyan tek bir çıkış arabelleğinde birleştirir. Tahliye döngüsü yalnızca salt yapılandırma olan (isConfig && !isKey) arabellekleri atlar -- yapılandırma bayraklı ancak aynı zamanda senkron kare taşıyan bir arabelleği atlamak, IDR'yi sessizce düşürür ve kod çözücüyü yalnızca P-karesi ile bırakarak mozaik çıktı üretir.

VideoPacket Protocol Design

Hem video hem de ses kareleri, WebSocket taşıması için birleşik VideoPacket ikili protokolünde sarılır.

Protokol Formatı

+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| 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 -/
AlanBoyutAçıklama
MAGIC1 byteSabit 0x56 ('V'), doğrulama için
FLAGS1 byte0x01=anahtar kare, 0x02=yapılandırma, 0x04=ses
FRAME_ID4 bytesMonoton artan kare numarası, uint32 big-endian
TIMESTAMP8 bytesKodlayıcı PTS'si mikrosaniye cinsinden, big-endian
DATAdeğişkenH.264 NAL birimi veya Opus verisi

Hem Android'in VideoPacket.encode() (kod çözücü commonMain içinde, bu nedenle kablosu JVM birim testleri tarafından herhangi bir Android bağımlılığı olmadan kapsanır) hem de web'in parseVideoPacket()'i bu formatı bağımsız olarak uygular -- paylaşılan bir serileştirme kütüphanesi yoktur, sadece her iki tarafın da uyduğu bir spesifikasyon vardır.

Diagram 2
2

Tasarım Notları

  • FRAME_ID işaretsiz ayrıştırma: ((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0 -- işaretsiz olduğundan emin olmak için >>> 0 kullanılmalıdır, aksi takdirde frameId > 2^31 negatif olarak ayrıştırılır ve yanlış kayıp algılamasına neden olur.
  • FRAME_ID asla sıfırlanmaz: Kodlayıcı yön değişikliği için yeniden oluşturulduğunda, frameId artmaya devam eder (kodlayıcıda değil, ScreenMirrorPipeline içinde yaşar). Bu, web tarafının döndürme sırasında frameId boşlukları aracılığıyla kare kaybını tespit etmesini sağlar.
  • TIMESTAMP kodlayıcı PTS'sini kullanır: Web istemcisinin saatine bağımlılık yoktur, saat kaymasının A/V senkronizasyon bozulmasına neden olmasını önler.
  • Sıfır kopyalı ayrıştırma: web ayrıştırıcı, yükü Uint8Array.subarray() ile dilimler -- orijinal WebSocket ArrayBuffer'ına bir görünüm, kopya değil.

Video Decoding Pipeline (Web)

WebCodecs VideoDecoder

Web istemcisi, donanım kod çözme için WebCodecs API'nin VideoDecoder'ını kullanır. MediaSource Extensions veya WebRTC ile karşılaştırıldığında, WebCodecs kod çözme süreci üzerinde ince taneli kontrol sağlar -- titreşim arabelleği yok, kapsayıcı katmanı yok ve kod çözülmüş VideoFrame nesneleri doğrudan WebGL dokuları olarak yüklenebilir.

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

Anahtar yapılandırmalar:

  • optimizeForLatency: true -- kod çözücüye düşük gecikmeyi önceliklendirmesini söyler, kare arabellekleme yok
  • hardwareAcceleration: 'prefer-hardware' -- GPU kod çözmeyi tercih eder
  • avc: { format: 'annexb' } -- her IDR'den önce satır içi SPS/PPS ile Annex-B formatını kullanır
  • codec dizesi sabit kodlanmamıştır -- extractAvc1CodecString(), yapılandırma blob'undaki ilk SPS NAL'dan profil/uyum/seviye baytlarını doğrudan okur

Yeşil Ekran Sorunu ve Başlangıç Sırası

Kodlayıcı, VirtualDisplay gerçek ekran içeriğini render etmeden önce ilk IDR karesini üretir -- bu boş (yeşil) bir karedir. Web tarafı bu kareyi kod çözerse, kullanıcı ekran içeriği değişip yeni bir kare tetiklenene kadar yeşil bir flaş görür.

Çözüm: Başlangıçta, web tarafı önbelleğe alınmış yapılandırmayı screenMirrorVideoCodec GraphQL sorgusu aracılığıyla çeker ancak birleştirilmiş anahtar kareyi kod çözmez. Bunun yerine, waitingForIdr = true ayarlamak için video.requestIdr() çağrısını yapar (bir IDR gelene kadar tüm P-karesi karelerini düşürür), ardından kayıp kurtarma için kullanılan aynı mutation üzerinden yeni bir IDR istemek için requestKeyFrame() çağrısını yapar. Yeni IDR geldiğinde, VirtualDisplay gerçek ekran içeriğine sahiptir.

video.requestIdr()      // P-karesi karelerini düşür, IDR bekle
await requestKeyFrame() // GraphQL mutation ile yeni IDR iste

onFirstFrameRendered geri çağrısı, handleVideo() yerine renderFrame()'e bağlanır, böylece UI'nin yalnızca gerçek bir kare render edildikten sonra güncellenmesi sağlanır -- sadece alındığında değil.

WebGL2 Rendering

Sıfır Kopyalı GPU Doğrudan Render

Kod çözülmüş VideoFrame nesneleri, CPU'dan asla geçmeden doğrudan WebGL2 dokuları olarak yüklenir:

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

gl.texImage2D, VideoFrame'i piksel kaynağı olarak kabul eder. Tarayıcı, YUV→RGB dönüşümünü ve GPU yüklemeyi dahili olarak halleder -- ImageData CPU kopyası yoktur. MirrorGLRenderer, getContext('webgl2', ...) başarısız olursa Canvas 2D drawImage()'e geri döner, böylece eski tarayıcılar yine de (biraz daha yüksek gecikmeli) bir görüntü alır.

desynchronized Context

const gl = canvas.getContext('webgl2', {
    alpha: false,
    desynchronized: true,        // birleştiriciyi atla, doğrudan ekrana yaz
    preserveDrawingBuffer: true, // ekran görüntüleri için arabelleği koru
    powerPreference: 'high-performance',
    antialias: false,
    depth: false,
    stencil: false,
    premultipliedAlpha: false,
})

desynchronized: true, tarayıcı birleştiricisini atlayarak doğrudan ekrana yazar ve yaklaşık 1 karelik görüntüleme gecikmesinden (~16ms @ 60fps) tasarruf sağlar.

preserveDrawingBuffer: true, çizim arabelleğini korur, böylece canvas.toDataURL() ekran görüntüleri içeriği okuyabilir. Varsayılan false ile arabellek birleştirmeden sonra temizlenir ve siyah ekran görüntüleri üretilir.

Gölgelendiricinin kendisi kasıtlı olarak minimaldir -- bir tam ekran üçgen vertex gölgelendiricisi ve dokuyu örnekleyen tek satırlık bir fragment gölgelendiricisi -- çünkü kare başına gereken tek iş "bu dokuyu ekrana koymaktır."

Canvas Otomatik Sığdırma

Canvas arka uç depolama boyutu, her değiştiğinde VideoFrame.displayWidth/Height'den ayarlanır. CSS boyutu daha sonra, en boy oranını koruyarak (gerektiğinde letterbox veya pillarbox) fitCanvasToWrapper() tarafından sarmalayıcı kapsayıcıya sığdırılır. Canvas'ın üst öğesindeki bir ResizeObserver, kapsayıcı yeniden boyutlandığında bu sığdırmayı yeniden çalıştırır, böylece video asla esnemez.

Loss Detection & Error Recovery

FrameId Boşluk Algılama

Her video karesi monoton artan bir frameId taşır. Kod çözücü lastFrameId'yi izler; yeni bir karenin frameId > lastFrameId + 1 olması durumunda kareler kaybolmuştur:

if (!this.waitingForIdr && this.lastFrameId > 0
    && packet.frameId > this.lastFrameId + 1) {
    if (!packet.isKeyFrame) {
        // Kayıp: sonraki P-karesi karelerini düşür, yeni IDR iste
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.lastFrameId = packet.frameId
        return
    }
}

waitingForIdr Durum Makinesi

waitingForIdr basit iki durumlu bir makinedir:

Diagram 3
3

DurumDavranış
NORMALTüm kareleri normal şekilde kod çöz
WAITING_FOR_IDRTüm P-karesi karelerini düşür, yalnızca IDR karelerini kod çöz; IDR geldiğinde NORMAL'e sıfırla

WAITING_FOR_IDR durumuna geçişi tetikleyen senaryolar:

  1. Başlangıçta: eski GraphQL anahtar karesini atla, gerçek IDR'yi bekle
  2. Paket kaybında: kod çözülemeyen P-karesi karelerini düşür, IDR kurtarmasını bekle
  3. Kod çözücü hatasında: kod çözücüyü sıfırla, IDR'yi bekle
  4. Yapılandırma değişikliğinde: yön/kalite değişikliğinden sonra kalan P-karesi karelerini düşür

Kod Çözücü Hata Kurtarma

VideoDecoder.onerror tetiklendiğinde, decoderNeedsReset = true hat katmanında (screen-mirror-pipeline.ts) ayarlanır. Bir sonraki IDR karesinde, kod çözücü GraphQL'e tekrar gitmek yerine önbelleğe alınmış SPS/PPS ile yeniden yapılandırılır:

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

Geri Basınç ve Zaman Damgası Tekilleştirme

decoder.decodeQueueSize > 5 ise, gelen P-karesi kareleri sıraya alınmak yerine düşürülür -- 5 eşiği (2 yerine), gereksiz takılmaya neden olmadan donanım kod çözücü başlangıç gecikmesini tolere eder. Ayrıca, render'dan sonra lastRenderedPts kaydedilir; zaman damgası daha eski olan bir kare (sıra dışı varış), anahtar kare olmadığı sürece düşürülür:

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

Orientation Change Handling

Kodlayıcıyı Yeniden Oluşturma

ScreenMirrorService içindeki bir OrientationEventListener, her sensör geri çağrısında ekranın rotation değerini önbelleğe alınmış isPortrait bayrağı ile karşılaştırır; yalnızca gerçek bir dikey/yatay dönüş pipeline.onOrientationChanged() çağrısını yapar ve dokunma koordinat ölçeklemesi için kullanılan erişilebilirlik ekran boyutu önbelleğini geçersiz kılar.

Diagram 4
4

rebuildEncoderAndResize():

  1. Yeni boyutlarda yeni bir kodlayıcı oluştur (örn. yatay 1920×1080)
  2. VirtualDisplay.surface'i yeni kodlayıcının giriş Surface'ine değiştir
  3. Eski kodlayıcıyı durdur
  4. VirtualDisplay.resize()'ı yeni boyutlara ayarla

Surface değişimi yeniden boyutlandırmadan önce gerçekleşir -- yeni kodlayıcının önce kare alması ve eski kodlayıcının yanlış boyutlu kareler almadan durdurulması sağlanır. virtualDisplay?.surface = ... hata verirse, yeniden oluşturma iptal edilir ve hat hiç kodlayıcısız kalmak yerine eski kodlayıcıyı çalıştırmaya devam eder.

Yapılandırma Değişikliği Bildirimi

Yeni kodlayıcı ilk kez SPS/PPS çıktısı verdiğinde, pendingConfigBroadcast hat üzerinde ayarlanır. Yeni kodlayıcıdan ilk IDR geldiğinde, sıradan bir video paketi olarak gönderilmek yerine bu yapılandırma ile birlikte tek bir screen_mirror_video_codec olayında birleştirilir.

Web istemcisi daha sonra handleConfig() içinde:

  1. Kod çözücüyü yeni SPS/PPS ile yeniden yapılandırır
  2. Birleştirilmiş IDR karesini hemen kod çözer
  3. Hala havada olan eski kodlayıcıdan kalan P-karesi karelerini düşürmek için video.requestIdr() çağrısını yapar
  4. Temiz, yeni bir IDR istemek için requestKeyFrame() çağrısını yapar

3-4. adımlar bir güvenlik ağıdır -- yeni kodlayıcının ilk IDR'si (asenkron yeniden boyutlandırma penceresi sırasında) yanlış boyutlara sahip olsa bile, web istemcisi hızla doğru boyutlara kurtarılır. handleConfig() ayrıca, gelen yapılandırma önbelleğe alınmış olanla bayt bazında aynıysa kısa devre yapar, çünkü kod çözücüyü değişmeyen baytlarla yeniden yapılandırmak, yine de kurtarmak için bir IDR'ye mal olan bir işlemdir.

System MediaProjection Lifecycle

Sorun

Kullanıcılar, uygulamanın UI'si yerine Android sistem bildirim çubuğu aracılığıyla sistem düzeyindeki ekran yayınını (MediaProjection) kapatabilir. Bu durumda, ScreenMirrorService yayının durduğunu bilmez -- running true olarak kalır, web istemcisi screenMirrorState'i sorgular ve true alır, ancak hiçbir video karesi gelmez ve sayfa yüklemede takılı kalır.

MediaProjection.Callback

MediaProjection, sistem yayını durdurduğunda tetiklenen bir Callback.onStop() geri çağrısı sağlar. ScreenMirrorPipeline.startEncoders() bu geri çağrıyı kaydeder ve onStop() içinde ScreenMirrorService.instance?.stop() çağrısını yapar:

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

Diagram 5
5

Service.stop() Sorumlulukları

stop(), web istemcisini bilgilendirmekten ve servisi durdurmaktan sorumlu olan açık durdurma noktasıdır:

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

if (!running) return koruması özyinelemeyi önler: onStop() → stop() → stopSelf() → onDestroy() → pipeline.stop() → projection.stop() → onStop() → stop() (bu noktada running=false, hemen döner).

Web Tarafı İşleme

Web istemcisi {"running":false} olayını aldığında, boşta durumuna sıfırlanır ve başlatma düğmesini gösterir:

const onScreenMirroring = (data: any) => {
    if (data?.running === false) {
        cleanupFn()
        fullReset()
        return
    }
    // running=true → akışa bağlan
}

Remote Control: Touch Injection

Ekran yansıtma varsayılan olarak tek yönlüdür (yalnızca video/ses); uzaktan kontrol isteğe bağlıdır ve kullanıcının PlainApp'in Erişilebilirlik Servisi'ni bir kez etkinleştirmesini gerektirir, çünkü Android'in AccessibilityService.dispatchGesture() dışında rastgele dokunma olayları enjekte etmek için herkese açık bir API'si yoktur.

Diagram 6
6

Koordinat Normalleştirme (Web)

<canvas>'in üzerinde şeffaf bir katman bulunur ve işaretçi olaylarını yakalar. normalizeCoords(), ham bir clientX/clientY'yi, katmanın sınırlayıcı kutusuna değil, gerçek video içerik alanına göre [0,1] koordinatlarına dönüştürür -- bunu, canvas'ın arka uç depolama en boy oranı ile render edilmiş kapsayıcı en boy oranı arasındaki letterbox/pillarbox uzaklığını hesaplayarak yapar:

if (videoAspect > containerAspect) {
    // Üst/alt letterbox
    renderW = containerW
    renderH = containerW / videoAspect
    offsetY = (containerH - renderH) / 2
} else {
    // Sol/sağ pillarbox
    renderH = containerH
    renderW = containerH * videoAspect
    offsetX = (containerW - renderW) / 2
}

Bir işaretçi basışı, başlangıç pozisyonunu/zamanını izleyen bir GestureState başlatır; 500ms'lik bir basılı tutma < 10px hareketle LONG_PRESS'e yükselir, bu eşiğin ötesindeki hareket SWIPE olur ve hızlı bir bırakma TAP olur. Görsel bir dokunma göstergesi (büyüyen/solan bir nokta), operatöre telefon yanıt vermeden önce hangi jestin tanındığına dair geri bildirim verir.

GraphQL → AccessibilityService

Tanınan her jest, bir action (TAP/LONG_PRESS/SWIPE/SCROLL/BACK/HOME/RECENTS/LOCK_SCREEN/KEY) ve normalleştirilmiş koordinatlar taşıyan bir sendScreenMirrorControl(input) mutation'ı olarak gönderilir. Çözümleyici, normalleştirilmiş koordinatları gerçek ekran boyutuyla (PlainAccessibilityService.getScreenSize()'dan, her yön değişikliğinde geçersiz kılınır) çarpan ve PlainAccessibilityService.dispatchControl()'e devreden dispatchScreenMirrorControl()'ü çağrır:

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 ve LONG_PRESS, daha uzun bir vuruş süresi veya tek bir nokta yerine bir çizgi yolu ile aynı GestureDescription'u oluşturur; SCROLL, (x, y)'den (x, y + deltaY)'ye ±500px ile sınırlandırılmış sentetik bir kaydırma olarak uygulanır. Dört global eylem (BACK/HOME/RECENTS/LOCK_SCREEN), jest gönderimini tamamen atlar ve doğrudan performGlobalAction()'ı çağrır. Erişilebilirlik Servisi etkin değilse, çözümleyici girdiyi sessizce düşürmek yerine bir GraphQLError fırlatır, böylece web UI kullanıcıya servisi etkinleştirmesini isteyebilir.

Audio Pipeline

Android Opus Kodlama

MediaCodecAudioEncoder, sistem sesini AudioRecord aracılığıyla yakalamak için AudioPlaybackCaptureConfiguration (aynı MediaProjection'dan oluşturulur) kullanır ve ham PCM'yi bir MediaCodec Opus kodlayıcısına besler. Bu, Android 10+ ve RECORD_AUDIO iznini gerektirir -- eski cihazlarda veya izin olmadan, start() bir uyarı günlüğe kaydeder ve sesi tamamen atlar (video çalışmaya devam eder). Kodlanmış Opus paketleri, aynı VideoPacket protokolünde (FLAG_AUDIO ayarlanmış) sarılır ve video paketinin SCREEN_MIRROR_AUDIO WebSocket kanalını paylaşır.

Web Opus Kod Çözme

ScreenMirrorAudioPipeline, Opus verilerini kod çözmek için WebCodecs AudioDecoder kullanır, çıktı AudioData'yı bir <audio> öğesine yönlendirir. Ses karesi timestamp değeri A/V senkronizasyonu için kullanılır -- video kareleriyle aynı zaman tabanını (kodlayıcı PTS'si, mikrosaniye cinsinden) paylaşır, böylece iki akış arasında ayrı bir saat anlaşması gerekmez.

Performance Optimizations

Sıfır Kopyalı Yollar

YolYöntem
VirtualDisplay → kodlayıcı SurfaceGPU direct, Surface geçişi
VideoDecoder → VideoFrame → WebGL dokusugl.texImage2D(VideoFrame), GPU direct
WebSocket alımı → VideoPacket ayrıştırmaUint8Array.subarray() bir görünümdür, kopya yok

avccToAnnexB İyileştirmesi

Bazı Android kodlayıcıları, WebCodecs kod çözme için Annex-B formatına (00 00 00 01 başlangıç kodu) dönüştürülmesi gereken AVCC formatını (4 bayt uzunluk öneki) çıktılar.

İlk uygulama, bayt başına kutulama ile ArrayList<Byte> kullandı -- 50KB'lık bir IDR karesi, 50.000 java.lang.Byte kutulama işlemi üreterek büyük GC baskısı yarattı. İyileştirme, iki geçişli tarama + copyInto (JVM'de System.arraycopy intrinsic'ine eşlenir) kullanır:

// İlk geçiş: çıktı boyutunu hesapla
var outSize = 0
// İkinci geçiş: toplu kopyalama
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)

P-Karesi Düşürme Stratejisi

Kod çözücü, başlatma sırasında yavaş olabilir. P-karesi kuyruğu çok uzunsa, gecikme birikir. Kod çözme kuyruğu boyut eşiği, donanım kod çözücü başlatma sırasında aşırı kare kaybını önlemek için > 5 ( > 2 yerine) olarak ayarlanır.

IDR İsteği Tekilleştirme

waitingForIdr koruması, kayıp olayı başına yalnızca bir IDR isteği yapılmasını sağlayarak, bir IDR gelmesini beklerken yinelenen istekleri önler.

Design Patterns Recap

DesenKonumNeden
Durum MakinesiwaitingForIdr bayrağıAçık P-karesi düşürme/kurtarma durum geçişleri
Özyineleme Korumasıstop() içinde if (!running) returnonStop → stop → onDestroy → pipeline.stop → projection.stop → onStop özyinelemesini önler
Sıfır Kopyalı HatVideoFrame → gl.texImage2DGPU direct doku yükleme, CPU kopyası yok
İki Geçişli TaramaavccToAnnexBBoyutu önceden hesapla, tek tahsis + toplu kopyalama, kutulamayı ortadan kaldırır
Birleştirilmiş OlaySPS/PPS + IDR tek olaydaYapılandırma değişikliği, yeniden yapılandırma + ilk kare kod çözmeyi tek olayda tamamlar
Geri Çağrı AyrımıonFirstFrameRendered vs onDisconnected vs onScreenMirrorOffİlk kare render, taşıma hatası ve telefon tarafı durdurma arasında net ayrım
Güvenlik AğırequestIdr() + requestKeyFrame()Kalan kareleri düşür + yapılandırma değişikliğinden sonra temiz IDR iste
PTS Tekilleştirmetimestamp < lastRenderedPtsSıra dışı kareleri düşür
FrameId BoşluğuframeId > lastFrameId + 1ACK'sız paket kaybı algılama
desynchronized ContextWebGL2 desynchronized: trueBirleştiriciyi atla, 1 kare gecikme kazancı
Açık Hızlı BaşarısızlıksendScreenMirrorControl GraphQLError fırlatır"Erişilebilirlik devre dışı" sorununu sessizce girdi düşürmek yerine yüzeye çıkarır

Further Reading