Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions opennow-stable/src/main/gfn/cloudmatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ function buildSessionRequestBody(input: SessionCreateRequest, deviceHashId: stri
const bitDepth = colorQualityBitDepth(cq);
const chromaFormat = colorQualityChromaFormat(cq);
const accountLinked = input.accountLinked ?? true;
const sonyHidEnabled = input.settings.experimentalGamepadGyro === true;

return {
sessionRequestData: {
Expand Down Expand Up @@ -550,7 +551,7 @@ function buildSessionRequestBody(input: SessionCreateRequest, deviceHashId: stri
}
: null,
surroundAudioInfo: 0,
remoteControllersBitmap: 0,
remoteControllersBitmap: sonyHidEnabled ? 1 : 0,
clientTimezoneOffset: timezoneOffsetMs(),
enhancedStreamMode: 1,
appLaunchMode: 1,
Expand All @@ -566,7 +567,7 @@ function buildSessionRequestBody(input: SessionCreateRequest, deviceHashId: stri
enabledL4S: input.settings.enableL4S,
mouseMovementFlags: 0,
trueHdr: hdrEnabled,
supportedHidDevices: 0,
supportedHidDevices: sonyHidEnabled ? 3 : 0,
profile: 0,
fallbackToLogicalResolution: false,
hidDevices: null,
Expand Down
16 changes: 15 additions & 1 deletion opennow-stable/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1622,7 +1622,7 @@ function registerIpcHandlers(): void {
});

ipcMain.handle(IPC_CHANNELS.SETTINGS_SET, async <K extends keyof Settings>(_event: Electron.IpcMainInvokeEvent, key: K, value: Settings[K]) => {
settingsManager.set(key, value);
settingsManager.set(key as keyof import("./settings").Settings, value as import("./settings").Settings[keyof import("./settings").Settings]);
// React to certain setting changes immediately in main process
try {
if (key === "autoCheckForUpdates") {
Expand Down Expand Up @@ -2281,6 +2281,7 @@ app.whenReady().then(async () => {
"pointerLock",
"keyboardLock",
"speaker-selection",
"hid",
Comment thread
capy-ai[bot] marked this conversation as resolved.
Outdated
]);

if (allowedPermissions.has(permission)) {
Expand All @@ -2300,11 +2301,24 @@ app.whenReady().then(async () => {
"pointerLock",
"keyboardLock",
"speaker-selection",
"hid",
]);

return allowedPermissions.has(permission);
});

session.defaultSession.setDevicePermissionHandler((details) => {
return details.deviceType === "hid" && details.device.vendorId === 0x054c;
});

session.defaultSession.on("select-hid-device", (event, details, callback) => {
const sonyDevice = details.deviceList.find((device) => device.vendorId === 0x054c);
if (sonyDevice) {
event.preventDefault();
callback(sonyDevice.deviceId);
}
});

registerOpenNowMediaProtocol();
registerIpcHandlers();

Expand Down
3 changes: 3 additions & 0 deletions opennow-stable/src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export interface Settings {
enableL4S: boolean;
/** Request Cloud G-Sync / Variable Refresh Rate on new sessions */
enableCloudGsync: boolean;
/** Experimental Sony controller gyro support over WebHID */
experimentalGamepadGyro: boolean;
/** Show the currently streaming game as Discord Rich Presence activity */
discordRichPresence: boolean;
/** Automatically check GitHub Releases for app updates in the background */
Expand Down Expand Up @@ -187,6 +189,7 @@ const DEFAULT_SETTINGS: Settings = {
gameLanguage: "en_US",
enableL4S: false,
enableCloudGsync: false,
experimentalGamepadGyro: false,
discordRichPresence: false,
autoCheckForUpdates: true,
allowEscapeToExitFullscreen: false,
Expand Down
20 changes: 20 additions & 0 deletions opennow-stable/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
type StreamDiagnostics,
type StreamTimeWarning,
} from "./gfn/webrtcClient";
import { requestSonyGamepadHidAccess } from "./gfn/gamepadMotion";
import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut } from "./shortcuts";
import { useControllerNavigation } from "./controllerNavigation";
import { useElapsedSeconds } from "./utils/useElapsedSeconds";
Expand Down Expand Up @@ -935,6 +936,7 @@ export function App(): JSX.Element {
gameLanguage: "en_US",
enableL4S: false,
enableCloudGsync: false,
experimentalGamepadGyro: false,
discordRichPresence: false,
autoCheckForUpdates: true,
});
Expand Down Expand Up @@ -2444,6 +2446,13 @@ export function App(): JSX.Element {
// Save settings when changed
const updateSetting = useCallback(async <K extends keyof Settings>(key: K, value: Settings[K]) => {
setSettings((prev) => ({ ...prev, [key]: value }));
if (key === "experimentalGamepadGyro" && value) {
try {
void requestSonyGamepadHidAccess((line) => console.log(`[WebHID] ${line}`));
} catch {
// ignore
}
}
if (settingsLoaded) {
await window.openNow.setSetting(key, value);
}
Expand Down Expand Up @@ -2476,6 +2485,13 @@ export function App(): JSX.Element {
// ignore
}
}
if (key === "experimentalGamepadGyro") {
try {
clientRef.current?.setExperimentalGamepadGyroEnabled(Boolean(value));
} catch {
// ignore
}
}
}, [settingsLoaded]);

const handleStreamVolumeChange = useCallback((v: number) => {
Expand Down Expand Up @@ -3025,6 +3041,7 @@ export function App(): JSX.Element {
gameLanguage: settings.gameLanguage,
enableL4S: settings.enableL4S,
enableCloudGsync: settings.enableCloudGsync,
experimentalGamepadGyro: settings.experimentalGamepadGyro,
},
});

Expand Down Expand Up @@ -3185,6 +3202,7 @@ export function App(): JSX.Element {
gameLanguage: settings.gameLanguage,
enableL4S: settings.enableL4S,
enableCloudGsync: settings.enableCloudGsync,
experimentalGamepadGyro: settings.experimentalGamepadGyro,
},
});
if (!isRecoveryGenerationCurrent(recoveryGeneration)) {
Expand Down Expand Up @@ -3301,6 +3319,7 @@ export function App(): JSX.Element {
microphoneDeviceId: settings.microphoneDeviceId || undefined,
mouseSensitivity: settings.mouseSensitivity,
mouseAcceleration: settings.mouseAcceleration,
experimentalGamepadGyro: settings.experimentalGamepadGyro,
onLog: (line: string) => console.log(`[WebRTC] ${line}`),
onStats: (stats) => diagnosticsStore.set(stats),
onTimeWarning: (warning) => {
Expand Down Expand Up @@ -3639,6 +3658,7 @@ export function App(): JSX.Element {
gameLanguage: settings.gameLanguage,
enableL4S: settings.enableL4S,
enableCloudGsync: settings.enableCloudGsync,
experimentalGamepadGyro: settings.experimentalGamepadGyro,
},
});

Expand Down
18 changes: 18 additions & 0 deletions opennow-stable/src/renderer/src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2157,6 +2157,24 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults,
</label>
</div>

<div className="settings-row settings-row--top-aligned">
<label className="settings-label settings-label--wrap">
<span>
Experimental Gamepad Gyro
<span className="settings-inline-badge settings-inline-badge--beta">Beta</span>
</span>
<span className="settings-hint">Requires a Sony DualShock 4 or DualSense exposed through WebHID. Sends DS4-style HID motion packets to GFN when motion data is available.</span>
</label>
<label className="settings-toggle">
<input
type="checkbox"
checked={settings.experimentalGamepadGyro ?? false}
onChange={(e) => handleChange("experimentalGamepadGyro", e.target.checked)}
/>
<span className="settings-toggle-track" />
</label>
</div>

<div className="settings-row settings-row--top-aligned">
<label className="settings-label settings-label--wrap">
Keyboard Layout
Expand Down
200 changes: 200 additions & 0 deletions opennow-stable/src/renderer/src/gfn/gamepadMotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
export interface GamepadMotionSample {
receivedAtMs: number;
gyroX: number;
gyroY: number;
gyroZ: number;
accelX: number;
accelY: number;
accelZ: number;
sensorTimestamp: number;
}

type HidDeviceFilter = { vendorId?: number; productId?: number };
type HidRequestOptions = { filters: HidDeviceFilter[] };
type HidInputReportEvent = Event & { device: HidDevice; reportId: number; data: DataView };
type HidDevice = EventTarget & {
vendorId: number;
productId: number;
productName?: string;
opened: boolean;
open(): Promise<void>;
close(): Promise<void>;
addEventListener(type: "inputreport", listener: (event: HidInputReportEvent) => void): void;
removeEventListener(type: "inputreport", listener: (event: HidInputReportEvent) => void): void;
};
type HidNavigator = Navigator & {
hid?: {
getDevices(): Promise<HidDevice[]>;
requestDevice(options: HidRequestOptions): Promise<HidDevice[]>;
};
};

const SONY_VENDOR_ID = 0x054c;
const DEFAULT_FRESHNESS_MS = 150;
const DS4_PRODUCT_IDS = new Set([0x05c4, 0x09cc]);
const DUALSENSE_PRODUCT_IDS = new Set([0x0ce6, 0x0df2]);

export function isSonyGamepad(gamepad: Gamepad): boolean {
return /054c|sony|wireless controller|dualsense|dualshock/i.test(gamepad.id);
}

export async function requestSonyGamepadHidAccess(log: (line: string) => void = () => {}): Promise<boolean> {
const hid = (navigator as HidNavigator).hid;
if (!hid?.requestDevice) {
log("Experimental gamepad gyro permission skipped: WebHID is not exposed");
return false;
}

try {
const devices = await hid.requestDevice({ filters: [{ vendorId: SONY_VENDOR_ID }] });
return devices.some(isSonyHidDevice);
} catch (error) {
log(`Experimental gamepad gyro permission request failed: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}

export class GamepadMotionManager {
private enabled = false;
private devices: HidDevice[] = [];
private latestSample: GamepadMotionSample | null = null;
private requested = false;
private readonly onReport = (event: HidInputReportEvent): void => this.handleInputReport(event);

constructor(private readonly log: (line: string) => void = () => {}) {}

get supported(): boolean {
return Boolean((navigator as HidNavigator).hid);
}

async setEnabled(enabled: boolean, requestPermission = false): Promise<void> {
if (enabled === this.enabled && (!enabled || !requestPermission || this.requested)) {
return;
}
this.enabled = enabled;
if (!enabled) {
this.stop();
return;
}
await this.start(requestPermission);
}

async start(requestPermission = false): Promise<void> {
const hid = (navigator as HidNavigator).hid;
if (!hid) {
this.log("Experimental gamepad gyro unavailable: WebHID is not exposed");
return;
}

try {
const devices = hid.getDevices ? await hid.getDevices() : [];
await this.openDevices(devices.filter(isSonyHidDevice));
if (requestPermission && !this.requested) {
this.requested = true;
await requestSonyGamepadHidAccess(this.log);
const requestedDevices = hid.getDevices ? await hid.getDevices() : [];
await this.openDevices(requestedDevices.filter(isSonyHidDevice));
}
} catch (error) {
this.log(`Experimental gamepad gyro WebHID start failed: ${error instanceof Error ? error.message : String(error)}`);
}
}

stop(): void {
for (const device of this.devices) {
try {
device.removeEventListener("inputreport", this.onReport);
} catch {}
try {
if (device.opened) {
void device.close().catch(() => {});
}
} catch {}
}
this.devices = [];
this.latestSample = null;
}

getFreshSample(gamepad: Gamepad, controllerId: number, maxAgeMs = DEFAULT_FRESHNESS_MS): GamepadMotionSample | null {
if (!this.enabled || controllerId !== 0 || !isSonyGamepad(gamepad)) {
return null;
}
const sample = this.latestSample;
if (!sample || performance.now() - sample.receivedAtMs > maxAgeMs) {
return null;
}
return sample;
}

private async openDevices(devices: HidDevice[]): Promise<void> {
for (const device of devices) {
if (this.devices.includes(device)) {
continue;
}
try {
if (!device.opened) {
await device.open();
}
device.addEventListener("inputreport", this.onReport);
this.devices.push(device);
this.log(`Experimental gamepad gyro opened Sony HID device: ${device.productName || `0x${device.productId.toString(16)}`}`);
} catch (error) {
this.log(`Experimental gamepad gyro could not open Sony HID device: ${error instanceof Error ? error.message : String(error)}`);
}
}
}

private handleInputReport(event: HidInputReportEvent): void {
if (!isSonyHidDevice(event.device)) {
return;
}
const sample = parseSonyMotionReport(event.device, event.reportId, event.data);
if (sample) {
this.latestSample = sample;
}
}
}

function isSonyHidDevice(device: HidDevice): boolean {
return device.vendorId === SONY_VENDOR_ID;
}

function parseSonyMotionReport(device: HidDevice, reportId: number, data: DataView): GamepadMotionSample | null {
if (reportId !== 1) {
return null;
}

if (isDualSenseHidDevice(device)) {
return data.byteLength >= 27 ? sampleFromOffsets(data, 15, 17, 19, 21, 23, 25) : null;
}

if (isDs4HidDevice(device) || !isDualSenseHidDevice(device)) {
return data.byteLength >= 24 ? sampleFromOffsets(data, 12, 14, 16, 18, 20, 22) : null;
}

return null;
}

function isDs4HidDevice(device: HidDevice): boolean {
const name = device.productName ?? "";
return DS4_PRODUCT_IDS.has(device.productId) || /dualshock|wireless controller/i.test(name);
}

function isDualSenseHidDevice(device: HidDevice): boolean {
const name = device.productName ?? "";
return DUALSENSE_PRODUCT_IDS.has(device.productId) || /dualsense/i.test(name);
}

function sampleFromOffsets(data: DataView, gx: number, gy: number, gz: number, ax: number, ay: number, az: number): GamepadMotionSample {
const now = performance.now();
return {
receivedAtMs: now,
gyroX: data.getInt16(gx, true),
gyroY: data.getInt16(gy, true),
gyroZ: data.getInt16(gz, true),
accelX: data.getInt16(ax, true),
accelY: data.getInt16(ay, true),
accelZ: data.getInt16(az, true),
sensorTimestamp: Math.floor(now) & 0xffff,
};
}
Loading
Loading