Skip to content

Commit a396fe9

Browse files
committed
feat(resilience): add selectable recovery profiles
1 parent 3acb66c commit a396fe9

23 files changed

Lines changed: 758 additions & 154 deletions

.github/workflows/mouse-flush-test-build.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
name: Resilient stream test build
1+
name: Resilience v2 test build
22

3-
# Custom diagnostic build: resilient SDP profile + diagnostics export v3.
3+
# Custom diagnostic build: Fast Burst Recovery, Cursor Viewport Guard,
4+
# calibrated queue ETA, and diagnostics export v4.
45

56
on:
67
workflow_dispatch:
@@ -68,12 +69,12 @@ jobs:
6869
run: |
6970
$source = Get-ChildItem dist-release\*-portable-x64.exe | Select-Object -First 1
7071
if (-not $source) { throw "Portable executable was not produced." }
71-
Copy-Item $source.FullName dist-release\OpenNOW-resilient-stream-test-portable-x64.exe
72+
Copy-Item $source.FullName dist-release\OpenNOW-resilience-v2-portable-x64.exe
7273
7374
- name: Upload portable executable
7475
uses: actions/upload-artifact@v4
7576
with:
76-
name: OpenNOW-resilient-stream-test-windows-x64
77-
path: opennow-stable/dist-release/OpenNOW-resilient-stream-test-portable-x64.exe
77+
name: OpenNOW-resilience-v2-windows-x64
78+
path: opennow-stable/dist-release/OpenNOW-resilience-v2-portable-x64.exe
7879
if-no-files-found: error
7980
retention-days: 14

locales/en.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@
385385
"calculating": "Calculating",
386386
"cleared": "Cleared",
387387
"elapsed": "Elapsed",
388+
"estimatedLeft": "Estimated left",
388389
"session": "Session",
389390
"protected": "Secured"
390391
},
@@ -759,8 +760,11 @@
759760
"gpu": "GPU",
760761
"cpu": "CPU",
761762
"softwareCpu": "Software (CPU)",
762-
"autoRecoveryBitrate": "Resilient Network Profile (Experimental)",
763-
"autoRecoveryBitrateHint": "WebRTC only. Before a new session starts, reserves bitrate headroom and requests stronger FEC so short packet-loss bursts are less likely to freeze the stream. This may reduce peak image quality and cannot eliminate severe packet loss.",
763+
"networkRecoveryProfile": "Network Recovery Profile",
764+
"networkRecoveryProfileHint": "WebRTC only. Applied before a new connection or Resume. Current preserves existing behavior; recovery profiles reserve bandwidth headroom and strengthen burst recovery.",
765+
"networkRecoveryCurrent": "Current (unchanged)",
766+
"networkRecoveryBalanced": "Balanced Recovery (up to 22 Mbps)",
767+
"networkRecoverySurvival": "Survival Recovery (up to 16 Mbps)",
764768
"sessionProxy": "Session proxy",
765769
"sessionProxyHint": "Used for Nvidia games catalog, session creation, and queue polling. Streaming/signaling traffic is not proxied.",
766770
"session": "Session & Compatibility",

opennow-stable/src/main/settings.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
normalizeRecordingFps,
2424
normalizeRecordingResolution,
2525
normalizeMouseFlushIntervalPreference,
26+
normalizeNetworkRecoveryProfile,
2627
} from "@shared/gfn";
2728
import type { StatsOverlayPosition } from "@shared/gfn";
2829

