Quay lại blog
Architecture15 min read

Screen Mirror: Kiến trúc Casting độ trễ thấp

Bài viết này trình bày thiết kế end-to-end của hệ thống screen mirror trên PlainApp: cách Android capture và mã hóa phần cứng H.264/Opus qua MediaCodec, cách các frame di chuyển qua WebSocket bằng giao thức nhị phân tùy chỉnh, cách phía web giải mã bằng WebCodecs và render qua WebGL2 với zero CPU copies, cách xử lý loss detection, xoay màn hình và remote touch control, cũng như cách đồng bộ vòng đời MediaProjection.

Mục lục

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

Diagram 1
1

TầngAndroidWeb
Screen captureMediaProjection + VirtualDisplay—
Video encodingMediaCodec H.264 hardware encoder—
Audio encodingMediaCodec Opus hardware encoder—
TransportWebSocket binary eventsWebSocket receiver
Video decoding—WebCodecs VideoDecoder
Audio decoding—WebCodecs AudioDecoder → <audio>
Rendering—WebGL2 texture direct-render
ControlAccessibilityService gesture injectionTouch 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_RATE6060fps cho độ mượt
KEY_I_FRAME_INTERVAL10IDR interval 10s, giảm overhead keyframe
KEY_BIT_RATE_MODEVBR (implicit, không set mode rõ ràng)Variable bitrate, thích ứng theo cảnh
KEY_PRIORITY0Ưu tiên thời gian thực
KEY_LATENCY1Chế độ độ 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ế độBitrateCapture resolution
HD8 Mbps1080p short side
Smooth4 Mbps1080p short side
Low2 Mbps720p 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ườngKích thướcMô tả
MAGIC1 byteCố định 0x56 ('V'), dùng để xác thực
FLAGS1 byte0x01=keyframe, 0x02=config, 0x04=audio
FRAME_ID4 bytesSố frame tăng dần, uint32 big-endian
TIMESTAMP8 bytesPTS của encoder tính bằng micro giây, big-endian
DATAbiến đổiH.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ủ.

Diagram 2
2

Ghi chú thiết kế

  • Phân tích FRAME_ID khô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ông frameId > 2^31 sẽ bị phân tích thành số âm, gây ra phát hiện mất gói sai.
  • FRAME_ID không bao giờ reset: Khi encoder được xây dựng lại do xoay màn hình, frameId tiếp tục tăng (nó tồn tại trong ScreenMirrorPipeline, 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.
  • TIMESTAMP dù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ào ArrayBuffer gố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 frame
  • hardwareAcceleration: 'prefer-hardware' — ưu tiên giải mã bằng GPU
  • avc: { 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:

Diagram 3
3

Trạng tháiHành vi
NORMALGiải mã tất cả frame bình thường
WAITING_FOR_IDRBỏ 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:

  1. Khi khởi động: bỏ qua GraphQL keyframe cũ, chờ IDR thực
  2. Khi mất gói: bỏ qua các P-frame không thể giải mã, chờ IDR khôi phục
  3. Khi decoder lỗi: reset decoder, chờ IDR
  4. 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.

Diagram 4
4

rebuildEncoderAndResize():

  1. Tạo encoder mới với kích thước mới (ví dụ landscape 1920x1080)
  2. Chuyển VirtualDisplay.surface sang Surface đầu vào của encoder mới
  3. Dừng encoder cũ
  4. 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():

  1. Cấu hình lại decoder với SPS/PPS mới
  2. Giải mã IDR frame đi kèm ngay lập tức
  3. Gọi video.requestIdr() để bỏ qua mọi P-frame còn sót lại từ encoder cũ đang trên đường đến
  4. 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)

Diagram 5
5

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().

Diagram 6
6

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ẫnPhương pháp
VirtualDisplay → encoder SurfaceGPU direct, Surface passthrough
VideoDecoder → VideoFrame → WebGL texturegl.texImage2D(VideoFrame), GPU direct
WebSocket receive → VideoPacket parseUint8Array.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

PatternVị tríLý do
State MachineCờ waitingForIdrChuyển đổi trạng thái drop/khôi phục P-frame rõ ràng
Recursion Guardif (!running) return trong stop()Ngăn đệ quy onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop
Zero-Copy PipelineVideoFrame → gl.texImage2DUpload texture GPU trực tiếp, không copy CPU
Two-Pass ScanavccToAnnexBTí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 EventSPS/PPS + IDR trong một eventThay đổi config hoàn thành reconfiguration + giải mã frame đầu tiên trong một event
Callback SeparationonFirstFrameRendered vs onDisconnected vs onScreenMirrorOffPhâ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 NetrequestIdr() + requestKeyFrame()Bỏ qua frame còn sót + yêu cầu IDR sạch sau thay đổi config
PTS Deduplicationtimestamp < lastRenderedPtsBỏ qua các frame đến không đúng thứ tự
FrameId GapframeId > lastFrameId + 1Phát hiện mất gói không cần ACK
desynchronized ContextWebGL2 desynchronized: trueBỏ qua compositor, tiết kiệm 1 frame độ trễ
Explicit Fail FastsendScreenMirrorControl ném GraphQLErrorHiể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.