Skip to content

Commit

Permalink
Add tutorials for Virtual Display and SBS modes
Browse files Browse the repository at this point in the history
  • Loading branch information
wheaney committed Dec 25, 2023
1 parent 022896f commit 04d4003
Show file tree
Hide file tree
Showing 12 changed files with 344 additions and 22 deletions.
Binary file added assets/tutorials/common/display-resolution.webp
Binary file not shown.
Binary file not shown.
Binary file added assets/tutorials/sbs/scaling-mode-stretch.webp
Binary file not shown.
Binary file not shown.
14 changes: 14 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
DRIVER_STATE_FILE_PATH = '/dev/shm/xr_driver_state'

INSTALLED_VERSION_SETTING_KEY = "installed_from_plugin_version"
DONT_SHOW_AGAIN_SETTING_KEY = "dont_show_again"
CONTROL_FLAGS = ['recenter_screen', 'recalibrate', 'sbs_mode']
SBS_MODE_VALUES = ['unset', 'enable', 'disable']

Expand Down Expand Up @@ -156,6 +157,19 @@ async def retrieve_driver_state(self):

return state

async def retrieve_dont_show_again_keys(self):
return [key for key in settings.getSetting(DONT_SHOW_AGAIN_SETTING_KEY, "").split(",") if key]

async def set_dont_show_again(self, key):
try:
dont_show_again_keys = await self.retrieve_dont_show_again_keys(self)
dont_show_again_keys.append(key)
settings.setSetting(DONT_SHOW_AGAIN_SETTING_KEY, ",".join(dont_show_again_keys))
return True
except Exception as e:
decky_plugin.logger.error(f"Error setting dont_show_again {e}")
return False

