Skip to content
Open
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
45 changes: 43 additions & 2 deletions client/src/features/stream/useLiveStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ function isDocumentForeground(): boolean {
return document.visibilityState === "visible";
}

function isViewerForeground(canvasVisible: boolean): boolean {
return isDocumentForeground() && canvasVisible;
}

export function useLiveStream({
canvasElement,
paused = false,
Expand All @@ -122,6 +126,7 @@ export function useLiveStream({
const retainedFrameRef = useRef(false);
const previousSimulatorUdidRef = useRef<string | undefined>(simulator?.udid);
const connectedStreamTargetKeyRef = useRef("");
const canvasVisibleRef = useRef(true);
const latestVisualArtifactRef = useRef<VisualArtifactSample | null>(null);
const latestVisualArtifactSampleCountRef = useRef(0);
const lastVisualArtifactSampleAtRef = useRef(0);
Expand Down Expand Up @@ -223,6 +228,40 @@ export function useLiveStream({
};
}, []);

useEffect(() => {
if (!canvasElement || !simulator?.udid || paused) {
return;
}

const sendCanvasForegroundState = () => {
workerClientRef.current?.sendStreamControl({
clientId: clientTelemetryIdRef.current,
foreground: isViewerForeground(canvasVisibleRef.current),
});
};

if (typeof IntersectionObserver !== "function") {
canvasVisibleRef.current = true;
sendCanvasForegroundState();
return;
}

const observer = new IntersectionObserver(
(entries) => {
const entry = entries[entries.length - 1];
canvasVisibleRef.current = Boolean(
entry?.isIntersecting && entry.intersectionRatio > 0,
);
sendCanvasForegroundState();
},
{ threshold: [0, 0.01] },
);
observer.observe(canvasElement);
return () => {
observer.disconnect();
};
}, [canvasElement, paused, simulator?.udid]);

useEffect(() => {
latestDecodedFramesRef.current = stats.decodedFrames;
latestRenderedFramesRef.current = stats.renderedFrames;
Expand Down Expand Up @@ -355,7 +394,9 @@ export function useLiveStream({
return;
}

const sendForegroundState = (foreground = isDocumentForeground()) => {
const sendForegroundState = (
foreground = isViewerForeground(canvasVisibleRef.current),
) => {
workerClientRef.current?.sendStreamControl({
clientId: clientTelemetryIdRef.current,
foreground,
Expand Down Expand Up @@ -449,7 +490,7 @@ export function useLiveStream({
};
workerClientRef.current?.sendStreamControl({
clientId: clientTelemetryIdRef.current,
foreground: isDocumentForeground(),
foreground: isViewerForeground(canvasVisibleRef.current),
});
if (
sendStreamClientStats(payload) ||
Expand Down
12 changes: 12 additions & 0 deletions docs/guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,15 @@ Include simulator refresh traffic:
```sh
npm run test:stress -- --udid <udid> --iterations 2000 --concurrency 16
```

## Stress Test Daemon Cleanup

```sh
npm run build:cli
npm run test:stress:daemon -- --iterations 30 --concurrency 3
```

This starts isolated temporary project daemons, hits health and metrics, stops
them through the CLI, and verifies the process group, listener port, and daemon
status are cleaned up. Use `--binary /path/to/simdeck` to test an installed or
packaged binary.
11 changes: 11 additions & 0 deletions docs/guide/video.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ SimDeck streams live device video to the browser. Local sessions default to high

iOS simulator H.264 uses VideoToolbox for hardware encoding and x264 for software encoding.

## When Encoding Runs

SimDeck starts encoding when a browser stream needs H.264 frames. The server
requests an initial keyframe to answer the WebRTC or H.264 WebSocket viewer,
then keeps a shared refresh pump active while frame subscribers exist.

The browser reports whether the page and stream canvas are foreground. When all
known viewers are hidden or the last frame subscriber disconnects, the native
session pauses encoder input and releases the active compression session. A
visible viewer, explicit refresh, or stream reconnect asks for a fresh keyframe.

## Pick A Stream Quality

Start with the default:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"test:studio-provider": "node --test scripts/studio-provider-bridge.test.mjs scripts/studio-host-provider.test.mjs",
"test:github-actions": "node --test scripts/github-actions.test.mjs",
"test:stress": "node scripts/stress/simdeck.mjs",
"test:stress:daemon": "node scripts/stress/daemon-cleanup.mjs",
"bench:encoder:build": "scripts/bench/build-encoder-benchmark.sh",
"codex:setup": "node scripts/codex-setup.mjs",
"codex:cache:save": "node scripts/codex-worktree-cache.mjs save --best-effort",
Expand Down
Loading
Loading