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
40 changes: 22 additions & 18 deletions opennow-stable/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut } from "./shortcuts";
import { useControllerNavigation } from "./controllerNavigation";
import { usePlaytime } from "./utils/usePlaytime";
import { createStreamDiagnosticsStore } from "./utils/streamDiagnosticsStore";

// UI Components
import { LoginScreen } from "./components/LoginScreen";
Expand Down Expand Up @@ -196,6 +197,8 @@ function defaultDiagnostics(): StreamDiagnostics {
inputQueuePeakBufferedBytes: 0,
inputQueueDropCount: 0,
inputQueueMaxSchedulingDelayMs: 0,
lagReason: "unknown",
lagReasonDetail: "Waiting for stream stats",
gpuType: "",
serverRegion: "",
decoderPressureActive: false,
Expand Down Expand Up @@ -403,11 +406,13 @@ export function App(): JSX.Element {
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [regions, setRegions] = useState<StreamRegion[]>([]);
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
const diagnosticsStoreRef = useRef<ReturnType<typeof createStreamDiagnosticsStore> | null>(null);
const diagnosticsStore =
diagnosticsStoreRef.current ?? (diagnosticsStoreRef.current = createStreamDiagnosticsStore(defaultDiagnostics()));

// Stream State
const [session, setSession] = useState<SessionInfo | null>(null);
const [streamStatus, setStreamStatus] = useState<StreamStatus>("idle");
const [diagnostics, setDiagnostics] = useState<StreamDiagnostics>(defaultDiagnostics());
const [showStatsOverlay, setShowStatsOverlay] = useState(true);
const [antiAfkEnabled, setAntiAfkEnabled] = useState(false);
const [escHoldReleaseIndicator, setEscHoldReleaseIndicator] = useState<{ visible: boolean; progress: number }>({
Expand Down Expand Up @@ -668,7 +673,7 @@ export function App(): JSX.Element {
setSessionElapsedSeconds(0);
setStreamWarning(null);
setEscHoldReleaseIndicator({ visible: false, progress: 0 });
setDiagnostics(defaultDiagnostics());
diagnosticsStore.set(defaultDiagnostics());

if (!options?.keepStreamingContext) {
setStreamingGame(null);
Expand All @@ -678,7 +683,7 @@ export function App(): JSX.Element {
if (!options?.keepLaunchError) {
setLaunchError(null);
}
}, []);
}, [diagnosticsStore]);

// Session ref sync
useEffect(() => {
Expand Down Expand Up @@ -1121,19 +1126,19 @@ export function App(): JSX.Element {
return;
}

const hasLiveFrames = diagnostics.framesDecoded > 0 || diagnostics.framesReceived > 0 || diagnostics.renderFps > 0;
if (!hasLiveFrames) {
return;
}
const evaluate = () => {
const snapshot = diagnosticsStore.getSnapshot();
const hasLiveFrames =
snapshot.framesDecoded > 0 || snapshot.framesReceived > 0 || snapshot.renderFps > 0;
if (hasLiveFrames) {
setSessionStartedAtMs(Date.now());
}
};

setSessionStartedAtMs(Date.now());
}, [
diagnostics.framesDecoded,
diagnostics.framesReceived,
diagnostics.renderFps,
sessionStartedAtMs,
streamStatus,
]);
evaluate();
const unsubscribe = diagnosticsStore.subscribe(evaluate);
return unsubscribe;
}, [sessionStartedAtMs, streamStatus]);

useEffect(() => {
if (!streamWarning) return;
Expand Down Expand Up @@ -1172,7 +1177,7 @@ export function App(): JSX.Element {
mouseSensitivity: settings.mouseSensitivity,
mouseAcceleration: settings.mouseAcceleration,
onLog: (line: string) => console.log(`[WebRTC] ${line}`),
onStats: (stats) => setDiagnostics(stats),
onStats: (stats) => diagnosticsStore.set(stats),
onEscHoldProgress: (visible, progress) => {
setEscHoldReleaseIndicator({ visible, progress });
},
Expand Down Expand Up @@ -2019,7 +2024,7 @@ export function App(): JSX.Element {
className={isSwitchingGame ? "sv--switching" : undefined}
videoRef={videoRef}
audioRef={audioRef}
stats={diagnostics}
diagnosticsStore={diagnosticsStore}
showStats={showStatsOverlay}
shortcuts={{
toggleStats: formatShortcutForDisplay(settings.shortcutToggleStats, isMac),
Expand All @@ -2031,7 +2036,6 @@ export function App(): JSX.Element {
}}
hideStreamButtons={settings.hideStreamButtons}
serverRegion={session?.serverIp}
connectedControllers={diagnostics.connectedGamepads}
antiAfkEnabled={antiAfkEnabled}
escHoldReleaseIndicator={escHoldReleaseIndicator}
exitPrompt={exitPrompt}
Expand Down
7 changes: 7 additions & 0 deletions opennow-stable/src/renderer/src/components/StatsOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ export function StatsOverlay({
</div>
)}

{stats.lagReason !== "stable" && stats.lagReason !== "unknown" && (
<div className="sovl-pill sovl-pill--warn" title={stats.lagReasonDetail}>
<AlertTriangle size={13} className="sovl-icon" />
<span className="sovl-val">{stats.lagReason}</span>
</div>
)}

{/* Controller Status */}
{connectedControllers > 0 && (
<div className="sovl-pill">
Expand Down
84 changes: 62 additions & 22 deletions opennow-stable/src/renderer/src/components/StreamView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import { createPortal } from "react-dom";
import type { JSX } from "react";
import { Maximize, Minimize, Gamepad2, Loader2, LogOut, Clock3, AlertTriangle, Mic, MicOff, Camera, ChevronLeft, ChevronRight, Save, Trash2, X, Circle, Square, Video, FolderOpen } from "lucide-react";
import SideBar from "./SideBar";
import type { StreamDiagnostics } from "../gfn/webrtcClient";
import type { StreamDiagnosticsStore } from "../utils/streamDiagnosticsStore";
import { useStreamDiagnosticsStore } from "../utils/streamDiagnosticsStore";
import type { StreamLagReason } from "../gfn/webrtcClient";
import { getStoreDisplayName, getStoreIconComponent } from "./GameCard";
import type { MicrophoneMode, ScreenshotEntry, RecordingEntry } from "@shared/gfn";
import { isShortcutMatch, normalizeShortcut } from "../shortcuts";

interface StreamViewProps {
videoRef: React.Ref<HTMLVideoElement>;
audioRef: React.Ref<HTMLAudioElement>;
stats: StreamDiagnostics;
diagnosticsStore: StreamDiagnosticsStore;
showStats: boolean;
shortcuts: {
toggleStats: string;
Expand All @@ -23,7 +25,6 @@ interface StreamViewProps {
};
hideStreamButtons?: boolean;
serverRegion?: string;
connectedControllers: number;
antiAfkEnabled: boolean;
escHoldReleaseIndicator: {
visible: boolean;
Expand Down Expand Up @@ -91,6 +92,38 @@ function getInputQueueColor(bufferedBytes: number, dropCount: number): string {
return "var(--success)";
}

function getLagReasonLabel(reason: StreamLagReason): string {
switch (reason) {
case "network":
return "Network";
case "decoder":
return "Decode";
case "input_backpressure":
return "Input";
case "render":
return "Render";
case "stable":
return "Stable";
default:
return "Unknown";
}
}

function getLagReasonColor(reason: StreamLagReason): string {
switch (reason) {
case "network":
case "decoder":
return "var(--error)";
case "input_backpressure":
case "render":
return "var(--warning)";
case "stable":
return "var(--success)";
default:
return "var(--ink-muted)";
}
}

function formatElapsed(totalSeconds: number): string {
const safe = Math.max(0, Math.floor(totalSeconds));
const hours = Math.floor(safe / 3600);
Expand Down Expand Up @@ -121,12 +154,6 @@ function formatWarningSeconds(value: number | undefined): string | null {
return `${seconds}s`;
}

/**
* Drives a canvas-based segmented level meter from a live MediaStreamTrack.
* Uses the Web Audio API AnalyserNode as a read-only tap — audio is never
* routed to the speaker. Runs a requestAnimationFrame loop while active;
* tears down fully (rAF cancelled, AudioContext closed) on deactivation.
*/
function useMicMeter(
canvasRef: React.RefObject<HTMLCanvasElement | null>,
track: MediaStreamTrack | null,
Expand All @@ -153,7 +180,7 @@ function useMicMeter(
let audioCtx: AudioContext | null = null;
let source: MediaStreamAudioSourceNode | null = null;
let analyser: AnalyserNode | null = null;
let raf = 0;
let tickTimer: number | null = null;
let dead = false;

const start = async () => {
Expand All @@ -176,21 +203,21 @@ function useMicMeter(
}

analyser = audioCtx.createAnalyser();
analyser.fftSize = 512;
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.65;
source = audioCtx.createMediaStreamSource(new MediaStream([track]));
source.connect(analyser);
// NOT connected to destination — monitoring only, no loopback

const buf = new Uint8Array(analyser.frequencyBinCount);
const SEG = 20;
const GAP = Math.round(2 * dpr);
const bw = (W - GAP * (SEG - 1)) / SEG;
const radius = Math.min(3 * dpr, bw / 2);
const frameIntervalMs = 33;

const frame = () => {
if (dead || !analyser) return;
raf = requestAnimationFrame(frame);
tickTimer = window.setTimeout(frame, frameIntervalMs);
analyser.getByteTimeDomainData(buf);

let sum = 0;
Expand Down Expand Up @@ -227,7 +254,9 @@ function useMicMeter(

return () => {
dead = true;
cancelAnimationFrame(raf);
if (tickTimer !== null) {
window.clearTimeout(tickTimer);
}
source?.disconnect();
analyser?.disconnect();
if (audioCtx && audioCtx.state !== "closed") {
Expand All @@ -243,11 +272,10 @@ function useMicMeter(
export function StreamView({
videoRef,
audioRef,
stats,
diagnosticsStore,
showStats,
shortcuts,
serverRegion,
connectedControllers,
antiAfkEnabled,
escHoldReleaseIndicator,
exitPrompt,
Expand Down Expand Up @@ -278,6 +306,7 @@ export function StreamView({
hideStreamButtons = false,
className,
}: StreamViewProps): JSX.Element {
const stats = useStreamDiagnosticsStore(diagnosticsStore);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showHints, setShowHints] = useState(true);
const [showSessionClock, setShowSessionClock] = useState(false);
Expand Down Expand Up @@ -1505,6 +1534,11 @@ export function StreamView({
<span className="sv-stats-chip" title="Input queue pressure (buffered bytes and delayed flush)">
IQ <span className="sv-stats-chip-val" style={{ color: inputQueueColor }}>{inputQueueText}</span>
</span>
{stats.lagReason !== "stable" && stats.lagReason !== "unknown" && (
<span className="sv-stats-chip" title={stats.lagReasonDetail}>
Lag <span className="sv-stats-chip-val" style={{ color: getLagReasonColor(stats.lagReason) }}>{getLagReasonLabel(stats.lagReason)}</span>
</span>
)}
</div>

<div className="sv-stats-foot">
Expand All @@ -1522,22 +1556,28 @@ export function StreamView({
{[stats.gpuType, regionLabel].filter(Boolean).join(" · ")}
</div>
)}

{stats.lagReason !== "stable" && stats.lagReason !== "unknown" && (
<div className="sv-stats-foot">
Lag source {getLagReasonLabel(stats.lagReason).toLowerCase()} · {stats.lagReasonDetail}
</div>
)}
</div>
)}

{/* Controller indicator (top-left) */}
{connectedControllers > 0 && !isConnecting && (
<div className="sv-ctrl" title={`${connectedControllers} controller(s) connected`}>
{stats.connectedGamepads > 0 && !isConnecting && (
<div className="sv-ctrl" title={`${stats.connectedGamepads} controller(s) connected`}>
<Gamepad2 size={18} />
{connectedControllers > 1 && <span className="sv-ctrl-n">{connectedControllers}</span>}
{stats.connectedGamepads > 1 && <span className="sv-ctrl-n">{stats.connectedGamepads}</span>}
</div>
)}

{/* Microphone toggle button (top-left, below controller badge when present) */}
{showMicIndicator && onToggleMicrophone && (
<button
type="button"
className={`sv-mic${connectedControllers > 0 || antiAfkEnabled ? " sv-mic--stacked" : ""}`}
className={`sv-mic${stats.connectedGamepads > 0 || antiAfkEnabled ? " sv-mic--stacked" : ""}`}
onClick={onToggleMicrophone}
data-enabled={micEnabled}
title={micEnabled ? "Mute microphone" : "Unmute microphone"}
Expand All @@ -1550,7 +1590,7 @@ export function StreamView({

{/* Anti-AFK indicator (top-left, below controller badge when present) */}
{antiAfkEnabled && !isConnecting && (
<div className={`sv-afk${connectedControllers > 0 ? " sv-afk--stacked" : ""}`} title="Anti-AFK is enabled">
<div className={`sv-afk${stats.connectedGamepads > 0 ? " sv-afk--stacked" : ""}`} title="Anti-AFK is enabled">
<span className="sv-afk-dot" />
<span className="sv-afk-label">ANTI-AFK ON</span>
</div>
Expand All @@ -1560,7 +1600,7 @@ export function StreamView({
{isRecording && !isConnecting && (
<div
className="sv-rec"
style={{ top: 14 + 42 * ([connectedControllers > 0, antiAfkEnabled, showMicIndicator].filter(Boolean).length) }}
style={{ top: 14 + 42 * ([stats.connectedGamepads > 0, antiAfkEnabled, showMicIndicator].filter(Boolean).length) }}
title={`Recording · ${formatElapsed(Math.round(recordingDurationMs / 1000))}`}
>
<span className="sv-rec-dot" />
Expand Down
Loading
Loading