Skip to content

Commit 8967e64

Browse files
committed
Add stream encoder and quality controls to the UI
1 parent 8dc07ac commit 8967e64

31 files changed

Lines changed: 2073 additions & 543 deletions

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ the outbound bridge alive until you press Ctrl-C. It uses software H.264 by
8181
default with realtime stream settings for remote viewing, and prints the active
8282
codec/profile when it starts. Studio defaults to the `smooth` stream quality
8383
profile (`1170` longest edge, dynamic up to `60` fps). Use
84-
`--stream-quality quality|balanced|smooth|economy|ci-software` to override it,
84+
`--stream-quality quality|balanced|fast|smooth|economy|ci-software` to override it,
8585
or pass `--video-codec hardware` when a dedicated hardware encoder is preferable.
8686
The remote viewer renders live video with the browser's native video element;
8787
the canvas is only used for input geometry.
@@ -116,11 +116,12 @@ more important than full-resolution smoothness:
116116
simdeck daemon start --video-codec software --low-latency
117117
```
118118

119-
Local browser streams default to 60 fps. On high-refresh local displays, opt in
120-
to a paced hardware stream up to 120 fps:
119+
Local browser streams default to realtime WebRTC delivery with the `quality`
120+
profile on VideoToolbox H.264: full resolution, 120 fps, and a high bitrate floor. On
121+
high-refresh local displays, raise the local stream target explicitly:
121122

122123
```sh
123-
simdeck daemon restart --local-stream-fps 120
124+
simdeck daemon restart --local-stream-fps 240
124125
```
125126

126127
Restart the CoreSimulator service layer when `simctl` reports a stale service

cli/DFPrivateSimulatorDisplayBridge.m

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3372,13 +3372,15 @@ - (void)disconnect {
33723372
self->_latestPixelBuffer = nil;
33733373
}
33743374

3375-
[self->_headlessHostWindow orderOut:nil];
3376-
[self->_headlessHostWindow close];
3377-
self->_headlessHostWindow = nil;
3378-
self->_headlessHostView = nil;
3375+
DFRunOnMainSync(^{
3376+
[self->_headlessHostWindow orderOut:nil];
3377+
[self->_headlessHostWindow close];
3378+
self->_headlessHostWindow = nil;
3379+
self->_headlessHostView = nil;
3380+
[self.displayView removeFromSuperview];
3381+
});
33793382

33803383
[self updateStatus:@"Disconnected"];
3381-
[self.displayView removeFromSuperview];
33823384
};
33833385

33843386
if (dispatch_get_specific(DFPrivateSimulatorCallbackQueueKey) != NULL) {

cli/XCWH264Encoder.m

Lines changed: 136 additions & 87 deletions
Large diffs are not rendered by default.

cli/XCWPrivateSimulatorSession.m

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ - (void)refreshCurrentFrame {
163163
}
164164

165165
- (void)requestKeyFrameRefresh {
166-
[self refreshCurrentFrame];
167166
[_videoEncoder requestKeyFrame];
167+
[self refreshCurrentFrame];
168168
}
169169

170170
- (void)requestFrameRefresh {
@@ -189,18 +189,9 @@ - (id)addEncodedFrameListener:(XCWPrivateSimulatorEncodedFrameHandler)handler {
189189
NSUUID *token = [NSUUID UUID];
190190
dispatch_sync(_stateQueue, ^{
191191
self->_encodedFrameListeners[token] = [handler copy];
192-
if (self->_latestKeyFrameData.length > 0) {
193-
handler(self->_latestKeyFrameData,
194-
self->_latestKeyFrameSequenceValue,
195-
self->_latestKeyFrameTimestampUs,
196-
YES,
197-
self->_latestKeyFrameCodec,
198-
self->_latestKeyFrameDecoderConfig,
199-
self->_latestKeyFrameDimensions);
200-
}
201192
});
202-
[self refreshCurrentFrame];
203193
[_videoEncoder requestKeyFrame];
194+
[self refreshCurrentFrame];
204195
return token;
205196
}
206197

@@ -209,7 +200,7 @@ - (void)removeEncodedFrameListener:(id)token {
209200
return;
210201
}
211202

212-
dispatch_async(_stateQueue, ^{
203+
dispatch_sync(_stateQueue, ^{
213204
[self->_encodedFrameListeners removeObjectForKey:(NSUUID *)token];
214205
});
215206
}

client/src/app/AppShell.tsx

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ import { usePointerInput } from "../features/input/usePointerInput";
4040
import { simulatorRuntimeLabel } from "../features/simulators/simulatorDisplay";
4141
import { useSimulatorList } from "../features/simulators/useSimulatorList";
4242
import { sendWebRtcControlMessage } from "../features/stream/streamWorkerClient";
43+
import type {
44+
StreamConfig,
45+
StreamEncoder,
46+
StreamFps,
47+
StreamQualityPreset,
48+
} from "../features/stream/streamTypes";
4349
import { useLiveStream } from "../features/stream/useLiveStream";
4450
import { DebugPanel } from "../features/toolbar/DebugPanel";
4551
import { Toolbar } from "../features/toolbar/Toolbar";
@@ -86,6 +92,16 @@ const REACT_NATIVE_ACCESSIBILITY_REFRESH_MS = 500;
8692
const DEFAULT_ACCESSIBILITY_MAX_DEPTH = 10;
8793
const LOGICAL_INSPECTOR_MAX_DEPTH = 80;
8894
const AUTH_REQUIRED_MESSAGE = "SimDeck API access token is required.";
95+
const LOCAL_STREAM_DEFAULTS: StreamConfig = {
96+
encoder: "hardware",
97+
fps: 120,
98+
quality: "quality",
99+
};
100+
const REMOTE_STREAM_DEFAULTS: StreamConfig = {
101+
encoder: "software",
102+
fps: 30,
103+
quality: "balanced",
104+
};
89105
clearLegacyVolatileUiState();
90106

91107
function buildChromeUrl(udid: string, stamp: number): string {
@@ -277,6 +293,9 @@ export function AppShell({
277293
const [touchOverlayVisible, setTouchOverlayVisible] = useState(() =>
278294
readStoredFlag(TOUCH_OVERLAY_VISIBLE_STORAGE_KEY, true),
279295
);
296+
const [streamConfig, setStreamConfig] = useState<StreamConfig>(() =>
297+
remoteStream ? REMOTE_STREAM_DEFAULTS : LOCAL_STREAM_DEFAULTS,
298+
);
280299
const [touchIndicators, setTouchIndicators] = useState<TouchIndicator[]>([]);
281300

282301
const menuRef = useRef<HTMLDivElement | null>(null);
@@ -390,8 +409,21 @@ export function AppShell({
390409
canvasElement: streamCanvasElement,
391410
remote: remoteStream,
392411
simulator: selectedSimulator,
412+
streamConfig,
393413
});
394414

415+
const updateStreamEncoder = useCallback((encoder: StreamEncoder) => {
416+
setStreamConfig((current) => ({ ...current, encoder }));
417+
}, []);
418+
419+
const updateStreamFps = useCallback((fps: StreamFps) => {
420+
setStreamConfig((current) => ({ ...current, fps }));
421+
}, []);
422+
423+
const updateStreamQuality = useCallback((quality: StreamQualityPreset) => {
424+
setStreamConfig((current) => ({ ...current, quality }));
425+
}, []);
426+
395427
useEffect(() => {
396428
if (
397429
!selectedSimulator ||
@@ -880,9 +912,7 @@ export function AppShell({
880912
? streamStatus.detail
881913
? `${streamStatus.error} ${streamStatus.detail}`
882914
: streamStatus.error
883-
: streamStatus.state === "connecting" && !hasFrame
884-
? (streamStatus.detail ?? "")
885-
: "";
915+
: "";
886916
const viewportStatusOverlayLabel =
887917
simulatorStatusOverlayLabel ||
888918
streamStatusMessage ||
@@ -1392,6 +1422,9 @@ export function AppShell({
13921422
setStreamStamp(Date.now());
13931423
}, false);
13941424
}}
1425+
onStreamEncoderChange={updateStreamEncoder}
1426+
onStreamFpsChange={updateStreamFps}
1427+
onStreamQualityChange={updateStreamQuality}
13951428
onShutdown={() => {
13961429
if (!selectedSimulator) {
13971430
return;
@@ -1438,6 +1471,7 @@ export function AppShell({
14381471
!selectedSimulator.isBooted &&
14391472
!selectedSimulatorTransitionKind,
14401473
)}
1474+
streamConfig={streamConfig}
14411475
showStopButton={Boolean(
14421476
selectedSimulator?.isBooted && !selectedSimulatorTransitionKind,
14431477
)}

client/src/features/input/usePointerInput.ts

Lines changed: 2 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from "react";
1+
import { useRef, useState } from "react";
22

33
import type { ChromeProfile, TouchPhase } from "../../api/types";
44
import { normalizedPointerCoordinatesForOrientation } from "./gestureMath";
@@ -33,48 +33,14 @@ export function usePointerInput({
3333
onTouchPreview,
3434
}: UsePointerInputOptions) {
3535
const activePointerRef = useRef<number | null>(null);
36-
const moveFrameRef = useRef<number>(0);
3736
const panningRef = useRef<{
3837
startX: number;
3938
startY: number;
4039
startPanX: number;
4140
startPanY: number;
4241
} | null>(null);
43-
const queuedMoveRef = useRef<Point | null>(null);
4442
const [isPanning, setIsPanning] = useState(false);
4543

46-
useEffect(() => {
47-
return () => {
48-
if (moveFrameRef.current) {
49-
cancelAnimationFrame(moveFrameRef.current);
50-
}
51-
};
52-
}, []);
53-
54-
function queueMove(coords: Point) {
55-
queuedMoveRef.current = coords;
56-
if (moveFrameRef.current) {
57-
return;
58-
}
59-
60-
moveFrameRef.current = requestAnimationFrame(() => {
61-
moveFrameRef.current = 0;
62-
const nextCoords = queuedMoveRef.current;
63-
queuedMoveRef.current = null;
64-
if (nextCoords) {
65-
onTouch("moved", nextCoords);
66-
}
67-
});
68-
}
69-
70-
function clearQueuedMove() {
71-
if (moveFrameRef.current) {
72-
cancelAnimationFrame(moveFrameRef.current);
73-
moveFrameRef.current = 0;
74-
}
75-
queuedMoveRef.current = null;
76-
}
77-
7844
function startPanning(event: React.PointerEvent<HTMLElement>) {
7945
if (event.pointerType !== "mouse") {
8046
return;
@@ -155,7 +121,7 @@ export function usePointerInput({
155121
);
156122
if (coords) {
157123
onTouchPreview?.("moved", coords);
158-
queueMove(coords);
124+
onTouch("moved", coords);
159125
}
160126
}
161127

@@ -168,7 +134,6 @@ export function usePointerInput({
168134
return;
169135
}
170136
activePointerRef.current = null;
171-
clearQueuedMove();
172137
const coords = normalizedPointerCoordinatesForOrientation(
173138
event,
174139
rotationQuarterTurns,

client/src/features/simulators/SimulatorMenu.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import type { RefObject } from "react";
22

33
import type { SimulatorMetadata } from "../../api/types";
4+
import type {
5+
StreamConfig,
6+
StreamEncoder,
7+
StreamFps,
8+
StreamQualityPreset,
9+
} from "../stream/streamTypes";
410
import { SimulatorRow } from "./SimulatorRow";
511

612
interface SimulatorMenuProps {
@@ -16,13 +22,17 @@ interface SimulatorMenuProps {
1622
onOpenBundlePrompt: () => void;
1723
onOpenUrlPrompt: () => void;
1824
onRotateLeft: () => void;
25+
onStreamEncoderChange: (encoder: StreamEncoder) => void;
26+
onStreamFpsChange: (fps: StreamFps) => void;
27+
onStreamQualityChange: (quality: StreamQualityPreset) => void;
1928
onToggleAppearance: () => void;
2029
onToggleDebug: () => void;
2130
onToggleMenu: () => void;
2231
onToggleTouchOverlay: () => void;
2332
search: string;
2433
selectedSimulator: SimulatorMetadata | null;
2534
setSelectedUDID: (udid: string) => void;
35+
streamConfig: StreamConfig;
2636
touchOverlayVisible: boolean;
2737
}
2838

@@ -39,13 +49,17 @@ export function SimulatorMenu({
3949
onOpenBundlePrompt,
4050
onOpenUrlPrompt,
4151
onRotateLeft,
52+
onStreamEncoderChange,
53+
onStreamFpsChange,
54+
onStreamQualityChange,
4255
onToggleAppearance,
4356
onToggleDebug,
4457
onToggleMenu,
4558
onToggleTouchOverlay,
4659
search,
4760
selectedSimulator,
4861
setSelectedUDID,
62+
streamConfig,
4963
touchOverlayVisible,
5064
}: SimulatorMenuProps) {
5165
return (
@@ -94,6 +108,46 @@ export function SimulatorMenu({
94108
) : null}
95109
{selectedSimulator ? (
96110
<>
111+
<div className="menu-divider" />
112+
<div className="menu-section">
113+
<span className="menu-section-title">Stream</span>
114+
<div aria-label="Encoder" className="menu-segment two">
115+
{STREAM_ENCODERS.map((option) => (
116+
<button
117+
className={`menu-option ${streamConfig.encoder === option.value ? "active" : ""}`}
118+
key={option.value}
119+
onClick={() => onStreamEncoderChange(option.value)}
120+
type="button"
121+
>
122+
{option.label}
123+
</button>
124+
))}
125+
</div>
126+
<div aria-label="Frame rate" className="menu-segment">
127+
{STREAM_FPS_OPTIONS.map((option) => (
128+
<button
129+
className={`menu-option ${streamConfig.fps === option.value ? "active" : ""}`}
130+
key={option.value}
131+
onClick={() => onStreamFpsChange(option.value)}
132+
type="button"
133+
>
134+
{option.label}
135+
</button>
136+
))}
137+
</div>
138+
<div aria-label="Quality" className="menu-segment">
139+
{STREAM_QUALITY_OPTIONS.map((option) => (
140+
<button
141+
className={`menu-option ${streamConfig.quality === option.value ? "active" : ""}`}
142+
key={option.value}
143+
onClick={() => onStreamQualityChange(option.value)}
144+
type="button"
145+
>
146+
{option.label}
147+
</button>
148+
))}
149+
</div>
150+
</div>
97151
<div className="menu-divider" />
98152
<div className="menu-actions">
99153
<button className="menu-action" onClick={onOpenUrlPrompt}>
@@ -148,6 +202,26 @@ export function SimulatorMenu({
148202
);
149203
}
150204

205+
const STREAM_ENCODERS: Array<{ label: string; value: StreamEncoder }> = [
206+
{ label: "Hardware", value: "hardware" },
207+
{ label: "Software", value: "software" },
208+
];
209+
210+
const STREAM_FPS_OPTIONS: Array<{ label: string; value: StreamFps }> = [
211+
{ label: "30", value: 30 },
212+
{ label: "60", value: 60 },
213+
{ label: "120", value: 120 },
214+
];
215+
216+
const STREAM_QUALITY_OPTIONS: Array<{
217+
label: string;
218+
value: StreamQualityPreset;
219+
}> = [
220+
{ label: "Quality", value: "quality" },
221+
{ label: "Balanced", value: "balanced" },
222+
{ label: "Fast", value: "fast" },
223+
];
224+
151225
function MenuIcon() {
152226
return (
153227
<svg fill="currentColor" height="16" viewBox="0 0 16 16" width="16">

client/src/features/stream/stats.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ export function createEmptyStreamStats(): StreamStats {
66
codec: "",
77
decodeQueueSize: 0,
88
decodedFrames: 0,
9+
decoderDroppedFrames: 0,
910
droppedFrames: 0,
1011
frameSequence: 0,
1112
height: 0,
1213
latestFrameGapMs: 0,
1314
latestRenderMs: 0,
1415
maxRenderMs: 0,
16+
packetsLost: 0,
17+
presentationDroppedFrames: 0,
1518
receivedPackets: 0,
1619
reconnects: 0,
1720
renderedFrames: 0,

0 commit comments

Comments
 (0)