@@ -140,10 +141,12 @@ export class SettingsManager {
140141
const content = readFileSync(this.settingsPath, "utf-8");
141142
type PersistedSettings = Partial<Settings> & {
142143
sessionTimeRemainingDisplay?: unknown;
144+
autoRecoveryBitrate?: unknown;
143145
};
144146
const parsed = JSON.parse(content) as PersistedSettings;
145147
const {
146148
sessionTimeRemainingDisplay: legacySessionTimeDisplay,
149+
autoRecoveryBitrate: legacyAutoRecoveryBitrate,
147150
...parsedSettings
148151
} = parsed;
149152

@@ -153,7 +156,16 @@ export class SettingsManager {
153156
...parsedSettings,
154157
};
155158

156-
let migrated = this.migrateLegacyShortcutDefaults(merged);
159+
let migrated = false;
160+
if (parsedSettings.networkRecoveryProfile === undefined && legacyAutoRecoveryBitrate === true) {
161+
merged.networkRecoveryProfile = "balanced";
162+
migrated = true;
163+
}
164+
if (legacyAutoRecoveryBitrate !== undefined) {
165+
migrated = true;
166+
}
167+
168+
migrated = this.migrateLegacyShortcutDefaults(merged) || migrated;
157169
migrated = this.enforceCompatibility(merged) || migrated;
158170

159171
const accentColorBefore = merged.appAccentColor;
@@ -318,8 +330,9 @@ export class SettingsManager {
318330
migrated = true;
319331
}
320332

321-
if (typeof settings.autoRecoveryBitrate !== "boolean") {
322-
settings.autoRecoveryBitrate = false;
333+
const networkRecoveryProfile = normalizeNetworkRecoveryProfile(settings.networkRecoveryProfile);
334+
if (settings.networkRecoveryProfile !== networkRecoveryProfile) {
335+
settings.networkRecoveryProfile = networkRecoveryProfile;
323336
migrated = true;
324337
}
325338

opennow-stable/src/renderer/src/App.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,10 @@ export function App(): JSX.Element {
288288
requestedCodec: settings.codec,
289289
targetFps: settings.fps,
290290
requestedMaxBitrateMbps: settings.maxBitrateMbps,
291-
resilientNetworkProfile: settings.autoRecoveryBitrate,
291+
resilientNetworkProfile: settings.networkRecoveryProfile,
292292
});
293293
}, [
294-
settings.autoRecoveryBitrate,
294+
settings.networkRecoveryProfile,
295295
settings.codec,
296296
settings.fps,
297297
settings.maxBitrateMbps,
@@ -3102,6 +3102,7 @@ export function App(): JSX.Element {
31023102
platformStore={streamingStore ?? undefined}
31033103
status={loadingStatus}
31043104
queuePosition={queuePosition}
3105+
queueEstimateKey={session?.zone ?? "default"}
31053106
adState={effectiveAdState}
31063107
activeAd={activeQueueAd}
31073108
activeAdMediaUrl={activeQueueAdMediaUrl}

opennow-stable/src/renderer/src/components/StreamLoading.tsx

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Cpu, Monitor, Radio, Wifi, X, XCircle } from "lucide-react";
2-
import { useEffect, useState } from "react";
2+
import { useEffect, useMemo, useRef, useState } from "react";
33
import type { JSX, Ref } from "react";
44
import { m, useReducedMotion } from "motion/react";
55
import {
@@ -15,6 +15,12 @@ import { QueueAdPreview, type QueueAdPlaybackEvent, type QueueAdPreviewHandle }
1515
import { LazyShaderAtmosphere } from "./LazyShaderAtmosphere";
1616
import { useTranslation } from "../i18n";
1717
import { getStatusPulseMotion } from "./MotionProvider";
18+
import {
19+
estimateQueueWait,
20+
formatQueueWaitEstimate,
21+
type QueuePositionObservation,
22+
} from "../utils/queueWaitEstimator";
23+
import "./StreamLoadingResilience.css";
1824

1925
type TranslateFunction = typeof import("../i18n").t;
2026

@@ -32,6 +38,7 @@ export interface StreamLoadingProps {
3238
status: "queue" | "setup" | "starting" | "connecting";
3339
queuePosition?: number;
3440
estimatedWait?: string;
41+
queueEstimateKey?: string;
3542
adState?: SessionAdState;
3643
activeAd?: SessionAdInfo;
3744
activeAdMediaUrl?: string;
@@ -127,6 +134,7 @@ export function StreamLoading({
127134
status,
128135
queuePosition,
129136
estimatedWait,
137+
queueEstimateKey = "default",
130138
adState,
131139
activeAd,
132140
activeAdMediaUrl,
@@ -141,21 +149,100 @@ export function StreamLoading({
141149
const statusPulseMotion = getStatusPulseMotion(reducedMotion);
142150
const [startedAt] = useState(() => Date.now());
143151
const [elapsedSeconds, setElapsedSeconds] = useState(0);
152+
const [activeQueueSeconds, setActiveQueueSeconds] = useState(0);
153+
const [queueObservations, setQueueObservations] = useState<QueuePositionObservation[]>([]);
154+
const [persistedQueueRate, setPersistedQueueRate] = useState<number | null>(null);
155+
const lastTimerAtRef = useRef(Date.now());
156+
const lastPersistedClearedRef = useRef(0);
144157
const hasError = Boolean(error);
145158
const statusMessage = getStatusMessage(t, status, queuePosition, adState, hasError);
146159
const platformName = platformStore ? getStoreDisplayName(platformStore) : "";
147160
const PlatformIcon = platformStore ? getStoreIconComponent(platformStore) : null;
148161
const adSummary = getAdSummary(t, adState);
149162
const cachedAdMediaUrl = activeAdMediaUrl ?? getPreferredSessionAdMediaUrl(activeAd);
150163
const activeStage = getActiveStage(status);
164+
const queuePaused = isSessionQueuePaused(adState);
165+
166+
useEffect(() => {
167+
const storageKey = `opennow.queue-rate.v1.${queueEstimateKey}`;
168+
try {
169+
const stored = JSON.parse(window.localStorage.getItem(storageKey) ?? "null") as {
170+
secondsPerPosition?: number;
171+
updatedAt?: number;
172+
} | null;
173+
const isFresh = stored?.updatedAt && Date.now() - stored.updatedAt < 14 * 24 * 60 * 60 * 1000;
174+
setPersistedQueueRate(isFresh && Number.isFinite(stored?.secondsPerPosition)
175+
? stored!.secondsPerPosition!
176+
: null);
177+
} catch {
178+
setPersistedQueueRate(null);
179+
}
180+
setQueueObservations([]);
181+
setActiveQueueSeconds(0);
182+
lastPersistedClearedRef.current = 0;
183+
}, [queueEstimateKey]);
151184

152185
useEffect(() => {
153186
if (hasError) return undefined;
187+
lastTimerAtRef.current = Date.now();
154188
const timer = window.setInterval(() => {
155-
setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000));
189+
const now = Date.now();
190+
const deltaSeconds = Math.max(0, (now - lastTimerAtRef.current) / 1000);
191+
lastTimerAtRef.current = now;
192+
setElapsedSeconds(Math.floor((now - startedAt) / 1000));
193+
if (status === "queue" && !queuePaused) {
194+
setActiveQueueSeconds((value) => value + deltaSeconds);
195+
}
156196
}, 1000);
157197
return () => window.clearInterval(timer);
158-
}, [hasError, startedAt]);
198+
}, [hasError, queuePaused, startedAt, status]);
199+
200+
useEffect(() => {
201+
if (status !== "queue" || !queuePosition || queuePosition < 1) return;
202+
setQueueObservations((current) => {
203+
const last = current.at(-1);
204+
if (last?.position === queuePosition) return current;
205+
const next = { activeElapsedSeconds: activeQueueSeconds, position: queuePosition };
206+
if (last && queuePosition > last.position) return [next];
207+
return [...current, next].slice(-40);
208+
});
209+
}, [activeQueueSeconds, queuePosition, status]);
210+
211+
const queueWaitEstimate = useMemo(() => {
212+
if (status !== "queue" || !queuePosition) return null;
213+
return estimateQueueWait({
214+
position: queuePosition,
215+
activeElapsedSeconds: activeQueueSeconds,
216+
observations: queueObservations,
217+
persistedSecondsPerPosition: persistedQueueRate,
218+
});
219+
}, [activeQueueSeconds, persistedQueueRate, queueObservations, queuePosition, status]);
220+
221+
useEffect(() => {
222+
if (!queueWaitEstimate || queueWaitEstimate.clearedPositions < 3) return;
223+
if (queueWaitEstimate.clearedPositions <= lastPersistedClearedRef.current) return;
224+
lastPersistedClearedRef.current = queueWaitEstimate.clearedPositions;
225+
const blendedRate = persistedQueueRate === null
226+
? queueWaitEstimate.secondsPerPosition
227+
: persistedQueueRate * 0.75 + queueWaitEstimate.secondsPerPosition * 0.25;
228+
setPersistedQueueRate(blendedRate);
229+
try {
230+
window.localStorage.setItem(`opennow.queue-rate.v1.${queueEstimateKey}`, JSON.stringify({
231+
secondsPerPosition: blendedRate,
232+
updatedAt: Date.now(),
233+
}));
234+
} catch {
235+
// The live estimate still works when storage is disabled.
236+
}
237+
}, [persistedQueueRate, queueEstimateKey, queueWaitEstimate]);
238+
239+
const queueWaitText = status === "queue"
240+
? estimatedWait
241+
? `~${estimatedWait}`
242+
: queueWaitEstimate
243+
? formatQueueWaitEstimate(queueWaitEstimate.remainingSeconds)
244+
: t("streamLoading.telemetry.calculating")
245+
: t("streamLoading.telemetry.cleared");
159246

160247
return (
161248
<div className={`sload${hasError ? " sload--error" : ""}`}>
@@ -234,7 +321,7 @@ export function StreamLoading({
234321
</div>
235322

236323
{!hasError && (
237-
<div className="sload-facts">
324+
<div className="sload-facts sload-facts--with-eta">
238325
<div className="sload-fact">
239326
<p>{t("streamLoading.telemetry.queuePosition")}</p>
240327
<strong>{status === "queue" && queuePosition ? `#${queuePosition}` : status === "queue" ? t("streamLoading.telemetry.calculating") : t("streamLoading.telemetry.cleared")}</strong>
@@ -243,6 +330,10 @@ export function StreamLoading({
243330
<p>{t("streamLoading.telemetry.elapsed")}</p>
244331
<strong>{formatWaitTime(elapsedSeconds)}</strong>
245332
</div>
333+
<div className="sload-fact">
334+
<p>{t("streamLoading.telemetry.estimatedLeft")}</p>
335+
<strong>{queueWaitText}</strong>
336+
</div>
246337
<div className="sload-fact">
247338
<p>{t("streamLoading.cozy.next")}</p>
248339
<strong>{getNextStep(t, status)}</strong>
@@ -267,10 +358,6 @@ export function StreamLoading({
267358
</div>
268359
)}
269360

270-
{status === "queue" && estimatedWait && !hasError && (
271-
<p className="sload-queue"><span className="sload-wait">~{estimatedWait}</span></p>
272-
)}
273-
274361
<div className="sload-actions">
275362
{hasError && error?.actionLabel && onErrorAction && (
276363
<button className="sload-cancel sload-cancel--primary" onClick={onErrorAction}>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.sload-facts.sload-facts--with-eta {
2+
grid-template-columns: repeat(4, minmax(0, 1fr));
3+
width: min(760px, 100%);
4+
}
5+
6+
@media (max-width: 620px) {
7+
.sload-facts.sload-facts--with-eta {
8+
grid-template-columns: 1fr;
9+
}
10+
}

opennow-stable/src/renderer/src/components/settings/sections/SettingsAboutSection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ export function SettingsAboutSection({
305305
// Keep the redacted raw value when the main process has no JSON capture yet.
306306
}
307307
const report = {
308-
schemaVersion: 3,
308+
schemaVersion: 4,
309309
generatedAt: new Date().toISOString(),
310310
environment: {
311311
platform: navigator.platform,
@@ -318,7 +318,7 @@ export function SettingsAboutSection({
318318
const url = URL.createObjectURL(blob);
319319
const a = document.createElement("a");
320320
a.href = url;
321-
a.download = `opennow-diagnostics-v3-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
321+
a.download = `opennow-diagnostics-v4-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
322322
document.body.appendChild(a);
323323
a.click();
324324
document.body.removeChild(a);

opennow-stable/src/renderer/src/components/settings/stream/StreamSessionOptions.tsx

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type JSX } from "react";
2-
import type { Settings } from "@shared/gfn";
2+
import type { NetworkRecoveryProfile, Settings } from "@shared/gfn";
33
import { useTranslation } from "../../../i18n";
4+
import { SelectDropdown } from "../../ui/SelectDropdown";
45
import type { SettingsChangeHandler } from "./streamSettingsTypes";
56

67
interface StreamSessionOptionsProps {
@@ -44,32 +45,33 @@ export function StreamSessionOptions({
4445
</span>
4546
</div>
4647

47-
<div className="settings-row settings-row--toggle">
48-
<div className="settings-row-top settings-row-top--compact">
49-
<label
50-
className="settings-label settings-label--wrap"
51-
htmlFor="settings-stream-auto-recovery-bitrate"
52-
>
53-
<span className="settings-label-title">
54-
{t("settings.video.autoRecoveryBitrate")}
55-
<span className="settings-inline-badge settings-inline-badge--beta">
56-
{t("app.labels.experimental")}
57-
</span>
48+
<div className="settings-row settings-row--simple">
49+
<label className="settings-label settings-label--wrap" htmlFor="settings-stream-network-recovery-profile">
50+
<span className="settings-label-title">
51+
{t("settings.video.networkRecoveryProfile")}
52+
<span className="settings-inline-badge settings-inline-badge--beta">
53+
{t("app.labels.experimental")}
5854
</span>
59-
</label>
60-
<label className="settings-toggle">
61-
<input
62-
id="settings-stream-auto-recovery-bitrate"
63-
type="checkbox"
64-
checked={settings.autoRecoveryBitrate}
65-
onChange={(event) => handleChange("autoRecoveryBitrate", event.target.checked)}
66-
/>
67-
<span className="settings-toggle-track" />
68-
</label>
55+
</span>
56+
<span className="settings-hint">
57+
{t("settings.video.networkRecoveryProfileHint")}
58+
</span>
59+
</label>
60+
<div className="settings-row-control">
61+
<SelectDropdown
62+
id="settings-stream-network-recovery-profile"
63+
value={settings.networkRecoveryProfile}
64+
options={[
65+
{ value: "current", label: t("settings.video.networkRecoveryCurrent") },
66+
{ value: "balanced", label: t("settings.video.networkRecoveryBalanced") },
67+
{ value: "survival", label: t("settings.video.networkRecoverySurvival") },
68+
]}
69+
onChange={(value) => handleChange(
70+
"networkRecoveryProfile",
71+
value as NetworkRecoveryProfile,
72+
)}
73+
/>
6974
</div>
70-
<span className="settings-subtle-hint">
71-
{t("settings.video.autoRecoveryBitrateHint")}
72-
</span>
7375
</div>
7476

7577
<div className="settings-row settings-row--toggle">

0 commit comments

Comments
 (0)