fix(emulator): follow decoded Android video dimensions (#35635)

* fix(emulator): follow decoded Android video dimensions

* test(emulator): trim decoded video size test to the rotation case

* chore(emulator): note video stream effect's timer cleanup for react-doctor

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-29 15:13:43 -07:00
committed by GitHub
co-authored by OrcaWin
parent c31fdaa6a5
commit 9dfb110420
2 changed files with 109 additions and 1 deletions
@@ -0,0 +1,98 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useEmulatorVideoStream } from './use-emulator-video-stream'
import type { StreamSize } from './emulator-device-frame-layout'
type Frame = { displayWidth: number; displayHeight: number; close: () => void }
type Meta = {
streamId: string
deviceId: string
meta: { codecId: string; width: number; height: number }
}
let output: (frame: Frame) => void
let metaListener: (meta: Meta) => void
let streamId: string
let reports: StreamSize[]
let root: Root
let contextDescriptor: PropertyDescriptor | undefined
function Screen() {
const { canvasRef } = useEmulatorVideoStream('device', 'stream', true, (size) =>
reports.push(size)
)
return <canvas ref={canvasRef} />
}
beforeEach(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true })
reports = []
vi.stubGlobal(
'VideoDecoder',
class {
state = 'unconfigured'
constructor(init: { output: (frame: Frame) => void }) {
output = init.output
}
close(): void {}
}
)
vi.stubGlobal('EncodedVideoChunk', class {})
contextDescriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, 'getContext')
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
configurable: true,
value: () => ({ clearRect: () => {}, drawImage: () => {} })
})
Object.defineProperty(window, 'api', {
configurable: true,
value: {
emulator: {
startVideoStream: async (args: { streamId: string }) => {
streamId = args.streamId
return args
},
stopVideoStream: async () => {},
onVideoStreamMeta: (cb: (meta: Meta) => void) => {
metaListener = cb
return () => {}
}
}
}
})
root = createRoot(document.createElement('div'))
})
afterEach(() => {
act(() => root.unmount())
if (contextDescriptor) {
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', contextDescriptor)
} else {
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'getContext')
}
Reflect.deleteProperty(window, 'api')
vi.unstubAllGlobals()
})
describe('useEmulatorVideoStream decoded size', () => {
it('reports decoded frame size changes once after codec metadata', async () => {
await act(async () => root.render(<Screen />))
const frame = (): void => output({ displayWidth: 480, displayHeight: 240, close: () => {} })
act(() => {
metaListener({
streamId,
deviceId: 'device',
meta: { codecId: 'h264', width: 240, height: 480 }
})
frame()
frame()
frame()
})
expect(reports).toEqual([
{ width: 240, height: 480 },
{ width: 480, height: 240 }
])
})
})
@@ -43,6 +43,7 @@ export function useEmulatorVideoStream(
const onSizeRef = useRef(onSize)
onSizeRef.current = onSize
// oxlint-disable-next-line react-doctor/effect-needs-cleanup -- cleanup() clears the first-frame timeout.
useEffect(() => {
const api = (window as { api?: { emulator?: EmulatorVideoApi } }).api?.emulator
if (!enabled || !deviceId) {
@@ -65,6 +66,14 @@ export function useEmulatorVideoStream(
let configured = false
let timestamp = 0
let configBytes: Uint8Array | null = null
let reportedSize: StreamSize | null = null
const reportSize = (width: number, height: number): void => {
if (reportedSize?.width === width && reportedSize.height === height) {
return
}
reportedSize = { width, height }
onSizeRef.current?.(reportedSize)
}
const currentStreamId = newVideoStreamId()
let streamId: string | null = currentStreamId
let unsubMeta: (() => void) | undefined
@@ -84,6 +93,7 @@ export function useEmulatorVideoStream(
canvas.width = frame.displayWidth
canvas.height = frame.displayHeight
}
reportSize(frame.displayWidth, frame.displayHeight)
ctx.drawImage(frame, 0, 0)
}
frame.close()
@@ -135,7 +145,7 @@ export function useEmulatorVideoStream(
unsubMeta = api.onVideoStreamMeta?.((msg) => {
if (!disposed && msg.streamId === streamId && msg.deviceId === deviceId) {
onSizeRef.current?.({ width: msg.meta.width, height: msg.meta.height })
reportSize(msg.meta.width, msg.meta.height)
}
})