mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-06 02:01:58 +02:00
Compare commits
43 Commits
v0.14.1
...
accel-mapp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a891a0323 | ||
|
|
d3b57048e9 | ||
|
|
c8a6e2fc0b | ||
|
|
471714afd0 | ||
|
|
91ce0ed734 | ||
|
|
a35d2a77a2 | ||
|
|
ea31d58fb8 | ||
|
|
e74dee1892 | ||
|
|
d84fc40b23 | ||
|
|
db68d808dd | ||
|
|
0dd004825d | ||
|
|
9f36444169 | ||
|
|
52509c7950 | ||
|
|
868c441220 | ||
|
|
5ad408f61d | ||
|
|
9b30f9800c | ||
|
|
07e5ef647a | ||
|
|
47909ad46c | ||
|
|
99c92fad9e | ||
|
|
d7861614b7 | ||
|
|
b4a3e9cbd5 | ||
|
|
e58946f622 | ||
|
|
c8532c76c2 | ||
|
|
6f26ea7b50 | ||
|
|
a848dfac43 | ||
|
|
e5ea747cde | ||
|
|
34a782193b | ||
|
|
8498270df6 | ||
|
|
cd6ed7296b | ||
|
|
57541e560c | ||
|
|
6f877c3852 | ||
|
|
ca8d75e749 | ||
|
|
0dc073ca48 | ||
|
|
edbaf49e7a | ||
|
|
ca790a3799 | ||
|
|
3639bdc0ff | ||
|
|
6817eca793 | ||
|
|
03365eb050 | ||
|
|
df5744b861 | ||
|
|
bc602892b4 | ||
|
|
f49121b8a4 | ||
|
|
c31aab8237 | ||
|
|
8e7070d257 |
3
.github/workflows/build-gui.yml
vendored
3
.github/workflows/build-gui.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
- if: matrix.os == 'ubuntu-22.04'
|
||||
name: Set up Linux dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.0
|
||||
with:
|
||||
packages: libgtk-3-dev webkit2gtk-4.1 libappindicator3-dev librsvg2-dev patchelf
|
||||
# Increment to invalidate the cache
|
||||
@@ -87,6 +87,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
NODE_OPTIONS: ${{ matrix.os == 'macos-latest' && '--max-old-space-size=4096' || '' }}
|
||||
run: pnpm run skipbundler --config $( ./gui/scripts/gitversion.mjs )
|
||||
|
||||
- if: matrix.os == 'windows-latest'
|
||||
|
||||
3
.github/workflows/gradle.yaml
vendored
3
.github/workflows/gradle.yaml
vendored
@@ -161,7 +161,7 @@ jobs:
|
||||
path: server/desktop/build/libs/
|
||||
|
||||
- name: Set up Linux dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.0
|
||||
with:
|
||||
packages: |
|
||||
build-essential curl wget file libssl-dev libgtk-3-dev libappindicator3-dev librsvg2-dev
|
||||
@@ -280,6 +280,7 @@ jobs:
|
||||
- name: Build
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: pnpm run tauri build --target universal-apple-darwin --config $( ./gui/scripts/gitversion.mjs )
|
||||
|
||||
- name: Modify Application
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,3 +46,4 @@ local.properties
|
||||
|
||||
# Ignore temporary config
|
||||
vrconfig.yml.tmp
|
||||
*.DS_Store
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default {
|
||||
'**/*.{ts,tsx}': () => 'tsc -p tsconfig.json --noEmit',
|
||||
'src/**/*.{js,jsx,ts,tsx}': 'eslint --max-warnings=0 --no-warn-ignored --cache --fix',
|
||||
'**/*.{js,jsx,ts,tsx,css,md,json}': 'prettier --write',
|
||||
'**/*.{js,jsx,ts,tsx,css,scss,md,json}': 'prettier --write',
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"@fontsource/poppins": "^5.1.0",
|
||||
"@formatjs/intl-localematcher": "^0.2.32",
|
||||
"@hookform/resolvers": "^3.6.0",
|
||||
"@react-hookz/deep-equal": "^3.0.3",
|
||||
"@react-three/drei": "^9.114.3",
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"@sentry/react": "^9.9.0",
|
||||
@@ -21,11 +22,13 @@
|
||||
"@tauri-apps/plugin-os": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@tauri-apps/plugin-store": "^2.0.0",
|
||||
"@twemoji/svg": "^15.0.0",
|
||||
"browser-fs-access": "^0.35.0",
|
||||
"classnames": "^2.5.1",
|
||||
"flatbuffers": "22.10.26",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"ip-num": "^1.5.1",
|
||||
"jotai": "^2.12.2",
|
||||
"prompts": "^2.4.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -96,4 +99,4 @@
|
||||
"typescript-eslint": "^8.8.0",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -104,29 +104,125 @@ board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR Dev IMU Glove
|
||||
## Proportions
|
||||
skeleton_bone-NONE = None
|
||||
skeleton_bone-HEAD = Head Shift
|
||||
skeleton_bone-HEAD-desc =
|
||||
This is the distance from your headset to the middle of your head.
|
||||
To adjust it, shake your head left to right as if you're disagreeing and modify
|
||||
it until any movement in other trackers is negligible.
|
||||
skeleton_bone-NECK = Neck Length
|
||||
skeleton_bone-NECK-desc =
|
||||
This is the distance from the middle of your head to the base of your neck.
|
||||
To adjust it, move your head up and down as if you're nodding or tilt your head
|
||||
to the left and right and modify it until any movement in other trackers is negligible.
|
||||
skeleton_bone-torso_group = Torso length
|
||||
skeleton_bone-torso_group-desc =
|
||||
This is the distance from the base of your neck to your hips.
|
||||
To adjust it, modify it standing up straight until your virtual hips line
|
||||
up with your real ones.
|
||||
skeleton_bone-UPPER_CHEST = Upper Chest Length
|
||||
skeleton_bone-UPPER_CHEST-desc =
|
||||
This is the distance from the base of your neck to the middle of your chest.
|
||||
To adjust it, adjust your Torso Length properly and modify it in various positions
|
||||
(sitting down, bending over, lying down, etc.) until your virtual spine matches with your real one.
|
||||
skeleton_bone-CHEST_OFFSET = Chest Offset
|
||||
skeleton_bone-CHEST_OFFSET-desc =
|
||||
This can be adjusted to move your virtual chest tracker up or down in order to aid
|
||||
with calibration in certain games or applications that may expect it to be higher or lower.
|
||||
skeleton_bone-CHEST = Chest Length
|
||||
skeleton_bone-CHEST-desc =
|
||||
This is the distance from the middle of your chest to the middle of your spine.
|
||||
To adjust it, adjust your Torso Length properly and modify it in various positions
|
||||
(sitting down, bending over, lying down, etc.) until your virtual spine matches with your real one.
|
||||
skeleton_bone-WAIST = Waist Length
|
||||
skeleton_bone-WAIST-desc =
|
||||
This is the distance from the middle of your spine to your belly button.
|
||||
To adjust it, adjust your Torso Length properly and modify it in various positions
|
||||
(sitting down, bending over, lying down, etc.) until your virtual spine matches with your real one.
|
||||
skeleton_bone-HIP = Hip Length
|
||||
skeleton_bone-HIP-desc =
|
||||
This is the distance from your belly button to your hips
|
||||
To adjust it, adjust your Torso Length properly and modify it in various positions
|
||||
(sitting down, bending over, lying down, etc.) until your virtual spine matches with your real one.
|
||||
skeleton_bone-HIP_OFFSET = Hip Offset
|
||||
skeleton_bone-HIP_OFFSET-desc =
|
||||
This can be adjusted to move your virtual hip tracker up or down in order to aid
|
||||
with calibration in certain games or applications that may expect it to be on your waist.
|
||||
skeleton_bone-HIPS_WIDTH = Hips Width
|
||||
skeleton_bone-HIPS_WIDTH-desc =
|
||||
This is the distance between the start of your legs.
|
||||
To adjust it, perform a full reset with your legs straight and modify it until
|
||||
your virtual legs match up with your real ones horizontally.
|
||||
skeleton_bone-leg_group = Leg length
|
||||
skeleton_bone-leg_group-desc =
|
||||
This is the distance from your hips to your feet.
|
||||
To adjust it, adjust your Torso Length properly and modify it
|
||||
until your virtual feet are at the same level as your real ones.
|
||||
skeleton_bone-UPPER_LEG = Upper Leg Length
|
||||
skeleton_bone-UPPER_LEG-desc =
|
||||
This is the distance from your hips to your knees.
|
||||
To adjust it, adjust your Leg Length properly and modify it
|
||||
until your virtual knees are at the same level as your real ones.
|
||||
skeleton_bone-LOWER_LEG = Lower Leg Length
|
||||
skeleton_bone-LOWER_LEG-desc =
|
||||
This is the distance from your knees to your ankles.
|
||||
To adjust it, adjust your Leg Length properly and modify it
|
||||
until your virtual knees are at the same level as your real ones.
|
||||
skeleton_bone-FOOT_LENGTH = Foot Length
|
||||
skeleton_bone-FOOT_LENGTH-desc =
|
||||
This is the distance from your ankles to your toes.
|
||||
To adjust it, tiptoe and modify it until your virtual feet stay in place.
|
||||
skeleton_bone-FOOT_SHIFT = Foot Shift
|
||||
skeleton_bone-FOOT_SHIFT-desc =
|
||||
This value is the horizontal distance from your knee to your ankle.
|
||||
It accounts for your lower legs going backwards when standing up straight.
|
||||
To adjust it, set Foot Length to 0, perform a full reset and modify it until your virtual
|
||||
feet line up with the middle of your ankles.
|
||||
skeleton_bone-SKELETON_OFFSET = Skeleton Offset
|
||||
skeleton_bone-SKELETON_OFFSET-desc =
|
||||
This can be adjusted to offsets all your trackers forward or backwards.
|
||||
It can be used in order to aid with calibration in certain games or applications
|
||||
that may expect your trackers to be more forward.
|
||||
skeleton_bone-SHOULDERS_DISTANCE = Shoulders Distance
|
||||
skeleton_bone-SHOULDERS_DISTANCE-desc =
|
||||
This is the vertical distance from the base of your neck to your shoulders.
|
||||
To adjust it, set Upper Arm Length to 0 and modify it until your virtual elbow trackers
|
||||
line up vertically with your real shoulders.
|
||||
skeleton_bone-SHOULDERS_WIDTH = Shoulders Width
|
||||
skeleton_bone-SHOULDERS_WIDTH-desc =
|
||||
This is the horizontal distance from the base of your neck to your shoulders.
|
||||
To adjust it, set Upper Arm Length to 0 and modify it until your virtual elbow trackers
|
||||
line up horizontally with your real shoulders.
|
||||
skeleton_bone-arm_group = Arm length
|
||||
skeleton_bone-arm_group-desc =
|
||||
This is the distance from your shoulders to your wrists.
|
||||
To adjust it, adjust Shoulders Distance properly, set Hand Distance Y
|
||||
to 0 and modify it until your hand trackers line up with your wrists.
|
||||
skeleton_bone-UPPER_ARM = Upper Arm Length
|
||||
skeleton_bone-UPPER_ARM-desc =
|
||||
This is the distance from your shoulders to your elbows.
|
||||
To adjust it, adjust Arm Length properly and modify it until
|
||||
your elbow trackers line up with your real elbows.
|
||||
skeleton_bone-LOWER_ARM = Lower Arm Length
|
||||
skeleton_bone-LOWER_ARM-desc =
|
||||
This is the distance from your elbows to your wrists.
|
||||
To adjust it, adjust Arm Length properly and modify it until
|
||||
your elbow trackers line up with your real elbows.
|
||||
skeleton_bone-HAND_Y = Hand Distance Y
|
||||
skeleton_bone-HAND_Y-desc =
|
||||
This is the vertical distance from your wrists to the middle of your hand.
|
||||
To adjust it for motion capture, adjust Arm Length properly and modify it until your
|
||||
hand trackers line up vertically with the middle of your hands.
|
||||
To adjust it for elbow tracking from your controllers, set Arm Length to 0 and
|
||||
modify it until your elbow trackers line up vertically with your wrists.
|
||||
skeleton_bone-HAND_Z = Hand Distance Z
|
||||
skeleton_bone-HAND_Z-desc =
|
||||
This is the horizontal distance from your wrists to the middle of your hand.
|
||||
To adjust it for motion capture, set it to 0.
|
||||
To adjust it for elbow tracking from your controllers, set Arm Length to 0 and
|
||||
modify it until your elbow trackers line up horizontally with your wrists.
|
||||
skeleton_bone-ELBOW_OFFSET = Elbow Offset
|
||||
skeleton_bone-ELBOW_OFFSET-desc =
|
||||
This can be adjusted to move your virtual elbow trackers up or down in order to aid
|
||||
with VRChat accidentally binding an elbow tracker to the chest.
|
||||
|
||||
## Tracker reset buttons
|
||||
reset-reset_all = Reset all proportions
|
||||
@@ -362,6 +458,7 @@ settings-sidebar-appearance = Appearance
|
||||
settings-sidebar-notifications = Notifications
|
||||
settings-sidebar-behavior = Behavior
|
||||
settings-sidebar-firmware-tool = DIY Firmware Tool
|
||||
settings-sidebar-vrc_warnings = VRChat Config Warnings
|
||||
settings-sidebar-advanced = Advanced
|
||||
|
||||
## SteamVR settings
|
||||
@@ -533,6 +630,9 @@ settings-general-gesture_control-numberTrackersOverThreshold-description = Incre
|
||||
|
||||
## Appearance settings
|
||||
settings-interface-appearance = Appearance
|
||||
settings-general-interface-dev_mode = Developer Mode
|
||||
settings-general-interface-dev_mode-description = This mode can be useful if you need in-depth data or to interact with connected trackers on a more advanced level.
|
||||
settings-general-interface-dev_mode-label = Developer Mode
|
||||
settings-general-interface-theme = Color theme
|
||||
settings-general-interface-show-navbar-onboarding = Show "{ navbar-onboarding }" on navigation bar
|
||||
settings-general-interface-show-navbar-onboarding-description = This changes if the "{ navbar-onboarding }" button shows on the navigation bar.
|
||||
@@ -814,6 +914,17 @@ onboarding-connect_tracker-connection_status-looking_for_server = Looking for se
|
||||
onboarding-connect_tracker-connection_status-connection_error = Unable to connect to Wi-Fi
|
||||
onboarding-connect_tracker-connection_status-could_not_find_server = Could not find the server
|
||||
onboarding-connect_tracker-connection_status-done = Connected to the Server
|
||||
onboarding-connect_tracker-connection_status-no_serial_log = Could not get logs from the tracker
|
||||
onboarding-connect_tracker-connection_status-no_serial_device_found = Could not find a tracker from USB
|
||||
onboarding-connect_serial-error-modal-no_serial_log = Is the tracker turned on?
|
||||
onboarding-connect_serial-error-modal-no_serial_log-desc = Make sure the tracker is turned on and connected to your computer
|
||||
onboarding-connect_serial-error-modal-no_serial_device_found = No trackers detected
|
||||
onboarding-connect_serial-error-modal-no_serial_device_found-desc =
|
||||
Please connect a tracker with the provided usb cable to your computer and turn the tracker on.
|
||||
If this does not work:
|
||||
- try with another usb cable
|
||||
- try with another usb port
|
||||
- try reinstalling the SlimeVR server and select "USB Drivers" in the components section
|
||||
# $amount (Number) - Amount of trackers connected (this is a number, but you can use CLDR plural rules for your language)
|
||||
# More info on https://www.unicode.org/cldr/cldr-aux/charts/22/supplemental/language_plural_rules.html
|
||||
# English in this case only has 2 plural rules, which are "one" and "other",
|
||||
@@ -987,17 +1098,16 @@ onboarding-automatic_mounting-put_trackers_on-next = I have all my trackers on
|
||||
## Tracker manual proportions setupa
|
||||
onboarding-manual_proportions-back = Go Back to Reset tutorial
|
||||
onboarding-manual_proportions-title = Manual Body Proportions
|
||||
onboarding-manual_proportions-precision = Precision adjust
|
||||
onboarding-manual_proportions-auto = Automatic proportions
|
||||
onboarding-manual_proportions-ratio = Adjust by ratio groups
|
||||
onboarding-manual_proportions-fine_tuning_button = Automatically fine tune proportions
|
||||
onboarding-manual_proportions-fine_tuning_button-disabled-tooltip = Please connect a VR headset to use automatic fine tuning
|
||||
onboarding-manual_proportions-export = Export proportions
|
||||
onboarding-manual_proportions-import = Import proportions
|
||||
onboarding-manual_proportions-import-success = Imported
|
||||
onboarding-manual_proportions-import-failed = Failed
|
||||
onboarding-manual_proportions-file_type = Body proportions file
|
||||
|
||||
onboarding-manual_proportions-normal_increment = Normal increment
|
||||
onboarding-manual_proportions-precise_increment = Precise increment
|
||||
onboarding-manual_proportions-grouped_proportions = Grouped proportions
|
||||
onboarding-manual_proportions-all_proportions = All proportions
|
||||
onboarding-manual_proportions-estimated_height = Estimated user height
|
||||
|
||||
## Tracker automatic proportions setup
|
||||
onboarding-automatic_proportions-back = Go Back to Manual Proportions
|
||||
@@ -1316,6 +1426,51 @@ unknown_device-modal-description = There is a new tracker with MAC address <b>{$
|
||||
unknown_device-modal-confirm = Sure!
|
||||
unknown_device-modal-forget = Ignore it
|
||||
|
||||
|
||||
# VRChat config warnings
|
||||
vrc_config-page-title = VRChat configuration warnings
|
||||
vrc_config-page-desc = This page shows the state of your VRChat settings and shows what settings are incompatible with SlimeVR. It is highly recommended that you fix any warnings showing up here for the best user experience with SlimeVR.
|
||||
vrc_config-page-help = Can't find the settings?
|
||||
vrc_config-page-help-desc = Check out our <a>documentation on this topic!</a>
|
||||
vrc_config-page-big_menu = Tracking & IK (Big Menu)
|
||||
vrc_config-page-big_menu-desc = Settings related to IK in the big settings menu
|
||||
vrc_config-page-wrist_menu = Tracking & IK (Wrist Menu)
|
||||
vrc_config-page-wrist_menu-desc = Settings related to IK in small settings menu (wrist menu)
|
||||
vrc_config-on = On
|
||||
vrc_config-off = Off
|
||||
vrc_config-invalid = You have misconfigured VRChat settings!
|
||||
vrc_config-show_more = Show more
|
||||
vrc_config-setting_name = VRChat Setting name
|
||||
vrc_config-recommended_value = Recommended Value
|
||||
vrc_config-current_value = Current Value
|
||||
vrc_config-mute = Mute Warning
|
||||
vrc_config-mute-btn = Mute
|
||||
vrc_config-unmute-btn = Unmute
|
||||
vrc_config-legacy_mode = Use Legacy IK Solving
|
||||
vrc_config-disable_shoulder_tracking = Disable Shoulder Tracking
|
||||
vrc_config-shoulder_width_compensation = Shoulder Width Compensation
|
||||
vrc_config-spine_mode = FBT Spine Mode
|
||||
vrc_config-tracker_model = FBT Tracker Model
|
||||
vrc_config-avatar_measurement_type = Avatar Measurement
|
||||
vrc_config-calibration_range = Calibration Range
|
||||
vrc_config-calibration_visuals = Display Calibration Visuals
|
||||
vrc_config-user_height = User Real Height
|
||||
|
||||
vrc_config-spine_mode-UNKNOWN = Unknown
|
||||
vrc_config-spine_mode-LOCK_BOTH = Lock Both
|
||||
vrc_config-spine_mode-LOCK_HEAD = Lock Head
|
||||
vrc_config-spine_mode-LOCK_HIP = Lock Hip
|
||||
|
||||
vrc_config-tracker_model-UNKNOWN = Unkown
|
||||
vrc_config-tracker_model-AXIS = Axis
|
||||
vrc_config-tracker_model-BOX = Box
|
||||
vrc_config-tracker_model-SPHERE = Sphere
|
||||
vrc_config-tracker_model-SYSTEM = System
|
||||
|
||||
vrc_config-avatar_measurement_type-UNKNOWN = Unknown
|
||||
vrc_config-avatar_measurement_type-HEIGHT = Height
|
||||
vrc_config-avatar_measurement_type-ARM_SPAN = Arm Span
|
||||
|
||||
## Error collection consent modal
|
||||
error_collection_modal-title = Can we collect errors?
|
||||
error_collection_modal-description_v2 = { settings-interface-behavior-error_tracking-description_v2 }
|
||||
|
||||
BIN
gui/public/videos/turn-on-tracker.webm
Normal file
BIN
gui/public/videos/turn-on-tracker.webm
Normal file
Binary file not shown.
@@ -24,6 +24,12 @@
|
||||
"store:allow-get",
|
||||
"store:allow-set",
|
||||
"store:allow-save",
|
||||
"fs:allow-write-text-file"
|
||||
"fs:allow-write-text-file",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-exists",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [{ "path": "$APPDATA" }, { "path": "$APPDATA/**" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import { ScaledProportionsPage } from './components/onboarding/pages/body-propor
|
||||
import { AdvancedSettings } from './components/settings/pages/AdvancedSettings';
|
||||
import { FirmwareUpdate } from './components/firmware-update/FirmwareUpdate';
|
||||
import { ConnectionLost } from './components/onboarding/pages/ConnectionLost';
|
||||
import { VRCWarningsPage } from './components/vrc/VRCWarningsPage';
|
||||
|
||||
export const GH_REPO = 'SlimeVR/SlimeVR-Server';
|
||||
export const VersionContext = createContext('');
|
||||
@@ -110,6 +111,14 @@ function Layout() {
|
||||
</MainLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/vrc-warnings"
|
||||
element={
|
||||
<MainLayout isMobile={isMobile} widgets={false}>
|
||||
<VRCWarningsPage />
|
||||
</MainLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { useConfig } from './hooks/config';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
|
||||
export function AppLayout() {
|
||||
const { config } = useConfig();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!config) return;
|
||||
@@ -26,6 +27,12 @@ export function AppLayout() {
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (config && !config.doneOnboarding) {
|
||||
navigate('/onboarding/home');
|
||||
}
|
||||
}, [config?.doneOnboarding]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { Localized } from '@fluent/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
RecordBVHRequestT,
|
||||
@@ -11,7 +11,6 @@ import { RecordIcon } from './commons/icon/RecordIcon';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export function BVHButton(props: React.HTMLAttributes<HTMLButtonElement>) {
|
||||
const { l10n } = useLocalization();
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
const [recording, setRecording] = useState(false);
|
||||
|
||||
@@ -30,15 +29,16 @@ export function BVHButton(props: React.HTMLAttributes<HTMLButtonElement>) {
|
||||
});
|
||||
|
||||
return (
|
||||
<BigButton
|
||||
text={l10n.getString(recording ? 'bvh-recording' : 'bvh-start_recording')}
|
||||
icon={<RecordIcon width={20} />}
|
||||
onClick={toggleBVH}
|
||||
className={classNames(
|
||||
props.className,
|
||||
'border',
|
||||
recording ? 'border-status-critical' : 'border-transparent'
|
||||
)}
|
||||
></BigButton>
|
||||
<Localized id={recording ? 'bvh-recording' : 'bvh-start_recording'}>
|
||||
<BigButton
|
||||
icon={<RecordIcon width={20} />}
|
||||
onClick={toggleBVH}
|
||||
className={classNames(
|
||||
props.className,
|
||||
'border',
|
||||
recording ? 'border-status-critical' : 'border-transparent'
|
||||
)}
|
||||
></BigButton>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { Localized } from '@fluent/react';
|
||||
import { ClearDriftCompensationRequestT, RpcMessage } from 'solarxr-protocol';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import { BigButton } from './commons/BigButton';
|
||||
@@ -9,7 +9,6 @@ export function ClearDriftCompensationButton({
|
||||
}: {
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { l10n } = useLocalization();
|
||||
const { sendRPCPacket } = useWebsocketAPI();
|
||||
|
||||
const clearDriftCompensation = () => {
|
||||
@@ -18,13 +17,12 @@ export function ClearDriftCompensationButton({
|
||||
};
|
||||
|
||||
return (
|
||||
<BigButton
|
||||
text={l10n.getString('widget-drift_compensation-clear')}
|
||||
icon={<TrashIcon size={20} />}
|
||||
onClick={clearDriftCompensation}
|
||||
disabled={disabled}
|
||||
>
|
||||
{}
|
||||
</BigButton>
|
||||
<Localized id="widget-drift_compensation-clear">
|
||||
<BigButton
|
||||
icon={<TrashIcon size={20} />}
|
||||
onClick={clearDriftCompensation}
|
||||
disabled={disabled}
|
||||
></BigButton>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { Localized } from '@fluent/react';
|
||||
import { ClearMountingResetRequestT, RpcMessage } from 'solarxr-protocol';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import { BigButton } from './commons/BigButton';
|
||||
import { TrashIcon } from './commons/icon/TrashIcon';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { Quaternion } from 'three';
|
||||
import { QuaternionFromQuatT, similarQuaternions } from '@/maths/quaternion';
|
||||
import { useMemo } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { assignedTrackersAtom } from '@/store/app-store';
|
||||
|
||||
const _q = new Quaternion();
|
||||
|
||||
export function ClearMountingButton() {
|
||||
const { l10n } = useLocalization();
|
||||
const { sendRPCPacket } = useWebsocketAPI();
|
||||
const { useAssignedTrackers } = useTrackers();
|
||||
const assignedTrackers = useAssignedTrackers();
|
||||
const assignedTrackers = useAtomValue(assignedTrackersAtom);
|
||||
|
||||
const trackerWithMounting = useMemo(
|
||||
() =>
|
||||
@@ -34,11 +33,12 @@ export function ClearMountingButton() {
|
||||
};
|
||||
|
||||
return (
|
||||
<BigButton
|
||||
text={l10n.getString('widget-clear_mounting')}
|
||||
icon={<TrashIcon size={20} />}
|
||||
onClick={clearMounting}
|
||||
disabled={!trackerWithMounting}
|
||||
/>
|
||||
<Localized id={'widget-clear_mounting'}>
|
||||
<BigButton
|
||||
icon={<TrashIcon size={20} />}
|
||||
onClick={clearMounting}
|
||||
disabled={!trackerWithMounting}
|
||||
/>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { QuestionIcon } from './commons/icon/QuestionIcon';
|
||||
import { useBreakpoint, useIsTauri } from '@/hooks/breakpoint';
|
||||
import { GearIcon } from './commons/icon/GearIcon';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { TrackersStillOnModal } from './TrackersStillOnModal';
|
||||
import { useConfig } from '@/hooks/config';
|
||||
import { listen, TauriEvent } from '@tauri-apps/api/event';
|
||||
@@ -35,6 +34,8 @@ import {
|
||||
getCurrentWindow,
|
||||
UserAttentionType,
|
||||
} from '@tauri-apps/api/window';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function VersionTag() {
|
||||
return (
|
||||
@@ -63,8 +64,7 @@ export function TopBar({
|
||||
const isTauri = useIsTauri();
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const connectedIMUTrackers = useConnectedIMUTrackers();
|
||||
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
|
||||
const { config, setConfig, saveConfig } = useConfig();
|
||||
const version = useContext(VersionContext);
|
||||
const [localIp, setLocalIp] = useState<string | null>(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { Localized } from '@fluent/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
SetPauseTrackingRequestT,
|
||||
@@ -15,7 +15,6 @@ import classNames from 'classnames';
|
||||
export function TrackingPauseButton(
|
||||
props: React.HTMLAttributes<HTMLButtonElement>
|
||||
) {
|
||||
const { l10n } = useLocalization();
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
const [trackingPause, setTrackingPause] = useState(false);
|
||||
|
||||
@@ -39,13 +38,14 @@ export function TrackingPauseButton(
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BigButton
|
||||
text={l10n.getString(
|
||||
trackingPause ? 'tracking-paused' : 'tracking-unpaused'
|
||||
)}
|
||||
icon={trackingPause ? <PlayIcon width={20} /> : <PauseIcon width={20} />}
|
||||
onClick={toggleTracking}
|
||||
className={classNames(props.className, 'min-h-24')}
|
||||
></BigButton>
|
||||
<Localized id={trackingPause ? 'tracking-paused' : 'tracking-unpaused'}>
|
||||
<BigButton
|
||||
icon={
|
||||
trackingPause ? <PlayIcon width={20} /> : <PauseIcon width={20} />
|
||||
}
|
||||
onClick={toggleTracking}
|
||||
className={classNames(props.className, 'min-h-24')}
|
||||
></BigButton>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,14 @@ import {
|
||||
UnknownDeviceHandshakeNotificationT,
|
||||
} from 'solarxr-protocol';
|
||||
import { useDebouncedEffect } from '@/hooks/timeout';
|
||||
import { useAppContext } from '@/hooks/app';
|
||||
import { useAtom } from 'jotai';
|
||||
import { ignoredTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function UnknownDeviceModal() {
|
||||
const { l10n } = useLocalization();
|
||||
const [open, setOpen] = useState(0);
|
||||
const { pathname } = useLocation();
|
||||
const { state, dispatch } = useAppContext();
|
||||
const [ignoredTrackers, setIgnoredTracker] = useAtom(ignoredTrackersAtom);
|
||||
const [currentTracker, setCurrentTracker] = useState<string | null>(null);
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
|
||||
@@ -28,7 +29,7 @@ export function UnknownDeviceModal() {
|
||||
['/onboarding/connect-trackers', '/settings/firmware-tool'].includes(
|
||||
pathname
|
||||
) ||
|
||||
state.ignoredTrackers.has(macAddress as string) ||
|
||||
(macAddress && ignoredTrackers.has(macAddress?.toString())) ||
|
||||
(currentTracker !== null && currentTracker !== macAddress)
|
||||
)
|
||||
return;
|
||||
@@ -91,9 +92,10 @@ export function UnknownDeviceModal() {
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: 'ignoreTracker',
|
||||
value: currentTracker as string,
|
||||
setIgnoredTracker((state) => {
|
||||
if (!currentTracker) throw 'should have a tracker';
|
||||
state.add(currentTracker);
|
||||
return state;
|
||||
});
|
||||
closeModal();
|
||||
}}
|
||||
|
||||
@@ -17,23 +17,42 @@ import {
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { parseStatusToLocale, useStatusContext } from '@/hooks/status-system';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import { useAppContext } from '@/hooks/app';
|
||||
import { ClearMountingButton } from './ClearMountingButton';
|
||||
import { ToggleableSkeletonVisualizerWidget } from './widgets/SkeletonVisualizerWidget';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { flatTrackersAtom } from '@/store/app-store';
|
||||
|
||||
function UnprioritizedStatuses() {
|
||||
const { l10n } = useLocalization();
|
||||
const trackers = useAtomValue(flatTrackersAtom);
|
||||
const { statuses } = useStatusContext();
|
||||
const unprioritizedStatuses = useMemo(
|
||||
() => Object.values(statuses).filter((status) => !status.prioritized),
|
||||
[statuses]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-3 mb-2">
|
||||
{unprioritizedStatuses.map((status) => (
|
||||
<Localized
|
||||
id={`status_system-${StatusData[status.dataType]}`}
|
||||
vars={parseStatusToLocale(status, trackers, l10n)}
|
||||
key={status.id}
|
||||
>
|
||||
<TipBox whitespace={false} hideIcon>
|
||||
{`Warning, you should fix ${StatusData[status.dataType]}`}
|
||||
</TipBox>
|
||||
</Localized>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetsComponent() {
|
||||
const { config } = useConfig();
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
const [driftCompensationEnabled, setDriftCompensationEnabled] =
|
||||
useState(false);
|
||||
const { trackers } = useAppContext();
|
||||
const { statuses } = useStatusContext();
|
||||
const { l10n } = useLocalization();
|
||||
const unprioritizedStatuses = useMemo(
|
||||
() => Object.values(statuses).filter((status) => !status.prioritized),
|
||||
[statuses]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT());
|
||||
}, []);
|
||||
@@ -46,9 +65,9 @@ export function WidgetsComponent() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2 w-full [&>*:nth-child(odd):last-of-type]:col-span-full">
|
||||
<ResetButton type={ResetType.Yaw} variant="big"></ResetButton>
|
||||
<ResetButton type={ResetType.Full} variant="big"></ResetButton>
|
||||
<ResetButton type={ResetType.Mounting} variant="big"></ResetButton>
|
||||
<ResetButton type={ResetType.Yaw} size="big"></ResetButton>
|
||||
<ResetButton type={ResetType.Full} size="big"></ResetButton>
|
||||
<ResetButton type={ResetType.Mounting} size="big"></ResetButton>
|
||||
<ClearMountingButton></ClearMountingButton>
|
||||
<BVHButton></BVHButton>
|
||||
<TrackingPauseButton></TrackingPauseButton>
|
||||
@@ -62,19 +81,7 @@ export function WidgetsComponent() {
|
||||
<div className="mb-2">
|
||||
<ToggleableSkeletonVisualizerWidget height={400} />
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-3 mb-2">
|
||||
{unprioritizedStatuses.map((status) => (
|
||||
<Localized
|
||||
id={`status_system-${StatusData[status.dataType]}`}
|
||||
vars={parseStatusToLocale(status, trackers, l10n)}
|
||||
key={status.id}
|
||||
>
|
||||
<TipBox whitespace={false} hideIcon={true}>
|
||||
{`Warning, you should fix ${StatusData[status.dataType]}`}
|
||||
</TipBox>
|
||||
</Localized>
|
||||
))}
|
||||
</div>
|
||||
<UnprioritizedStatuses></UnprioritizedStatuses>
|
||||
{config?.debug && (
|
||||
<div className="w-full">
|
||||
<DeveloperModeWidget></DeveloperModeWidget>
|
||||
|
||||
@@ -10,6 +10,7 @@ export function BaseModal({
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
children: ReactNode;
|
||||
appendClasses?: string;
|
||||
important?: boolean;
|
||||
closeable?: boolean;
|
||||
} & ReactModal.Props) {
|
||||
@@ -31,7 +32,8 @@ export function BaseModal({
|
||||
classNames(
|
||||
'items-center focus:ring-transparent focus:ring-offset-transparent',
|
||||
'focus:outline-transparent outline-none bg-background-60 p-6 rounded-lg m-2',
|
||||
'text-background-10'
|
||||
'text-background-10',
|
||||
props.appendClasses
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -2,15 +2,15 @@ import classNames from 'classnames';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
export function BigButton({
|
||||
text,
|
||||
icon,
|
||||
disabled,
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}: {
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
icon: ReactNode;
|
||||
children?: ReactNode;
|
||||
} & React.HTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
@@ -19,7 +19,7 @@ export function BigButton({
|
||||
{...props}
|
||||
type="button"
|
||||
className={classNames(
|
||||
'flex flex-col justify-center rounded-md py-3 gap-1 px-3 cursor-pointer items-center',
|
||||
'flex flex-col justify-center rounded-md p-3 gap-1 cursor-pointer items-center',
|
||||
{
|
||||
'bg-background-60 hover:bg-background-60 cursor-not-allowed text-background-40 fill-background-40':
|
||||
disabled,
|
||||
@@ -30,7 +30,7 @@ export function BigButton({
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-around">{icon}</div>
|
||||
<div className="flex text-default flex-grow items-center">{text}</div>
|
||||
<div className="flex text-default flex-grow items-center">{children}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { BodyPart, TrackerDataT } from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { useTracker } from '@/hooks/tracker';
|
||||
import { PersonFrontIcon } from './PersonFrontIcon';
|
||||
import { FlatDeviceTracker } from '@/store/app-store';
|
||||
|
||||
interface SlotDot {
|
||||
id: string;
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useLocalization } from '@fluent/react';
|
||||
import { useEffect, useMemo, useContext } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useConfig } from '@/hooks/config';
|
||||
import { langs, LangContext } from '@/i18n/config';
|
||||
import { LangContext } from '@/i18n/config';
|
||||
import { langs } from '@/i18n/names';
|
||||
import { Dropdown, DropdownDirection } from './Dropdown';
|
||||
|
||||
export function LangSelector({
|
||||
@@ -20,7 +21,21 @@ export function LangSelector({
|
||||
});
|
||||
|
||||
const languagesItems = useMemo(
|
||||
() => langs.map(({ key, name }) => ({ label: name, value: key })),
|
||||
() =>
|
||||
langs.map(({ key, name, emoji }) => ({
|
||||
component: (
|
||||
<div>
|
||||
<img
|
||||
draggable="false"
|
||||
className="inline-block w-auto h-[1em] -translate-y-[0.05em]"
|
||||
src={emoji}
|
||||
/>
|
||||
{' ' + name}
|
||||
</div>
|
||||
),
|
||||
label: name,
|
||||
value: key,
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export function WarningBox({
|
||||
>
|
||||
<WarningIcon></WarningIcon>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="flex flex-col justify-center w-full">
|
||||
<Typography
|
||||
color="text-background-60"
|
||||
whitespace={whitespace ? 'whitespace-pre-line' : undefined}
|
||||
|
||||
@@ -356,6 +356,10 @@ export function DrawerTooltip({
|
||||
}
|
||||
};
|
||||
|
||||
const scroll = () => {
|
||||
close();
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (drawerStyle) return;
|
||||
clearEffect();
|
||||
@@ -375,6 +379,8 @@ export function DrawerTooltip({
|
||||
|
||||
elem.addEventListener('mousedown', touchStart); // for debug on desktop
|
||||
elem.addEventListener('mouseup', touchEnd); // for debug on desktop
|
||||
elem.addEventListener('scroll', scroll);
|
||||
|
||||
elem.addEventListener('click', touchEnd);
|
||||
elem.addEventListener('touchstart', touchStart);
|
||||
elem.addEventListener('touchend', touchEnd);
|
||||
@@ -382,6 +388,8 @@ export function DrawerTooltip({
|
||||
return () => {
|
||||
elem.removeEventListener('mousedown', touchStart); // for debug on desktop
|
||||
elem.removeEventListener('mouseup', touchEnd); // for debug on desktop
|
||||
elem.removeEventListener('scroll', scroll);
|
||||
|
||||
elem.removeEventListener('touchstart', touchStart);
|
||||
elem.removeEventListener('touchend', touchEnd);
|
||||
clearTimeout(touchTimeout.current);
|
||||
@@ -442,26 +450,27 @@ export function Tooltip({
|
||||
const childRef = useRef<HTMLDivElement | null>(null);
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
|
||||
const portal = createPortal(
|
||||
isMobile ? (
|
||||
<DrawerTooltip childRef={childRef}>{content}</DrawerTooltip>
|
||||
) : (
|
||||
<FloatingTooltip
|
||||
preferedDirection={preferedDirection}
|
||||
mode={mode}
|
||||
childRef={childRef}
|
||||
>
|
||||
{content}
|
||||
</FloatingTooltip>
|
||||
),
|
||||
document.body
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="contents" ref={childRef}>
|
||||
{children}
|
||||
</div>
|
||||
{!disabled &&
|
||||
createPortal(
|
||||
isMobile ? (
|
||||
<DrawerTooltip childRef={childRef}>{content}</DrawerTooltip>
|
||||
) : (
|
||||
<FloatingTooltip
|
||||
preferedDirection={preferedDirection}
|
||||
mode={mode}
|
||||
childRef={childRef}
|
||||
>
|
||||
{content}
|
||||
</FloatingTooltip>
|
||||
),
|
||||
document.body
|
||||
)}
|
||||
{!disabled && portal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ export function ArrowUpIcon({ size = 24 }: { size?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowLeftIcon() {
|
||||
export function ArrowLeftIcon({ size = 12 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="10"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 12 10"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
@@ -46,11 +46,11 @@ export function ArrowLeftIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowRightIcon() {
|
||||
export function ArrowRightIcon({ size = 12 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="10"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 12 10"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export function CheckIcon(_props: any) {
|
||||
export function CheckIcon({ size = 9 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width="9"
|
||||
height="7"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 9 7"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export function CrossIcon() {
|
||||
export function CrossIcon({ size = 20 }: { size: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
width={size}
|
||||
height={20}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
|
||||
12
gui/src/components/commons/icon/ImportIcon.tsx
Normal file
12
gui/src/components/commons/icon/ImportIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export function ImportIcon({ size = 24 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 -960 960 960"
|
||||
>
|
||||
<path d="m480-280 160-160-56-56-64 62v-166h-80v166l-64-62-56 56 160 160ZM240-80q-33 0-56.5-23.5T160-160v-480l240-240h320q33 0 56.5 23.5T800-800v640q0 33-23.5 56.5T720-80H240Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
12
gui/src/components/commons/icon/PercentIcon.tsx
Normal file
12
gui/src/components/commons/icon/PercentIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export function PercentIcon({ size = 24 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={size}
|
||||
width={size}
|
||||
viewBox="0 -960 960 960"
|
||||
>
|
||||
<path d="M300-520q-58 0-99-41t-41-99q0-58 41-99t99-41q58 0 99 41t41 99q0 58-41 99t-99 41Zm0-80q25 0 42.5-17.5T360-660q0-25-17.5-42.5T300-720q-25 0-42.5 17.5T240-660q0 25 17.5 42.5T300-600Zm360 440q-58 0-99-41t-41-99q0-58 41-99t99-41q58 0 99 41t41 99q0 58-41 99t-99 41Zm0-80q25 0 42.5-17.5T720-300q0-25-17.5-42.5T660-360q-25 0-42.5 17.5T600-300q0 25 17.5 42.5T660-240Zm-444 80-56-56 584-584 56 56-584 584Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,12 @@ export function WifiIcon({
|
||||
value,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: number;
|
||||
value: number | null;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const percent = useMemo(
|
||||
() =>
|
||||
value
|
||||
value != null
|
||||
? Math.max(
|
||||
Math.min(((value - -95) * (100 - 0)) / (-40 - -95) + 0, 100)
|
||||
) / 100
|
||||
@@ -18,7 +18,7 @@ export function WifiIcon({
|
||||
[value]
|
||||
);
|
||||
|
||||
const y = useMemo(() => (percent ? (1 - percent) * 13 : 0), [percent]);
|
||||
const y = useMemo(() => (1 - percent) * 13, [percent]);
|
||||
|
||||
const col = useMemo(() => {
|
||||
const colorsMap: { [key: number]: string } = {
|
||||
|
||||
@@ -23,13 +23,14 @@ import {
|
||||
TrackerStatus,
|
||||
} from 'solarxr-protocol';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { useAppContext } from '@/hooks/app';
|
||||
import { Input } from '@/components/commons/Input';
|
||||
import { Dropdown } from '@/components/commons/Dropdown';
|
||||
import { useOnboarding } from '@/hooks/onboarding';
|
||||
import { DeviceCardControl } from './DeviceCard';
|
||||
import { getTrackerName } from '@/hooks/tracker';
|
||||
import { ObjectSchema, object, string } from 'yup';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { devicesAtom } from '@/store/app-store';
|
||||
|
||||
interface FlashingMethodForm {
|
||||
flashingMethod?: string;
|
||||
@@ -191,10 +192,10 @@ function OTADevicesList({
|
||||
}) {
|
||||
const { l10n } = useLocalization();
|
||||
const { selectDevices, newConfig } = useFirmwareTool();
|
||||
const { state } = useAppContext();
|
||||
const allDevices = useAtomValue(devicesAtom);
|
||||
|
||||
const devices =
|
||||
state.datafeed?.devices.filter(({ trackers, hardwareInfo }) => {
|
||||
allDevices.filter(({ trackers, hardwareInfo }) => {
|
||||
// We make sure the device is not one of these types
|
||||
if (
|
||||
hardwareInfo?.officialBoardType === BoardType.SLIMEVR_LEGACY ||
|
||||
|
||||
@@ -33,6 +33,8 @@ import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { object } from 'yup';
|
||||
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
|
||||
import { A } from '@/components/commons/A';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { devicesAtom } from '@/store/app-store';
|
||||
|
||||
export function checkForUpdate(
|
||||
currentFirmwareRelease: FirmwareRelease,
|
||||
@@ -53,8 +55,8 @@ export function checkForUpdate(
|
||||
|
||||
if (
|
||||
canUpdate &&
|
||||
device.hardwareStatus?.batteryPctEstimate &&
|
||||
device.hardwareStatus?.batteryPctEstimate < 50
|
||||
device.hardwareStatus?.batteryPctEstimate != null &&
|
||||
device.hardwareStatus.batteryPctEstimate < 50
|
||||
) {
|
||||
return 'low-battery';
|
||||
}
|
||||
@@ -99,15 +101,14 @@ const DeviceList = ({
|
||||
|
||||
const StatusList = ({ status }: { status: Record<string, UpdateStatus> }) => {
|
||||
const statusKeys = Object.keys(status);
|
||||
const devices = useAtomValue(devicesAtom);
|
||||
|
||||
return statusKeys.map((id, index) => {
|
||||
const val = status[id];
|
||||
|
||||
if (!val) throw new Error('there should always be a val');
|
||||
const { state } = useAppContext();
|
||||
const device = state.datafeed?.devices.find(
|
||||
({ id: dId }) => id === dId?.id.toString()
|
||||
);
|
||||
|
||||
const device = devices.find(({ id: dId }) => id === dId?.id.toString());
|
||||
|
||||
return (
|
||||
<DeviceCardControl
|
||||
@@ -132,11 +133,13 @@ export function FirmwareUpdate() {
|
||||
const { l10n } = useLocalization();
|
||||
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
|
||||
const pendingDevicesRef = useRef<SelectedDevice[]>([]);
|
||||
const { state, currentFirmwareRelease } = useAppContext();
|
||||
const { currentFirmwareRelease } = useAppContext();
|
||||
const [status, setStatus] = useState<Record<string, UpdateStatus>>({});
|
||||
|
||||
const allDevices = useAtomValue(devicesAtom);
|
||||
|
||||
const devices =
|
||||
state.datafeed?.devices.filter(
|
||||
allDevices.filter(
|
||||
(device) =>
|
||||
device.trackers.length > 0 &&
|
||||
currentFirmwareRelease &&
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Localized, useLocalization } from '@fluent/react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { StatusData, TrackerDataT } from 'solarxr-protocol';
|
||||
import { useConfig } from '@/hooks/config';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { TrackerCard } from '@/components/tracker/TrackerCard';
|
||||
import { TrackersTable } from '@/components/tracker/TrackersTable';
|
||||
@@ -15,14 +14,18 @@ import { useMemo } from 'react';
|
||||
import { WarningBox } from '@/components/commons/TipBox';
|
||||
import { HeadsetIcon } from '@/components/commons/icon/HeadsetIcon';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { flatTrackersAtom } from '@/store/app-store';
|
||||
import { useVRCConfig } from '@/hooks/vrc-config';
|
||||
|
||||
const DONT_REPEAT_STATUSES = [StatusData.StatusTrackerReset];
|
||||
|
||||
export function Home() {
|
||||
const { l10n } = useLocalization();
|
||||
const { config } = useConfig();
|
||||
const { trackers } = useTrackers();
|
||||
const trackers = useAtomValue(flatTrackersAtom);
|
||||
const { statuses } = useStatusContext();
|
||||
const { invalidConfig } = useVRCConfig();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sendToSettings = (tracker: TrackerDataT) => {
|
||||
@@ -51,9 +54,7 @@ export function Home() {
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div
|
||||
className={classNames(
|
||||
'px-3 pt-3 gap-3 w-full grid md:grid-cols-2 mobile:grid-cols-1',
|
||||
filteredStatuses.filter(([, status]) => status.prioritized)
|
||||
.length === 0 && 'hidden'
|
||||
'px-3 pt-3 gap-3 w-full grid md:grid-cols-2 mobile:grid-cols-1'
|
||||
)}
|
||||
>
|
||||
{filteredStatuses
|
||||
@@ -69,6 +70,22 @@ export function Home() {
|
||||
</WarningBox>
|
||||
</Localized>
|
||||
))}
|
||||
{invalidConfig && (
|
||||
<WarningBox whitespace={false}>
|
||||
<div className="flex gap-2 justify-between items-center w-full">
|
||||
<div className="flex">
|
||||
<Localized id={'vrc_config-invalid'}></Localized>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<Link to="/vrc-warnings">
|
||||
<div className="rounded-md p-2 bg-background-90 bg-opacity-15 hover:bg-background-10 hover:bg-opacity-25 text-nowrap">
|
||||
<Localized id={'vrc_config-show_more'}></Localized>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</WarningBox>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-y-auto flex flex-col gap-3">
|
||||
{trackers.length === 0 && (
|
||||
|
||||
@@ -25,11 +25,13 @@ import classNames from 'classnames';
|
||||
|
||||
export function ResetButton({
|
||||
type,
|
||||
variant = 'big',
|
||||
size = 'big',
|
||||
className,
|
||||
onReseted,
|
||||
}: {
|
||||
className?: string;
|
||||
type: ResetType;
|
||||
variant: 'big' | 'small';
|
||||
size: 'big' | 'small';
|
||||
onReseted?: () => void;
|
||||
}) {
|
||||
const { l10n } = useLocalization();
|
||||
@@ -115,32 +117,36 @@ export function ResetButton({
|
||||
};
|
||||
}, []);
|
||||
|
||||
return variant === 'small' ? (
|
||||
return size === 'small' ? (
|
||||
<Button
|
||||
icon={getIcon()}
|
||||
onClick={triggerReset}
|
||||
className={classNames(
|
||||
'border-2',
|
||||
isFinished ? 'border-status-success' : 'border-transparent'
|
||||
isFinished
|
||||
? 'border-status-success'
|
||||
: 'transition-[border-color] duration-500 ease-in-out border-transparent',
|
||||
className
|
||||
)}
|
||||
variant="primary"
|
||||
disabled={isCounting || needsFullReset}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="opacity-0 h-0">{text}</div>
|
||||
{!isCounting || type === ResetType.Yaw ? text : String(timer)}
|
||||
</div>
|
||||
{!isCounting || type === ResetType.Yaw ? text : String(timer)}
|
||||
</Button>
|
||||
) : (
|
||||
<BigButton
|
||||
text={!isCounting || type === ResetType.Yaw ? text : String(timer)}
|
||||
icon={getIcon()}
|
||||
onClick={triggerReset}
|
||||
className={classNames(
|
||||
'border-2',
|
||||
isFinished ? 'border-status-success' : 'border-transparent'
|
||||
isFinished
|
||||
? 'border-status-success'
|
||||
: 'transition-[border-color] duration-500 ease-in-out border-transparent',
|
||||
className
|
||||
)}
|
||||
disabled={isCounting || needsFullReset}
|
||||
></BigButton>
|
||||
>
|
||||
{!isCounting || type === ResetType.Yaw ? text : String(timer)}
|
||||
</BigButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { BodyPart } from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { AssignMode } from '@/hooks/config';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { BodyInteractions } from '@/components/commons/BodyInteractions';
|
||||
import { TrackerPartCard } from '@/components/tracker/TrackerPartCard';
|
||||
import { BodyPartError } from './pages/trackers-assign/TrackerAssignment';
|
||||
import { SIDES } from '@/components/commons/PersonFrontIcon';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { assignedTrackersAtom, FlatDeviceTracker } from '@/store/app-store';
|
||||
|
||||
export const ARMS_PARTS = new Set([
|
||||
BodyPart.LEFT_UPPER_ARM,
|
||||
@@ -112,9 +112,7 @@ export function BodyAssignment({
|
||||
width?: number;
|
||||
dotSize?: number;
|
||||
}) {
|
||||
const { useAssignedTrackers } = useTrackers();
|
||||
|
||||
const assignedTrackers = useAssignedTrackers();
|
||||
const assignedTrackers = useAtomValue(assignedTrackersAtom);
|
||||
|
||||
const trackerPartGrouped = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -8,11 +8,12 @@ import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
|
||||
import { useCountdown } from '@/hooks/countdown';
|
||||
import classNames from 'classnames';
|
||||
import { TaybolIcon } from '@/components/commons/icon/TaybolIcon';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useRestCalibrationTrackers } from '@/hooks/imu-logic';
|
||||
import { averageVector, Vector3FromVec3fT } from '@/maths/vector3';
|
||||
import { Vector3 } from 'three';
|
||||
import { useTimeout } from '@/hooks/timeout';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export enum CalibrationStatus {
|
||||
SUCCESS,
|
||||
@@ -36,8 +37,7 @@ export function CalibrationTutorialPage() {
|
||||
onCountdownEnd: () => setCalibrationStatus(CalibrationStatus.SUCCESS),
|
||||
});
|
||||
useTimeout(() => setSkipButton(true), 10000);
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const connectedIMUTrackers = useConnectedIMUTrackers();
|
||||
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
|
||||
const restCalibrationTrackers =
|
||||
useRestCalibrationTrackers(connectedIMUTrackers);
|
||||
const [rested, setRested] = useState(false);
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
WifiProvisioningStatusResponseT,
|
||||
} from 'solarxr-protocol';
|
||||
import { useOnboarding } from '@/hooks/onboarding';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import { ArrowLink } from '@/components/commons/ArrowLink';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
@@ -21,6 +20,9 @@ import { Typography } from '@/components/commons/Typography';
|
||||
import { TrackerCard } from '@/components/tracker/TrackerCard';
|
||||
import { useIsRestCalibrationTrackers } from '@/hooks/imu-logic';
|
||||
import './ConnectTracker.scss';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
import { BaseModal } from '@/components/commons/BaseModal';
|
||||
|
||||
const statusLabelMap = {
|
||||
[WifiProvisioningStatus.NONE]:
|
||||
@@ -41,12 +43,25 @@ const statusLabelMap = {
|
||||
'onboarding-connect_tracker-connection_status-connection_error',
|
||||
[WifiProvisioningStatus.COULD_NOT_FIND_SERVER]:
|
||||
'onboarding-connect_tracker-connection_status-could_not_find_server',
|
||||
[WifiProvisioningStatus.NO_SERIAL_LOGS_ERROR]:
|
||||
'onboarding-connect_tracker-connection_status-no_serial_log',
|
||||
[WifiProvisioningStatus.NO_SERIAL_DEVICE_FOUND]:
|
||||
'onboarding-connect_tracker-connection_status-no_serial_device_found',
|
||||
};
|
||||
|
||||
const errorLabelMap = {
|
||||
[WifiProvisioningStatus.NO_SERIAL_LOGS_ERROR]:
|
||||
'onboarding-connect_serial-error-modal-no_serial_log',
|
||||
[WifiProvisioningStatus.NO_SERIAL_DEVICE_FOUND]:
|
||||
'onboarding-connect_serial-error-modal-no_serial_device_found',
|
||||
};
|
||||
|
||||
const statusProgressMap = {
|
||||
[WifiProvisioningStatus.NONE]: 0,
|
||||
[WifiProvisioningStatus.SERIAL_INIT]: 0.2,
|
||||
[WifiProvisioningStatus.NO_SERIAL_DEVICE_FOUND]: 0.2,
|
||||
[WifiProvisioningStatus.OBTAINING_MAC_ADDRESS]: 0.3,
|
||||
[WifiProvisioningStatus.NO_SERIAL_LOGS_ERROR]: 0.3,
|
||||
[WifiProvisioningStatus.PROVISIONING]: 0.4,
|
||||
[WifiProvisioningStatus.CONNECTING]: 0.6,
|
||||
[WifiProvisioningStatus.LOOKING_FOR_SERVER]: 0.8,
|
||||
@@ -57,17 +72,16 @@ const statusProgressMap = {
|
||||
|
||||
export function ConnectTrackersPage() {
|
||||
const { l10n } = useLocalization();
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
|
||||
const { applyProgress, state } = useOnboarding();
|
||||
const navigate = useNavigate();
|
||||
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
|
||||
const [provisioningStatus, setProvisioningStatus] =
|
||||
useState<WifiProvisioningStatus>(WifiProvisioningStatus.NONE);
|
||||
const [ignoreError, setIgnoreError] = useState(false);
|
||||
|
||||
applyProgress(0.4);
|
||||
|
||||
const connectedIMUTrackers = useConnectedIMUTrackers();
|
||||
|
||||
const bnoExists = useIsRestCalibrationTrackers(connectedIMUTrackers);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -95,9 +109,22 @@ export function ConnectTrackersPage() {
|
||||
}
|
||||
);
|
||||
|
||||
const isError =
|
||||
provisioningStatus === WifiProvisioningStatus.CONNECTION_ERROR ||
|
||||
provisioningStatus === WifiProvisioningStatus.COULD_NOT_FIND_SERVER;
|
||||
const isError = useMemo(
|
||||
() =>
|
||||
[
|
||||
WifiProvisioningStatus.CONNECTION_ERROR,
|
||||
WifiProvisioningStatus.COULD_NOT_FIND_SERVER,
|
||||
WifiProvisioningStatus.NO_SERIAL_LOGS_ERROR,
|
||||
WifiProvisioningStatus.NO_SERIAL_DEVICE_FOUND,
|
||||
].includes(provisioningStatus),
|
||||
[provisioningStatus]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
setIgnoreError(false);
|
||||
}
|
||||
}, [isError]);
|
||||
|
||||
const progressBarClass = useMemo(() => {
|
||||
if (isError) {
|
||||
@@ -122,7 +149,7 @@ export function ConnectTrackersPage() {
|
||||
default:
|
||||
return SlimeState.JUMPY;
|
||||
}
|
||||
}, [provisioningStatus]);
|
||||
}, [provisioningStatus, isError]);
|
||||
|
||||
const currentTip = useMemo(
|
||||
() =>
|
||||
@@ -133,125 +160,170 @@ export function ConnectTrackersPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="connect-tracker-layout h-full">
|
||||
<div style={{ gridArea: 's' }} className="p-4">
|
||||
<Typography variant="main-title">
|
||||
{l10n.getString('onboarding-connect_tracker-title')}
|
||||
</Typography>
|
||||
<Typography color="secondary">
|
||||
{l10n.getString('onboarding-connect_tracker-description-p0-v1')}
|
||||
</Typography>
|
||||
<Typography color="secondary">
|
||||
{l10n.getString('onboarding-connect_tracker-description-p1-v1')}
|
||||
</Typography>
|
||||
<div className="flex flex-col gap-2 py-5">
|
||||
<ArrowLink
|
||||
to="/settings/serial"
|
||||
state={{ SerialPort: 'Auto' }}
|
||||
direction="right"
|
||||
variant={state.alonePage ? 'boxed-2' : 'boxed'}
|
||||
>
|
||||
{l10n.getString('onboarding-connect_tracker-issue-serial')}
|
||||
</ArrowLink>
|
||||
<>
|
||||
<BaseModal
|
||||
isOpen={
|
||||
!ignoreError &&
|
||||
[
|
||||
WifiProvisioningStatus.NO_SERIAL_LOGS_ERROR,
|
||||
WifiProvisioningStatus.NO_SERIAL_DEVICE_FOUND,
|
||||
].includes(provisioningStatus)
|
||||
}
|
||||
appendClasses={'w-xl max-w-xl mobile:w-full'}
|
||||
closeable
|
||||
onRequestClose={() => {
|
||||
setIgnoreError(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 ">
|
||||
<Localized id={(errorLabelMap as any)[provisioningStatus]}>
|
||||
<Typography variant="main-title"></Typography>
|
||||
</Localized>
|
||||
<Localized id={`${(errorLabelMap as any)[provisioningStatus]}-desc`}>
|
||||
<Typography
|
||||
variant="standard"
|
||||
whitespace="whitespace-pre-wrap"
|
||||
block
|
||||
></Typography>
|
||||
</Localized>
|
||||
<video
|
||||
src="/videos/turn-on-tracker.webm"
|
||||
loop
|
||||
autoPlay
|
||||
className="w-full aspect-video rounded-md mt-2"
|
||||
></video>
|
||||
<div className="flex gap-3 pt-5 justify-end w-full">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
setIgnoreError(true);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Localized
|
||||
id={currentTip}
|
||||
elems={{ em: <em className="italic"></em>, b: <b></b> }}
|
||||
>
|
||||
<TipBox>Conditional tip</TipBox>
|
||||
</Localized>
|
||||
</BaseModal>
|
||||
<div className="connect-tracker-layout h-full">
|
||||
<div style={{ gridArea: 's' }} className="p-4">
|
||||
<Typography variant="main-title">
|
||||
{l10n.getString('onboarding-connect_tracker-title')}
|
||||
</Typography>
|
||||
<Typography color="secondary">
|
||||
{l10n.getString('onboarding-connect_tracker-description-p0-v1')}
|
||||
</Typography>
|
||||
<Typography color="secondary">
|
||||
{l10n.getString('onboarding-connect_tracker-description-p1-v1')}
|
||||
</Typography>
|
||||
<div className="flex flex-col gap-2 py-5">
|
||||
<ArrowLink
|
||||
to="/settings/serial"
|
||||
state={{ SerialPort: 'Auto' }}
|
||||
direction="right"
|
||||
variant={state.alonePage ? 'boxed-2' : 'boxed'}
|
||||
>
|
||||
{l10n.getString('onboarding-connect_tracker-issue-serial')}
|
||||
</ArrowLink>
|
||||
</div>
|
||||
<Localized
|
||||
id={currentTip}
|
||||
elems={{ em: <em className="italic"></em>, b: <b></b> }}
|
||||
>
|
||||
<TipBox>Conditional tip</TipBox>
|
||||
</Localized>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'rounded-xl h-24 flex gap-2 p-3 lg:w-full mt-4 relative',
|
||||
state.alonePage ? 'bg-background-60' : 'bg-background-70',
|
||||
isError && 'border-2 border-status-critical'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-col justify-center fill-background-10 absolute',
|
||||
'right-5 bottom-8'
|
||||
'rounded-xl h-24 flex gap-2 p-3 lg:w-full mt-4 relative',
|
||||
state.alonePage ? 'bg-background-60' : 'bg-background-70',
|
||||
isError && 'border-2 border-status-critical'
|
||||
)}
|
||||
>
|
||||
<LoaderIcon slimeState={slimeStatus}></LoaderIcon>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-col justify-center fill-background-10 absolute',
|
||||
'right-5 bottom-8'
|
||||
)}
|
||||
>
|
||||
<LoaderIcon slimeState={slimeStatus}></LoaderIcon>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col grow self-center">
|
||||
<Typography bold>
|
||||
{l10n.getString('onboarding-connect_tracker-usb')}
|
||||
</Typography>
|
||||
<div className="flex fill-background-10 gap-1">
|
||||
<Typography color="secondary">
|
||||
{l10n.getString(statusLabelMap[provisioningStatus])}
|
||||
<div className="flex flex-col grow self-center">
|
||||
<Typography bold>
|
||||
{l10n.getString('onboarding-connect_tracker-usb')}
|
||||
</Typography>
|
||||
<div className="flex fill-background-10 gap-1">
|
||||
<Typography color="secondary">
|
||||
{l10n.getString(statusLabelMap[provisioningStatus])}
|
||||
</Typography>
|
||||
</div>
|
||||
<ProgressBar
|
||||
progress={statusProgressMap[provisioningStatus]}
|
||||
height={14}
|
||||
animated={true}
|
||||
colorClass={progressBarClass}
|
||||
></ProgressBar>
|
||||
</div>
|
||||
<ProgressBar
|
||||
progress={statusProgressMap[provisioningStatus]}
|
||||
height={14}
|
||||
animated={true}
|
||||
colorClass={progressBarClass}
|
||||
></ProgressBar>
|
||||
</div>
|
||||
<div className="flex flex-row mt-4 gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
state={{ alonePage: state.alonePage }}
|
||||
to="/onboarding/wifi-creds"
|
||||
>
|
||||
{state.alonePage
|
||||
? l10n.getString('onboarding-connect_tracker-back')
|
||||
: l10n.getString('onboarding-previous_step')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
to={
|
||||
state.alonePage
|
||||
? '/'
|
||||
: bnoExists
|
||||
? '/onboarding/calibration-tutorial'
|
||||
: '/onboarding/assign-tutorial'
|
||||
}
|
||||
className="ml-auto"
|
||||
>
|
||||
{l10n.getString('onboarding-connect_tracker-next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row mt-4 gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
state={{ alonePage: state.alonePage }}
|
||||
to="/onboarding/wifi-creds"
|
||||
>
|
||||
{state.alonePage
|
||||
? l10n.getString('onboarding-connect_tracker-back')
|
||||
: l10n.getString('onboarding-previous_step')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
to={
|
||||
state.alonePage
|
||||
? '/'
|
||||
: bnoExists
|
||||
? '/onboarding/calibration-tutorial'
|
||||
: '/onboarding/assign-tutorial'
|
||||
}
|
||||
className="ml-auto"
|
||||
>
|
||||
{l10n.getString('onboarding-connect_tracker-next')}
|
||||
</Button>
|
||||
<div style={{ gridArea: 't' }} className="flex items-center px-5">
|
||||
<Typography color="secondary" bold>
|
||||
{l10n.getString('onboarding-connect_tracker-connected_trackers', {
|
||||
amount: connectedIMUTrackers.length,
|
||||
})}
|
||||
</Typography>
|
||||
</div>
|
||||
<div style={{ gridArea: 'c' }} className="xs:overflow-y-auto">
|
||||
<div className="grid lg:grid-cols-2 md:grid-cols-1 gap-2 pr-1 mx-5 py-4">
|
||||
{Array.from({
|
||||
...connectedIMUTrackers,
|
||||
length: Math.max(connectedIMUTrackers.length, 1),
|
||||
}).map((tracker, index) => (
|
||||
<div key={index}>
|
||||
{!tracker && (
|
||||
<div
|
||||
className={classNames(
|
||||
'rounded-xl h-16 animate-pulse',
|
||||
state.alonePage ? 'bg-background-80' : 'bg-background-70'
|
||||
)}
|
||||
></div>
|
||||
)}
|
||||
{tracker && (
|
||||
<TrackerCard
|
||||
tracker={tracker.tracker}
|
||||
device={tracker.device}
|
||||
smol
|
||||
></TrackerCard>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ gridArea: 't' }} className="flex items-center px-5">
|
||||
<Typography color="secondary" bold>
|
||||
{l10n.getString('onboarding-connect_tracker-connected_trackers', {
|
||||
amount: connectedIMUTrackers.length,
|
||||
})}
|
||||
</Typography>
|
||||
</div>
|
||||
<div style={{ gridArea: 'c' }} className="xs:overflow-y-auto">
|
||||
<div className="grid lg:grid-cols-2 md:grid-cols-1 gap-2 pr-1 mx-5 py-4">
|
||||
{Array.from({
|
||||
...connectedIMUTrackers,
|
||||
length: Math.max(connectedIMUTrackers.length, 1),
|
||||
}).map((tracker, index) => (
|
||||
<div key={index}>
|
||||
{!tracker && (
|
||||
<div
|
||||
className={classNames(
|
||||
'rounded-xl h-16 animate-pulse',
|
||||
state.alonePage ? 'bg-background-80' : 'bg-background-70'
|
||||
)}
|
||||
></div>
|
||||
)}
|
||||
{tracker && (
|
||||
<TrackerCard
|
||||
tracker={tracker.tracker}
|
||||
device={tracker.device}
|
||||
smol
|
||||
></TrackerCard>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,24 +12,24 @@ import {
|
||||
SettingsRequestT,
|
||||
SettingsResponseT,
|
||||
} from 'solarxr-protocol';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { BodyDisplay } from '@/components/commons/BodyDisplay';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import classNames from 'classnames';
|
||||
import { useBreakpoint } from '@/hooks/breakpoint';
|
||||
import { log } from '@/utils/logging';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { assignedTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function ResetTutorialPage() {
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
const { l10n } = useLocalization();
|
||||
const { applyProgress } = useOnboarding();
|
||||
const { useAssignedTrackers } = useTrackers();
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
const [curIndex, setCurIndex] = useState(0);
|
||||
const [tapSettings, setTapSettings] = useState<number[]>([]);
|
||||
applyProgress(0.8);
|
||||
|
||||
const assignedTrackers = useAssignedTrackers();
|
||||
const assignedTrackers = useAtomValue(assignedTrackersAtom);
|
||||
|
||||
const highestTorsoTracker = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -5,15 +5,15 @@ import { Button } from '@/components/commons/Button';
|
||||
import { Input } from '@/components/commons/Input';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import classNames from 'classnames';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useIsRestCalibrationTrackers } from '@/hooks/imu-logic';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function WifiCredsPage() {
|
||||
const { l10n } = useLocalization();
|
||||
const { applyProgress, state } = useOnboarding();
|
||||
const { control, handleSubmit, submitWifiCreds, formState } = useWifiForm();
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const connectedIMUTrackers = useConnectedIMUTrackers();
|
||||
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
|
||||
|
||||
applyProgress(0.2);
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@ import { useLocalization } from '@fluent/react';
|
||||
import { useOnboarding } from '@/hooks/onboarding';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useIsRestCalibrationTrackers } from '@/hooks/imu-logic';
|
||||
import { StickerSlime } from './StickerSlime';
|
||||
import { TrackerArrow } from './TrackerArrow';
|
||||
import { ExtensionArrow } from './ExtensionArrow';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function AssignmentTutorialPage() {
|
||||
const { l10n } = useLocalization();
|
||||
const { applyProgress } = useOnboarding();
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const connectedIMUTrackers = useConnectedIMUTrackers();
|
||||
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
|
||||
const isRestCalibration = useIsRestCalibrationTrackers(connectedIMUTrackers);
|
||||
|
||||
applyProgress(0.46);
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { Localized, useLocalization } from '@fluent/react';
|
||||
import classNames from 'classnames';
|
||||
import { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
|
||||
import {
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
UIEvent,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import {
|
||||
LabelType,
|
||||
ProportionChangeType,
|
||||
Label,
|
||||
UpdateBoneParams,
|
||||
useManualProportions,
|
||||
} from '@/hooks/manual-proportions';
|
||||
import { useLocaleConfig } from '@/i18n/config';
|
||||
@@ -19,8 +12,7 @@ import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
} from '@/components/commons/icon/ArrowIcons';
|
||||
import { useBreakpoint } from '@/hooks/breakpoint';
|
||||
import { debounce } from '@/hooks/timeout';
|
||||
import { Tooltip } from '@/components/commons/Tooltip';
|
||||
|
||||
function IncrementButton({
|
||||
children,
|
||||
@@ -33,17 +25,226 @@ function IncrementButton({
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'p-3 rounded-lg xs:w-16 xs:h-16 mobile:w-10 flex flex-col justify-center items-center',
|
||||
'bg-background-60 hover:bg-opacity-50 active:bg-accent-background-30'
|
||||
'p-3 rounded-lg xs:w-10 xs:h-10 flex flex-col justify-center items-center cursor-pointer',
|
||||
'bg-background-40 hover:bg-opacity-50 active:bg-accent-background-30'
|
||||
)}
|
||||
>
|
||||
<Typography variant="mobile-title" bold>
|
||||
<Typography variant="vr-accessible" bold>
|
||||
{children}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProportionItem({
|
||||
type,
|
||||
part,
|
||||
precise,
|
||||
onBoneChange,
|
||||
}: {
|
||||
type: 'linear' | 'ratio';
|
||||
part: Label;
|
||||
precise: boolean;
|
||||
onBoneChange: (params: UpdateBoneParams) => void;
|
||||
}) {
|
||||
const { l10n } = useLocalization();
|
||||
const { currentLocales } = useLocaleConfig();
|
||||
|
||||
const { cmFormat, percentageFormat, configFormat } = useMemo(() => {
|
||||
const cmFormat = Intl.NumberFormat(currentLocales, {
|
||||
style: 'unit',
|
||||
unit: 'centimeter',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const percentageFormat = new Intl.NumberFormat(currentLocales, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const configFormat = Intl.NumberFormat(currentLocales, {
|
||||
signDisplay: 'always',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
return {
|
||||
cmFormat,
|
||||
percentageFormat,
|
||||
configFormat,
|
||||
};
|
||||
}, [currentLocales]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const toggleOpen = () => {
|
||||
if (part.type === 'bone') return;
|
||||
setOpen((open) => !open);
|
||||
};
|
||||
|
||||
const boneIncrement = (addition: number) => {
|
||||
const newValue =
|
||||
part.unit === 'cm'
|
||||
? (Math.round(part.value * 200) + addition * 2) / 200
|
||||
: // In the case of unit === percent we send only the added percent and not the value with added percent to it
|
||||
// this is so the percent added is relative to the whole group and not the bone as 1% added to the bone is not 1% of the group
|
||||
addition / 100;
|
||||
|
||||
if (part.type === 'bone') {
|
||||
onBoneChange({
|
||||
type: 'bone',
|
||||
newValue,
|
||||
bone: part.bone,
|
||||
});
|
||||
}
|
||||
if (part.type === 'group-part') {
|
||||
onBoneChange({
|
||||
type: 'group-part',
|
||||
newValue,
|
||||
bone: part.bone,
|
||||
group: part.group,
|
||||
});
|
||||
}
|
||||
if (part.type === 'group') {
|
||||
onBoneChange({
|
||||
type: 'group',
|
||||
newValue,
|
||||
group: part.label,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('flex flex-col rounded-md overflow-clip')}>
|
||||
<div
|
||||
key={part.label}
|
||||
itemID={part.label}
|
||||
className={classNames(
|
||||
'flex justify-center gap-6 mobile:gap-2 p-2 mobile:px-2 px-4',
|
||||
part.type === 'group-part'
|
||||
? 'bg-background-50 group/child-buttons'
|
||||
: 'bg-background-70 group/buttons'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'h-16 rounded-lg flex w-full items-center mobile:items-start transition-colors mobile:flex-col mobile:gap-2 mobile:py-2 mobile:h-auto',
|
||||
'duration-300'
|
||||
)}
|
||||
>
|
||||
<Tooltip
|
||||
content={
|
||||
<Localized id={`${part.label}-desc`}>
|
||||
<Typography
|
||||
variant="standard"
|
||||
whitespace="whitespace-pre-wrap"
|
||||
></Typography>
|
||||
</Localized>
|
||||
}
|
||||
preferedDirection="bottom"
|
||||
mode="corner"
|
||||
>
|
||||
<div className="flex flex-grow" onClick={toggleOpen}>
|
||||
<Typography variant="section-title" bold>
|
||||
{l10n.getString(part.label)}
|
||||
</Typography>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="flex gap-4 items-center mobile:justify-center mobile:w-full">
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center gap-2 my-2 opacity-10',
|
||||
part.type === 'group-part'
|
||||
? 'group-hover/child-buttons:opacity-100'
|
||||
: 'group-hover/buttons:opacity-100'
|
||||
)}
|
||||
>
|
||||
{!precise && (
|
||||
<IncrementButton onClick={() => boneIncrement(-5)}>
|
||||
{configFormat.format(-5)}
|
||||
</IncrementButton>
|
||||
)}
|
||||
<IncrementButton onClick={() => boneIncrement(-1)}>
|
||||
{configFormat.format(-1)}
|
||||
</IncrementButton>
|
||||
{precise && (
|
||||
<IncrementButton onClick={() => boneIncrement(-0.5)}>
|
||||
{configFormat.format(-0.5)}
|
||||
</IncrementButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xl font-bold min-w-24 text-center">
|
||||
{part.unit === 'percent'
|
||||
? /* Make number rounding so it's based on .5 decimals */
|
||||
percentageFormat.format(Math.round(part.ratio * 200) / 200)
|
||||
: cmFormat.format(part.value * 100)}
|
||||
{part.unit === 'percent' && (
|
||||
<p className="text-standard">{`(${cmFormat.format(
|
||||
part.value * 100
|
||||
)})`}</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center gap-2 my-2 opacity-10',
|
||||
part.type === 'group-part'
|
||||
? 'group-hover/child-buttons:opacity-100'
|
||||
: 'group-hover/buttons:opacity-100'
|
||||
)}
|
||||
>
|
||||
{precise && (
|
||||
<IncrementButton onClick={() => boneIncrement(+0.5)}>
|
||||
{configFormat.format(+0.5)}
|
||||
</IncrementButton>
|
||||
)}
|
||||
<IncrementButton onClick={() => boneIncrement(+1)}>
|
||||
{configFormat.format(+1)}
|
||||
</IncrementButton>
|
||||
{!precise && (
|
||||
<IncrementButton onClick={() => boneIncrement(+5)}>
|
||||
{configFormat.format(+5)}
|
||||
</IncrementButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{type === 'ratio' && part.type !== 'group-part' && (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center fill-background-20',
|
||||
part.type === 'bone' && 'opacity-20'
|
||||
)}
|
||||
onClick={toggleOpen}
|
||||
>
|
||||
{open ? (
|
||||
<ArrowUpIcon size={50}></ArrowUpIcon>
|
||||
) : (
|
||||
<ArrowDownIcon size={50}></ArrowDownIcon>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{part.type === 'group' && (
|
||||
<div
|
||||
className="bg-background-50 grid"
|
||||
style={{
|
||||
gridTemplateRows: open ? '1fr' : '0fr',
|
||||
transition: 'grid-template-rows 0.2s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{part.bones.map((part) => (
|
||||
<ProportionItem
|
||||
type={type}
|
||||
key={part.label}
|
||||
precise={precise}
|
||||
part={part}
|
||||
onBoneChange={onBoneChange}
|
||||
></ProportionItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BodyProportions({
|
||||
precise,
|
||||
type,
|
||||
@@ -53,377 +254,23 @@ export function BodyProportions({
|
||||
type: 'linear' | 'ratio';
|
||||
variant: 'onboarding' | 'alone';
|
||||
}) {
|
||||
const { bodyParts, dispatch, state, setRatioMode } = useManualProportions();
|
||||
const { l10n } = useLocalization();
|
||||
const { currentLocales } = useLocaleConfig();
|
||||
const { isTall } = useBreakpoint('tall');
|
||||
|
||||
const offsetItems = isTall ? 2 : 1;
|
||||
const itemsToDisplay = offsetItems * 2 + 1;
|
||||
const itemHeight = 80;
|
||||
const scrollHeight = itemHeight * itemsToDisplay;
|
||||
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const cmFormat = Intl.NumberFormat(currentLocales, {
|
||||
style: 'unit',
|
||||
unit: 'centimeter',
|
||||
maximumFractionDigits: 1,
|
||||
const { bodyPartsGrouped, changeBoneValue } = useManualProportions({
|
||||
type,
|
||||
});
|
||||
const percentageFormat = new Intl.NumberFormat(currentLocales, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const configFormat = Intl.NumberFormat(currentLocales, {
|
||||
signDisplay: 'always',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'linear') {
|
||||
setRatioMode(false);
|
||||
} else {
|
||||
setRatioMode(true);
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollerRef.current && bodyParts.length > 0) {
|
||||
selectId(bodyParts[offsetItems].label);
|
||||
}
|
||||
}, [scrollerRef, bodyParts.length]);
|
||||
|
||||
const handleUIEvent = (e: UIEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
const itemHeight = target.offsetHeight / itemsToDisplay;
|
||||
const atSnappingPoint = target.scrollTop % itemHeight === 0;
|
||||
const index = Math.round(target.scrollTop / itemHeight);
|
||||
const elem = scrollerRef.current?.childNodes[
|
||||
index + offsetItems
|
||||
] as HTMLDivElement;
|
||||
|
||||
elem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
if (atSnappingPoint) {
|
||||
const elem = scrollerRef.current?.childNodes[
|
||||
index + offsetItems
|
||||
] as HTMLDivElement;
|
||||
const id = elem.getAttribute('itemid');
|
||||
|
||||
if (id) selectNew(id);
|
||||
}
|
||||
};
|
||||
|
||||
const moveToId = (id: string) => {
|
||||
if (!scrollerRef.current) return;
|
||||
const index = bodyParts.findIndex(({ label }) => label === id);
|
||||
scrollerRef.current.scrollTo({
|
||||
top: index * itemHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
const clickPart = (id: string) => () => {
|
||||
moveToId(id);
|
||||
selectNew(id);
|
||||
};
|
||||
|
||||
const selectId = (id: string) => {
|
||||
moveToId(id);
|
||||
if (id) selectNew(id);
|
||||
};
|
||||
|
||||
const selectNew = (id: string) => {
|
||||
const part = bodyParts.find(({ label }) => label === id);
|
||||
if (!part) return;
|
||||
|
||||
const { value: originalValue, label, type, ...props } = part;
|
||||
|
||||
const value =
|
||||
'index' in props && props.index !== undefined
|
||||
? props.bones[props.index].value
|
||||
: originalValue;
|
||||
|
||||
switch (type) {
|
||||
case LabelType.Bone: {
|
||||
if (!('bone' in props)) throw 'unreachable';
|
||||
dispatch({
|
||||
...props,
|
||||
label,
|
||||
value,
|
||||
type: ProportionChangeType.Bone,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case LabelType.Group: {
|
||||
if (!('bones' in props)) throw 'unreachable';
|
||||
dispatch({
|
||||
...props,
|
||||
label,
|
||||
value,
|
||||
type: ProportionChangeType.Group,
|
||||
index: undefined,
|
||||
parentLabel: label,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case LabelType.GroupPart: {
|
||||
if (!('index' in props)) throw 'unreachable';
|
||||
dispatch({
|
||||
...props,
|
||||
label,
|
||||
// If this isn't done, we are replacing total
|
||||
// with percentage value
|
||||
value: originalValue,
|
||||
type: ProportionChangeType.Group,
|
||||
index: props.index,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const seletedLabel = useMemo(() => {
|
||||
return bodyParts.find(({ label }) => label === state.currentLabel);
|
||||
}, [state]);
|
||||
|
||||
const move = (action: 'next' | 'prev') => {
|
||||
const elem = scrollerRef.current?.querySelector(
|
||||
`div[itemid=${state.currentLabel}]`
|
||||
);
|
||||
|
||||
const moveId = (id: string) => {
|
||||
moveToId(id);
|
||||
selectNew(id);
|
||||
};
|
||||
|
||||
if (action === 'prev') {
|
||||
const prevElem = elem?.previousSibling as HTMLDivElement;
|
||||
const prevId = prevElem.getAttribute('itemid');
|
||||
if (!prevId) return;
|
||||
moveId(prevId);
|
||||
}
|
||||
|
||||
if (action === 'next') {
|
||||
const nextElem = elem?.nextSibling as HTMLDivElement;
|
||||
const nextId = nextElem.getAttribute('itemid');
|
||||
if (!nextId) return;
|
||||
moveId(nextId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
(bodyParts.length > 0 && (
|
||||
(bodyPartsGrouped.length > 0 && (
|
||||
<div className="flex w-full gap-3">
|
||||
<div className="flex items-center mobile:justify-center mobile:flex-col gap-2 my-2">
|
||||
{!precise && (
|
||||
<div className="mobile:order-2">
|
||||
<IncrementButton
|
||||
onClick={() =>
|
||||
seletedLabel?.type === LabelType.GroupPart
|
||||
? dispatch({
|
||||
type: ProportionChangeType.Ratio,
|
||||
value: -0.05,
|
||||
})
|
||||
: dispatch({
|
||||
type: ProportionChangeType.Linear,
|
||||
value: -5,
|
||||
})
|
||||
}
|
||||
>
|
||||
{configFormat.format(-5)}
|
||||
</IncrementButton>
|
||||
</div>
|
||||
)}
|
||||
<div className="mobile:order-1">
|
||||
<IncrementButton
|
||||
onClick={() =>
|
||||
seletedLabel?.type === LabelType.GroupPart
|
||||
? dispatch({
|
||||
type: ProportionChangeType.Ratio,
|
||||
value: -0.01,
|
||||
})
|
||||
: dispatch({
|
||||
type: ProportionChangeType.Linear,
|
||||
value: -1,
|
||||
})
|
||||
}
|
||||
>
|
||||
{configFormat.format(-1)}
|
||||
</IncrementButton>
|
||||
</div>
|
||||
{precise && (
|
||||
<div className="mobile:order-2">
|
||||
<IncrementButton
|
||||
onClick={() =>
|
||||
seletedLabel?.type === LabelType.GroupPart
|
||||
? dispatch({
|
||||
type: ProportionChangeType.Ratio,
|
||||
value: -0.005,
|
||||
})
|
||||
: dispatch({
|
||||
type: ProportionChangeType.Linear,
|
||||
value: -0.5,
|
||||
})
|
||||
}
|
||||
>
|
||||
{configFormat.format(-0.5)}
|
||||
</IncrementButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
onClick={() => move('prev')}
|
||||
className={classNames(
|
||||
'h-12 w-32 rounded-lg bg-background-60 flex flex-col justify-center',
|
||||
'items-center fill-background-10',
|
||||
(scrollerRef?.current?.scrollTop ?? 0 > 0)
|
||||
? 'opacity-100 active:bg-accent-background-30'
|
||||
: 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<ArrowUpIcon size={32}></ArrowUpIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={debounce(handleUIEvent, 150)} // Debounce at 150ms to match the animation speed and prevent snaping between two animations
|
||||
className={classNames(
|
||||
'flex-grow flex-col overflow-y-auto',
|
||||
'no-scrollbar'
|
||||
)}
|
||||
style={{ height: scrollHeight }}
|
||||
>
|
||||
{Array.from({ length: offsetItems }).map((_, index) => (
|
||||
<div style={{ height: itemHeight }} key={index}></div>
|
||||
))}
|
||||
{bodyParts.map((part) => {
|
||||
const { label, value: originalValue, type, ...props } = part;
|
||||
const value =
|
||||
'index' in props && props.index !== undefined
|
||||
? props.bones[props.index].value
|
||||
: originalValue;
|
||||
|
||||
const selected = state.currentLabel === label;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
itemID={label}
|
||||
onClick={clickPart(label)}
|
||||
style={{ height: itemHeight }}
|
||||
className="flex-col flex justify-center"
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'h-16 p-3 rounded-lg flex w-full items-center justify-between px-6 transition-colors',
|
||||
'duration-300 bg-background-60',
|
||||
(selected && 'opacity-100') ||
|
||||
'opacity-50 active:bg-accent-background-30'
|
||||
)}
|
||||
>
|
||||
<Typography variant="section-title" bold>
|
||||
{l10n.getString(label)}
|
||||
</Typography>
|
||||
<Typography variant="mobile-title" bold sentryMask>
|
||||
{type === LabelType.GroupPart
|
||||
? /* Make number rounding so it's based on .5 decimals */
|
||||
percentageFormat.format(Math.round(value * 200) / 200)
|
||||
: cmFormat.format(value * 100)}
|
||||
{type === LabelType.GroupPart && (
|
||||
<p className="text-standard">{`(${cmFormat.format(
|
||||
value * originalValue * 100
|
||||
)})`}</p>
|
||||
)}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: offsetItems }).map((_, index) => (
|
||||
<div
|
||||
className="h-20"
|
||||
style={{ height: itemHeight }}
|
||||
key={index}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
onClick={() => move('next')}
|
||||
className={classNames(
|
||||
'h-12 w-32 rounded-lg bg-background-60 flex flex-col justify-center',
|
||||
'items-center fill-background-10',
|
||||
scrollerRef?.current?.scrollTop !==
|
||||
(scrollerRef?.current?.scrollHeight ?? 0) -
|
||||
(scrollerRef?.current?.offsetHeight ?? 0)
|
||||
? 'opacity-100 active:bg-accent-background-30'
|
||||
: 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<ArrowDownIcon size={32}></ArrowDownIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mobile:justify-center mobile:flex-col gap-2">
|
||||
{precise && (
|
||||
<div className="mobile:order-2">
|
||||
<IncrementButton
|
||||
onClick={() =>
|
||||
seletedLabel?.type === LabelType.GroupPart
|
||||
? dispatch({
|
||||
type: ProportionChangeType.Ratio,
|
||||
value: 0.005,
|
||||
})
|
||||
: dispatch({
|
||||
type: ProportionChangeType.Linear,
|
||||
value: 0.5,
|
||||
})
|
||||
}
|
||||
>
|
||||
{configFormat.format(+0.5)}
|
||||
</IncrementButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mobile:order-1">
|
||||
<IncrementButton
|
||||
onClick={() =>
|
||||
seletedLabel?.type === LabelType.GroupPart
|
||||
? dispatch({
|
||||
type: ProportionChangeType.Ratio,
|
||||
value: 0.01,
|
||||
})
|
||||
: dispatch({
|
||||
type: ProportionChangeType.Linear,
|
||||
value: 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
{configFormat.format(+1)}
|
||||
</IncrementButton>
|
||||
</div>
|
||||
{!precise && (
|
||||
<div className="mobile:order-2">
|
||||
<IncrementButton
|
||||
onClick={() =>
|
||||
seletedLabel?.type === LabelType.GroupPart
|
||||
? dispatch({
|
||||
type: ProportionChangeType.Ratio,
|
||||
value: 0.05,
|
||||
})
|
||||
: dispatch({
|
||||
type: ProportionChangeType.Linear,
|
||||
value: 5,
|
||||
})
|
||||
}
|
||||
>
|
||||
{configFormat.format(+5)}
|
||||
</IncrementButton>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-grow flex-col gap-2 p-2">
|
||||
{bodyPartsGrouped.map((part) => (
|
||||
<ProportionItem
|
||||
type={type}
|
||||
key={part.label}
|
||||
part={part}
|
||||
precise={precise}
|
||||
onBoneChange={changeBoneValue}
|
||||
></ProportionItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)) || <></>
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Control, Controller, useForm } from 'react-hook-form';
|
||||
import {
|
||||
ChangeSkeletonConfigRequestT,
|
||||
ResetType,
|
||||
RpcMessage,
|
||||
SkeletonBone,
|
||||
SkeletonConfigRequestT,
|
||||
SkeletonConfigResponseT,
|
||||
SkeletonResetAllRequestT,
|
||||
} from 'solarxr-protocol';
|
||||
import { useOnboarding } from '@/hooks/onboarding';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { CheckBox } from '@/components/commons/Checkbox';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { BodyProportions } from './BodyProportions';
|
||||
import { Localized, useLocalization } from '@fluent/react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useBreakpoint, useIsTauri } from '@/hooks/breakpoint';
|
||||
import { SkeletonVisualizerWidget } from '@/components/widgets/SkeletonVisualizerWidget';
|
||||
import { ProportionsResetModal } from './ProportionsResetModal';
|
||||
@@ -23,8 +22,66 @@ import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { writeTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { error } from '@/utils/logging';
|
||||
import classNames from 'classnames';
|
||||
import { useAppContext } from '@/hooks/app';
|
||||
import { Tooltip } from '@/components/commons/Tooltip';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { computedTrackersAtom } from '@/store/app-store';
|
||||
import { RulerIcon } from '@/components/commons/icon/RulerIcon';
|
||||
import { PercentIcon } from '@/components/commons/icon/PercentIcon';
|
||||
import { UploadFileIcon } from '@/components/commons/icon/UploadFileIcon';
|
||||
import { FullResetIcon } from '@/components/commons/icon/ResetIcon';
|
||||
import { ImportIcon } from '@/components/commons/icon/ImportIcon';
|
||||
import { HumanIcon } from '@/components/commons/icon/HumanIcon';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { useLocaleConfig } from '@/i18n/config';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ResetButton } from '@/components/home/ResetButton';
|
||||
|
||||
function IconButton({
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
tooltip,
|
||||
showTooltip = false,
|
||||
icon,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
tooltip?: ReactNode;
|
||||
showTooltip?: boolean;
|
||||
icon: ReactNode;
|
||||
}) {
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
|
||||
if (isMobile) showTooltip = true;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
disabled={!showTooltip}
|
||||
preferedDirection="bottom"
|
||||
content={tooltip ?? children}
|
||||
>
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
'flex flex-col rounded-md p-2 justify-between gap-1 items-center text-standard fill-background-10',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-30'
|
||||
: 'hover:bg-background-50 cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-center items-center h-8 mobile:w-8 p-2 mobile:p-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div className={classNames('mobile:hidden')}>{children}</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function parseConfigImport(
|
||||
config: SkeletonConfigExport
|
||||
@@ -65,17 +122,6 @@ function ImportExportButtons() {
|
||||
const [importState, setImportState] = useState(ImportStatus.OK);
|
||||
const exporting = useRef(false);
|
||||
|
||||
const importStatusKey = useMemo(() => {
|
||||
switch (importState) {
|
||||
case ImportStatus.FAILED:
|
||||
return 'onboarding-manual_proportions-import-failed';
|
||||
case ImportStatus.SUCCESS:
|
||||
return 'onboarding-manual_proportions-import-success';
|
||||
case ImportStatus.OK:
|
||||
return 'onboarding-manual_proportions-import';
|
||||
}
|
||||
}, [importState]);
|
||||
|
||||
useRPCPacket(
|
||||
RpcMessage.SkeletonConfigResponse,
|
||||
(data: SkeletonConfigExport) => {
|
||||
@@ -160,41 +206,122 @@ function ImportExportButtons() {
|
||||
sendRPCPacket(RpcMessage.ChangeSkeletonConfigRequest, req)
|
||||
);
|
||||
setImportState(ImportStatus.SUCCESS);
|
||||
setTimeout(() => {
|
||||
setImportState(ImportStatus.OK);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
exporting.current = true;
|
||||
<div className="flex">
|
||||
<IconButton
|
||||
icon={<UploadFileIcon width={25}></UploadFileIcon>}
|
||||
onClick={onImport}
|
||||
className={classNames(
|
||||
'transition-colors',
|
||||
importState === ImportStatus.FAILED && 'text-status-critical',
|
||||
importState === ImportStatus.SUCCESS && 'text-status-success'
|
||||
)}
|
||||
>
|
||||
<Localized id="onboarding-manual_proportions-import">
|
||||
<Typography variant="standard"></Typography>
|
||||
</Localized>
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<IconButton
|
||||
icon={<ImportIcon size={25}></ImportIcon>}
|
||||
onClick={() => {
|
||||
exporting.current = true;
|
||||
|
||||
sendRPCPacket(
|
||||
RpcMessage.SkeletonConfigRequest,
|
||||
new SkeletonConfigRequestT()
|
||||
);
|
||||
}}
|
||||
>
|
||||
{l10n.getString('onboarding-manual_proportions-export')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={classNames(
|
||||
'transition-colors',
|
||||
importState === ImportStatus.FAILED && 'bg-status-critical',
|
||||
importState === ImportStatus.SUCCESS && 'bg-status-success'
|
||||
)}
|
||||
onClick={onImport}
|
||||
>
|
||||
{l10n.getString(importStatusKey)}
|
||||
</Button>
|
||||
sendRPCPacket(
|
||||
RpcMessage.SkeletonConfigRequest,
|
||||
new SkeletonConfigRequestT()
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Localized id="onboarding-manual_proportions-export">
|
||||
<Typography variant="standard"></Typography>
|
||||
</Localized>
|
||||
</IconButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonsControl() {
|
||||
const { l10n } = useLocalization();
|
||||
type ManualProportionControls = Control<{
|
||||
precise: boolean;
|
||||
ratio: boolean;
|
||||
}>;
|
||||
|
||||
function LinearRatioToggle({ control }: { control: ManualProportionControls }) {
|
||||
return (
|
||||
<Controller
|
||||
name="ratio"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
{value ? (
|
||||
<IconButton
|
||||
icon={<PercentIcon size={25}></PercentIcon>}
|
||||
onClick={() => onChange(!value)}
|
||||
>
|
||||
<Localized id="onboarding-manual_proportions-grouped_proportions">
|
||||
<Typography variant="standard"></Typography>
|
||||
</Localized>
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<RulerIcon width={25}></RulerIcon>}
|
||||
onClick={() => onChange(!value)}
|
||||
>
|
||||
<Localized id="onboarding-manual_proportions-all_proportions">
|
||||
<Typography variant="standard"></Typography>
|
||||
</Localized>
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
></Controller>
|
||||
);
|
||||
}
|
||||
|
||||
function PreciseToggle({ control }: { control: ManualProportionControls }) {
|
||||
return (
|
||||
<Controller
|
||||
name="precise"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
{!value ? (
|
||||
<IconButton
|
||||
icon={<div className="text-xl font-bold">+1</div>}
|
||||
onClick={() => onChange(!value)}
|
||||
>
|
||||
<Localized id="onboarding-manual_proportions-normal_increment">
|
||||
<Typography variant="standard"></Typography>
|
||||
</Localized>
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<div className="text-xl font-bold">+0.5</div>}
|
||||
onClick={() => onChange(!value)}
|
||||
>
|
||||
<Localized id="onboarding-manual_proportions-precise_increment">
|
||||
<Typography variant="standard"></Typography>
|
||||
</Localized>
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
></Controller>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonsControl({ control }: { control: ManualProportionControls }) {
|
||||
const { state } = useOnboarding();
|
||||
const nav = useNavigate();
|
||||
const computedTrackers = useAtomValue(computedTrackersAtom);
|
||||
const { sendRPCPacket } = useWebsocketAPI();
|
||||
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
@@ -205,56 +332,6 @@ function ButtonsControl() {
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gap-2 flex mobile:grid grid-cols-2">
|
||||
<div className="flex flex-grow mobile:contents">
|
||||
<Button
|
||||
variant="secondary"
|
||||
state={{ alonePage: state.alonePage }}
|
||||
to="/onboarding/body-proportions/scaled"
|
||||
>
|
||||
{l10n.getString('onboarding-scaled_proportions-title')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2 mobile:contents">
|
||||
<ImportExportButtons></ImportExportButtons>
|
||||
<Button variant="secondary" onClick={() => setShowWarning(true)}>
|
||||
{l10n.getString('reset-reset_all')}
|
||||
</Button>
|
||||
<ProportionsResetModal
|
||||
accept={() => {
|
||||
resetAll();
|
||||
setShowWarning(false);
|
||||
}}
|
||||
onClose={() => setShowWarning(false)}
|
||||
isOpen={showWarning}
|
||||
></ProportionsResetModal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManualProportionsPage() {
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
const { l10n } = useLocalization();
|
||||
const { applyProgress, state } = useOnboarding();
|
||||
const { computedTrackers } = useAppContext();
|
||||
|
||||
applyProgress(0.9);
|
||||
|
||||
const savedValue = useMemo(() => localStorage.getItem('ratioMode'), []);
|
||||
|
||||
const defaultValues = { precise: false, ratio: savedValue !== 'false' };
|
||||
|
||||
const { control, watch } = useForm<{ precise: boolean; ratio: boolean }>({
|
||||
defaultValues,
|
||||
});
|
||||
const { precise, ratio } = watch();
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ratioMode', ratio?.toString() ?? 'true');
|
||||
}, [ratio]);
|
||||
|
||||
const beneathFloor = useMemo(() => {
|
||||
const hmd = computedTrackers.find(
|
||||
(tracker) =>
|
||||
@@ -267,73 +344,141 @@ export function ManualProportionsPage() {
|
||||
const canUseFineTuning = !beneathFloor || import.meta.env.DEV;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-5 h-full items-center w-full xs:justify-center relative">
|
||||
<div className="flex flex-col w-full h-full xs:max-w-5xl xs:justify-center">
|
||||
<div className="flex gap-8 justify-center h-full xs:items-center">
|
||||
<div className="flex flex-col w-full xs:max-w-2xl gap-3 items-center mobile:justify-around">
|
||||
<div className="flex flex-col mx-4">
|
||||
<Typography variant="main-title">
|
||||
{l10n.getString('onboarding-manual_proportions-title')}
|
||||
</Typography>
|
||||
<CheckBox
|
||||
control={control}
|
||||
label={l10n.getString('onboarding-manual_proportions-ratio')}
|
||||
name="ratio"
|
||||
variant="toggle"
|
||||
></CheckBox>
|
||||
<CheckBox
|
||||
control={control}
|
||||
label={l10n.getString(
|
||||
'onboarding-manual_proportions-precision'
|
||||
)}
|
||||
name="precise"
|
||||
variant="toggle"
|
||||
></CheckBox>
|
||||
{isMobile && (
|
||||
<div className="flex gap-3 justify-between">
|
||||
<ButtonsControl></ButtonsControl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip
|
||||
content={
|
||||
<Localized id="onboarding-manual_proportions-fine_tuning_button-disabled-tooltip">
|
||||
<Typography></Typography>
|
||||
</Localized>
|
||||
}
|
||||
preferedDirection="top"
|
||||
disabled={canUseFineTuning}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
to="/onboarding/body-proportions/auto"
|
||||
state={{ alonePage: state.alonePage }}
|
||||
disabled={!canUseFineTuning}
|
||||
>
|
||||
{l10n.getString(
|
||||
'onboarding-manual_proportions-fine_tuning_button'
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<div className="bg-background-60 rounded-md flex gap-2">
|
||||
<div className="flex">
|
||||
<LinearRatioToggle control={control}></LinearRatioToggle>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<PreciseToggle control={control}></PreciseToggle>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<IconButton
|
||||
icon={<FullResetIcon width={20}></FullResetIcon>}
|
||||
onClick={() => setShowWarning(true)}
|
||||
>
|
||||
<Localized id="reset-reset_all">
|
||||
<Typography variant="standard"></Typography>
|
||||
</Localized>
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<IconButton
|
||||
showTooltip={!canUseFineTuning}
|
||||
tooltip={
|
||||
<Localized
|
||||
id={
|
||||
!canUseFineTuning
|
||||
? 'onboarding-manual_proportions-fine_tuning_button-disabled-tooltip'
|
||||
: 'onboarding-manual_proportions-fine_tuning_button'
|
||||
}
|
||||
>
|
||||
<Typography variant="standard"></Typography>
|
||||
</Localized>
|
||||
}
|
||||
disabled={!canUseFineTuning}
|
||||
icon={<HumanIcon width={20}></HumanIcon>}
|
||||
onClick={() =>
|
||||
nav('/onboarding/body-proportions/auto', {
|
||||
state: { alonePage: state.alonePage },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Localized id={'onboarding-manual_proportions-fine_tuning_button'}>
|
||||
<Typography variant="standard"></Typography>
|
||||
</Localized>
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex flex-grow"></div>
|
||||
<ImportExportButtons></ImportExportButtons>
|
||||
<ProportionsResetModal
|
||||
accept={() => {
|
||||
resetAll();
|
||||
setShowWarning(false);
|
||||
}}
|
||||
onClose={() => setShowWarning(false)}
|
||||
isOpen={showWarning}
|
||||
></ProportionsResetModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="w-full px-2">
|
||||
<BodyProportions
|
||||
precise={precise ?? defaultValues.precise}
|
||||
type={ratio ? 'ratio' : 'linear'}
|
||||
variant={state.alonePage ? 'alone' : 'onboarding'}
|
||||
></BodyProportions>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-col flex-grow gap-3 rounded-xl fill-background-50 items-center hidden md:flex">
|
||||
<SkeletonVisualizerWidget height="65vh" maxHeight={600} />
|
||||
</div>
|
||||
export function ManualProportionsPage() {
|
||||
const { applyProgress, state } = useOnboarding();
|
||||
const { useRPCPacket } = useWebsocketAPI();
|
||||
const { currentLocales } = useLocaleConfig();
|
||||
|
||||
const [userHeight, setUserHeight] = useState(0);
|
||||
|
||||
applyProgress(0.9);
|
||||
|
||||
const savedValue = useMemo(() => localStorage.getItem('ratioMode'), []);
|
||||
|
||||
const defaultValues = { precise: false, ratio: savedValue !== 'false' };
|
||||
|
||||
const { control, watch } = useForm<{ precise: boolean; ratio: boolean }>({
|
||||
defaultValues,
|
||||
});
|
||||
const { precise, ratio } = watch();
|
||||
|
||||
const { cmFormat } = useMemo(() => {
|
||||
const cmFormat = Intl.NumberFormat(currentLocales, {
|
||||
style: 'unit',
|
||||
unit: 'centimeter',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
return { cmFormat };
|
||||
}, [currentLocales]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ratioMode', ratio?.toString() ?? 'true');
|
||||
}, [ratio]);
|
||||
|
||||
useRPCPacket(
|
||||
RpcMessage.SkeletonConfigResponse,
|
||||
(data: SkeletonConfigResponseT) => {
|
||||
if (data.userHeight) setUserHeight(data.userHeight);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full h-full gap-2 bg-background-70 p-2">
|
||||
<div className="flex flex-col flex-grow gap-2">
|
||||
<ButtonsControl control={control}></ButtonsControl>
|
||||
<div className="bg-background-60 h-20 rounded-md flex-grow overflow-y-auto">
|
||||
<BodyProportions
|
||||
precise={precise ?? defaultValues.precise}
|
||||
type={ratio ? 'ratio' : 'linear'}
|
||||
variant={state.alonePage ? 'alone' : 'onboarding'}
|
||||
></BodyProportions>
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div className="my-5 mx-4">
|
||||
<ButtonsControl></ButtonsControl>
|
||||
</div>
|
||||
<div className="rounded-md overflow-clip w-1/3 bg-background-60 hidden mobile:hidden sm:flex relative">
|
||||
<SkeletonVisualizerWidget />
|
||||
|
||||
<div className="top-4 w-full px-4 absolute flex gap-2 flex-col md:flex-row">
|
||||
<div className="h-14 flex flex-grow items-center">
|
||||
<ResetButton
|
||||
type={ResetType.Full}
|
||||
size="small"
|
||||
className="w-full h-full bg-background-50 hover:bg-background-40 text-background-10"
|
||||
></ResetButton>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip
|
||||
preferedDirection="bottom"
|
||||
content={
|
||||
<Localized id="onboarding-manual_proportions-estimated_height">
|
||||
<Typography></Typography>
|
||||
</Localized>
|
||||
}
|
||||
>
|
||||
<div className="h-14 bg-background-50 p-4 flex items-center rounded-lg min-w-36 justify-center">
|
||||
<Typography variant="main-title">
|
||||
{cmFormat.format((userHeight * 100) / 0.936)}
|
||||
</Typography>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -8,19 +8,20 @@ import { CheckFloorHeightStep } from './autobone-steps/CheckFloorHeight';
|
||||
import { ResetProportionsStep } from './scaled-steps/ResetProportions';
|
||||
import { DoneStep } from './scaled-steps/Done';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMemo } from 'react';
|
||||
import { ManualHeightStep } from './scaled-steps/ManualHeightStep';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { BodyPart } from 'solarxr-protocol';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { WarningBox } from '@/components/commons/TipBox';
|
||||
import { useMemo } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { flatTrackersAtom } from '@/store/app-store';
|
||||
import { BodyPart } from 'solarxr-protocol';
|
||||
|
||||
export function ScaledProportionsPage() {
|
||||
const { l10n } = useLocalization();
|
||||
const { applyProgress, state } = useOnboarding();
|
||||
const heightContext = useProvideHeightContext();
|
||||
const navigate = useNavigate();
|
||||
const { trackers } = useTrackers();
|
||||
const trackers = useAtomValue(flatTrackersAtom);
|
||||
|
||||
const { hasHmd, hasHandControllers } = useMemo(() => {
|
||||
const hasHmd = trackers.some(
|
||||
|
||||
@@ -12,7 +12,11 @@ import { Typography } from '@/components/commons/Typography';
|
||||
import { Localized, useLocalization } from '@fluent/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocaleConfig } from '@/i18n/config';
|
||||
import { EYE_HEIGHT_TO_HEIGHT_RATIO, useHeightContext } from '@/hooks/height';
|
||||
import {
|
||||
EYE_HEIGHT_TO_HEIGHT_RATIO,
|
||||
useHeightContext,
|
||||
validateHeight,
|
||||
} from '@/hooks/height';
|
||||
import { useInterval } from '@/hooks/timeout';
|
||||
import { TooSmolModal } from './TooSmolModal';
|
||||
|
||||
@@ -26,8 +30,7 @@ export function CheckFloorHeightStep({
|
||||
variant: 'onboarding' | 'alone';
|
||||
}) {
|
||||
const { l10n } = useLocalization();
|
||||
const { floorHeight, hmdHeight, setFloorHeight, validateHeight } =
|
||||
useHeightContext();
|
||||
const { floorHeight, hmdHeight, setFloorHeight } = useHeightContext();
|
||||
const [fetchHeight, setFetchHeight] = useState(false);
|
||||
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
@@ -54,7 +54,7 @@ export function PreparationStep({
|
||||
{l10n.getString('onboarding-automatic_mounting-prev_step')}
|
||||
</Button>
|
||||
<ResetButton
|
||||
variant="small"
|
||||
size="small"
|
||||
type={ResetType.Full}
|
||||
onReseted={nextStep}
|
||||
></ResetButton>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useBreakpoint } from '@/hooks/breakpoint';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { BodyDisplay } from '@/components/commons/BodyDisplay';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { TipBox } from '@/components/commons/TipBox';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { flatTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function PutTrackersOnStep({
|
||||
nextStep,
|
||||
@@ -15,7 +16,7 @@ export function PutTrackersOnStep({
|
||||
}) {
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
const { l10n } = useLocalization();
|
||||
const { trackers } = useTrackers();
|
||||
const trackers = useAtomValue(flatTrackersAtom);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,9 +2,13 @@ import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useLocaleConfig } from '@/i18n/config';
|
||||
import { EYE_HEIGHT_TO_HEIGHT_RATIO, useHeightContext } from '@/hooks/height';
|
||||
import {
|
||||
DEFAULT_FULL_HEIGHT,
|
||||
EYE_HEIGHT_TO_HEIGHT_RATIO,
|
||||
useHeightContext,
|
||||
} from '@/hooks/height';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
ChangeSettingsRequestT,
|
||||
@@ -31,14 +35,24 @@ export function ManualHeightStep({
|
||||
}) {
|
||||
const { state } = useOnboarding();
|
||||
const { l10n } = useLocalization();
|
||||
const { setHmdHeight } = useHeightContext();
|
||||
const { control, handleSubmit, formState, watch } = useForm<HeightForm>({
|
||||
defaultValues: { height: 1.5 },
|
||||
});
|
||||
const { setHmdHeight, currentHeight } = useHeightContext();
|
||||
const { control, handleSubmit, formState, watch, reset } =
|
||||
useForm<HeightForm>({
|
||||
defaultValues: { height: DEFAULT_FULL_HEIGHT },
|
||||
});
|
||||
const { sendRPCPacket } = useWebsocketAPI();
|
||||
const { currentLocales } = useLocaleConfig();
|
||||
const height = watch('height');
|
||||
|
||||
// Load the last configured height
|
||||
useEffect(() => {
|
||||
reset({
|
||||
height:
|
||||
(currentHeight && currentHeight / EYE_HEIGHT_TO_HEIGHT_RATIO) ||
|
||||
DEFAULT_FULL_HEIGHT,
|
||||
});
|
||||
}, [currentHeight]);
|
||||
|
||||
const mFormat = useMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(currentLocales, {
|
||||
@@ -49,7 +63,7 @@ export function ManualHeightStep({
|
||||
[currentLocales]
|
||||
);
|
||||
|
||||
const submitHmdHeight = (values: HeightForm) => {
|
||||
const submitFullHeight = (values: HeightForm) => {
|
||||
const newHeight = values.height * EYE_HEIGHT_TO_HEIGHT_RATIO;
|
||||
setHmdHeight(newHeight);
|
||||
const settingsRequest = new ChangeSettingsRequestT();
|
||||
@@ -66,7 +80,7 @@ export function ManualHeightStep({
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col flex-grow"
|
||||
onSubmit={handleSubmit(submitHmdHeight)}
|
||||
onSubmit={handleSubmit(submitFullHeight)}
|
||||
>
|
||||
<div className="flex gap-2 flex-grow">
|
||||
<div className="flex flex-grow flex-col gap-4">
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AssignTrackerRequestT, BodyPart, RpcMessage } from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { useOnboarding } from '@/hooks/onboarding';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import {
|
||||
MountingOrientationDegreesToQuatT,
|
||||
@@ -18,6 +16,8 @@ import { useLocalization } from '@fluent/react';
|
||||
import { useBreakpoint } from '@/hooks/breakpoint';
|
||||
import { Quaternion } from 'three';
|
||||
import { AssignMode, defaultConfig, useConfig } from '@/hooks/config';
|
||||
import { assignedTrackersAtom, FlatDeviceTracker } from '@/store/app-store';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
export function ManualMountingPage() {
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
@@ -30,8 +30,7 @@ export function ManualMountingPage() {
|
||||
|
||||
applyProgress(0.7);
|
||||
|
||||
const { useAssignedTrackers } = useTrackers();
|
||||
const assignedTrackers = useAssignedTrackers();
|
||||
const assignedTrackers = useAtomValue(assignedTrackersAtom);
|
||||
|
||||
const trackerPartGrouped = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function MountingResetStep({
|
||||
{l10n.getString('onboarding-automatic_mounting-prev_step')}
|
||||
</Button>
|
||||
<ResetButton
|
||||
variant="small"
|
||||
size="small"
|
||||
type={ResetType.Mounting}
|
||||
onReseted={nextStep}
|
||||
></ResetButton>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function PreparationStep({
|
||||
{l10n.getString('onboarding-automatic_mounting-prev_step')}
|
||||
</Button>
|
||||
<ResetButton
|
||||
variant="small"
|
||||
size="small"
|
||||
type={ResetType.Full}
|
||||
onReseted={nextStep}
|
||||
></ResetButton>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useBreakpoint } from '@/hooks/breakpoint';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { BodyDisplay } from '@/components/commons/BodyDisplay';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { TipBox } from '@/components/commons/TipBox';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { flatTrackersAtom } from '@/store/app-store';
|
||||
|
||||
export function PutTrackersOnStep({
|
||||
nextStep,
|
||||
@@ -14,7 +15,7 @@ export function PutTrackersOnStep({
|
||||
variant: 'alone' | 'onboarding';
|
||||
}) {
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
const { trackers } = useTrackers();
|
||||
const trackers = useAtomValue(flatTrackersAtom);
|
||||
const { l10n } = useLocalization();
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Typography } from '@/components/commons/Typography';
|
||||
import { AssignMode, defaultConfig, useConfig } from '@/hooks/config';
|
||||
import { ASSIGNMENT_MODES } from '@/components/onboarding/BodyAssignment';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useEffect } from 'react';
|
||||
import { Dropdown } from '@/components/commons/Dropdown';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
|
||||
// Ordered collection of assign modes with the number of IMU trackers
|
||||
const ASSIGN_MODE_OPTIONS = [
|
||||
@@ -26,8 +27,7 @@ export function TrackerAssignOptions({
|
||||
variant: 'radio' | 'dropdown';
|
||||
}) {
|
||||
const { l10n } = useLocalization();
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const connectedIMUTrackers = useConnectedIMUTrackers().length;
|
||||
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
|
||||
|
||||
const { config, setConfig } = useConfig();
|
||||
const { control, watch, setValue } = useForm<{
|
||||
@@ -44,11 +44,11 @@ export function TrackerAssignOptions({
|
||||
}, [assignMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectedIMUTrackers <= ASSIGN_MODE_OPTIONS[assignMode]) return;
|
||||
if (connectedIMUTrackers.length <= ASSIGN_MODE_OPTIONS[assignMode]) return;
|
||||
|
||||
const selectedAssignMode =
|
||||
(Object.entries(ASSIGN_MODE_OPTIONS).find(
|
||||
([_, count]) => count >= connectedIMUTrackers
|
||||
([_, count]) => count >= connectedIMUTrackers.length
|
||||
)?.[0] as AssignMode) ?? AssignMode.All;
|
||||
|
||||
if (assignMode !== selectedAssignMode) {
|
||||
@@ -114,7 +114,9 @@ export function TrackerAssignOptions({
|
||||
name="assignMode"
|
||||
control={control}
|
||||
value={mode}
|
||||
disabled={connectedIMUTrackers > trackersCount && mode !== AssignMode.All}
|
||||
disabled={
|
||||
connectedIMUTrackers.length > trackersCount && mode !== AssignMode.All
|
||||
}
|
||||
className="hidden"
|
||||
>
|
||||
<div className="flex flex-row md:gap-4 gap-2">
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
ChangeSettingsRequestT,
|
||||
TapDetectionSetupNotificationT,
|
||||
} from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { useChokerWarning } from '@/hooks/choker-warning';
|
||||
import { useOnboarding } from '@/hooks/onboarding';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { useWebsocketAPI } from '@/hooks/websocket-api';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { CheckBox } from '@/components/commons/Checkbox';
|
||||
@@ -34,6 +32,12 @@ import { defaultConfig, useConfig } from '@/hooks/config';
|
||||
import { playTapSetupSound } from '@/sounds/sounds';
|
||||
import { useBreakpoint } from '@/hooks/breakpoint';
|
||||
import { TrackerAssignOptions } from './TrackerAssignOptions';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import {
|
||||
assignedTrackersAtom,
|
||||
FlatDeviceTracker,
|
||||
flatTrackersAtom,
|
||||
} from '@/store/app-store';
|
||||
|
||||
export type BodyPartError = {
|
||||
label: string | undefined;
|
||||
@@ -51,7 +55,6 @@ export function TrackersAssignPage() {
|
||||
const { isMobile } = useBreakpoint('mobile');
|
||||
const { l10n } = useLocalization();
|
||||
const { config, setConfig } = useConfig();
|
||||
const { useAssignedTrackers, trackers } = useTrackers();
|
||||
const { applyProgress, state } = useOnboarding();
|
||||
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
|
||||
const defaultValues = {
|
||||
@@ -62,7 +65,10 @@ export function TrackersAssignPage() {
|
||||
}>({ defaultValues });
|
||||
const { mirrorView } = watch();
|
||||
const [selectedRole, setSelectRole] = useState<BodyPart>(BodyPart.NONE);
|
||||
const assignedTrackers = useAssignedTrackers();
|
||||
|
||||
const assignedTrackers = useAtomValue(assignedTrackersAtom);
|
||||
const trackers = useAtomValue(flatTrackersAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setConfig({ mirrorView });
|
||||
}, [mirrorView]);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import classNames from 'classnames';
|
||||
import ReactModal from 'react-modal';
|
||||
import { BodyPart } from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { useTrackers } from '@/hooks/tracker';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { TipBox } from '@/components/commons/TipBox';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { TrackerCard } from '@/components/tracker/TrackerCard';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import {
|
||||
assignedTrackersAtom,
|
||||
FlatDeviceTracker,
|
||||
unassignedTrackersAtom,
|
||||
} from '@/store/app-store';
|
||||
|
||||
export function TrackerSelectionMenu({
|
||||
isOpen = true,
|
||||
@@ -21,10 +25,9 @@ export function TrackerSelectionMenu({
|
||||
onTrackerSelected: (tracker: FlatDeviceTracker | null) => void;
|
||||
}) {
|
||||
const { l10n } = useLocalization();
|
||||
const { useAssignedTrackers, useUnassignedTrackers } = useTrackers();
|
||||
|
||||
const unassignedTrackers = useUnassignedTrackers();
|
||||
const assignedTrackers = useAssignedTrackers();
|
||||
const unassignedTrackers = useAtomValue(unassignedTrackersAtom);
|
||||
const assignedTrackers = useAtomValue(assignedTrackersAtom);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DEFAULT_LOCALE, LangContext } from '@/i18n/config';
|
||||
import { getSentryOrCompute } from '@/utils/sentry';
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
if (config?.errorTracking !== undefined) {
|
||||
// load sentry ASAP to catch early errors
|
||||
getSentryOrCompute(config.errorTracking ?? false);
|
||||
|
||||
@@ -44,6 +44,10 @@ export function SettingSelectorMobile() {
|
||||
label: l10n.getString('settings-sidebar-firmware-tool'),
|
||||
value: { url: '/settings/firmware-tool' },
|
||||
},
|
||||
{
|
||||
label: l10n.getString('settings-sidebar-vrc_warnings'),
|
||||
value: { url: '/vrc-warnings' },
|
||||
},
|
||||
{
|
||||
label: l10n.getString('settings-sidebar-advanced'),
|
||||
value: { url: '/settings/advanced' },
|
||||
|
||||
@@ -107,6 +107,11 @@ export function SettingsSidebar() {
|
||||
{l10n.getString('settings-sidebar-firmware-tool')}
|
||||
</SettingsLink>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<SettingsLink to="/vrc-warnings">
|
||||
{l10n.getString('settings-sidebar-vrc_warnings')}
|
||||
</SettingsLink>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<SettingsLink to="/settings/advanced">
|
||||
{l10n.getString('settings-sidebar-advanced')}
|
||||
|
||||
@@ -27,25 +27,32 @@ export function TrackerBattery({
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const charging = (voltage || 0) > 4.3;
|
||||
const showVoltage = voltage && config?.debug;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col justify-around">
|
||||
<BatteryIcon
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
charging={(voltage || 0) > 4.3}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-10">
|
||||
<Typography color={textColor}>
|
||||
{percentFormatter.format(value)}
|
||||
</Typography>
|
||||
{voltage && config?.debug && (
|
||||
<Typography color={textColor}>
|
||||
{voltageFormatter.format(voltage)}V
|
||||
</Typography>
|
||||
)}
|
||||
<BatteryIcon value={value} disabled={disabled} charging={charging} />
|
||||
</div>
|
||||
{((!charging || showVoltage) && (
|
||||
<div className="w-10">
|
||||
{!charging && (
|
||||
<Typography color={textColor}>
|
||||
{percentFormatter.format(value)}
|
||||
</Typography>
|
||||
)}
|
||||
{showVoltage && (
|
||||
<Typography color={textColor}>
|
||||
{voltageFormatter.format(voltage)}V
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
)) || (
|
||||
<div className="flex flex-col justify-center w-10">
|
||||
<div className="w-5 h-1 bg-background-30 rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useConfig } from '@/hooks/config';
|
||||
import { MouseEventHandler } from 'react';
|
||||
import {
|
||||
DeviceDataT,
|
||||
@@ -69,6 +70,8 @@ function TrackerBig({
|
||||
tracker: TrackerDataT;
|
||||
device?: DeviceDataT;
|
||||
}) {
|
||||
const { config } = useConfig();
|
||||
|
||||
const { useName } = useTracker(tracker);
|
||||
|
||||
const trackerName = useName();
|
||||
@@ -86,10 +89,10 @@ function TrackerBig({
|
||||
<div className="flex justify-center">
|
||||
<TrackerStatus status={tracker.status}></TrackerStatus>
|
||||
</div>
|
||||
<div className="flex text-default justify-center gap-5 flex-wrap">
|
||||
<div className="min-h-9 flex text-default justify-center gap-5 flex-wrap items-center">
|
||||
{device && device.hardwareStatus && (
|
||||
<>
|
||||
{device.hardwareStatus.batteryPctEstimate && (
|
||||
{device.hardwareStatus.batteryPctEstimate != null && (
|
||||
<TrackerBattery
|
||||
voltage={device.hardwareStatus.batteryVoltage}
|
||||
value={device.hardwareStatus.batteryPctEstimate / 100}
|
||||
@@ -100,8 +103,9 @@ function TrackerBig({
|
||||
{(device.hardwareStatus.rssi != null ||
|
||||
device.hardwareStatus.ping != null) && (
|
||||
<TrackerWifi
|
||||
rssi={device.hardwareStatus.rssi || 0}
|
||||
ping={device.hardwareStatus.ping || 0}
|
||||
rssi={device.hardwareStatus.rssi}
|
||||
rssiShowNumeric={config?.debug}
|
||||
ping={device.hardwareStatus.ping}
|
||||
disabled={tracker.status === TrackerStatusEnum.DISCONNECTED}
|
||||
></TrackerWifi>
|
||||
)}
|
||||
@@ -138,7 +142,7 @@ function TrackerSmol({
|
||||
{device && device.hardwareStatus && (
|
||||
<>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
{device.hardwareStatus.batteryPctEstimate && (
|
||||
{device.hardwareStatus.batteryPctEstimate != null && (
|
||||
<TrackerBattery
|
||||
voltage={device.hardwareStatus.batteryVoltage}
|
||||
value={device.hardwareStatus.batteryPctEstimate / 100}
|
||||
@@ -150,8 +154,8 @@ function TrackerSmol({
|
||||
{(device.hardwareStatus.rssi != null ||
|
||||
device.hardwareStatus.ping != null) && (
|
||||
<TrackerWifi
|
||||
rssi={device.hardwareStatus.rssi || 0}
|
||||
ping={device.hardwareStatus.ping || 0}
|
||||
rssi={device.hardwareStatus.rssi}
|
||||
ping={device.hardwareStatus.ping}
|
||||
disabled={tracker.status === TrackerStatusEnum.DISCONNECTED}
|
||||
></TrackerWifi>
|
||||
)}
|
||||
@@ -200,10 +204,11 @@ export function TrackerCard({
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'rounded-lg overflow-hidden',
|
||||
'rounded-lg overflow-hidden transition-[box-shadow] duration-200 ease-linear',
|
||||
interactable && 'hover:bg-background-50 cursor-pointer',
|
||||
outlined && 'outline outline-2 outline-accent-background-40',
|
||||
warning && 'border-status-warning border-solid border-2',
|
||||
warning &&
|
||||
'outline outline-2 -outline-offset-2 outline-status-warning',
|
||||
bg
|
||||
)}
|
||||
style={
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import classNames from 'classnames';
|
||||
import { MouseEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { BodyPart, TrackerDataT } from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { useTracker } from '@/hooks/tracker';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { WarningIcon } from '@/components/commons/icon/WarningIcon';
|
||||
import { FlatDeviceTracker } from '@/store/app-store';
|
||||
|
||||
function Tracker({
|
||||
tracker,
|
||||
@@ -78,7 +78,7 @@ export function TrackerPartCard({
|
||||
(showCard && (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-col gap-1 control xs:w-32 hover:bg-background-50 px-2 py-1 rounded-md relative',
|
||||
'flex flex-col gap-1 control xs:w-32 hover:bg-background-50 px-2 py-1 rounded-md relative transition-[box-shadow] duration-200 ease-linear',
|
||||
direction === 'left' ? 'items-start' : 'items-end'
|
||||
)}
|
||||
id={BodyPart[role]}
|
||||
|
||||
@@ -39,6 +39,8 @@ import { useAppContext } from '@/hooks/app';
|
||||
import { MagnetometerToggleSetting } from '@/components/settings/pages/MagnetometerToggleSetting';
|
||||
import semver from 'semver';
|
||||
import { checkForUpdate } from '@/components/firmware-update/FirmwareUpdate';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { ignoredTrackersAtom } from '@/store/app-store';
|
||||
|
||||
const rotationsLabels: [Quaternion, string][] = [
|
||||
[rotationToQuatMap.BACK, 'tracker-rotation-back'],
|
||||
@@ -72,7 +74,7 @@ export function TrackerSettingsPage() {
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
});
|
||||
const { dispatch } = useAppContext();
|
||||
const setIgnoredTracker = useSetAtom(ignoredTrackersAtom);
|
||||
const { trackerName, allowDriftCompensation } = watch();
|
||||
|
||||
const tracker = useTrackerFromId(trackernum, deviceid);
|
||||
@@ -534,7 +536,10 @@ export function TrackerSettingsPage() {
|
||||
RpcMessage.ForgetDeviceRequest,
|
||||
new ForgetDeviceRequestT(macAddress)
|
||||
);
|
||||
dispatch({ type: 'ignoreTracker', value: macAddress });
|
||||
setIgnoredTracker((state) => {
|
||||
state.add(macAddress);
|
||||
return state;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{l10n.getString('tracker-settings-forget-label')}
|
||||
|
||||
@@ -8,8 +8,8 @@ export function TrackerWifi({
|
||||
disabled,
|
||||
textColor = 'secondary',
|
||||
}: {
|
||||
rssi: number;
|
||||
ping: number;
|
||||
rssi: number | null;
|
||||
ping: number | null;
|
||||
rssiShowNumeric?: boolean;
|
||||
disabled?: boolean;
|
||||
textColor?: string;
|
||||
@@ -19,19 +19,20 @@ export function TrackerWifi({
|
||||
<div className="flex flex-col justify-around">
|
||||
<WifiIcon value={rssi} disabled={disabled} />
|
||||
</div>
|
||||
{!disabled && (
|
||||
{(!disabled && (ping != null || (rssiShowNumeric && rssi != null)) && (
|
||||
<div className="w-12">
|
||||
<Typography color={textColor} whitespace="whitespace-nowrap">
|
||||
{ping} ms
|
||||
</Typography>
|
||||
{rssiShowNumeric && (
|
||||
{ping != null && (
|
||||
<Typography color={textColor} whitespace="whitespace-nowrap">
|
||||
{ping} ms
|
||||
</Typography>
|
||||
)}
|
||||
{rssiShowNumeric && rssi != null && (
|
||||
<Typography color={textColor} whitespace="whitespace-nowrap">
|
||||
{rssi} dBm
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{disabled && (
|
||||
)) || (
|
||||
<div className="flex flex-col justify-center w-12">
|
||||
<div className="w-7 h-1 bg-background-30 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
TrackerIdT,
|
||||
TrackerStatus as TrackerStatusEnum,
|
||||
} from 'solarxr-protocol';
|
||||
import { FlatDeviceTracker } from '@/hooks/app';
|
||||
import { useConfig } from '@/hooks/config';
|
||||
import { useTracker } from '@/hooks/tracker';
|
||||
import { BodyPartIcon } from '@/components/commons/BodyPartIcon';
|
||||
@@ -17,6 +16,7 @@ import { TrackerBattery } from './TrackerBattery';
|
||||
import { TrackerStatus } from './TrackerStatus';
|
||||
import { TrackerWifi } from './TrackerWifi';
|
||||
import { trackerStatusRelated, useStatusContext } from '@/hooks/status-system';
|
||||
import { FlatDeviceTracker } from '@/store/app-store';
|
||||
|
||||
enum DisplayColumn {
|
||||
NAME,
|
||||
@@ -141,13 +141,13 @@ export function RowContainer({
|
||||
)}px rgb(var(--accent-background-30))`,
|
||||
}}
|
||||
className={classNames(
|
||||
'h-[50px] flex flex-col justify-center px-3',
|
||||
rounded === 'left' && 'rounded-l-lg',
|
||||
rounded === 'right' && 'rounded-r-lg',
|
||||
'h-[50px] flex flex-col justify-center px-3 transition-[box-shadow] duration-200 ease-linear',
|
||||
rounded === 'left' && 'rounded-l-lg border-l-2',
|
||||
rounded === 'right' && 'rounded-r-lg border-r-2',
|
||||
hover ? 'bg-background-50 cursor-pointer' : 'bg-background-60',
|
||||
warning && 'border-status-warning border-solid border-t-2 border-b-2',
|
||||
rounded === 'left' && warning && 'border-l-2',
|
||||
rounded === 'right' && warning && 'border-r-2'
|
||||
(warning &&
|
||||
'border-status-warning border-solid border-t-2 border-b-2') ||
|
||||
'border-transparent'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -273,10 +273,10 @@ export function TrackersTable({
|
||||
id: DisplayColumn.BATTERY,
|
||||
label: l10n.getString('tracker-table-column-battery'),
|
||||
row: ({ device, tracker }) =>
|
||||
device?.hardwareStatus?.batteryPctEstimate && (
|
||||
device?.hardwareStatus?.batteryPctEstimate != null && (
|
||||
<TrackerBattery
|
||||
value={device.hardwareStatus.batteryPctEstimate / 100}
|
||||
voltage={device.hardwareStatus?.batteryVoltage}
|
||||
voltage={device.hardwareStatus.batteryVoltage}
|
||||
disabled={tracker.status === TrackerStatusEnum.DISCONNECTED}
|
||||
textColor={fontColor}
|
||||
/>
|
||||
@@ -290,9 +290,9 @@ export function TrackersTable({
|
||||
(device?.hardwareStatus?.rssi != null ||
|
||||
device?.hardwareStatus?.ping != null) && (
|
||||
<TrackerWifi
|
||||
rssi={device?.hardwareStatus?.rssi || 0}
|
||||
rssi={device?.hardwareStatus?.rssi}
|
||||
rssiShowNumeric
|
||||
ping={device?.hardwareStatus?.ping || 0}
|
||||
ping={device?.hardwareStatus?.ping}
|
||||
disabled={tracker.status === TrackerStatusEnum.DISCONNECTED}
|
||||
textColor={fontColor}
|
||||
></TrackerWifi>
|
||||
|
||||
312
gui/src/components/vrc/VRCWarningsPage.tsx
Normal file
312
gui/src/components/vrc/VRCWarningsPage.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { ReactNode } from 'react';
|
||||
import { CheckIcon } from '@/components/commons/icon/CheckIcon';
|
||||
import { WarningIcon } from '@/components/commons/icon/WarningIcon';
|
||||
import {
|
||||
avatarMeasurementTypeTranslationMap,
|
||||
spineModeTranslationMap,
|
||||
trackerModelTranslationMap,
|
||||
useVRCConfig,
|
||||
VRCConfigStateSupported,
|
||||
} from '@/hooks/vrc-config';
|
||||
import { Localized, useLocalization } from '@fluent/react';
|
||||
import classNames from 'classnames';
|
||||
import { useLocaleConfig } from '@/i18n/config';
|
||||
import { A } from '@/components/commons/A';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
|
||||
function SettingRow({
|
||||
name,
|
||||
valid,
|
||||
value,
|
||||
recommendedValue,
|
||||
muted,
|
||||
mute,
|
||||
}: {
|
||||
name: string;
|
||||
recommendedValue: ReactNode;
|
||||
value: ReactNode;
|
||||
valid: boolean;
|
||||
muted: boolean;
|
||||
mute: () => void;
|
||||
}) {
|
||||
return (
|
||||
<tr className="group border-b border-background-60">
|
||||
<td className="px-6 py-4 flex gap-2 fill-status-success items-center">
|
||||
{valid ? (
|
||||
<CheckIcon size={20} />
|
||||
) : (
|
||||
<WarningIcon width={20} className="text-status-warning" />
|
||||
)}
|
||||
<Localized id={name}>
|
||||
<Typography>{name}</Typography>
|
||||
</Localized>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-end items-center">{recommendedValue}</td>
|
||||
<td
|
||||
className={classNames(
|
||||
'px-6 py-4 text-end items-center',
|
||||
!valid && !muted && 'text-status-warning'
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
<td
|
||||
className={classNames('px-6 py-4 text-end items-end justify-end flex')}
|
||||
>
|
||||
<Localized id={muted ? 'vrc_config-unmute-btn' : 'vrc_config-mute-btn'}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="min-w-24"
|
||||
onClick={mute}
|
||||
></Button>
|
||||
</Localized>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Table({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<table className="min-w-full divide-y divide-background-50">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-start">
|
||||
<Localized id={'vrc_config-setting_name'}>
|
||||
<Typography />
|
||||
</Localized>
|
||||
</th>
|
||||
|
||||
<th scope="col" className="px-6 py-3 text-end">
|
||||
<Localized id={'vrc_config-recommended_value'}>
|
||||
<Typography />
|
||||
</Localized>
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-end">
|
||||
<Localized id={'vrc_config-current_value'}>
|
||||
<Typography />
|
||||
</Localized>
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-end">
|
||||
<Localized id={'vrc_config-mute'}>
|
||||
<Typography />
|
||||
</Localized>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
const onOffKey = (value: boolean) =>
|
||||
value ? 'vrc_config-on' : 'vrc_config-off';
|
||||
|
||||
export function VRCWarningsPage() {
|
||||
const { l10n } = useLocalization();
|
||||
const { state, toggleMutedSettings, mutedSettings } = useVRCConfig();
|
||||
const { currentLocales } = useLocaleConfig();
|
||||
|
||||
const meterFormat = Intl.NumberFormat(currentLocales, {
|
||||
style: 'unit',
|
||||
unit: 'meter',
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
if (!state || !state.isSupported) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const settingRowProps = (key: keyof VRCConfigStateSupported['validity']) => ({
|
||||
mute: () => toggleMutedSettings(key),
|
||||
muted: mutedSettings.includes(key),
|
||||
valid: state.validity[key] == true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-4 w-full">
|
||||
<div className="flex flex-col max-w-lg mobile:w-full gap-3">
|
||||
<Localized id={'vrc_config-page-title'}>
|
||||
<Typography variant="main-title" />
|
||||
</Localized>
|
||||
<Localized id={'vrc_config-page-desc'}>
|
||||
<Typography variant="standard" color="secondary" />
|
||||
</Localized>
|
||||
</div>
|
||||
<div className="w-full mt-4 gap-2 flex flex-col">
|
||||
<div className="-m-2 overflow-x-auto">
|
||||
<div className="p-2 min-w-full inline-block align-middle">
|
||||
<div className="overflow-hidden flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Localized id="vrc_config-page-big_menu">
|
||||
<Typography variant="section-title" />
|
||||
</Localized>
|
||||
<Localized id="vrc_config-page-big_menu-desc">
|
||||
<Typography color="secondary" />
|
||||
</Localized>
|
||||
<Table>
|
||||
<SettingRow
|
||||
{...settingRowProps('userHeightOk')}
|
||||
name="vrc_config-user_height"
|
||||
recommendedValue={meterFormat.format(
|
||||
state.recommended.userHeight
|
||||
)}
|
||||
value={meterFormat.format(state.state.userHeight)}
|
||||
></SettingRow>
|
||||
<SettingRow
|
||||
{...settingRowProps('legacyModeOk')}
|
||||
name="vrc_config-legacy_mode"
|
||||
recommendedValue={
|
||||
<Localized
|
||||
id={onOffKey(state.recommended.legacyMode)}
|
||||
></Localized>
|
||||
}
|
||||
value={
|
||||
<Localized
|
||||
id={onOffKey(state.state.legacyMode)}
|
||||
></Localized>
|
||||
}
|
||||
></SettingRow>
|
||||
<SettingRow
|
||||
{...settingRowProps('shoulderTrackingOk')}
|
||||
name="vrc_config-disable_shoulder_tracking"
|
||||
recommendedValue={
|
||||
<Localized
|
||||
id={onOffKey(
|
||||
state.recommended.shoulderTrackingDisabled
|
||||
)}
|
||||
></Localized>
|
||||
}
|
||||
value={
|
||||
<Localized
|
||||
id={onOffKey(state.state.shoulderTrackingDisabled)}
|
||||
></Localized>
|
||||
}
|
||||
></SettingRow>
|
||||
<SettingRow
|
||||
{...settingRowProps('shoulderWidthCompensationOk')}
|
||||
name="vrc_config-shoulder_width_compensation"
|
||||
recommendedValue={
|
||||
<Localized
|
||||
id={onOffKey(
|
||||
state.recommended.shoulderWidthCompensation
|
||||
)}
|
||||
></Localized>
|
||||
}
|
||||
value={
|
||||
<Localized
|
||||
id={onOffKey(state.state.shoulderWidthCompensation)}
|
||||
></Localized>
|
||||
}
|
||||
></SettingRow>
|
||||
<SettingRow
|
||||
{...settingRowProps('calibrationVisualsOk')}
|
||||
name="vrc_config-calibration_visuals"
|
||||
recommendedValue={
|
||||
<Localized
|
||||
id={onOffKey(state.recommended.calibrationVisuals)}
|
||||
></Localized>
|
||||
}
|
||||
value={
|
||||
<Localized
|
||||
id={onOffKey(state.state.calibrationVisuals)}
|
||||
></Localized>
|
||||
}
|
||||
></SettingRow>
|
||||
<SettingRow
|
||||
{...settingRowProps('calibrationRangeOk')}
|
||||
name="vrc_config-calibration_range"
|
||||
recommendedValue={meterFormat.format(
|
||||
state.recommended.calibrationRange
|
||||
)}
|
||||
value={meterFormat.format(state.state.calibrationRange)}
|
||||
></SettingRow>
|
||||
<SettingRow
|
||||
{...settingRowProps('trackerModelOk')}
|
||||
name="vrc_config-tracker_model"
|
||||
recommendedValue={
|
||||
<Localized
|
||||
id={
|
||||
trackerModelTranslationMap[
|
||||
state.recommended.trackerModel
|
||||
]
|
||||
}
|
||||
></Localized>
|
||||
}
|
||||
value={
|
||||
<Localized
|
||||
id={
|
||||
trackerModelTranslationMap[state.state.trackerModel]
|
||||
}
|
||||
></Localized>
|
||||
}
|
||||
></SettingRow>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Localized id="vrc_config-page-wrist_menu">
|
||||
<Typography variant="section-title" />
|
||||
</Localized>
|
||||
<Localized id="vrc_config-page-wrist_menu-desc">
|
||||
<Typography color="secondary" />
|
||||
</Localized>
|
||||
<Table>
|
||||
<SettingRow
|
||||
{...settingRowProps('spineModeOk')}
|
||||
name="vrc_config-spine_mode"
|
||||
recommendedValue={state.recommended.spineMode
|
||||
.map((mode) =>
|
||||
l10n.getString(spineModeTranslationMap[mode])
|
||||
)
|
||||
.join(', ')}
|
||||
value={
|
||||
<Localized
|
||||
id={spineModeTranslationMap[state.state.spineMode]}
|
||||
></Localized>
|
||||
}
|
||||
></SettingRow>
|
||||
|
||||
<SettingRow
|
||||
{...settingRowProps('avatarMeasurementTypeOk')}
|
||||
name="vrc_config-avatar_measurement_type"
|
||||
recommendedValue={
|
||||
<Localized
|
||||
id={
|
||||
avatarMeasurementTypeTranslationMap[
|
||||
state.recommended.avatarMeasurementType
|
||||
]
|
||||
}
|
||||
></Localized>
|
||||
}
|
||||
value={
|
||||
<Localized
|
||||
id={
|
||||
avatarMeasurementTypeTranslationMap[
|
||||
state.state.avatarMeasurementType
|
||||
]
|
||||
}
|
||||
></Localized>
|
||||
}
|
||||
></SettingRow>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col max-w-lg mobile:w-full gap-2 mt-4">
|
||||
<Localized id={'vrc_config-page-help'}>
|
||||
<Typography variant="section-title" />
|
||||
</Localized>
|
||||
<Localized
|
||||
id={'vrc_config-page-help-desc'}
|
||||
elems={{
|
||||
a: <A href="https://docs.slimevr.dev/tools/vrchat-config.html"></A>,
|
||||
}}
|
||||
>
|
||||
<Typography color="secondary" />
|
||||
</Localized>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { PerspectiveCamera, Vector3 } from 'three';
|
||||
import { Button } from '@/components/commons/Button';
|
||||
import { QuatObject } from '@/maths/quaternion';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { Vector3Object } from '@/maths/vector3';
|
||||
import { Vector3Object, Vector3FromVec3fT } from '@/maths/vector3';
|
||||
import { Gltf } from '@react-three/drei';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
@@ -28,12 +28,20 @@ export function TrackerModel({ model }: { model: string }) {
|
||||
function SceneRenderer({
|
||||
quat,
|
||||
vec,
|
||||
mag,
|
||||
model,
|
||||
}: {
|
||||
quat: QuatObject;
|
||||
vec: Vector3Object;
|
||||
mag: Vector3Object;
|
||||
model: string;
|
||||
}) {
|
||||
const magDir = new Vector3(mag.x, mag.y, mag.z);
|
||||
const magLen = magDir.length();
|
||||
const magMag = Math.sqrt(magLen / 100); // normalize magnituge
|
||||
if (magLen > 0)
|
||||
magDir.multiplyScalar(1/ magLen);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
className="container"
|
||||
@@ -56,23 +64,17 @@ function SceneRenderer({
|
||||
|
||||
<arrowHelper
|
||||
args={[
|
||||
new Vector3(-Math.sign(vec.x), 0, 0),
|
||||
Vector3FromVec3fT(vec).normalize(),
|
||||
new Vector3(0, 0, 0),
|
||||
Math.sqrt(Math.abs(vec.x)) * 2,
|
||||
Math.sqrt(Vector3FromVec3fT(vec).length()) * 2,
|
||||
]}
|
||||
/>
|
||||
<arrowHelper
|
||||
args={[
|
||||
new Vector3(0, Math.sign(vec.y), 0),
|
||||
new Vector3(0, 0, 0),
|
||||
Math.sqrt(Math.abs(vec.y)) * 2,
|
||||
]}
|
||||
/>
|
||||
<arrowHelper
|
||||
args={[
|
||||
new Vector3(0, 0, -Math.sign(vec.z)),
|
||||
new Vector3(0, 0, 0),
|
||||
Math.sqrt(Math.abs(vec.z)) * 2,
|
||||
magDir,
|
||||
magDir.clone().multiplyScalar(-magMag),
|
||||
2 * magMag,
|
||||
THREE.Color.NAMES.aqua,
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -108,14 +110,17 @@ export function IMUVisualizerWidget({ tracker }: { tracker: TrackerDataT }) {
|
||||
|
||||
const rotationRaw = useRawRotationEulerDegrees();
|
||||
const rotationIdent = useIdentAdjRotationEulerDegrees() || rotationRaw;
|
||||
const quat =
|
||||
tracker?.rotationIdentityAdjusted ||
|
||||
tracker?.rotation ||
|
||||
new THREE.Quaternion();
|
||||
const vec =
|
||||
tracker?.linearAcceleration ||
|
||||
tracker?.rawAcceleration ||
|
||||
new THREE.Vector3();
|
||||
const quat = useMemo(() => {
|
||||
return tracker?.rotationIdentityAdjusted || tracker?.rotation || new THREE.Quaternion()
|
||||
}, [tracker])
|
||||
|
||||
const vec = useMemo(() => {
|
||||
return new Vector3().copy(tracker?.linearAcceleration || tracker?.rawAcceleration || {x: 0, y: 0, z: 0}) // .applyQuaternion(new THREE.Quaternion().copy(quat))
|
||||
}, [tracker, quat])
|
||||
|
||||
const mag =
|
||||
tracker?.rawMagneticVector ||
|
||||
new THREE.Vector3();
|
||||
|
||||
return (
|
||||
<div className="bg-background-70 flex flex-col p-3 rounded-lg gap-2">
|
||||
@@ -157,6 +162,17 @@ export function IMUVisualizerWidget({ tracker }: { tracker: TrackerDataT }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tracker.rawMagneticVector && (
|
||||
<div className="flex justify-between">
|
||||
<Typography color="secondary">
|
||||
{l10n.getString('tracker-infos-magnetometer')}
|
||||
</Typography>
|
||||
<Typography>
|
||||
{formatVector3(tracker.rawMagneticVector, 1)}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!enabled && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -189,6 +205,7 @@ export function IMUVisualizerWidget({ tracker }: { tracker: TrackerDataT }) {
|
||||
<SceneRenderer
|
||||
quat={{ ...quat }}
|
||||
vec={{ ...vec }}
|
||||
mag={{ ...mag }}
|
||||
model={
|
||||
isExtension ? '/models/extension.gltf' : '/models/tracker.gltf'
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Canvas, Object3DNode, extend, useThree } from '@react-three/fiber';
|
||||
import { useAppContext } from '@/hooks/app';
|
||||
import { Bone } from 'three';
|
||||
import { useMemo, useEffect, useRef, useState } from 'react';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import {
|
||||
OrbitControls,
|
||||
OrthographicCamera,
|
||||
@@ -20,6 +19,8 @@ import { Button } from '@/components/commons/Button';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { Typography } from '@/components/commons/Typography';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { bonesAtom } from '@/store/app-store';
|
||||
|
||||
extend({ BasedSkeletonHelper });
|
||||
|
||||
@@ -84,9 +85,10 @@ interface SkeletonVisualizerWidgetProps {
|
||||
maxHeight?: number | string;
|
||||
}
|
||||
|
||||
export function ToggleableSkeletonVisualizerWidget(
|
||||
props: SkeletonVisualizerWidgetProps
|
||||
) {
|
||||
export function ToggleableSkeletonVisualizerWidget({
|
||||
height,
|
||||
maxHeight,
|
||||
}: SkeletonVisualizerWidgetProps) {
|
||||
const { l10n } = useLocalization();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
@@ -121,33 +123,33 @@ export function ToggleableSkeletonVisualizerWidget(
|
||||
>
|
||||
{l10n.getString('widget-skeleton_visualizer-hide')}
|
||||
</Button>
|
||||
<SkeletonVisualizerWidget {...props} />
|
||||
<div
|
||||
style={{ height, maxHeight }}
|
||||
className="bg-background-60 p-1 rounded-md"
|
||||
>
|
||||
<SkeletonVisualizerWidget />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonVisualizerWidget({
|
||||
height = '35vh',
|
||||
maxHeight = 400,
|
||||
}: SkeletonVisualizerWidgetProps) {
|
||||
const { bones: _bones } = useAppContext();
|
||||
export function SkeletonVisualizerWidget() {
|
||||
const _bones = useAtomValue(bonesAtom);
|
||||
|
||||
const { l10n } = useLocalization();
|
||||
const bones = useMemo(
|
||||
() => new Map(_bones.map((b) => [b.bodyPart, b])),
|
||||
[JSON.stringify(_bones)]
|
||||
const bones = useMemo(() => {
|
||||
return new Map(_bones.map((b) => [b.bodyPart, b]));
|
||||
}, [_bones]);
|
||||
|
||||
const skeleton = useMemo(
|
||||
() => createChildren(bones, BoneKind.root),
|
||||
[bones.size]
|
||||
);
|
||||
|
||||
const skeleton = useRef<Bone[]>();
|
||||
|
||||
useEffect(() => {
|
||||
skeleton.current = createChildren(bones, BoneKind.root);
|
||||
}, [bones.size]);
|
||||
|
||||
useEffect(() => {
|
||||
skeleton.current?.forEach(
|
||||
skeleton.forEach(
|
||||
(bone) => bone instanceof BoneKind && bone.updateData(bones)
|
||||
);
|
||||
}, [bones]);
|
||||
@@ -163,15 +165,13 @@ export function SkeletonVisualizerWidget({
|
||||
return (yLength as BoneT[]).reduce((prev, cur) => prev + cur.boneLength, 0);
|
||||
}, [bones]);
|
||||
|
||||
const bonesInitialized = bones.size > 0;
|
||||
|
||||
const targetCamera = useMemo(() => {
|
||||
const hmd = bones.get(BodyPart.HEAD);
|
||||
if (hmd?.headPositionG?.y && hmd.headPositionG.y > 0) {
|
||||
return hmd.headPositionG.y / 2;
|
||||
}
|
||||
return heightOffset / 2;
|
||||
}, [bonesInitialized]);
|
||||
}, [bones]);
|
||||
|
||||
const yawReset = useMemo(() => {
|
||||
const hmd = bones.get(BodyPart.HEAD);
|
||||
@@ -187,45 +187,40 @@ export function SkeletonVisualizerWidget({
|
||||
new THREE.Vector3(quat.x, quat.y, quat.z).dot(VEC_Y) / VEC_Y.lengthSq()
|
||||
);
|
||||
return new THREE.Quaternion(vec.x, vec.y, vec.z, quat.w).normalize();
|
||||
}, [bonesInitialized]);
|
||||
}, [bones.size]);
|
||||
|
||||
const scale = useMemo(
|
||||
() => Math.max(1.8, heightOffset) / 1.8,
|
||||
[heightOffset]
|
||||
);
|
||||
|
||||
if (!skeleton.current) return <></>;
|
||||
if (!skeleton) return <></>;
|
||||
return (
|
||||
<div className="bg-background-60 flex flex-col p-3 rounded-lg gap-2">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<Typography color="primary" textAlign="text-center">
|
||||
{l10n.getString('tips-failed_webgl')}
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
<Canvas
|
||||
className={classNames('container mx-auto')}
|
||||
style={{ height, background: 'transparent', maxHeight }}
|
||||
>
|
||||
<gridHelper args={[10, 50, GROUND_COLOR, GROUND_COLOR]} />
|
||||
<group position={[0, heightOffset, 0]} quaternion={yawReset}>
|
||||
<SkeletonHelper object={skeleton.current[0]}></SkeletonHelper>
|
||||
</group>
|
||||
<primitive object={skeleton.current[0]} />
|
||||
<PerspectiveCamera
|
||||
makeDefault
|
||||
position={[3, 2.5, -3]}
|
||||
fov={20}
|
||||
zoom={1 / scale}
|
||||
/>
|
||||
<OrbitControls
|
||||
target={[0, targetCamera, 0]}
|
||||
maxDistance={20}
|
||||
maxPolarAngle={Math.PI / 2}
|
||||
/>
|
||||
</Canvas>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<Typography color="primary" textAlign="text-center">
|
||||
{l10n.getString('tips-failed_webgl')}
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
<Canvas className={classNames('container mx-auto')}>
|
||||
<gridHelper args={[10, 50, GROUND_COLOR, GROUND_COLOR]} />
|
||||
<group position={[0, heightOffset, 0]} quaternion={yawReset}>
|
||||
<SkeletonHelper object={skeleton[0]}></SkeletonHelper>
|
||||
</group>
|
||||
<primitive object={skeleton[0]} />
|
||||
<PerspectiveCamera
|
||||
makeDefault
|
||||
position={[3, 2.5, -3]}
|
||||
fov={20}
|
||||
zoom={1 / scale}
|
||||
/>
|
||||
<OrbitControls
|
||||
target={[0, targetCamera, 0]}
|
||||
maxDistance={20}
|
||||
maxPolarAngle={Math.PI / 2}
|
||||
/>
|
||||
</Canvas>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
Dispatch,
|
||||
Reducer,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
BoneT,
|
||||
DataFeedMessage,
|
||||
DataFeedUpdateT,
|
||||
DeviceDataT,
|
||||
ResetResponseT,
|
||||
ResetStatus,
|
||||
ResetType,
|
||||
RpcMessage,
|
||||
StartDataFeedT,
|
||||
TrackerDataT,
|
||||
} from 'solarxr-protocol';
|
||||
import { playSoundOnResetEnded, playSoundOnResetStarted } from '@/sounds/sounds';
|
||||
import { useConfig } from './config';
|
||||
@@ -26,6 +14,8 @@ import { useDataFeedConfig } from './datafeed-config';
|
||||
import { useWebsocketAPI } from './websocket-api';
|
||||
import { error } from '@/utils/logging';
|
||||
import { cacheWrap } from './cache';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { datafeedAtom, devicesAtom } from '@/store/app-store';
|
||||
import { updateSentryContext } from '@/utils/sentry';
|
||||
|
||||
export interface FirmwareRelease {
|
||||
@@ -35,41 +25,8 @@ export interface FirmwareRelease {
|
||||
firmwareFile: string;
|
||||
}
|
||||
|
||||
export interface FlatDeviceTracker {
|
||||
device?: DeviceDataT;
|
||||
tracker: TrackerDataT;
|
||||
}
|
||||
|
||||
export type AppStateAction =
|
||||
| { type: 'datafeed'; value: DataFeedUpdateT }
|
||||
| { type: 'ignoreTracker'; value: string };
|
||||
|
||||
export interface AppState {
|
||||
datafeed?: DataFeedUpdateT;
|
||||
ignoredTrackers: Set<string>;
|
||||
}
|
||||
|
||||
export interface AppContext {
|
||||
currentFirmwareRelease: FirmwareRelease | null;
|
||||
state: AppState;
|
||||
trackers: FlatDeviceTracker[];
|
||||
dispatch: Dispatch<AppStateAction>;
|
||||
bones: BoneT[];
|
||||
computedTrackers: FlatDeviceTracker[];
|
||||
}
|
||||
|
||||
export function reducer(state: AppState, action: AppStateAction) {
|
||||
switch (action.type) {
|
||||
case 'datafeed':
|
||||
return { ...state, datafeed: action.value };
|
||||
case 'ignoreTracker':
|
||||
return {
|
||||
...state,
|
||||
ignoredTrackers: new Set([...state.ignoredTrackers, action.value]),
|
||||
};
|
||||
default:
|
||||
throw new Error(`unhandled state action ${(action as AppStateAction).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function useProvideAppContext(): AppContext {
|
||||
@@ -77,10 +34,9 @@ export function useProvideAppContext(): AppContext {
|
||||
useWebsocketAPI();
|
||||
const { config } = useConfig();
|
||||
const { dataFeedConfig } = useDataFeedConfig();
|
||||
const [state, dispatch] = useReducer<Reducer<AppState, AppStateAction>>(reducer, {
|
||||
datafeed: new DataFeedUpdateT(),
|
||||
ignoredTrackers: new Set(),
|
||||
});
|
||||
const setDatafeed = useSetAtom(datafeedAtom);
|
||||
const devices = useAtomValue(devicesAtom);
|
||||
|
||||
const [currentFirmwareRelease, setCurrentFirmwareRelease] =
|
||||
useState<FirmwareRelease | null>(null);
|
||||
|
||||
@@ -92,32 +48,13 @@ export function useProvideAppContext(): AppContext {
|
||||
}
|
||||
}, [isConnected]);
|
||||
|
||||
const trackers = useMemo(
|
||||
() =>
|
||||
(state.datafeed?.devices || []).reduce<FlatDeviceTracker[]>(
|
||||
(curr, device) => [
|
||||
...curr,
|
||||
...device.trackers.map((tracker) => ({ tracker, device })),
|
||||
],
|
||||
[]
|
||||
),
|
||||
[state]
|
||||
);
|
||||
|
||||
const computedTrackers: FlatDeviceTracker[] = useMemo(
|
||||
() => (state.datafeed?.syntheticTrackers || []).map((tracker) => ({ tracker })),
|
||||
[state]
|
||||
);
|
||||
|
||||
const bones = useMemo(() => state.datafeed?.bones || [], [state]);
|
||||
|
||||
useDataFeedPacket(DataFeedMessage.DataFeedUpdate, (packet: DataFeedUpdateT) => {
|
||||
dispatch({ type: 'datafeed', value: packet });
|
||||
setDatafeed(packet);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateSentryContext(state);
|
||||
}, [state.datafeed?.devices]);
|
||||
updateSentryContext(devices);
|
||||
}, [devices]);
|
||||
|
||||
useRPCPacket(RpcMessage.ResetResponse, ({ status, resetType }: ResetResponseT) => {
|
||||
if (!config?.feedbackSound) return;
|
||||
@@ -187,11 +124,6 @@ export function useProvideAppContext(): AppContext {
|
||||
|
||||
return {
|
||||
currentFirmwareRelease,
|
||||
state,
|
||||
trackers,
|
||||
dispatch,
|
||||
bones,
|
||||
computedTrackers,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface Config {
|
||||
errorTracking: boolean | null;
|
||||
decorations: boolean;
|
||||
showNavbarOnboarding: boolean;
|
||||
vrcMutedWarnings: string[];
|
||||
}
|
||||
|
||||
export interface ConfigContext {
|
||||
@@ -67,6 +68,7 @@ export const defaultConfig: Omit<Config, 'devSettings'> = {
|
||||
errorTracking: null,
|
||||
decorations: false,
|
||||
showNavbarOnboarding: true,
|
||||
vrcMutedWarnings: [],
|
||||
};
|
||||
|
||||
interface CrossStorage {
|
||||
|
||||
@@ -17,6 +17,7 @@ export function useDataFeedConfig() {
|
||||
trackerData.rotationReferenceAdjusted = true;
|
||||
trackerData.rotationIdentityAdjusted = true;
|
||||
trackerData.tps = true;
|
||||
trackerData.rawMagneticVector = true;
|
||||
|
||||
const dataMask = new DeviceDataMaskT();
|
||||
dataMask.deviceData = true;
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useConfig } from './config';
|
||||
import { useInterval } from './timeout';
|
||||
import { useTrackers } from './tracker';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { warn } from '@/utils/logging';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { connectedIMUTrackersAtom } from '@/store/app-store';
|
||||
import { getDefaultStore } from 'jotai';
|
||||
|
||||
export function useDiscordPresence() {
|
||||
const { config } = useConfig();
|
||||
const { useConnectedIMUTrackers } = useTrackers();
|
||||
const { l10n } = useLocalization();
|
||||
const imuTrackers = useConnectedIMUTrackers();
|
||||
|
||||
const updatePresence = useCallback(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (await checkDiscordClient()) {
|
||||
// If discord client exists, try updating presence
|
||||
await updateDiscordPresence({
|
||||
details: l10n.getString(
|
||||
'settings-general-interface-discord_presence-message',
|
||||
{ amount: imuTrackers.length }
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// else, try creating a discord client
|
||||
await createDiscordClient();
|
||||
}
|
||||
} catch (e) {
|
||||
warn(`failed to update presence, error: ${e}`);
|
||||
}
|
||||
})();
|
||||
}, [imuTrackers.length, l10n]);
|
||||
|
||||
// Update presence every 6.9 seconds
|
||||
useInterval(updatePresence, config?.discordPresence ? 6900 : null);
|
||||
useInterval(
|
||||
() => {
|
||||
(async () => {
|
||||
try {
|
||||
// Better to do this instead of useAtomValue as we are doing polling with the interval
|
||||
// useAtomValue can trigger re render of the dom and this hook is top level, so this
|
||||
// would be really bad
|
||||
const imuTrackers = getDefaultStore().get(connectedIMUTrackersAtom);
|
||||
if (await checkDiscordClient()) {
|
||||
// If discord client exists, try updating presence
|
||||
await updateDiscordPresence({
|
||||
details: l10n.getString(
|
||||
'settings-general-interface-discord_presence-message',
|
||||
{ amount: imuTrackers.length }
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// else, try creating a discord client
|
||||
await createDiscordClient();
|
||||
}
|
||||
} catch (e) {
|
||||
warn(`failed to update presence, error: ${e}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
config?.discordPresence ? 6900 : null
|
||||
);
|
||||
|
||||
// Clear presence on config being disabled
|
||||
useEffect(() => {
|
||||
|
||||
@@ -64,6 +64,7 @@ export const boardTypeToFirmwareToolBoardType: Record<
|
||||
[BoardType.XIAO_ESP32C3]: null,
|
||||
[BoardType.ESP32C6DEVKITC1]: null,
|
||||
[BoardType.GLOVE_IMU_SLIMEVR_DEV]: null,
|
||||
[BoardType.GESTURES]: null,
|
||||
};
|
||||
|
||||
export const firmwareToolToBoardType: Record<CreateBoardConfigDTO['type'], BoardType> =
|
||||
@@ -187,6 +188,7 @@ export function useFirmwareToolContext(): FirmwareToolContext {
|
||||
boardConfig: {
|
||||
...currConfig.boardConfig,
|
||||
...form,
|
||||
batteryResistances: form.batteryResistances.map((r) => Number(r)),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useWebsocketAPI } from './websocket-api';
|
||||
import { RpcMessage, SettingsRequestT, SettingsResponseT } from 'solarxr-protocol';
|
||||
import { MIN_HEIGHT } from './manual-proportions';
|
||||
@@ -8,10 +8,7 @@ export interface HeightContext {
|
||||
setHmdHeight: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
floorHeight: number | null;
|
||||
setFloorHeight: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
validateHeight: (
|
||||
hmdHeight: number | null | undefined,
|
||||
floorHeight: number | null | undefined
|
||||
) => boolean;
|
||||
currentHeight: number | null;
|
||||
}
|
||||
|
||||
export function useProvideHeightContext(): HeightContext {
|
||||
@@ -19,17 +16,6 @@ export function useProvideHeightContext(): HeightContext {
|
||||
const [floorHeight, setFloorHeight] = useState<number | null>(null);
|
||||
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
|
||||
|
||||
function validateHeight(
|
||||
hmdHeight: number | null | undefined,
|
||||
floorHeight: number | null | undefined
|
||||
) {
|
||||
return (
|
||||
hmdHeight !== undefined &&
|
||||
hmdHeight !== null &&
|
||||
hmdHeight - (floorHeight ?? 0) > MIN_HEIGHT
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()),
|
||||
[]
|
||||
@@ -44,7 +30,12 @@ export function useProvideHeightContext(): HeightContext {
|
||||
}
|
||||
});
|
||||
|
||||
return { hmdHeight, setHmdHeight, floorHeight, setFloorHeight, validateHeight };
|
||||
const currentHeight = useMemo(
|
||||
() => computeHeight(hmdHeight, floorHeight),
|
||||
[hmdHeight, floorHeight]
|
||||
);
|
||||
|
||||
return { hmdHeight, setHmdHeight, floorHeight, setFloorHeight, currentHeight };
|
||||
}
|
||||
|
||||
export const HeightContextC = createContext<HeightContext>(undefined as never);
|
||||
@@ -57,7 +48,29 @@ export function useHeightContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
export function validateHeight(
|
||||
hmdHeight: number | null | undefined,
|
||||
floorHeight: number | null | undefined
|
||||
) {
|
||||
const height = computeHeight(hmdHeight, floorHeight);
|
||||
return height != null && height >= MIN_HEIGHT;
|
||||
}
|
||||
|
||||
export function computeHeight(
|
||||
hmdHeight: number | null | undefined,
|
||||
floorHeight: number | null | undefined
|
||||
) {
|
||||
return hmdHeight !== undefined && hmdHeight !== null
|
||||
? hmdHeight - (floorHeight ?? 0)
|
||||
: null;
|
||||
}
|
||||
|
||||
// The headset height is not the full height! This value compensates for the
|
||||
// offset from the headset height to the user full height
|
||||
// From Drillis and Contini (1966)
|
||||
export const EYE_HEIGHT_TO_HEIGHT_RATIO = 0.936;
|
||||
|
||||
// Based on average human height (1.65m)
|
||||
// From https://ourworldindata.org/human-height (January 2024)
|
||||
export const DEFAULT_FULL_HEIGHT = 1.65;
|
||||
export const DEFAULT_EYE_HEIGHT = DEFAULT_FULL_HEIGHT * EYE_HEIGHT_TO_HEIGHT_RATIO;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FlatDeviceTracker } from '@/store/app-store';
|
||||
import { useMemo } from 'react';
|
||||
import { FlatDeviceTracker } from './app';
|
||||
|
||||
const IGNORED_BOARDS = new Set(['Sony Mocopi', 'Haritora']);
|
||||
|
||||
|
||||
@@ -6,181 +6,31 @@ import {
|
||||
ChangeSkeletonConfigRequestT,
|
||||
} from 'solarxr-protocol';
|
||||
import { useWebsocketAPI } from './websocket-api';
|
||||
import { useReducer, useEffect, useMemo, useState, useLayoutEffect } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export type ProportionChange = LinearChange | RatioChange | BoneChange | GroupChange;
|
||||
|
||||
export enum ProportionChangeType {
|
||||
Linear,
|
||||
Ratio,
|
||||
Bone,
|
||||
Group,
|
||||
}
|
||||
|
||||
export interface LinearChange {
|
||||
type: ProportionChangeType.Linear;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface RatioChange {
|
||||
type: ProportionChangeType.Ratio;
|
||||
/**
|
||||
* This is a number between -1 and 1 [-1; 1]
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BoneChange {
|
||||
type: ProportionChangeType.Bone;
|
||||
bone: SkeletonBone;
|
||||
type LabelBase = {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
} & ({ unit: 'cm' } | { unit: 'percent'; ratio: number });
|
||||
|
||||
export interface GroupChange {
|
||||
type: ProportionChangeType.Group;
|
||||
bones: {
|
||||
bone: SkeletonBone;
|
||||
/**
|
||||
* This is a number between 0 and 1 [0; 1]
|
||||
*/
|
||||
value: number;
|
||||
label: string;
|
||||
}[];
|
||||
value: number;
|
||||
label: string;
|
||||
index?: number;
|
||||
parentLabel: string;
|
||||
}
|
||||
|
||||
export type ProportionState = BoneState | GroupState;
|
||||
|
||||
export enum BoneType {
|
||||
Single,
|
||||
Group,
|
||||
}
|
||||
|
||||
export interface BoneState {
|
||||
type: BoneType.Single;
|
||||
export type BoneLabel = LabelBase & {
|
||||
type: 'bone';
|
||||
bone: SkeletonBone;
|
||||
value: number;
|
||||
currentLabel: string;
|
||||
}
|
||||
};
|
||||
|
||||
export interface GroupState {
|
||||
type: BoneType.Group;
|
||||
bones: {
|
||||
bone: SkeletonBone;
|
||||
/**
|
||||
* This is a number between 0 and 1 [0; 1]
|
||||
*/
|
||||
value: number;
|
||||
}[];
|
||||
value: number;
|
||||
currentLabel: string;
|
||||
index?: number;
|
||||
parentLabel: string;
|
||||
}
|
||||
export type GroupLabel = LabelBase & {
|
||||
type: 'group';
|
||||
bones: GroupPartLabel[];
|
||||
};
|
||||
|
||||
function reducer(state: ProportionState, action: ProportionChange): ProportionState {
|
||||
switch (action.type) {
|
||||
case ProportionChangeType.Bone: {
|
||||
return {
|
||||
...action,
|
||||
currentLabel: action.label,
|
||||
type: BoneType.Single,
|
||||
};
|
||||
}
|
||||
|
||||
case ProportionChangeType.Group: {
|
||||
return {
|
||||
...action,
|
||||
currentLabel: action.label,
|
||||
type: BoneType.Group,
|
||||
};
|
||||
}
|
||||
|
||||
case ProportionChangeType.Linear: {
|
||||
if (action.value > 0) {
|
||||
return {
|
||||
...state,
|
||||
value: roundedStep(state.value, action.value, true),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
value: state.value + action.value / 100,
|
||||
};
|
||||
}
|
||||
|
||||
case ProportionChangeType.Ratio: {
|
||||
if (state.type === BoneType.Single || state.index === undefined) {
|
||||
throw new Error(`Unexpected increase of bone ${state.currentLabel}`);
|
||||
}
|
||||
|
||||
const newState: GroupState = JSON.parse(JSON.stringify(state));
|
||||
if (newState.index === undefined) throw 'unreachable';
|
||||
newState.bones[newState.index].value += action.value;
|
||||
if (newState.bones[newState.index].value <= 0) return state;
|
||||
const filtered = newState.bones.filter((_it, index) => newState.index !== index);
|
||||
const total = filtered.reduce((acc, cur) => acc + cur.value, 0);
|
||||
|
||||
for (const part of filtered) {
|
||||
part.value += (part.value / total) * action.value * -1;
|
||||
if (part.value <= 0) return state;
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
}
|
||||
}
|
||||
export type GroupPartLabel = LabelBase & {
|
||||
type: 'group-part';
|
||||
bone: SkeletonBone;
|
||||
group: string;
|
||||
};
|
||||
|
||||
export type Label = BoneLabel | GroupLabel | GroupPartLabel;
|
||||
|
||||
export enum LabelType {
|
||||
Bone,
|
||||
Group,
|
||||
GroupPart,
|
||||
}
|
||||
|
||||
export interface BoneLabel {
|
||||
type: LabelType.Bone;
|
||||
bone: SkeletonBone;
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GroupLabel {
|
||||
type: LabelType.Group;
|
||||
bones: {
|
||||
bone: SkeletonBone;
|
||||
/**
|
||||
* This is a number between 0 and 1 [0; 1]
|
||||
*/
|
||||
value: number;
|
||||
label: string;
|
||||
}[];
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GroupPartLabel {
|
||||
type: LabelType.GroupPart;
|
||||
bones: {
|
||||
bone: SkeletonBone;
|
||||
/**
|
||||
* This is a number between 0 and 1 [0; 1]
|
||||
*/
|
||||
value: number;
|
||||
label: string;
|
||||
}[];
|
||||
value: number;
|
||||
label: string;
|
||||
parentLabel: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const BONE_MAPPING: Map<string, SkeletonBone[]> = new Map([
|
||||
[
|
||||
'skeleton_bone-torso_group',
|
||||
@@ -195,100 +45,76 @@ const BONE_MAPPING: Map<string, SkeletonBone[]> = new Map([
|
||||
['skeleton_bone-arm_group', [SkeletonBone.UPPER_ARM, SkeletonBone.LOWER_ARM]],
|
||||
]);
|
||||
|
||||
export const INVALID_BONE: BoneState = {
|
||||
type: BoneType.Single,
|
||||
bone: SkeletonBone.NONE,
|
||||
value: 0,
|
||||
currentLabel: 'invalid-bone',
|
||||
};
|
||||
export type UpdateBoneParams = { newValue: number } & (
|
||||
| { type: 'bone'; bone: SkeletonBone }
|
||||
| { type: 'group'; group: string }
|
||||
| { type: 'group-part'; group: string; bone: SkeletonBone }
|
||||
);
|
||||
|
||||
export function useManualProportions(): {
|
||||
bodyParts: Label[];
|
||||
ratioMode: boolean;
|
||||
state: ProportionState;
|
||||
dispatch: (change: ProportionChange) => void;
|
||||
setRatioMode: (ratio: boolean) => void;
|
||||
export function useManualProportions({ type }: { type: 'linear' | 'ratio' }): {
|
||||
bodyPartsGrouped: Label[];
|
||||
changeBoneValue: (params: UpdateBoneParams) => void;
|
||||
} {
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
const [config, setConfig] = useState<Omit<SkeletonConfigResponseT, 'pack'> | null>(
|
||||
null
|
||||
);
|
||||
const [ratio, setRatio] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, INVALID_BONE);
|
||||
|
||||
const bodyParts: Label[] = useMemo(() => {
|
||||
const bodyPartsGrouped: Label[] = useMemo(() => {
|
||||
if (!config) return [];
|
||||
if (ratio) {
|
||||
const groups: GroupPartLabel[] = [];
|
||||
for (const [label, related] of BONE_MAPPING) {
|
||||
const children = config.skeletonParts.filter((it) => related.includes(it.bone));
|
||||
const total = children.reduce((acc, cur) => cur.value + acc, 0);
|
||||
|
||||
const group: GroupPartLabel = {
|
||||
parentLabel: label,
|
||||
label,
|
||||
type: LabelType.GroupPart,
|
||||
value: total,
|
||||
bones: children.map((it) => ({
|
||||
label: 'skeleton_bone-' + SkeletonBone[it.bone],
|
||||
value: it.value / total,
|
||||
bone: it.bone,
|
||||
})),
|
||||
index: 0,
|
||||
};
|
||||
groups.push(
|
||||
...children.map((_it, index) => ({
|
||||
...group,
|
||||
index,
|
||||
label: group.bones[index].label,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return config.skeletonParts.flatMap(({ bone, value }) => {
|
||||
const part = groups.find((it) => it.bones[it.index].bone === bone);
|
||||
if (part === undefined) {
|
||||
return {
|
||||
type: LabelType.Bone,
|
||||
if (type === 'linear') {
|
||||
return config.skeletonParts.map(
|
||||
({ bone, value }) =>
|
||||
({
|
||||
type: 'bone',
|
||||
unit: 'cm',
|
||||
bone,
|
||||
label: 'skeleton_bone-' + SkeletonBone[bone],
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
if (part.index === 0) {
|
||||
return [
|
||||
// For some reason, Typescript can't handle this being a GroupPart
|
||||
// when specifically inside an array. If I directly return it without part,
|
||||
// it will work. Surely some typing in flatMap's definition is wrong
|
||||
{
|
||||
...part,
|
||||
type: LabelType.Group,
|
||||
label: part.parentLabel,
|
||||
index: undefined,
|
||||
} as unknown as GroupPartLabel,
|
||||
part,
|
||||
];
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}) satisfies BoneLabel
|
||||
);
|
||||
}
|
||||
|
||||
return config.skeletonParts.map(({ bone, value }) => ({
|
||||
type: LabelType.Bone,
|
||||
bone,
|
||||
label: 'skeleton_bone-' + SkeletonBone[bone],
|
||||
value,
|
||||
}));
|
||||
}, [config, ratio]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
dispatch({
|
||||
...INVALID_BONE,
|
||||
label: INVALID_BONE.currentLabel,
|
||||
type: ProportionChangeType.Bone,
|
||||
});
|
||||
}, [ratio]);
|
||||
return [
|
||||
...BONE_MAPPING.keys().map((groupName) => {
|
||||
const groupBones = BONE_MAPPING.get(groupName);
|
||||
if (!groupBones) throw 'invalid state - this value should always exits';
|
||||
const total = config.skeletonParts
|
||||
.filter(({ bone }) => groupBones.includes(bone))
|
||||
.reduce((acc, cur) => cur.value + acc, 0);
|
||||
return {
|
||||
type: 'group',
|
||||
bones: config.skeletonParts
|
||||
.filter(({ bone }) => groupBones.includes(bone))
|
||||
.map(({ bone, value }) => ({
|
||||
type: 'group-part',
|
||||
group: groupName,
|
||||
unit: 'percent',
|
||||
bone,
|
||||
label: 'skeleton_bone-' + SkeletonBone[bone],
|
||||
value: value,
|
||||
ratio: value / total,
|
||||
})),
|
||||
unit: 'cm',
|
||||
label: groupName,
|
||||
value: total,
|
||||
} satisfies GroupLabel;
|
||||
}),
|
||||
...config.skeletonParts
|
||||
.filter(
|
||||
({ bone }) => !BONE_MAPPING.values().find((bones) => bones.includes(bone))
|
||||
)
|
||||
.map(
|
||||
({ bone, value }) =>
|
||||
({
|
||||
type: 'bone',
|
||||
unit: 'cm',
|
||||
bone,
|
||||
label: 'skeleton_bone-' + SkeletonBone[bone],
|
||||
value,
|
||||
}) satisfies BoneLabel
|
||||
),
|
||||
];
|
||||
}, [config, type]);
|
||||
|
||||
useRPCPacket(RpcMessage.SkeletonConfigResponse, (data: SkeletonConfigResponseT) => {
|
||||
setConfig(data);
|
||||
@@ -298,70 +124,72 @@ export function useManualProportions(): {
|
||||
sendRPCPacket(RpcMessage.SkeletonConfigRequest, new SkeletonConfigRequestT());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const conf = { ...config } as Omit<SkeletonConfigResponseT, 'pack'> | null;
|
||||
|
||||
if (state.type === BoneType.Single) {
|
||||
// Just ignore if bone is none (because initial state value)
|
||||
// and check if we actually changed of value
|
||||
if (
|
||||
state.bone === SkeletonBone.NONE ||
|
||||
bodyParts.find((it) => it.type === LabelType.Bone && it.bone === state.bone)
|
||||
?.value === state.value
|
||||
) {
|
||||
return;
|
||||
return {
|
||||
bodyPartsGrouped,
|
||||
changeBoneValue: (params) => {
|
||||
if (!config) return;
|
||||
if (params.type === 'group') {
|
||||
const group = BONE_MAPPING.get(params.group);
|
||||
if (!group) throw 'invalid state - group should exist';
|
||||
const oldGroupTotal = config.skeletonParts
|
||||
.filter(({ bone }) => group.includes(bone))
|
||||
.reduce((acc, cur) => cur.value + acc, 0);
|
||||
for (const part of group) {
|
||||
const currentValue = config.skeletonParts.find(({ bone }) => bone === part);
|
||||
if (!currentValue) throw 'invalid state - the bone should exists';
|
||||
const currentRatio = currentValue.value / oldGroupTotal;
|
||||
sendRPCPacket(
|
||||
RpcMessage.ChangeSkeletonConfigRequest,
|
||||
new ChangeSkeletonConfigRequestT(part, params.newValue * currentRatio)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sendRPCPacket(
|
||||
RpcMessage.ChangeSkeletonConfigRequest,
|
||||
new ChangeSkeletonConfigRequestT(state.bone, state.value)
|
||||
);
|
||||
const b = conf?.skeletonParts?.find(({ bone }) => bone === state.bone);
|
||||
if (!b || !conf) return;
|
||||
b.value = state.value;
|
||||
} else {
|
||||
const part = bodyParts.find(
|
||||
(it) =>
|
||||
it.type === LabelType.Group &&
|
||||
(it.label === state.currentLabel || it.label === state.parentLabel)
|
||||
) as GroupLabel | undefined;
|
||||
if (params.type === 'group-part') {
|
||||
const group = BONE_MAPPING.get(params.group);
|
||||
if (!group) throw 'invalid state - group should exist';
|
||||
const part = config.skeletonParts.find(({ bone }) => bone === params.bone);
|
||||
if (!part) throw 'invalid state - the part should exists';
|
||||
const oldGroupTotal = config.skeletonParts
|
||||
.filter(({ bone }) => group.includes(bone))
|
||||
.reduce((acc, cur) => cur.value + acc, 0);
|
||||
let newValue = part.value + oldGroupTotal * params.newValue; // the new ratio is computed from the group size and not the bone
|
||||
if (newValue <= 0)
|
||||
// Prevent ratios from getting below zero
|
||||
newValue = 0;
|
||||
|
||||
// Check if we found the group we were looking for
|
||||
// and check if it even changed of value
|
||||
// we only need to check one child because changing one
|
||||
// value propagates to the other children
|
||||
|
||||
if (
|
||||
!part ||
|
||||
(part.value === state.value && part.bones[0].value === state.bones[0].value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const child of state.bones) {
|
||||
sendRPCPacket(
|
||||
RpcMessage.ChangeSkeletonConfigRequest,
|
||||
new ChangeSkeletonConfigRequestT(child.bone, state.value * child.value)
|
||||
new ChangeSkeletonConfigRequestT(params.bone, newValue)
|
||||
);
|
||||
|
||||
const b = conf?.skeletonParts?.find(({ bone }) => bone === child.bone);
|
||||
if (!b || !conf) return;
|
||||
b.value = state.value * child.value;
|
||||
// Update percent from other bones ratios so the total stays 100%
|
||||
// it will remove or add to the other bones proportionally to their current value
|
||||
const diffValue = Math.abs(newValue - part.value);
|
||||
const signDiff = Math.sign(newValue - part.value);
|
||||
for (const part of group) {
|
||||
if (part === params.bone) continue;
|
||||
const currentValue = config.skeletonParts.find(({ bone }) => bone === part);
|
||||
if (!currentValue) throw 'invalid state - the bone should exists';
|
||||
sendRPCPacket(
|
||||
RpcMessage.ChangeSkeletonConfigRequest,
|
||||
new ChangeSkeletonConfigRequestT(
|
||||
part,
|
||||
currentValue.value - (diffValue / (group.length - 1)) * signDiff
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setConfig(conf);
|
||||
}, [state]);
|
||||
|
||||
return { bodyParts, ratioMode: ratio, state, dispatch, setRatioMode: setRatio };
|
||||
}
|
||||
|
||||
function roundedStep(value: number, step: number, add: boolean): number {
|
||||
if (!add) {
|
||||
return (Math.round(value * 200) - step * 2) / 200;
|
||||
} else {
|
||||
return (Math.round(value * 200) + step * 2) / 200;
|
||||
}
|
||||
if (params.type === 'bone') {
|
||||
sendRPCPacket(
|
||||
RpcMessage.ChangeSkeletonConfigRequest,
|
||||
new ChangeSkeletonConfigRequestT(params.bone, params.newValue)
|
||||
);
|
||||
}
|
||||
sendRPCPacket(RpcMessage.SkeletonConfigRequest, new SkeletonConfigRequestT());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const MIN_HEIGHT = 0.4;
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
} from 'solarxr-protocol';
|
||||
import { useWebsocketAPI } from './websocket-api';
|
||||
import { FluentVariable } from '@fluent/bundle';
|
||||
import { FlatDeviceTracker } from './app';
|
||||
import { ReactLocalization } from '@fluent/react';
|
||||
import { FlatDeviceTracker } from '@/store/app-store';
|
||||
|
||||
type StatusSystemStateAction =
|
||||
| StatusSystemStateFixedAction
|
||||
|
||||
@@ -1,40 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BodyPart, TrackerDataT, TrackerInfoT, TrackerStatus } from 'solarxr-protocol';
|
||||
import { BodyPart, TrackerDataT, TrackerInfoT } from 'solarxr-protocol';
|
||||
import { QuaternionFromQuatT, QuaternionToEulerDegrees } from '@/maths/quaternion';
|
||||
import { useAppContext } from './app';
|
||||
import { ReactLocalization, useLocalization } from '@fluent/react';
|
||||
import { useDataFeedConfig } from './datafeed-config';
|
||||
import { Quaternion, Vector3 } from 'three';
|
||||
import { Vector3FromVec3fT } from '@/maths/vector3';
|
||||
|
||||
export function useTrackers() {
|
||||
const { trackers } = useAppContext();
|
||||
|
||||
return {
|
||||
trackers,
|
||||
useAssignedTrackers: () =>
|
||||
useMemo(
|
||||
() =>
|
||||
trackers.filter(({ tracker }) => tracker.info?.bodyPart !== BodyPart.NONE),
|
||||
[trackers]
|
||||
),
|
||||
useUnassignedTrackers: () =>
|
||||
useMemo(
|
||||
() =>
|
||||
trackers.filter(({ tracker }) => tracker.info?.bodyPart === BodyPart.NONE),
|
||||
[trackers]
|
||||
),
|
||||
useConnectedIMUTrackers: () =>
|
||||
useMemo(
|
||||
() =>
|
||||
trackers.filter(
|
||||
({ tracker }) =>
|
||||
tracker.status !== TrackerStatus.DISCONNECTED && tracker.info?.isImu
|
||||
),
|
||||
[trackers]
|
||||
),
|
||||
};
|
||||
}
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { trackerFromIdAtom } from '@/store/app-store';
|
||||
|
||||
export function getTrackerName(l10n: ReactLocalization, info: TrackerInfoT | null) {
|
||||
if (info?.customName) return info?.customName;
|
||||
@@ -115,19 +87,9 @@ export function useTrackerFromId(
|
||||
trackerNum: string | number | undefined,
|
||||
deviceId: string | number | undefined
|
||||
) {
|
||||
const { trackers } = useAppContext();
|
||||
|
||||
const tracker = useMemo(
|
||||
() =>
|
||||
trackers.find(
|
||||
({ tracker }) =>
|
||||
trackerNum &&
|
||||
deviceId &&
|
||||
tracker?.trackerId?.trackerNum == trackerNum &&
|
||||
tracker?.trackerId?.deviceId?.id == deviceId
|
||||
),
|
||||
[trackers, trackerNum, deviceId]
|
||||
const trackerAtom = useMemo(
|
||||
() => trackerFromIdAtom({ trackerNum, deviceId }),
|
||||
[trackerNum, deviceId]
|
||||
);
|
||||
|
||||
return tracker;
|
||||
return useAtomValue(trackerAtom);
|
||||
}
|
||||
|
||||
89
gui/src/hooks/vrc-config.ts
Normal file
89
gui/src/hooks/vrc-config.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useWebsocketAPI } from './websocket-api';
|
||||
import {
|
||||
RpcMessage,
|
||||
VRCAvatarMeasurementType,
|
||||
VRCConfigStateChangeResponseT,
|
||||
VRCConfigStateRequestT,
|
||||
VRCSpineMode,
|
||||
VRCTrackerModel,
|
||||
} from 'solarxr-protocol';
|
||||
import { useConfig } from './config';
|
||||
|
||||
type NonNull<T> = {
|
||||
[P in keyof T]: NonNullable<T[P]>;
|
||||
};
|
||||
|
||||
export type VRCConfigStateSupported = { isSupported: true } & NonNull<
|
||||
Pick<VRCConfigStateChangeResponseT, 'recommended' | 'state' | 'validity'>
|
||||
>;
|
||||
export type VRCConfigState = { isSupported: false } | VRCConfigStateSupported;
|
||||
|
||||
export const spineModeTranslationMap: Record<VRCSpineMode, string> = {
|
||||
[VRCSpineMode.UNKNOWN]: 'vrc_config-spine_mode-UNKNOWN',
|
||||
[VRCSpineMode.LOCK_BOTH]: 'vrc_config-spine_mode-LOCK_BOTH',
|
||||
[VRCSpineMode.LOCK_HEAD]: 'vrc_config-spine_mode-LOCK_HEAD',
|
||||
[VRCSpineMode.LOCK_HIP]: 'vrc_config-spine_mode-LOCK_HIP',
|
||||
};
|
||||
|
||||
export const trackerModelTranslationMap: Record<VRCTrackerModel, string> = {
|
||||
[VRCTrackerModel.UNKNOWN]: 'vrc_config-tracker_model-UNKNOWN',
|
||||
[VRCTrackerModel.AXIS]: 'vrc_config-tracker_model-AXIS',
|
||||
[VRCTrackerModel.BOX]: 'vrc_config-tracker_model-BOX',
|
||||
[VRCTrackerModel.SPHERE]: 'vrc_config-tracker_model-SPHERE',
|
||||
[VRCTrackerModel.SYSTEM]: 'vrc_config-tracker_model-SYSTEM',
|
||||
};
|
||||
|
||||
export const avatarMeasurementTypeTranslationMap: Record<
|
||||
VRCAvatarMeasurementType,
|
||||
string
|
||||
> = {
|
||||
[VRCAvatarMeasurementType.UNKNOWN]: 'vrc_config-avatar_measurement_type-UNKNOWN',
|
||||
[VRCAvatarMeasurementType.HEIGHT]: 'vrc_config-avatar_measurement_type-HEIGHT',
|
||||
[VRCAvatarMeasurementType.ARM_SPAN]: 'vrc_config-avatar_measurement_type-ARM_SPAN',
|
||||
};
|
||||
|
||||
export function useVRCConfig() {
|
||||
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
|
||||
const { config, setConfig } = useConfig();
|
||||
const [state, setState] = useState<VRCConfigState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
sendRPCPacket(RpcMessage.VRCConfigStateRequest, new VRCConfigStateRequestT());
|
||||
}, []);
|
||||
|
||||
useRPCPacket(
|
||||
RpcMessage.VRCConfigStateChangeResponse,
|
||||
(data: VRCConfigStateChangeResponseT) => {
|
||||
setState(data as VRCConfigState);
|
||||
}
|
||||
);
|
||||
|
||||
const mutedSettings = useMemo(() => {
|
||||
if (!state?.isSupported) return [];
|
||||
return Object.keys(state.validity).filter((k) =>
|
||||
config?.vrcMutedWarnings.includes(k)
|
||||
);
|
||||
}, [state, config]);
|
||||
|
||||
const invalidConfig = useMemo(() => {
|
||||
if (!state?.isSupported) return false;
|
||||
return Object.entries(state.validity)
|
||||
.filter(([k]) => !config?.vrcMutedWarnings.includes(k))
|
||||
.some(([, v]) => !v);
|
||||
}, [state, config]);
|
||||
|
||||
return {
|
||||
state,
|
||||
invalidConfig,
|
||||
mutedSettings,
|
||||
toggleMutedSettings: async (key: keyof VRCConfigStateSupported['validity']) => {
|
||||
if (!config) return;
|
||||
const index = config.vrcMutedWarnings.findIndex((v) => v === key);
|
||||
if (index === -1) config.vrcMutedWarnings.push(key);
|
||||
else config?.vrcMutedWarnings.splice(index, 1);
|
||||
await setConfig(config);
|
||||
console.log(config.vrcMutedWarnings);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,110 +13,18 @@ import { exists, readTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
|
||||
import { error } from '@/utils/logging';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { isTrayAvailable } from '@/utils/tauri';
|
||||
import { langs } from './names';
|
||||
|
||||
export const defaultNS = 'translation';
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
const OVERRIDE_FILENAME = 'override.ftl';
|
||||
|
||||
export const langs = [
|
||||
{
|
||||
name: '🇦🇪 عربى',
|
||||
key: 'ar',
|
||||
},
|
||||
{
|
||||
name: '🇨🇿 Čeština',
|
||||
key: 'cs',
|
||||
},
|
||||
{
|
||||
name: '🇩🇰 Dansk',
|
||||
key: 'da',
|
||||
},
|
||||
{
|
||||
name: '🇩🇪 Deutsch',
|
||||
key: 'de',
|
||||
},
|
||||
{
|
||||
name: '🇺🇸 English',
|
||||
key: 'en',
|
||||
},
|
||||
{
|
||||
name: '🌎 Español Latinoamericano',
|
||||
key: 'es-419',
|
||||
},
|
||||
{
|
||||
name: '🇪🇸 Español España',
|
||||
key: 'es-ES',
|
||||
},
|
||||
{
|
||||
name: '🇪🇪 Eesti',
|
||||
key: 'et',
|
||||
},
|
||||
{
|
||||
name: '🇫🇮 Suomi',
|
||||
key: 'fi',
|
||||
},
|
||||
{
|
||||
name: '🇫🇷 Français',
|
||||
key: 'fr',
|
||||
},
|
||||
{
|
||||
name: '🇮🇹 Italiano',
|
||||
key: 'it',
|
||||
},
|
||||
{
|
||||
name: '🇯🇵 日本語',
|
||||
key: 'ja',
|
||||
},
|
||||
{
|
||||
name: '🇰🇷 한국어',
|
||||
key: 'ko',
|
||||
},
|
||||
{
|
||||
name: '🇳🇴 Norsk bokmål',
|
||||
key: 'nb-NO',
|
||||
},
|
||||
{
|
||||
name: '🇳🇱 Nederlands',
|
||||
key: 'nl',
|
||||
},
|
||||
{
|
||||
name: '🇵🇱 Polski',
|
||||
key: 'pl',
|
||||
},
|
||||
{
|
||||
name: '🇧🇷 Português Brasileiro',
|
||||
key: 'pt-BR',
|
||||
},
|
||||
{
|
||||
name: '🇷🇺 Русский',
|
||||
key: 'ru',
|
||||
},
|
||||
{
|
||||
name: '🇺🇦 Українська',
|
||||
key: 'uk',
|
||||
},
|
||||
{
|
||||
name: '🇻🇳 Tiếng Việt',
|
||||
key: 'vi',
|
||||
},
|
||||
{
|
||||
name: '🇨🇳 简体中文',
|
||||
key: 'zh-Hans',
|
||||
},
|
||||
{
|
||||
name: '🧋 繁體中文',
|
||||
key: 'zh-Hant',
|
||||
},
|
||||
{
|
||||
name: '🥺 Engwish~ OwO',
|
||||
key: 'en-x-owo',
|
||||
},
|
||||
];
|
||||
|
||||
// AppConfig path: https://docs.rs/tauri/1.2.4/tauri/api/path/fn.config_dir.html
|
||||
// We doing this only once, don't want an override check to be done on runtime,
|
||||
// only on launch :P
|
||||
const overrideLangExists = exists(OVERRIDE_FILENAME).catch(() => false);
|
||||
const overrideLangExists = exists(OVERRIDE_FILENAME, {
|
||||
baseDir: BaseDirectory.AppConfig,
|
||||
}).catch(() => false);
|
||||
|
||||
// Fetch translation file
|
||||
async function fetchMessages(locale: string): Promise<[string, string]> {
|
||||
|
||||
141
gui/src/i18n/names.ts
Normal file
141
gui/src/i18n/names.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import ar from '@twemoji/svg/1f1e6-1f1ea.svg';
|
||||
import cs from '@twemoji/svg/1f1e8-1f1ff.svg';
|
||||
import da from '@twemoji/svg/1f1e9-1f1f0.svg';
|
||||
import de from '@twemoji/svg/1f1e9-1f1ea.svg';
|
||||
import en from '@twemoji/svg/1f1fa-1f1f8.svg';
|
||||
import americas from '@twemoji/svg/1f30e.svg';
|
||||
import es from '@twemoji/svg/1f1ea-1f1f8.svg';
|
||||
import et from '@twemoji/svg/1f1ea-1f1ea.svg';
|
||||
import fi from '@twemoji/svg/1f1eb-1f1ee.svg';
|
||||
import fr from '@twemoji/svg/1f1eb-1f1f7.svg';
|
||||
import it from '@twemoji/svg/1f1ee-1f1f9.svg';
|
||||
import ja from '@twemoji/svg/1f1ef-1f1f5.svg';
|
||||
import ko from '@twemoji/svg/1f1f0-1f1f7.svg';
|
||||
import nb from '@twemoji/svg/1f1f3-1f1f4.svg';
|
||||
import nl from '@twemoji/svg/1f1f3-1f1f1.svg';
|
||||
import pl from '@twemoji/svg/1f1f5-1f1f1.svg';
|
||||
import br from '@twemoji/svg/1f1e7-1f1f7.svg';
|
||||
import ru from '@twemoji/svg/1f1f7-1f1fa.svg';
|
||||
import uk from '@twemoji/svg/1f1fa-1f1e6.svg';
|
||||
import vi from '@twemoji/svg/1f1fb-1f1f3.svg';
|
||||
import cn from '@twemoji/svg/1f1e8-1f1f3.svg';
|
||||
import bubble from '@twemoji/svg/1f9cb.svg';
|
||||
import plead from '@twemoji/svg/1f97a.svg';
|
||||
|
||||
export const langs = [
|
||||
{
|
||||
emoji: ar,
|
||||
name: 'عربى',
|
||||
key: 'ar',
|
||||
},
|
||||
{
|
||||
emoji: cs,
|
||||
name: 'Čeština',
|
||||
key: 'cs',
|
||||
},
|
||||
{
|
||||
emoji: da,
|
||||
name: 'Dansk',
|
||||
key: 'da',
|
||||
},
|
||||
{
|
||||
emoji: de,
|
||||
name: 'Deutsch',
|
||||
key: 'de',
|
||||
},
|
||||
{
|
||||
emoji: en,
|
||||
name: 'English',
|
||||
key: 'en',
|
||||
},
|
||||
{
|
||||
emoji: americas,
|
||||
name: 'Español Latinoamericano',
|
||||
key: 'es-419',
|
||||
},
|
||||
{
|
||||
emoji: es,
|
||||
name: 'Español España',
|
||||
key: 'es-ES',
|
||||
},
|
||||
{
|
||||
emoji: et,
|
||||
name: 'Eesti',
|
||||
key: 'et',
|
||||
},
|
||||
{
|
||||
emoji: fi,
|
||||
name: 'Suomi',
|
||||
key: 'fi',
|
||||
},
|
||||
{
|
||||
emoji: fr,
|
||||
name: 'Français',
|
||||
key: 'fr',
|
||||
},
|
||||
{
|
||||
emoji: it,
|
||||
name: 'Italiano',
|
||||
key: 'it',
|
||||
},
|
||||
{
|
||||
emoji: ja,
|
||||
name: '日本語',
|
||||
key: 'ja',
|
||||
},
|
||||
{
|
||||
emoji: ko,
|
||||
name: '한국어',
|
||||
key: 'ko',
|
||||
},
|
||||
{
|
||||
emoji: nb,
|
||||
name: 'Norsk bokmål',
|
||||
key: 'nb-NO',
|
||||
},
|
||||
{
|
||||
emoji: nl,
|
||||
name: 'Nederlands',
|
||||
key: 'nl',
|
||||
},
|
||||
{
|
||||
emoji: pl,
|
||||
name: 'Polski',
|
||||
key: 'pl',
|
||||
},
|
||||
{
|
||||
emoji: br,
|
||||
name: 'Português Brasileiro',
|
||||
key: 'pt-BR',
|
||||
},
|
||||
{
|
||||
emoji: ru,
|
||||
name: 'Русский',
|
||||
key: 'ru',
|
||||
},
|
||||
{
|
||||
emoji: uk,
|
||||
name: 'Українська',
|
||||
key: 'uk',
|
||||
},
|
||||
{
|
||||
emoji: vi,
|
||||
name: 'Tiếng Việt',
|
||||
key: 'vi',
|
||||
},
|
||||
{
|
||||
emoji: cn,
|
||||
name: '简体中文',
|
||||
key: 'zh-Hans',
|
||||
},
|
||||
{
|
||||
emoji: bubble,
|
||||
name: '繁體中文',
|
||||
key: 'zh-Hant',
|
||||
},
|
||||
{
|
||||
emoji: plead,
|
||||
name: 'Engwish~ OwO',
|
||||
key: 'en-x-owo',
|
||||
},
|
||||
];
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
body {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-name), 'Noto Sans CJK', sans-serif, 'Twemoji Chromium',
|
||||
emoji;
|
||||
font-family: var(--font-name), 'Noto Sans CJK', sans-serif, emoji;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
user-select: none;
|
||||
@@ -16,29 +15,6 @@ body {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
@media (-webkit-animation) {
|
||||
body {
|
||||
font-family: var(--font-name), 'Noto Sans CJK', sans-serif, 'Twemoji Webkit',
|
||||
emoji;
|
||||
}
|
||||
|
||||
body.linux {
|
||||
font-family: var(--font-name), 'Noto Sans CJK', sans-serif, emoji;
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Twemoji Webkit';
|
||||
src: url('/fonts/twemoji-picosvg.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Twemoji Chromium';
|
||||
src: url('/fonts/twemoji-glyf_colr_1.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'OpenDyslexic';
|
||||
src: url('/fonts/OpenDyslexic-Regular.woff') format('woff');
|
||||
@@ -411,3 +387,12 @@ input::-ms-clear {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
a,
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
button,
|
||||
div {
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function playSoundOnResetEnded(resetType: ResetType, volume = 1) {
|
||||
xylophone.play({
|
||||
notes: ['C4'],
|
||||
offset: 0.15,
|
||||
type: 'square',
|
||||
type: 'custom',
|
||||
volume,
|
||||
});
|
||||
break;
|
||||
@@ -26,7 +26,7 @@ export async function playSoundOnResetEnded(resetType: ResetType, volume = 1) {
|
||||
xylophone.play({
|
||||
notes: ['E3', 'G3'],
|
||||
offset: 0.15,
|
||||
type: 'square',
|
||||
type: 'custom',
|
||||
volume,
|
||||
});
|
||||
break;
|
||||
@@ -35,7 +35,7 @@ export async function playSoundOnResetEnded(resetType: ResetType, volume = 1) {
|
||||
xylophone.play({
|
||||
notes: ['G3', 'B3', 'D4'],
|
||||
offset: 0.15,
|
||||
type: 'square',
|
||||
type: 'custom',
|
||||
volume,
|
||||
});
|
||||
break;
|
||||
@@ -47,7 +47,7 @@ export async function playSoundOnResetStarted(volume = 1) {
|
||||
await xylophone.play({
|
||||
notes: ['A4'],
|
||||
offset: 0.4,
|
||||
type: 'square',
|
||||
type: 'custom',
|
||||
volume,
|
||||
});
|
||||
}
|
||||
@@ -58,17 +58,26 @@ export async function playTapSetupSound(volume = 1) {
|
||||
xylophone.play({
|
||||
notes: tones[lastTap],
|
||||
offset: 0.15,
|
||||
type: 'square',
|
||||
type: 'custom',
|
||||
volume,
|
||||
});
|
||||
} else {
|
||||
xylophone.play({
|
||||
notes: ['D4', 'E4', 'G4', 'E4', 'B4', 'B4', 'A4'],
|
||||
offset: 0.15,
|
||||
length: 1,
|
||||
type: 'sawtooth',
|
||||
volume,
|
||||
});
|
||||
xylophone.play([
|
||||
{
|
||||
notes: ['G#3', 'A#3', 'C#4', 'A#3'],
|
||||
offset: 0.15,
|
||||
length: 1,
|
||||
type: 'custom',
|
||||
volume,
|
||||
},
|
||||
{
|
||||
notes: ['F4', 'F4', 'D#4'],
|
||||
offset: 0.45,
|
||||
length: 2,
|
||||
type: 'custom',
|
||||
volume,
|
||||
},
|
||||
]);
|
||||
}
|
||||
lastTap++;
|
||||
if (lastTap >= tones.length) {
|
||||
|
||||
@@ -208,19 +208,22 @@ export default class Xylophone {
|
||||
* each `IMeasure` will play when the previous one completes. Resolves when all sound has stopped.
|
||||
* @param measure The `IMeasure` or `IMeasure[]` to play
|
||||
*/
|
||||
public async play(measure: IMeasure | IMeasure[]): Promise<void> {
|
||||
public async play(measure: IMeasure | IMeasure[], delay = 0): Promise<void> {
|
||||
if (measure instanceof Array) {
|
||||
const arr = [];
|
||||
|
||||
for (const m of measure) arr.push(await this.play(m));
|
||||
for (const m of measure) {
|
||||
arr.push(this.play(m, delay));
|
||||
if (m.offset) delay += m.notes.length * m.offset;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
let i = 0;
|
||||
await Promise.all(
|
||||
measure.notes.map((note) => {
|
||||
let offset;
|
||||
if (measure.offset) offset = measure.offset * i++;
|
||||
let offset = delay;
|
||||
if (measure.offset) offset += measure.offset * i++;
|
||||
|
||||
return this.playTone({
|
||||
length: measure.length,
|
||||
@@ -275,15 +278,33 @@ export default class Xylophone {
|
||||
this.oscillator = this.context.createOscillator();
|
||||
this.gainNode = this.context.createGain();
|
||||
|
||||
const real = new Float32Array([0, 0, 0, 0]); // cos
|
||||
const imag = new Float32Array([0, 1, 0.333, 0.2]); // sine
|
||||
const wave = this.context.createPeriodicWave(real, imag); // cos, sine
|
||||
|
||||
this.oscillator.connect(this.gainNode);
|
||||
this.gainNode.connect(this.context.destination);
|
||||
this.oscillator.type = type;
|
||||
if (type === 'custom') {
|
||||
this.oscillator.setPeriodicWave(wave);
|
||||
} else {
|
||||
this.oscillator.type = type;
|
||||
}
|
||||
|
||||
const gain = Math.min(1, Math.pow((volume ?? 1) * 0.5, Math.E) + 0.05);
|
||||
/**
|
||||
* freqGain: 1/f amplitude for equal energy per octave
|
||||
* and clamp to 1 amplitude at 200Hz
|
||||
* gain: logarithmic volume
|
||||
* and final gain of 0.5
|
||||
*/
|
||||
const freqGain = Math.min(
|
||||
1,
|
||||
Math.sqrt(200) * (1 / Math.sqrt(Xylophone.toHertz(note)))
|
||||
);
|
||||
const gain = Math.min(1, Math.pow(volume ?? 1, Math.E) * freqGain) * 0.5;
|
||||
|
||||
this.oscillator.frequency.value = Xylophone.toHertz(note);
|
||||
this.gainNode.gain.setValueAtTime(0, offset);
|
||||
this.gainNode.gain.linearRampToValueAtTime(1 * gain, offset + 0.01);
|
||||
this.gainNode.gain.linearRampToValueAtTime(1 * gain, offset + 0.02);
|
||||
|
||||
this.oscillator.start(offset);
|
||||
this.gainNode.gain.exponentialRampToValueAtTime(0.001 * gain, offset + length);
|
||||
|
||||
94
gui/src/store/app-store.ts
Normal file
94
gui/src/store/app-store.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { atom } from 'jotai';
|
||||
import {
|
||||
BodyPart,
|
||||
DataFeedUpdateT,
|
||||
DeviceDataT,
|
||||
TrackerDataT,
|
||||
TrackerStatus,
|
||||
} from 'solarxr-protocol';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { isEqual } from '@react-hookz/deep-equal';
|
||||
|
||||
export interface FlatDeviceTracker {
|
||||
device?: DeviceDataT;
|
||||
tracker: TrackerDataT;
|
||||
}
|
||||
|
||||
export const ignoredTrackersAtom = atom(new Set<string>());
|
||||
|
||||
export const datafeedAtom = atom(new DataFeedUpdateT());
|
||||
|
||||
export const devicesAtom = selectAtom(
|
||||
datafeedAtom,
|
||||
(datafeed) => datafeed.devices,
|
||||
isEqual
|
||||
);
|
||||
|
||||
export const flatTrackersAtom = atom((get) => {
|
||||
const devices = get(devicesAtom);
|
||||
|
||||
return devices.flatMap<FlatDeviceTracker>((device) =>
|
||||
device.trackers.map((tracker) => ({ tracker, device }))
|
||||
);
|
||||
});
|
||||
|
||||
export const assignedTrackersAtom = atom((get) => {
|
||||
const trackers = get(flatTrackersAtom);
|
||||
return trackers.filter(({ tracker }) => tracker.info?.bodyPart !== BodyPart.NONE);
|
||||
});
|
||||
|
||||
export const unassignedTrackersAtom = atom((get) => {
|
||||
const trackers = get(flatTrackersAtom);
|
||||
return trackers.filter(({ tracker }) => tracker.info?.bodyPart === BodyPart.NONE);
|
||||
});
|
||||
|
||||
export const connectedIMUTrackersAtom = atom((get) => {
|
||||
const trackers = get(flatTrackersAtom);
|
||||
return trackers.filter(
|
||||
({ tracker }) =>
|
||||
tracker.status !== TrackerStatus.DISCONNECTED && tracker.info?.isImu
|
||||
);
|
||||
});
|
||||
|
||||
export const computedTrackersAtom = selectAtom(
|
||||
datafeedAtom,
|
||||
(datafeed) => datafeed.syntheticTrackers.map((tracker) => ({ tracker })),
|
||||
isEqual
|
||||
);
|
||||
|
||||
export const bonesAtom = selectAtom(
|
||||
datafeedAtom,
|
||||
(datafeed) => datafeed.bones,
|
||||
isEqual
|
||||
);
|
||||
|
||||
export const hasHMDTrackerAtom = atom((get) => {
|
||||
const trackers = get(flatTrackersAtom);
|
||||
|
||||
return trackers.some(
|
||||
(tracker) =>
|
||||
tracker.tracker.info?.bodyPart === BodyPart.HEAD &&
|
||||
(tracker.tracker.info.isHmd || tracker.tracker.position?.y !== undefined)
|
||||
);
|
||||
});
|
||||
|
||||
export const trackerFromIdAtom = ({
|
||||
trackerNum,
|
||||
deviceId,
|
||||
}: {
|
||||
trackerNum: string | number | undefined;
|
||||
deviceId: string | number | undefined;
|
||||
}) =>
|
||||
selectAtom(
|
||||
atom((get) =>
|
||||
get(flatTrackersAtom).find(
|
||||
({ tracker }) =>
|
||||
trackerNum &&
|
||||
deviceId &&
|
||||
tracker?.trackerId?.trackerNum == trackerNum &&
|
||||
tracker?.trackerId?.deviceId?.id == deviceId
|
||||
)
|
||||
),
|
||||
(a) => a,
|
||||
isEqual
|
||||
);
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
} from 'react-router-dom';
|
||||
import { AppState } from '@/hooks/app';
|
||||
import { DeviceDataT } from 'solarxr-protocol';
|
||||
|
||||
export function getSentryOrCompute(enabled = false) {
|
||||
// if sentry is already initialized - SKIP
|
||||
@@ -65,21 +65,19 @@ export function getSentryOrCompute(enabled = false) {
|
||||
return newClient;
|
||||
}
|
||||
|
||||
export function updateSentryContext(state: AppState) {
|
||||
export function updateSentryContext(devices: DeviceDataT[]) {
|
||||
// We filter out the shit we dont want. We dont need rotation data or ip addresses
|
||||
const trackers = (state.datafeed?.devices || []).map(
|
||||
({ hardwareInfo, trackers, id }) => ({
|
||||
id: id?.id,
|
||||
hardwareInfo: { ...hardwareInfo, ipAddress: undefined },
|
||||
trackers: trackers.map(({ info, trackerId }) => ({
|
||||
info,
|
||||
trackerId: {
|
||||
trackerNum: trackerId?.trackerNum,
|
||||
deviceId: trackerId?.deviceId?.id,
|
||||
},
|
||||
})),
|
||||
})
|
||||
);
|
||||
const trackers = (devices || []).map(({ hardwareInfo, trackers, id }) => ({
|
||||
id: id?.id,
|
||||
hardwareInfo: { ...hardwareInfo, ipAddress: undefined },
|
||||
trackers: trackers.map(({ info, trackerId }) => ({
|
||||
info,
|
||||
trackerId: {
|
||||
trackerNum: trackerId?.trackerNum,
|
||||
deviceId: trackerId?.deviceId?.id,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Will send the latest context to sentry when an error happens
|
||||
Sentry.setContext('trackers', {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { defineConfig, PluginOption } from 'vite';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import jotaiReactRefresh from 'jotai/babel/plugin-react-refresh';
|
||||
|
||||
const commitHash = execSync('git rev-parse --verify --short HEAD').toString().trim();
|
||||
const versionTag = execSync('git --no-pager tag --sort -taggerdate --points-at HEAD')
|
||||
@@ -41,7 +42,7 @@ export default defineConfig({
|
||||
__GIT_CLEAN__: gitClean,
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
react({ babel: { plugins: [jotaiReactRefresh] } }),
|
||||
i18nHotReload(),
|
||||
visualizer() as PluginOption,
|
||||
sentryVitePlugin({
|
||||
|
||||
57
pnpm-lock.yaml
generated
57
pnpm-lock.yaml
generated
@@ -32,9 +32,12 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0(react-hook-form@7.53.0(react@18.3.1))
|
||||
'@react-hookz/deep-equal':
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
'@react-three/drei':
|
||||
specifier: ^9.114.3
|
||||
version: 9.114.5(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0))(@types/react@18.3.11)(@types/three@0.163.0)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0)
|
||||
version: 9.114.5(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0))(@types/react@18.3.11)(@types/three@0.163.0)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0)
|
||||
'@react-three/fiber':
|
||||
specifier: ^8.17.10
|
||||
version: 8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0)
|
||||
@@ -68,6 +71,9 @@ importers:
|
||||
'@tauri-apps/plugin-store':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@twemoji/svg':
|
||||
specifier: ^15.0.0
|
||||
version: 15.0.0
|
||||
browser-fs-access:
|
||||
specifier: ^0.35.0
|
||||
version: 0.35.0
|
||||
@@ -83,6 +89,9 @@ importers:
|
||||
ip-num:
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
jotai:
|
||||
specifier: ^2.12.2
|
||||
version: 2.12.2(@types/react@18.3.11)(react@18.3.1)
|
||||
prompts:
|
||||
specifier: ^2.4.2
|
||||
version: 2.4.2
|
||||
@@ -819,6 +828,10 @@ packages:
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@react-hookz/deep-equal@3.0.3':
|
||||
resolution: {integrity: sha512-SLy+NmiDpncqc2d9TR4Y4R7f8lUFOQK9WbnIq02A6wDxy+dTHfA2Np0dPvj0SFp6i1nqERLmEUe9MxPLuO/IqA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@react-spring/animated@9.6.1':
|
||||
resolution: {integrity: sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==}
|
||||
peerDependencies:
|
||||
@@ -1245,6 +1258,9 @@ packages:
|
||||
'@tweenjs/tween.js@23.1.2':
|
||||
resolution: {integrity: sha512-kMCNaZCJugWI86xiEHaY338CU5JpD0B97p1j1IKNn/Zto8PgACjQx0UxbHjmOcLl/dDOBnItwD07KmCs75pxtQ==}
|
||||
|
||||
'@twemoji/svg@15.0.0':
|
||||
resolution: {integrity: sha512-ZSPef2B6nBaYnfgdTbAy4jgW95o7pi2xPGwGCU+WMTxo7J6B1lMPTWwSq/wTuiMq+N0khQ90CcvYp1wFoQpo/w==}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
|
||||
@@ -2556,8 +2572,8 @@ packages:
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
immer@9.0.21:
|
||||
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
|
||||
immer@10.1.1:
|
||||
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
|
||||
|
||||
immutable@4.3.6:
|
||||
resolution: {integrity: sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==}
|
||||
@@ -2793,6 +2809,18 @@ packages:
|
||||
resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==}
|
||||
hasBin: true
|
||||
|
||||
jotai@2.12.2:
|
||||
resolution: {integrity: sha512-oN8715y7MkjXlSrpyjlR887TOuc/NLZMs9gvgtfWH/JP47ChwO0lR2ijSwBvPMYyXRAPT+liIAhuBavluKGgtA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=17.0.0'
|
||||
react: '>=17.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -5049,6 +5077,8 @@ snapshots:
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@react-hookz/deep-equal@3.0.3': {}
|
||||
|
||||
'@react-spring/animated@9.6.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@react-spring/shared': 9.6.1(react@18.3.1)
|
||||
@@ -5083,7 +5113,7 @@ snapshots:
|
||||
|
||||
'@react-spring/types@9.6.1': {}
|
||||
|
||||
'@react-three/drei@9.114.5(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0))(@types/react@18.3.11)(@types/three@0.163.0)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0)':
|
||||
'@react-three/drei@9.114.5(@react-three/fiber@8.17.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0))(@types/react@18.3.11)(@types/three@0.163.0)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.163.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.7
|
||||
'@mediapipe/tasks-vision': 0.10.8
|
||||
@@ -5107,7 +5137,7 @@ snapshots:
|
||||
three-mesh-bvh: 0.7.8(three@0.163.0)
|
||||
three-stdlib: 2.30.3(three@0.163.0)
|
||||
troika-three-text: 0.49.1(three@0.163.0)
|
||||
tunnel-rat: 0.1.2(@types/react@18.3.11)(immer@9.0.21)(react@18.3.1)
|
||||
tunnel-rat: 0.1.2(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1)
|
||||
utility-types: 3.11.0
|
||||
uuid: 9.0.1
|
||||
zustand: 3.7.2(react@18.3.1)
|
||||
@@ -5433,6 +5463,8 @@ snapshots:
|
||||
|
||||
'@tweenjs/tween.js@23.1.2': {}
|
||||
|
||||
'@twemoji/svg@15.0.0': {}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.24.7
|
||||
@@ -6983,7 +7015,7 @@ snapshots:
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@9.0.21:
|
||||
immer@10.1.1:
|
||||
optional: true
|
||||
|
||||
immutable@4.3.6: {}
|
||||
@@ -7219,6 +7251,11 @@ snapshots:
|
||||
|
||||
jiti@1.21.6: {}
|
||||
|
||||
jotai@2.12.2(@types/react@18.3.11)(react@18.3.1):
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.11
|
||||
react: 18.3.1
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.0:
|
||||
@@ -8817,9 +8854,9 @@ snapshots:
|
||||
tslib: 1.14.1
|
||||
typescript: 4.8.2
|
||||
|
||||
tunnel-rat@0.1.2(@types/react@18.3.11)(immer@9.0.21)(react@18.3.1):
|
||||
tunnel-rat@0.1.2(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1):
|
||||
dependencies:
|
||||
zustand: 4.5.2(@types/react@18.3.11)(immer@9.0.21)(react@18.3.1)
|
||||
zustand: 4.5.2(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
@@ -9145,12 +9182,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
|
||||
zustand@4.5.2(@types/react@18.3.11)(immer@9.0.21)(react@18.3.1):
|
||||
zustand@4.5.2(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.2.0(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.11
|
||||
immer: 9.0.21
|
||||
immer: 10.1.1
|
||||
react: 18.3.1
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
@@ -244,15 +244,15 @@ class AndroidSerialHandler(val activity: AppCompatActivity) :
|
||||
|
||||
override fun getCurrentPort(): SlimeSerialPort? = this.currentPort
|
||||
|
||||
private fun addLog(str: String) {
|
||||
private fun addLog(str: String, server: Boolean = true) {
|
||||
LogManager.info("[Serial] $str")
|
||||
listeners.forEach { it.onSerialLog(str) }
|
||||
listeners.forEach { it.onSerialLog(str, server) }
|
||||
}
|
||||
|
||||
override fun onNewData(data: ByteArray?) {
|
||||
if (data != null) {
|
||||
val s = StandardCharsets.UTF_8.decode(ByteBuffer.wrap(data)).toString()
|
||||
addLog(s)
|
||||
addLog(s, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ net session >nul 2>&1
|
||||
if %errorlevel% == 0 (
|
||||
echo Running with administrative privileges! - Needed for firewall modification!
|
||||
) else (
|
||||
echo Requesting administrative privileges - Needed for firewall modification!
|
||||
echo Requesting administrative privileges - Needed for firewall modification!
|
||||
:: Temp script to request admin... Works and doesn't leave a mess.
|
||||
echo Set UAC = CreateObject^("Shell.Application"^) > "%temp%\getadmin.vbs"
|
||||
echo UAC.ShellExecute "%~s0", "", "%~dp0", "runas", 1 >> "%temp%\getadmin.vbs"
|
||||
@@ -21,8 +21,11 @@ rem WebSocket server default port
|
||||
call :AddRule "SlimeVR TCP 21110 incoming" "dir=in action=allow protocol=TCP localport=21110 enable=yes"
|
||||
call :AddRule "SlimeVR TCP 21110 outgoing" "dir=out action=allow protocol=TCP localport=21110 enable=yes"
|
||||
rem OpenJDK Platform Binary access
|
||||
call :AddRule "SlimeVR OpenJDK Platform incoming" "dir=in action=allow program=%~dp0jre\bin\java.exe enable=yes"
|
||||
call :AddRule "SlimeVR OpenJDK Platform outgoing" "dir=out action=allow program=%~dp0jre\bin\java.exe enable=yes"
|
||||
call :AddRule "SlimeVR OpenJDK Platform incoming" "dir=in action=allow program=""%~dp0jre\bin\java.exe"" enable=yes"
|
||||
call :AddRule "SlimeVR OpenJDK Platform outgoing" "dir=out action=allow program=""%~dp0jre\bin\java.exe"" enable=yes"
|
||||
rem ESP8266 OTA default port
|
||||
call :AddRule "SlimeVR UDP 8266 incoming" "dir=in action=allow protocol=UDP localport=8266 enable=yes"
|
||||
call :AddRule "SlimeVR UDP 8266 outgoing" "dir=out action=allow protocol=UDP localport=8266 enable=yes"
|
||||
|
||||
echo Done!
|
||||
pause
|
||||
@@ -32,7 +35,9 @@ exit /B
|
||||
:AddRule
|
||||
setlocal
|
||||
set "ruleName=%~1"
|
||||
set "ruleParams=%~2"
|
||||
set "rulePara=%~2"
|
||||
:: Change from "" to "
|
||||
set "ruleParams=%rulePara:""="%"
|
||||
|
||||
netsh advfirewall firewall show rule name="%ruleName%" >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
|
||||
@@ -26,6 +26,9 @@ call :DeleteRule "SlimeVR TCP 21110 outgoing"
|
||||
rem OpenJDK Platform Binary access
|
||||
call :DeleteRule "SlimeVR OpenJDK Platform incoming"
|
||||
call :DeleteRule "SlimeVR OpenJDK Platform outgoing"
|
||||
rem ESP8266 OTA default port
|
||||
call :DeleteRule "SlimeVR UDP 8266 incoming"
|
||||
call :DeleteRule "SlimeVR UDP 8266 outgoing"
|
||||
|
||||
echo Done!
|
||||
pause
|
||||
|
||||
@@ -7,6 +7,9 @@ import dev.slimevr.bridge.ISteamVRBridge
|
||||
import dev.slimevr.config.ConfigManager
|
||||
import dev.slimevr.firmware.FirmwareUpdateHandler
|
||||
import dev.slimevr.firmware.SerialFlashingHandler
|
||||
import dev.slimevr.games.vrchat.VRCConfigHandler
|
||||
import dev.slimevr.games.vrchat.VRCConfigHandlerStub
|
||||
import dev.slimevr.games.vrchat.VRChatConfigManager
|
||||
import dev.slimevr.osc.OSCHandler
|
||||
import dev.slimevr.osc.OSCRouter
|
||||
import dev.slimevr.osc.VMCHandler
|
||||
@@ -50,6 +53,7 @@ class VRServer @JvmOverloads constructor(
|
||||
bridgeProvider: BridgeProvider = { _, _ -> sequence {} },
|
||||
serialHandlerProvider: (VRServer) -> SerialHandler = { _ -> SerialHandlerStub() },
|
||||
flashingHandlerProvider: (VRServer) -> SerialFlashingHandler? = { _ -> null },
|
||||
vrcConfigHandlerProvider: (VRServer) -> VRCConfigHandler = { _ -> VRCConfigHandlerStub() },
|
||||
acquireMulticastLock: () -> Any? = { null },
|
||||
// configPath is used by VRWorkout, do not remove!
|
||||
configPath: String,
|
||||
@@ -87,6 +91,8 @@ class VRServer @JvmOverloads constructor(
|
||||
|
||||
val firmwareUpdateHandler: FirmwareUpdateHandler
|
||||
|
||||
val vrcConfigManager: VRChatConfigManager
|
||||
|
||||
@JvmField
|
||||
val autoBoneHandler: AutoBoneHandler
|
||||
|
||||
@@ -124,6 +130,7 @@ class VRServer @JvmOverloads constructor(
|
||||
// AutoBone requires HumanPoseManager first
|
||||
autoBoneHandler = AutoBoneHandler(this)
|
||||
firmwareUpdateHandler = FirmwareUpdateHandler(this)
|
||||
vrcConfigManager = VRChatConfigManager(this, vrcConfigHandlerProvider(this))
|
||||
protocolAPI = ProtocolAPI(this)
|
||||
val computedTrackers = humanPoseManager.computedTrackers
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class FiltersConfig {
|
||||
fun updateTrackersFilters() {
|
||||
for (tracker in VRServer.instance.allTrackers) {
|
||||
if (tracker.allowFiltering) {
|
||||
tracker.filteringHandler.readFilteringConfig(this, tracker.getRawRotation())
|
||||
tracker.filteringHandler.readFilteringConfig(this, tracker.getRotation())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ class ServerConfig {
|
||||
}.awaitAll()
|
||||
|
||||
useMagnetometerOnAllTrackers = state
|
||||
VRServer.instance.configManager.saveConfig()
|
||||
} finally {
|
||||
magMutex.unlock()
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ class VRConfig {
|
||||
if (tracker.allowFiltering) {
|
||||
tracker
|
||||
.filteringHandler
|
||||
.readFilteringConfig(filters, tracker.getRawRotation())
|
||||
.readFilteringConfig(filters, tracker.getRotation())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ class QuaternionMovingAverage(
|
||||
predictFactor = PREDICT_MULTIPLIER * amount + PREDICT_MIN
|
||||
rotBuffer = CircularArrayList(PREDICT_BUFFER)
|
||||
}
|
||||
resetQuats(initialRotation)
|
||||
|
||||
// We have no reference at the start, so just use the initial rotation
|
||||
resetQuats(initialRotation, initialRotation)
|
||||
}
|
||||
|
||||
// Runs at up to 1000hz. We use a timer to make it framerate-independent
|
||||
@@ -57,17 +59,17 @@ class QuaternionMovingAverage(
|
||||
@Synchronized
|
||||
fun update() {
|
||||
if (type == TrackerFilters.PREDICTION) {
|
||||
if (rotBuffer!!.size > 0) {
|
||||
var quatBuf = latestQuaternion
|
||||
|
||||
val rotBuf = rotBuffer
|
||||
if (rotBuf != null && rotBuf.isNotEmpty()) {
|
||||
// Applies the past rotations to the current rotation
|
||||
rotBuffer?.forEach { quatBuf *= it }
|
||||
val predictRot = rotBuf.fold(latestQuaternion) { buf, rot -> buf * rot }
|
||||
|
||||
// Calculate how much to slerp
|
||||
// Limit slerp by a reasonable amount so low TPS doesn't break tracking
|
||||
val amt = (predictFactor * fpsTimer.timePerFrame).coerceAtMost(1f)
|
||||
|
||||
// Slerps the target rotation to that predicted rotation by amt
|
||||
filteredQuaternion = filteredQuaternion.interpR(quatBuf, amt)
|
||||
filteredQuaternion = filteredQuaternion.interpQ(predictRot, amt)
|
||||
}
|
||||
} else if (type == TrackerFilters.SMOOTHING) {
|
||||
// Make it framerate-independent
|
||||
@@ -78,7 +80,7 @@ class QuaternionMovingAverage(
|
||||
val amt = (smoothFactor * timeSinceUpdate).coerceAtMost(1f)
|
||||
|
||||
// Smooth towards the target rotation by the slerp factor
|
||||
filteredQuaternion = smoothingQuaternion.interpR(latestQuaternion, amt)
|
||||
filteredQuaternion = smoothingQuaternion.interpQ(latestQuaternion, amt)
|
||||
}
|
||||
|
||||
filteringImpact = latestQuaternion.angleToR(filteredQuaternion)
|
||||
@@ -86,29 +88,40 @@ class QuaternionMovingAverage(
|
||||
|
||||
@Synchronized
|
||||
fun addQuaternion(q: Quaternion) {
|
||||
val oldQ = latestQuaternion
|
||||
val newQ = q.twinNearest(oldQ)
|
||||
latestQuaternion = newQ
|
||||
|
||||
if (type == TrackerFilters.PREDICTION) {
|
||||
if (rotBuffer!!.size == rotBuffer!!.capacity()) {
|
||||
rotBuffer?.removeLast()
|
||||
}
|
||||
|
||||
// Gets and stores the rotation between the last 2 quaternions
|
||||
rotBuffer?.add(latestQuaternion.inv().times(q))
|
||||
rotBuffer?.add(oldQ.inv().times(newQ))
|
||||
} else if (type == TrackerFilters.SMOOTHING) {
|
||||
timeSinceUpdate = 0f
|
||||
smoothingQuaternion = filteredQuaternion
|
||||
} else {
|
||||
// No filtering; just keep track of rotations (for going over 180 degrees)
|
||||
filteredQuaternion = q.twinNearest(filteredQuaternion)
|
||||
filteredQuaternion = newQ
|
||||
}
|
||||
|
||||
latestQuaternion = q
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligns the quaternion space of [q] to the [reference] and sets the latest
|
||||
* [filteredQuaternion] immediately
|
||||
*/
|
||||
@Synchronized
|
||||
fun resetQuats(q: Quaternion) {
|
||||
fun resetQuats(q: Quaternion, reference: Quaternion) {
|
||||
// Assume a rotation within 180 degrees of the reference
|
||||
// TODO: Currently the reference is the headset, this restricts all trackers to
|
||||
// have at most a 180 degree rotation from the HMD during a reset, we can
|
||||
// probably do better using a hierarchy
|
||||
val rot = q.twinNearest(reference)
|
||||
rotBuffer?.clear()
|
||||
latestQuaternion = q
|
||||
filteredQuaternion = q
|
||||
addQuaternion(q)
|
||||
latestQuaternion = rot
|
||||
filteredQuaternion = rot
|
||||
addQuaternion(rot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,12 +88,12 @@ class FirmwareUpdateHandler(private val server: VRServer) :
|
||||
}
|
||||
}
|
||||
|
||||
private fun startOtaUpdate(
|
||||
private suspend fun startOtaUpdate(
|
||||
part: DownloadedFirmwarePart,
|
||||
deviceId: UpdateDeviceId<Int>,
|
||||
) {
|
||||
): Unit = suspendCancellableCoroutine { c ->
|
||||
val udpDevice: UDPDevice? =
|
||||
(this.server.deviceManager.devices.find { device -> device is UDPDevice && device.id == deviceId.id }) as UDPDevice?
|
||||
(server.deviceManager.devices.find { device -> device is UDPDevice && device.id == deviceId.id }) as UDPDevice?
|
||||
|
||||
if (udpDevice == null) {
|
||||
onStatusChange(
|
||||
@@ -102,14 +102,18 @@ class FirmwareUpdateHandler(private val server: VRServer) :
|
||||
FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND,
|
||||
),
|
||||
)
|
||||
return
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
OTAUpdateTask(
|
||||
val task = OTAUpdateTask(
|
||||
part.firmware,
|
||||
deviceId,
|
||||
udpDevice.ipAddress,
|
||||
this::onStatusChange,
|
||||
).run()
|
||||
::onStatusChange,
|
||||
)
|
||||
c.invokeOnCancellation {
|
||||
task.cancel()
|
||||
}
|
||||
task.run()
|
||||
}
|
||||
|
||||
private fun startSerialUpdate(
|
||||
@@ -258,6 +262,7 @@ class FirmwareUpdateHandler(private val server: VRServer) :
|
||||
watchRestartQueue.clear()
|
||||
runningJobs.forEach { it.cancelAndJoin() }
|
||||
runningJobs.clear()
|
||||
LogManager.info("[FirmwareUpdateHandler] Update jobs canceled")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.util.*
|
||||
@@ -20,6 +21,10 @@ class OTAUpdateTask(
|
||||
private val statusCallback: Consumer<UpdateStatusEvent<Int>>,
|
||||
) {
|
||||
private val receiveBuffer: ByteArray = ByteArray(38)
|
||||
var socketServer: ServerSocket? = null
|
||||
var uploadSocket: Socket? = null
|
||||
var authSocket: DatagramSocket? = null
|
||||
var canceled: Boolean = false
|
||||
|
||||
@Throws(NoSuchAlgorithmException::class)
|
||||
private fun bytesToMd5(bytes: ByteArray): String {
|
||||
@@ -36,6 +41,7 @@ class OTAUpdateTask(
|
||||
private fun authenticate(localPort: Int): Boolean {
|
||||
try {
|
||||
DatagramSocket().use { socket ->
|
||||
authSocket = socket
|
||||
statusCallback.accept(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.AUTHENTICATING))
|
||||
LogManager.info("[OTAUpdate] Sending OTA invitation to: $deviceIp")
|
||||
|
||||
@@ -98,15 +104,15 @@ class OTAUpdateTask(
|
||||
LogManager.info("[OTAUpdate] Waiting for device...")
|
||||
|
||||
val connection = serverSocket.accept()
|
||||
this.uploadSocket = connection
|
||||
connection.setSoTimeout(1000)
|
||||
|
||||
val dos = DataOutputStream(connection.getOutputStream())
|
||||
val dis = DataInputStream(connection.getInputStream())
|
||||
|
||||
LogManager.info("[OTAUpdate] Upload size: ${firmware.size} bytes")
|
||||
var offset = 0
|
||||
val chunkSize = 2048
|
||||
while (offset != firmware.size) {
|
||||
while (offset != firmware.size && !canceled) {
|
||||
statusCallback.accept(
|
||||
UpdateStatusEvent(
|
||||
deviceId,
|
||||
@@ -126,6 +132,7 @@ class OTAUpdateTask(
|
||||
// for the OK response. Saving time
|
||||
dis.skipNBytes(4)
|
||||
}
|
||||
if (canceled) return false
|
||||
|
||||
LogManager.info("[OTAUpdate] Waiting for result...")
|
||||
// We set the timeout of the connection bigger as it can take some time for the MCU
|
||||
@@ -143,6 +150,7 @@ class OTAUpdateTask(
|
||||
|
||||
fun run() {
|
||||
ServerSocket(0).use { serverSocket ->
|
||||
socketServer = serverSocket
|
||||
if (!authenticate(serverSocket.localPort)) {
|
||||
statusCallback.accept(
|
||||
UpdateStatusEvent(
|
||||
@@ -172,6 +180,13 @@ class OTAUpdateTask(
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
canceled = true
|
||||
socketServer?.close()
|
||||
authSocket?.close()
|
||||
uploadSocket?.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FLASH = 0
|
||||
private const val PORT = 8266
|
||||
|
||||
@@ -31,7 +31,7 @@ class SerialRebootHandler(
|
||||
currentPort = null
|
||||
}
|
||||
|
||||
override fun onSerialLog(str: String) {
|
||||
override fun onSerialLog(str: String, ignored: Boolean) {
|
||||
if (str.contains("starting up...")) {
|
||||
val foundPort = watchRestartQueue.find { it.first.id == currentPort?.portLocation }
|
||||
if (foundPort != null) {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package dev.slimevr.games.vrchat
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.tracking.processor.config.SkeletonConfigToggles
|
||||
import dev.slimevr.tracking.trackers.TrackerPosition
|
||||
import dev.slimevr.tracking.trackers.TrackerUtils
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.math.*
|
||||
|
||||
enum class VRCTrackerModel(val value: Int, val id: Int) {
|
||||
UNKNOWN(-1, solarxr_protocol.rpc.VRCTrackerModel.UNKNOWN),
|
||||
SPHERE(0, solarxr_protocol.rpc.VRCTrackerModel.SPHERE),
|
||||
SYSTEM(1, solarxr_protocol.rpc.VRCTrackerModel.SYSTEM),
|
||||
BOX(2, solarxr_protocol.rpc.VRCTrackerModel.BOX),
|
||||
AXIS(3, solarxr_protocol.rpc.VRCTrackerModel.AXIS),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val byValue = VRCTrackerModel.entries.associateBy { it.value }
|
||||
|
||||
fun getByValue(value: Int): VRCTrackerModel? = byValue[value]
|
||||
}
|
||||
}
|
||||
|
||||
enum class VRCSpineMode(val value: Int, val id: Int) {
|
||||
UNKNOWN(-1, solarxr_protocol.rpc.VRCSpineMode.UNKNOWN),
|
||||
LOCK_HIP(0, solarxr_protocol.rpc.VRCSpineMode.LOCK_HIP),
|
||||
LOCK_HEAD(1, solarxr_protocol.rpc.VRCSpineMode.LOCK_HEAD),
|
||||
LOCK_BOTH(2, solarxr_protocol.rpc.VRCSpineMode.LOCK_BOTH),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val byValue = VRCSpineMode.entries.associateBy { it.value }
|
||||
|
||||
fun getByValue(value: Int): VRCSpineMode? = byValue[value]
|
||||
}
|
||||
}
|
||||
|
||||
enum class VRCAvatarMeasurementType(val value: Int, val id: Int) {
|
||||
UNKNOWN(-1, solarxr_protocol.rpc.VRCAvatarMeasurementType.UNKNOWN),
|
||||
ARM_SPAN(0, solarxr_protocol.rpc.VRCAvatarMeasurementType.ARM_SPAN),
|
||||
HEIGHT(1, solarxr_protocol.rpc.VRCAvatarMeasurementType.HEIGHT),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val byValue = VRCAvatarMeasurementType.entries.associateBy { it.value }
|
||||
|
||||
fun getByValue(value: Int): VRCAvatarMeasurementType? = byValue[value]
|
||||
}
|
||||
}
|
||||
|
||||
data class VRCConfigValues(
|
||||
val legacyMode: Boolean,
|
||||
val shoulderTrackingDisabled: Boolean,
|
||||
val shoulderWidthCompensation: Boolean,
|
||||
val userHeight: Double,
|
||||
val calibrationRange: Double,
|
||||
val calibrationVisuals: Boolean,
|
||||
val trackerModel: VRCTrackerModel,
|
||||
val spineMode: VRCSpineMode,
|
||||
val avatarMeasurementType: VRCAvatarMeasurementType,
|
||||
)
|
||||
|
||||
data class VRCConfigRecommendedValues(
|
||||
val legacyMode: Boolean,
|
||||
val shoulderTrackingDisabled: Boolean,
|
||||
val shoulderWidthCompensation: Boolean,
|
||||
val userHeight: Double,
|
||||
val calibrationRange: Double,
|
||||
val calibrationVisuals: Boolean,
|
||||
val trackerModel: VRCTrackerModel,
|
||||
val spineMode: Array<VRCSpineMode>,
|
||||
val avatarMeasurementType: VRCAvatarMeasurementType,
|
||||
)
|
||||
|
||||
data class VRCConfigValidity(
|
||||
val legacyModeOk: Boolean,
|
||||
val shoulderTrackingOk: Boolean,
|
||||
val shoulderWidthCompensationOk: Boolean,
|
||||
val userHeightOk: Boolean,
|
||||
val calibrationOk: Boolean,
|
||||
val calibrationVisualsOk: Boolean,
|
||||
val tackerModelOk: Boolean,
|
||||
val spineModeOk: Boolean,
|
||||
val avatarMeasurementOk: Boolean,
|
||||
)
|
||||
|
||||
abstract class VRCConfigHandler {
|
||||
abstract val isSupported: Boolean
|
||||
abstract fun initHandler(onChange: (config: VRCConfigValues) -> Unit)
|
||||
}
|
||||
|
||||
class VRCConfigHandlerStub : VRCConfigHandler() {
|
||||
override val isSupported: Boolean
|
||||
get() = false
|
||||
|
||||
override fun initHandler(onChange: (config: VRCConfigValues) -> Unit) {}
|
||||
}
|
||||
|
||||
interface VRCConfigListener {
|
||||
fun onChange(validity: VRCConfigValidity, values: VRCConfigValues, recommended: VRCConfigRecommendedValues)
|
||||
}
|
||||
|
||||
class VRChatConfigManager(val server: VRServer, private val handler: VRCConfigHandler) {
|
||||
|
||||
private val listeners: MutableList<VRCConfigListener> = CopyOnWriteArrayList()
|
||||
var currentValues: VRCConfigValues? = null
|
||||
|
||||
val isSupported: Boolean
|
||||
get() = handler.isSupported
|
||||
|
||||
init {
|
||||
handler.initHandler(::onChange)
|
||||
}
|
||||
|
||||
/**
|
||||
* shoulderTrackingDisabled should be true if:
|
||||
* The user isn't tracking their whole arms from their controllers:
|
||||
* forceArmsFromHMD is enabled || the user doesn't have hand trackers with position || the user doesn't have lower arms trackers || the user doesn't have upper arm trackers
|
||||
* And the user isn't tracking their arms from their HMD or doesn't have both shoulders:
|
||||
* (forceArmsFromHMD is disabled && user has hand trackers with position) || user is missing a shoulder tracker
|
||||
*/
|
||||
fun recommendedValues(): VRCConfigRecommendedValues {
|
||||
val forceArmsFromHMD = server.humanPoseManager.getToggle(SkeletonConfigToggles.FORCE_ARMS_FROM_HMD)
|
||||
|
||||
val hasLeftHandWithPosition = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_HAND)?.hasPosition ?: false
|
||||
val hasRightHandWithPosition = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_HAND)?.hasPosition ?: false
|
||||
|
||||
val isMissingAnArmTracker = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_LOWER_ARM) == null ||
|
||||
TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_LOWER_ARM) == null ||
|
||||
TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_UPPER_ARM) == null ||
|
||||
TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_UPPER_ARM) == null
|
||||
val isMissingAShoulderTracker = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_SHOULDER) == null ||
|
||||
TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_SHOULDER) == null
|
||||
|
||||
return VRCConfigRecommendedValues(
|
||||
legacyMode = false,
|
||||
shoulderTrackingDisabled =
|
||||
((forceArmsFromHMD || !hasLeftHandWithPosition || !hasRightHandWithPosition) || isMissingAnArmTracker) && // Not tracking shoulders from hands
|
||||
((!forceArmsFromHMD && hasLeftHandWithPosition && hasRightHandWithPosition) || isMissingAShoulderTracker), // Not tracking shoulders from HMD
|
||||
userHeight = server.humanPoseManager.realUserHeight.toDouble(),
|
||||
calibrationRange = 0.2,
|
||||
trackerModel = VRCTrackerModel.AXIS,
|
||||
spineMode = arrayOf(VRCSpineMode.LOCK_HIP, VRCSpineMode.LOCK_HEAD),
|
||||
calibrationVisuals = true,
|
||||
avatarMeasurementType = VRCAvatarMeasurementType.HEIGHT,
|
||||
shoulderWidthCompensation = true,
|
||||
)
|
||||
}
|
||||
|
||||
fun addListener(listener: VRCConfigListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: VRCConfigListener) {
|
||||
listeners.removeIf { l -> l === listener }
|
||||
}
|
||||
|
||||
fun checkValidity(values: VRCConfigValues, recommended: VRCConfigRecommendedValues): VRCConfigValidity = VRCConfigValidity(
|
||||
legacyModeOk = values.legacyMode == recommended.legacyMode,
|
||||
shoulderTrackingOk = values.shoulderTrackingDisabled == recommended.shoulderTrackingDisabled,
|
||||
spineModeOk = recommended.spineMode.contains(values.spineMode),
|
||||
tackerModelOk = values.trackerModel == recommended.trackerModel,
|
||||
calibrationOk = abs(values.calibrationRange - recommended.calibrationRange) < 0.1,
|
||||
userHeightOk = abs(server.humanPoseManager.realUserHeight - values.userHeight) < 0.1,
|
||||
calibrationVisualsOk = values.calibrationVisuals == recommended.calibrationVisuals,
|
||||
avatarMeasurementOk = values.avatarMeasurementType == recommended.avatarMeasurementType,
|
||||
shoulderWidthCompensationOk = values.shoulderWidthCompensation == recommended.shoulderWidthCompensation,
|
||||
)
|
||||
|
||||
fun onChange(values: VRCConfigValues) {
|
||||
val recommended = recommendedValues()
|
||||
val validity = checkValidity(values, recommended)
|
||||
currentValues = values
|
||||
listeners.forEach {
|
||||
it.onChange(validity, values, recommended)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package dev.slimevr.protocol.datafeed;
|
||||
import com.google.flatbuffers.FlatBufferBuilder;
|
||||
import dev.slimevr.tracking.trackers.Device;
|
||||
import dev.slimevr.tracking.trackers.Tracker;
|
||||
import dev.slimevr.tracking.trackers.udp.MagnetometerStatus;
|
||||
import dev.slimevr.tracking.trackers.udp.UDPDevice;
|
||||
import io.github.axisangles.ktmath.Quaternion;
|
||||
import io.github.axisangles.ktmath.Vector3;
|
||||
@@ -80,6 +81,16 @@ public class DataFeedBuilder {
|
||||
return TrackerId.endTrackerId(fbb);
|
||||
}
|
||||
|
||||
public static int createVec3(FlatBufferBuilder fbb, Vector3 vec) {
|
||||
return Vec3f
|
||||
.createVec3f(
|
||||
fbb,
|
||||
vec.getX(),
|
||||
vec.getY(),
|
||||
vec.getZ()
|
||||
);
|
||||
}
|
||||
|
||||
public static int createQuat(FlatBufferBuilder fbb, Quaternion quaternion) {
|
||||
return Quat
|
||||
.createQuat(
|
||||
@@ -146,14 +157,7 @@ public class DataFeedBuilder {
|
||||
}
|
||||
|
||||
public static int createTrackerPosition(FlatBufferBuilder fbb, Tracker tracker) {
|
||||
Vector3 pos = tracker.getPosition();
|
||||
return Vec3f
|
||||
.createVec3f(
|
||||
fbb,
|
||||
pos.getX(),
|
||||
pos.getY(),
|
||||
pos.getZ()
|
||||
);
|
||||
return createVec3(fbb, tracker.getPosition());
|
||||
}
|
||||
|
||||
public static int createTrackerRotation(FlatBufferBuilder fbb, Tracker tracker) {
|
||||
@@ -161,14 +165,11 @@ public class DataFeedBuilder {
|
||||
}
|
||||
|
||||
public static int createTrackerAcceleration(FlatBufferBuilder fbb, Tracker tracker) {
|
||||
Vector3 accel = tracker.getAcceleration();
|
||||
return Vec3f
|
||||
.createVec3f(
|
||||
fbb,
|
||||
accel.getX(),
|
||||
accel.getY(),
|
||||
accel.getZ()
|
||||
);
|
||||
return createVec3(fbb, tracker.getAcceleration());
|
||||
}
|
||||
|
||||
public static int createTrackerMagneticVector(FlatBufferBuilder fbb, Tracker tracker) {
|
||||
return createVec3(fbb, tracker.getMagVector());
|
||||
}
|
||||
|
||||
public static int createTrackerTemperature(FlatBufferBuilder fbb, Tracker tracker) {
|
||||
@@ -233,6 +234,9 @@ public class DataFeedBuilder {
|
||||
if (mask.getTps()) {
|
||||
TrackerData.addTps(fbb, (int) tracker.getTps());
|
||||
}
|
||||
if (mask.getRawMagneticVector() && tracker.getMagStatus() == MagnetometerStatus.ENABLED) {
|
||||
TrackerData.addRawMagneticVector(fbb, createTrackerMagneticVector(fbb, tracker));
|
||||
}
|
||||
|
||||
return TrackerData.endTrackerData(fbb);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user