Mục lục
- Kiến trúc tổng quan
- Pipeline mã hóa video (Android)
- Thiết kế giao thức VideoPacket
- Pipeline giải mã video (Web)
- WebGL2 Rendering
- Phát hiện mất gói & Khôi phục lỗi
- Xử lý thay đổi hướng màn hình
- Vòng đời MediaProjection hệ thống
- Điều khiển từ xa: Touch Injection
- Pipeline âm thanh
- Tối ưu hiệu năng
- Tổng kết các Design Patterns
Kiến trúc tổng quan
Screen mirror của PlainApp là một hệ thống casting end-to-end độ trễ thấp: thiết bị Android capture nội dung màn hình, mã hóa phần cứng thành H.264 video và Opus audio, đẩy qua WebSocket bằng giao thức nhị phân tùy chỉnh đến web client; web client giải mã bằng WebCodecs API và render trực tiếp lên Canvas qua WebGL2, với zero CPU copies xuyên suốt. Một lớp touch overlay trong suốt hoàn thiện vòng lặp, biến các thao tác con trỏ thành cử chỉ trên điện thoại.
Không WebRTC, không RTMP, không máy chủ trung gian. Toàn bộ pipeline là:
Android VirtualDisplay → MediaCodec H.264 Encoder → WebSocket →
WebCodecs VideoDecoder → WebGL2 Texture → Canvas
Tại sao không dùng WebRTC?
WebRTC được thiết kế cho truyền thông thời gian thực. ICE/STUN/TURN negotiation, congestion control và jitter buffering của nó là quá mức cần thiết cho LAN screen casting. Trường hợp sử dụng của PlainApp là:
- Cùng LAN, độ trễ < 5ms, không cần NAT traversal
- Theo đuổi độ trễ cực thấp, không cần jitter buffering
- Chất lượng cao, bitrate có thể lớn (8Mbps)
- Điều khiển màn hình (touch injection), DataChannel của WebRTC gây thêm độ phức tạp không cần thiết
Giao thức nhị phân tùy chỉnh qua WebSocket nhẹ hơn và dễ kiểm soát hơn cho kịch bản LAN.
Sơ đồ thành phần
| Tầng | Android | Web |
|---|---|---|
| Screen capture | MediaProjection + VirtualDisplay | — |
| Video encoding | MediaCodec H.264 hardware encoder | — |
| Audio encoding | MediaCodec Opus hardware encoder | — |
| Transport | WebSocket binary events | WebSocket receiver |
| Video decoding | — | WebCodecs VideoDecoder |
| Audio decoding | — | WebCodecs AudioDecoder → <audio> |
| Rendering | — | WebGL2 texture direct-render |
| Control | AccessibilityService gesture injection | Touch overlay → GraphQL mutation |
Video và audio luôn truyền theo hướng thiết bị → trình duyệt trên cùng một kết nối WebSocket; điều khiển truyền theo hướng ngược lại qua GraphQL (sendScreenMirrorControl), đồng thời đóng vai trò là kênh phụ cho codec config (screenMirrorVideoCodec query) và keyframe request (requestScreenMirrorKeyFrame mutation).
Pipeline mã hóa video (Android)
Tinh chỉnh tham số mã hóa
Các tham số mã hóa được tinh chỉnh đặc biệt cho LAN screen casting độ trễ thấp:
| Tham số | Giá trị | Ghi chú |
|---|---|---|
KEY_FRAME_RATE | 60 | 60fps cho độ mượt |
KEY_I_FRAME_INTERVAL | 10 | IDR interval 10s, giảm overhead keyframe |
KEY_BIT_RATE_MODE | VBR (implicit, không set mode rõ ràng) | Variable bitrate, thích ứng theo cảnh |
KEY_PRIORITY | 0 | Ưu tiên thời gian thực |
KEY_LATENCY | 1 | Chế độ độ trễ thấp |
Bitrate được phân tầng theo chế độ chất lượng — các bitrate cao hơn (ví dụ 24 Mbps) đã được thử nghiệm và gây ra tình trạng drop frame ở encoder/decoder cũng như tăng độ trễ end-to-end mà không cải thiện chất lượng đáng kể cho nội dung màn hình:
| Chế độ | Bitrate | Capture resolution |
|---|---|---|
| HD | 8 Mbps | 1080p short side |
| Smooth | 4 Mbps | 1080p short side |
| Low | 2 Mbps | 720p short side |
Cấu hình độ trễ thấp cho Encoder
MediaCodecVideoEncoder cấu hình encoder một lần tại thời điểm tạo:
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 và KEY_LATENCY=1 là chìa khóa cho độ trễ thấp — chúng yêu cầu encoder ưu tiên mã hóa thời gian thực hơn là tỷ lệ nén. Đầu vào là một Surface được tạo bởi MediaCodec.createInputSurface() và được feed trực tiếp vào VirtualDisplay — không có SurfaceTexture readback, không có I420 conversion, CPU không chạm vào pixel.
Capture Resolution
ScreenMirrorCaptureSize.compute() tính toán kích thước capture thực tế từ kích thước màn hình vật lý, mục tiêu short-side theo chế độ chất lượng (720/1080), và maxWidth/maxHeight cùng width/height alignment của encoder (được truy vấn một lần qua MediaCodecVideoEncoder.queryEncoderCaps()), để encoder không bao giờ nhận kích thước không hỗ trợ.
Keyframe Requests
Web client có thể yêu cầu một IDR frame qua GraphQL mutation requestScreenMirrorKeyFrame để khôi phục sau mất gói. Android phản hồi qua 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 và Keyframe Broadcast
Sau khi encoder khởi động, INFO_OUTPUT_FORMAT_CHANGED cung cấp csd-0/csd-1 (SPS/PPS), ScreenMirrorPipeline ghép chúng thành một Annex-B config blob và lưu cache (cachedConfig). IDR đầu tiên tiếp theo cũng được cache (cachedKeyFrame) để web client mới kết nối có thể pull cả hai qua GraphQL query screenMirrorVideoCodec mà không cần đợi keyframe interval tiếp theo. Khi config vừa thay đổi (xoay màn hình hoặc chuyển chất lượng), Android không gửi IDR mới như một video packet thông thường — nó gộp SPS/PPS + IDR thành một sự kiện WebSocket screen_mirror_video_codec, giúp web client hoàn thành việc cấu hình lại decoder và giải mã frame đầu tiên trong một lần duy nhất, thay vì phải chạy đua giữa decoder cũ và bitstream mới.
Một số OEM encoder (Qualcomm/Xiaomi) gộp SPS+PPS+IDR vào một output buffer duy nhất mang cả BUFFER_FLAG_CODEC_CONFIG và BUFFER_FLAG_SYNC_FRAME. Vòng lặp drain chỉ bỏ qua các buffer thuần túy config (isConfig && !isKey) — bỏ qua một buffer có flag config nhưng cũng chứa sync frame sẽ âm thầm đánh rơi IDR và khiến decoder chỉ có P-frames, tạo ra đầu ra bị vỡ hình (mosaic).
Thiết kế giao thức VideoPacket
Cả video và audio frame đều được đóng gói trong giao thức nhị phân thống nhất VideoPacket để vận chuyển qua WebSocket.
Định dạng giao thức
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| 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 -/
| Trường | Kích thước | Mô tả |
|---|---|---|
MAGIC | 1 byte | Cố định 0x56 ('V'), dùng để xác thực |
FLAGS | 1 byte | 0x01=keyframe, 0x02=config, 0x04=audio |
FRAME_ID | 4 bytes | Số frame tăng dần, uint32 big-endian |
TIMESTAMP | 8 bytes | PTS của encoder tính bằng micro giây, big-endian |
DATA | biến đổi | H.264 NAL unit hoặc dữ liệu Opus |
Cả VideoPacket.encode() trên Android (trong commonMain, do đó định dạng truyền được kiểm tra bởi unit test JVM mà không phụ thuộc Android) và parseVideoPacket() trên web đều triển khai định dạng này một cách độc lập — không có thư viện serialization dùng chung, chỉ có một spec mà cả hai bên tuân thủ.
Ghi chú thiết kế
- Phân tích
FRAME_IDkhông dấu:((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0— phải dùng>>> 0để đảm bảo không dấu, nếu khôngframeId > 2^31sẽ bị phân tích thành số âm, gây ra phát hiện mất gói sai. FRAME_IDkhông bao giờ reset: Khi encoder được xây dựng lại do xoay màn hình,frameIdtiếp tục tăng (nó tồn tại trongScreenMirrorPipeline, không phải trong encoder). Điều này cho phép web phát hiện mất frame trong quá trình xoay thông qua các khoảng trống frameId.TIMESTAMPdùng PTS của encoder: Không phụ thuộc vào đồng hồ của web client, tránh sai lệch đồng hồ gây mất đồng bộ A/V.- Zero-copy parsing: trình phân tích web cắt payload bằng
Uint8Array.subarray()— một view vàoArrayBuffergốc của WebSocket, không phải bản sao.
Pipeline giải mã video (Web)
WebCodecs VideoDecoder
Web client sử dụng VideoDecoder của WebCodecs API để giải mã bằng phần cứng. So với MediaSource Extensions hay WebRTC, WebCodecs cung cấp khả năng kiểm soát chi tiết quá trình giải mã — không có jitter buffer, không có container layer, và các đối tượng VideoFrame đã giải mã có thể được upload trực tiếp dưới dạng WebGL textures.
const decoder = new VideoDecoder({
output: (frame) => this.renderFrame(frame),
error: (e) => {
this.waitingForIdr = true
this.onRequestKeyFrame?.()
this.onError?.(e)
},
})
decoder.configure({
codec, // ví dụ 'avc1.42c01e', đọc từ SPS NAL
avc: { format: 'annexb' },
optimizeForLatency: true,
hardwareAcceleration: 'prefer-hardware',
})
Các cấu hình chính:
optimizeForLatency: true— yêu cầu decoder ưu tiên độ trễ thấp, không đệm framehardwareAcceleration: 'prefer-hardware'— ưu tiên giải mã bằng GPUavc: { format: 'annexb' }— sử dụng định dạng Annex-B với SPS/PPS nội tuyến trước mỗi IDR- chuỗi codec không được hardcode —
extractAvc1CodecString()đọc các byte profile/compat/level trực tiếp từ NAL SPS đầu tiên trong config blob
Vấn đề màn hình xanh (Green Screen) và trình tự khởi động
Encoder tạo ra IDR frame đầu tiên trước khi VirtualDisplay render nội dung màn hình thực — đó là một frame trống (màu xanh). Nếu web giải mã frame này, người dùng sẽ thấy một lóe xanh cho đến khi nội dung màn hình thay đổi và kích hoạt frame mới.
Giải pháp: Khi khởi động, web pull config đã cache qua GraphQL query screenMirrorVideoCodec nhưng không giải mã keyframe đi kèm. Thay vào đó, nó gọi video.requestIdr() để đặt waitingForIdr = true (bỏ qua tất cả P-frames cho đến khi IDR đến), sau đó gọi requestKeyFrame() để yêu cầu một IDR mới qua mutation tương tự dùng để khôi phục mất gói. Khi IDR mới đến, VirtualDisplay đã có nội dung màn hình thực.
video.requestIdr() // bỏ qua P-frames, chờ IDR
await requestKeyFrame() // yêu cầu IDR mới qua GraphQL mutation
Callback onFirstFrameRendered được gắn với renderFrame() thay vì handleVideo(), đảm bảo UI chỉ cập nhật sau khi một frame thực được render — chứ không chỉ đơn thuần là nhận được.
WebGL2 Rendering
Zero-Copy GPU Direct Render
Các đối tượng VideoFrame đã giải mã được upload trực tiếp dưới dạng WebGL2 textures, không bao giờ đi qua CPU:
VideoDecoder → VideoFrame → gl.texImage2D(VideoFrame) → Canvas
gl.texImage2D chấp nhận VideoFrame như một nguồn pixel. Trình duyệt xử lý chuyển đổi YUV→RGB và upload GPU nội bộ — không có ImageData CPU copy. MirrorGLRenderer sẽ fallback về Canvas 2D drawImage() nếu getContext('webgl2', ...) thất bại, để các trình duyệt cũ hơn vẫn có hình ảnh (dù độ trễ cao hơn một chút).
desynchronized Context
const gl = canvas.getContext('webgl2', {
alpha: false,
desynchronized: true, // bỏ qua compositor, ghi trực tiếp ra màn hình
preserveDrawingBuffer: true, // giữ buffer để chụp màn hình
powerPreference: 'high-performance',
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
})
desynchronized: true bỏ qua compositor của trình duyệt, ghi trực tiếp ra màn hình, tiết kiệm ~1 frame độ trễ hiển thị (~16ms @ 60fps).
preserveDrawingBuffer: true giữ nguyên drawing buffer để canvas.toDataURL() có thể đọc nội dung chụp màn hình. Với giá trị mặc định false, buffer bị xóa sau khi compositing, tạo ra ảnh chụp đen.
Bản thân shader được thiết kế tối giản có chủ đích — một vertex shader fullscreen-triangle và một fragment shader một dòng lấy mẫu texture — vì công việc duy nhất cần làm mỗi frame là "đặt texture này lên màn hình."
Canvas Auto-Fit
Kích thước backing store của canvas được đặt từ VideoFrame.displayWidth/Height mỗi khi thay đổi. Kích thước CSS sau đó được fit vào wrapper container bằng fitCanvasToWrapper() trong khi bảo toàn tỷ lệ khung hình (letterboxing hoặc pillarboxing tùy nhu cầu). Một ResizeObserver trên phần tử cha của canvas chạy lại việc fit này mỗi khi container thay đổi kích thước, để video không bao giờ bị kéo giãn.
Phát hiện mất gói & Khôi phục lỗi
Phát hiện khoảng trống FrameId
Mỗi video frame mang một frameId tăng dần. Decoder theo dõi lastFrameId; nếu frameId của frame mới > lastFrameId + 1, các frame đã bị mất:
if (!this.waitingForIdr && this.lastFrameId > 0
&& packet.frameId > this.lastFrameId + 1) {
if (!packet.isKeyFrame) {
// Mất gói: bỏ qua các P-frame tiếp theo, yêu cầu IDR mới
this.waitingForIdr = true
this.onRequestKeyFrame?.()
this.lastFrameId = packet.frameId
return
}
}
Trạng thái máy trạng thái waitingForIdr
waitingForIdr là một máy trạng thái hai trạng thái đơn giản:
| Trạng thái | Hành vi |
|---|---|
NORMAL | Giải mã tất cả frame bình thường |
WAITING_FOR_IDR | Bỏ qua tất cả P-frame, chỉ giải mã IDR frame; đặt lại về NORMAL khi IDR đến |
Các kịch bản kích hoạt chuyển đổi sang WAITING_FOR_IDR:
- Khi khởi động: bỏ qua GraphQL keyframe cũ, chờ IDR thực
- Khi mất gói: bỏ qua các P-frame không thể giải mã, chờ IDR khôi phục
- Khi decoder lỗi: reset decoder, chờ IDR
- Khi thay đổi config: bỏ qua các P-frame còn sót lại sau khi thay đổi hướng màn hình/chất lượng
Khôi phục lỗi Decoder
Khi VideoDecoder.onerror được kích hoạt, decoderNeedsReset = true được đặt trong tầng pipeline (screen-mirror-pipeline.ts). Khi IDR frame tiếp theo đến, decoder được cấu hình lại với SPS/PPS đã cache thay vì phải truy vấn GraphQL lại:
if (decoderNeedsReset) {
if (!packet.isKeyFrame || !cachedConfig) return
video.configure(cachedConfig)
decoderNeedsReset = false
}
Backpressure và khử trùng lặp Timestamp
Nếu decoder.decodeQueueSize > 5, các P-frame đến bị bỏ qua thay vì xếp hàng đợi — ngưỡng 5 (thay vì 2) chịu được độ trễ khởi động của hardware decoder mà không gây giật. Riêng biệt, sau khi render, lastRenderedPts được ghi lại; một frame có timestamp cũ hơn (đến không đúng thứ tự) sẽ bị bỏ qua trừ khi nó là keyframe:
if (packet.timestamp < this.lastRenderedPts && !packet.isKeyFrame) {
return
}
Xử lý thay đổi hướng màn hình
Xây dựng lại Encoder
Một OrientationEventListener trong ScreenMirrorService so sánh rotation của màn hình với cờ isPortrait đã cache trên mỗi callback cảm biến; chỉ khi có sự thay đổi thực sự giữa portrait/landscape mới gọi pipeline.onOrientationChanged() và vô hiệu hóa cache kích thước màn hình của accessibility service dùng để tính tỷ lệ tọa độ touch.
rebuildEncoderAndResize():
- Tạo encoder mới với kích thước mới (ví dụ landscape 1920x1080)
- Chuyển
VirtualDisplay.surfacesangSurfaceđầu vào của encoder mới - Dừng encoder cũ
VirtualDisplay.resize()đến kích thước mới
Việc chuyển Surface diễn ra trước resize — đảm bảo encoder mới nhận frame trước, và encoder cũ được dừng trước khi có thể nhận các frame sai kích thước. Nếu virtualDisplay?.surface = ... gây lỗi, việc xây dựng lại bị hủy bỏ và giữ encoder cũ chạy thay vì để pipeline không có encoder nào.
Thông báo thay đổi Config
Khi encoder mới xuất ra SPS/PPS lần đầu, pendingConfigBroadcast được đặt trên pipeline. Khi IDR đầu tiên từ encoder mới đến, nó được đóng gói cùng config đó thành một sự kiện screen_mirror_video_codec duy nhất thay vì được gửi như một video packet thông thường.
Sau đó, web client trong handleConfig():
- Cấu hình lại decoder với SPS/PPS mới
- Giải mã IDR frame đi kèm ngay lập tức
- Gọi
video.requestIdr()để bỏ qua mọi P-frame còn sót lại từ encoder cũ đang trên đường đến - Gọi
requestKeyFrame()để yêu cầu một IDR mới, sạch
Các bước 3-4 là một safety net — ngay cả khi IDR đầu tiên của encoder mới có kích thước không chính xác (trong cửa sổ resize bất đồng bộ), web client vẫn nhanh chóng khôi phục về kích thước đúng. handleConfig() cũng short-circuit nếu config đến giống hệt byte với config đã cache, vì cấu hình lại decoder với các byte không thay đổi là một no-op nhưng vẫn tốn một IDR để khôi phục.
Vòng đời MediaProjection hệ thống
Vấn đề
Người dùng có thể đóng tính năng screen cast cấp hệ thống (MediaProjection) qua thanh thông báo Android, thay vì qua UI của ứng dụng. Trong trường hợp này, ScreenMirrorService không biết casting đã dừng — running vẫn là true, web client truy vấn screenMirrorState và nhận được true, nhưng không có video frame nào đến, và trang bị kẹt ở trạng thái loading.
MediaProjection.Callback
MediaProjection cung cấp callback Callback.onStop() được kích hoạt khi hệ thống dừng casting. ScreenMirrorPipeline.startEncoders() đăng ký callback này và gọi ScreenMirrorService.instance?.stop() trong onStop():
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
ScreenMirrorService.instance?.stop()
}
}, null)
Trách nhiệm của Service.stop()
stop() là điểm dừng rõ ràng, chịu trách nhiệm thông báo cho web client và dừng service:
fun stop() {
if (!running) return // ngăn đệ quy
running = false
sendEvent(WebSocketEvent(EventType.SCREEN_MIRRORING, """{"running":false}"""))
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
Guard if (!running) return ngăn đệ quy: onStop() → stop() → stopSelf() → onDestroy() → pipeline.stop() → projection.stop() → onStop() → stop() (lúc này running=false, trả về ngay lập tức).
Xử lý phía Web
Khi web client nhận được sự kiện {"running":false}, nó reset về trạng thái idle và hiển thị nút bắt đầu:
const onScreenMirroring = (data: any) => {
if (data?.running === false) {
cleanupFn()
fullReset()
return
}
// running=true → kết nối stream
}
Điều khiển từ xa: Touch Injection
Screen mirroring là một chiều theo mặc định (chỉ video/audio); điều khiển từ xa là tùy chọn và yêu cầu người dùng bật Accessibility Service của PlainApp một lần, vì Android không có API công khai để inject các sự kiện touch tùy ý bên ngoài AccessibilityService.dispatchGesture().
Chuẩn hóa tọa độ (Web)
Một overlay trong suốt nằm phía trên <canvas> và bắt các sự kiện con trỏ. normalizeCoords() chuyển đổi clientX/clientY thô thành tọa độ [0,1] tương đối so với khu vực nội dung video thực tế — không phải bounding box của overlay — bằng cách tính toán offset letterbox/pillarbox từ tỷ lệ khung hình của backing-store canvas so với tỷ lệ khung hình của container đã render:
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
}
Một cú nhấn chuột bắt đầu một GestureState theo dõi vị trí/thời gian bắt đầu; giữ 500ms với di chuyển < 10px được nâng cấp thành LONG_PRESS, di chuyển vượt quá ngưỡng đó trở thành SWIPE, và thả nhanh là TAP. Một chỉ báo touch trực quan (một chấm tròn phát triển và mờ dần) cung cấp phản hồi cho người điều khiển về cử chỉ nào đã được nhận dạng, trước cả khi điện thoại phản hồi.
GraphQL → AccessibilityService
Mọi cử chỉ được nhận dạng đều được gửi dưới dạng một mutation sendScreenMirrorControl(input) mang action (TAP/LONG_PRESS/SWIPE/SCROLL/BACK/HOME/RECENTS/LOCK_SCREEN/KEY) cùng tọa độ đã chuẩn hóa. Resolver gọi dispatchScreenMirrorControl(), nhân tọa độ đã chuẩn hóa với kích thước màn hình thực (từ PlainAccessibilityService.getScreenSize(), bị vô hiệu hóa mỗi khi xoay màn hình) và ủy quyền cho 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 và LONG_PRESS xây dựng GestureDescription tương tự với thời gian stroke dài hơn hoặc đường path thay vì một điểm đơn; SCROLL được triển khai như một swipe tổng hợp từ (x, y) đến (x, y + deltaY) được kẹp trong khoảng ±500px. Bốn hành động toàn cục (BACK/HOME/RECENTS/LOCK_SCREEN) bỏ qua hoàn toàn gesture dispatch và gọi trực tiếp performGlobalAction(). Nếu Accessibility Service không được bật, resolver ném ra một GraphQLError thay vì âm thầm bỏ qua đầu vào, để web UI có thể nhắc người dùng bật nó lên.
Pipeline âm thanh
Mã hóa Opus trên Android
MediaCodecAudioEncoder sử dụng AudioPlaybackCaptureConfiguration (được xây dựng từ cùng MediaProjection) để capture âm thanh hệ thống qua AudioRecord, feed PCM thô vào encoder Opus của MediaCodec. Điều này yêu cầu Android 10+ và quyền RECORD_AUDIO — trên các thiết bị cũ hơn hoặc không có quyền, start() ghi log cảnh báo và bỏ qua âm thanh (video vẫn hoạt động). Các gói Opus đã mã hóa được đóng gói trong cùng giao thức VideoPacket (với cờ FLAG_AUDIO) và chia sẻ kênh WebSocket SCREEN_MIRROR_AUDIO với video packet.
Giải mã Opus trên Web
ScreenMirrorAudioPipeline sử dụng WebCodecs AudioDecoder để giải mã dữ liệu Opus, xuất ra AudioData được định tuyến đến một phần tử <audio>. timestamp của audio frame được dùng để đồng bộ A/V — chia sẻ cùng cơ sở thời gian (encoder PTS, tính bằng micro giây) với video frame, do đó không cần đàm phán đồng hồ riêng giữa hai luồng.
Tối ưu hiệu năng
Zero-Copy Paths
| Đường dẫn | Phương pháp |
|---|---|
| VirtualDisplay → encoder Surface | GPU direct, Surface passthrough |
| VideoDecoder → VideoFrame → WebGL texture | gl.texImage2D(VideoFrame), GPU direct |
| WebSocket receive → VideoPacket parse | Uint8Array.subarray() là một view, không copy |
Tối ưu avccToAnnexB
Một số encoder Android xuất ra định dạng AVCC (tiền tố 4 byte độ dài), cần được chuyển đổi sang định dạng Annex-B (start code 00 00 00 01) để WebCodecs giải mã.
Triển khai ban đầu sử dụng ArrayList<Byte> với boxing từng byte — một IDR frame 50KB tạo ra 50.000 thao tác boxing java.lang.Byte, gây áp lực GC khổng lồ. Tối ưu sử dụng quét hai lượt + copyInto (ánh xạ đến intrinsic System.arraycopy trên JVM):
// Lượt 1: tính kích thước đầu ra
var outSize = 0
// Lượt 2: copy hàng loạt
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)
Chiến lược bỏ qua P-Frame
Decoder có thể chậm trong quá trình khởi tạo. Nếu hàng đợi P-frame quá dài, độ trễ tích tụ. Ngưỡng kích thước hàng đợi decode được đặt ở > 5 (thay vì > 2) để tránh mất frame quá mức trong quá trình khởi tạo hardware decoder.
Khử trùng lặp IDR Request
Guard waitingForIdr đảm bảo chỉ một yêu cầu IDR cho mỗi sự kiện mất gói, ngăn chặn các yêu cầu trùng lặp trong khi chờ IDR đến.
Tổng kết các Design Patterns
| Pattern | Vị trí | Lý do |
|---|---|---|
| State Machine | Cờ waitingForIdr | Chuyển đổi trạng thái drop/khôi phục P-frame rõ ràng |
| Recursion Guard | if (!running) return trong stop() | Ngăn đệ quy onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop |
| Zero-Copy Pipeline | VideoFrame → gl.texImage2D | Upload texture GPU trực tiếp, không copy CPU |
| Two-Pass Scan | avccToAnnexB | Tính trước kích thước, cấp phát một lần + copy hàng loạt, loại bỏ boxing |
| Bundled Event | SPS/PPS + IDR trong một event | Thay đổi config hoàn thành reconfiguration + giải mã frame đầu tiên trong một event |
| Callback Separation | onFirstFrameRendered vs onDisconnected vs onScreenMirrorOff | Phân biệt rõ ràng giữa render frame đầu tiên, lỗi truyền tải, và dừng phía điện thoại |
| Safety Net | requestIdr() + requestKeyFrame() | Bỏ qua frame còn sót + yêu cầu IDR sạch sau thay đổi config |
| PTS Deduplication | timestamp < lastRenderedPts | Bỏ qua các frame đến không đúng thứ tự |
| FrameId Gap | frameId > lastFrameId + 1 | Phát hiện mất gói không cần ACK |
| desynchronized Context | WebGL2 desynchronized: true | Bỏ qua compositor, tiết kiệm 1 frame độ trễ |
| Explicit Fail Fast | sendScreenMirrorControl ném GraphQLError | Hiển thị lỗi "accessibility bị tắt" thay vì âm thầm bỏ qua đầu vào |
Đọc thêm
- WebCodecs API — Tài liệu MDN về các interface
VideoDecoder/AudioDecoder.