From f1e887e73e69f2ebea322182efcc11545be09339 Mon Sep 17 00:00:00 2001 From: Meganugger <182369132+Meganugger@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:08:38 +0000 Subject: [PATCH] Implement Linux ARM video feature separation and decode backend override UI --- opennow-stable/src/main/index.ts | 50 ++++- opennow-stable/src/main/settings.ts | 56 ++++- opennow-stable/src/preload/index.ts | 39 +++- opennow-stable/src/renderer/src/App.tsx | 35 +++- .../renderer/src/components/SettingsPage.tsx | 111 +++++++++- .../src/renderer/src/gfn/webrtcClient.ts | 61 +++++- opennow-stable/src/shared/gfn.ts | 198 +++++++++++++++++- opennow-stable/src/shared/ipc.ts | 14 +- 8 files changed, 534 insertions(+), 30 deletions(-) diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index c15821f8..6e1f1591 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -27,6 +27,7 @@ import type { IceCandidatePayload, Settings, VideoAccelerationPreference, + VideoDecodeBackend, SubscriptionFetchRequest, SessionConflictChoice, } from "@shared/gfn"; @@ -53,16 +54,22 @@ const __dirname = dirname(__filename); interface BootstrapVideoPreferences { decoderPreference: VideoAccelerationPreference; encoderPreference: VideoAccelerationPreference; + videoDecodeBackend: VideoDecodeBackend; } function isAccelerationPreference(value: unknown): value is VideoAccelerationPreference { return value === "auto" || value === "hardware" || value === "software"; } +function isVideoDecodeBackend(value: unknown): value is VideoDecodeBackend { + return value === "auto" || value === "vaapi" || value === "v4l2" || value === "software"; +} + function loadBootstrapVideoPreferences(): BootstrapVideoPreferences { const defaults: BootstrapVideoPreferences = { decoderPreference: "auto", encoderPreference: "auto", + videoDecodeBackend: "auto", }; try { const settingsPath = join(app.getPath("userData"), "settings.json"); @@ -77,6 +84,9 @@ function loadBootstrapVideoPreferences(): BootstrapVideoPreferences { encoderPreference: isAccelerationPreference(parsed.encoderPreference) ? parsed.encoderPreference : defaults.encoderPreference, + videoDecodeBackend: isVideoDecodeBackend(parsed.videoDecodeBackend) + ? parsed.videoDecodeBackend + : defaults.videoDecodeBackend, }; } catch { return defaults; @@ -85,13 +95,18 @@ function loadBootstrapVideoPreferences(): BootstrapVideoPreferences { const bootstrapVideoPrefs = loadBootstrapVideoPreferences(); console.log( - `[Main] Video acceleration preference: decode=${bootstrapVideoPrefs.decoderPreference}, encode=${bootstrapVideoPrefs.encoderPreference}`, + `[Main] Video acceleration preference: decode=${bootstrapVideoPrefs.decoderPreference}, encode=${bootstrapVideoPrefs.encoderPreference}, videoDecodeBackend=${bootstrapVideoPrefs.videoDecodeBackend}`, ); // --- Platform-specific HW video decode features --- const platformFeatures: string[] = []; const isLinuxArm = process.platform === "linux" && (process.arch === "arm64" || process.arch === "arm"); +const isLinuxArm = process.platform === "linux" && (process.arch === "arm64" || process.arch === "arm"); +const platformFeatures: string[] = []; +const disableFeatures: string[] = [ + "WebRtcHideLocalIpsWithMdns", +]; if (process.platform === "win32") { // Windows: D3D11 + Media Foundation path for HW decode/encode acceleration if (bootstrapVideoPrefs.decoderPreference !== "software") { @@ -123,7 +138,33 @@ if (process.platform === "win32") { ) { platformFeatures.push("VaapiIgnoreDriverChecks"); } + const pref = bootstrapVideoPrefs.videoDecodeBackend; + + if (pref === "software") { + disableFeatures.push("UseChromeOSDirectVideoDecoder"); + } else if (pref === "vaapi") { + if (bootstrapVideoPrefs.decoderPreference !== "software") { + platformFeatures.push("VaapiVideoDecoder"); + platformFeatures.push("VaapiIgnoreDriverChecks"); + } + disableFeatures.push("UseChromeOSDirectVideoDecoder"); + } else if (pref === "v4l2") { + platformFeatures.push("UseChromeOSDirectVideoDecoder"); + } else { + // auto: select based on architecture + if (isLinuxArm) { + platformFeatures.push("UseChromeOSDirectVideoDecoder"); + } else { + if (bootstrapVideoPrefs.decoderPreference !== "software") { + platformFeatures.push("VaapiVideoDecoder"); + platformFeatures.push("VaapiIgnoreDriverChecks"); + } + disableFeatures.push("UseChromeOSDirectVideoDecoder"); + } } + + if (bootstrapVideoPrefs.encoderPreference !== "software") { + platformFeatures.push("VaapiVideoEncoder"); } } // macOS: VideoToolbox handles HW acceleration natively, no extra feature flags needed @@ -145,8 +186,7 @@ const disableFeatures: string[] = [ if (process.platform === "linux" && !isLinuxArm) { // ChromeOS-only direct video decoder path interferes on regular Linux disableFeatures.push("UseChromeOSDirectVideoDecoder"); -} -app.commandLine.appendSwitch("disable-features", disableFeatures.join(",")); +}app.commandLine.appendSwitch("disable-features", disableFeatures.join(",")); app.commandLine.appendSwitch("force-fieldtrials", [ @@ -501,7 +541,9 @@ function registerIpcHandlers(): void { }); // Save window size when it changes - mainWindow?.on("resize", () => { + ipcMain.handle(IPC_CHANNELS.GET_PLATFORM_INFO, () => { + return { platform: process.platform, arch: process.arch }; + }); 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..6d4d44af 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, MicMode, HevcCompatMode, VideoDecodeBackend } from "@shared/gfn"; +import { defaultFlightSlots } from "@shared/gfn"; export interface Settings { /** Video resolution (e.g., "1920x1080") */ resolution: string; @@ -48,7 +49,40 @@ 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; + /** Microphone mode: off, on, push-to-talk */ + micMode: MicMode; + /** Selected microphone device ID (empty = default) */ + micDeviceId: string; + /** Microphone input gain 0.0 - 2.0 */ + micGain: number; + /** Enable noise suppression */ + micNoiseSuppression: boolean; + /** Enable automatic gain control */ + micAutoGainControl: boolean; + /** Enable echo cancellation */ + micEchoCancellation: boolean; + /** Toggle mic on/off shortcut (works in-stream) */ + shortcutToggleMic: string; + /** HEVC compatibility mode: auto, force_h264, force_hevc, hevc_software */ + hevcCompatMode: HevcCompatMode; + /** Linux video decode backend override: auto, vaapi, v4l2, software */ + videoDecodeBackend: VideoDecodeBackend; + /** Show session clock every N minutes (0 = always visible) */ + sessionClockShowEveryMinutes: number; + /** Duration in seconds to show session clock when periodically revealed */ + sessionClockShowDurationSeconds: number;} const defaultStopShortcut = "Ctrl+Shift+Q"; const defaultAntiAfkShortcut = "Ctrl+Shift+K"; @@ -75,7 +109,23 @@ const DEFAULT_SETTINGS: Settings = { microphoneMode: "disabled", microphoneDeviceId: "", hideStreamButtons: false, - sessionClockShowEveryMinutes: 60, + windowWidth: 1400, + windowHeight: 900, + discordPresenceEnabled: false, + discordClientId: "", + flightControlsEnabled: false, + flightControlsSlot: 3, + flightSlots: defaultFlightSlots(), + hdrStreaming: "off", + micMode: "off", + micDeviceId: "", + micGain: 1.0, + micNoiseSuppression: true, + micAutoGainControl: true, + micEchoCancellation: true, + shortcutToggleMic: "Ctrl+Shift+M", + hevcCompatMode: "auto", + videoDecodeBackend: "auto", sessionClockShowEveryMinutes: 60, sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 155321c2..0a56d278 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -18,7 +18,10 @@ import type { IceCandidatePayload, Settings, SubscriptionFetchRequest, -} from "@shared/gfn"; + DiscordPresencePayload, + FlightProfile, + MicDeviceInfo, + PlatformInfo,} from "@shared/gfn"; // Extend the OpenNowApi interface for internal preload use type PreloadApi = OpenNowApi; @@ -74,6 +77,38 @@ 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), + relaunchApp: () => ipcRenderer.invoke(IPC_CHANNELS.APP_RELAUNCH), + micEnumerateDevices: () => ipcRenderer.invoke(IPC_CHANNELS.MIC_ENUMERATE_DEVICES), + onMicDevicesChanged: (listener: (devices: MicDeviceInfo[]) => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, devices: MicDeviceInfo[]) => { + listener(devices); + }; + ipcRenderer.on(IPC_CHANNELS.MIC_DEVICES_CHANGED, wrapped); + return () => { + ipcRenderer.off(IPC_CHANNELS.MIC_DEVICES_CHANGED, wrapped); + }; + }, + onSessionExpired: (listener: (reason: string) => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, reason: string) => { + listener(reason); + }; + ipcRenderer.on(IPC_CHANNELS.AUTH_SESSION_EXPIRED, wrapped); + return () => { + ipcRenderer.off(IPC_CHANNELS.AUTH_SESSION_EXPIRED, wrapped); + }; + }, + getPlatformInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_PLATFORM_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..06040bd5 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -13,7 +13,11 @@ import type { SubscriptionInfo, StreamRegion, VideoCodec, -} from "@shared/gfn"; + DiscordPresencePayload, + FlightSlotConfig, + HdrCapability, + HdrStreamState, + PlatformInfo,} from "@shared/gfn"; import { GfnWebRtcClient, @@ -282,7 +286,23 @@ export function App(): JSX.Element { microphoneMode: "disabled", microphoneDeviceId: "", hideStreamButtons: false, - sessionClockShowEveryMinutes: 60, + windowWidth: 1400, + windowHeight: 900, + discordPresenceEnabled: false, + discordClientId: "", + flightControlsEnabled: false, + flightControlsSlot: 3, + flightSlots: defaultFlightSlots(), + hdrStreaming: "off", + micMode: "off", + micDeviceId: "", + micGain: 1.0, + micNoiseSuppression: true, + micAutoGainControl: true, + micEchoCancellation: true, + shortcutToggleMic: DEFAULT_SHORTCUTS.shortcutToggleMic, + hevcCompatMode: "auto", + videoDecodeBackend: "auto", sessionClockShowEveryMinutes: 60, sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, @@ -291,6 +311,9 @@ export function App(): JSX.Element { const [regions, setRegions] = useState([]); const [subscriptionInfo, setSubscriptionInfo] = useState(null); + // Platform info (from main process) + const [platformInfo, setPlatformInfo] = useState(null); + // Stream State const [session, setSession] = useState(null); const [streamStatus, setStreamStatus] = useState("idle"); @@ -359,7 +382,8 @@ export function App(): JSX.Element { document.body.classList.remove("controller-mode"); }; }, [controllerConnected]); - + void window.openNow.getPlatformInfo().then(setPlatformInfo).catch(() => {}); + }, []); // Derived state const selectedProvider = useMemo(() => { return providers.find((p) => p.idpId === providerIdpId) ?? authSession?.provider ?? null; @@ -722,7 +746,10 @@ export function App(): JSX.Element { resolution: settings.resolution, fps: settings.fps, maxBitrateKbps: settings.maxBitrateMbps * 1000, - }); + hdrEnabled: hdrEnabledForStream, + hevcCompatMode: settings.hevcCompatMode, + platformInfo, + videoDecodeBackend: settings.videoDecodeBackend, }); setLaunchError(null); setStreamStatus("streaming"); setSessionStartedAtMs((current) => current ?? Date.now()); diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index a19c115e..9d6db893 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -10,7 +10,12 @@ import type { EntitledResolution, VideoAccelerationPreference, MicrophoneMode, -} from "@shared/gfn"; + HdrStreamingMode, + HdrCapability, + MicMode, + MicDeviceInfo, + PlatformInfo, + VideoDecodeBackend,} from "@shared/gfn"; import { colorQualityRequiresHevc } from "@shared/gfn"; import { formatShortcutForDisplay, normalizeShortcut } from "../shortcuts"; @@ -296,11 +301,19 @@ function guessDecodeBackend(hwAccelerated: boolean): string { if (platform.includes("mac") || ua.includes("macintosh")) return "VideoToolbox (GPU)"; if (platform.includes("linux") || ua.includes("linux")) { return isLinuxArmClient() ? "V4L2 (GPU)" : "VA-API (GPU)"; - } +function guessDecodeBackend(hwAccelerated: boolean, platformInfo: PlatformInfo | null): string { + if (!hwAccelerated) return "Software (CPU)"; + if (platformInfo) { + if (platformInfo.platform === "win32") return "D3D11 (GPU)"; + if (platformInfo.platform === "darwin") return "VideoToolbox (GPU)"; + if (platformInfo.platform === "linux") { + if (platformInfo.arch === "arm64" || platformInfo.arch === "arm") return "V4L2 (GPU)"; + return "VA-API (GPU)"; + } } return "Hardware (GPU)"; } -function guessEncodeBackend(hwAccelerated: boolean): string { +function guessEncodeBackend(hwAccelerated: boolean, platformInfo: PlatformInfo | null): string { if (!hwAccelerated) return "Software (CPU)"; const platform = navigator.platform?.toLowerCase() ?? ""; const ua = navigator.userAgent?.toLowerCase() ?? ""; @@ -308,11 +321,14 @@ function guessEncodeBackend(hwAccelerated: boolean): string { if (platform.includes("mac") || ua.includes("macintosh")) return "VideoToolbox (GPU)"; if (platform.includes("linux") || ua.includes("linux")) { return isLinuxArmClient() ? "V4L2 (GPU)" : "VA-API (GPU)"; - } + if (platformInfo) { + if (platformInfo.platform === "win32") return "Media Foundation (GPU)"; + if (platformInfo.platform === "darwin") return "VideoToolbox (GPU)"; + if (platformInfo.platform === "linux") return "VA-API (GPU)"; } return "Hardware (GPU)"; } -async function testCodecSupport(): Promise { +async function testCodecSupport(platformInfo: PlatformInfo | null): Promise { const results: CodecTestResult[] = []; // Get WebRTC receiver capabilities once @@ -418,10 +434,10 @@ async function testCodecSupport(): Promise { encodeSupported, encodeHwAccelerated, decodeVia: (decodeSupported || webrtcSupported) - ? guessDecodeBackend(hwAccelerated) + ? guessDecodeBackend(hwAccelerated, platformInfo) : "Unsupported", encodeVia: encodeSupported - ? guessEncodeBackend(encodeHwAccelerated) + ? guessEncodeBackend(encodeHwAccelerated, platformInfo) : "Unsupported", profiles, }); @@ -448,20 +464,45 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag if (platform.includes("mac")) return "VideoToolbox"; if (platform.includes("linux")) return isLinuxArmClient() ? "V4L2" : "VA-API"; return "Hardware"; - }, []); + + // 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 [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(() => { + if (platformInfo) { + if (platformInfo.platform === "win32") return "D3D11 / DXVA"; + if (platformInfo.platform === "darwin") return "VideoToolbox"; + if (platformInfo.platform === "linux") { + if (platformInfo.arch === "arm64" || platformInfo.arch === "arm") return "V4L2"; + return "VA-API"; + } + } + return "Hardware"; + }, [platformInfo]); const runCodecTest = useCallback(async () => { setCodecTesting(true); setCodecTestOpen(true); try { - const results = await testCodecSupport(); + const results = await testCodecSupport(platformInfo); setCodecResults(results); } catch (err) { console.error("Codec test failed:", err); } finally { setCodecTesting(false); } - }, []); + }, [platformInfo]); useEffect(() => { try { @@ -840,7 +881,55 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag - {/* 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) => ( + + ))} +
+ + 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 */}
diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index 3911ba3e..b94db810 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -5,7 +5,11 @@ import type { SessionInfo, VideoCodec, MicrophoneMode, -} from "@shared/gfn"; + FlightGamepadState, + HdrStreamState, + HevcCompatMode, + PlatformInfo, + VideoDecodeBackend,} from "@shared/gfn"; import { InputEncoder, @@ -38,7 +42,10 @@ interface OfferSettings { resolution: string; fps: number; maxBitrateKbps: number; -} + hdrEnabled: boolean; + hevcCompatMode: HevcCompatMode; + platformInfo: PlatformInfo | null; + videoDecodeBackend: VideoDecodeBackend;} interface KeyStrokeSpec { vk: number; @@ -2578,7 +2585,55 @@ export class GfnWebRtcClient { this.log(`Browser supported video codecs: ${supported.join(", ") || "unknown"}`); if (settings.codec === "H265") { - const hevcProfiles = this.getSupportedHevcProfiles(); + // 3a. HEVC Compatibility Mode — detect AMD Polaris/Vega GPU and fallback if needed + const hevcCompat = resolveHevcCompat(effectiveCodec, settings.hevcCompatMode); + this.log( + `[HEVC Compat] mode=${settings.hevcCompatMode}, vendor=${hevcCompat.gpuInfo.vendor}, ` + + `renderer="${hevcCompat.gpuInfo.unmaskedRenderer || hevcCompat.gpuInfo.renderer}", ` + + `isAmdPolarisOrVega=${hevcCompat.gpuInfo.isAmdPolarisOrVega}, ` + + `decision=${hevcCompat.effectiveCodec}, overridden=${hevcCompat.wasOverridden}`, + ); + this.log(`[HEVC Compat] reason: ${hevcCompat.reason}`); + if (hevcCompat.wasOverridden) { + effectiveCodec = hevcCompat.effectiveCodec; + this.log(`[HEVC Compat] Codec overridden: ${settings.codec} → ${effectiveCodec}`); + } + + const useSoftwareDecode = shouldRequestSoftwareDecode(settings.hevcCompatMode, effectiveCodec); + if (useSoftwareDecode) { + this.log("[HEVC Compat] Software decode requested for HEVC"); + } + + // 3b. Linux ARM V4L2 codec safety — fallback unsupported codecs to H.264 + const isLinuxArm = settings.platformInfo?.platform === "linux" && + (settings.platformInfo.arch === "arm64" || settings.platformInfo.arch === "arm"); + const v4l2Active = isLinuxArm && (settings.videoDecodeBackend === "auto" || settings.videoDecodeBackend === "v4l2"); + if (v4l2Active) { + if (effectiveCodec === "AV1" && !supported.includes("AV1")) { + this.log(`[Linux ARM] AV1 not in browser codec list, falling back to H.264`); + effectiveCodec = "H264"; + } + if (effectiveCodec === "H265" && !supported.includes("H265")) { + this.log(`[Linux ARM] HEVC not in browser codec list, falling back to H.264`); + effectiveCodec = "H264"; + } + } + + // 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 eaa823ba..f2e9995a 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -1,7 +1,61 @@ export type VideoCodec = "H264" | "H265" | "AV1"; export type VideoAccelerationPreference = "auto" | "hardware" | "software"; -/** Color quality (bit depth + chroma subsampling), matching Rust ColorQuality enum */ +export type HdrStreamingMode = "off" | "auto" | "on"; + +export type MicMode = "off" | "on" | "push-to-talk"; + +export type HevcCompatMode = "auto" | "force_h264" | "force_hevc" | "hevc_software"; + +export type VideoDecodeBackend = "auto" | "vaapi" | "v4l2" | "software"; + +export interface PlatformInfo { + platform: string; + arch: string; +} + +export interface MicSettings { + micMode: MicMode; + micDeviceId: string; + micGain: number; + micNoiseSuppression: boolean; + micAutoGainControl: boolean; + micEchoCancellation: boolean; + shortcutToggleMic: string; +} + +export interface MicDeviceInfo { + deviceId: string; + label: string; + isDefault: boolean; +} + +export type MicStatus = "off" | "active" | "muted" | "no-device" | "permission-denied" | "error"; + +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"; /** Helper: get CloudMatch bitDepth value (0 = 8-bit SDR, 10 = 10-bit HDR capable) */ @@ -45,7 +99,23 @@ export interface Settings { microphoneMode: MicrophoneMode; microphoneDeviceId: string; hideStreamButtons: boolean; - sessionClockShowEveryMinutes: number; + windowWidth: number; + windowHeight: number; + discordPresenceEnabled: boolean; + discordClientId: string; + flightControlsEnabled: boolean; + flightControlsSlot: number; + flightSlots: FlightSlotConfig[]; + hdrStreaming: HdrStreamingMode; + micMode: MicMode; + micDeviceId: string; + micGain: number; + micNoiseSuppression: boolean; + micAutoGainControl: boolean; + micEchoCancellation: boolean; + shortcutToggleMic: string; + hevcCompatMode: HevcCompatMode; + videoDecodeBackend: VideoDecodeBackend; sessionClockShowEveryMinutes: number; sessionClockShowDurationSeconds: number; windowWidth: number; windowHeight: number; @@ -332,4 +402,128 @@ 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 }>; + relaunchApp(): Promise; + micEnumerateDevices(): Promise; + onMicDevicesChanged(listener: (devices: MicDeviceInfo[]) => void): () => void; + onSessionExpired(listener: (reason: string) => void): () => void; + getPlatformInfo(): Promise; +} + +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..5841b01b 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -27,6 +27,18 @@ 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", + MIC_ENUMERATE_DEVICES: "mic:enumerate-devices", + MIC_DEVICES_CHANGED: "mic:devices-changed", + APP_RELAUNCH: "app:relaunch", + AUTH_SESSION_EXPIRED: "auth:session-expired", + GET_PLATFORM_INFO: "app:get-platform-info",} as const; export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS];