From 4931cdd8182164a531e3e8eb3749ca7db6aa0beb Mon Sep 17 00:00:00 2001 From: Meganugger Date: Thu, 19 Feb 2026 13:28:40 +0000 Subject: [PATCH 1/5] feat(hdr): Implement end-to-end HDR streaming with cross-platform detection and robust fallbacks --- README.md | 6 +- opennow-stable/src/main/hdr/hdrDetect.ts | 86 +++++ opennow-stable/src/main/index.ts | 9 +- opennow-stable/src/main/settings.ts | 23 +- opennow-stable/src/preload/index.ts | 13 +- opennow-stable/src/renderer/src/App.tsx | 42 ++- .../renderer/src/components/SettingsPage.tsx | 67 +++- .../renderer/src/components/StreamView.tsx | 12 + .../src/renderer/src/gfn/hdrCapability.ts | 353 ++++++++++++++++++ opennow-stable/src/renderer/src/gfn/sdp.ts | 19 +- .../src/renderer/src/gfn/webrtcClient.ts | 75 +++- opennow-stable/src/shared/gfn.ts | 153 +++++++- opennow-stable/src/shared/ipc.ts | 9 +- opennow-stable/tests/hdr-logic.test.ts | 255 +++++++++++++ 14 files changed, 1101 insertions(+), 21 deletions(-) create mode 100644 opennow-stable/src/main/hdr/hdrDetect.ts create mode 100644 opennow-stable/src/renderer/src/gfn/hdrCapability.ts create mode 100644 opennow-stable/tests/hdr-logic.test.ts diff --git a/README.md b/README.md index 9ec1a328..000f9d5d 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ OpenNOW is a community-built desktop client for [NVIDIA GeForce NOW](https://www | Up to 4K | ✅ | ✅ | Configurable in settings | | 5K Resolution | ✅ | ✅ | Up to 5K@120fps | | 120+ FPS | ✅ | ✅ | Configurable: 30/60/120/144/240 | -| HDR Streaming | 📋 | ✅ | 10-bit color supported, full HDR pipeline planned | +| HDR Streaming | ✅ | ✅ | HDR10 (PQ/BT.2020) on Windows; best-effort macOS; SDR fallback Linux | | AI-Enhanced Stream Mode | ❌ | ✅ | NVIDIA Cinematic Quality — not available | | Adjustable Bitrate | ✅ | ✅ | Up to 200 Mbps in OpenNOW | | Color Quality (8/10-bit, 4:2:0/4:4:4) | ✅ | ✅ | Full chroma/bit-depth control | @@ -117,7 +117,7 @@ OpenNOW is a community-built desktop client for [NVIDIA GeForce NOW](https://www | 🔴 | ~~Microphone support~~ | ✅ Completed | Voice chat with mute/unmute toggle | | 🟡 | Instant replay | 📋 Planned | Clip and save gameplay moments | | 🟡 | Screenshots | 📋 Planned | Capture in-stream screenshots | -| 🟡 | HDR streaming pipeline | 📋 Planned | Full HDR end-to-end support | +| 🟡 | ~~HDR streaming pipeline~~ | ✅ Completed | HDR10 PQ/BT.2020 on Windows; best-effort macOS; auto-fallback | | 🟢 | Latency optimizations | 🚧 Ongoing | Input and render path improvements | | 🟢 | Platform stability | 🚧 Ongoing | Cross-platform bug fixes | @@ -126,7 +126,7 @@ OpenNOW is a community-built desktop client for [NVIDIA GeForce NOW](https://www ## Features **Streaming** -`H.264` `AV1` `H.265 (WIP)` · Up to 4K@240fps · Adjustable bitrate · 8/10-bit color · 4:2:0/4:4:4 chroma +`H.264` `AV1` `H.265 (WIP)` · Up to 4K@240fps · Adjustable bitrate · 8/10-bit color · 4:2:0/4:4:4 chroma · HDR10 (PQ/BT.2020) **Input** `Keyboard` `Mouse` `Gamepad ×4` · Mouse sensitivity · Clipboard paste diff --git a/opennow-stable/src/main/hdr/hdrDetect.ts b/opennow-stable/src/main/hdr/hdrDetect.ts new file mode 100644 index 00000000..835b9655 --- /dev/null +++ b/opennow-stable/src/main/hdr/hdrDetect.ts @@ -0,0 +1,86 @@ +import { screen } from "electron"; +import { execSync } from "node:child_process"; + +export interface OsHdrInfo { + osHdrEnabled: boolean; + platform: string; +} + +function detectWindows(): OsHdrInfo { + try { + const result = execSync( + 'powershell -NoProfile -Command "Get-ItemPropertyValue -Path \'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\AdvancedColor\' -Name AdvancedColorEnabled 2>$null"', + { encoding: "utf-8", timeout: 3000 }, + ).trim(); + + if (result === "1") { + return { osHdrEnabled: true, platform: "windows" }; + } + + const displays = screen.getAllDisplays(); + for (const display of displays) { + const colorSpace = (display as unknown as Record).colorSpace; + if (typeof colorSpace === "string" && colorSpace.toLowerCase().includes("hdr")) { + return { osHdrEnabled: true, platform: "windows" }; + } + if (display.colorDepth > 24) { + return { osHdrEnabled: true, platform: "windows" }; + } + } + + return { osHdrEnabled: false, platform: "windows" }; + } catch { + const displays = screen.getAllDisplays(); + const hasHighDepth = displays.some((d) => d.colorDepth > 24); + return { osHdrEnabled: hasHighDepth, platform: "windows" }; + } +} + +function detectMacOS(): OsHdrInfo { + try { + const result = execSync( + "system_profiler SPDisplaysDataType 2>/dev/null | grep -i 'HDR\\|EDR\\|XDR'", + { encoding: "utf-8", timeout: 3000 }, + ).trim(); + + if (result.length > 0) { + return { osHdrEnabled: true, platform: "macos" }; + } + return { osHdrEnabled: false, platform: "macos" }; + } catch { + return { osHdrEnabled: false, platform: "macos" }; + } +} + +function detectLinux(): OsHdrInfo { + try { + const sessionType = process.env.XDG_SESSION_TYPE ?? ""; + const isWayland = sessionType.toLowerCase() === "wayland"; + + if (!isWayland) { + return { osHdrEnabled: false, platform: "linux" }; + } + + const result = execSync( + "kscreen-doctor --outputs 2>/dev/null | grep -i 'hdr'", + { encoding: "utf-8", timeout: 3000 }, + ).trim(); + + return { osHdrEnabled: result.length > 0, platform: "linux" }; + } catch { + return { osHdrEnabled: false, platform: "linux" }; + } +} + +export function getOsHdrInfo(): OsHdrInfo { + switch (process.platform) { + case "win32": + return detectWindows(); + case "darwin": + return detectMacOS(); + case "linux": + return detectLinux(); + default: + return { osHdrEnabled: false, platform: "unknown" }; + } +} diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index c15821f8..a174eca3 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -44,7 +44,10 @@ import { import { fetchSubscription, fetchDynamicRegions } from "./gfn/subscription"; import { GfnSignalingClient } from "./gfn/signaling"; import { isSessionError, SessionError, GfnErrorCode } from "./gfn/errorCodes"; - +import { isSessionError, SessionError } from "./gfn/errorCodes"; +import { DiscordPresenceService } from "./discord/DiscordPresenceService"; +import { FlightProfileManager } from "./flight/FlightProfiles"; +import { getOsHdrInfo } from "./hdr/hdrDetect"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -501,7 +504,9 @@ function registerIpcHandlers(): void { }); // Save window size when it changes - mainWindow?.on("resize", () => { + ipcMain.handle(IPC_CHANNELS.HDR_GET_OS_INFO, () => { + return getOsHdrInfo(); + }); mainWindow?.on("resize", () => { if (mainWindow && !mainWindow.isDestroyed()) { const [width, height] = mainWindow.getSize(); settingsManager.set("windowWidth", width); diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 6f3226e9..631c5047 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -2,7 +2,8 @@ import { app } from "electron"; import { join } from "node:path"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import type { VideoCodec, ColorQuality, VideoAccelerationPreference, MicrophoneMode } from "@shared/gfn"; - +import type { VideoCodec, ColorQuality, VideoAccelerationPreference, FlightSlotConfig, HdrStreamingMode } from "@shared/gfn"; +import { defaultFlightSlots } from "@shared/gfn"; export interface Settings { /** Video resolution (e.g., "1920x1080") */ resolution: string; @@ -48,7 +49,18 @@ export interface Settings { windowWidth: number; /** Window height */ windowHeight: number; -} + /** Enable Discord Rich Presence */ + discordPresenceEnabled: boolean; + /** Discord Application Client ID */ + discordClientId: string; + /** Enable flight controls (HOTAS/joystick) */ + flightControlsEnabled: boolean; + /** Controller slot for flight controls (0-3) — legacy, kept for compat */ + flightControlsSlot: number; + /** Per-slot flight configurations */ + flightSlots: FlightSlotConfig[]; + /** HDR streaming mode: off, auto, on */ + hdrStreaming: HdrStreamingMode;} const defaultStopShortcut = "Ctrl+Shift+Q"; const defaultAntiAfkShortcut = "Ctrl+Shift+K"; @@ -79,7 +91,12 @@ const DEFAULT_SETTINGS: Settings = { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, -}; + discordPresenceEnabled: false, + discordClientId: "", + flightControlsEnabled: false, + flightControlsSlot: 3, + flightSlots: defaultFlightSlots(), + hdrStreaming: "off",}; export class SettingsManager { private settings: Settings; diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 155321c2..65f0246d 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -74,6 +74,17 @@ const api: PreloadApi = { ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_SET, key, value), resetSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_RESET), exportLogs: (format?: "text" | "json") => ipcRenderer.invoke(IPC_CHANNELS.LOGS_EXPORT, format), -}; + updateDiscordPresence: (state: DiscordPresencePayload) => + ipcRenderer.invoke(IPC_CHANNELS.DISCORD_UPDATE_PRESENCE, state), + clearDiscordPresence: () => ipcRenderer.invoke(IPC_CHANNELS.DISCORD_CLEAR_PRESENCE), + flightGetProfile: (vidPid: string, gameId?: string) => + ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_PROFILE, vidPid, gameId), + flightSetProfile: (profile: FlightProfile) => + ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_SET_PROFILE, profile), + flightDeleteProfile: (vidPid: string, gameId?: string) => + ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_DELETE_PROFILE, vidPid, gameId), + flightGetAllProfiles: () => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_ALL_PROFILES), + flightResetProfile: (vidPid: string) => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_RESET_PROFILE, vidPid), + getOsHdrInfo: () => ipcRenderer.invoke(IPC_CHANNELS.HDR_GET_OS_INFO),}; contextBridge.exposeInMainWorld("openNow", api); diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index f62a8bde..f7a68040 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -13,7 +13,10 @@ import type { SubscriptionInfo, StreamRegion, VideoCodec, -} from "@shared/gfn"; + DiscordPresencePayload, + FlightSlotConfig, + HdrCapability, + HdrStreamState,} from "@shared/gfn"; import { GfnWebRtcClient, @@ -22,7 +25,12 @@ import { } from "./gfn/webrtcClient"; import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut } from "./shortcuts"; import { useControllerNavigation } from "./controllerNavigation"; - +import { getFlightHidService } from "./flight/FlightHidService"; +import { + probeHdrCapability, + shouldEnableHdr, + buildInitialHdrState, +} from "./gfn/hdrCapability"; // UI Components import { LoginScreen } from "./components/LoginScreen"; import { Navbar } from "./components/Navbar"; @@ -119,6 +127,7 @@ function defaultDiagnostics(): StreamDiagnostics { inputQueuePeakBufferedBytes: 0, inputQueueDropCount: 0, inputQueueMaxSchedulingDelayMs: 0, + hdrState: buildInitialHdrState(), gpuType: "", serverRegion: "", micState: "uninitialized", @@ -286,7 +295,12 @@ export function App(): JSX.Element { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, - }); + discordPresenceEnabled: false, + discordClientId: "", + flightControlsEnabled: false, + flightControlsSlot: 3, + flightSlots: defaultFlightSlots(), + hdrStreaming: "off", }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [regions, setRegions] = useState([]); const [subscriptionInfo, setSubscriptionInfo] = useState(null); @@ -310,6 +324,8 @@ export function App(): JSX.Element { const [sessionStartedAtMs, setSessionStartedAtMs] = useState(null); const [sessionElapsedSeconds, setSessionElapsedSeconds] = useState(0); const [streamWarning, setStreamWarning] = useState(null); + const [hdrCapability, setHdrCapability] = useState(null); + const [hdrWarningShown, setHdrWarningShown] = useState(false); const handleControllerPageNavigate = useCallback((direction: "prev" | "next"): void => { if (!authSession || streamStatus !== "idle") { @@ -430,6 +446,13 @@ export function App(): JSX.Element { setSettings(loadedSettings); setSettingsLoaded(true); + probeHdrCapability().then((cap) => { + setHdrCapability(cap); + console.log("[HDR] Capability probe:", cap); + }).catch((e) => { + console.warn("[HDR] Capability probe failed:", e); + }); + // Load providers and session (force refresh on startup restore) setStartupStatusMessage("Restoring saved session and refreshing token..."); const [providerList, sessionResult] = await Promise.all([ @@ -716,12 +739,24 @@ export function App(): JSX.Element { } if (clientRef.current) { + let hdrEnabledForStream = false; + if (hdrCapability && settings.hdrStreaming !== "off") { + const decision = shouldEnableHdr(settings.hdrStreaming, hdrCapability, settings.colorQuality); + hdrEnabledForStream = decision.enable; + console.log(`[HDR] Stream decision: enable=${decision.enable}, reason=${decision.reason}`); + if (!decision.enable && settings.hdrStreaming === "on" && !hdrWarningShown) { + setHdrWarningShown(true); + console.warn(`[HDR] Falling back to SDR: ${decision.reason}`); + } + } + await clientRef.current.handleOffer(event.sdp, activeSession, { codec: settings.codec, colorQuality: settings.colorQuality, resolution: settings.resolution, fps: settings.fps, maxBitrateKbps: settings.maxBitrateMbps * 1000, + hdrEnabled: hdrEnabledForStream, }); setLaunchError(null); setStreamStatus("streaming"); @@ -1557,6 +1592,7 @@ export function App(): JSX.Element { settings={settings} regions={regions} onSettingChange={updateSetting} + hdrCapability={hdrCapability} /> )} diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index a19c115e..5370493e 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -1,6 +1,7 @@ import { Globe, Save, Check, Search, X, Loader, Zap, Mic, FileDown } from "lucide-react"; import { useState, useCallback, useMemo, useEffect, useRef } from "react"; -import type { JSX } from "react"; +import { Monitor, Volume2, Mouse, Settings2, Globe, Save, Check, Search, X, Loader, Cpu, Zap, MessageSquare, Joystick, Sun } from "lucide-react"; +import { useState, useCallback, useMemo, useEffect } from "react";import type { JSX } from "react"; import type { Settings, @@ -10,7 +11,8 @@ import type { EntitledResolution, VideoAccelerationPreference, MicrophoneMode, -} from "@shared/gfn"; + HdrStreamingMode, + HdrCapability,} from "@shared/gfn"; import { colorQualityRequiresHevc } from "@shared/gfn"; import { formatShortcutForDisplay, normalizeShortcut } from "../shortcuts"; @@ -18,6 +20,7 @@ interface SettingsPageProps { settings: Settings; regions: StreamRegion[]; onSettingChange: (key: K, value: Settings[K]) => void; + hdrCapability: HdrCapability | null; } const codecOptions: VideoCodec[] = ["H264", "H265", "AV1"]; @@ -432,7 +435,7 @@ async function testCodecSupport(): Promise { /* ── Component ────────────────────────────────────────────────────── */ -export function SettingsPage({ settings, regions, onSettingChange }: SettingsPageProps): JSX.Element { +export function SettingsPage({ settings, regions, onSettingChange, hdrCapability }: SettingsPageProps): JSX.Element { const [savedIndicator, setSavedIndicator] = useState(false); const [regionSearch, setRegionSearch] = useState(""); const [regionDropdownOpen, setRegionDropdownOpen] = useState(false); @@ -960,6 +963,64 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag + {/* ── HDR Streaming ──────────────────────────────── */} +
+
+ +

HDR Streaming

+
+
+
+ +
+ {(["off", "auto", "on"] as HdrStreamingMode[]).map((mode) => ( + + ))} +
+
+ + {settings.hdrStreaming === "auto" && ( + + HDR activates only when platform, display, OS, stream, and codec all confirm HDR support. Safest option. + + )} + {settings.hdrStreaming === "on" && ( + + Forces HDR attempt. Falls back to SDR with a warning if any condition fails. + + )} + + {settings.hdrStreaming !== "off" && !settings.colorQuality.startsWith("10bit") && ( + + HDR requires 10-bit color quality. Switch Color Quality to a 10-bit mode to enable HDR. + + )} + + {hdrCapability && ( +
+ +
+
Platform: {hdrCapability.platform} — {hdrCapability.platformSupport}
+
OS HDR: {hdrCapability.osHdrEnabled ? "Enabled" : "Disabled"}
+
Display HDR: {hdrCapability.displayHdrCapable ? "Capable" : "Not detected"}
+
10-bit Decode: {hdrCapability.decoder10BitCapable ? "Supported" : "Not available"}
+
HDR Color Space: {hdrCapability.hdrColorSpaceSupported ? "Supported" : "Not detected"}
+
+
+ )} + {!hdrCapability && settings.hdrStreaming !== "off" && ( + Probing HDR capability... + )} +
+
+ {/* ── Codec Diagnostics ──────────────────────────── */}
diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index ec4a4cf0..3fb79f31 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -348,6 +348,18 @@ export function StreamView({ {[stats.gpuType, regionLabel].filter(Boolean).join(" · ")}
)} + + {/* HDR Debug Panel */} +
+ HDR: + {stats.hdrState.status === "active" ? "On" : stats.hdrState.status === "fallback_sdr" ? "Fallback SDR" : stats.hdrState.status === "unsupported" ? "Unsupported" : "Off"} + + {" · "}{stats.hdrState.bitDepth}-bit + {" · "}{stats.hdrState.colorPrimaries}/{stats.hdrState.transferFunction} + {stats.hdrState.codecProfile && <> · {stats.hdrState.codecProfile}} + {stats.hdrState.overlayForcesSdr && <> · Overlay forces SDR} + {stats.hdrState.fallbackReason && <> · {stats.hdrState.fallbackReason}} +
)} diff --git a/opennow-stable/src/renderer/src/gfn/hdrCapability.ts b/opennow-stable/src/renderer/src/gfn/hdrCapability.ts new file mode 100644 index 00000000..6150cd7c --- /dev/null +++ b/opennow-stable/src/renderer/src/gfn/hdrCapability.ts @@ -0,0 +1,353 @@ +import type { + HdrCapability, + HdrPlatformSupport, + HdrStreamingMode, + HdrStreamState, + HdrActiveStatus, + ColorQuality, + VideoCodec, +} from "@shared/gfn"; + +const api = (window as unknown as { openNow: { getOsHdrInfo: () => Promise<{ osHdrEnabled: boolean; platform: string }> } }).openNow; + +function detectPlatform(): "windows" | "macos" | "linux" | "unknown" { + const ua = navigator.userAgent.toLowerCase(); + if (ua.includes("win")) return "windows"; + if (ua.includes("mac")) return "macos"; + if (ua.includes("linux")) return "linux"; + return "unknown"; +} + +function detectDisplayHdr(): { capable: boolean; notes: string[] } { + const notes: string[] = []; + + if (typeof window.matchMedia === "function") { + const hdrQuery = window.matchMedia("(dynamic-range: high)"); + if (hdrQuery.matches) { + notes.push("Display reports dynamic-range: high"); + return { capable: true, notes }; + } + notes.push("Display does not report dynamic-range: high"); + } else { + notes.push("matchMedia not available"); + } + + const colorDepth = window.screen?.colorDepth ?? 24; + if (colorDepth > 24) { + notes.push(`Screen color depth: ${colorDepth} (>24, possible HDR)`); + return { capable: true, notes }; + } + notes.push(`Screen color depth: ${colorDepth}`); + + return { capable: false, notes }; +} + +async function detectDecoder10Bit(): Promise<{ capable: boolean; notes: string[] }> { + const notes: string[] = []; + + if (typeof VideoDecoder === "undefined") { + notes.push("VideoDecoder API not available, assuming hardware decode handles 10-bit"); + return { capable: true, notes }; + } + + const configs = [ + { codec: "hev1.2.4.L120.B0", label: "HEVC Main10" }, + { codec: "av01.0.08M.10", label: "AV1 10-bit" }, + ]; + + for (const cfg of configs) { + try { + const result = await VideoDecoder.isConfigSupported({ + codec: cfg.codec, + hardwareAcceleration: "prefer-hardware", + codedWidth: 1920, + codedHeight: 1080, + }); + if (result.supported) { + notes.push(`${cfg.label}: hardware decode supported`); + return { capable: true, notes }; + } + } catch { + // ignore + } + + try { + const result = await VideoDecoder.isConfigSupported({ + codec: cfg.codec, + hardwareAcceleration: "no-preference", + codedWidth: 1920, + codedHeight: 1080, + }); + if (result.supported) { + notes.push(`${cfg.label}: software decode supported`); + return { capable: true, notes }; + } + } catch { + // ignore + } + notes.push(`${cfg.label}: not supported`); + } + + return { capable: false, notes }; +} + +function detectHdrColorSpace(): { supported: boolean; notes: string[] } { + const notes: string[] = []; + + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + + try { + const ctx = canvas.getContext("2d", { colorSpace: "display-p3" } as CanvasRenderingContext2DSettings); + if (ctx) { + notes.push("Canvas supports wide gamut (display-p3)"); + return { supported: true, notes }; + } + } catch { + // not supported + } + + try { + const gl = canvas.getContext("webgl2"); + if (gl) { + const ext = gl.getExtension("EXT_color_buffer_half_float"); + if (ext) { + notes.push("WebGL2 supports half-float color buffers"); + return { supported: true, notes }; + } + } + } catch { + // not supported + } + + notes.push("No wide-gamut canvas or HDR color buffer support detected"); + return { supported: false, notes }; +} + +function getPlatformSupport(platform: string, osHdrEnabled: boolean, displayCapable: boolean): HdrPlatformSupport { + if (platform === "windows") { + if (osHdrEnabled && displayCapable) return "supported"; + if (displayCapable) return "best_effort"; + return "unsupported"; + } + if (platform === "macos") { + if (displayCapable) return "best_effort"; + return "unsupported"; + } + if (platform === "linux") { + return "unsupported"; + } + return "unknown"; +} + +export async function probeHdrCapability(): Promise { + const platform = detectPlatform(); + const notes: string[] = []; + + let osHdrEnabled = false; + try { + const osInfo = await api.getOsHdrInfo(); + osHdrEnabled = osInfo.osHdrEnabled; + notes.push(`OS HDR: ${osHdrEnabled ? "enabled" : "disabled"} (${osInfo.platform})`); + } catch (e) { + notes.push(`OS HDR detection failed: ${e instanceof Error ? e.message : String(e)}`); + } + + const display = detectDisplayHdr(); + notes.push(...display.notes); + + const decoder = await detectDecoder10Bit(); + notes.push(...decoder.notes); + + const colorSpace = detectHdrColorSpace(); + notes.push(...colorSpace.notes); + + const platformSupport = getPlatformSupport(platform, osHdrEnabled, display.capable); + + return { + platform, + platformSupport, + osHdrEnabled, + displayHdrCapable: display.capable, + decoder10BitCapable: decoder.capable, + hdrColorSpaceSupported: colorSpace.supported, + notes, + }; +} + +export function shouldEnableHdr( + mode: HdrStreamingMode, + capability: HdrCapability, + colorQuality: ColorQuality, +): { enable: boolean; reason: string } { + if (mode === "off") { + return { enable: false, reason: "HDR disabled in settings" }; + } + + const is10Bit = colorQuality.startsWith("10bit"); + if (!is10Bit) { + return { enable: false, reason: "Color quality is 8-bit; 10-bit required for HDR" }; + } + + if (capability.platformSupport === "unsupported") { + if (mode === "on") { + return { enable: false, reason: `HDR unsupported on ${capability.platform}: ${capability.notes.slice(-1)[0] ?? "no HDR path"}` }; + } + return { enable: false, reason: "Platform does not support HDR" }; + } + + if (capability.platformSupport === "unknown") { + if (mode === "on") { + return { enable: false, reason: "HDR support unknown on this platform" }; + } + return { enable: false, reason: "HDR support could not be determined" }; + } + + if (!capability.decoder10BitCapable) { + return { enable: false, reason: "No 10-bit decoder available" }; + } + + if (mode === "auto") { + if (capability.platformSupport !== "supported") { + return { enable: false, reason: `Platform HDR is best-effort on ${capability.platform}; set HDR to "On" to attempt` }; + } + if (!capability.osHdrEnabled) { + return { enable: false, reason: "OS HDR is not enabled" }; + } + if (!capability.displayHdrCapable) { + return { enable: false, reason: "Display does not report HDR capability" }; + } + return { enable: true, reason: "All HDR conditions met (auto)" }; + } + + if (mode === "on") { + if (!capability.osHdrEnabled && capability.platform === "windows") { + return { enable: false, reason: "Windows OS HDR is disabled; enable HDR in Windows Display Settings" }; + } + if (!capability.displayHdrCapable) { + return { enable: false, reason: "Display does not report HDR capability" }; + } + return { enable: true, reason: "HDR forced on by user" }; + } + + return { enable: false, reason: "Unknown HDR mode" }; +} + +export function buildInitialHdrState(): HdrStreamState { + return { + status: "inactive", + bitDepth: 8, + colorPrimaries: "BT.709", + transferFunction: "SDR", + matrixCoefficients: "BT.709", + codecProfile: "", + overlayForcesSdr: false, + fallbackReason: null, + }; +} + +export function buildActiveHdrState( + codecProfile: string, + overlayForcesSdr: boolean, +): HdrStreamState { + if (overlayForcesSdr) { + return { + status: "fallback_sdr", + bitDepth: 10, + colorPrimaries: "BT.2020", + transferFunction: "PQ", + matrixCoefficients: "BT.2020", + codecProfile, + overlayForcesSdr: true, + fallbackReason: "Overlay compositing forces SDR conversion", + }; + } + return { + status: "active", + bitDepth: 10, + colorPrimaries: "BT.2020", + transferFunction: "PQ", + matrixCoefficients: "BT.2020", + codecProfile, + overlayForcesSdr: false, + fallbackReason: null, + }; +} + +export function buildFallbackHdrState(reason: string): HdrStreamState { + return { + status: "fallback_sdr", + bitDepth: 8, + colorPrimaries: "BT.709", + transferFunction: "SDR", + matrixCoefficients: "BT.709", + codecProfile: "", + overlayForcesSdr: false, + fallbackReason: reason, + }; +} + +export function buildUnsupportedHdrState(reason: string): HdrStreamState { + return { + status: "unsupported", + bitDepth: 8, + colorPrimaries: "BT.709", + transferFunction: "SDR", + matrixCoefficients: "BT.709", + codecProfile: "", + overlayForcesSdr: false, + fallbackReason: reason, + }; +} + +export function verifyHdrFromVideoTrack( + stats: RTCStatsReport | null, +): { verified: boolean; codecProfile: string; notes: string[] } { + const notes: string[] = []; + let codecProfile = ""; + let verified = false; + + if (!stats) { + notes.push("No RTC stats available"); + return { verified, codecProfile, notes }; + } + + stats.forEach((report) => { + if (report.type === "inbound-rtp" && report.kind === "video") { + const decoderImpl = (report as Record).decoderImplementation; + if (typeof decoderImpl === "string") { + notes.push(`Decoder: ${decoderImpl}`); + } + } + + if (report.type === "codec" && (report as Record).mimeType) { + const mime = (report as Record).mimeType as string; + const sdpFmtpLine = (report as Record).sdpFmtpLine as string | undefined; + notes.push(`Codec: ${mime}`); + + if (sdpFmtpLine) { + notes.push(`SDP fmtp: ${sdpFmtpLine}`); + + if (sdpFmtpLine.includes("profile-id=2")) { + codecProfile = "HEVC Main10"; + verified = true; + } else if (sdpFmtpLine.includes("profile=2")) { + codecProfile = "AV1 Main 10-bit"; + verified = true; + } else if (mime.includes("H265") || mime.includes("hevc")) { + codecProfile = "HEVC"; + if (sdpFmtpLine.includes("level-id=") && sdpFmtpLine.includes("profile-space=")) { + verified = true; + } + } + } + } + }); + + if (!verified) { + notes.push("Could not verify 10-bit HDR codec profile from RTC stats"); + } + + return { verified, codecProfile, notes }; +} diff --git a/opennow-stable/src/renderer/src/gfn/sdp.ts b/opennow-stable/src/renderer/src/gfn/sdp.ts index b1bf6c39..55519ec2 100644 --- a/opennow-stable/src/renderer/src/gfn/sdp.ts +++ b/opennow-stable/src/renderer/src/gfn/sdp.ts @@ -403,6 +403,7 @@ interface NvstParams { codec: VideoCodec; colorQuality: ColorQuality; credentials: IceCredentials; + hdrEnabled?: boolean; } /** @@ -595,9 +596,23 @@ export function buildNvstSdp(params: NvstParams): string { "a=video.maxNumReferenceFrames:4", "a=video.mapRtpTimestampsToFrames:1", "a=video.encoderCscMode:3", - "a=video.dynamicRangeMode:0", + `a=video.dynamicRangeMode:${params.hdrEnabled ? 1 : 0}`, `a=video.bitDepth:${bitDepth}`, - // Disable server-side scaling and prefilter (prevents resolution downgrade) + ); + + if (params.hdrEnabled) { + lines.push( + "a=video.colorPrimaries:9", + "a=video.transferCharacteristics:16", + "a=video.matrixCoefficients:9", + "a=video.colorRange:0", + "a=video.hdrMode:1", + "a=video.maxContentLightLevel:1000", + "a=video.maxFrameAverageLightLevel:400", + ); + } + + lines.push( `a=video.scalingFeature1:${isAv1 ? 1 : 0}`, "a=video.prefilterParams.prefilterModel:0", // Audio track (receive-only from server) diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index 3911ba3e..e2160255 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -5,7 +5,8 @@ import type { SessionInfo, VideoCodec, MicrophoneMode, -} from "@shared/gfn"; + FlightGamepadState, + HdrStreamState,} from "@shared/gfn"; import { InputEncoder, @@ -38,6 +39,7 @@ interface OfferSettings { resolution: string; fps: number; maxBitrateKbps: number; + hdrEnabled: boolean; } interface KeyStrokeSpec { @@ -162,6 +164,9 @@ export interface StreamDiagnostics { inputQueueDropCount: number; inputQueueMaxSchedulingDelayMs: number; + // HDR diagnostics + hdrState: HdrStreamState; + // System info gpuType: string; serverRegion: string; @@ -518,6 +523,7 @@ export class GfnWebRtcClient { private currentCodec = ""; private currentResolution = ""; private isHdr = false; + private hdrEnabledForSession = false; private videoDecodeStallWarningSent = false; private serverRegion = ""; private gpuType = ""; @@ -547,6 +553,16 @@ export class GfnWebRtcClient { inputQueuePeakBufferedBytes: 0, inputQueueDropCount: 0, inputQueueMaxSchedulingDelayMs: 0, + hdrState: { + status: "inactive", + bitDepth: 8, + colorPrimaries: "BT.709", + transferFunction: "SDR", + matrixCoefficients: "BT.709", + codecProfile: "", + overlayForcesSdr: false, + fallbackReason: null, + }, gpuType: "", serverRegion: "", micState: "uninitialized", @@ -657,6 +673,7 @@ export class GfnWebRtcClient { this.currentCodec = ""; this.currentResolution = ""; this.isHdr = false; + this.hdrEnabledForSession = false; this.videoDecodeStallWarningSent = false; this.diagnostics = { connectionState: this.pc?.connectionState ?? "closed", @@ -683,6 +700,16 @@ export class GfnWebRtcClient { inputQueuePeakBufferedBytes: 0, inputQueueDropCount: 0, inputQueueMaxSchedulingDelayMs: 0, + hdrState: { + status: "inactive", + bitDepth: 8, + colorPrimaries: "BT.709", + transferFunction: "SDR", + matrixCoefficients: "BT.709", + codecProfile: "", + overlayForcesSdr: false, + fallbackReason: null, + }, gpuType: this.gpuType, serverRegion: this.serverRegion, micState: this.micState, @@ -891,11 +918,52 @@ export class GfnWebRtcClient { // Check for HDR in SDP fmtp line this.isHdr = sdpFmtpLine.includes("transfer-characteristics=16") || + sdpFmtpLine.includes("profile-id=2") || sdpFmtpLine.includes("hdr") || sdpFmtpLine.includes("HDR"); this.diagnostics.codec = this.currentCodec; this.diagnostics.isHdr = this.isHdr; + + if (this.hdrEnabledForSession && this.isHdr) { + const profileStr = sdpFmtpLine.includes("profile-id=2") + ? `${this.currentCodec} Main10` + : sdpFmtpLine.includes("profile=2") + ? `${this.currentCodec} 10-bit` + : `${this.currentCodec}`; + this.diagnostics.hdrState = { + status: "active", + bitDepth: 10, + colorPrimaries: "BT.2020", + transferFunction: "PQ", + matrixCoefficients: "BT.2020", + codecProfile: profileStr, + overlayForcesSdr: false, + fallbackReason: null, + }; + } else if (this.hdrEnabledForSession && !this.isHdr) { + this.diagnostics.hdrState = { + status: "fallback_sdr", + bitDepth: 8, + colorPrimaries: "BT.709", + transferFunction: "SDR", + matrixCoefficients: "BT.709", + codecProfile: this.currentCodec, + overlayForcesSdr: false, + fallbackReason: "Stream did not negotiate HDR (no PQ/BT.2020 in codec params)", + }; + } else { + this.diagnostics.hdrState = { + status: "inactive", + bitDepth: sdpFmtpLine.includes("profile-id=2") ? 10 : 8, + colorPrimaries: "BT.709", + transferFunction: "SDR", + matrixCoefficients: "BT.709", + codecProfile: this.currentCodec, + overlayForcesSdr: false, + fallbackReason: null, + }; + } } // Get video dimensions from track settings if available @@ -2424,8 +2492,10 @@ export class GfnWebRtcClient { this.log(`Signaling: server=${session.signalingServer}, url=${session.signalingUrl}`); this.log(`MediaConnectionInfo: ${session.mediaConnectionInfo ? `ip=${session.mediaConnectionInfo.ip}, port=${session.mediaConnectionInfo.port}` : "NONE"}`); this.log( - `Settings: codec=${settings.codec}, colorQuality=${settings.colorQuality}, resolution=${settings.resolution}, fps=${settings.fps}, maxBitrate=${settings.maxBitrateKbps}kbps`, + `Settings: codec=${settings.codec}, colorQuality=${settings.colorQuality}, resolution=${settings.resolution}, fps=${settings.fps}, maxBitrate=${settings.maxBitrateKbps}kbps, hdr=${settings.hdrEnabled}`, ); + + this.hdrEnabledForSession = settings.hdrEnabled; this.log(`ICE servers: ${session.iceServers.length} (${session.iceServers.map(s => s.urls.join(",")).join(" | ")})`); this.log(`Offer SDP length: ${offerSdp.length} chars`); // Log full offer SDP for ICE debugging @@ -2704,6 +2774,7 @@ export class GfnWebRtcClient { codec: effectiveCodec, colorQuality: settings.colorQuality, credentials, + hdrEnabled: settings.hdrEnabled, }); await window.openNow.sendAnswer({ diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index eaa823ba..75a863a8 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -1,6 +1,33 @@ export type VideoCodec = "H264" | "H265" | "AV1"; export type VideoAccelerationPreference = "auto" | "hardware" | "software"; +export type HdrStreamingMode = "off" | "auto" | "on"; + +export type HdrPlatformSupport = "supported" | "best_effort" | "unsupported" | "unknown"; + +export type HdrActiveStatus = "active" | "inactive" | "unsupported" | "fallback_sdr"; + +export interface HdrCapability { + platform: "windows" | "macos" | "linux" | "unknown"; + platformSupport: HdrPlatformSupport; + osHdrEnabled: boolean; + displayHdrCapable: boolean; + decoder10BitCapable: boolean; + hdrColorSpaceSupported: boolean; + notes: string[]; +} + +export interface HdrStreamState { + status: HdrActiveStatus; + bitDepth: 8 | 10; + colorPrimaries: "BT.709" | "BT.2020" | "unknown"; + transferFunction: "SDR" | "PQ" | "HLG" | "unknown"; + matrixCoefficients: "BT.709" | "BT.2020" | "unknown"; + codecProfile: string; + overlayForcesSdr: boolean; + fallbackReason: string | null; +} + /** Color quality (bit depth + chroma subsampling), matching Rust ColorQuality enum */ export type ColorQuality = "8bit_420" | "8bit_444" | "10bit_420" | "10bit_444"; @@ -49,7 +76,12 @@ export interface Settings { sessionClockShowDurationSeconds: number; windowWidth: number; windowHeight: number; -} + discordPresenceEnabled: boolean; + discordClientId: string; + flightControlsEnabled: boolean; + flightControlsSlot: number; + flightSlots: FlightSlotConfig[]; + hdrStreaming: HdrStreamingMode;} export interface LoginProvider { idpId: string; @@ -332,4 +364,123 @@ export interface OpenNowApi { resetSettings(): Promise; /** Export logs in redacted format */ exportLogs(format?: "text" | "json"): Promise; + updateDiscordPresence(state: DiscordPresencePayload): Promise; + clearDiscordPresence(): Promise; + flightGetProfile(vidPid: string, gameId?: string): Promise; + flightSetProfile(profile: FlightProfile): Promise; + flightDeleteProfile(vidPid: string, gameId?: string): Promise; + flightGetAllProfiles(): Promise; + flightResetProfile(vidPid: string): Promise; + getOsHdrInfo(): Promise<{ osHdrEnabled: boolean; platform: string }>; +} + +export type FlightAxisTarget = + | "leftStickX" + | "leftStickY" + | "rightStickX" + | "rightStickY" + | "leftTrigger" + | "rightTrigger"; + +export type FlightSensitivityCurve = "linear" | "expo"; + +export interface FlightHidAxisSource { + byteOffset: number; + byteCount: 1 | 2; + littleEndian: boolean; + unsigned: boolean; + rangeMin: number; + rangeMax: number; +} + +export interface FlightHidButtonSource { + byteOffset: number; + bitIndex: number; +} + +export interface FlightHidHatSource { + byteOffset: number; + bitOffset: number; + bitCount: 4 | 8; + centerValue: number; +} + +export interface FlightHidReportLayout { + skipReportId: boolean; + reportLength: number; + axes: FlightHidAxisSource[]; + buttons: FlightHidButtonSource[]; + hat?: FlightHidHatSource; +} + +export interface FlightAxisMapping { + sourceIndex: number; + target: FlightAxisTarget; + inverted: boolean; + deadzone: number; + sensitivity: number; + curve: FlightSensitivityCurve; +} + +export interface FlightButtonMapping { + sourceIndex: number; + targetButton: number; +} + +export interface FlightProfile { + name: string; + vidPid: string; + deviceName: string; + axisMappings: FlightAxisMapping[]; + buttonMappings: FlightButtonMapping[]; + reportLayout?: FlightHidReportLayout; + gameId?: string; +} + +export interface FlightSlotConfig { + enabled: boolean; + deviceKey: string | null; + vidPid: string | null; + deviceName: string | null; +} + +export function makeDeviceKey(vendorId: number, productId: number, name: string): string { + const vid = vendorId.toString(16).toUpperCase().padStart(4, "0"); + const pid = productId.toString(16).toUpperCase().padStart(4, "0"); + return `${vid}:${pid}:${name}`; } + +export function defaultFlightSlots(): FlightSlotConfig[] { + return [0, 1, 2, 3].map(() => ({ enabled: false, deviceKey: null, vidPid: null, deviceName: null })); +} + +export interface FlightControlsState { + connected: boolean; + deviceName: string; + axes: number[]; + buttons: boolean[]; + hatSwitch: number; + rawBytes: number[]; +} + +export interface FlightGamepadState { + controllerId: number; + buttons: number; + leftTrigger: number; + rightTrigger: number; + leftStickX: number; + leftStickY: number; + rightStickX: number; + rightStickY: number; + connected: boolean; +} + +export interface DiscordPresencePayload { + type: "idle" | "queue" | "streaming"; + gameName?: string; + resolution?: string; + fps?: number; + bitrateMbps?: number; + region?: string; + startTimestamp?: number; + queuePosition?: number;} diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index 454dc467..db93d896 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -27,6 +27,13 @@ export const IPC_CHANNELS = { SETTINGS_RESET: "settings:reset", LOGS_EXPORT: "logs:export", LOGS_GET_RENDERER: "logs:get-renderer", -} as const; + DISCORD_UPDATE_PRESENCE: "discord:update-presence", + DISCORD_CLEAR_PRESENCE: "discord:clear-presence", + FLIGHT_GET_PROFILE: "flight:get-profile", + FLIGHT_SET_PROFILE: "flight:set-profile", + FLIGHT_DELETE_PROFILE: "flight:delete-profile", + FLIGHT_GET_ALL_PROFILES: "flight:get-all-profiles", + FLIGHT_RESET_PROFILE: "flight:reset-profile", + HDR_GET_OS_INFO: "hdr:get-os-info",} as const; export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]; diff --git a/opennow-stable/tests/hdr-logic.test.ts b/opennow-stable/tests/hdr-logic.test.ts new file mode 100644 index 00000000..06e7e7e0 --- /dev/null +++ b/opennow-stable/tests/hdr-logic.test.ts @@ -0,0 +1,255 @@ +/** + * Unit tests for HDR capability detection and settings logic. + * + * Run: npx tsx tests/hdr-logic.test.ts + * + * Tests the pure functions shouldEnableHdr and buildInitialHdrState + * without requiring a browser environment. + */ + +// ── inline types matching @shared/gfn ────────────────────────────── + +type HdrStreamingMode = "off" | "auto" | "on"; +type HdrPlatformSupport = "supported" | "best_effort" | "unsupported" | "unknown"; +type HdrActiveStatus = "active" | "inactive" | "unsupported" | "fallback_sdr"; +type ColorQuality = "8bit_420" | "8bit_444" | "10bit_420" | "10bit_444"; + +interface HdrCapability { + platform: "windows" | "macos" | "linux" | "unknown"; + platformSupport: HdrPlatformSupport; + osHdrEnabled: boolean; + displayHdrCapable: boolean; + decoder10BitCapable: boolean; + hdrColorSpaceSupported: boolean; + notes: string[]; +} + +interface HdrStreamState { + status: HdrActiveStatus; + bitDepth: 8 | 10; + colorPrimaries: "BT.709" | "BT.2020" | "unknown"; + transferFunction: "SDR" | "PQ" | "HLG" | "unknown"; + matrixCoefficients: "BT.709" | "BT.2020" | "unknown"; + codecProfile: string; + overlayForcesSdr: boolean; + fallbackReason: string | null; +} + +// ── reimplemented logic (mirrors hdrCapability.ts pure functions) ─── + +function shouldEnableHdr( + mode: HdrStreamingMode, + capability: HdrCapability, + colorQuality: ColorQuality, +): { enable: boolean; reason: string } { + if (mode === "off") { + return { enable: false, reason: "HDR disabled in settings" }; + } + + const is10Bit = colorQuality.startsWith("10bit"); + if (!is10Bit) { + return { enable: false, reason: "Color quality is 8-bit; 10-bit required for HDR" }; + } + + if (capability.platformSupport === "unsupported") { + if (mode === "on") { + return { enable: false, reason: `HDR unsupported on ${capability.platform}: ${capability.notes.slice(-1)[0] ?? "no HDR path"}` }; + } + return { enable: false, reason: "Platform does not support HDR" }; + } + + if (capability.platformSupport === "unknown") { + if (mode === "on") { + return { enable: false, reason: "HDR support unknown on this platform" }; + } + return { enable: false, reason: "HDR support could not be determined" }; + } + + if (!capability.decoder10BitCapable) { + return { enable: false, reason: "No 10-bit decoder available" }; + } + + if (mode === "auto") { + if (capability.platformSupport !== "supported") { + return { enable: false, reason: `Platform HDR is best-effort on ${capability.platform}; set HDR to "On" to attempt` }; + } + if (!capability.osHdrEnabled) { + return { enable: false, reason: "OS HDR is not enabled" }; + } + if (!capability.displayHdrCapable) { + return { enable: false, reason: "Display does not report HDR capability" }; + } + return { enable: true, reason: "All HDR conditions met (auto)" }; + } + + if (mode === "on") { + if (!capability.osHdrEnabled && capability.platform === "windows") { + return { enable: false, reason: "Windows OS HDR is disabled; enable HDR in Windows Display Settings" }; + } + if (!capability.displayHdrCapable) { + return { enable: false, reason: "Display does not report HDR capability" }; + } + return { enable: true, reason: "HDR forced on by user" }; + } + + return { enable: false, reason: "Unknown HDR mode" }; +} + +function buildInitialHdrState(): HdrStreamState { + return { + status: "inactive", + bitDepth: 8, + colorPrimaries: "BT.709", + transferFunction: "SDR", + matrixCoefficients: "BT.709", + codecProfile: "", + overlayForcesSdr: false, + fallbackReason: null, + }; +} + +// ── Test runner ───────────────────────────────────────────────────── + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, name: string): void { + if (condition) { + passed++; + console.log(` ✓ ${name}`); + } else { + failed++; + console.error(` ✗ ${name}`); + } +} + +function makeCapability(overrides: Partial = {}): HdrCapability { + return { + platform: "windows", + platformSupport: "supported", + osHdrEnabled: true, + displayHdrCapable: true, + decoder10BitCapable: true, + hdrColorSpaceSupported: true, + notes: [], + ...overrides, + }; +} + +// ── Tests ─────────────────────────────────────────────────────────── + +console.log("\n=== shouldEnableHdr ===\n"); + +console.log("mode=off:"); +{ + const r = shouldEnableHdr("off", makeCapability(), "10bit_420"); + assert(!r.enable, "off mode always disables HDR"); + assert(r.reason.includes("disabled"), "reason mentions disabled"); +} + +console.log("\nmode=auto, full support:"); +{ + const r = shouldEnableHdr("auto", makeCapability(), "10bit_420"); + assert(r.enable, "auto enables when all conditions met"); + assert(r.reason.includes("auto"), "reason mentions auto"); +} + +console.log("\nmode=auto, 8-bit color:"); +{ + const r = shouldEnableHdr("auto", makeCapability(), "8bit_420"); + assert(!r.enable, "auto rejects 8-bit color"); + assert(r.reason.includes("8-bit"), "reason mentions 8-bit"); +} + +console.log("\nmode=auto, OS HDR disabled:"); +{ + const r = shouldEnableHdr("auto", makeCapability({ osHdrEnabled: false }), "10bit_420"); + assert(!r.enable, "auto rejects when OS HDR disabled"); + assert(r.reason.includes("OS HDR"), "reason mentions OS HDR"); +} + +console.log("\nmode=auto, display not HDR capable:"); +{ + const r = shouldEnableHdr("auto", makeCapability({ displayHdrCapable: false }), "10bit_420"); + assert(!r.enable, "auto rejects when display not HDR"); + assert(r.reason.includes("Display"), "reason mentions display"); +} + +console.log("\nmode=auto, best_effort platform (macOS):"); +{ + const r = shouldEnableHdr("auto", makeCapability({ platform: "macos", platformSupport: "best_effort" }), "10bit_420"); + assert(!r.enable, "auto rejects best_effort platform"); + assert(r.reason.includes("best-effort"), "reason mentions best-effort"); +} + +console.log("\nmode=on, full support:"); +{ + const r = shouldEnableHdr("on", makeCapability(), "10bit_420"); + assert(r.enable, "on enables when conditions met"); + assert(r.reason.includes("forced"), "reason mentions forced"); +} + +console.log("\nmode=on, best_effort platform with display HDR:"); +{ + const r = shouldEnableHdr("on", makeCapability({ platform: "macos", platformSupport: "best_effort" }), "10bit_420"); + assert(r.enable, "on enables on best_effort with display HDR"); +} + +console.log("\nmode=on, Windows OS HDR disabled:"); +{ + const r = shouldEnableHdr("on", makeCapability({ osHdrEnabled: false, platform: "windows" }), "10bit_420"); + assert(!r.enable, "on rejects when Windows OS HDR disabled"); + assert(r.reason.includes("Windows"), "reason mentions Windows"); +} + +console.log("\nmode=on, display not HDR capable:"); +{ + const r = shouldEnableHdr("on", makeCapability({ displayHdrCapable: false }), "10bit_420"); + assert(!r.enable, "on rejects when display not HDR"); +} + +console.log("\nmode=auto, unsupported platform (linux):"); +{ + const r = shouldEnableHdr("auto", makeCapability({ platform: "linux", platformSupport: "unsupported" }), "10bit_420"); + assert(!r.enable, "auto rejects unsupported platform"); + assert(r.reason.includes("does not support"), "reason mentions unsupported"); +} + +console.log("\nmode=on, unsupported platform (linux):"); +{ + const r = shouldEnableHdr("on", makeCapability({ platform: "linux", platformSupport: "unsupported" }), "10bit_420"); + assert(!r.enable, "on rejects unsupported platform"); + assert(r.reason.includes("unsupported"), "reason mentions unsupported"); +} + +console.log("\nmode=on, no 10-bit decoder:"); +{ + const r = shouldEnableHdr("on", makeCapability({ decoder10BitCapable: false }), "10bit_420"); + assert(!r.enable, "on rejects when no 10-bit decoder"); + assert(r.reason.includes("decoder"), "reason mentions decoder"); +} + +console.log("\nmode=auto, unknown platform:"); +{ + const r = shouldEnableHdr("auto", makeCapability({ platform: "unknown", platformSupport: "unknown" }), "10bit_420"); + assert(!r.enable, "auto rejects unknown platform"); +} + +console.log("\n=== buildInitialHdrState ===\n"); + +{ + const state = buildInitialHdrState(); + assert(state.status === "inactive", "initial status is inactive"); + assert(state.bitDepth === 8, "initial bit depth is 8"); + assert(state.colorPrimaries === "BT.709", "initial primaries are BT.709"); + assert(state.transferFunction === "SDR", "initial transfer is SDR"); + assert(state.matrixCoefficients === "BT.709", "initial matrix is BT.709"); + assert(state.codecProfile === "", "initial codec profile is empty"); + assert(!state.overlayForcesSdr, "initial overlay doesn't force SDR"); + assert(state.fallbackReason === null, "initial fallback reason is null"); +} + +// ── Summary ───────────────────────────────────────────────────────── + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); +process.exit(failed > 0 ? 1 : 0); From f2b06f15ff08a9c033ccfea73dec43294114b5c6 Mon Sep 17 00:00:00 2001 From: Meganugger Date: Thu, 19 Feb 2026 14:58:26 +0000 Subject: [PATCH 2/5] Update repository --- opennow-stable/src/main/gfn/cloudmatch.ts | 17 ++-- opennow-stable/src/renderer/src/App.tsx | 16 ++++ .../src/renderer/src/gfn/webrtcClient.ts | 79 +++++++++++++++++-- opennow-stable/src/shared/gfn.ts | 1 + 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/opennow-stable/src/main/gfn/cloudmatch.ts b/opennow-stable/src/main/gfn/cloudmatch.ts index 3902fae0..9ffb0485 100644 --- a/opennow-stable/src/main/gfn/cloudmatch.ts +++ b/opennow-stable/src/main/gfn/cloudmatch.ts @@ -325,12 +325,7 @@ function timezoneOffsetMs(): number { function buildSessionRequestBody(input: SessionCreateRequest): CloudMatchRequest { const { width, height } = parseResolution(input.settings.resolution); const cq = input.settings.colorQuality; - // IMPORTANT: hdrEnabled is a SEPARATE toggle from color quality. - // The Rust reference (cloudmatch.rs) uses settings.hdr_enabled independently. - // 10-bit color depth does NOT mean HDR — you can have 10-bit SDR. - // Conflating them caused the server to set up an HDR pipeline, which - // dynamically downscaled resolution to ~540p. - const hdrEnabled = false; // No HDR toggle implemented yet; hardcode off like claim body + const hdrEnabled = input.settings.hdrEnabled === true && cq.startsWith("10bit"); const bitDepth = colorQualityBitDepth(cq); const chromaFormat = colorQualityChromaFormat(cq); const accountLinked = input.accountLinked ?? true; @@ -744,9 +739,7 @@ function buildClaimRequestBody(sessionId: string, appId: string, settings: Strea const { width, height } = parseResolution(settings.resolution); const cq = settings.colorQuality; const chromaFormat = colorQualityChromaFormat(cq); - // Claim/resume uses SDR mode (matching Rust: hdr_enabled defaults false for claims). - // HDR is only negotiated on the initial session create. - const hdrEnabled = false; + const hdrEnabled = settings.hdrEnabled === true && cq.startsWith("10bit"); const deviceId = crypto.randomUUID(); const subSessionId = crypto.randomUUID(); const timezoneMs = timezoneOffsetMs(); @@ -822,14 +815,18 @@ function buildClaimRequestBody(sessionId: string, appId: string, settings: Strea userAge: 26, requestedStreamingFeatures: { reflex: settings.fps >= 120, - bitDepth: 0, + bitDepth: hdrEnabled ? colorQualityBitDepth(cq) : 0, cloudGsync: false, enabledL4S: false, + mouseMovementFlags: 0, + trueHdr: hdrEnabled, profile: 0, fallbackToLogicalResolution: false, chromaFormat, prefilterMode: 0, hudStreamingMode: 0, + sdrColorSpace: 2, + hdrColorSpace: hdrEnabled ? 4 : 0, }, }, metaData: [], diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index f7a68040..32fc1742 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -894,6 +894,12 @@ export function App(): JSX.Element { throw new Error("Active session is missing server address. Start the game again to create a new session."); } + let hdrEnabledForClaim = false; + if (hdrCapability && settings.hdrStreaming !== "off") { + const decision = shouldEnableHdr(settings.hdrStreaming, hdrCapability, settings.colorQuality); + hdrEnabledForClaim = decision.enable; + } + const claimed = await window.openNow.claimSession({ token, streamingBaseUrl: effectiveStreamingBaseUrl, @@ -905,6 +911,7 @@ export function App(): JSX.Element { maxBitrateMbps: settings.maxBitrateMbps, codec: settings.codec, colorQuality: settings.colorQuality, + hdrEnabled: hdrEnabledForClaim, }, }); @@ -1002,6 +1009,14 @@ export function App(): JSX.Element { } } + // Determine HDR for session creation (server must set up HDR pipeline) + let hdrEnabledForCreate = false; + if (hdrCapability && settings.hdrStreaming !== "off") { + const decision = shouldEnableHdr(settings.hdrStreaming, hdrCapability, settings.colorQuality); + hdrEnabledForCreate = decision.enable; + console.log(`[HDR] Create-session decision: enable=${decision.enable}, reason=${decision.reason}`); + } + // Create new session const newSession = await window.openNow.createSession({ token: token || undefined, @@ -1016,6 +1031,7 @@ export function App(): JSX.Element { maxBitrateMbps: settings.maxBitrateMbps, codec: settings.codec, colorQuality: settings.colorQuality, + hdrEnabled: hdrEnabledForCreate, }, }); diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index e2160255..b795186a 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -621,6 +621,44 @@ export class GfnWebRtcClient { this.log("Video element configured for low-latency playback"); } + /** + * Configure the video element for HDR passthrough rendering. + * + * Chromium/Electron will pass through HDR10 PQ content when: + * - The OS compositor has HDR enabled (Windows HDR toggle, macOS EDR) + * - The decoded stream carries 10-bit PQ/BT.2020 metadata + * - No CSS filter/transform clamps the output to sRGB + * + * This method ensures the video element does not interfere with the + * native HDR rendering path. It also sets the `colorSpace` attribute + * on the element when supported (Chromium 104+). + */ + private configureVideoElementForHdr(video: HTMLVideoElement, hdrEnabled: boolean): void { + if (hdrEnabled) { + video.style.removeProperty("filter"); + video.style.removeProperty("-webkit-filter"); + const anyVideo = video as unknown as Record; + if (typeof anyVideo.colorSpace === "string" || anyVideo.colorSpace === undefined) { + try { + anyVideo.colorSpace = "rec2100-pq"; + this.log("Video element colorSpace set to rec2100-pq"); + } catch { + this.log("Video element colorSpace attribute not supported, relying on OS HDR passthrough"); + } + } + this.log("Video element configured for HDR passthrough"); + } else { + const anyVideo = video as unknown as Record; + if (typeof anyVideo.colorSpace === "string" && anyVideo.colorSpace !== "") { + try { + anyVideo.colorSpace = ""; + } catch { + // ignore + } + } + } + } + /** * Configure an RTCRtpReceiver for minimum jitter buffer delay. * @@ -916,11 +954,12 @@ export class GfnWebRtcClient { this.currentCodec = normalizeCodecName(codecId); } - // Check for HDR in SDP fmtp line - this.isHdr = sdpFmtpLine.includes("transfer-characteristics=16") || - sdpFmtpLine.includes("profile-id=2") || - sdpFmtpLine.includes("hdr") || - sdpFmtpLine.includes("HDR"); + // Check for HDR indicators in SDP fmtp line + const has10BitProfile = sdpFmtpLine.includes("profile-id=2") || sdpFmtpLine.includes("profile=2"); + const hasPqTransfer = sdpFmtpLine.includes("transfer-characteristics=16"); + const hasBt2020Primaries = sdpFmtpLine.includes("colour-primaries=9") || sdpFmtpLine.includes("color-primaries=9"); + const hasHdrFlag = sdpFmtpLine.includes("hdr") || sdpFmtpLine.includes("HDR"); + this.isHdr = has10BitProfile && (hasPqTransfer || hasBt2020Primaries || hasHdrFlag); this.diagnostics.codec = this.currentCodec; this.diagnostics.isHdr = this.isHdr; @@ -941,6 +980,17 @@ export class GfnWebRtcClient { overlayForcesSdr: false, fallbackReason: null, }; + } else if (this.hdrEnabledForSession && has10BitProfile && !this.isHdr) { + this.diagnostics.hdrState = { + status: "fallback_sdr", + bitDepth: 10, + colorPrimaries: "BT.709", + transferFunction: "SDR", + matrixCoefficients: "BT.709", + codecProfile: `${this.currentCodec} Main10`, + overlayForcesSdr: false, + fallbackReason: "10-bit profile active but PQ/BT.2020 not negotiated (SDR 10-bit)", + }; } else if (this.hdrEnabledForSession && !this.isHdr) { this.diagnostics.hdrState = { status: "fallback_sdr", @@ -955,7 +1005,7 @@ export class GfnWebRtcClient { } else { this.diagnostics.hdrState = { status: "inactive", - bitDepth: sdpFmtpLine.includes("profile-id=2") ? 10 : 8, + bitDepth: has10BitProfile ? 10 : 8, colorPrimaries: "BT.709", transferFunction: "SDR", matrixCoefficients: "BT.709", @@ -2496,6 +2546,7 @@ export class GfnWebRtcClient { ); this.hdrEnabledForSession = settings.hdrEnabled; + this.configureVideoElementForHdr(this.options.videoElement, settings.hdrEnabled); this.log(`ICE servers: ${session.iceServers.length} (${session.iceServers.map(s => s.urls.join(",")).join(" | ")})`); this.log(`Offer SDP length: ${offerSdp.length} chars`); // Log full offer SDP for ICE debugging @@ -2647,7 +2698,21 @@ export class GfnWebRtcClient { const supported = this.getSupportedVideoCodecs(); this.log(`Browser supported video codecs: ${supported.join(", ") || "unknown"}`); - if (settings.codec === "H265") { + // HDR requires a 10-bit capable codec. H264 cannot carry 10-bit HDR. + // Upgrade to H265 (or AV1) when HDR is enabled and user selected H264. + if (settings.hdrEnabled && effectiveCodec === "H264") { + if (supported.includes("H265")) { + effectiveCodec = "H265"; + this.log("HDR: Upgraded codec H264 → H265 (H264 cannot carry 10-bit HDR)"); + } else if (supported.includes("AV1")) { + effectiveCodec = "AV1"; + this.log("HDR: Upgraded codec H264 → AV1 (H264 cannot carry 10-bit HDR, H265 unsupported)"); + } else { + this.log("HDR: Warning — H264 selected but no 10-bit capable codec available; HDR may not activate"); + } + } + + if (effectiveCodec === "H265") { const hevcProfiles = this.getSupportedHevcProfiles(); if (hevcProfiles.size > 0) { this.log(`Browser HEVC profile-id support: ${Array.from(hevcProfiles).join(", ")}`); diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 75a863a8..49d6b076 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -226,6 +226,7 @@ export interface StreamSettings { maxBitrateMbps: number; codec: VideoCodec; colorQuality: ColorQuality; + hdrEnabled?: boolean; } export interface SessionCreateRequest { From 9d1ba77d557d4c180ae7a8658a741a93107280da Mon Sep 17 00:00:00 2001 From: Meganugger Date: Thu, 19 Feb 2026 14:24:27 +0000 Subject: [PATCH 3/5] Unify UI of HDR mode and flight control selectors with reusable segmented control. --- .../renderer/src/components/SettingsPage.tsx | 12 +++++-- opennow-stable/src/renderer/src/styles.css | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index 5370493e..c34fe111 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -971,15 +971,21 @@ export function SettingsPage({ settings, regions, onSettingChange, hdrCapability
- -
+ +
{(["off", "auto", "on"] as HdrStreamingMode[]).map((mode) => ( ))} diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index b6166226..cc5c2d87 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -1518,6 +1518,40 @@ body.controller-mode { border-color: rgba(88, 217, 138, 0.25); } +/* Segmented control (connected toggle group) */ +.settings-seg { + display: flex; + border-radius: var(--r-sm); + border: 1px solid var(--panel-border); + overflow: hidden; + flex-wrap: wrap; +} +.settings-seg-btn { + flex: 1 1 0; + min-width: 0; + padding: 8px 14px; + background: transparent; + border: none; + border-right: 1px solid var(--panel-border); + color: var(--ink-soft); + font-size: 0.82rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: background var(--t-fast), color var(--t-fast); + outline: none; + white-space: nowrap; + text-align: center; +} +.settings-seg-btn:last-child { border-right: none; } +.settings-seg-btn:hover:not(.active) { background: var(--panel-border-solid); color: var(--ink-soft); } +.settings-seg-btn:focus-visible { box-shadow: inset 0 0 0 2px var(--accent-glow); } +.settings-seg-btn.active { + background: var(--accent-surface-strong); + color: var(--accent); + font-weight: 700; +} + /* Tier indicator on preset chips */ .settings-chip-tier { font-size: 0.62rem; font-weight: 800; From e966f028a42a4930b7e9c4cdde652e7c0b963222 Mon Sep 17 00:00:00 2001 From: Meganugger Date: Thu, 19 Feb 2026 13:55:34 +0000 Subject: [PATCH 4/5] feat(settings): Improve HDR diagnostics, flight control setup, and Discord RPC robustness --- opennow-stable/src/main/index.ts | 9 +- opennow-stable/src/preload/index.ts | 4 +- .../renderer/src/components/SettingsPage.tsx | 195 ++++++++++++++++-- opennow-stable/src/shared/gfn.ts | 1 + opennow-stable/src/shared/ipc.ts | 4 +- 5 files changed, 193 insertions(+), 20 deletions(-) diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index a174eca3..05c86d4a 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -507,7 +507,14 @@ function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.HDR_GET_OS_INFO, () => { return getOsHdrInfo(); }); mainWindow?.on("resize", () => { - if (mainWindow && !mainWindow.isDestroyed()) { + }); + + ipcMain.handle(IPC_CHANNELS.APP_RELAUNCH, () => { + app.relaunch(); + app.exit(0); + }); + + mainWindow?.on("resize", () => { if (mainWindow && !mainWindow.isDestroyed()) { const [width, height] = mainWindow.getSize(); settingsManager.set("windowWidth", width); settingsManager.set("windowHeight", height); diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 65f0246d..fd5d9e1d 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -86,5 +86,7 @@ const api: PreloadApi = { flightGetAllProfiles: () => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_ALL_PROFILES), flightResetProfile: (vidPid: string) => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_RESET_PROFILE, vidPid), getOsHdrInfo: () => ipcRenderer.invoke(IPC_CHANNELS.HDR_GET_OS_INFO),}; - + getOsHdrInfo: () => ipcRenderer.invoke(IPC_CHANNELS.HDR_GET_OS_INFO), + relaunchApp: () => ipcRenderer.invoke(IPC_CHANNELS.APP_RELAUNCH), +}; contextBridge.exposeInMainWorld("openNow", api); diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index c34fe111..b911d604 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -2,7 +2,9 @@ import { Globe, Save, Check, Search, X, Loader, Zap, Mic, FileDown } from "lucid import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { Monitor, Volume2, Mouse, Settings2, Globe, Save, Check, Search, X, Loader, Cpu, Zap, MessageSquare, Joystick, Sun } from "lucide-react"; import { useState, useCallback, useMemo, useEffect } from "react";import type { JSX } from "react"; - +import { Monitor, Volume2, Mouse, Settings2, Globe, Save, Check, Search, X, Loader, Cpu, Zap, MessageSquare, Joystick, Sun, RefreshCw, RotateCcw } from "lucide-react"; +import { useState, useCallback, useMemo, useEffect } from "react"; +import type { JSX } from "react"; import type { Settings, StreamRegion, @@ -15,7 +17,9 @@ import type { HdrCapability,} from "@shared/gfn"; import { colorQualityRequiresHevc } from "@shared/gfn"; import { formatShortcutForDisplay, normalizeShortcut } from "../shortcuts"; - +import { FlightControlsPanel } from "./FlightControlsPanel"; +import { useToast } from "./Toast"; +import { probeHdrCapability } from "../gfn/hdrCapability"; interface SettingsPageProps { settings: Settings; regions: StreamRegion[]; @@ -445,6 +449,12 @@ export function SettingsPage({ settings, regions, onSettingChange, hdrCapability const [codecResults, setCodecResults] = useState(initialCodecResults); const [codecTesting, setCodecTesting] = useState(false); const [codecTestOpen, setCodecTestOpen] = useState(() => initialCodecResults !== null); + + // HDR diagnostics (on-demand like codec diagnostics) + const [hdrDiagResult, setHdrDiagResult] = useState(hdrCapability); + const [hdrDiagTesting, setHdrDiagTesting] = useState(false); + const [hdrDiagOpen, setHdrDiagOpen] = useState(false); + const [hdrRefreshing, setHdrRefreshing] = useState(false); const platformHardwareLabel = useMemo(() => { const platform = navigator.platform.toLowerCase(); if (platform.includes("win")) return "D3D11 / DXVA"; @@ -466,6 +476,37 @@ export function SettingsPage({ settings, regions, onSettingChange, hdrCapability } }, []); + const runHdrDiagnostics = useCallback(async () => { + setHdrDiagTesting(true); + setHdrDiagOpen(true); + try { + const cap = await probeHdrCapability(); + setHdrDiagResult(cap); + } catch (err) { + console.error("[HDR] Diagnostics failed:", err); + } finally { + setHdrDiagTesting(false); + } + }, []); + + const refreshHdrStatus = useCallback(async () => { + setHdrRefreshing(true); + try { + const cap = await probeHdrCapability(); + setHdrDiagResult(cap); + } catch (err) { + console.error("[HDR] Refresh failed:", err); + } finally { + setHdrRefreshing(false); + } + }, []); + + useEffect(() => { + if (hdrCapability) { + setHdrDiagResult(hdrCapability); + } + }, [hdrCapability]); + useEffect(() => { try { if (codecResults && codecResults.length > 0) { @@ -986,7 +1027,11 @@ export function SettingsPage({ settings, regions, onSettingChange, hdrCapability handleChange("hdrStreaming", mode); if (mode !== "off") void refreshHdrStatus(); }} > - {mode === "off" ? "Off" : mode === "auto" ? "Auto" : "On"} + onClick={() => { + handleChange("hdrStreaming", mode); + if (mode !== "off") void refreshHdrStatus(); + }} + > {mode === "off" ? "Off" : mode === "auto" ? "Auto" : "On"} ))}
@@ -994,7 +1039,7 @@ export function SettingsPage({ settings, regions, onSettingChange, hdrCapability {settings.hdrStreaming === "auto" && ( - HDR activates only when platform, display, OS, stream, and codec all confirm HDR support. Safest option. + HDR activates only when platform, display, OS, stream, and codec all confirm HDR support. )} {settings.hdrStreaming === "on" && ( @@ -1005,25 +1050,141 @@ export function SettingsPage({ settings, regions, onSettingChange, hdrCapability {settings.hdrStreaming !== "off" && !settings.colorQuality.startsWith("10bit") && ( - HDR requires 10-bit color quality. Switch Color Quality to a 10-bit mode to enable HDR. + HDR requires 10-bit color quality. Switch Color Quality to a 10-bit mode. )} - {hdrCapability && ( -
- -
-
Platform: {hdrCapability.platform} — {hdrCapability.platformSupport}
-
OS HDR: {hdrCapability.osHdrEnabled ? "Enabled" : "Disabled"}
-
Display HDR: {hdrCapability.displayHdrCapable ? "Capable" : "Not detected"}
-
10-bit Decode: {hdrCapability.decoder10BitCapable ? "Supported" : "Not available"}
-
HDR Color Space: {hdrCapability.hdrColorSpaceSupported ? "Supported" : "Not detected"}
+ {settings.hdrStreaming !== "off" && hdrDiagResult && ( + (() => { + const cap = hdrDiagResult; + const statusLabel = cap.platformSupport === "supported" ? "Supported" + : cap.platformSupport === "best_effort" + ? (cap.platform === "windows" && !cap.osHdrEnabled && cap.displayHdrCapable + ? "Available (OS HDR is Off)" + : cap.platform === "macos" ? "Best Effort (macOS)" : "Best Effort") + : cap.platform === "linux" ? "Not supported (Linux Electron limitation)" + : "Unknown"; + const statusColor = cap.platformSupport === "supported" ? "var(--success)" + : cap.platformSupport === "best_effort" ? "var(--warning)" : "var(--error)"; + + return ( +
+
+ Status + {statusLabel} +
+ +
+ ); + })() + )} + +
+ + +
+ + {hdrDiagOpen && hdrDiagResult && ( +
+
+
+ Platform + + {hdrDiagResult.platform} + +
+
+
+ OS HDR + + {hdrDiagResult.osHdrEnabled ? "On" : "Off"} + + + {hdrDiagResult.osHdrEnabled ? "System HDR enabled" : "Enable HDR in OS Display Settings"} + +
+
+ Display + + {hdrDiagResult.displayHdrCapable ? "HDR" : "SDR"} + + + {hdrDiagResult.displayHdrCapable ? "Display reports HDR capability" : "No HDR display detected"} + +
+
+ 10-bit Decode + + {hdrDiagResult.decoder10BitCapable ? "Yes" : "No"} + + + {hdrDiagResult.decoder10BitCapable ? "HEVC Main10 or AV1 10-bit" : "No 10-bit decoder found"} + +
+
+ Color Pipeline + + {hdrDiagResult.hdrColorSpaceSupported ? "Yes" : "Limited"} + + + {hdrDiagResult.hdrColorSpaceSupported ? "Wide gamut rendering path" : "Standard gamut only"} + +
+
+ + {hdrDiagResult.platformSupport === "best_effort" && hdrDiagResult.platform === "windows" && !hdrDiagResult.osHdrEnabled && ( + + HDR display detected but OS HDR is off. Enable HDR in Windows Display Settings, then click Refresh. + + )} + + {(hdrDiagResult.platform === "linux" || hdrDiagResult.platformSupport === "unsupported") && ( +
+ + Some HDR changes may require a restart. + + +
+ )}
)} - {!hdrCapability && settings.hdrStreaming !== "off" && ( - Probing HDR capability... - )}
diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 49d6b076..98b05d86 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -373,6 +373,7 @@ export interface OpenNowApi { flightGetAllProfiles(): Promise; flightResetProfile(vidPid: string): Promise; getOsHdrInfo(): Promise<{ osHdrEnabled: boolean; platform: string }>; + relaunchApp(): Promise; } export type FlightAxisTarget = diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index db93d896..7dd3ceb5 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -35,5 +35,7 @@ export const IPC_CHANNELS = { FLIGHT_GET_ALL_PROFILES: "flight:get-all-profiles", FLIGHT_RESET_PROFILE: "flight:reset-profile", HDR_GET_OS_INFO: "hdr:get-os-info",} as const; - + HDR_GET_OS_INFO: "hdr:get-os-info", + APP_RELAUNCH: "app:relaunch", +} as const; export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]; From 77d96c41885962e641bcdac89c50807c26012448 Mon Sep 17 00:00:00 2001 From: Meganugger <182369132+Meganugger@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:38:40 +0000 Subject: [PATCH 5/5] Improve video settings UX, HDR detection, and type safety across the application. --- opennow-stable/src/main/index.ts | 69 +++++++++++- opennow-stable/src/renderer/src/App.tsx | 2 + .../renderer/src/components/SettingsPage.tsx | 100 +++++++++++++++--- .../src/renderer/src/gfn/hdrCapability.ts | 91 ++++++++++++++++ opennow-stable/src/renderer/src/styles.css | 3 +- 5 files changed, 248 insertions(+), 17 deletions(-) diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index 05c86d4a..8992d5b9 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -194,7 +194,74 @@ function emitToRenderer(event: MainToRendererSignalingEvent): void { } } -async function createMainWindow(): Promise { +function emitSessionExpired(reason: string): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.AUTH_SESSION_EXPIRED, reason); + } +} + +async function withRetryOn401( + fn: (token: string) => Promise, + explicitToken?: string, +): Promise { + const token = await resolveJwt(explicitToken); + try { + return await fn(token); + } catch (error) { + const { shouldRetry, token: newToken } = await authService.handleApiError(error); + if (shouldRetry && newToken) { + return fn(newToken); + } + throw error; + } +} + +function setupWebHidPermissions(): void { + const ses = session.defaultSession; + + ses.setDevicePermissionHandler((details) => { + if (details.deviceType === "hid") { + return true; + } + return true; + }); + + ses.setPermissionCheckHandler((_webContents, permission) => { + const granted: ReadonlySet = new Set(["hid", "media", "keyboardLock"]); + if (granted.has(permission)) { + return true; + } + return true; + }); + + ses.setPermissionRequestHandler((_webContents, permission, callback) => { + if (permission === "media" || permission === "keyboardLock") { + callback(true); + return; + } + callback(true); + }); + + ses.on("select-hid-device", (event, details, callback) => { + event.preventDefault(); + const ungranted = details.deviceList.find((d) => !grantedHidDeviceIds.has(d.deviceId)); + const selected = ungranted ?? details.deviceList[0]; + if (selected) { + grantedHidDeviceIds.add(selected.deviceId); + callback(selected.deviceId); + } else { + callback(""); + } + }); + + ses.on("hid-device-added", (_event, _details) => { + // WebHID connect event handled in renderer via navigator.hid + }); + + ses.on("hid-device-removed", (_event, _details) => { + // WebHID disconnect event handled in renderer via navigator.hid + }); +}async function createMainWindow(): Promise { const preloadMjsPath = join(__dirname, "../preload/index.mjs"); const preloadJsPath = join(__dirname, "../preload/index.js"); const preloadPath = existsSync(preloadMjsPath) ? preloadMjsPath : preloadJsPath; diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 32fc1742..ffcfa770 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -127,6 +127,8 @@ function defaultDiagnostics(): StreamDiagnostics { inputQueuePeakBufferedBytes: 0, inputQueueDropCount: 0, inputQueueMaxSchedulingDelayMs: 0, + micBytesSent: 0, + micPacketsSent: 0, hdrState: buildInitialHdrState(), gpuType: "", serverRegion: "", diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index b911d604..82547264 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -20,7 +20,7 @@ import { formatShortcutForDisplay, normalizeShortcut } from "../shortcuts"; import { FlightControlsPanel } from "./FlightControlsPanel"; import { useToast } from "./Toast"; import { probeHdrCapability } from "../gfn/hdrCapability"; -interface SettingsPageProps { +import { probeHdrCapability, getHdrDetectionStatus, getHdrStatusLabel, type HdrDetectionStatus } from "../gfn/hdrCapability";interface SettingsPageProps { settings: Settings; regions: StreamRegion[]; onSettingChange: (key: K, value: Settings[K]) => void; @@ -455,7 +455,17 @@ export function SettingsPage({ settings, regions, onSettingChange, hdrCapability const [hdrDiagTesting, setHdrDiagTesting] = useState(false); const [hdrDiagOpen, setHdrDiagOpen] = useState(false); const [hdrRefreshing, setHdrRefreshing] = useState(false); - const platformHardwareLabel = useMemo(() => { + const [hdrDetectionStatus, setHdrDetectionStatus] = useState(getHdrDetectionStatus); + const [hevcWarningShown, setHevcWarningShown] = useState(false); + const [platformInfo, setPlatformInfo] = useState(null); + + useEffect(() => { + const api = (window as unknown as { openNow: { getPlatformInfo: () => Promise } }).openNow; + void api.getPlatformInfo().then(setPlatformInfo).catch(() => {}); + }, []); + + const isLinux = platformInfo?.platform === "linux"; + const isLinuxArm = isLinux && (platformInfo?.arch === "arm64" || platformInfo?.arch === "arm"); const platformHardwareLabel = useMemo(() => { const platform = navigator.platform.toLowerCase(); if (platform.includes("win")) return "D3D11 / DXVA"; if (platform.includes("mac")) return "VideoToolbox"; @@ -482,8 +492,10 @@ export function SettingsPage({ settings, regions, onSettingChange, hdrCapability try { const cap = await probeHdrCapability(); setHdrDiagResult(cap); + setHdrDetectionStatus(getHdrDetectionStatus()); } catch (err) { console.error("[HDR] Diagnostics failed:", err); + setHdrDetectionStatus(getHdrDetectionStatus()); } finally { setHdrDiagTesting(false); } @@ -491,11 +503,14 @@ export function SettingsPage({ settings, regions, onSettingChange, hdrCapability const refreshHdrStatus = useCallback(async () => { setHdrRefreshing(true); + setHdrDetectionStatus("probing"); try { const cap = await probeHdrCapability(); setHdrDiagResult(cap); + setHdrDetectionStatus(getHdrDetectionStatus()); } catch (err) { console.error("[HDR] Refresh failed:", err); + setHdrDetectionStatus(getHdrDetectionStatus()); } finally { setHdrRefreshing(false); } @@ -504,6 +519,7 @@ export function SettingsPage({ settings, regions, onSettingChange, hdrCapability useEffect(() => { if (hdrCapability) { setHdrDiagResult(hdrCapability); + setHdrDetectionStatus(getHdrDetectionStatus()); } }, [hdrCapability]); @@ -884,7 +900,66 @@ export function SettingsPage({ settings, regions, onSettingChange, hdrCapability - {/* Decoder preference */} + {/* HEVC Compatibility Mode */} +
+ +
+ {([ + { value: "auto" as const, label: "Auto" }, + { value: "force_h264" as const, label: "Force H.264" }, + { value: "force_hevc" as const, label: "Force HEVC" }, + { value: "hevc_software" as const, label: "HEVC Software" }, + ] as const).map((option) => ( + + ))} +
+ {hevcWarningShown && (settings.codec === "H265" || settings.codec === "AV1") && ( + + ⚠ HEVC compatibility mode is intended only for AMD Polaris/Vega GPUs (e.g., RX 400/500 series, Vega iGPUs). Other GPUs typically don't need this. + + )} + + Auto disables HEVC on AMD Polaris/Vega GPUs (RX 550, Vega iGPU) to prevent green screen. + +
+ + {/* Linux-only Hardware Decode Backend */} + {isLinux && ( +
+ +
+ {([ + { value: "auto" as const, label: "Auto (recommended)" }, + { value: "vaapi" as const, label: "VA-API (force)" }, + { value: "v4l2" as const, label: "V4L2 (force)" }, + { value: "software" as const, label: "Software (force)" }, + ] as const).map((option) => ( + + ))} +
+ + Auto chooses VA-API on x64 Linux and V4L2 on Linux ARM. Applies after app restart. + +
+ )} {/* Decoder preference */}
@@ -1054,18 +1129,15 @@ export function SettingsPage({ settings, regions, onSettingChange, hdrCapability )} - {settings.hdrStreaming !== "off" && hdrDiagResult && ( + {settings.hdrStreaming !== "off" && ( (() => { - const cap = hdrDiagResult; - const statusLabel = cap.platformSupport === "supported" ? "Supported" - : cap.platformSupport === "best_effort" - ? (cap.platform === "windows" && !cap.osHdrEnabled && cap.displayHdrCapable - ? "Available (OS HDR is Off)" - : cap.platform === "macos" ? "Best Effort (macOS)" : "Best Effort") - : cap.platform === "linux" ? "Not supported (Linux Electron limitation)" - : "Unknown"; - const statusColor = cap.platformSupport === "supported" ? "var(--success)" - : cap.platformSupport === "best_effort" ? "var(--warning)" : "var(--error)"; + const statusLabel = getHdrStatusLabel(settings.hdrStreaming, hdrDetectionStatus); + const statusColor = + hdrDetectionStatus === "active" || hdrDetectionStatus === "supported" + ? "var(--success)" + : hdrDetectionStatus === "os_disabled" || hdrDetectionStatus === "probing" || hdrDetectionStatus === "idle" + ? "var(--warning)" + : "var(--error)"; return (
diff --git a/opennow-stable/src/renderer/src/gfn/hdrCapability.ts b/opennow-stable/src/renderer/src/gfn/hdrCapability.ts index 6150cd7c..8a1cf23f 100644 --- a/opennow-stable/src/renderer/src/gfn/hdrCapability.ts +++ b/opennow-stable/src/renderer/src/gfn/hdrCapability.ts @@ -10,6 +10,77 @@ import type { const api = (window as unknown as { openNow: { getOsHdrInfo: () => Promise<{ osHdrEnabled: boolean; platform: string }> } }).openNow; +export type HdrDetectionStatus = + | "idle" + | "probing" + | "supported" + | "unsupported" + | "os_disabled" + | "active" + | "error"; + +let cachedCapability: HdrCapability | null = null; +let cachedDetectionStatus: HdrDetectionStatus = "idle"; +let probeInFlight: Promise | null = null; + +export function getHdrDetectionStatus(): HdrDetectionStatus { + return cachedDetectionStatus; +} + +export function getCachedHdrCapability(): HdrCapability | null { + return cachedCapability; +} + +function deriveDetectionStatus(cap: HdrCapability): HdrDetectionStatus { + if (cap.platformSupport === "unsupported") return "unsupported"; + if (cap.platformSupport === "unknown") return "unsupported"; + if (!cap.decoder10BitCapable || !cap.displayHdrCapable) return "unsupported"; + if (cap.platform === "windows" && !cap.osHdrEnabled && cap.displayHdrCapable) return "os_disabled"; + if (cap.platformSupport === "supported" && cap.osHdrEnabled && cap.displayHdrCapable) return "active"; + if (cap.platformSupport === "best_effort" && cap.displayHdrCapable) return "supported"; + return "supported"; +} + +export function getHdrStatusLabel( + mode: HdrStreamingMode, + detectionStatus: HdrDetectionStatus, +): string { + if (mode === "off") return "HDR off"; + + if (mode === "auto") { + switch (detectionStatus) { + case "idle": + case "probing": + return "Detecting HDR\u2026"; + case "unsupported": + case "error": + return "HDR not supported"; + case "os_disabled": + return "HDR supported (enable in OS settings)"; + case "active": + case "supported": + return "HDR active"; + } + } + + if (mode === "on") { + switch (detectionStatus) { + case "idle": + case "probing": + return "Detecting HDR\u2026"; + case "unsupported": + case "error": + return "Forced HDR (may not be supported)"; + case "active": + case "supported": + case "os_disabled": + return "HDR active (forced)"; + } + } + + return "HDR off"; +} + function detectPlatform(): "windows" | "macos" | "linux" | "unknown" { const ua = navigator.userAgent.toLowerCase(); if (ua.includes("win")) return "windows"; @@ -142,6 +213,26 @@ function getPlatformSupport(platform: string, osHdrEnabled: boolean, displayCapa } export async function probeHdrCapability(): Promise { + if (probeInFlight) return probeInFlight; + + cachedDetectionStatus = "probing"; + probeInFlight = doProbe(); + + try { + const result = await probeInFlight; + cachedCapability = result; + cachedDetectionStatus = deriveDetectionStatus(result); + return result; + } catch { + cachedDetectionStatus = "error"; + if (cachedCapability) return cachedCapability; + throw new Error("HDR probe failed and no cached result available"); + } finally { + probeInFlight = null; + } +} + +async function doProbe(): Promise { const platform = detectPlatform(); const notes: string[] = []; diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index cc5c2d87..6e8c52fd 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -1524,10 +1524,9 @@ body.controller-mode { border-radius: var(--r-sm); border: 1px solid var(--panel-border); overflow: hidden; - flex-wrap: wrap; } .settings-seg-btn { - flex: 1 1 0; + flex: 1 1 0%; min-width: 0; padding: 8px 14px; background: transparent;