Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ profile (`1170` longest edge, dynamic up to `60` fps). Use
`--stream-quality quality|balanced|fast|smooth|economy|ci-software` to override it,
or pass `--video-codec hardware` when a dedicated hardware encoder is preferable.
The remote viewer renders live video with the browser's native video element;
the canvas is only used for input geometry.
the canvas is only used for input geometry. Remote viewers can choose 15, 30,
or 60 fps in the browser stream menu.

CLI commands automatically use the same warm daemon:

Expand Down
205 changes: 196 additions & 9 deletions client/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
type FormEvent,
} from "react";

import { ApiError, accessTokenFromLocation, pairBrowser } from "../api/client";
import {
ApiError,
accessTokenFromLocation,
apiRequest,
pairBrowser,
} from "../api/client";
import { apiUrl, configureSimDeckClient } from "../api/config";
import {
bootSimulator,
Expand Down Expand Up @@ -98,12 +103,38 @@ const LOCAL_STREAM_DEFAULTS: StreamConfig = {
quality: "quality",
};
const REMOTE_STREAM_DEFAULTS: StreamConfig = {
encoder: "auto",
encoder: "software",
fps: 30,
quality: "balanced",
};
const STREAM_CONFIG_SYNC_INTERVAL_MS = 5000;
const STREAM_CONFIG_USER_CHANGE_GRACE_MS = 1000;
const STREAM_ENCODER_VALUES = new Set<StreamEncoder>([
"auto",
"hardware",
"software",
]);
const STREAM_QUALITY_VALUES = new Set<StreamQualityPreset>([
"balanced",
"ci-software",
"economy",
"fast",
"quality",
"smooth",
]);
clearLegacyVolatileUiState();

interface StreamQualityResponse {
ok?: boolean;
quality?: {
fps?: number;
maxEdge?: number;
profile?: string;
videoCodec?: string;
};
videoCodec?: string;
}

function buildChromeUrl(udid: string, stamp: number): string {
return buildAuthenticatedAssetUrl(
`/api/simulators/${udid}/chrome.png`,
Expand Down Expand Up @@ -296,6 +327,8 @@ export function AppShell({
const [streamConfig, setStreamConfig] = useState<StreamConfig>(() =>
remoteStream ? REMOTE_STREAM_DEFAULTS : LOCAL_STREAM_DEFAULTS,
);
const [streamConfigApplyKey, setStreamConfigApplyKey] = useState(0);
const [streamConfigReady, setStreamConfigReady] = useState(false);
const [touchIndicators, setTouchIndicators] = useState<TouchIndicator[]>([]);

const menuRef = useRef<HTMLDivElement | null>(null);
Expand All @@ -313,6 +346,8 @@ export function AppShell({
const gestureStartZoomRef = useRef(1);
const accessibilityRequestIdRef = useRef(0);
const accessibilityLoadingRef = useRef(false);
const streamConfigRequestIdRef = useRef(0);
const streamConfigUserChangeAtRef = useRef(0);
const controlSocketRef = useRef<{
udid: string;
socket: WebSocket;
Expand Down Expand Up @@ -395,6 +430,52 @@ export function AppShell({
[],
);

const syncStreamConfig = useCallback(async () => {
const requestId = ++streamConfigRequestIdRef.current;
try {
const response = await apiRequest<StreamQualityResponse>(
"/api/stream-quality",
);
if (requestId !== streamConfigRequestIdRef.current) {
return;
}
if (
Date.now() - streamConfigUserChangeAtRef.current <
STREAM_CONFIG_USER_CHANGE_GRACE_MS
) {
return;
}
setStreamConfig((current) =>
mergeStreamQualityResponse(current, response),
);
} catch {
// Keep the existing local/default selection; the stream path will surface
// provider reachability errors separately.
} finally {
if (requestId === streamConfigRequestIdRef.current) {
setStreamConfigReady(true);
}
}
}, []);

useEffect(() => {
let cancelled = false;
setStreamConfigReady(false);

const run = () => {
if (!cancelled) {
void syncStreamConfig();
}
};

run();
const intervalId = window.setInterval(run, STREAM_CONFIG_SYNC_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [remoteStream, syncStreamConfig]);

const {
deviceNaturalSize,
error: streamError,
Expand All @@ -407,20 +488,31 @@ export function AppShell({
streamCanvasKey,
} = useLiveStream({
canvasElement: streamCanvasElement,
paused: !streamConfigReady,
remote: remoteStream,
simulator: selectedSimulator,
streamConfig,
streamConfigApplyKey,
});

const updateStreamEncoder = useCallback((encoder: StreamEncoder) => {
streamConfigUserChangeAtRef.current = Date.now();
setStreamConfigReady(true);
setStreamConfigApplyKey((current) => current + 1);
setStreamConfig((current) => ({ ...current, encoder }));
}, []);

const updateStreamFps = useCallback((fps: StreamFps) => {
streamConfigUserChangeAtRef.current = Date.now();
setStreamConfigReady(true);
setStreamConfigApplyKey((current) => current + 1);
setStreamConfig((current) => ({ ...current, fps }));
}, []);

const updateStreamQuality = useCallback((quality: StreamQualityPreset) => {
streamConfigUserChangeAtRef.current = Date.now();
setStreamConfigReady(true);
setStreamConfigApplyKey((current) => current + 1);
setStreamConfig((current) => ({ ...current, quality }));
}, []);

Expand Down Expand Up @@ -899,27 +991,34 @@ export function AppShell({
});

const pairingRequired =
!remoteStream &&
pairingEnabled &&
listError === AUTH_REQUIRED_MESSAGE &&
!accessTokenFromLocation();
const visibleListError = selectedSimulator
? friendlyClientError(listError)
: listError;
const visibleListError =
remoteStream && listError === AUTH_REQUIRED_MESSAGE
? ""
: selectedSimulator
? friendlyClientError(listError)
: listError;
const toolbarError = pairingRequired
? localError
: localError || (selectedSimulator ? "" : visibleListError);
const streamStatusMessage = streamStatus.error
const visibleStreamError = friendlyStreamError(streamStatus.error, {
remote: remoteStream,
});
const streamStatusMessage = visibleStreamError
? streamStatus.detail
? `${streamStatus.error} ${streamStatus.detail}`
: streamStatus.error
? `${visibleStreamError} ${streamStatus.detail}`
: visibleStreamError
: "";
const viewportStatusOverlayLabel =
simulatorStatusOverlayLabel ||
streamStatusMessage ||
(selectedSimulator ? visibleListError : "");
const viewportHasStreamError = Boolean(
streamStatus.state === "error" ||
streamStatus.error ||
visibleStreamError ||
(selectedSimulator && visibleListError),
);
const deviceTransform = `translate(${pan.x}px, ${pan.y + autoViewportOffsetY}px) scale(${effectiveZoom})`;
Expand Down Expand Up @@ -1061,6 +1160,9 @@ export function AppShell({
if (sendWebRtcControlMessage(encoded)) {
return true;
}
if (remoteStream) {
return false;
}
const state = ensureControlSocket(udid);
if (state.socket.readyState === WebSocket.OPEN) {
state.socket.send(encoded);
Expand Down Expand Up @@ -1462,6 +1564,7 @@ export function AppShell({
onToggleTouchOverlay={() =>
setTouchOverlayVisible((current) => !current)
}
remoteStream={remoteStream}
search={search}
selectedSimulator={selectedSimulator}
selectedSimulatorIdentifier={selectedSimulatorDetail}
Expand Down Expand Up @@ -1607,3 +1710,87 @@ function friendlyClientError(message: string): string {
}
return message;
}

function friendlyStreamError(
message: string | undefined,
options: { remote: boolean },
): string {
const normalized = message?.trim() ?? "";
if (!normalized) {
return "";
}
if (
options.remote &&
normalized.toLowerCase().includes(AUTH_REQUIRED_MESSAGE.toLowerCase())
) {
return "";
}
return friendlyClientError(normalized);
}

function mergeStreamQualityResponse(
current: StreamConfig,
response: StreamQualityResponse,
): StreamConfig {
const quality = response.quality ?? {};
const next: StreamConfig = {
...current,
encoder: normalizeStreamEncoder(
quality.videoCodec ?? response.videoCodec,
current.encoder,
),
fps: normalizeStreamFps(quality.fps, current.fps),
maxEdge: normalizeMaxEdge(quality.maxEdge, current.maxEdge),
quality: normalizeStreamQuality(quality.profile, current.quality),
};
return streamConfigsEqual(current, next) ? current : next;
}

function normalizeStreamEncoder(
value: string | undefined,
fallback: StreamEncoder,
): StreamEncoder {
const normalized = value?.trim().toLowerCase() as StreamEncoder | undefined;
return normalized && STREAM_ENCODER_VALUES.has(normalized)
? normalized
: fallback;
}

function normalizeStreamQuality(
value: string | undefined,
fallback: StreamQualityPreset,
): StreamQualityPreset {
const normalized = value?.trim().toLowerCase() as
| StreamQualityPreset
| undefined;
return normalized && STREAM_QUALITY_VALUES.has(normalized)
? normalized
: fallback;
}

function normalizeStreamFps(
value: number | undefined,
fallback: StreamFps,
): StreamFps {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.round(value)
: fallback;
}

function normalizeMaxEdge(
value: number | undefined,
fallback: number | undefined,
): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.round(value)
: fallback;
}

function streamConfigsEqual(left: StreamConfig, right: StreamConfig): boolean {
return (
left.encoder === right.encoder &&
left.fps === right.fps &&
left.maxEdge === right.maxEdge &&
left.quality === right.quality
);
}
Loading
Loading