Fix mobile emulator device rotation (#6326)
This commit is contained in:
parent
eb8435950a
commit
2e5057f341
|
|
@ -29,7 +29,8 @@ export default function EmulatorPane({ tab, worktreeId, isActive = true }: Emula
|
|||
previewUrl,
|
||||
wsUrl,
|
||||
streamKey,
|
||||
isLive
|
||||
isLive,
|
||||
visualOrientation
|
||||
} = useEmulatorPaneSession({
|
||||
worktreeId,
|
||||
tabId: tab?.id,
|
||||
|
|
@ -80,6 +81,7 @@ export default function EmulatorPane({ tab, worktreeId, isActive = true }: Emula
|
|||
deviceName={displayName}
|
||||
loading={loading}
|
||||
isLive={isLive}
|
||||
visualOrientation={visualOrientation}
|
||||
isActive={isActive}
|
||||
onTap={(x, y) => void sendTap(x, y)}
|
||||
onGesture={(points) => void sendGesture(points)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { fitDeviceFrameToPane, resolveDeviceFrameKind } from './emulator-device-frame-layout'
|
||||
import {
|
||||
fitDeviceFrameToPane,
|
||||
resolveDeviceFrameKind,
|
||||
resolveVisualScreenAspectRatio,
|
||||
resolveVisualStreamGeometry
|
||||
} from './emulator-device-frame-layout'
|
||||
|
||||
describe('resolveDeviceFrameKind', () => {
|
||||
it('prefers device names over aspect-ratio fallback', () => {
|
||||
|
|
@ -13,6 +18,42 @@ describe('resolveDeviceFrameKind', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('resolveVisualScreenAspectRatio', () => {
|
||||
it('uses the requested orientation even when the stream canvas has not swapped yet', () => {
|
||||
const portraitStream = { width: 390, height: 844 }
|
||||
|
||||
expect(resolveVisualScreenAspectRatio(portraitStream, 'portrait')).toBeCloseTo(390 / 844)
|
||||
expect(resolveVisualScreenAspectRatio(portraitStream, 'landscape')).toBeCloseTo(844 / 390)
|
||||
})
|
||||
|
||||
it('uses the requested orientation only before a stream frame arrives', () => {
|
||||
expect(resolveVisualScreenAspectRatio(null, 'portrait')).toBeCloseTo(9 / 19)
|
||||
expect(resolveVisualScreenAspectRatio(null, 'landscape')).toBeCloseTo(19 / 9)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveVisualStreamGeometry', () => {
|
||||
it('uses visual orientation for the interactive screen rectangle', () => {
|
||||
const geometry = resolveVisualStreamGeometry({ width: 390, height: 844 }, 'landscape')
|
||||
|
||||
expect(geometry.size).toEqual({
|
||||
width: 844,
|
||||
height: 390
|
||||
})
|
||||
expect(geometry.streamRotation).toBe(90)
|
||||
})
|
||||
|
||||
it('rotates a stale landscape stream back into portrait geometry', () => {
|
||||
const geometry = resolveVisualStreamGeometry({ width: 844, height: 390 }, 'portrait')
|
||||
|
||||
expect(geometry.size).toEqual({
|
||||
width: 390,
|
||||
height: 844
|
||||
})
|
||||
expect(geometry.streamRotation).toBe(-90)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fitDeviceFrameToPane', () => {
|
||||
it('fits a phone shell and hardware controls inside the pane', () => {
|
||||
const pane = { width: 600, height: 1000 }
|
||||
|
|
@ -50,4 +91,15 @@ describe('fitDeviceFrameToPane', () => {
|
|||
expect(layout?.shellWidth).toBeGreaterThan(1)
|
||||
expect(layout?.shellHeight).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('fits the entire phone frame as landscape after a device rotation', () => {
|
||||
const pane = { width: 1000, height: 600 }
|
||||
const aspectRatio = resolveVisualScreenAspectRatio({ width: 844, height: 390 }, 'landscape')
|
||||
const layout = fitDeviceFrameToPane(pane, aspectRatio, 'phone')
|
||||
|
||||
expect(layout).not.toBeNull()
|
||||
expect(layout?.width).toBeLessThanOrEqual(pane.width)
|
||||
expect(layout?.height).toBeLessThanOrEqual(pane.height)
|
||||
expect(layout ? layout.shellWidth / layout.shellHeight : 0).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ export type StreamSize = {
|
|||
height: number
|
||||
}
|
||||
|
||||
export type EmulatorDeviceVisualOrientation = 'portrait' | 'landscape'
|
||||
|
||||
export type PaneSize = {
|
||||
width: number
|
||||
height: number
|
||||
|
|
@ -23,6 +25,12 @@ export type DeviceFrameLayout = {
|
|||
sideButtonThickness: number
|
||||
}
|
||||
|
||||
export type VisualStreamGeometry = {
|
||||
aspectRatio: number
|
||||
size: StreamSize | null
|
||||
streamRotation: -90 | 0 | 90
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(max, Math.max(min, value))
|
||||
const FIT_MARGIN_PX = 0.5
|
||||
|
|
@ -40,6 +48,38 @@ export function resolveDeviceFrameKind(
|
|||
return screenAspectRatio > 0.62 && screenAspectRatio < 1.62 ? 'tablet' : 'phone'
|
||||
}
|
||||
|
||||
export function resolveVisualScreenAspectRatio(
|
||||
streamSize: StreamSize | null,
|
||||
visualOrientation: EmulatorDeviceVisualOrientation
|
||||
): number {
|
||||
return resolveVisualStreamGeometry(streamSize, visualOrientation).aspectRatio
|
||||
}
|
||||
|
||||
export function resolveVisualStreamGeometry(
|
||||
streamSize: StreamSize | null,
|
||||
visualOrientation: EmulatorDeviceVisualOrientation
|
||||
): VisualStreamGeometry {
|
||||
// Why: serve-sim can keep the same canvas dimensions after rotate; the pane's
|
||||
// physical frame and input rect still need to follow the successful request.
|
||||
const width = streamSize?.width ?? 9
|
||||
const height = streamSize?.height ?? 19
|
||||
const shortSide = Math.min(width, height)
|
||||
const longSide = Math.max(width, height)
|
||||
const visualSize =
|
||||
visualOrientation === 'landscape'
|
||||
? { width: longSide, height: shortSide }
|
||||
: { width: shortSide, height: longSide }
|
||||
const streamIsLandscape = streamSize ? streamSize.width > streamSize.height : false
|
||||
const visualIsLandscape = visualOrientation === 'landscape'
|
||||
const streamRotation =
|
||||
streamSize && streamIsLandscape !== visualIsLandscape ? (visualIsLandscape ? 90 : -90) : 0
|
||||
return {
|
||||
aspectRatio: visualSize.width / visualSize.height,
|
||||
size: streamSize ? visualSize : null,
|
||||
streamRotation
|
||||
}
|
||||
}
|
||||
|
||||
function fitScreenToPane(paneSize: PaneSize | null, aspectRatio: number): PaneSize | null {
|
||||
if (!paneSize || paneSize.width <= 0 || paneSize.height <= 0 || aspectRatio <= 0) {
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ async function renderFrame(isActive: boolean): Promise<void> {
|
|||
wsUrl="ws://127.0.0.1:3100/ws"
|
||||
loading={false}
|
||||
isLive={true}
|
||||
visualOrientation="portrait"
|
||||
isActive={isActive}
|
||||
onTap={vi.fn()}
|
||||
onGesture={vi.fn()}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ function renderFrame(props?: {
|
|||
wsUrl="ws://127.0.0.1:3100/ws"
|
||||
loading={false}
|
||||
isLive={true}
|
||||
visualOrientation="portrait"
|
||||
isActive={true}
|
||||
onTap={props?.onTap ?? vi.fn()}
|
||||
onGesture={props?.onGesture ?? vi.fn()}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import {
|
|||
} from 'react'
|
||||
import {
|
||||
fitDeviceFrameToPane,
|
||||
resolveVisualStreamGeometry,
|
||||
resolveDeviceFrameKind,
|
||||
type EmulatorDeviceVisualOrientation,
|
||||
type StreamSize
|
||||
} from './emulator-device-frame-layout'
|
||||
import {
|
||||
|
|
@ -25,12 +27,10 @@ import {
|
|||
type PointerSample
|
||||
} from './emulator-screen-gesture'
|
||||
import { PhoneHardwareButtons } from './emulator-phone-hardware-buttons'
|
||||
import { EmulatorScreenStreamContent } from './emulator-screen-stream-content'
|
||||
import { EmulatorScreenSurface } from './emulator-screen-surface'
|
||||
import { useEmulatorControlStream } from './use-emulator-control-stream'
|
||||
import { useEmulatorPaneSize } from './use-emulator-pane-size'
|
||||
import { useEmulatorScreenKeyboard } from './use-emulator-screen-keyboard'
|
||||
import { getEmulatorScreenAriaLabel } from './emulator-screen-aria-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type EmulatorDeviceFrameProps = {
|
||||
previewUrl?: string
|
||||
|
|
@ -39,8 +39,8 @@ type EmulatorDeviceFrameProps = {
|
|||
deviceName?: string
|
||||
loading: boolean
|
||||
isLive: boolean
|
||||
/** False when the pane is backgrounded (hidden tab/worktree). Gates the frame
|
||||
* stream so a parked emulator stops decoding, matching the pane's visibility. */
|
||||
visualOrientation: EmulatorDeviceVisualOrientation
|
||||
/** False when backgrounded; parks the stream with the pane's visibility. */
|
||||
isActive: boolean
|
||||
onTap: (x: number, y: number) => void
|
||||
onGesture: (points: EmulatorGesturePoint[]) => void
|
||||
|
|
@ -55,11 +55,10 @@ type PendingWheelGesture = {
|
|||
timerId: number | null
|
||||
}
|
||||
|
||||
type ScreenCoordinateEvent = {
|
||||
clientX: number
|
||||
clientY: number
|
||||
currentTarget: HTMLDivElement
|
||||
}
|
||||
type ScreenCoordinateEvent = Pick<
|
||||
PointerEvent<HTMLDivElement>,
|
||||
'clientX' | 'clientY' | 'currentTarget'
|
||||
>
|
||||
|
||||
export function EmulatorDeviceFrame({
|
||||
previewUrl,
|
||||
|
|
@ -68,6 +67,7 @@ export function EmulatorDeviceFrame({
|
|||
deviceName,
|
||||
loading,
|
||||
isLive,
|
||||
visualOrientation,
|
||||
isActive,
|
||||
onTap,
|
||||
onGesture
|
||||
|
|
@ -81,6 +81,10 @@ export function EmulatorDeviceFrame({
|
|||
const wheelGestureRef = useRef<PendingWheelGesture | null>(null)
|
||||
const [streamError, setStreamError] = useState(false)
|
||||
const [streamSize, setStreamSize] = useState<StreamSize | null>(null)
|
||||
const visualStreamGeometry = useMemo(
|
||||
() => resolveVisualStreamGeometry(streamSize, visualOrientation),
|
||||
[streamSize, visualOrientation]
|
||||
)
|
||||
const canInteract = isLive && !loading && !streamError
|
||||
const { cancelKeyboardFrames, sendKeyboardFrames, sendTouch } = useEmulatorControlStream(
|
||||
wsUrl,
|
||||
|
|
@ -103,9 +107,9 @@ export function EmulatorDeviceFrame({
|
|||
mapClientPointToSimulatorScreen(
|
||||
{ clientX: event.clientX, clientY: event.clientY },
|
||||
event.currentTarget.getBoundingClientRect(),
|
||||
streamSize
|
||||
visualStreamGeometry.size
|
||||
),
|
||||
[streamSize]
|
||||
[visualStreamGeometry.size]
|
||||
)
|
||||
|
||||
const sendGesturePoints = useCallback(
|
||||
|
|
@ -262,7 +266,7 @@ export function EmulatorDeviceFrame({
|
|||
const action = resolveEmulatorPointerAction(
|
||||
samples,
|
||||
event.currentTarget.getBoundingClientRect(),
|
||||
streamSize
|
||||
visualStreamGeometry.size
|
||||
)
|
||||
if (!action) {
|
||||
return
|
||||
|
|
@ -273,7 +277,7 @@ export function EmulatorDeviceFrame({
|
|||
sendGesturePoints(action.points)
|
||||
}
|
||||
},
|
||||
[canInteract, mapEventToScreenPoint, onTap, sendGesturePoints, sendTouch, streamSize]
|
||||
[canInteract, mapEventToScreenPoint, onTap, sendGesturePoints, sendTouch, visualStreamGeometry]
|
||||
)
|
||||
|
||||
const handleWheel = useCallback(
|
||||
|
|
@ -290,7 +294,7 @@ export function EmulatorDeviceFrame({
|
|||
deltaY: event.deltaY
|
||||
},
|
||||
event.currentTarget.getBoundingClientRect(),
|
||||
streamSize
|
||||
visualStreamGeometry.size
|
||||
)
|
||||
if (!delta) {
|
||||
return
|
||||
|
|
@ -317,7 +321,7 @@ export function EmulatorDeviceFrame({
|
|||
timerId: window.setTimeout(flushWheelGesture, WHEEL_GESTURE_IDLE_MS)
|
||||
}
|
||||
},
|
||||
[canInteract, flushWheelGesture, sendTouch, streamSize]
|
||||
[canInteract, flushWheelGesture, sendTouch, visualStreamGeometry]
|
||||
)
|
||||
|
||||
const handleStreamSize = useCallback((size: NonNullable<StreamSize>) => {
|
||||
|
|
@ -331,20 +335,16 @@ export function EmulatorDeviceFrame({
|
|||
setStreamError(true)
|
||||
}, [])
|
||||
|
||||
// Why: only decode frames while the pane is visible. A backgrounded but still
|
||||
// attached emulator otherwise keeps running the WebCodecs H.264 decode / MJPEG
|
||||
// blob churn against a hidden canvas, with frames still flowing over IPC (and
|
||||
// the network for paired SSH/remote clients). The session stays attached, so
|
||||
// the stream re-fires within a frame on re-show. Mirrors the browser pane's
|
||||
// park-when-hidden behavior.
|
||||
// Why: hidden panes still receive emulator frames, including over SSH, so
|
||||
// parking the stream avoids background decode/IPC churn while staying attached.
|
||||
const showStream = isActive && isLive && Boolean(previewUrl)
|
||||
const screenAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19
|
||||
const screenAspectRatioStyle = streamSize
|
||||
? `${streamSize.width} / ${streamSize.height}`
|
||||
: '9 / 19'
|
||||
const streamAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19
|
||||
// Why: serve-sim may keep portrait-sized pixels for portrait-locked apps; the
|
||||
// physical frame still follows the last successful rotate request.
|
||||
const screenAspectRatio = visualStreamGeometry.aspectRatio
|
||||
const frameKind = useMemo(
|
||||
() => resolveDeviceFrameKind(deviceName, screenAspectRatio),
|
||||
[deviceName, screenAspectRatio]
|
||||
() => resolveDeviceFrameKind(deviceName, streamAspectRatio),
|
||||
[deviceName, streamAspectRatio]
|
||||
)
|
||||
const frameLayout = useMemo(
|
||||
() => fitDeviceFrameToPane(paneSize, screenAspectRatio, frameKind),
|
||||
|
|
@ -376,43 +376,28 @@ export function EmulatorDeviceFrame({
|
|||
borderRadius: frameLayout ? `${frameLayout.outerRadius}px` : '54px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
frameLayout
|
||||
? 'absolute overflow-hidden bg-black ring-1 ring-white/10'
|
||||
: 'relative w-full overflow-hidden bg-black ring-1 ring-white/10',
|
||||
isLive && 'touch-none select-none'
|
||||
)}
|
||||
style={{
|
||||
inset: frameLayout ? `${frameLayout.bezel}px` : undefined,
|
||||
aspectRatio: frameLayout ? undefined : screenAspectRatioStyle,
|
||||
borderRadius: frameLayout ? `${frameLayout.innerRadius}px` : '44px'
|
||||
}}
|
||||
<EmulatorScreenSurface
|
||||
frameLayout={frameLayout}
|
||||
isLive={isLive}
|
||||
keyboardCaptureActive={keyboardCaptureActive}
|
||||
loading={loading}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onStreamError={handleStreamError}
|
||||
onStreamSize={handleStreamSize}
|
||||
onWheel={handleWheel}
|
||||
role={isLive ? 'application' : undefined}
|
||||
tabIndex={isLive ? 0 : undefined}
|
||||
aria-keyshortcuts={keyboardCaptureActive ? 'Escape' : undefined}
|
||||
aria-label={getEmulatorScreenAriaLabel(isLive, keyboardCaptureActive)}
|
||||
>
|
||||
{/* Why: the stream is the actual emulator screen; fake in-screen
|
||||
chrome doubles up with iOS's real status bar and makes bezels lie. */}
|
||||
<EmulatorScreenStreamContent
|
||||
loading={loading}
|
||||
onStreamError={handleStreamError}
|
||||
onStreamSize={handleStreamSize}
|
||||
previewUrl={previewUrl}
|
||||
showStream={Boolean(showStream)}
|
||||
streamError={streamError}
|
||||
streamKey={streamKey}
|
||||
/>
|
||||
</div>
|
||||
previewUrl={previewUrl}
|
||||
screenAspectRatio={screenAspectRatio}
|
||||
showStream={Boolean(showStream)}
|
||||
streamError={streamError}
|
||||
streamKey={streamKey}
|
||||
streamRotation={visualStreamGeometry.streamRotation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import type { SimulatorDeviceRow } from './emulator-pane-types'
|
||||
|
||||
// Raw shape returned by the unified `emulator.listDevices` RPC (iOS simulators + Android AVDs).
|
||||
export type RawEmulatorDevice = {
|
||||
id: string
|
||||
name: string
|
||||
state: string
|
||||
detail?: string
|
||||
isAvailable?: boolean
|
||||
}
|
||||
|
||||
export function toSimulatorDeviceRows(raw: RawEmulatorDevice[]): SimulatorDeviceRow[] {
|
||||
return raw.map((device) => ({
|
||||
name: device.name,
|
||||
udid: device.id,
|
||||
state: device.state === 'booted' ? 'Booted' : 'Shutdown',
|
||||
runtime: device.detail,
|
||||
isAvailable: device.isAvailable
|
||||
}))
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
resolveEmulatorWheelDelta,
|
||||
resolveEmulatorPointerAction
|
||||
} from './emulator-screen-gesture'
|
||||
import { resolveVisualStreamGeometry } from './emulator-device-frame-layout'
|
||||
|
||||
const rect = {
|
||||
left: 10,
|
||||
|
|
@ -114,4 +115,22 @@ describe('emulator screen gestures', () => {
|
|||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('maps the full landscape screen while the stream canvas shape settles', () => {
|
||||
const landscapeRect = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 400,
|
||||
height: 200
|
||||
}
|
||||
const stalePortraitStream = { width: 390, height: 844 }
|
||||
|
||||
expect(
|
||||
mapClientPointToSimulatorScreen(
|
||||
{ clientX: 380, clientY: 100 },
|
||||
landscapeRect,
|
||||
resolveVisualStreamGeometry(stalePortraitStream, 'landscape').size
|
||||
)
|
||||
).toEqual({ x: 0.95, y: 0.5 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -90,7 +90,10 @@ afterEach(() => {
|
|||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function renderStream(streamKey = 'abc'): Promise<void> {
|
||||
async function renderStream(
|
||||
streamKey = 'abc',
|
||||
props?: { screenAspectRatio?: number; streamRotation?: -90 | 0 | 90 }
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EmulatorScreenStreamContent
|
||||
|
|
@ -98,9 +101,11 @@ async function renderStream(streamKey = 'abc'): Promise<void> {
|
|||
onStreamError={vi.fn()}
|
||||
onStreamSize={vi.fn()}
|
||||
previewUrl="http://127.0.0.1:3100/stream.mjpeg"
|
||||
screenAspectRatio={props?.screenAspectRatio}
|
||||
showStream={true}
|
||||
streamError={false}
|
||||
streamKey={streamKey}
|
||||
streamRotation={props?.streamRotation}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
@ -122,6 +127,24 @@ describe('EmulatorScreenStreamContent', () => {
|
|||
|
||||
const img = container.querySelector('img')
|
||||
expect(img?.getAttribute('src')).toBe('blob:emulator-frame-1')
|
||||
expect(img?.className).toContain('object-contain')
|
||||
expect(img?.className).not.toContain('object-fill')
|
||||
})
|
||||
|
||||
it('rotates mismatched stream media without stretching it', async () => {
|
||||
await renderStream('abc', { screenAspectRatio: 844 / 390, streamRotation: 90 })
|
||||
|
||||
await act(async () => {
|
||||
frameListeners[0]?.({ streamId: 'stream-1', bytes: new Uint8Array([1, 2, 3]).buffer })
|
||||
})
|
||||
|
||||
const img = container.querySelector('img')
|
||||
expect(img?.className).toContain('object-contain')
|
||||
expect(img?.className).toContain('absolute')
|
||||
expect(img?.className).not.toContain('object-fill')
|
||||
expect(img?.style.transform).toBe('translate(-50%, -50%) rotate(90deg)')
|
||||
expect(img?.style.width).toBe(`${100 / (844 / 390)}%`)
|
||||
expect(img?.style.height).toBe(`${100 * (844 / 390)}%`)
|
||||
})
|
||||
|
||||
it('clears the previous frame while a new stream key is connecting', async () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, type CSSProperties } from 'react'
|
||||
import { useEmulatorFrameStream } from './use-emulator-frame-stream'
|
||||
import { useEmulatorVideoStream } from './use-emulator-video-stream'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { VisualStreamGeometry } from './emulator-device-frame-layout'
|
||||
|
||||
type StreamSize = {
|
||||
height: number
|
||||
|
|
@ -14,9 +15,11 @@ type EmulatorScreenStreamContentProps = {
|
|||
onStreamError: () => void
|
||||
onStreamSize: (size: StreamSize) => void
|
||||
previewUrl?: string
|
||||
screenAspectRatio?: number
|
||||
showStream: boolean
|
||||
streamError: boolean
|
||||
streamKey?: string
|
||||
streamRotation?: VisualStreamGeometry['streamRotation']
|
||||
}
|
||||
|
||||
// Android sessions stream H.264 over scrcpy://<serial>; iOS uses an MJPEG http URL.
|
||||
|
|
@ -27,9 +30,11 @@ export function EmulatorScreenStreamContent({
|
|||
onStreamError,
|
||||
onStreamSize,
|
||||
previewUrl,
|
||||
screenAspectRatio = 9 / 19,
|
||||
showStream,
|
||||
streamError,
|
||||
streamKey
|
||||
streamKey,
|
||||
streamRotation = 0
|
||||
}: EmulatorScreenStreamContentProps) {
|
||||
const androidDeviceId =
|
||||
previewUrl && previewUrl.startsWith(SCRCPY_PREFIX)
|
||||
|
|
@ -54,11 +59,18 @@ export function EmulatorScreenStreamContent({
|
|||
}
|
||||
}, [frameStream.error, video.error, onStreamError])
|
||||
|
||||
const mediaStyle = resolveStreamMediaStyle(streamRotation, screenAspectRatio)
|
||||
const mediaClassName =
|
||||
streamRotation === 0
|
||||
? 'block h-full w-full bg-black object-contain'
|
||||
: 'absolute left-1/2 top-1/2 block max-w-none bg-black object-contain'
|
||||
|
||||
if (androidDeviceId && showStream && !video.error) {
|
||||
return (
|
||||
<canvas
|
||||
ref={video.canvasRef}
|
||||
className="block h-full w-full bg-black object-contain"
|
||||
className={mediaClassName}
|
||||
style={mediaStyle}
|
||||
aria-label={translate(
|
||||
'auto.components.emulator.pane.emulator.screen.stream.content.5ee64cd44e',
|
||||
'Emulator screen'
|
||||
|
|
@ -76,8 +88,9 @@ export function EmulatorScreenStreamContent({
|
|||
'auto.components.emulator.pane.emulator.screen.stream.content.5ee64cd44e',
|
||||
'Emulator screen'
|
||||
)}
|
||||
className="block h-full w-full bg-black object-contain"
|
||||
className={mediaClassName}
|
||||
draggable={false}
|
||||
style={mediaStyle}
|
||||
onError={onStreamError}
|
||||
onLoad={(event) => {
|
||||
const { naturalWidth, naturalHeight } = event.currentTarget
|
||||
|
|
@ -123,3 +136,18 @@ export function EmulatorScreenStreamContent({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveStreamMediaStyle(
|
||||
streamRotation: VisualStreamGeometry['streamRotation'],
|
||||
screenAspectRatio: number
|
||||
): CSSProperties | undefined {
|
||||
if (streamRotation === 0 || screenAspectRatio <= 0) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
height: `${100 * screenAspectRatio}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${streamRotation}deg)`,
|
||||
transformOrigin: 'center',
|
||||
width: `${100 / screenAspectRatio}%`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
import type {
|
||||
ClipboardEventHandler,
|
||||
FocusEventHandler,
|
||||
KeyboardEventHandler,
|
||||
PointerEventHandler,
|
||||
WheelEventHandler
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getEmulatorScreenAriaLabel } from './emulator-screen-aria-label'
|
||||
import { EmulatorScreenStreamContent } from './emulator-screen-stream-content'
|
||||
import type {
|
||||
DeviceFrameLayout,
|
||||
StreamSize,
|
||||
VisualStreamGeometry
|
||||
} from './emulator-device-frame-layout'
|
||||
|
||||
type EmulatorScreenSurfaceProps = {
|
||||
frameLayout: DeviceFrameLayout | null
|
||||
isLive: boolean
|
||||
keyboardCaptureActive: boolean
|
||||
loading: boolean
|
||||
onBlur: FocusEventHandler<HTMLDivElement>
|
||||
onKeyDown: KeyboardEventHandler<HTMLDivElement>
|
||||
onPaste: ClipboardEventHandler<HTMLDivElement>
|
||||
onPointerCancel: PointerEventHandler<HTMLDivElement>
|
||||
onPointerDown: PointerEventHandler<HTMLDivElement>
|
||||
onPointerMove: PointerEventHandler<HTMLDivElement>
|
||||
onPointerUp: PointerEventHandler<HTMLDivElement>
|
||||
onStreamError: () => void
|
||||
onStreamSize: (size: StreamSize) => void
|
||||
onWheel: WheelEventHandler<HTMLDivElement>
|
||||
previewUrl?: string
|
||||
screenAspectRatio: number
|
||||
showStream: boolean
|
||||
streamError: boolean
|
||||
streamKey?: string
|
||||
streamRotation: VisualStreamGeometry['streamRotation']
|
||||
}
|
||||
|
||||
export function EmulatorScreenSurface({
|
||||
frameLayout,
|
||||
isLive,
|
||||
keyboardCaptureActive,
|
||||
loading,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onPointerCancel,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onStreamError,
|
||||
onStreamSize,
|
||||
onWheel,
|
||||
previewUrl,
|
||||
screenAspectRatio,
|
||||
showStream,
|
||||
streamError,
|
||||
streamKey,
|
||||
streamRotation
|
||||
}: EmulatorScreenSurfaceProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
frameLayout
|
||||
? 'absolute overflow-hidden bg-black ring-1 ring-white/10'
|
||||
: 'relative w-full overflow-hidden bg-black ring-1 ring-white/10',
|
||||
isLive && 'touch-none select-none'
|
||||
)}
|
||||
style={{
|
||||
inset: frameLayout ? `${frameLayout.bezel}px` : undefined,
|
||||
aspectRatio: frameLayout ? undefined : `${screenAspectRatio}`,
|
||||
borderRadius: frameLayout ? `${frameLayout.innerRadius}px` : '44px'
|
||||
}}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
onWheel={onWheel}
|
||||
role={isLive ? 'application' : undefined}
|
||||
tabIndex={isLive ? 0 : undefined}
|
||||
aria-keyshortcuts={keyboardCaptureActive ? 'Escape' : undefined}
|
||||
aria-label={getEmulatorScreenAriaLabel(isLive, keyboardCaptureActive)}
|
||||
>
|
||||
{/* Why: the stream is the actual emulator screen; fake in-screen chrome
|
||||
doubles up with iOS's real status bar and makes bezels lie. */}
|
||||
<EmulatorScreenStreamContent
|
||||
loading={loading}
|
||||
onStreamError={onStreamError}
|
||||
onStreamSize={onStreamSize}
|
||||
previewUrl={previewUrl}
|
||||
screenAspectRatio={screenAspectRatio}
|
||||
showStream={showStream}
|
||||
streamError={streamError}
|
||||
streamKey={streamKey}
|
||||
streamRotation={streamRotation}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
import { useCallback, useRef } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import type { EmulatorDeviceVisualOrientation } from './emulator-device-frame-layout'
|
||||
import type { EmulatorGesturePoint } from './emulator-screen-gesture'
|
||||
|
||||
export function useEmulatorPaneControls(worktreeId: string) {
|
||||
export function useEmulatorPaneControls(worktreeId: string, onRotateSettled?: () => void) {
|
||||
const nextRotateOrientationRef = useRef<'landscape_left' | 'portrait'>('landscape_left')
|
||||
const visualOrientationEpochRef = useRef(0)
|
||||
const [visualOrientation, setVisualOrientation] =
|
||||
useState<EmulatorDeviceVisualOrientation>('portrait')
|
||||
|
||||
const sendTap = useCallback(
|
||||
async (x: number, y: number) => {
|
||||
|
|
@ -28,13 +32,27 @@ export function useEmulatorPaneControls(worktreeId: string) {
|
|||
|
||||
const sendRotate = useCallback(async () => {
|
||||
const orientation = nextRotateOrientationRef.current
|
||||
const epoch = visualOrientationEpochRef.current
|
||||
await callRuntimeRpc({ kind: 'local' }, 'emulator.rotate', {
|
||||
orientation,
|
||||
worktree: worktreeId
|
||||
})
|
||||
if (visualOrientationEpochRef.current !== epoch) {
|
||||
return null
|
||||
}
|
||||
const nextVisualOrientation = orientation === 'landscape_left' ? 'landscape' : 'portrait'
|
||||
setVisualOrientation(nextVisualOrientation)
|
||||
nextRotateOrientationRef.current =
|
||||
orientation === 'landscape_left' ? 'portrait' : 'landscape_left'
|
||||
}, [worktreeId])
|
||||
onRotateSettled?.()
|
||||
return nextVisualOrientation
|
||||
}, [onRotateSettled, worktreeId])
|
||||
|
||||
return { sendTap, sendButton, sendGesture, sendRotate }
|
||||
const resetVisualOrientation = useCallback(() => {
|
||||
visualOrientationEpochRef.current += 1
|
||||
nextRotateOrientationRef.current = 'landscape_left'
|
||||
setVisualOrientation('portrait')
|
||||
}, [])
|
||||
|
||||
return { sendTap, sendButton, sendGesture, sendRotate, visualOrientation, resetVisualOrientation }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const deviceList = devices.map((device) => ({
|
|||
}))
|
||||
|
||||
let attachDeferred: Deferred<AttachResult>
|
||||
let rotateDeferred: Deferred<void>
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let latest: ReturnType<typeof useEmulatorPaneSession> | null = null
|
||||
|
|
@ -113,6 +114,7 @@ describe('useEmulatorPaneSession', () => {
|
|||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true
|
||||
attachDeferred = createDeferred<AttachResult>()
|
||||
rotateDeferred = createDeferred<void>()
|
||||
latest = null
|
||||
consumePrelaunchedSimulatorSession(WORKTREE_ID)
|
||||
rememberPrelaunchedSimulatorSession(WORKTREE_ID, {
|
||||
|
|
@ -133,6 +135,9 @@ describe('useEmulatorPaneSession', () => {
|
|||
if (method === 'emulator.attach') {
|
||||
return runtimeSuccess(await attachDeferred.promise)
|
||||
}
|
||||
if (method === 'emulator.rotate') {
|
||||
return runtimeSuccess(await rotateDeferred.promise)
|
||||
}
|
||||
if (method === 'emulator.shutdown') {
|
||||
return runtimeSuccess({ deviceUdid: 'device-b' })
|
||||
}
|
||||
|
|
@ -196,6 +201,48 @@ describe('useEmulatorPaneSession', () => {
|
|||
expect(latest?.previewUrl).toBe('http://127.0.0.1:3200/stream.mjpeg')
|
||||
})
|
||||
|
||||
it('ignores a rotate response from the previous session after switching devices', async () => {
|
||||
await act(async () => {
|
||||
root.render(<Probe />)
|
||||
})
|
||||
await flushEffects()
|
||||
|
||||
let rotatePromise: Promise<unknown> = Promise.resolve()
|
||||
await act(async () => {
|
||||
rotatePromise = latest?.sendRotate() ?? Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
rotateDeferred.resolve()
|
||||
await rotatePromise
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(latest?.visualOrientation).toBe('portrait')
|
||||
|
||||
await act(async () => {
|
||||
attachDeferred.resolve({
|
||||
attached: true,
|
||||
info: {
|
||||
deviceUdid: 'device-b',
|
||||
displayName: 'iPhone B',
|
||||
streamUrl: 'http://127.0.0.1:3200/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3200/ws'
|
||||
}
|
||||
})
|
||||
await attachDeferred.promise
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(latest?.visualOrientation).toBe('portrait')
|
||||
})
|
||||
|
||||
it('keeps simulator discovery setup errors during auto attach', async () => {
|
||||
const message =
|
||||
'Xcode Simulator tools are unavailable. Install full Xcode, open it once, then select it with `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer`.'
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type SimulatorDeviceRow
|
||||
} from './emulator-pane-types'
|
||||
import { markSimulatorDeviceBooted, markSimulatorDeviceShutdown } from './emulator-device-state'
|
||||
import { toSimulatorDeviceRows, type RawEmulatorDevice } from './emulator-device-row-mapping'
|
||||
import { useEmulatorPaneControls } from './use-emulator-pane-controls'
|
||||
import { useEmulatorPaneSessionEvents } from './use-emulator-pane-session-events'
|
||||
import {
|
||||
|
|
@ -56,25 +57,25 @@ export function useEmulatorPaneSession({
|
|||
const liveTargetRef = useRef<string | null>(prelaunchedState.liveTarget)
|
||||
const deviceRefreshErrorRef = useRef<unknown>(null)
|
||||
const suppressAutoAttachRef = useRef(false)
|
||||
const { sendTap, sendButton, sendGesture, sendRotate } = useEmulatorPaneControls(worktreeId)
|
||||
const refreshStreamKey = useCallback(() => setStreamKey(String(Date.now())), [])
|
||||
const {
|
||||
sendTap,
|
||||
sendButton,
|
||||
sendGesture,
|
||||
sendRotate,
|
||||
visualOrientation,
|
||||
resetVisualOrientation
|
||||
} = useEmulatorPaneControls(worktreeId, refreshStreamKey)
|
||||
|
||||
const refreshDevices = useCallback(async (bootedTarget?: string | null) => {
|
||||
try {
|
||||
// Unified list so Android devices/AVDs appear alongside iOS simulators.
|
||||
const raw = (await callRuntimeRpc({ kind: 'local' }, 'emulator.listDevices', {})) as {
|
||||
id: string
|
||||
name: string
|
||||
state: string
|
||||
detail?: string
|
||||
isAvailable?: boolean
|
||||
}[]
|
||||
const list: SimulatorDeviceRow[] = raw.map((device) => ({
|
||||
name: device.name,
|
||||
udid: device.id,
|
||||
state: device.state === 'booted' ? 'Booted' : 'Shutdown',
|
||||
runtime: device.detail,
|
||||
isAvailable: device.isAvailable
|
||||
}))
|
||||
const raw = (await callRuntimeRpc(
|
||||
{ kind: 'local' },
|
||||
'emulator.listDevices',
|
||||
{}
|
||||
)) as RawEmulatorDevice[]
|
||||
const list = toSimulatorDeviceRows(raw)
|
||||
const next = markSimulatorDeviceBooted(list, bootedTarget)
|
||||
if (!mountedRef.current) {
|
||||
return next
|
||||
|
|
@ -106,6 +107,9 @@ export function useEmulatorPaneSession({
|
|||
if (attached && rows !== deviceRows) {
|
||||
setDevices(rows)
|
||||
}
|
||||
if (attached && target && target !== liveTargetRef.current) {
|
||||
resetVisualOrientation()
|
||||
}
|
||||
const row = rows.find((d) => d.udid === target || d.name === target)
|
||||
const displayName = row?.name || deviceLabel(info)
|
||||
const enriched = { ...info, displayName, state: attached ? 'Booted' : info?.state }
|
||||
|
|
@ -126,7 +130,7 @@ export function useEmulatorPaneSession({
|
|||
useAppStore.getState().setTabLabel(tabId, displayName)
|
||||
}
|
||||
},
|
||||
[devices, tabId]
|
||||
[devices, resetVisualOrientation, tabId]
|
||||
)
|
||||
|
||||
const clearSessionAfterShutdown = useCallback(
|
||||
|
|
@ -141,13 +145,14 @@ export function useEmulatorPaneSession({
|
|||
liveTargetRef.current = null
|
||||
suppressAutoAttachRef.current = true
|
||||
setStreamKey(null)
|
||||
resetVisualOrientation()
|
||||
setError(null)
|
||||
if (tabId) {
|
||||
const row = devices.find((device) => device.udid === target || device.name === target)
|
||||
useAppStore.getState().setTabLabel(tabId, row?.name || 'Mobile Emulator')
|
||||
}
|
||||
},
|
||||
[devices, selectedUdid, session, tabId]
|
||||
[devices, resetVisualOrientation, selectedUdid, session, tabId]
|
||||
)
|
||||
|
||||
const attach = useCallback(
|
||||
|
|
@ -189,6 +194,7 @@ export function useEmulatorPaneSession({
|
|||
setSession(null)
|
||||
setStreamKey(null)
|
||||
liveTargetRef.current = null
|
||||
resetVisualOrientation()
|
||||
}
|
||||
const res = (await callRuntimeRpc({ kind: 'local' }, 'emulator.attach', {
|
||||
device: target,
|
||||
|
|
@ -238,6 +244,7 @@ export function useEmulatorPaneSession({
|
|||
devices,
|
||||
loading,
|
||||
refreshDevices,
|
||||
resetVisualOrientation,
|
||||
selectedUdid,
|
||||
tabId,
|
||||
worktreeId
|
||||
|
|
@ -301,6 +308,7 @@ export function useEmulatorPaneSession({
|
|||
sendButton,
|
||||
sendGesture,
|
||||
sendRotate,
|
||||
visualOrientation,
|
||||
displayName: view.displayName,
|
||||
previewUrl: view.previewUrl,
|
||||
wsUrl: view.wsUrl,
|
||||
|
|
|
|||
Loading…
Reference in New Issue