Compare commits

...

43 Commits

Author SHA1 Message Date
Eiren Rain
9a891a0323 Accel works correctly like this for some magical reason
Still need to figure out why we need to sandwich accel TWICE.. but only if we adjust it by reference, otherwise we can do it once...
2025-05-13 00:47:15 +02:00
Ilia Ki
d3b57048e9 Merge branch 'main' into fixAxis 2025-05-13 02:06:14 +07:00
Ilia Ki
c8a6e2fc0b Update server/core/src/main/java/dev/slimevr/tracking/trackers/Tracker.kt
Co-authored-by: Eiren Rain <Eirenliel@users.noreply.github.com>
2025-05-13 02:06:00 +07:00
Butterscotch!
471714afd0 Tracking pause unit test (#1360) 2025-05-12 21:08:06 +03:00
Aleksandra M
91ce0ed734 Add magnetic field vector (#1416) 2025-05-12 18:59:44 +03:00
sctanf
a35d2a77a2 TrackersHID clearly define acceleration axes (#1431) 2025-05-12 18:53:02 +03:00
sctanf
ea31d58fb8 IMUVisualizerWidget accel replace three arrows with one vector (#1430) 2025-05-12 18:52:50 +03:00
sctanf
e74dee1892 TrackersHID report magnetometer packet (#1428) 2025-05-12 18:52:41 +03:00
sctanf
d84fc40b23 TrackersHID keep opening existing devices (#1429) 2025-05-12 18:52:29 +03:00
Ilia Ki
db68d808dd Fix acceleration axis 2025-05-12 01:19:20 +07:00
Uriel
0dd004825d Fix macOS heap error in CI (#1426) 2025-05-06 20:08:36 +02:00
lucas lelievre
9f36444169 Allow to mute vrc warnings (#1420) 2025-05-06 12:07:51 +03:00
sctanf
52509c7950 Tracker display names (#1417) 2025-05-06 12:02:16 +03:00
Uriel
868c441220 Stop using Twemoji and just use SVGs for it (#1364) 2025-05-06 12:01:46 +03:00
jabberrock
5ad408f61d Reduce log entry size when logging invalid UDP packets (#1424) 2025-05-06 11:46:39 +03:00
sctanf
9b30f9800c TrackersHID fix exception from null magStatus (#1419) 2025-05-04 21:18:14 +02:00
lucas lelievre
07e5ef647a Make sure Global mag setting is saved (#1421) 2025-05-02 21:50:27 +03:00
lucas lelievre
47909ad46c Fix battery resistances being sent as string instead of numbers (#1413) 2025-04-28 17:59:11 +02:00
sctanf
99c92fad9e make tracker entry borders consistent with no border (#1411)
Co-authored-by: lucas lelievre <loucass003@gmail.com>
2025-04-28 16:51:06 +02:00
sctanf
d7861614b7 Better battery and wifi statuses behavior (#1410)
Co-authored-by: lucas lelievre <loucass003@gmail.com>
2025-04-28 16:44:27 +02:00
unlogisch04
b4a3e9cbd5 fix Firewall Rules for java.exe (#1415) 2025-04-28 14:56:43 +02:00
lucas lelievre
e58946f622 Bring back onboarding (#1414) 2025-04-28 14:55:52 +02:00
Erimel
c8532c76c2 Fix lower_leg-desc and elbow_offset-desc typos (#1412) 2025-04-27 01:29:57 +02:00
Butterscotch!
6f26ea7b50 Add OTA port to firewall uninstall script (#1409) 2025-04-26 16:01:22 +02:00
Butterscotch!
a848dfac43 Filtering fixes (#1405)
Co-authored-by: lucas lelievre <loucass003@gmail.com>
2025-04-25 17:27:20 +02:00
lucas lelievre
e5ea747cde Fix fw tool crash (#1408) 2025-04-25 17:21:25 +02:00
sctanf
34a782193b add transition to tracker shadows (#1402) 2025-04-25 08:37:17 +02:00
lucas lelievre
8498270df6 More provisioning errors (#1404)
Co-authored-by: Butterscotch! <bscotchvanilla@gmail.com>
2025-04-25 08:28:37 +02:00
Butterscotch!
cd6ed7296b Interpolation tests & better skeleton test class (#1359) 2025-04-24 00:19:21 +03:00
lucas lelievre
57541e560c vrconfig linux fix (#1403) 2025-04-23 21:22:08 +02:00
Erimel
6f877c3852 Apply filtering post-reset, reapply 180° fix removal & fix getYawQuaternion polarity (#1352)
Co-authored-by: lucas lelievre <loucass003@gmail.com>
2025-04-23 15:18:20 +02:00
lucas lelievre
ca8d75e749 Optimise re-renders (#1355)
Co-authored-by: Uriel <imurx@proton.me>
2025-04-23 14:58:19 +02:00
lucas lelievre
0dc073ca48 VRChat Config Warnings (#1358)
Co-authored-by: Erimel <marioluigivideo@gmail.com>
2025-04-23 14:36:29 +02:00
lucas lelievre
edbaf49e7a manual proportions rework (#1346)
Co-authored-by: Erimel <marioluigivideo@gmail.com>
2025-04-23 13:48:31 +02:00
Butterscotch!
ca790a3799 Load configured height for manual height config (#1395)
Co-authored-by: lucas lelievre <loucass003@gmail.com>
2025-04-23 12:20:54 +03:00
Uriel
3639bdc0ff Use automatic hyphenation when possible (#1362) 2025-04-23 11:16:21 +02:00
sctanf
6817eca793 Use HID tracker magnetometer status from mag_id (#1366) 2025-04-23 12:14:43 +03:00
dependabot[bot]
03365eb050 Bump awalsh128/cache-apt-pkgs-action from 1.4.3 to 1.5.0 (#1361)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-23 12:12:16 +03:00
sctanf
df5744b861 add fade out to reset button borders (#1400) 2025-04-23 11:06:46 +02:00
unlogisch04
bc602892b4 Fix for override.ftl not working (#1398) 2025-04-21 04:13:14 +02:00
sctanf
f49121b8a4 softer reset sounds (#1394) 2025-04-19 18:23:12 +02:00
lucas lelievre
c31aab8237 fw update server deadlock fix (#1393) 2025-04-19 18:22:59 +02:00
Butterscotch!
8e7070d257 Add OTA port to firewall script (#1397) 2025-04-19 19:22:14 +03:00
131 changed files with 3505 additions and 1977 deletions

View File

@@ -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'

View File

@@ -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
View File

@@ -46,3 +46,4 @@ local.properties
# Ignore temporary config
vrconfig.yml.tmp
*.DS_Store

View File

@@ -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',
};

View File

@@ -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"
}
}
}

View File

@@ -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 }

Binary file not shown.

View File

@@ -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/**" }]
}
]
}

View File

@@ -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={

View File

@@ -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 />

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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();
}}

View File

@@ -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>

View File

@@ -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
)
}
>

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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,
})),
[]
);

View File

@@ -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}

View File

@@ -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}
</>
);
}

View File

@@ -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"
>

View File

@@ -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"
>

View File

@@ -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"

View 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>
);
}

View 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>
);
}

View File

@@ -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 } = {

View File

@@ -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 ||

View File

@@ -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 &&

View File

@@ -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 && (

View File

@@ -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>
);
}

View File

@@ -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(
() =>

View File

@@ -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);

View File

@@ -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>
</>
);
}

View File

@@ -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(
() =>

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>
)) || <></>

View File

@@ -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>
</>

View File

@@ -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(

View File

@@ -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);

View File

@@ -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>

View File

@@ -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 (
<>

View File

@@ -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">

View File

@@ -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(
() =>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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">

View File

@@ -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]);

View File

@@ -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 (
<>

View File

@@ -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);

View File

@@ -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' },

View File

@@ -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')}

View File

@@ -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>
);
}

View File

@@ -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={

View File

@@ -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]}

View File

@@ -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')}

View File

@@ -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>

View File

@@ -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>

View 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>
);
}

View File

@@ -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'
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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)),
},
};
});

View File

@@ -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;

View File

@@ -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']);

View File

@@ -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;

View File

@@ -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

View File

@@ -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);
}

View 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);
},
};
}

View File

@@ -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
View 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',
},
];

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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);

View 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
);

View File

@@ -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', {

View File

@@ -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
View File

@@ -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: {}

View File

@@ -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)
}
}

View File

@@ -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 (

View File

@@ -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

View File

@@ -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

View File

@@ -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())
}
}
}

View File

@@ -58,6 +58,7 @@ class ServerConfig {
}.awaitAll()
useMagnetometerOnAllTrackers = state
VRServer.instance.configManager.saveConfig()
} finally {
magMutex.unlock()
}

View File

@@ -108,7 +108,7 @@ class VRConfig {
if (tracker.allowFiltering) {
tracker
.filteringHandler
.readFilteringConfig(filters, tracker.getRawRotation())
.readFilteringConfig(filters, tracker.getRotation())
}
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)
}
}
}

View File

@@ -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