Skip to content

Commit 210831d

Browse files
committed
Add runtime stream codec and transport controls
1 parent 120cc2e commit 210831d

12 files changed

Lines changed: 351 additions & 38 deletions

File tree

client/src/api/controls.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
SimulatorMetadata,
77
SimulatorResponse,
88
TouchPayload,
9+
VideoCodecMode,
910
} from "./types";
1011

1112
export type ControlMessage =
@@ -88,3 +89,13 @@ export function rotateLeft(udid: string) {
8889
export function rotateRight(udid: string) {
8990
return postSimulatorAction(udid, "rotate-right");
9091
}
92+
93+
export function setSimulatorVideoCodec(udid: string, codec: VideoCodecMode) {
94+
return apiRequest<{ ok: boolean; videoCodec: VideoCodecMode }>(
95+
`/api/simulators/${encodeURIComponent(udid)}/video-codec`,
96+
{
97+
body: JSON.stringify({ codec }),
98+
method: "POST",
99+
},
100+
);
101+
}

client/src/api/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface HealthResponse {
2626
videoCodec?: string;
2727
}
2828

29+
export type VideoCodecMode = "hevc" | "h264" | "h264-software";
30+
2931
export interface SimulatorResponse {
3032
simulator: SimulatorMetadata;
3133
}

client/src/app/AppShell.tsx

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import {
88
type FormEvent,
99
} from "react";
1010

11-
import { ApiError, accessTokenFromLocation, pairBrowser } from "../api/client";
11+
import {
12+
ApiError,
13+
accessTokenFromLocation,
14+
fetchHealth,
15+
pairBrowser,
16+
} from "../api/client";
1217
import {
1318
bootSimulator,
1419
dismissKeyboard,
@@ -18,6 +23,7 @@ import {
1823
pressHome,
1924
rotateLeft,
2025
rotateRight,
26+
setSimulatorVideoCodec,
2127
simulatorControlSocketUrl,
2228
shutdownSimulator,
2329
toggleAppearance,
@@ -32,13 +38,18 @@ import type {
3238
ChromeProfile,
3339
SimulatorMetadata,
3440
TouchPhase,
41+
VideoCodecMode,
3542
} from "../api/types";
3643
import { AccessibilityInspector } from "../features/accessibility/AccessibilityInspector";
3744
import { useKeyboardInput } from "../features/input/useKeyboardInput";
3845
import { usePointerInput } from "../features/input/usePointerInput";
3946
import { simulatorRuntimeLabel } from "../features/simulators/simulatorDisplay";
4047
import { useSimulatorList } from "../features/simulators/useSimulatorList";
41-
import { sendWebRtcControlMessage } from "../features/stream/streamWorkerClient";
48+
import {
49+
initialStreamTransportMode,
50+
sendWebRtcControlMessage,
51+
type StreamTransportMode,
52+
} from "../features/stream/streamWorkerClient";
4253
import { useLiveStream } from "../features/stream/useLiveStream";
4354
import { DebugPanel } from "../features/toolbar/DebugPanel";
4455
import { Toolbar } from "../features/toolbar/Toolbar";
@@ -85,6 +96,8 @@ const REACT_NATIVE_ACCESSIBILITY_REFRESH_MS = 500;
8596
const DEFAULT_ACCESSIBILITY_MAX_DEPTH = 10;
8697
const LOGICAL_INSPECTOR_MAX_DEPTH = 80;
8798
const AUTH_REQUIRED_MESSAGE = "SimDeck API access token is required.";
99+
const STREAM_TRANSPORT_STORAGE_KEY = "simdeck.streamTransport";
100+
const VIDEO_CODEC_STORAGE_KEY = "simdeck.videoCodec";
88101

89102
clearLegacyVolatileUiState();
90103

@@ -163,6 +176,32 @@ type SimulatorTransition = {
163176
udid: string;
164177
};
165178

179+
function isVideoCodecMode(value: unknown): value is VideoCodecMode {
180+
return value === "hevc" || value === "h264" || value === "h264-software";
181+
}
182+
183+
function readStoredTransportMode(): StreamTransportMode {
184+
if (typeof window === "undefined") {
185+
return "auto";
186+
}
187+
if (new URLSearchParams(window.location.search).has("transport")) {
188+
return initialStreamTransportMode();
189+
}
190+
const stored = window.localStorage.getItem(STREAM_TRANSPORT_STORAGE_KEY);
191+
if (stored === "auto" || stored === "webtransport" || stored === "webrtc") {
192+
return stored;
193+
}
194+
return initialStreamTransportMode();
195+
}
196+
197+
function readStoredVideoCodec(): VideoCodecMode {
198+
if (typeof window === "undefined") {
199+
return "h264-software";
200+
}
201+
const stored = window.localStorage.getItem(VIDEO_CODEC_STORAGE_KEY);
202+
return isVideoCodecMode(stored) ? stored : "h264-software";
203+
}
204+
166205
export function AppShell() {
167206
const [initialUiState] = useState(readPersistedUiState);
168207
const [initialSelectedUDID] = useState(
@@ -200,6 +239,11 @@ export function AppShell() {
200239
initialViewportState.rotationQuarterTurns,
201240
);
202241
const [streamStamp, setStreamStamp] = useState(Date.now());
242+
const [streamSettingsRevision, setStreamSettingsRevision] = useState(0);
243+
const [streamTransportMode, setStreamTransportMode] =
244+
useState<StreamTransportMode>(readStoredTransportMode);
245+
const [videoCodec, setVideoCodec] =
246+
useState<VideoCodecMode>(readStoredVideoCodec);
203247
const [viewMode, setViewMode] = useState<ViewMode>(
204248
initialViewportState.viewMode,
205249
);
@@ -333,6 +377,8 @@ export function AppShell() {
333377
} = useLiveStream({
334378
canvasElement: streamCanvasElement,
335379
simulator: selectedSimulator,
380+
streamRevision: streamSettingsRevision,
381+
transportMode: streamTransportMode,
336382
});
337383
const shouldRenderChrome =
338384
selectedSimulator != null && shouldRenderNativeChrome(selectedSimulator);
@@ -391,6 +437,33 @@ export function AppShell() {
391437
);
392438
}, [accessibilityPreferredSource]);
393439

440+
useEffect(() => {
441+
window.localStorage.setItem(
442+
STREAM_TRANSPORT_STORAGE_KEY,
443+
streamTransportMode,
444+
);
445+
}, [streamTransportMode]);
446+
447+
useEffect(() => {
448+
window.localStorage.setItem(VIDEO_CODEC_STORAGE_KEY, videoCodec);
449+
}, [videoCodec]);
450+
451+
useEffect(() => {
452+
let cancelled = false;
453+
fetchHealth()
454+
.then((health) => {
455+
if (!cancelled && isVideoCodecMode(health.videoCodec)) {
456+
setVideoCodec(health.videoCodec);
457+
}
458+
})
459+
.catch(() => {
460+
// Non-critical: stream setup still fetches health and reports errors.
461+
});
462+
return () => {
463+
cancelled = true;
464+
};
465+
}, [streamSettingsRevision]);
466+
394467
useEffect(() => {
395468
if (simulatorTransition == null) {
396469
return;
@@ -1133,6 +1206,28 @@ export function AppShell() {
11331206
);
11341207
}
11351208

1209+
function handleSelectTransportMode(mode: StreamTransportMode) {
1210+
setStreamTransportMode(mode);
1211+
setStreamSettingsRevision((current) => current + 1);
1212+
setStreamStamp(Date.now());
1213+
}
1214+
1215+
function handleSelectVideoCodec(codec: VideoCodecMode) {
1216+
if (!selectedSimulator) {
1217+
return;
1218+
}
1219+
const udid = selectedSimulator.udid;
1220+
setVideoCodec(codec);
1221+
setStreamSettingsRevision((current) => current + 1);
1222+
setStreamStamp(Date.now());
1223+
void runAction(async () => {
1224+
const response = await setSimulatorVideoCodec(udid, codec);
1225+
setVideoCodec(response.videoCodec);
1226+
setStreamSettingsRevision((current) => current + 1);
1227+
setStreamStamp(Date.now());
1228+
}, false);
1229+
}
1230+
11361231
async function submitPairing(event: FormEvent<HTMLFormElement>) {
11371232
event.preventDefault();
11381233
const code = pairingCode.trim();
@@ -1194,6 +1289,7 @@ export function AppShell() {
11941289
isLoading={isLoading}
11951290
menuOpen={menuOpen}
11961291
menuRef={menuRef}
1292+
onChangeVideoCodec={handleSelectVideoCodec}
11971293
onBoot={() => {
11981294
if (!selectedSimulator) {
11991295
return;
@@ -1287,11 +1383,14 @@ export function AppShell() {
12871383
onToggleTouchOverlay={() =>
12881384
setTouchOverlayVisible((current) => !current)
12891385
}
1386+
onChangeTransportMode={handleSelectTransportMode}
12901387
search={search}
12911388
selectedSimulator={selectedSimulator}
12921389
selectedSimulatorIdentifier={selectedSimulatorDetail}
12931390
setSelectedUDID={setSelectedUDID}
1391+
streamTransportMode={streamTransportMode}
12941392
touchOverlayVisible={touchOverlayVisible}
1393+
videoCodec={videoCodec}
12951394
/>
12961395
<SimulatorViewport
12971396
accessibilityHoveredId={accessibilityHoveredId}

client/src/features/simulators/SimulatorMenu.tsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { RefObject } from "react";
22

3-
import type { SimulatorMetadata } from "../../api/types";
3+
import type { SimulatorMetadata, VideoCodecMode } from "../../api/types";
4+
import type { StreamTransportMode } from "../stream/streamWorkerClient";
45
import { SimulatorRow } from "./SimulatorRow";
56

67
interface SimulatorMenuProps {
@@ -10,6 +11,8 @@ interface SimulatorMenuProps {
1011
menuOpen: boolean;
1112
menuRef: RefObject<HTMLDivElement | null>;
1213
onChangeSearch: (value: string) => void;
14+
onChangeTransportMode: (mode: StreamTransportMode) => void;
15+
onChangeVideoCodec: (codec: VideoCodecMode) => void;
1316
onCloseMenu: () => void;
1417
onOpenBundlePrompt: () => void;
1518
onOpenUrlPrompt: () => void;
@@ -18,6 +21,8 @@ interface SimulatorMenuProps {
1821
search: string;
1922
selectedSimulator: SimulatorMetadata | null;
2023
setSelectedUDID: (udid: string) => void;
24+
streamTransportMode: StreamTransportMode;
25+
videoCodec: VideoCodecMode;
2126
}
2227

2328
export function SimulatorMenu({
@@ -27,6 +32,8 @@ export function SimulatorMenu({
2732
menuOpen,
2833
menuRef,
2934
onChangeSearch,
35+
onChangeTransportMode,
36+
onChangeVideoCodec,
3037
onCloseMenu,
3138
onOpenBundlePrompt,
3239
onOpenUrlPrompt,
@@ -35,7 +42,20 @@ export function SimulatorMenu({
3542
search,
3643
selectedSimulator,
3744
setSelectedUDID,
45+
streamTransportMode,
46+
videoCodec,
3847
}: SimulatorMenuProps) {
48+
const codecOptions: { label: string; value: VideoCodecMode }[] = [
49+
{ label: "HEVC", value: "hevc" },
50+
{ label: "H.264", value: "h264" },
51+
{ label: "H.264 SW", value: "h264-software" },
52+
];
53+
const transportOptions: { label: string; value: StreamTransportMode }[] = [
54+
{ label: "Auto", value: "auto" },
55+
{ label: "WebTransport", value: "webtransport" },
56+
{ label: "WebRTC", value: "webrtc" },
57+
];
58+
3959
return (
4060
<div className="menu-wrap" ref={menuRef}>
4161
<button
@@ -90,6 +110,42 @@ export function SimulatorMenu({
90110
</>
91111
) : null}
92112
<div className="menu-divider" />
113+
<div className="menu-section">
114+
<div className="menu-section-title">Codec</div>
115+
<div className="menu-segment" role="group" aria-label="Codec">
116+
{codecOptions.map((option) => (
117+
<button
118+
className={`menu-option ${
119+
option.value === videoCodec ? "active" : ""
120+
}`}
121+
disabled={!selectedSimulator}
122+
key={option.value}
123+
onClick={() => onChangeVideoCodec(option.value)}
124+
type="button"
125+
>
126+
{option.label}
127+
</button>
128+
))}
129+
</div>
130+
</div>
131+
<div className="menu-section">
132+
<div className="menu-section-title">Transport</div>
133+
<div className="menu-segment" role="group" aria-label="Transport">
134+
{transportOptions.map((option) => (
135+
<button
136+
className={`menu-option ${
137+
option.value === streamTransportMode ? "active" : ""
138+
}`}
139+
key={option.value}
140+
onClick={() => onChangeTransportMode(option.value)}
141+
type="button"
142+
>
143+
{option.label}
144+
</button>
145+
))}
146+
</div>
147+
</div>
148+
<div className="menu-divider" />
93149
<div className="menu-actions">
94150
<button className="menu-action" onClick={onToggleDebug}>
95151
{debugVisible ? "Hide Debug Info" : "Show Debug Info"}

0 commit comments

Comments
 (0)