diff --git a/src/renderer/src/components/emulator-pane/use-emulator-video-stream.test.tsx b/src/renderer/src/components/emulator-pane/use-emulator-video-stream.test.tsx
new file mode 100644
index 00000000000..fadaf1abf07
--- /dev/null
+++ b/src/renderer/src/components/emulator-pane/use-emulator-video-stream.test.tsx
@@ -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
+}
+
+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())
+ 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 }
+ ])
+ })
+})
diff --git a/src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts b/src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts
index b0fdf11c961..5ba4fe622d6 100644
--- a/src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts
+++ b/src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts
@@ -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)
}
})