Table of Contents
- Üst Düzey Mimari
- Video Kodlama Hattı (Android)
- VideoPacket Protokol Tasarımı
- Video Kod Çözme Hattı (Web)
- WebGL2 Render
- Kayıp Algılama ve Hata Kurtarma
- Yön Değişikliği Yönetimi
- Sistem MediaProjection Yaşam Döngüsü
- Uzaktan Kontrol: Dokunma Enjeksiyonu
- Ses Hattı
- Performans İyileştirmeleri
- Tasarım Desenleri Özeti
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ı
| Katman | Android | Web |
|---|---|---|
| Ekran yakalama | MediaProjection + VirtualDisplay | — |
| Video kodlama | MediaCodec H.264 donanım kodlayıcı | — |
| Ses kodlama | MediaCodec Opus donanım kodlayıcı | — |
| Taşıma | WebSocket ikili olaylar | WebSocket alıcı |
| Video kod çözme | — | WebCodecs VideoDecoder |
| Ses kod çözme | — | WebCodecs AudioDecoder → <audio> |
| Render | — | WebGL2 doku doğrudan render |
| Kontrol | AccessibilityService jest enjeksiyonu | Dokunma 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:
| Parametre | Değer | Notlar |
|---|---|---|
KEY_FRAME_RATE | 60 | 60fps akıcılık için |
KEY_I_FRAME_INTERVAL | 10 | IDR aralığı 10s, anahtar kare yükünü azaltır |
KEY_BIT_RATE_MODE | VBR (örtük, açık mod ayarlanmamıştır) | Değişken bit hızı, sahne uyarlamalı |
KEY_PRIORITY | 0 | Gerçek zamanlı öncelik |
KEY_LATENCY | 1 | Düşü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:
| Mod | Bit Hızı | Yakalama çözünürlüğü |
|---|---|---|
| HD | 8 Mbps | 1080p kısa kenar |
| Smooth | 4 Mbps | 1080p kısa kenar |
| Low | 2 Mbps | 720p 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 -/
| Alan | Boyut | Açıklama |
|---|---|---|
MAGIC | 1 byte | Sabit 0x56 ('V'), doğrulama için |
FLAGS | 1 byte | 0x01=anahtar kare, 0x02=yapılandırma, 0x04=ses |
FRAME_ID | 4 bytes | Monoton artan kare numarası, uint32 big-endian |
TIMESTAMP | 8 bytes | Kodlayıcı PTS'si mikrosaniye cinsinden, big-endian |
DATA | değişken | H.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.
Tasarım Notları
FRAME_IDişaretsiz ayrıştırma:((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0-- işaretsiz olduğundan emin olmak için>>> 0kullanılmalıdır, aksi takdirdeframeId > 2^31negatif olarak ayrıştırılır ve yanlış kayıp algılamasına neden olur.FRAME_IDasla sıfırlanmaz: Kodlayıcı yön değişikliği için yeniden oluşturulduğunda,frameIdartmaya devam eder (kodlayıcıda değil,ScreenMirrorPipelineiç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.TIMESTAMPkodlayı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 WebSocketArrayBuffer'ı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 yokhardwareAcceleration: 'prefer-hardware'-- GPU kod çözmeyi tercih ederavc: { 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:
| Durum | Davranış |
|---|---|
NORMAL | Tüm kareleri normal şekilde kod çöz |
WAITING_FOR_IDR | Tü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:
- Başlangıçta: eski GraphQL anahtar karesini atla, gerçek IDR'yi bekle
- Paket kaybında: kod çözülemeyen P-karesi karelerini düşür, IDR kurtarmasını bekle
- Kod çözücü hatasında: kod çözücüyü sıfırla, IDR'yi bekle
- 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.
rebuildEncoderAndResize():
- Yeni boyutlarda yeni bir kodlayıcı oluştur (örn. yatay 1920×1080)
VirtualDisplay.surface'i yeni kodlayıcının girişSurface'ine değiştir- Eski kodlayıcıyı durdur
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:
- Kod çözücüyü yeni SPS/PPS ile yeniden yapılandırır
- Birleştirilmiş IDR karesini hemen kod çözer
- Hala havada olan eski kodlayıcıdan kalan P-karesi karelerini düşürmek için
video.requestIdr()çağrısını yapar - 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)
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.
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
| Yol | Yöntem |
|---|---|
| VirtualDisplay → kodlayıcı Surface | GPU direct, Surface geçişi |
| VideoDecoder → VideoFrame → WebGL dokusu | gl.texImage2D(VideoFrame), GPU direct |
| WebSocket alımı → VideoPacket ayrıştırma | Uint8Array.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
| Desen | Konum | Neden |
|---|---|---|
| Durum Makinesi | waitingForIdr bayrağı | Açık P-karesi düşürme/kurtarma durum geçişleri |
| Özyineleme Koruması | stop() içinde if (!running) return | onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop özyinelemesini önler |
| Sıfır Kopyalı Hat | VideoFrame → gl.texImage2D | GPU direct doku yükleme, CPU kopyası yok |
| İki Geçişli Tarama | avccToAnnexB | Boyutu önceden hesapla, tek tahsis + toplu kopyalama, kutulamayı ortadan kaldırır |
| Birleştirilmiş Olay | SPS/PPS + IDR tek olayda | Yapı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ştirme | timestamp < lastRenderedPts | Sıra dışı kareleri düşür |
| FrameId Boşluğu | frameId > lastFrameId + 1 | ACK'sız paket kaybı algılama |
| desynchronized Context | WebGL2 desynchronized: true | Birleştiriciyi atla, 1 kare gecikme kazancı |
| Açık Hızlı Başarısızlık | sendScreenMirrorControl GraphQLError fırlatır | "Erişilebilirlik devre dışı" sorununu sessizce girdi düşürmek yerine yüzeye çıkarır |
Further Reading
- WebCodecs API -- MDN belgeleri,
VideoDecoder/AudioDecoderarayüzlerini kapsar. - MediaCodec -- Android resmi dokümantasyonu, donanım kodlama/kod çözme API'si.
- MediaProjection -- Android ekran yakalama API referansı.
- WebGL2 -- MDN WebGL2 dokümantasyonu.
- WebCodecs VideoFrame -- MDN
VideoFramearayüzü. - AccessibilityService -- Android Erişilebilirlik Servisi API'si.