async def is_driver_running(self):
try:
output = subprocess.check_output(['systemctl', 'is-active', 'xreal-air-driver'], stderr=subprocess.STDOUT)
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "decky-xrealAir",
"version": "0.5.5",
"version": "0.5.6",
"description": "Virtual display and head-tracking modes for the XREAL Air glasses",
"scripts": {
"build": "shx rm -rf dist && rollup -c",
Expand Down Expand Up @@ -34,6 +34,7 @@
"@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-replace": "^4.0.0",
"@rollup/plugin-typescript": "^8.3.3",
"@rollup/plugin-url": "^8.0.2",
"@types/react": "16.14.0",
"@types/webpack": "^5.28.0",
"rollup": "^2.77.1",
Expand Down
51 changes: 51 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { defineConfig } from 'rollup';
import importAssets from 'rollup-plugin-import-assets';

import { name } from "./plugin.json";
import url from "@rollup/plugin-url";

export default defineConfig({
input: './src/index.tsx',
Expand All @@ -21,6 +22,14 @@ export default defineConfig({
}),
importAssets({
publicPath: `http://127.0.0.1:1337/plugins/${name}/`
}),
url({
include: ['**/*.webp'],
emitFiles: true,
limit: 0,
destDir: 'dist/assets',
fileName: '[name][hash][extname]',
publicPath: `http://127.0.0.1:1337/plugins/${name}/assets/`
})
],
context: 'window',
Expand Down
77 changes: 56 additions & 21 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
ToggleField
} from "decky-frontend-lib";
// @ts-ignore
import React, {Fragment, useEffect, useState, VFC} from "react";
import React, {Dispatch, Fragment, SetStateAction, useEffect, useState, VFC} from "react";
import {FaGlasses} from "react-icons/fa";
import {BiMessageError} from "react-icons/bi";
import { PiPlugsConnected } from "react-icons/pi";
Expand All @@ -22,6 +22,8 @@ import {SiDiscord, SiKofi} from 'react-icons/si';
import {LuHelpCircle} from 'react-icons/lu';
import QrButton from "./QrButton";
import beam from "../assets/beam.png";
import {onChangeTutorial} from "./tutorials";
import {useStableState} from "./stableState";

interface Config {
disabled: boolean;
Expand Down Expand Up @@ -80,12 +82,14 @@ function headsetModeToConfig(headsetMode: HeadsetModeOption, joystickMode: boole
}
}

function configToHeadsetMode(config: Config): HeadsetModeOption {
if (config.disabled) return "disabled"
function configToHeadsetMode(config?: Config): HeadsetModeOption {
if (!config || config.disabled) return "disabled"
if (config.output_mode == "external_only") return "virtual_display"
return "vr_lite"
}

const HeadsetModeConfirmationTimeoutMs = 1000

const ModeNotchLabels: NotchLabel[] = [
{
label: "Virtual display",
Expand Down Expand Up @@ -155,6 +159,8 @@ const Content: VFC<{ serverAPI: ServerAPI }> = ({serverAPI}) => {
const [installationStatus, setInstallationStatus] = useState<InstallationStatus>("checking");
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
const [error, setError] = useState<string>();
const [dontShowAgainKeys, setDontShowAgainKeys] = useState<string[]>([]);
const [dirtyHeadsetMode, stableHeadsetMode, setDirtyHeadsetMode] = useStableState<HeadsetModeOption | undefined>(undefined, HeadsetModeConfirmationTimeoutMs);

async function retrieveConfig() {
const configRes: ServerResponse<Config> = await serverAPI.callPluginMethod<{}, Config>("retrieve_config", {});
Expand All @@ -176,6 +182,15 @@ const Content: VFC<{ serverAPI: ServerAPI }> = ({serverAPI}) => {
}, 1000);
}

async function retrieveDontShowAgainKeys() {
const dontShowAgainKeysRes: ServerResponse<string[]> = await serverAPI.callPluginMethod<{}, string[]>("retrieve_dont_show_again_keys", {});
if (dontShowAgainKeysRes.success) {
setDontShowAgainKeys(dontShowAgainKeysRes.result);
} else {
setError(dontShowAgainKeysRes.result);
}
}

async function checkInstallation() {
const installedRes: ServerResponse<boolean> = await serverAPI.callPluginMethod<{}, boolean>("is_driver_installed", {});
if (installedRes.success) {
Expand Down Expand Up @@ -208,11 +223,21 @@ const Content: VFC<{ serverAPI: ServerAPI }> = ({serverAPI}) => {
res.success ? setDirtyControlFlags({...flags, last_updated: Date.now()}) : setError(res.result);
}

async function setDontShowAgain(key: string) {
const res = await serverAPI.callPluginMethod<{ key: string }, void>("set_dont_show_again", { key });
if (res.success) {
setDontShowAgainKeys([...dontShowAgainKeys, key]);
} else {
setError(res.result);
}
}

// these asynchronous calls should execute ONLY one time, hence the empty array as the second argument
useEffect(() => {
retrieveConfig().catch((err) => setError(err));
checkInstallation().catch((err) => setError(err));
retrieveDriverState().catch((err) => setError(err));
retrieveDontShowAgainKeys().catch((err) => setError(err));
}, []);

useEffect(() => {
Expand All @@ -228,12 +253,24 @@ const Content: VFC<{ serverAPI: ServerAPI }> = ({serverAPI}) => {
}
}, [driverState])

// this effect will be triggered after headsetMode has been stable for a certain period of time
useEffect(() => {
if (stableHeadsetMode && config) {
onChangeTutorial(`headset_mode_${stableHeadsetMode}`, () => {
updateConfig({
...config,
...headsetModeToConfig(stableHeadsetMode, config.output_mode == "joystick")
}).catch(e => setError(e))
}, dontShowAgainKeys, setDontShowAgain);
}
}, [stableHeadsetMode])

const deviceConnected = !!driverState?.connected_device_brand && !!driverState?.connected_device_model
const deviceName = deviceConnected ? `${driverState?.connected_device_brand} ${driverState?.connected_device_model}` : "No device connected"
const isDisabled = !deviceConnected || (config?.disabled ?? false)
const headsetMode: HeadsetModeOption = config ? configToHeadsetMode(config) : "disabled"
const isVirtualDisplayMode = !isDisabled && config?.output_mode == "external_only"
const isVrLiteMode = !isDisabled && config?.output_mode != "external_only";
const headsetMode: HeadsetModeOption = dirtyHeadsetMode ?? configToHeadsetMode(config)
const isDisabled = !deviceConnected || headsetMode == 'disabled'
const isVirtualDisplayMode = !isDisabled && headsetMode == 'virtual_display'
const isVrLiteMode = !isDisabled && headsetMode == 'vr_lite'
let sbsModeEnabled = driverState?.sbs_mode_enabled ?? false
if (dirtyControlFlags?.sbs_mode && dirtyControlFlags?.sbs_mode !== 'unset') sbsModeEnabled = dirtyControlFlags.sbs_mode === 'enable'
const calibrating = dirtyControlFlags.recalibrate || driverState?.calibration_state == "CALIBRATING";
Expand All @@ -243,11 +280,16 @@ const Content: VFC<{ serverAPI: ServerAPI }> = ({serverAPI}) => {
checked={sbsModeEnabled}
label={"Enable side-by-side mode"}
description={!driverState?.sbs_mode_enabled && "Adjust virtual display depth. View 3D content."}
onChange={(sbs_mode_enabled) => writeControlFlags(
{
sbs_mode: sbs_mode_enabled ? 'enable' : 'disable'
}
)}/>
onChange={(sbs_mode_enabled) => {
onChangeTutorial(`sbs_mode_enabled_${sbs_mode_enabled}`, () => {
writeControlFlags(
{
sbs_mode: sbs_mode_enabled ? 'enable' : 'disable'
}
)}, dontShowAgainKeys, setDontShowAgain
)
}}
/>
</PanelSectionRow>;

const joystickModeButton = <PanelSectionRow>
Expand Down Expand Up @@ -336,18 +378,11 @@ const Content: VFC<{ serverAPI: ServerAPI }> = ({serverAPI}) => {
min={0} max={HeadsetModeOptions.length-1}
notchLabels={ModeNotchLabels}
notchCount={HeadsetModeOptions.length}
onChange={(newMode) => {
if (config) {
updateConfig({
...config,
...headsetModeToConfig(HeadsetModeOptions[newMode], isJoystickMode)
}).catch(e => setError(e))
}
}}
onChange={(newMode) => setDirtyHeadsetMode(HeadsetModeOptions[newMode])}
/>
</PanelSectionRow>}
{!isDisabled && isVrLiteMode && isJoystickMode && joystickModeButton}
{!isDisabled && config.output_mode == "mouse" && <PanelSectionRow>
{!isDisabled && isVrLiteMode && !isJoystickMode && <PanelSectionRow>
<SliderField value={config.mouse_sensitivity}
min={5} max={100} showValue={true} notchTicksVisible={true}
label={"Mouse sensitivity"}
Expand Down
17 changes: 17 additions & 0 deletions src/stableState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {Dispatch, SetStateAction, useEffect, useState} from "react";

// allows for setting a dirty state that doesn't take effect (as a stable state) until a delay has passed without change
export function useStableState<T>(initialState: T, delay: number) : [T, T, Dispatch<SetStateAction<T>>] {
const [dirtyState, setDirtyState] = useState(initialState);
const [stableState, setStableState] = useState(initialState);

useEffect(() => {
const timeoutId = setTimeout(() => {
setStableState(dirtyState);
}, delay);

return () => clearTimeout(timeoutId);
}, [dirtyState, delay]);

return [dirtyState, stableState, setDirtyState];
}
Loading

0 comments on commit 04d4003

Please sign in to comment.