diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 6f3226e9..5e841f7c 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, KeyboardLayout } 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,8 +49,44 @@ 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; + /** Keyboard layout: auto, qwerty, azerty, qwertz */ + keyboardLayout: KeyboardLayout;} +} const defaultStopShortcut = "Ctrl+Shift+Q"; const defaultAntiAfkShortcut = "Ctrl+Shift+K"; const defaultMicShortcut = "Ctrl+Shift+M"; @@ -79,8 +116,8 @@ const DEFAULT_SETTINGS: Settings = { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, + keyboardLayout: "auto",}; }; - export class SettingsManager { private settings: Settings; private readonly settingsPath: string; diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index f62a8bde..15976740 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -22,7 +22,15 @@ import { } from "./gfn/webrtcClient"; import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut } from "./shortcuts"; import { useControllerNavigation } from "./controllerNavigation"; - +import { MicAudioService } from "./gfn/micAudioService"; +import type { MicAudioState } from "./gfn/micAudioService"; +import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut } from "./shortcuts"; +import { getFlightHidService } from "./flight/FlightHidService"; +import { + probeHdrCapability, + shouldEnableHdr, + buildInitialHdrState, +} from "./gfn/hdrCapability"; // UI Components import { LoginScreen } from "./components/LoginScreen"; import { Navbar } from "./components/Navbar"; @@ -123,8 +131,9 @@ function defaultDiagnostics(): StreamDiagnostics { serverRegion: "", micState: "uninitialized", micEnabled: false, - }; -} + keyboardLayout: "qwerty", + detectedKeyboardLayout: "unknown", }; + };} function isSessionLimitError(error: unknown): boolean { if (error && typeof error === "object" && "gfnErrorCode" in error) { @@ -286,8 +295,8 @@ export function App(): JSX.Element { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, - }); - const [settingsLoaded, setSettingsLoaded] = useState(false); + keyboardLayout: "auto", }); + }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [regions, setRegions] = useState([]); const [subscriptionInfo, setSubscriptionInfo] = useState(null); @@ -338,6 +347,10 @@ export function App(): JSX.Element { onBackAction: handleControllerBackAction, }); + + // Mic state for StreamView indicator + const [micAudioState, setMicAudioState] = useState(null); + // Refs const videoRef = useRef(null); const audioRef = useRef(null); @@ -713,8 +726,15 @@ export function App(): JSX.Element { if (settings.microphoneMode !== "disabled") { void clientRef.current.startMicrophone(); } + // Set keyboard layout on freshly created client + const cached = getCachedDetection(); + const effective = resolveEffectiveLayout( + settings.keyboardLayout, + cached?.detected ?? "unknown", + ); + clientRef.current.setKeyboardLayout(effective); + clientRef.current.setDetectedKeyboardLayout(cached?.detected ?? "unknown"); } } - if (clientRef.current) { await clientRef.current.handleOffer(event.sdp, activeSession, { codec: settings.codec, @@ -1344,7 +1364,64 @@ export function App(): JSX.Element { streamStatus, ]); - // Filter games by search + useEffect(() => { + if (streamStatus === "streaming") { + if (!micServiceRef.current) { + micServiceRef.current = new MicAudioService(); + } + const svc = micServiceRef.current; + + if (clientRef.current) { + clientRef.current.setMicService(svc); + } + + void svc.configure({ + mode: settings.micMode, + deviceId: settings.micDeviceId, + gain: settings.micGain, + noiseSuppression: settings.micNoiseSuppression, + autoGainControl: settings.micAutoGainControl, + echoCancellation: settings.micEchoCancellation, + }); + } else { + if (micServiceRef.current) { + micServiceRef.current.dispose(); + micServiceRef.current = null; + } + } + }, [ + streamStatus, + settings.micMode, + settings.micDeviceId, + settings.micGain, + settings.micNoiseSuppression, + settings.micAutoGainControl, + settings.micEchoCancellation, + ]); + + useEffect(() => { + return () => { + if (micServiceRef.current) { + micServiceRef.current.dispose(); + micServiceRef.current = null; + } + }; + }, []); + + + // Subscribe to mic audio state for StreamView indicator + useEffect(() => { + const svc = micServiceRef.current; + if (!svc) { + setMicAudioState(null); + return; + } + setMicAudioState(svc.getState()); + const unsub = svc.onStateChange((state) => { + setMicAudioState(state); + }); + return unsub; + }, [streamStatus, settings.micMode]); // Filter games by search const filteredGames = useMemo(() => { const query = searchQuery.trim().toLowerCase(); if (!query) return games; @@ -1451,6 +1528,7 @@ export function App(): JSX.Element { streamWarning={streamWarning} isConnecting={streamStatus === "connecting"} gameTitle={streamingGame?.title ?? "Game"} + micStatus={micAudioState?.status ?? null} onToggleFullscreen={() => { if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index a19c115e..b6fa23ea 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, Keyboard, Settings2, Globe, Save, Check, Search, X, Loader, Cpu, Zap, MessageSquare, Joystick, Sun, RefreshCw, RotateCcw, Mic, MicOff } from "lucide-react";import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { Monitor, Volume2, Mouse, Settings2, Globe, Save, Check, Search, X, Loader, Cpu, Zap, MessageSquare, Joystick, Sun, RefreshCw, RotateCcw, Mic, MicOff } from "lucide-react"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react";import type { JSX } from "react"; import type { Settings, @@ -10,8 +11,14 @@ import type { EntitledResolution, VideoAccelerationPreference, MicrophoneMode, -} from "@shared/gfn"; -import { colorQualityRequiresHevc } from "@shared/gfn"; + HdrStreamingMode, + HdrCapability, + MicMode, + MicDeviceInfo, + PlatformInfo, + VideoDecodeBackend, + KeyboardLayout,} from "@shared/gfn"; +} from "@shared/gfn";import { colorQualityRequiresHevc } from "@shared/gfn"; import { formatShortcutForDisplay, normalizeShortcut } from "../shortcuts"; interface SettingsPageProps { @@ -486,6 +493,130 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag const [toggleAntiAfkError, setToggleAntiAfkError] = useState(false); const [toggleMicrophoneError, setToggleMicrophoneError] = useState(false); + const [micDevices, setMicDevices] = useState([]); + const [micDevicesLoading, setMicDevicesLoading] = useState(false); + const [micToggleShortcutInput, setMicToggleShortcutInput] = useState(settings.shortcutToggleMic); + const [micToggleShortcutError, setMicToggleShortcutError] = useState(false); + const micLevelRef = useRef(null); + const [micTestLevel, setMicTestLevel] = useState(0); + const [micTestRunning, setMicTestRunning] = useState(false); + const [micTestError, setMicTestError] = useState(null); + const micTestStreamRef = useRef(null); + const micTestContextRef = useRef(null); + const micTestTimerRef = useRef | null>(null); + const micTestAnalyserRef = useRef(null); + const micTestBufferRef = useRef | null>(null); + + const refreshMicDevices = useCallback(async () => { + setMicDevicesLoading(true); + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioInputs = devices.filter((d) => d.kind === "audioinput" && d.deviceId !== ""); + const defaultDev = audioInputs.find((d) => d.deviceId === "default"); + const defaultGroupId = defaultDev?.groupId; + const mapped: MicDeviceInfo[] = audioInputs.map((d) => ({ + deviceId: d.deviceId, + label: d.label || `Microphone (${d.deviceId.slice(0, 8)})`, + isDefault: d.deviceId === "default" || (!!defaultGroupId && d.groupId === defaultGroupId && d.deviceId !== "default"), + })); + setMicDevices(mapped); + } catch { + setMicDevices([]); + } finally { + setMicDevicesLoading(false); + } + }, []); + + useEffect(() => { + void refreshMicDevices(); + const handler = (): void => { void refreshMicDevices(); }; + navigator.mediaDevices.addEventListener("devicechange", handler); + return () => navigator.mediaDevices.removeEventListener("devicechange", handler); + }, [refreshMicDevices]); + + useEffect(() => { + setMicToggleShortcutInput(settings.shortcutToggleMic); + }, [settings.shortcutToggleMic]); + + const startMicTest = useCallback(async () => { + setMicTestError(null); + try { + const constraints: MediaStreamConstraints = { + audio: { + deviceId: settings.micDeviceId ? { exact: settings.micDeviceId } : undefined, + noiseSuppression: { ideal: settings.micNoiseSuppression }, + autoGainControl: { ideal: settings.micAutoGainControl }, + echoCancellation: { ideal: settings.micEchoCancellation }, + }, + video: false, + }; + const stream = await navigator.mediaDevices.getUserMedia(constraints); + micTestStreamRef.current = stream; + + const ctx = new AudioContext({ sampleRate: 48000, latencyHint: "interactive" }); + micTestContextRef.current = ctx; + + const src = ctx.createMediaStreamSource(stream); + const gain = ctx.createGain(); + gain.gain.value = settings.micGain; + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.3; + micTestAnalyserRef.current = analyser; + micTestBufferRef.current = new Float32Array(analyser.fftSize) as Float32Array; + + src.connect(gain); + gain.connect(analyser); + + micTestTimerRef.current = setInterval(() => { + if (!micTestAnalyserRef.current || !micTestBufferRef.current) return; + micTestAnalyserRef.current.getFloatTimeDomainData(micTestBufferRef.current); + let sum = 0; + for (let i = 0; i < micTestBufferRef.current.length; i++) { + const v = micTestBufferRef.current[i]; + sum += v * v; + } + const rms = Math.sqrt(sum / micTestBufferRef.current.length); + const db = 20 * Math.log10(Math.max(rms, 1e-10)); + setMicTestLevel(Math.max(0, Math.min(1, (db + 60) / 60))); + }, 50); + + setMicTestRunning(true); + } catch (err: unknown) { + setMicTestLevel(0); + setMicTestRunning(false); + if (err instanceof DOMException && (err.name === "NotAllowedError" || err.name === "PermissionDeniedError")) { + setMicTestError("Microphone permission denied"); + } else if (err instanceof DOMException && (err.name === "NotFoundError" || err.name === "OverconstrainedError")) { + setMicTestError("No microphone found"); + } else { + setMicTestError("Failed to access microphone"); + } + } + }, [settings.micDeviceId, settings.micGain, settings.micNoiseSuppression, settings.micAutoGainControl, settings.micEchoCancellation]); + + const stopMicTest = useCallback(() => { + if (micTestTimerRef.current) { + clearInterval(micTestTimerRef.current); + micTestTimerRef.current = null; + } + if (micTestContextRef.current) { + void micTestContextRef.current.close().catch(() => {}); + micTestContextRef.current = null; + } + if (micTestStreamRef.current) { + for (const track of micTestStreamRef.current.getTracks()) track.stop(); + micTestStreamRef.current = null; + } + micTestAnalyserRef.current = null; + micTestBufferRef.current = null; + setMicTestLevel(0); + setMicTestRunning(false); + }, []); + + useEffect(() => { + return () => stopMicTest(); + }, [stopMicTest]); // Dynamic entitled resolutions from MES API const [entitledResolutions, setEntitledResolutions] = useState([]); const [subscriptionLoading, setSubscriptionLoading] = useState(true); @@ -1194,7 +1325,164 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag /> +
+
+ +
+
+ {(["auto", "qwerty", "azerty", "qwertz"] as KeyboardLayout[]).map((l) => ( + + ))} +
+ Auto uses the OS layout; override only if keys are wrong in the stream.
+
+ + +
+ {(["off", "on", "push-to-talk"] as MicMode[]).map((m) => ( + + ))} +
+ + {settings.micMode !== "off" && ( + <> +
+ +
+ + +
+
+ +
+ +
+
+
+
+ +
+ {micTestError && ( + {micTestError} + )} +
+ +
+ +
+ { + const v = parseInt(e.target.value, 10) / 100; + handleChange("micGain", v); + if (micTestContextRef.current) { + const nodes = micTestContextRef.current.destination; + void nodes; + } + }} + /> + {Math.round(settings.micGain * 100)}% +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + )}
diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index ec4a4cf0..de1bfb89 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type { JSX } from "react"; import { Maximize, Minimize, Gamepad2, Loader2, LogOut, Clock3, AlertTriangle, Mic, MicOff } from "lucide-react"; -import type { StreamDiagnostics } from "../gfn/webrtcClient"; +import type { MicStatus } from "@shared/gfn";import type { StreamDiagnostics } from "../gfn/webrtcClient"; interface StreamViewProps { videoRef: React.Ref; @@ -37,6 +37,7 @@ interface StreamViewProps { } | null; isConnecting: boolean; gameTitle: string; + micStatus: MicStatus | null; onToggleFullscreen: () => void; onConfirmExit: () => void; onCancelExit: () => void; @@ -44,6 +45,17 @@ interface StreamViewProps { onToggleMicrophone?: () => void; } +function micLabel(status: MicStatus): string { + switch (status) { + case "active": return "Mic On"; + case "muted": return "Mic Muted"; + case "no-device": return "No Mic"; + case "permission-denied": return "Mic Denied"; + case "error": return "Mic Error"; + default: return ""; + } +} + function getRttColor(rttMs: number): string { if (rttMs <= 0) return "var(--ink-muted)"; if (rttMs < 30) return "var(--success)"; @@ -111,6 +123,7 @@ export function StreamView({ streamWarning, isConnecting, gameTitle, + micStatus, onToggleFullscreen, onConfirmExit, onCancelExit, @@ -348,8 +361,27 @@ 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}} +
+ +
+ Keyboard: {stats.keyboardLayout.toUpperCase()} + {stats.detectedKeyboardLayout !== "unknown" && ( + <> · detected {stats.detectedKeyboardLayout} + )} +
+ + )} {/* Controller indicator (top-left) */} {connectedControllers > 0 && !isConnecting && ( @@ -463,6 +495,17 @@ export function StreamView({ )} + {/* Microphone status indicator — shown whenever mic is active or muted */} + {micStatus && micStatus !== "off" && !isConnecting && ( +
+ {micStatus === "active" ? : } + {micLabel(micStatus)} +
+ )} + {/* Game title (bottom-center, fades) */} {hasResolution && showHints && (
diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index 3911ba3e..1104dfca 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -19,6 +19,7 @@ import { GAMEPAD_MAX_CONTROLLERS, type GamepadInput, } from "./inputProtocol"; + import { buildNvstSdp, extractIceCredentials, @@ -169,8 +170,11 @@ export interface StreamDiagnostics { // Microphone state micState: MicState; micEnabled: boolean; -} + // Keyboard layout diagnostics + keyboardLayout: string; + detectedKeyboardLayout: string;} +} export interface StreamTimeWarning { code: 1 | 2 | 3; secondsLeft?: number; @@ -483,6 +487,7 @@ export class GfnWebRtcClient { // Track currently pressed keys (VK codes) for synthetic Escape detection private pressedKeys: Set = new Set(); + // Video element reference for pointer lock re-acquisition private videoElement: HTMLVideoElement | null = null; // Timer for synthetic Escape on pointer lock loss @@ -504,6 +509,8 @@ export class GfnWebRtcClient { private pendingMouseTimestampUs: bigint | null = null; private mouseDeltaFilter = new MouseDeltaFilter(); + + private partialReliableThresholdMs = GfnWebRtcClient.DEFAULT_PARTIAL_RELIABLE_THRESHOLD_MS; private inputQueuePeakBufferedBytesWindow = 0; private inputQueueMaxSchedulingDelayMsWindow = 0; @@ -551,8 +558,10 @@ export class GfnWebRtcClient { serverRegion: "", micState: "uninitialized", micEnabled: false, - }; + keyboardLayout: "qwerty", + detectedKeyboardLayout: "unknown", }; + }; constructor(private readonly options: ClientOptions) { options.videoElement.srcObject = this.videoStream; options.audioElement.srcObject = this.audioStream; @@ -687,8 +696,10 @@ export class GfnWebRtcClient { serverRegion: this.serverRegion, micState: this.micState, micEnabled: this.micManager?.isEnabled() ?? false, - }; - this.emitStats(); + keyboardLayout: this.effectiveKeyboardLayout, + detectedKeyboardLayout: this.diagnostics.detectedKeyboardLayout, }; + + }; this.emitStats(); } private resetInputState(): void { @@ -2416,7 +2427,140 @@ export class GfnWebRtcClient { } } - async handleOffer(offerSdp: string, session: SessionInfo, settings: OfferSettings): Promise { + + + setMicService(service: MicAudioService | null): void { + this.micService = service; + if (service) { + service.setPeerConnection(this.pc); + if (this.micTransceiver) { + service.setMicTransceiver(this.micTransceiver); + } + } + } + + private bindMicTransceiverFromOffer(pc: RTCPeerConnection, offerSdp: string): void { + const transceivers = pc.getTransceivers(); + + this.log(`=== Mic transceiver binding: ${transceivers.length} transceivers ===`); + for (let i = 0; i < transceivers.length; i++) { + const t = transceivers[i]; + this.log( + ` [${i}] mid=${t.mid} direction=${t.direction} currentDirection=${t.currentDirection}` + + ` sender.track=${t.sender.track ? `${t.sender.track.kind}(enabled=${t.sender.track.enabled})` : "null"}` + + ` receiver.track=${t.receiver.track ? t.receiver.track.kind : "null"}`, + ); + } + + let micT: RTCRtpTransceiver | null = null; + + const mid3 = transceivers.find( + (t) => t.mid === "3" && (t.receiver.track?.kind === "audio" || t.sender.track?.kind === "audio"), + ); + if (mid3) { + micT = mid3; + this.log(`Mic transceiver: matched mid=3 (direction=${mid3.direction})`); + } + + if (!micT) { + const micMid = this.deriveMicMidFromOfferSdp(offerSdp); + if (micMid !== null) { + const byMid = transceivers.find( + (t) => t.mid === micMid && (t.receiver.track?.kind === "audio" || t.sender.track?.kind === "audio"), + ); + if (byMid) { + micT = byMid; + this.log(`Mic transceiver: matched via SDP m-line mapping mid=${micMid} (direction=${byMid.direction})`); + } else { + this.log(`Mic transceiver: SDP says mid=${micMid} but no matching audio transceiver found`); + } + } else { + this.log("Mic transceiver: could not derive mic mid from offer SDP"); + } + } + + if (!micT) { + this.log("WARNING: No mic transceiver found — mic audio will NOT transmit upstream"); + return; + } + + if (micT.direction === "recvonly" || micT.direction === "inactive") { + const wasDirec = micT.direction; + try { + micT.direction = "sendrecv"; + this.log(`Mic transceiver direction changed: ${wasDirec} → sendrecv`); + } catch (err) { + this.log(`Failed to change mic transceiver direction: ${String(err)}`); + } + } + + this.micTransceiver = micT; + + if (this.micService) { + this.micService.setMicTransceiver(micT); + } + } + + private deriveMicMidFromOfferSdp(sdp: string): string | null { + const lines = sdp.split(/\r?\n/); + let mLineIndex = -1; + let currentMid: string | null = null; + let currentType = ""; + let currentDirection = ""; + const mLineSections: { index: number; type: string; mid: string | null; direction: string }[] = []; + + for (const line of lines) { + if (line.startsWith("m=")) { + if (mLineIndex >= 0) { + mLineSections.push({ index: mLineIndex, type: currentType, mid: currentMid, direction: currentDirection }); + } + mLineIndex++; + currentType = line.split(" ")[0].replace("m=", ""); + currentMid = null; + currentDirection = ""; + } else if (line.startsWith("a=mid:")) { + currentMid = line.replace("a=mid:", "").trim(); + } else if ( + line === "a=recvonly" || line === "a=sendonly" || + line === "a=sendrecv" || line === "a=inactive" + ) { + currentDirection = line.replace("a=", ""); + } + } + if (mLineIndex >= 0) { + mLineSections.push({ index: mLineIndex, type: currentType, mid: currentMid, direction: currentDirection }); + } + + this.log(`Offer SDP m-line map: ${mLineSections.map((s) => `[${s.index}] m=${s.type} mid=${s.mid} dir=${s.direction}`).join(" | ")}`); + + if (mLineSections.length >= 4) { + const micSection = mLineSections[3]; + if (micSection.mid) { + this.log(`SDP fallback: m-line index 3 (m=${micSection.type}) has mid=${micSection.mid}`); + return micSection.mid; + } + } + + const audioSections = mLineSections.filter((s) => s.type === "audio"); + if (audioSections.length >= 2) { + const micCandidate = audioSections.find((s) => s.direction === "recvonly" || s.direction === "inactive"); + if (micCandidate?.mid) { + this.log(`SDP fallback: recvonly/inactive audio m-line at index ${micCandidate.index} has mid=${micCandidate.mid}`); + return micCandidate.mid; + } + const second = audioSections[1]; + if (second?.mid) { + this.log(`SDP fallback: second audio m-line at index ${second.index} has mid=${second.mid}`); + return second.mid; + } + } + + return null; + } + + getPeerConnection(): RTCPeerConnection | null { + return this.pc; + } async handleOffer(offerSdp: string, session: SessionInfo, settings: OfferSettings): Promise { this.cleanupPeerConnection(); this.log("=== handleOffer START ==="); diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index b6166226..c8ab7da0 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -1809,9 +1809,55 @@ body.controller-mode { background: var(--card-hover); border-color: rgba(255,255,255,0.12); transform: translateY(-1px); + +.mic-device-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-surface); +} + +.mic-refresh-btn { + flex-shrink: 0; + padding: 6px 8px !important; } + +.mic-test-meter-wrap { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.mic-level-meter { + flex: 1; + height: 8px; + background: var(--chip); + border-radius: 4px; + overflow: hidden; + min-width: 120px; + max-width: 260px; +} + +.mic-level-meter-fill { + width: 100%; + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--warning)); + border-radius: 4px; + transform-origin: left; + transition: transform 60ms linear;} .settings-export-logs-btn:active { transform: translateY(0); } +.mic-test-btn { + flex-shrink: 0; + white-space: nowrap; +} + +.mic-test-error { + font-size: 0.72rem; + color: var(--error); + margin-top: 2px; +} + /* Codec Diagnostics */ .codec-test-btn { display: flex; align-items: center; gap: 8px; @@ -2442,6 +2488,20 @@ body.controller-mode { font-size: 0.65rem; font-family: inherit; color: var(--ink-soft); } +/* Microphone status indicator — positioned left of sv-end (right:64px + 38px width + 8px gap = 110px) */ +.sv-mic { + position: fixed; bottom: 18px; right: 110px; z-index: 1001; + display: inline-flex; align-items: center; justify-content: center; gap: 5px; + height: 38px; padding: 0 10px; + background: rgba(10, 10, 12, 0.9); + border: 1px solid var(--panel-border); border-radius: var(--r-sm); + font-size: 0.7rem; line-height: 1; color: var(--ink-muted); + animation: fade-in 300ms var(--ease); +} +.sv-mic--on { color: var(--success); border-color: rgba(74, 222, 128, 0.25); } +.sv-mic--off { color: var(--ink-muted); opacity: 0.7; } +.sv-mic-label { white-space: nowrap; line-height: 1; } + /* Game title toast */ .sv-title-bar { position: fixed; bottom: 68px; left: 50%; transform: translateX(-50%); z-index: 1001; diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index eaa823ba..b5a71c8e 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -1,7 +1,62 @@ 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) */ @@ -49,8 +104,8 @@ export interface Settings { sessionClockShowDurationSeconds: number; windowWidth: number; windowHeight: number; + keyboardLayout: KeyboardLayout;} } - export interface LoginProvider { idpId: string; code: string;