Session flightlist (#1407)

Co-authored-by: sctanf <36978460+sctanf@users.noreply.github.com>
Co-authored-by: Butterscotch! <bscotchvanilla@gmail.com>
Co-authored-by: Aed <145398159+Aed-1@users.noreply.github.com>
This commit is contained in:
lucas lelievre
2025-12-02 14:49:45 +01:00
committed by GitHub
parent 0dc78a7e72
commit 2e1ec07b23
137 changed files with 6693 additions and 4215 deletions

63
Cargo.lock generated
View File

@@ -1273,7 +1273,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 0.9.0",
"toml 0.9.2",
"vswhom",
"winreg 0.55.0",
]
@@ -1431,6 +1431,19 @@ dependencies = [
"miniz_oxide 0.8.0",
]
[[package]]
name = "flexi_logger"
version = "0.29.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a5a6882b2e137c4f2664562995865084eb5a00611fba30c582ef10354c4ad8"
dependencies = [
"chrono",
"log",
"nu-ansi-term",
"regex",
"thiserror 2.0.12",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -2770,6 +2783,15 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "num-conv"
version = "0.1.0"
@@ -4380,6 +4402,7 @@ dependencies = [
"const_format",
"dirs-next",
"discord-sdk",
"flexi_logger",
"glob",
"itertools",
"libloading 0.8.5",
@@ -4759,7 +4782,7 @@ dependencies = [
"serde_json",
"tauri-utils",
"tauri-winres",
"toml 0.9.0",
"toml 0.9.2",
"walkdir",
]
@@ -4817,7 +4840,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"toml 0.9.0",
"toml 0.9.2",
"walkdir",
]
@@ -4857,7 +4880,7 @@ dependencies = [
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.12",
"toml 0.9.0",
"toml 0.9.2",
"url",
]
@@ -5067,7 +5090,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.12",
"toml 0.9.0",
"toml 0.9.2",
"url",
"urlpattern",
"uuid",
@@ -5284,9 +5307,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.0"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f271e09bde39ab52250160a67e88577e0559ad77e9085de6e9051a2c4353f8f8"
checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac"
dependencies = [
"indexmap 2.6.0",
"serde",
@@ -5294,7 +5317,7 @@ dependencies = [
"toml_datetime 0.7.0",
"toml_parser",
"toml_writer",
"winnow 0.7.11",
"winnow 0.7.12",
]
[[package]]
@@ -5348,16 +5371,16 @@ dependencies = [
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.11",
"winnow 0.7.12",
]
[[package]]
name = "toml_parser"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5c1c469eda89749d2230d8156a5969a69ffe0d6d01200581cdc6110674d293e"
checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30"
dependencies = [
"winnow 0.7.11",
"winnow 0.7.12",
]
[[package]]
@@ -5368,9 +5391,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.0.0"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b679217f2848de74cabd3e8fc5e6d66f40b7da40f8e1954d92054d9010690fd5"
checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64"
[[package]]
name = "tower-service"
@@ -6430,9 +6453,9 @@ dependencies = [
[[package]]
name = "winnow"
version = "0.7.11"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd"
checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
dependencies = [
"memchr",
]
@@ -6656,7 +6679,7 @@ dependencies = [
"tracing",
"uds_windows",
"windows-sys 0.59.0",
"winnow 0.7.11",
"winnow 0.7.12",
"xdg-home",
"zbus_macros 5.4.0",
"zbus_names 4.2.0",
@@ -6711,7 +6734,7 @@ checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97"
dependencies = [
"serde",
"static_assertions",
"winnow 0.7.11",
"winnow 0.7.12",
"zvariant 5.7.0",
]
@@ -6819,7 +6842,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 0.7.11",
"winnow 0.7.12",
"zvariant_derive 5.7.0",
"zvariant_utils 3.2.1",
]
@@ -6871,5 +6894,5 @@ dependencies = [
"quote",
"serde",
"syn 2.0.87",
"winnow 0.7.11",
"winnow 0.7.12",
]

View File

@@ -1,33 +1,33 @@
## SlimeVR is a trademark or a registered trademark of SlimeVR B.V.
**Usage of SlimeVR software, hardware, or other intellectual property in this or other repositories does not grant you the right to use SlimeVR trademark as your own.**
The purpose of a trademark is to remove uncertainty for users and customers regarding the product's manufacturer or endorsement. You're not allowed to market your product using SlimeVR name, and your usage of the name should be only factual and descriptive. For example, calling original SlimeVR products SlimeVR or describing compatibility of other products or derivatives. This applies to all products, including software, and hardware including non-official Full-Body Trackers.
**Here are a few _acceptable_ uses of SlimeVR name when selling unofficial Slime trackers:**
* SlimeVR-compatible trackers
* Unofficial SlimeVR trackers / Non-official SlimeVR trackers
* DIY SlimeVR trackers
* Third-party SlimeVR Trackers
* Custom SlimeVR-compatible trackers
* < Your Brand > Slime Trackers
* Using "SlimeVR" as a search tag
**_Unacceptable_ uses include, but are not limited to:**
* SlimeVR store
* Buy SlimeVR
* SlimeVR Trackers
* Original SlimeVR
* Official SlimeVR
* SlimeVR BMI270 (or any other IMU model along with SlimeVR name)
* < Your brand > SlimeVR / < your brand > SlimeVR Trackers
Use of the SlimeVR name that can cause confusion is not allowed in any part of the listing, including, but not limited to: product title, product description, product metadata, site title, site name, site metadata, site texts, social media posts, or other advertisement.
Also, please ensure you use the correct spelling and capitalization: only **"SlimeVR" is acceptable**. Not "Slimevr", "slimevr", or "Slime VR". You're allowed to use the word "slime" as you wish, it's not a trademark.
Please understand that we have an obligation to reduce confusion for the customers, and we believe that our usage terms are generous compared to many other companies and products. This applies to all sellers or derivative products, we do not make exceptions.
---
## SlimeVR is a trademark or a registered trademark of SlimeVR B.V.
**Usage of SlimeVR software, hardware, or other intellectual property in this or other repositories does not grant you the right to use SlimeVR trademark as your own.**
The purpose of a trademark is to remove uncertainty for users and customers regarding the product's manufacturer or endorsement. You're not allowed to market your product using SlimeVR name, and your usage of the name should be only factual and descriptive. For example, calling original SlimeVR products SlimeVR or describing compatibility of other products or derivatives. This applies to all products, including software, and hardware including non-official Full-Body Trackers.
**Here are a few _acceptable_ uses of SlimeVR name when selling unofficial Slime trackers:**
* SlimeVR-compatible trackers
* Unofficial SlimeVR trackers / Non-official SlimeVR trackers
* DIY SlimeVR trackers
* Third-party SlimeVR Trackers
* Custom SlimeVR-compatible trackers
* < Your Brand > Slime Trackers
* Using "SlimeVR" as a search tag
**_Unacceptable_ uses include, but are not limited to:**
* SlimeVR store
* Buy SlimeVR
* SlimeVR Trackers
* Original SlimeVR
* Official SlimeVR
* SlimeVR BMI270 (or any other IMU model along with SlimeVR name)
* < Your brand > SlimeVR / < your brand > SlimeVR Trackers
Use of the SlimeVR name that can cause confusion is not allowed in any part of the listing, including, but not limited to: product title, product description, product metadata, site title, site name, site metadata, site texts, social media posts, or other advertisement.
Also, please ensure you use the correct spelling and capitalization: only **"SlimeVR" is acceptable**. Not "Slimevr", "slimevr", or "Slime VR". You're allowed to use the word "slime" as you wish, it's not a trademark.
Please understand that we have an obligation to reduce confusion for the customers, and we believe that our usage terms are generous compared to many other companies and products. This applies to all sellers or derivative products, we do not make exceptions.
---
If you have any questions about SlimeVR trademark or copyrighted materials, you can reach out to us at *tm[at]slimevr.dev*.

View File

@@ -16,15 +16,15 @@
"@sentry/vite-plugin": "^2.22.7",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query": "^5.48.0",
"@tauri-apps/api": "~2",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-fs": "~2",
"@tauri-apps/plugin-http": "~2",
"@tauri-apps/api": "^2.0.2",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "2.4.1",
"@tauri-apps/plugin-opener": "^2.4.0",
"@tauri-apps/plugin-http": "^2.5.0",
"@tauri-apps/plugin-log": "~2",
"@tauri-apps/plugin-opener": "~2",
"@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-shell": "~2",
"@tauri-apps/plugin-store": "~2",
"@tauri-apps/plugin-os": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.3.0",
"@tauri-apps/plugin-store": "^2.4.1",
"@tweenjs/tween.js": "^25.0.0",
"@twemoji/svg": "^15.0.0",
"ajv": "^8.17.1",

View File

@@ -238,9 +238,9 @@ reset-reset_all_warning_default-v2 =
Are you sure you want to do this?
reset-full = Full Reset
reset-mounting = Reset Mounting
reset-mounting-feet = Reset Feet Mounting
reset-mounting-fingers = Reset Fingers Mounting
reset-mounting = Mounting Calibration
reset-mounting-feet = Feet Calibration
reset-mounting-fingers = Fingers Calibration
reset-yaw = Yaw Reset
## Serial detection stuff
@@ -262,6 +262,7 @@ navbar-settings = Settings
## Biovision hierarchy recording
bvh-start_recording = Record BVH
bvh-stop_recording = Save BVH recording
bvh-recording = Recording...
bvh-save_title = Save BVH recording
@@ -277,8 +278,8 @@ widget-overlay-is_mirrored_label = Display Overlay as Mirror
## Widget: Drift compensation
widget-drift_compensation-clear = Clear drift compensation
## Widget: Clear Reset Mounting
widget-clear_mounting = Clear Reset Mounting
## Widget: Clear Mounting calibration
widget-clear_mounting = Clear mounting calibration
## Widget: Developer settings
widget-developer_mode = Developer Mode
@@ -455,6 +456,7 @@ mounting_selection_menu-close = Close
## Sidebar settings
settings-sidebar-title = Settings
settings-sidebar-general = General
settings-sidebar-steamvr = SteamVR
settings-sidebar-tracker_mechanics = Tracker mechanics
settings-sidebar-stay_aligned = Stay Aligned
settings-sidebar-fk_settings = Tracking settings
@@ -462,9 +464,12 @@ settings-sidebar-gesture_control = Gesture control
settings-sidebar-interface = Interface
settings-sidebar-osc_router = OSC router
settings-sidebar-osc_trackers = VRChat OSC Trackers
settings-sidebar-osc_vmc = VMC
settings-sidebar-utils = Utilities
settings-sidebar-serial = Serial console
settings-sidebar-appearance = Appearance
settings-sidebar-home = Home Screen
settings-sidebar-checklist = Tracking checklist
settings-sidebar-notifications = Notifications
settings-sidebar-behavior = Behavior
settings-sidebar-firmware-tool = DIY Firmware Tool
@@ -654,7 +659,7 @@ settings-general-gesture_control-yawResetTaps = Taps for yaw reset
settings-general-gesture_control-fullResetEnabled = Enable tap to full reset
settings-general-gesture_control-fullResetDelay = Full reset delay
settings-general-gesture_control-fullResetTaps = Taps for full reset
settings-general-gesture_control-mountingResetEnabled = Enable tap to reset mounting
settings-general-gesture_control-mountingResetEnabled = Enable tap to mounting calibration
settings-general-gesture_control-mountingResetDelay = Mounting reset delay
settings-general-gesture_control-mountingResetTaps = Taps for mounting reset
# The number of trackers that can have higher acceleration before a tap is rejected
@@ -879,6 +884,16 @@ settings-utils-advanced-open_logs = Logs folder
settings-utils-advanced-open_logs-description = Open SlimeVR's logs folder in file explorer, containing the logs of the app
settings-utils-advanced-open_logs-label = Open folder
## Home Screen
settings-home-list-layout = Trackers list layout
settings-home-list-layout-desc = Select one of the possible layouts of the home screen
settings-home-list-layout-grid = Grid
settings-home-list-layout-table = Table
## Tracking Checlist
settings-tracking_checklist-active_steps = Active Steps
settings-tracking_checklist-active_steps-desc = List all the steps that will show in the tracking checklist. You can either disable or enable ignorable steps
## Setup/onboarding menu
onboarding-skip = Skip setup
onboarding-continue = Continue
@@ -1132,7 +1147,11 @@ onboarding-automatic_mounting-done-description = Your mounting calibration is co
onboarding-automatic_mounting-done-restart = Try again
onboarding-automatic_mounting-mounting_reset-title = Mounting Reset
onboarding-automatic_mounting-mounting_reset-step-0 = 1. Squat in a "skiing" pose with your legs bent, your upper body tilted forwards, and your arms bent.
onboarding-automatic_mounting-mounting_reset-step-1 = 2. Press the "Reset Mounting" button and wait for 3 seconds before the trackers' mounting orientations will reset.
onboarding-automatic_mounting-mounting_reset-step-1 = 2. Press the "Mounting calibration" button and wait for 3 seconds before the trackers' mounting orientations will reset.
onboarding-automatic_mounting-mounting_reset-feet-step-0 = 1. Stand on your toes with both feet pointing forward. Alternatively you can do it siting on a chair.
onboarding-automatic_mounting-mounting_reset-feet-step-1 = 2. Press the "Feet calibration" button and wait for 3 seconds before the trackers' mounting orientations will reset.
onboarding-automatic_mounting-preparation-title = Preparation
onboarding-automatic_mounting-preparation-v2-step-0 = 1. Press the "Full Reset" button.
onboarding-automatic_mounting-preparation-v2-step-1 = 2. Stand upright with your arms to your sides. Make sure to look forward.
@@ -1305,6 +1324,8 @@ onboarding-stay_aligned-done = Done
## Home
home-no_trackers = No trackers detected or assigned
home-settings = Home Page Settings
home-settings-close = Close
## Trackers Still On notification
trackers_still_on-modal-title = Trackers still on
@@ -1522,3 +1543,58 @@ error_collection_modal-description_v2 = { settings-interface-behavior-error_trac
You can change this setting later in the Behavior section of the settings page.
error_collection_modal-confirm = I agree
error_collection_modal-cancel = I don't want to
tracking_checklist = Tracking Checklist
tracking_checklist-settings = Tracking Checklist Settings
tracking_checklist-settings-close = Close
tracking_checklist-status-incomplete = You are not prepared to use SlimeVR!
tracking_checklist-status-partial = {$count ->
[one] You have 1 warning!
*[many] You have {$count} warnings!
}
tracking_checklist-status-complete = You are prepared to use SlimeVR!
tracking_checklist-MOUNTING_CALIBRATION = Perform a mounting calibration
tracking_checklist-FEET_MOUNTING_CALIBRATION = Perform a feet mounting calibration
tracking_checklist-FULL_RESET = Perform a full Reset
tracking_checklist-FULL_RESET-desc = Some Trackers need a reset to be performed
tracking_checklist-STEAMVR_DISCONNECTED = SteamVR not running
tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR is not running. Are you using it for vr?
tracking_checklist-STEAMVR_DISCONNECTED-open = Launch SteamVR
tracking_checklist-TRACKERS_REST_CALIBRATION = Calibrate your trackers
tracking_checklist-TRACKERS_REST_CALIBRATION-desc = You didn't perform tracker calibration. Please let your Slimes (highlighted in yellow) rest on a stable surface for a few secconds.
tracking_checklist-TRACKER_ERROR = Trackers with Errors
tracking_checklist-TRACKER_ERROR-desc = Some of your trackers have an error. Please restart the tracker.
tracking_checklist-VRCHAT_SETTINGS = Configure VRChat settings
tracking_checklist-VRCHAT_SETTINGS-desc = You have misconfigured VRchat Settings! This can impact your tracking experience.
tracking_checklist-VRCHAT_SETTINGS-open = Go to VRChat Warnings
tracking_checklist-UNASSIGNED_HMD = VR Headset not assigned to Head
tracking_checklist-UNASSIGNED_HMD-desc = The VR headset should be assigned as a head tracker.
tracking_checklist-NETWORK_PROFILE_PUBLIC = Change your network profile
tracking_checklist-NETWORK_PROFILE_PUBLIC-desc = {$count ->
[one] Your network profile is currently set to Public ({$adapters}).
This is not recommended for SlimeVR to function properly.
<PublicFixLink>See how to fix it here.</PublicFixLink>
*[many] Some of your network adapters are set to public:
{$adapters}
This is not recommended for SlimeVR to function properly.
<PublicFixLink>See how to fix it here.</PublicFixLink>
}
tracking_checklist-NETWORK_PROFILE_PUBLIC-open = Open Control Panel
tracking_checklist-STAY_ALIGNED_CONFIGURED = Configure Stay Aligned
tracking_checklist-STAY_ALIGNED_CONFIGURED-desc = Record the Stay Aligned poses for an improved imu drift
tracking_checklist-STAY_ALIGNED_CONFIGURED-open = Open Stay Aligned Wizard
tracking_checklist-ignore = Ignore
preview-mocap_mode_soon = Mocap Mode (Soon™)
preview-disable_render = Disable rendering
preview-disabled_render = Rendering disabled
toolbar-mounting_calibration = Mounting Calibration
toolbar-mounting_calibration-default = Body
toolbar-mounting_calibration-feet = Feet
toolbar-mounting_calibration-fingers = Fingers
toolbar-drift_reset = Drift Reset
toolbar-assigned_trackers = {$count} trackers assigned
toolbar-unassigned_trackers = {$count} trackers unassigned

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
gui/public/sounds/mew.ogg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -28,15 +28,14 @@ shadow-rs = "0.35"
[dependencies]
serde_json = "1"
serde = { version = "1", features = ["derive"] }
tauri = { version = "2", features = ["devtools", "tray-icon", "image-png", "rustls-tls"] }
tauri-runtime = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-http = "2"
tauri-plugin-opener = "2"
tauri-plugin-os = "2"
tauri-plugin-shell = "2"
tauri-plugin-store = "2"
tauri = { version = "2.0", features = ["devtools", "tray-icon", "image-png", "rustls-tls"] }
tauri-runtime = "2.0"
tauri-plugin-dialog = "2.0"
tauri-plugin-fs = "2.4.1"
tauri-plugin-os = "2.0"
tauri-plugin-shell = "2.3.0"
tauri-plugin-store = "2.0"
flexi_logger = "0.29"
log-panics = { version = "2", features = ["with-backtrace"] }
log = "0.4"
clap = { version = "4.0.29", features = ["derive"] }
@@ -55,6 +54,8 @@ dirs-next = "2.0.0"
discord-sdk = "0.3.6"
tokio = { version = "1.37.0", features = ["time"] }
itertools = "0.13.0"
tauri-plugin-opener = "2.4.0"
tauri-plugin-http = "2.5.0"
tauri-plugin-log = "2"
[target.'cfg(windows)'.dependencies]

View File

@@ -41,6 +41,17 @@
}
]
},
{
"identifier": "opener:allow-open-url",
"allow": [
{
"url": "steam:*"
},
{
"url": "ms-settings:network"
}
]
},
{
"identifier": "http:default",
"allow": [
@@ -52,4 +63,4 @@
"opener:default",
"log:default"
]
}
}

View File

@@ -39,7 +39,6 @@ import { OSCRouterSettings } from './components/settings/pages/OSCRouterSettings
import * as os from '@tauri-apps/plugin-os';
import { VMCSettings } from './components/settings/pages/VMCSettings';
import { MountingChoose } from './components/onboarding/pages/mounting/MountingChoose';
import { StatusProvider } from './components/providers/StatusSystemContext';
import { VersionUpdateModal } from './components/VersionUpdateModal';
import { CalibrationTutorialPage } from './components/onboarding/pages/CalibrationTutorial';
import { AssignmentTutorialPage } from './components/onboarding/pages/assignment-preparation/AssignmentTutorial';
@@ -61,6 +60,9 @@ import { FirmwareUpdate } from './components/firmware-update/FirmwareUpdate';
import { ConnectionLost } from './components/onboarding/pages/ConnectionLost';
import { VRCWarningsPage } from './components/vrc/VRCWarningsPage';
import { StayAlignedSetup } from './components/onboarding/pages/stay-aligned/StayAlignedSetup';
import { TrackingChecklistProvider } from './components/tracking-checklist/TrackingChecklistProvider';
import { HomeScreenSettings } from './components/settings/pages/HomeScreenSettings';
import { ChecklistPage } from './components/tracking-checklist/TrackingChecklist';
export const GH_REPO = 'SlimeVR/SlimeVR-Server';
export const VersionContext = createContext('');
@@ -83,7 +85,7 @@ function Layout() {
<Route
path="/"
element={
<MainLayout isMobile={isMobile}>
<MainLayout isMobile={isMobile} full>
<Home />
</MainLayout>
}
@@ -91,7 +93,7 @@ function Layout() {
<Route
path="/firmware-update"
element={
<MainLayout isMobile={isMobile} widgets={false}>
<MainLayout isMobile={isMobile}>
<FirmwareUpdate />
</MainLayout>
}
@@ -99,11 +101,19 @@ function Layout() {
<Route
path="/vr-mode"
element={
<MainLayout isMobile={isMobile}>
<MainLayout isMobile={isMobile} full>
<VRModePage />
</MainLayout>
}
/>
<Route
path="/checklist"
element={
<MainLayout isMobile={isMobile}>
<ChecklistPage />
</MainLayout>
}
/>
<Route
path="/tracker/:trackernum/:deviceid"
element={
@@ -115,7 +125,7 @@ function Layout() {
<Route
path="/vrc-warnings"
element={
<MainLayout isMobile={isMobile} widgets={false}>
<MainLayout isMobile={isMobile}>
<VRCWarningsPage />
</MainLayout>
}
@@ -135,6 +145,7 @@ function Layout() {
<Route path="osc/vrchat" element={<VRCOSCSettings />} />
<Route path="osc/vmc" element={<VMCSettings />} />
<Route path="interface" element={<InterfaceSettings />} />
<Route path="interface/home" element={<HomeScreenSettings />} />
<Route path="advanced" element={<AdvancedSettings />} />
</Route>
<Route
@@ -297,7 +308,7 @@ export default function App() {
<WebSocketApiContext.Provider value={websocketAPI}>
<AppContextProvider>
<OnboardingContextProvider>
<StatusProvider>
<TrackingChecklistProvider>
<VersionContext.Provider value={updateFound}>
<div className="h-full w-full text-standard bg-background-80 text-background-10">
<Preload />
@@ -305,7 +316,7 @@ export default function App() {
{websocketAPI.isConnected && <Layout />}
</div>
</VersionContext.Provider>
</StatusProvider>
</TrackingChecklistProvider>
</OnboardingContextProvider>
</AppContextProvider>
</WebSocketApiContext.Provider>

View File

@@ -1,44 +0,0 @@
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 { 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 { sendRPCPacket } = useWebsocketAPI();
const assignedTrackers = useAtomValue(assignedTrackersAtom);
const trackerWithMounting = useMemo(
() =>
assignedTrackers.some(
(d) =>
!similarQuaternions(
QuaternionFromQuatT(d?.tracker.info?.mountingResetOrientation),
_q
)
),
[assignedTrackers]
);
const clearMounting = () => {
const record = new ClearMountingResetRequestT();
sendRPCPacket(RpcMessage.ClearMountingResetRequest, record);
};
return (
<Localized id={'widget-clear_mounting'}>
<BigButton
icon={<TrashIcon size={20} />}
onClick={clearMounting}
disabled={!trackerWithMounting}
/>
</Localized>
);
}

View File

@@ -1,24 +1,67 @@
:root {
--toolbar-h: 120px;
}
.main-layout {
display: grid;
grid-template:
't t' var(--topbar-h)
's c' calc(100% - var(--topbar-h))
'n c' calc(100% - var(--topbar-h))
/ var(--navbar-w) calc(100% - var(--navbar-w));
&:has(.widgets) {
&.full {
grid-template:
't t t' var(--topbar-h)
's c w' calc(100% - var(--topbar-h))
/ var(--navbar-w) calc(100% - var(--navbar-w) - var(--widget-w)) var(
--widget-w
);
'n b s' var(--toolbar-h)
'n c s' calc(100% - var(--topbar-h) - var(--toolbar-h))
/ var(--navbar-w) calc(100% - var(--navbar-w) - var(--right-section-w)) var(--right-section-w);
}
@screen nsm {
--right-section-w: 40%;
}
@screen sm {
--right-section-w: 35%;
}
@screen md {
--right-section-w: 30%;
}
@screen lg {
--right-section-w: 25%;
}
@screen xl {
--right-section-w: 22%;
}
@screen mobile {
--checklist-h: 30px;
&.checklist-ok {
--checklist-h: 0px;
}
&.full {
grid-template:
't' var(--topbar-h)
'l' var(--checklist-h)
'b' var(--toolbar-h)
'c' calc(
100% - var(--topbar-h) - var(--checklist-h) - var(--toolbar-h) - var(
--navbar-h
)
)
'n' calc(var(--navbar-h))
/ 100%;
}
grid-template:
't' var(--topbar-h)
'c' calc(100% - var(--topbar-h) - var(--navbar-h))
's' calc(var(--navbar-h))
'n' calc(var(--navbar-h))
/ 100%;
}
}

View File

@@ -9,20 +9,25 @@ import {
import { Navbar } from './Navbar';
import { TopBar } from './TopBar';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { WidgetsComponent } from './WidgetsComponent';
import './MainLayout.scss';
import { Toolbar } from './Toolbar';
import { Sidebar } from './Sidebar';
import { TrackingChecklistMobile } from './tracking-checklist/TrackingChecklist';
import { useTrackingChecklist } from '@/hooks/tracking-checklist';
export function MainLayout({
children,
background = true,
widgets = true,
full = false,
isMobile = undefined,
}: {
children: ReactNode;
background?: boolean;
isMobile?: boolean;
widgets?: boolean;
showToolbarSettings?: boolean;
full?: boolean;
}) {
const { completion } = useTrackingChecklist();
const { sendRPCPacket } = useWebsocketAPI();
const [ProportionsLastPageOpen, setProportionsLastPageOpen] = useState(true);
@@ -58,33 +63,42 @@ export function MainLayout({
});
return (
<div className="">
<div className="main-layout w-full h-screen">
<div style={{ gridArea: 't' }}>
<TopBar />
</div>
<div style={{ gridArea: 's' }} className="overflow-y-auto">
<Navbar />
</div>
<div
style={{ gridArea: 'c' }}
className={classNames(
'overflow-y-auto mr-2 my-2 mobile:m-0',
'flex flex-col rounded-xl',
background && 'bg-background-70'
)}
>
{children}
</div>
{!isMobile && widgets && (
<div
style={{ gridArea: 'w' }}
className="overflow-y-auto mr-2 my-2 rounded-xl bg-background-70 flex flex-col gap-2 p-2 widgets"
>
<WidgetsComponent />
</div>
)}
<div
className={classNames('main-layout w-full h-screen', full && 'full', {
'checklist-ok': completion === 'complete',
})}
>
<div style={{ gridArea: 't' }}>
<TopBar />
</div>
<div style={{ gridArea: 'n' }} className="overflow-y-auto">
<Navbar />
</div>
<div
style={{ gridArea: 'c' }}
className={classNames(
'overflow-y-auto mr-2 my-2 mobile:m-0',
'flex flex-col rounded-md',
background && 'bg-background-70',
{ 'rounded-t-none': !isMobile && full }
)}
>
{children}
</div>
{full && isMobile && completion !== 'complete' && (
<TrackingChecklistMobile />
)}
{full && (
<div style={{ gridArea: 'b' }}>
<Toolbar />
</div>
)}
{!isMobile && full && (
<div style={{ gridArea: 's' }} className="mr-2">
<Sidebar />
</div>
)}
</div>
);
}

View File

@@ -2,14 +2,14 @@ import { useLocalization } from '@fluent/react';
import classnames from 'classnames';
import { ReactNode } from 'react';
import { NavLink, useMatch } from 'react-router-dom';
import { CubeIcon } from './commons/icon/CubeIcon';
import { GearIcon } from './commons/icon/GearIcon';
import { HumanIcon } from './commons/icon/HumanIcon';
import { RulerIcon } from './commons/icon/RulerIcon';
import { SparkleIcon } from './commons/icon/SparkleIcon';
import { WrenchIcon } from './commons/icon/WrenchIcons';
import { useBreakpoint } from '@/hooks/breakpoint';
import { useConfig } from '@/hooks/config';
import { HomeIcon } from './commons/icon/HomeIcon';
import { SkiIcon } from './commons/icon/SkiIcon';
export function NavButton({
to,
@@ -34,7 +34,7 @@ export function NavButton({
state={state}
className={classnames(
'flex flex-col justify-center xs:gap-4 mobile:gap-2',
'xs:w-[85px] mobile:w-[80px] mobile:h-[80px]',
'xs:w-[85px] mobile:w-[65px] mobile:h-[65px]',
'xs:py-3 mobile:py-4 rounded-md mobile:rounded-b-none group select-text',
{
'bg-accent-background-50 fill-accent-background-20': doesMatch,
@@ -44,16 +44,16 @@ export function NavButton({
>
<div className="flex justify-around">
<div
className={classnames('scale-150', {
className={classnames('scale-[150%]', {
'fill-accent-lighter': doesMatch,
'fill-background-50': !doesMatch,
'fill-background-40': !doesMatch,
})}
>
{icon}
</div>
</div>
<div
className={classnames('text-center', {
className={classnames('text-center mobile:hidden', {
'text-accent-background-10': doesMatch,
'text-background-10': !doesMatch,
})}
@@ -70,7 +70,7 @@ export function MainLinks() {
return (
<>
<NavButton to="/" icon={<CubeIcon />}>
<NavButton to="/" icon={<HomeIcon />}>
{l10n.getString('navbar-home')}
</NavButton>
<NavButton
@@ -84,7 +84,7 @@ export function MainLinks() {
to="/onboarding/mounting/choose"
match="/onboarding/mounting/*"
state={{ alonePage: true }}
icon={<WrenchIcon />}
icon={<SkiIcon />}
>
{l10n.getString('navbar-mounting')}
</NavButton>

View File

@@ -0,0 +1,251 @@
import { useTrackingChecklist } from '@/hooks/tracking-checklist';
import { TrackingChecklist } from './tracking-checklist/TrackingChecklist';
import { SkeletonVisualizerWidget } from './widgets/SkeletonVisualizerWidget';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { Typography } from './commons/Typography';
import { useLocaleConfig } from '@/i18n/config';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import {
RpcMessage,
SkeletonConfigRequestT,
SkeletonConfigResponseT,
} from 'solarxr-protocol';
import { Tooltip } from './commons/Tooltip';
import { Vector3 } from 'three';
import { RecordIcon } from './commons/icon/RecordIcon';
import { PauseIcon } from './commons/icon/PauseIcon';
import { HumanIcon } from './commons/icon/HumanIcon';
import { EyeIcon } from './commons/icon/EyeIcon';
import { useConfig } from '@/hooks/config';
import { useBHV } from '@/hooks/bvh';
import { usePauseTracking } from '@/hooks/pause-tracking';
import { PlayIcon } from './commons/icon/PlayIcon';
export function PreviewControls({ open }: { open: boolean }) {
const [userHeight, setUserHeight] = useState('');
const { currentLocales } = useLocaleConfig();
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
const {
state: bvhState,
toggle: toggleBVH,
available: bvhAvailable,
} = useBHV();
const { paused, toggle: toggleTracking } = usePauseTracking();
const { cmFormat } = useMemo(() => {
const cmFormat = Intl.NumberFormat(currentLocales, {
style: 'unit',
unit: 'centimeter',
maximumFractionDigits: 1,
});
return { cmFormat };
}, [currentLocales]);
useRPCPacket(
RpcMessage.SkeletonConfigResponse,
(data: SkeletonConfigResponseT) => {
if (data.userHeight)
setUserHeight(cmFormat.format((data.userHeight * 100) / 0.936));
}
);
useEffect(() => {
sendRPCPacket(
RpcMessage.SkeletonConfigRequest,
new SkeletonConfigRequestT()
);
}, []);
return (
<>
<Tooltip
preferedDirection="bottom"
content={
<Typography id="onboarding-manual_proportions-estimated_height" />
}
>
<div
className={classNames(
'h-10 bg-background-60 p-4 flex items-center rounded-lg justify-center cursor-help w-fit top-2 left-2 absolute',
{
'opacity-0': !open,
'opacity-100': open,
}
)}
>
<Typography variant="section-title">{userHeight}</Typography>
</div>
</Tooltip>
<div className="absolute bottom-0 pb-4 flex justify-center w-full">
<div className="flex bg-background-80 bg-opacity-70 rounded-lg gap-2 px-4 py-2 items-center fill-background-10">
{bvhAvailable && (
<Tooltip
content={
<Typography
variant="section-title"
id={
bvhState === 'idle'
? 'bvh-start_recording'
: 'bvh-stop_recording'
}
/>
}
preferedDirection="top"
>
<div
className={classNames(
'flex justify-center items-center w-10 h-10 rounded-full hover:bg-background-60 cursor-pointer',
{ 'bg-background-60': bvhState !== 'idle' }
)}
onClick={() => toggleBVH()}
>
{bvhState === 'idle' && <RecordIcon width={20} />}
{bvhState !== 'idle' && (
<div className="w-5 h-5 rounded-full bg-status-critical animate-pulse" />
)}
</div>
</Tooltip>
)}
<Tooltip
content={
<Typography
variant="section-title"
id={paused ? 'tracking-paused' : 'tracking-unpaused'}
/>
}
preferedDirection="top"
>
<div
className="flex justify-center items-center w-14 h-14 rounded-full bg-background-60 hover:bg-background-50 cursor-pointer"
onClick={() => toggleTracking()}
>
{!paused && <PauseIcon width={25} />}
{paused && <PlayIcon width={25} />}
</div>
</Tooltip>
<Tooltip
content={
<Typography
variant="section-title"
id="preview-mocap_mode_soon"
/>
}
preferedDirection="top"
>
<div className="flex justify-center items-center w-10 h-10 rounded-full cursor-not-allowed">
<HumanIcon width={20} />
</div>
</Tooltip>
</div>
</div>
</>
);
}
function PreviewSection({ open }: { open: boolean }) {
const { config, setConfig } = useConfig();
const [disabledRender, setDisabledRender] = useState(config?.skeletonPreview);
const toggleRender = () => {
setConfig({ skeletonPreview: disabledRender });
};
useLayoutEffect(() => {
// need useLayoutEffect to make sure that the state is corect before the first render of the skeleton
setDisabledRender(!config?.skeletonPreview);
}, [config]);
return (
<div
className={classNames(
'transition-opacity duration-500 delay-500 h-full relative',
{
'opacity-0': !open,
'opacity-100': open,
}
)}
>
<SkeletonVisualizerWidget
disabled={disabledRender}
toggleDisabled={() => toggleRender()}
onInit={(context) => {
context.addView({
left: 0,
bottom: 0,
width: 1,
height: 1,
position: new Vector3(3, 2.5, -3),
onHeightChange(v, newHeight) {
v.controls.target.set(0, newHeight / 2.2, 0.1);
const scale = Math.max(1, newHeight) / 1.3;
v.camera.zoom = 1 / scale;
},
});
}}
/>
<Tooltip
preferedDirection="bottom"
content={<Typography id="preview-disable_render" />}
>
<div
className="flex justify-center items-center w-10 h-10 cursor-pointer rounded-full fill-background-10 absolute right-2 top-2 bg-background-60 hover:bg-background-50"
onClick={() => toggleRender()}
>
<EyeIcon width={18} closed={!disabledRender} />
</div>
</Tooltip>
<PreviewControls open={open} />
</div>
);
}
export function Sidebar() {
const { completion } = useTrackingChecklist();
const [closed, setClosed] = useState(true);
const [closing, setClosing] = useState(false);
const closedHight = '90px';
const checklistSize = closed ? closedHight : 'calc(100% - 16px)';
const previewSize = closed ? `calc(100% - ${closedHight} - 24px)` : '0%';
const toggleClosed = () => setClosed((closed) => !closed);
useLayoutEffect(() => {
setClosing(true);
const ref = setTimeout(() => setClosing(false), 1000);
return () => {
clearTimeout(ref);
setClosing(false);
};
}, [closed]);
useEffect(() => {
if (completion === 'complete') {
setClosed(true);
} else if (completion === 'incomplete') {
setClosed(false);
}
}, [completion]);
return (
<>
<div
className="transition-[height] duration-500 rounded-lg my-2 bg-background-70 overflow-clip"
style={{ height: checklistSize }}
>
<TrackingChecklist
closed={closed}
closing={closing}
toggleClosed={toggleClosed}
/>
</div>
<div
className="transition-[height] duration-500 rounded-lg my-2 bg-background-70 overflow-clip"
style={{ height: previewSize }}
>
<PreviewSection open={closed} />
</div>
</>
);
}

View File

@@ -0,0 +1,202 @@
import { Typography } from './commons/Typography';
import classNames from 'classnames';
import { ResetType } from 'solarxr-protocol';
import {
BODY_PARTS_GROUPS,
MountingResetGroup,
ResetBtnStatus,
useReset,
UseResetOptions,
} from '@/hooks/reset';
import { Tooltip } from './commons/Tooltip';
import { useAtomValue } from 'jotai';
import { assignedTrackersAtom } from '@/store/app-store';
import { useBreakpoint } from '@/hooks/breakpoint';
import { useMemo } from 'react';
import { ResetButtonIcon } from './home/ResetButton';
const MAINBUTTON_CLASSES = ({ disabled }: { disabled: boolean }) =>
classNames(
'relative overflow-clip',
'flex h-full items-center justify-center gap-2 px-4 bg-background-60 rounded-lg fill-background-10 aspect-square md:aspect-auto',
{
'cursor-pointer hover:bg-background-50 bg-background-60': !disabled,
'cursor-not-allowed bg-background-70 brightness-75': disabled,
}
);
function ButtonProgress({
progress,
status,
}: {
progress: number;
status: ResetBtnStatus;
}) {
return (
<div
className={classNames(
'absolute top-0 left-0 w-0 h-full bg-accent-background-20 opacity-50'
)}
style={{
width: `${progress * 100}%`,
transition:
status === 'counting'
? 'width 0.3s cubic-bezier(0.68, -0.8, 0.32, 1.8)'
: 'width 1s linear',
}}
/>
);
}
function BasicResetButton(options: UseResetOptions & { customName?: string }) {
const { isMd } = useBreakpoint('md');
const {
triggerReset,
status,
name: resetName,
timer,
progress: resetProress,
disabled,
duration,
} = useReset(options);
const progress = status === 'counting' ? resetProress / duration : 0;
const name = options.customName || resetName;
const skiReset =
options.type === ResetType.Mounting && options.group === 'default';
return (
<Tooltip
disabled={isMd}
content={<Typography textAlign="text-center" id={name} />}
preferedDirection="top"
>
<div
className={classNames(
MAINBUTTON_CLASSES({ disabled }),
'rounded-lg',
'absolute'
)}
style={{
animationIterationCount: 1,
}}
onClick={() => !disabled && triggerReset()}
>
<div
className={classNames({
'animate-spin-ccw': !skiReset && status === 'finished',
'animate-skiing': skiReset && status === 'finished',
'opacity-0': status === 'counting',
})}
style={{
animationIterationCount: 1,
}}
>
<ResetButtonIcon {...options} />
</div>
<div
className={classNames('hidden md:block relative', {
'opacity-0': status === 'counting',
})}
>
<Typography
variant="section-title"
textAlign="text-center"
id={name}
/>
</div>
<ButtonProgress progress={progress} status={status} />
<div
className={classNames(
{
'opacity-0': status !== 'counting',
'animate-timer-tick': status === 'counting',
},
'absolute top-0 h-full flex items-center justify-center'
)}
>
<Typography variant="main-title" textAlign="text-center">
{timer}
</Typography>
</div>
</div>
</Tooltip>
);
}
export function Toolbar() {
const assignedTrackers = useAtomValue(assignedTrackersAtom);
const { visibleGroups, groupVisibility } = useMemo(() => {
const groupVisibility = Object.keys(BODY_PARTS_GROUPS)
.filter((k) => ['fingers'].includes(k))
.reduce(
(curr, key) => {
const group = key as MountingResetGroup;
curr[group] = assignedTrackers.some(
({ tracker }) =>
tracker.info?.bodyPart &&
BODY_PARTS_GROUPS[group].includes(tracker.info?.bodyPart)
);
return curr;
},
{} as Record<MountingResetGroup, boolean>
);
return {
groupVisibility,
visibleGroups: Object.values(groupVisibility).filter((v) => v).length,
};
}, [assignedTrackers]);
return (
<>
<div className="flex mobile:py-2 flex-col items-center bg-background-70 rounded-t-lg h-[var(--toolbar-h)] mr-2 xs:mt-2 mobile:mr-0">
<div className="px-3 py-3 w-full flex gap-4 justify-center md:justify-start">
<div className="flex-col flex gap-1 md:w-[60%]">
<Typography variant="section-title" id="toolbar-drift_reset" />
<div className="gap-2 md:h-[72px] h-[62px] w-full grid-cols-2 grid">
<BasicResetButton type={ResetType.Full} />
<BasicResetButton type={ResetType.Yaw} />
</div>
</div>
<div className="flex-col flex gap-1 md:flex-grow">
<Typography
variant="section-title"
id="toolbar-mounting_calibration"
/>
<div
className="gap-2 md:h-[72px] h-[62px] w-full md:grid flex"
style={{
gridTemplateColumns: `repeat(calc(2 + ${visibleGroups}), 1fr)`,
}}
>
<BasicResetButton
type={ResetType.Mounting}
group={'default'}
customName="toolbar-mounting_calibration-default"
/>
<BasicResetButton
type={ResetType.Mounting}
group={'feet'}
customName="toolbar-mounting_calibration-feet"
/>
{groupVisibility['fingers'] && (
<BasicResetButton
type={ResetType.Mounting}
group={'fingers'}
customName="toolbar-mounting_calibration-fingers"
/>
)}
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,86 +0,0 @@
import { Localized, useLocalization } from '@fluent/react';
import { BVHButton } from './BVHButton';
import { TrackingPauseButton } from './TrackingPauseButton';
import { ResetButton } from './home/ResetButton';
import { OverlayWidget } from './widgets/OverlayWidget';
import { TipBox } from './commons/TipBox';
import { DeveloperModeWidget } from './widgets/DeveloperModeWidget';
import { useConfig } from '@/hooks/config';
import { ResetType, StatusData } from 'solarxr-protocol';
import { useMemo } from 'react';
import { parseStatusToLocale, useStatusContext } from '@/hooks/status-system';
import { ClearMountingButton } from './ClearMountingButton';
import { ToggleableSkeletonVisualizerWidget } from './widgets/SkeletonVisualizerWidget';
import { useAtomValue } from 'jotai';
import { flatTrackersAtom } from '@/store/app-store';
import { A } from './commons/A';
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}
elems={{
PublicFixLink: (
<A href="https://docs.slimevr.dev/common-issues.html#network-profile-is-currently-set-to-public" />
),
}}
>
<TipBox whitespace={false} hideIcon>
{`Warning, you should fix ${StatusData[status.dataType]}`}
</TipBox>
</Localized>
))}
</div>
);
}
export function WidgetsComponent() {
const { config } = useConfig();
return (
<>
<div className="grid grid-cols-2 gap-2 w-full [&>*:nth-child(odd):last-of-type]:col-span-full">
<ResetButton type={ResetType.Yaw} size="big" />
<ResetButton type={ResetType.Full} size="big" />
<ResetButton type={ResetType.Mounting} size="big" />
<ResetButton
type={ResetType.Mounting}
size="big"
bodyPartsToReset="feet"
/>
<ResetButton
type={ResetType.Mounting}
size="big"
bodyPartsToReset="fingers"
/>
<ClearMountingButton />
{!window.__ANDROID__?.isThere() && <BVHButton />}
<TrackingPauseButton />
</div>
<div className="w-full">
<OverlayWidget />
</div>
<div className="mb-2">
<ToggleableSkeletonVisualizerWidget height={400} />
</div>
<UnprioritizedStatuses />
{config?.debug && (
<div className="w-full">
<DeveloperModeWidget />
</div>
)}
</>
);
}

View File

@@ -1,14 +1,23 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import { open } from '@tauri-apps/plugin-shell';
import classNames from 'classnames';
import { ReactNode } from 'react';
export function A({ href, children }: { href?: string; children?: ReactNode }) {
export function A({
href,
children,
className,
}: {
href?: string;
children?: ReactNode;
className?: string;
}) {
return (
<a
href="javascript:void(0)"
onClick={() =>
href && openUrl(href).catch(() => window.open(href, '_blank'))
href && open(href).catch(() => window.open(href, '_blank'))
}
className="underline"
className={classNames(className, 'underline', 'cursor-pointer')}
>
{children}
</a>

View File

@@ -2,6 +2,7 @@ import classNames from 'classnames';
import React, { ReactNode, useMemo } from 'react';
import { NavLink } from 'react-router-dom';
import { LoaderIcon, SlimeState } from './icon/LoaderIcon';
import { Localized, LocalizedProps } from '@fluent/react';
function ButtonContent({
loading,
@@ -17,11 +18,11 @@ function ButtonContent({
<div
className={classNames(
{ 'opacity-0': loading },
'flex flex-row gap-2 justify-center'
'flex flex-row gap-2 justify-center items-center'
)}
>
{icon && (
<div className="flex justify-center items-center fill-background-10 w-5 h-5">
<div className="flex justify-center items-center fill-background-10 w-5">
{icon}
</div>
)}
@@ -44,7 +45,9 @@ export type ButtonProps = {
loading?: boolean;
rounded?: boolean;
state?: any;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
id?: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement> &
Omit<LocalizedProps, 'id'>;
export function Button({
children,
@@ -55,6 +58,10 @@ export function Button({
state = {},
icon,
rounded = false,
attrs,
id,
vars,
elems,
...props
}: ButtonProps) {
const classes = useMemo(() => {
@@ -95,7 +102,7 @@ export function Button({
);
}, [variant, disabled, rounded, props.className]);
return to ? (
const content = to ? (
<NavLink
to={to}
className={classes}
@@ -103,14 +110,26 @@ export function Button({
onClick={(ev) => disabled && ev.preventDefault()}
>
<ButtonContent icon={icon} loading={loading}>
{children}
{id && (
<Localized attrs={attrs} vars={vars} elems={elems} id={id}>
{children}
</Localized>
)}
{!id && children}
</ButtonContent>
</NavLink>
) : (
<button type="button" {...props} className={classes} disabled={disabled}>
<ButtonContent icon={icon} loading={loading}>
{children}
{id && (
<Localized attrs={attrs} vars={vars} elems={elems} id={id}>
{children}
</Localized>
)}
{!id && children}
</ButtonContent>
</button>
);
return content;
}

View File

@@ -3,7 +3,7 @@ import { forwardRef, useMemo } from 'react';
import { Control, Controller } from 'react-hook-form';
export const CHECKBOX_CLASSES = classNames(
'bg-background-50 border-background-50 rounded-md w-5 h-5 text-accent-background-30 focus:border-accent-background-40 focus:ring-transparent focus:ring-offset-transparent focus:outline-transparent'
'bg-background-50 border-background-50 cursor-pointer rounded-md w-5 h-5 text-accent-background-30 focus:border-accent-background-40 focus:ring-transparent focus:ring-offset-transparent focus:outline-transparent'
);
export const CheckboxInternal = forwardRef<
@@ -34,7 +34,9 @@ export const CheckboxInternal = forwardRef<
const classes = useMemo(() => {
const vriantsMap = {
checkbox: {
checkbox: CHECKBOX_CLASSES,
checkbox: classNames(CHECKBOX_CLASSES, {
'brightness-50 hover:cursor-not-allowed': disabled,
}),
toggle: '',
pin: '',
},
@@ -47,7 +49,7 @@ export const CheckboxInternal = forwardRef<
},
};
return vriantsMap[variant];
}, [variant]);
}, [variant, disabled]);
return (
<div

View File

@@ -0,0 +1,86 @@
import {
DeviceDataT,
TrackerDataT,
TrackerStatus as TrackerStatusEnum,
} from 'solarxr-protocol';
import { Typography } from './Typography';
import classNames from 'classnames';
import { DownloadIcon } from './icon/DownloadIcon';
import { Link } from 'react-router-dom';
import { useAppContext } from '@/hooks/app';
import { Tooltip } from './Tooltip';
import { Localized } from '@fluent/react';
import { checkForUpdate } from '@/hooks/firmware-update';
function UpdateIcon({
showUpdate,
}: {
showUpdate:
| 'can-update'
| 'low-battery'
| 'updated'
| 'unavailable'
| 'blocked';
}) {
const content = (
<div className="relative">
<div
className={classNames(
'absolute rounded-full h-6 w-6 left-1 top-1 bg-accent-background-10 animate-[ping_2s_linear_3]',
showUpdate !== 'can-update' && 'hidden'
)}
/>
<div
className={classNames(
'absolute rounded-full h-8 w-8 justify-center flex items-center',
showUpdate === 'low-battery'
? 'cursor-not-allowed bg-background-80 outline-2 outline-status-critical outline'
: 'hover:bg-background-40 hover:cursor-pointer bg-background-50'
)}
>
<DownloadIcon width={15} />
</div>
</div>
);
return showUpdate !== 'can-update' ? (
<Tooltip
preferedDirection="top"
content={
<Localized id={'tracker-settings-update-low-battery'}>
<Typography />
</Localized>
}
>
<div className="absolute right-5 -top-2.5">{content}</div>
</Tooltip>
) : (
<Link to="/firmware-update" className="absolute right-5 -top-2.5">
{content}
</Link>
);
}
export function FirmwareIcon({
tracker,
device,
}: {
tracker: TrackerDataT;
device?: DeviceDataT;
}) {
const { currentFirmwareRelease } = useAppContext();
const showUpdate =
tracker.status !== TrackerStatusEnum.DISCONNECTED &&
currentFirmwareRelease &&
device &&
checkForUpdate(currentFirmwareRelease, device);
return (
<div>
{showUpdate &&
showUpdate !== 'unavailable' &&
showUpdate !== 'updated' && <UpdateIcon showUpdate={'can-update'} />}
</div>
);
}

View File

@@ -53,8 +53,9 @@ export function Bar({
}) {
const value = useMemo(
() => Math.min(Math.max((progress * parts) / 1 - index, 0), 1),
[index, progress]
[index, progress, parts]
);
return (
<div
className={classNames(

View File

@@ -8,17 +8,23 @@ import {
useLayoutEffect,
MutableRefObject,
useMemo,
createElement,
} from 'react';
import { createPortal } from 'react-dom';
import { Typography } from './Typography';
import { CloseIcon } from './icon/CloseIcon';
type Direction = 'top' | 'left' | 'right' | 'bottom';
interface TooltipProps {
content: ReactNode;
children: ReactElement;
preferedDirection: 'top' | 'left' | 'right' | 'bottom';
preferedDirection: Direction;
blockedDirections?: Direction[];
mode?: 'corner' | 'center';
variant?: 'auto' | 'drawer' | 'floating';
disabled?: boolean;
tag?: string;
spacing?: number;
}
interface TooltipPos {
@@ -77,12 +83,12 @@ const clamp = (v: number, min: number, max: number) =>
const getFloatingTooltipPosition = (
preferedDirection: TooltipProps['preferedDirection'],
blockedDirections: Direction[],
mode: TooltipProps['mode'],
childrenRect: DOMRect,
tooltipRect: DOMRect
tooltipRect: DOMRect,
spacing: number
) => {
const spacing = 10;
const getPosition = (
direction: TooltipProps['preferedDirection']
): TooltipPos => {
@@ -135,9 +141,10 @@ const getFloatingTooltipPosition = (
const pos = getPosition(preferedDirection);
if (isNotInside({ ...pos, height: tooltipRect.height }, windowRect)) {
const [firstPos] = ['left', 'top', 'right', 'bottom']
.filter((dir) => !blockedDirections.includes(dir as Direction))
.map((dir) => ({
dir,
area: getPosition(dir as TooltipProps['preferedDirection']),
area: getPosition(dir as Direction),
}))
.toSorted(
(a, b) =>
@@ -226,12 +233,17 @@ const getFloatingTooltipPosition = (
export function FloatingTooltip({
childRef,
preferedDirection,
blockedDirections = [],
mode,
children,
spacing,
}: {
childRef: MutableRefObject<HTMLDivElement | null>;
childRef: MutableRefObject<HTMLElement | null>;
children: ReactNode;
} & Pick<TooltipProps, 'mode' | 'preferedDirection'>) {
} & Pick<
TooltipProps,
'mode' | 'preferedDirection' | 'blockedDirections' | 'spacing'
>) {
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [tooltipStyle, setTooltipStyle] = useState<TooltipPos | undefined>();
@@ -245,9 +257,11 @@ export function FloatingTooltip({
setTooltipStyle(
getFloatingTooltipPosition(
preferedDirection,
blockedDirections,
mode,
childrenRect,
tooltipRect
tooltipRect,
spacing ?? 20
)
);
};
@@ -314,7 +328,7 @@ export function DrawerTooltip({
childRef,
}: {
children: ReactNode;
childRef: MutableRefObject<HTMLDivElement | null>;
childRef: MutableRefObject<HTMLElement | null>;
}) {
const touchTimestamp = useRef<number>(0);
const touchTimeout = useRef<number>(0);
@@ -377,17 +391,12 @@ export function DrawerTooltip({
if (childRef.current && childRef.current.children[0]) {
const elem = childRef.current.children[0] as HTMLElement;
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);
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);
@@ -396,6 +405,7 @@ export function DrawerTooltip({
};
}
}, []);
// FIXME: Completely broken not sure why. Will be solved when tooltips on mobile actually work
return (
<>
@@ -444,33 +454,53 @@ export function Tooltip({
content,
children,
preferedDirection,
blockedDirections = [],
mode = 'center',
variant = 'auto',
disabled = false,
tag = 'div',
spacing = 20,
}: TooltipProps) {
const childRef = useRef<HTMLDivElement | null>(null);
const childRef = useRef<HTMLElement | null>(null);
const { isMobile } = useBreakpoint('mobile');
const portal = createPortal(
isMobile ? (
let portal = null;
if (variant === 'auto') {
portal = isMobile ? (
<DrawerTooltip childRef={childRef}>{content}</DrawerTooltip>
) : (
<FloatingTooltip
preferedDirection={preferedDirection}
blockedDirections={blockedDirections}
mode={mode}
childRef={childRef}
spacing={spacing}
>
{content}
</FloatingTooltip>
),
document.body
);
);
}
if (variant === 'drawer')
portal = <DrawerTooltip childRef={childRef}>{content}</DrawerTooltip>;
if (variant === 'floating')
portal = (
<FloatingTooltip
blockedDirections={blockedDirections}
preferedDirection={preferedDirection}
mode={mode}
childRef={childRef}
spacing={spacing}
>
{content}
</FloatingTooltip>
);
return (
<>
<div className="contents" ref={childRef}>
{children}
</div>
{!disabled && portal}
{createElement(tag, { className: 'contents', ref: childRef }, children)}
{!disabled && createPortal(portal, document.body)}
</>
);
}

View File

@@ -1,4 +1,5 @@
import { useConfig } from '@/hooks/config';
import { Localized, LocalizedProps } from '@fluent/react';
import classNames from 'classnames';
import { createElement, ReactNode, useMemo } from 'react';
@@ -12,6 +13,10 @@ export function Typography({
truncate = false,
textAlign,
sentryMask = false,
id,
attrs,
elems,
vars,
}: {
variant?:
| 'main-title'
@@ -39,7 +44,8 @@ export function Typography({
| 'text-end';
children?: ReactNode;
sentryMask?: boolean;
}) {
id?: string;
} & Omit<LocalizedProps, 'id'>) {
const tag = useMemo(() => {
const tags = {
'main-title': 'h1',
@@ -52,7 +58,7 @@ export function Typography({
}, [variant]);
const { config } = useConfig();
return createElement(
const element = createElement(
tag,
{
className: classNames([
@@ -71,12 +77,22 @@ export function Typography({
whitespace,
textAlign,
italic && 'italic',
truncate && 'leading-3 text-ellipsis',
truncate && 'leading-[1.2rem] text-ellipsis',
truncate && (config?.textSize ?? 12) > 12 && 'line-clamp-1',
truncate && (config?.textSize ?? 12) <= 12 && 'line-clamp-2',
sentryMask && 'sentry-mask',
]),
},
children || []
children || id || []
);
if (id) {
return (
<Localized id={id} attrs={attrs} elems={elems} vars={vars}>
{element}
</Localized>
);
}
return element;
}

View File

@@ -0,0 +1,12 @@
export function Checklist({ size = 24 }: { size?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
viewBox="0 -960 960 960"
width={size}
>
<path d="M222-200 80-342l56-56 85 85 170-170 56 57-225 226Zm0-320L80-662l56-56 85 85 170-170 56 57-225 226Zm298 240v-80h360v80H520Zm0-320v-80h360v80H520Z" />
</svg>
);
}

View File

@@ -0,0 +1,12 @@
export function ClearIcon({ size = 24 }: { size?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
width={size}
viewBox="0 -960 960 960"
>
<path d="M440-520h80v-280q0-17-11.5-28.5T480-840q-17 0-28.5 11.5T440-800v280ZM200-360h560v-80H200v80Zm-58 240h98v-80q0-17 11.5-28.5T280-240q17 0 28.5 11.5T320-200v80h120v-80q0-17 11.5-28.5T480-240q17 0 28.5 11.5T520-200v80h120v-80q0-17 11.5-28.5T680-240q17 0 28.5 11.5T720-200v80h98l-40-160H182l-40 160Zm676 80H142q-39 0-63-31t-14-69l55-220v-80q0-33 23.5-56.5T200-520h160v-280q0-50 35-85t85-35q50 0 85 35t35 85v280h160q33 0 56.5 23.5T840-440v80l55 220q13 38-11.5 69T818-40Zm-58-400H200h560Zm-240-80h-80 80Z" />
</svg>
);
}

View File

@@ -1,8 +1,8 @@
export function GearIcon() {
export function GearIcon({ size = 20 }: { size?: number }) {
return (
<svg
width="20"
height="20"
width={size}
height={size}
viewBox="0 0 14 13"
xmlns="http://www.w3.org/2000/svg"
>

View File

@@ -0,0 +1,12 @@
export function HomeIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
>
<path d="M160-120v-480l320-240 320 240v480H560v-280H400v280H160Z" />
</svg>
);
}

View File

@@ -0,0 +1,12 @@
export function LayoutIcon({ size = 16 }: { size?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 16 16"
>
<path d="M1 2a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2Zm8 9a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-3ZM1 8a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V8Zm8-6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V2Z" />
</svg>
);
}

View File

@@ -0,0 +1,12 @@
export function SkiIcon({ size = 24 }: { size?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
width={size}
viewBox="0 -960 960 960"
>
<path d="M740-40q-26 0-50.5-4T642-56L80-261l20-57 276 101 69-178-143-149q-27-28-21.5-66.5T320-669l139-80q17-10 34.5-11.5T528-755q17 6 29.5 19t18.5 31l13 43q13 43 42.5 76t70.5 50l21-64 57 18-45 138q-74-12-131-58t-84-114l-101 58 121 138-89 230 124 45 84-257q14 5 28 9t29 7l-85 262 31 11q18 6 37.5 9.5T740-100q26 0 49.5-5t45.5-15l45 45q-32 17-67 26t-73 9Zm-80-660q-33 0-56.5-23.5T580-780q0-33 23.5-56.5T660-860q33 0 56.5 23.5T740-780q0 33-23.5 56.5T660-700Z" />
</svg>
);
}

View File

@@ -1,31 +1,28 @@
import { Localized, useLocalization } from '@fluent/react';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import { StatusData, TrackerDataT } from 'solarxr-protocol';
import { useLocalization } from '@fluent/react';
import { NavLink, useNavigate } from 'react-router-dom';
import { TrackerDataT } from 'solarxr-protocol';
import { useConfig } from '@/hooks/config';
import { Typography } from '@/components/commons/Typography';
import { TrackerCard } from '@/components/tracker/TrackerCard';
import { TrackersTable } from '@/components/tracker/TrackersTable';
import {
parseStatusToLocale,
trackerStatusRelated,
useStatusContext,
} from '@/hooks/status-system';
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];
import {
assignedTrackersAtom,
unassignedTrackersAtom,
} from '@/store/app-store';
import { useTrackingChecklist } from '@/hooks/tracking-checklist';
import { Checklist } from '@/components/commons/icon/ChecklistIcon';
import { useState } from 'react';
import { HomeSettingsModal } from './HomeSettingsModal';
import { LayoutIcon } from '@/components/commons/icon/LayoutIcon';
export function Home() {
const { l10n } = useLocalization();
const { config } = useConfig();
const trackers = useAtomValue(flatTrackersAtom);
const { statuses } = useStatusContext();
const { invalidConfig } = useVRCConfig();
const trackers = useAtomValue(assignedTrackersAtom);
const unassignedTrackers = useAtomValue(unassignedTrackersAtom);
const { highlightedTrackers } = useTrackingChecklist();
const navigate = useNavigate();
const sendToSettings = (tracker: TrackerDataT) => {
@@ -34,95 +31,121 @@ export function Home() {
);
};
const filteredStatuses = useMemo(() => {
const dontRepeat = new Map(DONT_REPEAT_STATUSES.map((x) => [x, false]));
return Object.entries(statuses).filter(([, value]) => {
if (dontRepeat.get(value.dataType)) return false;
if (dontRepeat.has(value.dataType)) dontRepeat.set(value.dataType, true);
return true;
});
}, [statuses]);
const settingsOpenState = useState(false);
const [, setSettingsOpen] = settingsOpenState;
return (
<div className="relative h-full">
<HomeSettingsModal open={settingsOpenState} />
<NavLink
to="/vr-mode"
className="xs:hidden absolute z-50 h-12 w-12 rounded-full bg-accent-background-30 bottom-3 right-3 flex justify-center items-center fill-background-10"
>
<HeadsetIcon />
</NavLink>
<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)
.map(([, status]) => (
<Localized
key={status.id}
id={`status_system-${StatusData[status.dataType]}`}
vars={parseStatusToLocale(status, trackers, l10n)}
>
<WarningBox whitespace={false}>
{`Warning, you should fix ${StatusData[status.dataType]}`}
</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'} />
</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'} />
</div>
</Link>
</div>
</div>
</WarningBox>
)}
<NavLink
to="/checklist"
className="xs:hidden absolute z-50 h-12 w-12 rounded-full bg-accent-background-30 bottom-[70px] right-3 flex justify-center items-center fill-background-10"
>
<Checklist />
</NavLink>
<div className="overflow-y-auto flex flex-col gap-3">
<div className="flex w-full gap-2 items-center px-4 h-5">
<Typography
color="secondary"
id="toolbar-assigned_trackers"
vars={{ count: trackers.length }}
/>
<div className="bg-background-50 h-[2px] rounded-lg flex-grow" />
<div
className="fill-background-30 hover:fill-background-20 cursor-pointer"
onClick={() => setSettingsOpen(true)}
>
<LayoutIcon size={18} />
</div>
</div>
<div className="overflow-y-auto flex flex-col gap-3">
{trackers.length === 0 && (
<div className="flex px-5 pt-5 justify-center">
<Typography variant="standard">
{l10n.getString('home-no_trackers')}
</Typography>
</div>
)}
{trackers.length === 0 && (
<div className="flex px-5 pt-5 justify-center">
<Typography variant="standard">
{l10n.getString('home-no_trackers')}
</Typography>
</div>
)}
{!config?.debug && trackers.length > 0 && (
<div className="grid sm:grid-cols-1 md:grid-cols-2 gap-4 px-5 my-5">
{trackers.map(({ tracker, device }, index) => (
<TrackerCard
key={index}
tracker={tracker}
device={device}
onClick={() => sendToSettings(tracker)}
smol
showUpdates
interactable
warning={Object.values(statuses).some((status) =>
trackerStatusRelated(tracker, status)
)}
/>
))}
</div>
)}
{config?.debug && trackers.length > 0 && (
<div className="px-2 pt-5 overflow-y-scroll overflow-x-auto">
<TrackersTable
flatTrackers={trackers}
clickedTracker={(tracker) => sendToSettings(tracker)}
{config?.homeLayout == 'default' && trackers.length > 0 && (
<div className="grid sm:grid-cols-1 md:grid-cols-2 gap-4 px-5 my-5">
{trackers.map(({ tracker, device }, index) => (
<TrackerCard
key={index}
tracker={tracker}
device={device}
onClick={() => sendToSettings(tracker)}
smol
showUpdates
interactable
warning={
!!highlightedTrackers?.trackers.find(
(t) =>
t?.deviceId?.id === tracker.trackerId?.deviceId?.id &&
t?.trackerNum === tracker.trackerId?.trackerNum
) && highlightedTrackers.step
}
/>
))}
</div>
)}
{config?.homeLayout === 'table' && trackers.length > 0 && (
<div className="mx-2 overflow-x-auto">
<TrackersTable
flatTrackers={trackers}
clickedTracker={(tracker) => sendToSettings(tracker)}
/>
</div>
)}
{unassignedTrackers.length > 0 && (
<>
<div className="flex w-full gap-2 items-center px-4 h-5">
<Typography
color="secondary"
id="toolbar-unassigned_trackers"
vars={{ count: unassignedTrackers.length }}
/>
<div className="bg-background-50 h-[2px] rounded-lg flex-grow" />
</div>
)}
</div>
{config?.homeLayout == 'default' && (
<div className="grid sm:grid-cols-1 md:grid-cols-2 gap-4 px-5 my-3">
{unassignedTrackers.map(({ tracker, device }, index) => (
<TrackerCard
key={index}
tracker={tracker}
device={device}
onClick={() => sendToSettings(tracker)}
smol
showUpdates
interactable
warning={
!!highlightedTrackers?.trackers.find(
(t) =>
t?.deviceId?.id === tracker.trackerId?.deviceId?.id &&
t?.trackerNum === tracker.trackerId?.trackerNum
) && highlightedTrackers.step
}
/>
))}
</div>
)}
{config?.homeLayout === 'table' && (
<div className="mx-2 overflow-x-auto">
<TrackersTable
flatTrackers={unassignedTrackers}
clickedTracker={(tracker) => sendToSettings(tracker)}
/>
</div>
)}
</>
)}
</div>
</div>
);

View File

@@ -0,0 +1,34 @@
import { Dispatch, SetStateAction } from 'react';
import { BaseModal } from '@/components/commons/BaseModal';
import { Typography } from '@/components/commons/Typography';
import { Button } from '@/components/commons/Button';
import { HomeLayoutSettings } from '@/components/settings/pages/HomeScreenSettings';
export function HomeSettingsModal({
open,
}: {
open: [boolean, Dispatch<SetStateAction<boolean>>];
}) {
return (
<BaseModal
isOpen={open[0]}
appendClasses={'max-w-xl w-full'}
closeable
onRequestClose={() => {
open[1](false);
}}
>
<div className="flex flex-col gap-4">
<Typography variant="main-title" id="home-settings" />
<HomeLayoutSettings />
<div className="flex justify-end">
<Button
variant="tertiary"
onClick={() => open[1](false)}
id="home-settings-close"
/>
</div>
</div>
</BaseModal>
);
}

View File

@@ -1,219 +1,67 @@
import { useLocalization } from '@fluent/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
BodyPart,
ResetRequestT,
ResetType,
RpcMessage,
StatusData,
} from 'solarxr-protocol';
import { useConfig } from '@/hooks/config';
import { useCountdown } from '@/hooks/countdown';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import {
playSoundOnResetEnded,
playSoundOnResetStarted,
} from '@/sounds/sounds';
import { BigButton } from '@/components/commons/BigButton';
import { Localized } from '@fluent/react';
import { ResetType } from 'solarxr-protocol';
import { Button } from '@/components/commons/Button';
import {
MountingResetIcon,
YawResetIcon,
FullResetIcon,
} from '@/components/commons/icon/ResetIcon';
import { useStatusContext } from '@/hooks/status-system';
import classNames from 'classnames';
import { useReset, UseResetOptions } from '@/hooks/reset';
import {
FullResetIcon,
YawResetIcon,
} from '@/components/commons/icon/ResetIcon';
import { ReactNode } from 'react';
import { SkiIcon } from '@/components/commons/icon/SkiIcon';
import { FootIcon } from '@/components/commons/icon/FootIcon';
import { FingersIcon } from '@/components/commons/icon/FingersIcon';
export function ResetButtonIcon(options: UseResetOptions) {
if (options.type === ResetType.Mounting && !options.group)
options.group = 'default';
if (options.type === ResetType.Yaw) return <YawResetIcon width={18} />;
if (options.type === ResetType.Full) return <FullResetIcon width={18} />;
if (options.type === ResetType.Mounting) {
if (options.group === 'default') return <SkiIcon />;
if (options.group === 'feet') return <FootIcon />;
if (options.group === 'fingers') return <FingersIcon width={16} />;
}
}
export function ResetButton({
type,
size = 'big',
bodyPartsToReset = 'default',
className,
onReseted,
children,
...options
}: {
className?: string;
type: ResetType;
size: 'big' | 'small';
bodyPartsToReset?: 'default' | 'feet' | 'fingers';
children?: ReactNode;
onReseted?: () => void;
}) {
const { l10n } = useLocalization();
const { sendRPCPacket } = useWebsocketAPI();
const { statuses } = useStatusContext();
const { config } = useConfig();
const finishedTimeoutRef = useRef(-1);
const [isFinished, setFinished] = useState(false);
const needsFullReset = useMemo(
() =>
type === ResetType.Mounting &&
Object.values(statuses).some(
(status) => status.dataType === StatusData.StatusTrackerReset
),
[statuses]
} & UseResetOptions) {
const { triggerReset, status, timer, disabled, name } = useReset(
options,
onReseted
);
const feetBodyParts = [BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT];
const fingerBodyParts = [
BodyPart.LEFT_THUMB_METACARPAL,
BodyPart.LEFT_THUMB_PROXIMAL,
BodyPart.LEFT_THUMB_DISTAL,
BodyPart.LEFT_INDEX_PROXIMAL,
BodyPart.LEFT_INDEX_INTERMEDIATE,
BodyPart.LEFT_INDEX_DISTAL,
BodyPart.LEFT_MIDDLE_PROXIMAL,
BodyPart.LEFT_MIDDLE_INTERMEDIATE,
BodyPart.LEFT_MIDDLE_DISTAL,
BodyPart.LEFT_RING_PROXIMAL,
BodyPart.LEFT_RING_INTERMEDIATE,
BodyPart.LEFT_RING_DISTAL,
BodyPart.LEFT_LITTLE_PROXIMAL,
BodyPart.LEFT_LITTLE_INTERMEDIATE,
BodyPart.LEFT_LITTLE_DISTAL,
BodyPart.RIGHT_THUMB_METACARPAL,
BodyPart.RIGHT_THUMB_PROXIMAL,
BodyPart.RIGHT_THUMB_DISTAL,
BodyPart.RIGHT_INDEX_PROXIMAL,
BodyPart.RIGHT_INDEX_INTERMEDIATE,
BodyPart.RIGHT_INDEX_DISTAL,
BodyPart.RIGHT_MIDDLE_PROXIMAL,
BodyPart.RIGHT_MIDDLE_INTERMEDIATE,
BodyPart.RIGHT_MIDDLE_DISTAL,
BodyPart.RIGHT_RING_PROXIMAL,
BodyPart.RIGHT_RING_INTERMEDIATE,
BodyPart.RIGHT_RING_DISTAL,
BodyPart.RIGHT_LITTLE_PROXIMAL,
BodyPart.RIGHT_LITTLE_INTERMEDIATE,
BodyPart.RIGHT_LITTLE_DISTAL,
];
const reset = () => {
const req = new ResetRequestT();
req.resetType = type;
switch (bodyPartsToReset) {
case 'default':
// Server handles it. Usually all body parts except fingers.
req.bodyParts = [];
break;
case 'feet':
req.bodyParts = feetBodyParts;
break;
case 'fingers':
req.bodyParts = fingerBodyParts;
break;
}
sendRPCPacket(RpcMessage.ResetRequest, req);
};
const { isCounting, startCountdown, timer } = useCountdown({
duration: type === ResetType.Yaw ? 0 : undefined,
onCountdownEnd: () => {
maybePlaySoundOnResetEnd(type);
reset();
setFinished(true);
if (finishedTimeoutRef.current !== -1)
clearTimeout(finishedTimeoutRef.current);
finishedTimeoutRef.current = setTimeout(() => {
setFinished(false);
finishedTimeoutRef.current = -1;
}, 2000) as unknown as number;
if (onReseted) onReseted();
},
});
const text = useMemo(() => {
switch (type) {
case ResetType.Yaw:
return l10n.getString(
'reset-yaw' +
(bodyPartsToReset !== 'default' ? '-' + bodyPartsToReset : '')
);
case ResetType.Mounting:
return l10n.getString(
'reset-mounting' +
(bodyPartsToReset !== 'default' ? '-' + bodyPartsToReset : '')
);
case ResetType.Full:
return l10n.getString(
'reset-full' +
(bodyPartsToReset !== 'default' ? '-' + bodyPartsToReset : '')
);
}
}, [type, bodyPartsToReset]);
const getIcon = () => {
switch (type) {
case ResetType.Yaw:
return <YawResetIcon width={20} />;
case ResetType.Mounting:
switch (bodyPartsToReset) {
case 'default':
return <MountingResetIcon width={20} />;
case 'feet':
return <FootIcon width={30} />;
case 'fingers':
return <FingersIcon width={20} />;
}
}
return <FullResetIcon width={20} />;
};
const maybePlaySoundOnResetEnd = (type: ResetType) => {
if (!config?.feedbackSound) return;
playSoundOnResetEnded(type, config?.feedbackSoundVolume);
};
const maybePlaySoundOnResetStart = () => {
if (!config?.feedbackSound) return;
if (type !== ResetType.Yaw)
playSoundOnResetStarted(config?.feedbackSoundVolume);
};
const triggerReset = () => {
setFinished(false);
startCountdown();
maybePlaySoundOnResetStart();
};
useEffect(() => {
return () => {
if (finishedTimeoutRef.current !== -1)
clearTimeout(finishedTimeoutRef.current);
};
}, []);
return size === 'small' ? (
return (
<Button
icon={getIcon()}
icon={<ResetButtonIcon {...options} />}
onClick={triggerReset}
className={classNames(
'border-2',
isFinished
'border-2 py-[5px]',
status === 'finished'
? 'border-status-success'
: 'transition-[border-color] duration-500 ease-in-out border-transparent',
className
)}
variant="primary"
disabled={isCounting || needsFullReset}
disabled={disabled}
>
{!isCounting || type === ResetType.Yaw ? text : String(timer)}
<div className="flex flex-col">
<div className="opacity-0 h-0">
{children || <Localized id={name} />}
</div>
{status !== 'counting' || options.type === ResetType.Yaw
? children || <Localized id={name} />
: String(timer)}
</div>
</Button>
) : (
<BigButton
icon={getIcon()}
onClick={triggerReset}
className={classNames(
'border-2',
isFinished
? 'border-status-success'
: 'transition-[border-color] duration-500 ease-in-out border-transparent',
className
)}
disabled={isCounting || needsFullReset}
>
{!isCounting || type === ResetType.Yaw ? text : String(timer)}
</BigButton>
);
}

View File

@@ -34,8 +34,6 @@ export function OnboardingLayout({ children }: { children: ReactNode }) {
</div>
</div>
) : (
<MainLayout widgets={false} isMobile={isMobile}>
{children}
</MainLayout>
<MainLayout isMobile={isMobile}>{children}</MainLayout>
);
}

View File

@@ -12,9 +12,9 @@
grid-template:
's t' var(--connect-tracker-layout-top)
's c' calc(100% - var(--connect-tracker-layout-top))
/ calc(var(--connect-tracker-layout-sidebar)) calc(
100% - var(--connect-tracker-layout-sidebar)
);
/ calc(var(--connect-tracker-layout-sidebar)) calc(100% - var(
--connect-tracker-layout-sidebar
));
@screen mobile {
grid-template:

View File

@@ -5,8 +5,9 @@ import { useNavigate } from 'react-router-dom';
import {
RpcMessage,
StartWifiProvisioningRequestT,
StatusData,
StopWifiProvisioningRequestT,
TrackingChecklistPublicNetworksT,
TrackingChecklistStepId,
WifiProvisioningStatus,
WifiProvisioningStatusResponseT,
} from 'solarxr-protocol';
@@ -24,9 +25,9 @@ import './ConnectTracker.scss';
import { useAtomValue } from 'jotai';
import { connectedIMUTrackersAtom } from '@/store/app-store';
import { BaseModal } from '@/components/commons/BaseModal';
import { parseStatusToLocale, useStatusContext } from '@/hooks/status-system';
import { A } from '@/components/commons/A';
import { CONNECT_TRACKER } from '@/utils/tauri';
import { useTrackingChecklist } from '@/hooks/tracking-checklist';
const statusLabelMap = {
[WifiProvisioningStatus.NONE]:
@@ -74,9 +75,34 @@ const statusProgressMap = {
[WifiProvisioningStatus.COULD_NOT_FIND_SERVER]: 0.8,
};
export function InvalidNetworkProfileWarning({
extraData,
}: {
extraData: TrackingChecklistPublicNetworksT;
}) {
return (
<div className="pt-4">
<Localized
id="tracking_checklist-NETWORK_PROFILE_PUBLIC-desc"
elems={{
PublicFixLink: (
<A href="https://docs.slimevr.dev/common-issues.html#network-profile-is-currently-set-to-public" />
),
}}
vars={{
count: extraData.adapters.length,
adapters: extraData.adapters.join(', '),
}}
>
<WarningBox whitespace={false}>WARNING</WarningBox>
</Localized>
</div>
);
}
export function ConnectTrackersPage() {
const { l10n } = useLocalization();
const { statuses } = useStatusContext();
const { visibleSteps } = useTrackingChecklist();
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
const { applyProgress, state } = useOnboarding();
@@ -165,11 +191,13 @@ export function ConnectTrackersPage() {
[connectedIMUTrackers.length]
);
const filteredStatuses = useMemo(() => {
return Object.entries(statuses).filter(
([, value]) => value.dataType == StatusData.StatusPublicNetwork
const invalidNetworkProfile = useMemo(() => {
return visibleSteps.find(
(step) =>
step.id === TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC &&
!step.valid
);
}, [statuses]);
}, [visibleSteps]);
return (
<>
@@ -243,24 +271,13 @@ export function ConnectTrackersPage() {
>
<TipBox>Conditional tip</TipBox>
</Localized>
{filteredStatuses.map(([, status]) => (
<div className="pt-4">
<Localized
key={status.id}
id={`status_system-${StatusData[status.dataType]}`}
vars={parseStatusToLocale(status, connectedIMUTrackers, l10n)}
elems={{
PublicFixLink: (
<A href="https://docs.slimevr.dev/common-issues.html#network-profile-is-currently-set-to-public" />
),
}}
>
<WarningBox whitespace={false}>
{`Warning, you should fix ${StatusData[status.dataType]}`}
</WarningBox>
</Localized>
</div>
))}
{invalidNetworkProfile && (
<InvalidNetworkProfileWarning
extraData={
invalidNetworkProfile.extraData as TrackingChecklistPublicNetworksT
}
/>
)}
<div
className={classNames(
'rounded-xl h-24 flex gap-2 p-3 lg:w-full mt-4 relative',

View File

@@ -477,7 +477,6 @@ export function ManualProportionsPage() {
<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"
/>
</div>

View File

@@ -69,11 +69,7 @@ export function PreparationStep({
>
{l10n.getString('onboarding-automatic_mounting-prev_step')}
</Button>
<ResetButton
size="small"
type={ResetType.Full}
onReseted={nextStep}
/>
<ResetButton type={ResetType.Full} onReseted={nextStep} />
</div>
</div>
</div>

View File

@@ -58,8 +58,8 @@ export function MountingResetStep({
{l10n.getString('onboarding-automatic_mounting-prev_step')}
</Button>
<ResetButton
size="small"
type={ResetType.Mounting}
group="default"
onReseted={nextStep}
/>
</div>

View File

@@ -69,11 +69,7 @@ export function PreparationStep({
>
{l10n.getString('onboarding-automatic_mounting-prev_step')}
</Button>
<ResetButton
size="small"
type={ResetType.Full}
onReseted={nextStep}
/>
<ResetButton type={ResetType.Full} onReseted={nextStep} />
</div>
</div>
</div>

View File

@@ -53,7 +53,7 @@ export function PreparationStep({
<Button variant={'secondary'} onClick={prevStep} />
</Localized>
<ResetButton size="small" type={ResetType.Full} onReseted={nextStep} />
<ResetButton type={ResetType.Full} onReseted={nextStep} />
</div>
</div>
);

View File

@@ -54,8 +54,8 @@ export function VerifyMountingStep({
{l10n.getString('onboarding-automatic_mounting-prev_step')}
</Button>
<ResetButton
size="small"
type={ResetType.Mounting}
group="default"
onReseted={nextStep}
/>
</div>

View File

@@ -281,7 +281,7 @@ export function TrackersAssignPage() {
<div className="flex flex-col w-full overflow-y-auto px-4 xs:items-center">
<div className="flex mobile:flex-col md:gap-8 mobile:gap-4 mobile:pb-4">
<div className="flex flex-col xs:max-w-sm gap-3">
<Typography variant="main-title">
<Typography variant="main-title" color="accent">
{l10n.getString('onboarding-assign_trackers-title')}
</Typography>
<Typography>

View File

@@ -1,10 +0,0 @@
import { ReactNode } from 'react';
import { StatusSystemC, useProvideStatusContext } from '@/hooks/status-system';
export function StatusProvider({ children }: { children: ReactNode }) {
const context = useProvideStatusContext();
return (
<StatusSystemC.Provider value={context}>{children}</StatusSystemC.Provider>
);
}

View File

@@ -7,9 +7,9 @@
grid-template:
't t t' var(--topbar-h)
'n s c' calc(100% - var(--topbar-h))
/ var(--navbar-w) var(--settings-sidebar-w) calc(
100% - var(--navbar-w) - var(--settings-sidebar-w)
);
/ var(--navbar-w) var(--settings-sidebar-w) calc(100% - var(--navbar-w) - var(
--settings-sidebar-w
));
@screen lg {
--settings-sidebar-w: 270px;

View File

@@ -64,7 +64,7 @@ export function SettingsPagePaneLayout({
{...props}
>
<div className="flex mobile:absolute mobile:right-4">
<div className=" w-10 h-10 bg-accent-background-40 flex justify-center items-center rounded-full fill-background-10">
<div className="w-10 h-10 bg-accent-background-40 flex justify-center items-center rounded-full fill-background-10">
{icon}
</div>
</div>

View File

@@ -1,18 +1,17 @@
import classNames from 'classnames';
import { ReactNode, useMemo } from 'react';
import { useMemo } from 'react';
import { NavLink, useLocation, useMatch } from 'react-router-dom';
import { Typography } from '@/components/commons/Typography';
import { useLocalization } from '@fluent/react';
import { useVRCConfig } from '@/hooks/vrc-config';
export function SettingsLink({
to,
scrollTo,
children,
id,
}: {
id: string;
to: string;
scrollTo?: string;
children: ReactNode;
}) {
const { state } = useLocation();
const doesMatch = useMatch({
@@ -35,94 +34,118 @@ export function SettingsLink({
'bg-background-60': isActive,
})}
>
{children}
<Typography id={id} />
</NavLink>
);
}
export function SettingsSidebar() {
const { l10n } = useLocalization();
const { state: vrcConfigState } = useVRCConfig();
return (
<div className="flex flex-col px-5 py-5 gap-3 overflow-y-auto bg-background-70 rounded-lg h-full">
<Typography variant="main-title">
{l10n.getString('settings-sidebar-title')}
</Typography>
<Typography variant="main-title" id="settings-sidebar-title" />
<div className="flex flex-col gap-3">
<Typography variant="section-title">
{l10n.getString('settings-sidebar-general')}
</Typography>
<Typography variant="section-title" id="settings-sidebar-general" />
<div className="flex flex-col gap-2">
<SettingsLink to="/settings/trackers" scrollTo="steamvr">
SteamVR
</SettingsLink>
<SettingsLink to="/settings/trackers" scrollTo="stayaligned">
{l10n.getString('settings-sidebar-stay_aligned')}
</SettingsLink>
<SettingsLink to="/settings/trackers" scrollTo="mechanics">
{l10n.getString('settings-sidebar-tracker_mechanics')}
</SettingsLink>
<SettingsLink to="/settings/trackers" scrollTo="fksettings">
{l10n.getString('settings-sidebar-fk_settings')}
</SettingsLink>
<SettingsLink to="/settings/trackers" scrollTo="gestureControl">
{l10n.getString('settings-sidebar-gesture_control')}
</SettingsLink>
<SettingsLink
to="/settings/trackers"
scrollTo="steamvr"
id="settings-sidebar-steamvr"
/>
<SettingsLink
to="/settings/trackers"
scrollTo="stayaligned"
id="settings-sidebar-stay_aligned"
/>
<SettingsLink
to="/settings/trackers"
scrollTo="mechanics"
id="settings-sidebar-tracker_mechanics"
/>
<SettingsLink
to="/settings/trackers"
scrollTo="fksettings"
id="settings-sidebar-fk_settings"
/>
<SettingsLink
to="/settings/trackers"
scrollTo="gestureControl"
id="settings-sidebar-gesture_control"
/>
</div>
</div>
<div className="flex flex-col gap-3">
<Typography variant="section-title">
{l10n.getString('settings-sidebar-interface')}
</Typography>
<Typography variant="section-title" id="settings-sidebar-interface" />
<div className="flex flex-col gap-2">
<SettingsLink to="/settings/interface" scrollTo="notifications">
{l10n.getString('settings-sidebar-notifications')}
</SettingsLink>
<SettingsLink to="/settings/interface" scrollTo="behavior">
{l10n.getString('settings-sidebar-behavior')}
</SettingsLink>
<SettingsLink to="/settings/interface" scrollTo="appearance">
{l10n.getString('settings-sidebar-appearance')}
</SettingsLink>
<SettingsLink
to="/settings/interface"
scrollTo="notifications"
id="settings-sidebar-notifications"
/>
<SettingsLink
to="/settings/interface"
scrollTo="behavior"
id="settings-sidebar-behavior"
/>
<SettingsLink
to="/settings/interface"
scrollTo="appearance"
id="settings-sidebar-appearance"
/>
<SettingsLink
to="/settings/interface/home"
scrollTo="home"
id="settings-sidebar-home"
/>
<SettingsLink
to="/settings/interface/home"
scrollTo="checklist"
id="settings-sidebar-checklist"
/>
</div>
<div className="flex flex-col gap-3">
<Typography variant="section-title">OSC</Typography>
<div className="flex flex-col gap-2">
<SettingsLink to="/settings/osc/router" scrollTo="router">
{l10n.getString('settings-sidebar-osc_router')}
</SettingsLink>
<SettingsLink to="/settings/osc/vrchat" scrollTo="vrchat">
{l10n.getString('settings-sidebar-osc_trackers')}
</SettingsLink>
<SettingsLink to="/settings/osc/vmc" scrollTo="vmc">
VMC
</SettingsLink>
<SettingsLink
to="/settings/osc/router"
scrollTo="router"
id="settings-sidebar-osc_router"
/>
<SettingsLink
to="/settings/osc/vrchat"
scrollTo="vrchat"
id="settings-sidebar-osc_trackers"
/>
<SettingsLink
to="/settings/osc/vmc"
scrollTo="vmc"
id="settings-sidebar-osc_vmc"
/>
</div>
</div>
<div className="flex flex-col gap-3">
<Typography variant="section-title">
{l10n.getString('settings-sidebar-utils')}
</Typography>
<Typography variant="section-title" id="settings-sidebar-utils" />
<div className="flex flex-col gap-2">
<SettingsLink to="/settings/serial">
{l10n.getString('settings-sidebar-serial')}
</SettingsLink>
<SettingsLink to="/settings/firmware-tool">
{l10n.getString('settings-sidebar-firmware-tool')}
</SettingsLink>
<SettingsLink to="/settings/serial" id="settings-sidebar-serial" />
<SettingsLink
to="/settings/firmware-tool"
id="settings-sidebar-firmware-tool"
/>
</div>
{vrcConfigState?.isSupported && (
<div className="flex flex-col gap-2">
<SettingsLink to="/vrc-warnings">
{l10n.getString('settings-sidebar-vrc_warnings')}
</SettingsLink>
<SettingsLink
to="/vrc-warnings"
id="settings-sidebar-vrc_warnings"
/>
</div>
)}
<div className="flex flex-col gap-2">
<SettingsLink to="/settings/advanced">
{l10n.getString('settings-sidebar-advanced')}
</SettingsLink>
<SettingsLink
to="/settings/advanced"
id="settings-sidebar-advanced"
/>
</div>
</div>
</div>

View File

@@ -9,7 +9,6 @@ import {
ModelRatiosT,
ModelSettingsT,
ModelTogglesT,
ResetsSettingsT,
RpcMessage,
SettingsRequestT,
SettingsResponseT,
@@ -38,6 +37,11 @@ import {
serializeStayAlignedSettings,
deserializeStayAlignedSettings,
} from './components/StayAlignedSettings';
import {
defaultResetSettings,
loadResetSettings,
ResetSettingsForm,
} from '@/hooks/reset-settings';
export type SettingsForm = {
trackers: {
@@ -95,13 +99,7 @@ export type SettingsForm = {
legTweaks: {
correctionStrength: number;
};
resetsSettings: {
resetMountingFeet: boolean;
armsMountingResetMode: number;
yawResetSmoothTime: number;
saveMountingReset: boolean;
resetHmdPitch: boolean;
};
resetsSettings: ResetSettingsForm;
stayAligned: StayAlignedSettingsForm;
};
@@ -156,22 +154,14 @@ const defaultValues: SettingsForm = {
numberTrackersOverThreshold: 1,
},
legTweaks: { correctionStrength: 0.3 },
resetsSettings: {
resetMountingFeet: false,
armsMountingResetMode: 0,
yawResetSmoothTime: 0.0,
saveMountingReset: false,
resetHmdPitch: false,
},
resetsSettings: defaultResetSettings,
stayAligned: defaultStayAlignedSettings,
};
export function GeneralSettings() {
const { l10n } = useLocalization();
const { config } = useConfig();
// const { state } = useLocation();
const { currentLocales } = useLocaleConfig();
// const pageRef = useRef<HTMLFormElement | null>(null);
const percentageFormat = new Intl.NumberFormat(currentLocales, {
style: 'percent',
@@ -288,17 +278,7 @@ export function GeneralSettings() {
settings.stayAligned = serializeStayAlignedSettings(values.stayAligned);
if (values.resetsSettings) {
const resetsSettings = new ResetsSettingsT();
resetsSettings.resetMountingFeet =
values.resetsSettings.resetMountingFeet;
resetsSettings.armsMountingResetMode =
values.resetsSettings.armsMountingResetMode;
resetsSettings.yawResetSmoothTime =
values.resetsSettings.yawResetSmoothTime;
resetsSettings.saveMountingReset =
values.resetsSettings.saveMountingReset;
resetsSettings.resetHmdPitch = values.resetsSettings.resetHmdPitch;
settings.resetsSettings = resetsSettings;
settings.resetsSettings = loadResetSettings(values.resetsSettings);
}
sendRPCPacket(RpcMessage.ChangeSettingsRequest, settings);

View File

@@ -0,0 +1,190 @@
import { CheckBox } from '@/components/commons/Checkbox';
import { CheckIcon } from '@/components/commons/icon/CheckIcon';
import { HomeIcon } from '@/components/commons/icon/HomeIcon';
import { Typography } from '@/components/commons/Typography';
import {
SettingsPageLayout,
SettingsPagePaneLayout,
} from '@/components/settings/SettingsPageLayout';
import { Config, useConfig } from '@/hooks/config';
import {
trackingchecklistIdtoLabel,
useTrackingChecklist,
} from '@/hooks/tracking-checklist';
import { useLocalization } from '@fluent/react';
import classNames from 'classnames';
import { ReactNode, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { TrackingChecklistStepId } from 'solarxr-protocol';
type StepsForm = { steps: Record<TrackingChecklistStepId, boolean> };
export function TrackingChecklistSettings({
variant,
}: {
variant: 'settings' | 'modal';
}) {
const { l10n } = useLocalization();
const { ignoredSteps, steps, ignoreStep } = useTrackingChecklist();
const { control, reset, handleSubmit } = useForm<StepsForm>({
defaultValues: {
steps: steps.reduce(
(curr, { id }) => ({ [id]: !ignoredSteps.includes(id), ...curr }),
{}
),
},
mode: 'onChange',
});
useEffect(() => {
reset({
steps: steps.reduce(
(curr, { id }) => ({ [id]: !ignoredSteps.includes(id), ...curr }),
{}
),
});
}, [ignoredSteps]);
const onSubmit = (values: StepsForm) => {
for (const [id, value] of Object.entries(values.steps)) {
const stepId = +id;
if (!stepId) continue;
// doing it this way prevents calling ignore step for every step.
// that prevent sending a packet for steps that didnt change
if (!value && !ignoredSteps.includes(stepId)) {
ignoreStep(stepId, true);
}
if (value && ignoredSteps.includes(stepId)) {
ignoreStep(stepId, false);
}
}
};
return (
<div className="flex flex-col">
<div className="flex flex-col pt-4 pb-2">
<Typography bold id="settings-tracking_checklist-active_steps" />
<Typography
color="secondary"
id="settings-tracking_checklist-active_steps-desc"
/>
</div>
<form
className="grid md:grid-cols-2 gap-2 grid-rows-3 mobile:flex flex-col"
onChange={handleSubmit(onSubmit)}
>
{steps
.filter((step) => step.enabled)
.map((step) => (
<div key={step.id}>
<CheckBox
control={control}
name={`steps.${step.id}`}
disabled={!step.ignorable || !step.enabled}
label={l10n.getString(trackingchecklistIdtoLabel[step.id])}
outlined
color={variant === 'settings' ? 'primary' : 'secondary'}
/>
</div>
))}
</form>
</div>
);
}
export function LayoutSelector({
children,
name,
active = false,
onClick,
}: {
children: ReactNode;
name: string;
active: boolean;
onClick: () => void;
}) {
return (
<div
className={classNames(
'w-40 aspect-video bg-background-70 flex-col flex rounded-lg border-2 group cursor-pointer',
{
'border-accent-background-20': active,
'border-background-50 hover:border-background-40': !active,
}
)}
onClick={onClick}
>
<div className="px-2 pt-2 pb-1">
<Typography id={name} />
</div>
<div
className={classNames('h-[2px] w-full mb-2', {
'group-hover:bg-background-40 bg-background-50': !active,
'bg-accent-background-20': active,
})}
/>
{children}
</div>
);
}
export function HomeLayoutSettings() {
const { config, setConfig } = useConfig();
const setLayout = (layout: Config['homeLayout']) =>
setConfig({ homeLayout: layout });
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col pt-4 pb-2">
<Typography bold id="settings-home-list-layout" />
<Typography color="secondary" id="settings-home-list-layout-desc" />
</div>
<div className="flex gap-4">
<LayoutSelector
name="settings-home-list-layout-grid"
active={config?.homeLayout === 'default'}
onClick={() => setLayout('default')}
>
<div className="grid grid-cols-2 gap-2 p-2">
<div className="h-2 rounded-lg bg-background-40" />
<div className="h-2 rounded-lg bg-background-40" />
<div className="h-2 rounded-lg bg-background-40" />
<div className="h-2 rounded-lg bg-background-40" />
</div>
</LayoutSelector>
<LayoutSelector
name="settings-home-list-layout-table"
active={config?.homeLayout === 'table'}
onClick={() => setLayout('table')}
>
<div className="grid grid-cols-1 gap-2 p-2">
<div className="h-2 rounded-lg bg-background-40" />
<div className="h-2 rounded-lg bg-background-40" />
<div className="h-2 rounded-lg bg-background-40" />
<div className="h-2 rounded-lg bg-background-40" />
</div>
</LayoutSelector>
</div>
</div>
);
}
export function HomeScreenSettings() {
return (
<SettingsPageLayout>
<div className="flex flex-col gap-2">
<SettingsPagePaneLayout icon={<HomeIcon />}>
<Typography variant="main-title" id="home-settings" />
<HomeLayoutSettings />
</SettingsPagePaneLayout>
<SettingsPagePaneLayout icon={<CheckIcon size={18} />}>
<Typography variant="main-title" id="tracking_checklist" />
<TrackingChecklistSettings variant="settings" />
</SettingsPagePaneLayout>
</div>
</SettingsPageLayout>
);
}

View File

@@ -20,6 +20,7 @@ import { ArrowRightLeftIcon } from '@/components/commons/icon/ArrowIcons';
import { isTrayAvailable } from '@/utils/tauri';
import { isTauri } from '@tauri-apps/api/core';
import { TauriFileInput } from '@/components/commons/TauriFileInput';
import { DeveloperModeWidget } from '@/components/widgets/DeveloperModeWidget';
interface InterfaceSettingsForm {
appearance: {
@@ -304,16 +305,19 @@ export function InterfaceSettings() {
)}
</Typography>
</div>
<div className="grid sm:grid-cols-2 pb-4">
<CheckBox
variant="toggle"
control={control}
outlined
name="behavior.devmode"
label={l10n.getString(
'settings-general-interface-dev_mode-label'
)}
/>
<div className="grid grid-cols-1 gap-2 pb-4 w-full">
<div className="">
<CheckBox
variant="toggle"
control={control}
outlined
name="behavior.devmode"
label={l10n.getString(
'settings-general-interface-dev_mode-label'
)}
/>
</div>
{config?.debug && <DeveloperModeWidget />}
</div>
<Typography variant="section-title">

View File

@@ -4,6 +4,7 @@ import {
DeviceDataT,
TrackerDataT,
TrackerStatus as TrackerStatusEnum,
TrackingChecklistStepT,
} from 'solarxr-protocol';
import { Typography } from '@/components/commons/Typography';
import { TrackerBattery } from './TrackerBattery';
@@ -12,61 +13,10 @@ import { TrackerStatus } from './TrackerStatus';
import classNames from 'classnames';
import { useTracker } from '@/hooks/tracker';
import { BodyPartIcon } from '@/components/commons/BodyPartIcon';
import { DownloadIcon } from '@/components/commons/icon/DownloadIcon';
import { Link } from 'react-router-dom';
import { useAppContext } from '@/hooks/app';
import { Tooltip } from '@/components/commons/Tooltip';
import { Localized } from '@fluent/react';
import { checkForUpdate } from '@/hooks/firmware-update';
function UpdateIcon({
showUpdate,
}: {
showUpdate:
| 'can-update'
| 'low-battery'
| 'updated'
| 'unavailable'
| 'blocked';
}) {
const content = (
<div className="relative">
<div
className={classNames(
'absolute rounded-full h-6 w-6 left-1 top-1 bg-accent-background-10 animate-[ping_2s_linear_infinite]',
showUpdate !== 'can-update' && 'hidden'
)}
/>
<div
className={classNames(
'absolute rounded-full h-8 w-8 justify-center flex items-center',
showUpdate === 'low-battery'
? 'cursor-not-allowed bg-background-80 outline-2 outline-status-critical outline'
: 'hover:bg-background-40 hover:cursor-pointer bg-background-50'
)}
>
<DownloadIcon width={15} />
</div>
</div>
);
return showUpdate !== 'can-update' ? (
<Tooltip
preferedDirection="top"
content={
<Localized id={'tracker-settings-update-low-battery'}>
<Typography />
</Localized>
}
>
<div className="absolute right-5 -top-2.5">{content}</div>
</Tooltip>
) : (
<Link to="/firmware-update" className="absolute right-5 -top-2.5">
{content}
</Link>
);
}
import { FirmwareIcon } from '@/components/commons/FirmwareIcon';
import { WarningIcon } from '@/components/commons/icon/WarningIcon';
import { trackingchecklistIdtoLabel } from '@/hooks/tracking-checklist';
function TrackerBig({
device,
@@ -125,21 +75,39 @@ function TrackerBig({
function TrackerSmol({
device,
tracker,
warning,
}: {
tracker: TrackerDataT;
device?: DeviceDataT;
warning?: TrackingChecklistStepT | boolean;
}) {
const { useName } = useTracker(tracker);
const trackerName = useName();
return (
<div className="flex rounded-md py-3 px-4 w-full gap-4 h-16">
<div className="flex flex-col justify-center items-center fill-background-10">
<BodyPartIcon bodyPart={tracker.info?.bodyPart} />
<div className="flex rounded-md py-3 px-4 w-full gap-4 h-[70px]">
<div className="flex flex-col justify-center items-center fill-background-10 relative">
{warning && (
<div className="absolute -right-2 -bottom-3 text-status-warning ">
<WarningIcon width={20} />
</div>
)}
<div
className={classNames(
'border-[3px] border-opacity-80 rounded-md overflow-clip',
{
'border-status-warning': warning,
'border-transparent': !warning,
}
)}
>
<BodyPartIcon bodyPart={tracker.info?.bodyPart} width={40} />
</div>
</div>
<div className="flex flex-col flex-grow justify-center">
<Typography bold truncate>
<div className="flex flex-col flex-grow justify-center gap-1">
<Typography bold truncate variant="section-title">
{trackerName}
</Typography>
<TrackerStatus status={tracker.status} />
@@ -191,19 +159,12 @@ export function TrackerCard({
bg?: string;
shakeHighlight?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>;
warning?: boolean;
warning?: TrackingChecklistStepT | boolean;
showUpdates?: boolean;
}) {
const { currentFirmwareRelease } = useAppContext();
const { useVelocity } = useTracker(tracker);
const velocity = useVelocity();
const showUpdate =
showUpdates &&
tracker.status !== TrackerStatusEnum.DISCONNECTED &&
currentFirmwareRelease &&
device &&
checkForUpdate(currentFirmwareRelease, device);
return (
<div className="relative">
<div
@@ -212,8 +173,6 @@ export function TrackerCard({
'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 &&
'outline outline-2 -outline-offset-2 outline-status-warning',
bg
)}
style={
@@ -226,12 +185,26 @@ export function TrackerCard({
: {}
}
>
{smol && <TrackerSmol tracker={tracker} device={device} />}
{smol && (
<Tooltip
preferedDirection="top"
disabled={!warning}
spacing={5}
content={
typeof warning === 'object' && (
<div className="flex gap-1 items-center text-status-warning">
<WarningIcon width={20} />
<Typography id={trackingchecklistIdtoLabel[warning.id]} />
</div>
)
}
>
<TrackerSmol tracker={tracker} device={device} warning={warning} />
</Tooltip>
)}
{!smol && <TrackerBig tracker={tracker} device={device} />}
</div>
{showUpdate &&
showUpdate !== 'unavailable' &&
showUpdate !== 'updated' && <UpdateIcon showUpdate={showUpdate} />}
{showUpdates && <FirmwareIcon tracker={tracker} device={device} />}
</div>
);
}

View File

@@ -6,7 +6,7 @@ export function TrackerWifi({
ping,
rssiShowNumeric,
disabled,
textColor = 'secondary',
textColor = 'primary',
}: {
rssi: number | null;
ping: number | null;

View File

@@ -1,13 +1,6 @@
import { useLocalization } from '@fluent/react';
import classNames from 'classnames';
import { IPv4 } from 'ip-num';
import { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
import {
BodyPart,
TrackerDataT,
TrackerIdT,
TrackerStatus as TrackerStatusEnum,
} from 'solarxr-protocol';
import { IPv4 } from 'ip-num/IPNumber';
import { createContext, ReactNode, useContext, useMemo } from 'react';
import { useConfig } from '@/hooks/config';
import { useTracker } from '@/hooks/tracker';
import { BodyPartIcon } from '@/components/commons/BodyPartIcon';
@@ -16,37 +9,22 @@ import { formatVector3 } from '@/utils/formatting';
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';
import { StayAlignedInfo } from '@/components/stay-aligned/StayAlignedInfo';
enum DisplayColumn {
NAME,
TYPE,
BATTERY,
PING,
TPS,
ROTATION,
TEMPERATURE,
LINEAR_ACCELERATION,
POSITION,
STAY_ALIGNED,
URL,
}
const displayColumns: { [k: string]: boolean } = {
[DisplayColumn.NAME]: true,
[DisplayColumn.TYPE]: true,
[DisplayColumn.BATTERY]: true,
[DisplayColumn.PING]: true,
[DisplayColumn.TPS]: true,
[DisplayColumn.ROTATION]: true,
[DisplayColumn.TEMPERATURE]: true,
[DisplayColumn.LINEAR_ACCELERATION]: true,
[DisplayColumn.POSITION]: true,
[DisplayColumn.STAY_ALIGNED]: true,
[DisplayColumn.URL]: true,
};
import { Tooltip } from '@/components/commons/Tooltip';
import { WarningIcon } from '@/components/commons/icon/WarningIcon';
import { FirmwareIcon } from '@/components/commons/FirmwareIcon';
import {
BodyPart,
TrackerDataT,
TrackerStatus as TrackerStatusEnum,
TrackingChecklistStepT,
} from 'solarxr-protocol';
import {
highlightedTrackers,
trackingchecklistIdtoLabel,
useTrackingChecklist,
} from '@/hooks/tracking-checklist';
const isHMD = ({ tracker }: FlatDeviceTracker) =>
tracker.info?.isHmd || tracker.info?.bodyPart === BodyPart.HEAD;
@@ -58,15 +36,36 @@ const isSlime = ({ device }: FlatDeviceTracker) =>
const getTrackerName = ({ tracker }: FlatDeviceTracker) =>
tracker?.info?.customName?.toString() || '';
export function TrackerNameCell({ tracker }: { tracker: TrackerDataT }) {
export function TrackerNameCell({
tracker,
warning,
}: {
tracker: TrackerDataT;
warning: TrackingChecklistStepT | boolean;
}) {
const { useName } = useTracker(tracker);
const name = useName();
return (
<div className="flex flex-row gap-2">
<div className="flex flex-col justify-center items-center fill-background-10">
<BodyPartIcon bodyPart={tracker.info?.bodyPart} />
<div className="flex gap-2">
<div className="flex flex-col justify-center items-center fill-background-10 relative">
{warning && (
<div className="absolute -left-2 -top-1 text-status-warning ">
<WarningIcon width={16} />
</div>
)}
<div
className={classNames(
'border-[2px] border-opacity-80 rounded-md overflow-clip',
{
'border-status-warning': warning,
'border-transparent': !warning,
}
)}
>
<BodyPartIcon bodyPart={tracker.info?.bodyPart} />
</div>
</div>
<div className="flex flex-col flex-grow">
<Typography bold whitespace="whitespace-nowrap">
@@ -103,60 +102,195 @@ export function TrackerRotCell({
);
}
export function RowContainer({
function Header({
name,
className,
first = false,
last = false,
show = true,
}: {
first?: boolean;
last?: boolean;
name: string;
className?: string;
show?: boolean;
}) {
return (
<th
className={classNames('text-start px-2', {
hidden: !show,
'pl-4': first,
'pr-4': last,
})}
>
<div className={className}>
<Typography id={name} whitespace="whitespace-nowrap" />
</div>
</th>
);
}
function Cell({
children,
rounded = 'none',
hover,
tracker,
onClick,
onMouseOver,
onMouseOut,
warning,
first = false,
last = false,
show = true,
}: {
children: ReactNode;
rounded?: 'left' | 'right' | 'none';
hover: boolean;
tracker: TrackerDataT;
onClick?: MouseEventHandler<HTMLDivElement>;
onMouseOver?: MouseEventHandler<HTMLDivElement>;
onMouseOut?: MouseEventHandler<HTMLDivElement>;
warning: boolean;
first?: boolean;
last?: boolean;
show?: boolean;
}) {
const { tracker } = useContext(TrackerRowProvider);
const { useVelocity } = useTracker(tracker);
const velocity = useVelocity();
return (
<div
className={classNames(
'py-1',
rounded === 'left' && 'pl-3',
rounded === 'right' && 'pr-3',
'overflow-hidden'
)}
>
<td className={classNames('py-2 group overflow-hidden', { hidden: !show })}>
<div
onClick={onClick}
onMouseEnter={onMouseOver}
onMouseLeave={onMouseOut}
style={{
boxShadow: `0px 0px ${Math.floor(velocity * 8)}px ${Math.floor(
velocity * 8
)}px rgb(var(--accent-background-30))`,
}}
className={classNames(
'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') ||
'border-transparent'
{ 'rounded-l-md ml-3': first, 'rounded-r-md mr-3': last },
'bg-background-60 group-hover:bg-background-50 hover:cursor-pointer p-2 h-[50px] flex items-center'
)}
>
{children}
</div>
</div>
</td>
);
}
const TrackerRowProvider = createContext<FlatDeviceTracker>(undefined as never);
function Row({
data,
highlightedTrackers,
clickedTracker,
}: {
data: FlatDeviceTracker;
highlightedTrackers: highlightedTrackers | undefined;
clickedTracker: (tracker: TrackerDataT) => void;
}) {
const { config } = useConfig();
const fontColor = config?.devSettings?.highContrast ? 'primary' : 'secondary';
const moreInfo = config?.devSettings?.moreInfo;
const { tracker, device } = data;
const warning =
!!highlightedTrackers?.trackers.find(
(t) =>
t?.deviceId?.id === tracker.trackerId?.deviceId?.id &&
t?.trackerNum === tracker.trackerId?.trackerNum
) && highlightedTrackers.step;
return (
<TrackerRowProvider.Provider value={data}>
<Tooltip
disabled={!warning}
preferedDirection="top"
content={
warning && (
<div className="flex gap-1 items-center text-status-warning">
<WarningIcon width={20} />
<Typography id={trackingchecklistIdtoLabel[warning.id]} />
</div>
)
}
tag="tr"
spacing={-5}
>
<>
<div className="relative z-10">
<div className="absolute top-2 left-5">
<FirmwareIcon tracker={tracker} device={device} />
</div>
</div>
<tr className="group" onClick={() => clickedTracker(tracker)}>
<Cell first>
<TrackerNameCell tracker={tracker} warning={warning} />
</Cell>
<Cell>
<Typography color={fontColor}>
{device?.hardwareInfo?.manufacturer || '--'}
</Typography>
</Cell>
<Cell>
{device?.hardwareStatus?.batteryPctEstimate != null && (
<TrackerBattery
value={device.hardwareStatus.batteryPctEstimate / 100}
voltage={device.hardwareStatus.batteryVoltage}
disabled={tracker.status === TrackerStatusEnum.DISCONNECTED}
textColor={fontColor}
/>
)}
</Cell>
<Cell>
{(device?.hardwareStatus?.rssi != null ||
device?.hardwareStatus?.ping != null) && (
<TrackerWifi
rssi={device?.hardwareStatus?.rssi}
rssiShowNumeric
ping={device?.hardwareStatus?.ping}
disabled={tracker.status === TrackerStatusEnum.DISCONNECTED}
textColor={fontColor}
/>
)}
</Cell>
<Cell>
{tracker.tps && (
<Typography color={fontColor}>{tracker.tps}</Typography>
)}
</Cell>
<Cell>
<TrackerRotCell
tracker={tracker}
precise={config?.devSettings?.preciseRotation}
referenceAdjusted={!config?.devSettings?.rawSlimeRotation}
color={fontColor}
/>
</Cell>
<Cell last={!moreInfo}>
{tracker?.temp && tracker?.temp?.temp != 0 && (
<Typography color={fontColor} whitespace="whitespace-nowrap">
{tracker.temp.temp.toFixed(2)}
</Typography>
)}
</Cell>
<Cell show={moreInfo}>
{tracker.linearAcceleration && (
<Typography color={fontColor} whitespace="whitespace-nowrap">
{formatVector3(tracker.linearAcceleration, 1)}
</Typography>
)}
</Cell>
<Cell show={moreInfo}>
{tracker.position && (
<Typography color={fontColor} whitespace="whitespace-nowrap">
{formatVector3(tracker.position, 2)}
</Typography>
)}
</Cell>
<Cell show={moreInfo}>
<StayAlignedInfo color={fontColor} tracker={tracker} />
</Cell>
<Cell last={moreInfo} show={moreInfo}>
<Typography color={fontColor} whitespace="whitespace-nowrap">
udp://
{IPv4.fromNumber(
device?.hardwareInfo?.ipAddress?.addr || 0
).toString()}
</Typography>
</Cell>
</tr>
</>
</Tooltip>
</TrackerRowProvider.Provider>
);
}
@@ -167,14 +301,8 @@ export function TrackersTable({
clickedTracker: (tracker: TrackerDataT) => void;
flatTrackers: FlatDeviceTracker[];
}) {
const { l10n } = useLocalization();
const [hoverTracker, setHoverTracker] = useState<TrackerIdT | null>(null);
const { config } = useConfig();
const { statuses } = useStatusContext();
const trackerEqual = (id: TrackerIdT | null) =>
id?.trackerNum == hoverTracker?.trackerNum &&
(!id?.deviceId || id.deviceId.id == hoverTracker?.deviceId?.id);
const { highlightedTrackers } = useTrackingChecklist();
const filteringEnabled =
config?.debug && config?.devSettings?.filterSlimesAndHMD;
@@ -191,201 +319,51 @@ export function TrackersTable({
return list;
}, [flatTrackers, filteringEnabled, sortingEnabled]);
const fontColor = config?.devSettings?.highContrast ? 'primary' : 'secondary';
const moreInfo = config?.devSettings?.moreInfo;
const hasTemperature = !!filteredSortedTrackers.find(
({ tracker }) => tracker?.temp && tracker?.temp?.temp != 0
);
displayColumns[DisplayColumn.TEMPERATURE] = hasTemperature || false;
displayColumns[DisplayColumn.POSITION] = moreInfo || false;
displayColumns[DisplayColumn.LINEAR_ACCELERATION] = moreInfo || false;
displayColumns[DisplayColumn.STAY_ALIGNED] = moreInfo || false;
displayColumns[DisplayColumn.URL] = moreInfo || false;
const displayColumnsKeys = Object.keys(displayColumns).filter(
(k) => displayColumns[k]
);
const firstColumnId = +displayColumnsKeys[0];
const lastColumnId = +displayColumnsKeys[displayColumnsKeys.length - 1];
function column({
id,
label,
labelClassName,
row,
}: {
id: DisplayColumn;
label: string;
labelClassName?: string;
row: (data: FlatDeviceTracker) => ReactNode | null;
}) {
let rounded: 'left' | 'right' | 'none' = 'none';
if (firstColumnId === id) rounded = 'left';
else if (lastColumnId === id) rounded = 'right';
if (!displayColumns[id]) return <></>;
return (
<div
className={classNames('flex flex-col gap-1', {
'flex-grow': lastColumnId === id,
})}
>
<div className={`flex px-3 whitespace-nowrap ${labelClassName}`}>
{label}
</div>
{filteredSortedTrackers.map((data, index) => (
<RowContainer
rounded={rounded}
key={index}
tracker={data.tracker}
onClick={() => clickedTracker(data.tracker)}
hover={trackerEqual(data.tracker.trackerId)}
onMouseOver={() => setHoverTracker(data.tracker.trackerId)}
onMouseOut={() => setHoverTracker(null)}
warning={Object.values(statuses).some((status) =>
trackerStatusRelated(data.tracker, status)
)}
>
{row(data) || <></>}
</RowContainer>
))}
</div>
);
}
return (
<div className="flex w-full overflow-x-auto py-2">
{column({
id: DisplayColumn.NAME,
label: l10n.getString('tracker-table-column-name'),
row: ({ tracker }) => <TrackerNameCell tracker={tracker} />,
})}
{column({
id: DisplayColumn.TYPE,
label: l10n.getString('tracker-table-column-type'),
row: ({ device }) => (
<Typography color={fontColor}>
{device?.hardwareInfo?.manufacturer || '--'}
</Typography>
),
})}
{column({
id: DisplayColumn.BATTERY,
label: l10n.getString('tracker-table-column-battery'),
row: ({ device, tracker }) =>
device?.hardwareStatus?.batteryPctEstimate != null && (
<TrackerBattery
value={device.hardwareStatus.batteryPctEstimate / 100}
voltage={device.hardwareStatus.batteryVoltage}
disabled={tracker.status === TrackerStatusEnum.DISCONNECTED}
textColor={fontColor}
/>
),
})}
{column({
id: DisplayColumn.PING,
label: l10n.getString('tracker-table-column-ping'),
row: ({ device, tracker }) =>
(device?.hardwareStatus?.rssi != null ||
device?.hardwareStatus?.ping != null) && (
<TrackerWifi
rssi={device?.hardwareStatus?.rssi}
rssiShowNumeric
ping={device?.hardwareStatus?.ping}
disabled={tracker.status === TrackerStatusEnum.DISCONNECTED}
textColor={fontColor}
/>
),
})}
{column({
id: DisplayColumn.TPS,
label: l10n.getString('tracker-table-column-tps'),
row: ({ tracker }) => (
<Typography color={fontColor}>
{tracker?.tps != null ? <>{tracker.tps}</> : <></>}
</Typography>
),
})}
{column({
id: DisplayColumn.ROTATION,
label: l10n.getString('tracker-table-column-rotation'),
labelClassName: classNames({
'w-44': config?.devSettings?.preciseRotation,
'w-32': !config?.devSettings?.preciseRotation,
}),
row: ({ tracker }) => (
<TrackerRotCell
tracker={tracker}
precise={config?.devSettings?.preciseRotation}
referenceAdjusted={!config?.devSettings?.rawSlimeRotation}
color={fontColor}
<div className="w-full overflow-x-auto py-2 px-2">
<table className="w-full" cellPadding={0} cellSpacing={0}>
<tr>
<Header name={'tracker-table-column-name'} first />
<Header name={'tracker-table-column-type'} />
<Header name={'tracker-table-column-battery'} />
<Header name={'tracker-table-column-ping'} className="w-24" />
<Header name={'tracker-table-column-tps'} />
<Header
name={'tracker-table-column-rotation'}
className={classNames({
'w-44': config?.devSettings?.preciseRotation,
'w-32': !config?.devSettings?.preciseRotation,
})}
/>
),
})}
{column({
id: DisplayColumn.TEMPERATURE,
label: l10n.getString('tracker-table-column-temperature'),
row: ({ tracker }) =>
tracker?.temp &&
tracker?.temp?.temp != 0 && (
<Typography color={fontColor} whitespace="whitespace-nowrap">
{`${tracker.temp.temp.toFixed(2)}`}
</Typography>
),
})}
{column({
id: DisplayColumn.LINEAR_ACCELERATION,
label: l10n.getString('tracker-table-column-linear-acceleration'),
labelClassName: 'w-36',
row: ({ tracker }) =>
tracker.linearAcceleration && (
<Typography color={fontColor} whitespace="whitespace-nowrap">
{formatVector3(tracker.linearAcceleration, 1)}
</Typography>
),
})}
{column({
id: DisplayColumn.POSITION,
label: l10n.getString('tracker-table-column-position'),
labelClassName: 'w-36',
row: ({ tracker }) =>
tracker.position && (
<Typography color={fontColor} whitespace="whitespace-nowrap">
{formatVector3(tracker.position, 2)}
</Typography>
),
})}
{column({
id: DisplayColumn.STAY_ALIGNED,
label: l10n.getString('tracker-table-column-stay_aligned'),
labelClassName: 'w-36',
row: ({ tracker }) => (
<StayAlignedInfo color={fontColor} tracker={tracker} />
),
})}
{column({
id: DisplayColumn.URL,
label: l10n.getString('tracker-table-column-url'),
row: ({ device }) => (
<Typography color={fontColor} whitespace="whitespace-nowrap">
udp://
{IPv4.fromNumber(
device?.hardwareInfo?.ipAddress?.addr || 0
).toString()}
</Typography>
),
})}
<Header name={'tracker-table-column-temperature'} last={!moreInfo} />
<Header
name={'tracker-table-column-linear-acceleration'}
className="w-36"
show={moreInfo}
/>
<Header
name={'tracker-table-column-position'}
className="w-36"
show={moreInfo}
/>
<Header
name={'tracker-table-column-stay_aligned'}
className="w-36"
show={moreInfo}
last={moreInfo}
/>
<Header name={'tracker-table-column-url'} show={moreInfo} />
</tr>
{filteredSortedTrackers.map((data) => (
<Row
clickedTracker={clickedTracker}
data={data}
highlightedTrackers={highlightedTrackers}
/>
))}
</table>
</div>
);
}

View File

@@ -0,0 +1,547 @@
import {
TrackingChecklistStep,
TrackingChecklistContext,
useTrackingChecklist,
trackingchecklistIdtoLabel,
} from '@/hooks/tracking-checklist';
import classNames from 'classnames';
import {
ResetType,
TrackingChecklistPublicNetworksT,
TrackingChecklistStepId,
} from 'solarxr-protocol';
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { openUrl } from '@tauri-apps/plugin-opener';
import { CheckIcon } from '@/components/commons/icon/CheckIcon';
import { Typography } from '@/components/commons/Typography';
import { Button } from '@/components/commons/Button';
import { ResetButton } from '@/components/home/ResetButton';
import { A } from '@/components/commons/A';
import { LoaderIcon, SlimeState } from '@/components/commons/icon/LoaderIcon';
import { ProgressBar } from '@/components/commons/ProgressBar';
import { CrossIcon } from '@/components/commons/icon/CrossIcon';
import {
ArrowDownIcon,
ArrowRightIcon,
} from '@/components/commons/icon/ArrowIcons';
import { Localized } from '@fluent/react';
import { WrenchIcon } from '@/components/commons/icon/WrenchIcons';
import { TrackingChecklistModal } from './TrackingChecklistModal';
import { NavLink, useNavigate } from 'react-router-dom';
import { useBreakpoint } from '@/hooks/breakpoint';
function Step({
step: { status, id, optional, firstRequired },
children,
}: {
step: TrackingChecklistStep;
index: number;
children: ReactNode;
}) {
const [open, setOpen] = useState(firstRequired);
const canBeOpened =
(status === 'skipped' || status === 'invalid') && !firstRequired;
useEffect(() => {
if (!canBeOpened) setOpen(false);
}, [open]);
return (
<div
className={classNames(
'flex flex-col pr-2 ml-6 last:pb-0 pb-3 border-l-[2px] border-background-50',
status !== 'complete' || (firstRequired && 'border-dashed')
)}
>
<div
className={classNames(
'flex w-full gap-2 ',
canBeOpened && 'group cursor-pointer'
)}
onClick={() => {
if (canBeOpened) setOpen((open) => !open);
}}
>
<div
className={classNames(
'p-1 rounded-full fill-background-10 flex items-center justify-center z-10 h-[25px] w-[25px] -ml-[13px]',
status === 'complete' && 'bg-accent-background-20',
status === 'blocked' && 'bg-background-50',
status === 'skipped' && 'bg-background-50 fill-background-30',
status === 'invalid' && !optional && 'bg-background-50',
status === 'invalid' && optional && 'bg-background-50'
)}
>
{status === 'skipped' && <CheckIcon size={10} />}
{status === 'complete' && <CheckIcon size={10} />}
{(status === 'invalid' || status === 'blocked') && (
<div
className={classNames(
'h-[12px] w-[12px] rounded-full',
optional && 'bg-background-40',
!optional &&
'bg-accent-background-10 animate-pulse brightness-75'
)}
/>
)}
</div>
<div className="flex items-center justify-between w-full group-hover:text-background-20 text-section-title">
<Localized id={trackingchecklistIdtoLabel[id]} />
{canBeOpened && (
<div className="fill-background-30 group-hover:scale-125 group-hover:fill-background-20 transition-transform">
<ArrowDownIcon size={20} />
</div>
)}
</div>
</div>
{(firstRequired || open) && children && (
<div className="pt-2 pl-5">{children}</div>
)}
</div>
);
}
const stepContentLookup: Record<
number,
(
step: TrackingChecklistStep,
context: TrackingChecklistContext
) => JSX.Element
> = {
[TrackingChecklistStepId.TRACKERS_REST_CALIBRATION]: (step, { toggle }) => {
return (
<div className="space-y-2.5">
<Typography id="tracking_checklist-TRACKERS_REST_CALIBRATION-desc" />
<div className="flex justify-end">
{step.ignorable && (
<Button
id="tracking_checklist-ignore"
variant="secondary"
onClick={() => toggle(step.id)}
/>
)}
</div>
</div>
);
},
[TrackingChecklistStepId.FULL_RESET]: () => {
return (
<div className="space-y-2.5">
<Typography id="tracking_checklist-FULL_RESET-desc" />
<div>
<Typography id="onboarding-automatic_mounting-preparation-v2-step-0" />
<Typography id="onboarding-automatic_mounting-preparation-v2-step-1" />
<Typography id="onboarding-automatic_mounting-preparation-v2-step-2" />
</div>
<div className="grid grid-cols-3 py-1.5 gap-2">
<div className="flex flex-col bg-background-80 rounded-md relative max-h-52">
<CheckIcon className="md:w-9 sm:w-8 w-6 h-auto absolute top-2 right-2 fill-status-success" />
<img
src="/images/reset/FullResetPose.webp"
className="h-full object-contain scale-110"
alt="Reset position"
/>
</div>
<div className="flex flex-col bg-background-80 rounded-md relative max-h-52">
<CheckIcon className="md:w-9 sm:w-8 w-6 h-auto absolute top-2 right-2 fill-status-success" />
<img
src="/images/reset/FullResetPoseSide.webp"
className="h-full object-contain scale-110"
alt="Reset position side"
/>
</div>
<div className="flex flex-col bg-background-80 rounded-md relative max-h-52">
<CrossIcon className="md:w-9 sm:w-8 w-6 h-auto absolute top-2 right-2 fill-status-critical" />
<img
src="/images/reset/FullResetPoseWrong.webp"
className="h-full object-contain scale-110"
alt="Reset position wrong"
/>
</div>
</div>
<div className="flex">
<ResetButton type={ResetType.Full} />
</div>
</div>
);
},
[TrackingChecklistStepId.STEAMVR_DISCONNECTED]: (step, { toggle }) => {
return (
<>
<div className="space-y-2.5">
<Typography id="tracking_checklist-STEAMVR_DISCONNECTED-desc" />
<div className="flex justify-between sm:items-center gap-1 flex-col sm:flex-row">
<Button
id="tracking_checklist-STEAMVR_DISCONNECTED-open"
variant="primary"
onClick={() => openUrl('steam://run/250820')}
/>
{step.ignorable && (
<Button
id="tracking_checklist-ignore"
variant="secondary"
onClick={() => toggle(step.id)}
/>
)}
</div>
</div>
</>
);
},
[TrackingChecklistStepId.TRACKER_ERROR]: () => {
return <Typography id="tracking_checklist-TRACKER_ERROR-desc" />;
},
[TrackingChecklistStepId.UNASSIGNED_HMD]: () => {
return <Typography id="tracking_checklist-UNASSIGNED_HMD-desc" />;
},
[TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC]: (step, { toggle }) => {
const data = step.extraData as TrackingChecklistPublicNetworksT | null;
return (
<>
<div className="space-y-2.5">
<Typography
id="tracking_checklist-NETWORK_PROFILE_PUBLIC-desc"
vars={{
count: data?.adapters?.length ?? 0,
adapters: data?.adapters?.join(', ') ?? '',
}}
elems={{
PublicFixLink: (
<A
className="text-background-20"
href="https://docs.slimevr.dev/common-issues.html#network-profile-is-currently-set-to-public"
/>
),
}}
whitespace="whitespace-pre-wrap"
/>
<div className="flex justify-between sm:items-center gap-1 flex-col sm:flex-row">
<Button
id="tracking_checklist-NETWORK_PROFILE_PUBLIC-open"
variant="primary"
onClick={() => openUrl('ms-settings:network')}
/>
{step.ignorable && (
<Button
id="tracking_checklist-ignore"
variant="secondary"
onClick={() => toggle(step.id)}
/>
)}
</div>
</div>
</>
);
},
[TrackingChecklistStepId.VRCHAT_SETTINGS]: (step, { toggle }) => {
return (
<>
<div className="space-y-2.5">
<Typography id="tracking_checklist-VRCHAT_SETTINGS-desc" />
<div className="flex justify-between sm:items-center gap-1 flex-col sm:flex-row flex-wrap">
<Button
variant="primary"
to="/vrc-warnings"
id="tracking_checklist-VRCHAT_SETTINGS-open"
/>
{step.ignorable && (
<Button
id="tracking_checklist-ignore"
variant="secondary"
onClick={() => toggle(step.id)}
/>
)}
</div>
</div>
</>
);
},
[TrackingChecklistStepId.MOUNTING_CALIBRATION]: (step, { toggle }) => {
return (
<div className="space-y-2.5">
<Typography id="onboarding-automatic_mounting-mounting_reset-step-0" />
<Typography id="onboarding-automatic_mounting-mounting_reset-step-1" />
<div className="flex w-full justify-center">
<img
src="/images/mounting-reset-pose.webp"
className="h-44"
alt="mounting reset ski pose"
/>
</div>
<div className="flex justify-between sm:items-center gap-1 flex-col sm:flex-row">
<ResetButton type={ResetType.Mounting} group="default" />
{step.ignorable && (
<Button
id="tracking_checklist-ignore"
variant="secondary"
onClick={() => toggle(step.id)}
/>
)}
</div>
</div>
);
},
[TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION]: (step, { toggle }) => {
return (
<div className="space-y-2.5">
<Typography id="onboarding-automatic_mounting-mounting_reset-feet-step-0" />
<Typography id="onboarding-automatic_mounting-mounting_reset-feet-step-1" />
<div className="flex w-full gap-2">
<div className="flex flex-col bg-background-80 rounded-md w-full">
<img
src="/images/mounting/MountingFeets.webp"
className="h-44 object-contain"
alt="mounting reset ski pose"
/>
</div>
<div className="flex flex-col bg-background-80 rounded-md w-full">
<img
src="/images/mounting/MountingFeetsSide.webp"
className="h-44 object-contain"
alt="mounting reset ski pose"
/>
</div>
</div>
<div className="flex justify-between sm:items-center gap-1 flex-col sm:flex-row">
<ResetButton type={ResetType.Mounting} group="feet" />
{step.ignorable && (
<Button
id="tracking_checklist-ignore"
variant="secondary"
onClick={() => toggle(step.id)}
/>
)}
</div>
</div>
);
},
[TrackingChecklistStepId.STAY_ALIGNED_CONFIGURED]: (step, { toggle }) => {
return (
<>
<div className="space-y-2.5">
<Typography id="tracking_checklist-STAY_ALIGNED_CONFIGURED-desc" />
<div className="flex justify-between sm:items-center gap-1 flex-col sm:flex-row">
<Button
id="tracking_checklist-STAY_ALIGNED_CONFIGURED-open"
variant="primary"
to="/onboarding/stay-aligned"
state={{ alonePage: true }}
/>
{step.ignorable && (
<Button
id="tracking_checklist-ignore"
variant="secondary"
onClick={() => toggle(step.id)}
/>
)}
</div>
</div>
</>
);
},
};
export function TrackingChecklistMobile() {
const context = useTrackingChecklist();
const { completion, firstRequired, warnings } = context;
return (
<div style={{ gridArea: 'l' }}>
<NavLink
to="/checklist"
className={classNames(
'bg-accent-background-30 h-full flex items-center justify-between px-2 fill-background-10 no-underline',
{
'bg-status-critical': completion === 'incomplete',
'bg-status-warning text-background-90 fill-background-90':
completion === 'partial',
}
)}
>
<div className={'flex flex-col justify-center'}>
{completion === 'incomplete' ? 'Required:' : 'Warning:'}{' '}
<Localized
id={
trackingchecklistIdtoLabel[
firstRequired?.id ?? warnings[0]?.id ?? 0
]
}
/>
</div>
<ArrowRightIcon />
</NavLink>
</div>
);
}
export function TrackingChecklist({
closable = true,
closed,
closing,
toggleClosed,
}: {
closable?: boolean;
closed: boolean;
closing: boolean;
toggleClosed: () => void;
}) {
const context = useTrackingChecklist();
const { visibleSteps, progress, completion, warnings } = context;
const slimeState = useMemo(() => {
if (completion === 'complete') return SlimeState.HAPPY;
if (completion === 'incomplete') return SlimeState.CURIOUS;
if (completion === 'partial') return SlimeState.SAD;
return SlimeState.HAPPY;
}, [completion]);
const settingsOpenState = useState(false);
const [, setSettingsOpen] = settingsOpenState;
return (
<>
<div
className={classNames(
{
'overflow-y-auto': !closing && !closed,
},
'h-full w-full flex flex-col overflow-x-clip pt-1'
)}
>
<div
className={classNames(
'flex pl-3 pr-2 pb-2 pt-1 justify-between items-center'
)}
>
<div className="gap-2 flex fill-background-40">
<Typography variant="section-title" id="tracking_checklist" />
</div>
<div className="flex gap-1">
<div
className="flex gap-1 items-center justify-center fill-background-40 hover:fill-background-30 cursor-pointer rounded-full w-8 h-8 hover:bg-background-50"
onClick={() => setSettingsOpen(true)}
>
<WrenchIcon width={15} />
</div>
{closable && (
<div
className="flex gap-1 items-center justify-center fill-background-40 hover:fill-background-30 cursor-pointer rounded-full w-8 h-8 hover:bg-background-50"
onClick={() => toggleClosed()}
>
{closed && <ArrowDownIcon size={25} />}
{!closed && <CrossIcon size={25} />}
</div>
)}
</div>
</div>
<div
className={classNames('transition-all duration-500 delay-100', {
'opacity-0 h-0': closed,
})}
>
{visibleSteps.map((step, index) => (
<Step step={step} index={index + 1} key={step.id}>
{stepContentLookup[step.id]?.(step, context) || undefined}
</Step>
))}
</div>
<div
className={classNames(
'flex flex-col flex-grow border-l-[2px] justify-end ml-6 transition-all duration-500 delay-100',
{
'pt-3 border-background-50': !closed,
'border-transparent': closed,
'border-dashed': completion === 'incomplete',
}
)}
>
<div
className={classNames('flex w-full gap-2 z-10', {
'cursor-pointer': closed,
'pointer-events-none': !closed,
})}
onClick={() => toggleClosed()}
>
<div className="rounded-full bg-background-50 flex items-center justify-center h-[25px] w-[25px] -ml-[13px] relative">
<div
className={classNames('h-[12px] w-[12px] rounded-full', {
'bg-status-success': completion === 'complete',
'bg-status-critical animate-pulse':
completion === 'incomplete',
'bg-status-warning animate-pulse': completion === 'partial',
})}
/>
</div>
<div className={'flex flex-col justify-center'}>
{completion === 'incomplete' && (
<Typography
variant="section-title"
id="tracking_checklist-status-incomplete"
/>
)}
{completion === 'partial' && (
<Typography
variant="section-title"
id="tracking_checklist-status-partial"
vars={{ count: warnings.length }}
/>
)}
{completion == 'complete' && (
<Typography
variant="section-title"
id="tracking_checklist-status-complete"
/>
)}
</div>
</div>
</div>
<div
className={classNames('w-full flex relative p-3 pr-12', {
'pt-0': closed,
})}
>
{!closed && (
<ProgressBar
progress={progress}
colorClass={
completion === 'incomplete'
? 'bg-accent-background-20'
: completion === 'partial'
? 'bg-status-warning'
: 'bg-status-success'
}
/>
)}
<div className="absolute bottom-0 right-0 w-20 h-20 overflow-clip pointer-events-none">
<div className="-rotate-45 translate-x-3.5 translate-y-3.5">
<LoaderIcon slimeState={slimeState} />
</div>
</div>
</div>
</div>
<TrackingChecklistModal open={settingsOpenState} />
</>
);
}
export function ChecklistPage() {
const nav = useNavigate();
const { isMobile } = useBreakpoint('mobile');
useEffect(() => {
if (!isMobile) nav('/');
}, [isMobile]);
return (
<div className="rounded-t-lg h-full">
<TrackingChecklist
closable={false}
closed={false}
closing={false}
toggleClosed={() => {}}
/>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Dispatch, SetStateAction } from 'react';
import { BaseModal } from '@/components/commons/BaseModal';
import { Typography } from '@/components/commons/Typography';
import { Button } from '@/components/commons/Button';
import { TrackingChecklistSettings } from '@/components/settings/pages/HomeScreenSettings';
export function TrackingChecklistModal({
open,
}: {
open: [boolean, Dispatch<SetStateAction<boolean>>];
}) {
return (
<BaseModal
isOpen={open[0]}
appendClasses={
'max-w-xl md:w-full mobile:max-h-[400px] mobile:overflow-y-auto'
}
closeable
onRequestClose={() => {
open[1](false);
}}
>
<div className="flex flex-col gap-4">
<Typography variant="main-title" id="tracking_checklist-settings" />
<TrackingChecklistSettings variant="modal" />
<div className="flex justify-end">
<Button
variant="tertiary"
onClick={() => open[1](false)}
id="tracking_checklist-settings-close"
/>
</div>
</div>
</BaseModal>
);
}

View File

@@ -0,0 +1,19 @@
import {
TrackingChecklistContectC,
provideTrackingChecklist,
} from '@/hooks/tracking-checklist';
import { ReactNode } from 'react';
export function TrackingChecklistProvider({
children,
}: {
children: ReactNode;
}) {
const context = provideTrackingChecklist();
return (
<TrackingChecklistContectC.Provider value={context}>
{children}
</TrackingChecklistContectC.Provider>
);
}

View File

@@ -1,7 +1,10 @@
import { useEffect } from 'react';
import { useBreakpoint } from '@/hooks/breakpoint';
import { WidgetsComponent } from '@/components/WidgetsComponent';
import { useNavigate } from 'react-router-dom';
import { NavLink, useNavigate } from 'react-router-dom';
import { SkeletonVisualizerWidget } from '@/components/widgets/SkeletonVisualizerWidget';
import { Checklist } from '@/components/commons/icon/ChecklistIcon';
import { PreviewControls } from '@/components/Sidebar';
import { Vector3 } from 'three';
export function VRModePage() {
const nav = useNavigate();
@@ -12,8 +15,30 @@ export function VRModePage() {
}, [isMobile]);
return (
<div className="p-2 flex flex-col gap-2 h-full">
<WidgetsComponent />
<div className="flex flex-col gap-2 h-full rounded-t-lg relative">
<SkeletonVisualizerWidget
onInit={(context) => {
context.addView({
left: 0,
bottom: 0,
width: 1,
height: 1,
position: new Vector3(3, 2.5, -3),
onHeightChange(v, newHeight) {
v.controls.target.set(0, newHeight / 2.4, 0.1);
const scale = Math.max(1, newHeight) / 1;
v.camera.zoom = 1 / scale;
},
});
}}
/>
<NavLink
to="/checklist"
className="xs:hidden absolute z-50 h-12 w-12 rounded-full bg-accent-background-30 bottom-3 right-3 flex justify-center items-center fill-background-10"
>
<Checklist />
</NavLink>
<PreviewControls open />
</div>
);
}

View File

@@ -100,7 +100,7 @@ const onOffKey = (value: boolean) =>
export function VRCWarningsPage() {
const { l10n } = useLocalization();
const { state, toggleMutedSettings, mutedSettings } = useVRCConfig();
const { state, toggleMutedSettings } = useVRCConfig();
const { currentLocales } = useLocaleConfig();
const meterFormat = Intl.NumberFormat(currentLocales, {
@@ -115,7 +115,7 @@ export function VRCWarningsPage() {
const settingRowProps = (key: keyof VRCConfigStateSupported['validity']) => ({
mute: () => toggleMutedSettings(key),
muted: mutedSettings.includes(key),
muted: state.muted.includes(key),
valid: state.validity[key] == true,
});

View File

@@ -4,7 +4,6 @@ import { useConfig } from '@/hooks/config';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { CheckBox } from '@/components/commons/Checkbox';
import { useLocalization } from '@fluent/react';
import { Typography } from '@/components/commons/Typography';
export interface DeveloperModeWidgetForm {
highContrast: boolean;
@@ -57,6 +56,7 @@ export function DeveloperModeWidget() {
key={index}
control={control}
variant="toggle"
outlined
name={name}
label={l10n.getString(`widget-developer_mode-${label}`)}
/>
@@ -73,10 +73,7 @@ export function DeveloperModeWidget() {
};
return (
<form className="bg-background-60 flex flex-col w-full rounded-md px-2">
<div className="mt-2 px-1">
<Typography>{l10n.getString('widget-developer_mode')}</Typography>
</div>
<form className="grid grid-cols-2 w-full rounded-md gap-2">
{Object.entries(toggles).map(makeToggle)}
</form>
);

View File

@@ -10,7 +10,6 @@ import * as THREE from 'three';
import { BodyPart, BoneT } from 'solarxr-protocol';
import { QuaternionFromQuatT, isIdentity } from '@/maths/quaternion';
import classNames from 'classnames';
import { Button } from '@/components/commons/Button';
import { useLocalization } from '@fluent/react';
import { ErrorBoundary } from 'react-error-boundary';
import { Typography } from '@/components/commons/Typography';
@@ -18,8 +17,9 @@ import { useAtomValue } from 'jotai';
import { bonesAtom } from '@/store/app-store';
import { useConfig } from '@/hooks/config';
import { Tween } from '@tweenjs/tween.js';
import { EyeIcon } from '@/components/commons/icon/EyeIcon';
const GROUND_COLOR = '#4444aa';
const GROUND_COLOR = '#2c2c6b';
// Just need to know the length of the total body, so don't need right legs
const Y_PARTS = [
@@ -32,61 +32,6 @@ const Y_PARTS = [
BodyPart.LEFT_LOWER_LEG,
];
interface SkeletonVisualizerWidgetProps {
height?: number | string;
maxHeight?: number | string;
}
export function ToggleableSkeletonVisualizerWidget({
height,
maxHeight,
}: SkeletonVisualizerWidgetProps) {
const { l10n } = useLocalization();
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const state = localStorage.getItem('skeletonModelPreview');
if (state) setEnabled(state === 'true');
}, []);
return (
<>
{!enabled && (
<Button
variant="secondary"
className="w-full"
onClick={() => {
setEnabled(true);
localStorage.setItem('skeletonModelPreview', 'true');
}}
>
{l10n.getString('widget-skeleton_visualizer-preview')}
</Button>
)}
{enabled && (
<div className="flex flex-col gap-2">
<Button
className="w-full"
variant="secondary"
onClick={() => {
setEnabled(false);
localStorage.setItem('skeletonModelPreview', 'false');
}}
>
{l10n.getString('widget-skeleton_visualizer-hide')}
</Button>
<div
style={{ height, maxHeight }}
className="bg-background-60 p-1 rounded-md"
>
<SkeletonVisualizerWidget />
</div>
</div>
)}
</>
);
}
export type SkeletonPreviewView = {
left: number;
bottom: number;
@@ -110,7 +55,7 @@ function initializePreview(
const resolution = new THREE.Vector2(canvas.clientWidth, canvas.clientHeight);
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({
let renderer: THREE.WebGLRenderer | null = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
@@ -195,7 +140,7 @@ function initializePreview(
const render = (delta: number) => {
views.forEach((v) => {
if (v.hidden) return;
if (v.hidden || !renderer) return;
v.controls.update(delta);
const left = Math.floor(resolution.x * v.left);
@@ -251,6 +196,7 @@ function initializePreview(
resize: (width: number, height: number) => {
resolution.set(width, height);
skeletonHelper.resolution.copy(resolution);
if (!renderer) return;
renderer.setSize(width, height);
},
setFrameInterval: (interval: number) => {
@@ -276,9 +222,11 @@ function initializePreview(
}
},
destroy: () => {
skeletonHelper.dispose();
renderer.dispose();
cancelAnimationFrame(animationFrameId);
skeletonHelper.dispose();
if (!renderer) return;
renderer.dispose();
renderer = null; // Very important for js to free the WebGL context. dispose does not to it alone
},
addView: ({
left,
@@ -297,6 +245,8 @@ function initializePreview(
hidden?: boolean;
onHeightChange: (view: SkeletonPreviewView, newHeight: number) => void;
}) => {
if (!renderer) return;
const camera = new THREE.PerspectiveCamera(
20,
resolution.width / resolution.height,
@@ -344,8 +294,10 @@ type PreviewContext = ReturnType<typeof initializePreview>;
function SkeletonVisualizer({
onInit,
disabled = false,
}: {
onInit: (context: PreviewContext) => void;
disabled?: boolean;
}) {
const { config } = useConfig();
@@ -362,15 +314,15 @@ function SkeletonVisualizer({
useEffect(() => {
if (bones.size === 0) return;
const context = previewContext.current;
if (!context) return;
if (!context || disabled) return;
context.rebuildSkeleton(createChildren(bones, BoneKind.root), bones);
}, [bones.size]);
}, [bones.size, disabled]);
useEffect(() => {
const context = previewContext.current;
if (!context) return;
if (!context || disabled) return;
context.updatesBones(bones);
}, [bones]);
}, [bones, disabled]);
const onResize = (e: ResizeObserverEntry) => {
const context = previewContext.current;
@@ -393,6 +345,7 @@ function SkeletonVisualizer({
};
useLayoutEffect(() => {
if (disabled) return;
if (!canvasRef.current || !containerRef.current)
throw 'invalid state - no canvas or container';
resizeObserver.current.observe(containerRef.current);
@@ -416,11 +369,12 @@ function SkeletonVisualizer({
if (!previewContext.current || !containerRef.current) return;
resizeObserver.current.unobserve(containerRef.current);
previewContext.current.destroy();
previewContext.current = null;
containerRef.current.removeEventListener('mouseenter', onEnter);
containerRef.current.removeEventListener('mouseleave', onLeave);
};
}, []);
}, [disabled]);
return (
<div ref={containerRef} className={classNames('w-full h-full')}>
@@ -444,20 +398,53 @@ export function SkeletonVisualizerWidget({
},
});
},
disabled = false,
toggleDisabled,
}: {
onInit?: (context: PreviewContext) => void;
disabled?: boolean;
toggleDisabled?: () => void;
}) {
const { l10n } = useLocalization();
const [error, setError] = useState(false);
return (
<ErrorBoundary
fallback={
<Typography color="primary" textAlign="text-center">
{l10n.getString('tips-failed_webgl')}
</Typography>
}
>
<SkeletonVisualizer onInit={onInit} />
</ErrorBoundary>
<div className={classNames('w-full h-full relative')}>
<div
className={classNames('w-full h-full transition-all', {
blur: disabled,
})}
>
<ErrorBoundary onError={() => setError(true)} fallback={<></>}>
<SkeletonVisualizer onInit={onInit} disabled={disabled} />
</ErrorBoundary>
</div>
<div
className={classNames(
'absolute h-full w-full top-0 flex items-center justify-center transition-opacity duration-300',
{ 'opacity-0 pointer-events-none': !disabled || error }
)}
>
<div
className="bg-background-90 rounded-lg p-2 px-3 flex gap-2 items-center hover:bg-background-60 cursor-pointer"
onClick={() => toggleDisabled?.()}
>
<EyeIcon closed width={20} />
<Typography id="preview-disabled_render" />
</div>
</div>
<div
className={classNames(
'absolute h-full w-full top-0 flex items-center justify-center transition-opacity duration-300',
{ 'opacity-0 pointer-events-none': !error }
)}
>
<div className="bg-background-90 rounded-lg p-2 px-3 flex gap-2 items-center">
<Typography color="primary" textAlign="text-center">
{l10n.getString('tips-failed_webgl')}
</Typography>
</div>
</div>
</div>
);
}

View File

@@ -3,16 +3,13 @@ import {
DataFeedMessage,
DataFeedUpdateT,
ResetResponseT,
ResetStatus,
ResetType,
RpcMessage,
StartDataFeedT,
} from 'solarxr-protocol';
import { playSoundOnResetEnded, playSoundOnResetStarted } from '@/sounds/sounds';
import { handleResetSounds } from '@/sounds/sounds';
import { useConfig } from './config';
import { useBonesDataFeedConfig, useDataFeedConfig } from './datafeed-config';
import { useWebsocketAPI } from './websocket-api';
import { error } from '@/utils/logging';
import { useAtomValue, useSetAtom } from 'jotai';
import { bonesAtom, datafeedAtom, devicesAtom } from '@/store/app-store';
import { updateSentryContext } from '@/utils/sentry';
@@ -55,23 +52,9 @@ export function useProvideAppContext(): AppContext {
updateSentryContext(devices);
}, [devices]);
useRPCPacket(RpcMessage.ResetResponse, ({ status, resetType }: ResetResponseT) => {
useRPCPacket(RpcMessage.ResetResponse, (resetResponse: ResetResponseT) => {
if (!config?.feedbackSound) return;
try {
switch (status) {
case ResetStatus.STARTED: {
if (resetType !== ResetType.Yaw)
playSoundOnResetStarted(config?.feedbackSoundVolume);
break;
}
case ResetStatus.FINISHED: {
playSoundOnResetEnded(resetType, config?.feedbackSoundVolume);
break;
}
}
} catch (e) {
error(e);
}
handleResetSounds(config?.feedbackSoundVolume ?? 1, resetResponse);
});
useEffect(() => {

View File

@@ -0,0 +1,90 @@
import { BodyPart } from 'solarxr-protocol';
export const ALL_BODY_PARTS = [
BodyPart.NONE,
BodyPart.HEAD,
BodyPart.NECK,
BodyPart.CHEST,
BodyPart.WAIST,
BodyPart.HIP,
BodyPart.LEFT_UPPER_LEG,
BodyPart.RIGHT_UPPER_LEG,
BodyPart.LEFT_LOWER_LEG,
BodyPart.RIGHT_LOWER_LEG,
BodyPart.LEFT_FOOT,
BodyPart.RIGHT_FOOT,
BodyPart.LEFT_LOWER_ARM,
BodyPart.RIGHT_LOWER_ARM,
BodyPart.LEFT_UPPER_ARM,
BodyPart.RIGHT_UPPER_ARM,
BodyPart.LEFT_HAND,
BodyPart.RIGHT_HAND,
BodyPart.LEFT_SHOULDER,
BodyPart.RIGHT_SHOULDER,
BodyPart.UPPER_CHEST,
BodyPart.LEFT_HIP,
BodyPart.RIGHT_HIP,
BodyPart.LEFT_THUMB_METACARPAL,
BodyPart.LEFT_THUMB_PROXIMAL,
BodyPart.LEFT_THUMB_DISTAL,
BodyPart.LEFT_INDEX_PROXIMAL,
BodyPart.LEFT_INDEX_INTERMEDIATE,
BodyPart.LEFT_INDEX_DISTAL,
BodyPart.LEFT_MIDDLE_PROXIMAL,
BodyPart.LEFT_MIDDLE_INTERMEDIATE,
BodyPart.LEFT_MIDDLE_DISTAL,
BodyPart.LEFT_RING_PROXIMAL,
BodyPart.LEFT_RING_INTERMEDIATE,
BodyPart.LEFT_RING_DISTAL,
BodyPart.LEFT_LITTLE_PROXIMAL,
BodyPart.LEFT_LITTLE_INTERMEDIATE,
BodyPart.LEFT_LITTLE_DISTAL,
BodyPart.RIGHT_THUMB_METACARPAL,
BodyPart.RIGHT_THUMB_PROXIMAL,
BodyPart.RIGHT_THUMB_DISTAL,
BodyPart.RIGHT_INDEX_PROXIMAL,
BodyPart.RIGHT_INDEX_INTERMEDIATE,
BodyPart.RIGHT_INDEX_DISTAL,
BodyPart.RIGHT_MIDDLE_PROXIMAL,
BodyPart.RIGHT_MIDDLE_INTERMEDIATE,
BodyPart.RIGHT_MIDDLE_DISTAL,
BodyPart.RIGHT_RING_PROXIMAL,
BodyPart.RIGHT_RING_INTERMEDIATE,
BodyPart.RIGHT_RING_DISTAL,
BodyPart.RIGHT_LITTLE_PROXIMAL,
BodyPart.RIGHT_LITTLE_INTERMEDIATE,
BodyPart.RIGHT_LITTLE_DISTAL,
];
export const FEET_BODY_PARTS = [BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT];
export const FINGER_BODY_PARTS = [
BodyPart.LEFT_THUMB_METACARPAL,
BodyPart.LEFT_THUMB_PROXIMAL,
BodyPart.LEFT_THUMB_DISTAL,
BodyPart.LEFT_INDEX_PROXIMAL,
BodyPart.LEFT_INDEX_INTERMEDIATE,
BodyPart.LEFT_INDEX_DISTAL,
BodyPart.LEFT_MIDDLE_PROXIMAL,
BodyPart.LEFT_MIDDLE_INTERMEDIATE,
BodyPart.LEFT_MIDDLE_DISTAL,
BodyPart.LEFT_RING_PROXIMAL,
BodyPart.LEFT_RING_INTERMEDIATE,
BodyPart.LEFT_RING_DISTAL,
BodyPart.LEFT_LITTLE_PROXIMAL,
BodyPart.LEFT_LITTLE_INTERMEDIATE,
BodyPart.LEFT_LITTLE_DISTAL,
BodyPart.RIGHT_THUMB_METACARPAL,
BodyPart.RIGHT_THUMB_PROXIMAL,
BodyPart.RIGHT_THUMB_DISTAL,
BodyPart.RIGHT_INDEX_PROXIMAL,
BodyPart.RIGHT_INDEX_INTERMEDIATE,
BodyPart.RIGHT_INDEX_DISTAL,
BodyPart.RIGHT_MIDDLE_PROXIMAL,
BodyPart.RIGHT_MIDDLE_INTERMEDIATE,
BodyPart.RIGHT_MIDDLE_DISTAL,
BodyPart.RIGHT_RING_PROXIMAL,
BodyPart.RIGHT_RING_INTERMEDIATE,
BodyPart.RIGHT_RING_DISTAL,
BodyPart.RIGHT_LITTLE_PROXIMAL,
BodyPart.RIGHT_LITTLE_INTERMEDIATE,
BodyPart.RIGHT_LITTLE_DISTAL,
];

54
gui/src/hooks/bvh.ts Normal file
View File

@@ -0,0 +1,54 @@
import { useLocalization } from '@fluent/react';
import { isTauri } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
import { RecordBVHRequestT, RecordBVHStatusT, RpcMessage } from 'solarxr-protocol';
import { useWebsocketAPI } from './websocket-api';
import { useConfig } from './config';
import { save } from '@tauri-apps/plugin-dialog';
export function useBHV() {
const { config } = useConfig();
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
const [state, setState] = useState<'idle' | 'recording' | 'saving'>('idle');
const { l10n } = useLocalization();
useEffect(() => {
sendRPCPacket(RpcMessage.RecordBVHStatusRequest, new RecordBVHRequestT());
}, []);
const toggle = async () => {
const record = new RecordBVHRequestT(state === 'recording');
if (isTauri() && state === 'idle') {
if (config?.bvhDirectory) {
record.path = config.bvhDirectory;
} else {
setState('saving');
record.path = await save({
title: l10n.getString('bvh-save_title'),
filters: [
{
name: 'BVH',
extensions: ['bvh'],
},
],
defaultPath: 'bvh-recording.bvh',
});
setState('idle');
}
}
sendRPCPacket(RpcMessage.RecordBVHRequest, record);
};
useRPCPacket(RpcMessage.RecordBVHStatus, (data: RecordBVHStatusT) => {
setState(data.recording ? 'recording' : 'idle');
});
return {
available:
typeof window.__ANDROID__ === 'undefined' || !window.__ANDROID__?.isThere(),
state,
toggle,
};
}

View File

@@ -46,6 +46,8 @@ export interface Config {
showNavbarOnboarding: boolean;
vrcMutedWarnings: string[];
bvhDirectory: string | null;
homeLayout: 'default' | 'table';
skeletonPreview: boolean;
}
export interface ConfigContext {
@@ -75,6 +77,8 @@ export const defaultConfig: Config = {
vrcMutedWarnings: [],
devSettings: defaultDevSettings,
bvhDirectory: null,
homeLayout: 'default',
skeletonPreview: true,
};
interface CrossStorage {

View File

@@ -17,13 +17,16 @@ export function useCountdown({
const startCountdown = () => {
setIsCounting(true);
setDisplayTimer(duration);
if (countdownTimer.current) {
clearTimer();
}
counter.current = 0;
countdownTimer.current = setInterval(
() => {
counter.current++;
setDisplayTimer(duration - counter.current);
if (counter.current >= duration) {
clearInterval(countdownTimer.current);
resetEnd();
}
},
@@ -31,15 +34,20 @@ export function useCountdown({
);
};
const clearTimer = () => {
clearInterval(countdownTimer.current);
countdownTimer.current = undefined;
};
const resetEnd = () => {
setIsCounting(false);
clearInterval(countdownTimer.current);
clearTimer();
onCountdownEnd();
};
const abortCountdown = () => {
setIsCounting(false);
clearInterval(countdownTimer.current);
clearTimer();
};
return {

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import { useWebsocketAPI } from './websocket-api';
import {
RpcMessage,
SetPauseTrackingRequestT,
TrackingPauseStateRequestT,
TrackingPauseStateResponseT,
} from 'solarxr-protocol';
import { restartAndPlay, trackingPauseSound, trackingPlaySound } from '@/sounds/sounds';
import { useConfig } from './config';
export function usePauseTracking() {
const { config } = useConfig();
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
const [paused, setPaused] = useState(false);
const toggle = () => {
const pause = new SetPauseTrackingRequestT(!paused);
sendRPCPacket(RpcMessage.SetPauseTrackingRequest, pause);
if (!config) return;
if (pause.pauseTracking) {
restartAndPlay(trackingPauseSound, config.feedbackSoundVolume);
} else {
restartAndPlay(trackingPlaySound, config.feedbackSoundVolume);
}
};
useRPCPacket(
RpcMessage.TrackingPauseStateResponse,
(data: TrackingPauseStateResponseT) => {
setPaused(data.trackingPaused);
}
);
useEffect(() => {
sendRPCPacket(
RpcMessage.TrackingPauseStateRequest,
new TrackingPauseStateRequestT()
);
}, []);
return {
toggle,
paused,
};
}

View File

@@ -0,0 +1,58 @@
import {
ChangeSettingsRequestT,
ResetsSettingsT,
RpcMessage,
SettingsResetRequestT,
SettingsResponseT,
} from 'solarxr-protocol';
import { useWebsocketAPI } from './websocket-api';
import { useEffect, useState } from 'react';
export interface ResetSettingsForm {
resetMountingFeet: boolean;
armsMountingResetMode: number;
yawResetSmoothTime: number;
saveMountingReset: boolean;
resetHmdPitch: boolean;
}
export const defaultResetSettings = {
resetMountingFeet: false,
armsMountingResetMode: 0,
yawResetSmoothTime: 0.0,
saveMountingReset: false,
resetHmdPitch: false,
};
export function loadResetSettings(resetSettingsForm: ResetSettingsForm) {
const resetsSettings = new ResetsSettingsT();
resetsSettings.resetMountingFeet = resetSettingsForm.resetMountingFeet;
resetsSettings.armsMountingResetMode = resetSettingsForm.armsMountingResetMode;
resetsSettings.yawResetSmoothTime = resetSettingsForm.yawResetSmoothTime;
resetsSettings.saveMountingReset = resetSettingsForm.saveMountingReset;
resetsSettings.resetHmdPitch = resetSettingsForm.resetHmdPitch;
return resetsSettings;
}
export function useResetSettings() {
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const [settings, setSettings] = useState<ResetSettingsForm>(defaultResetSettings);
useEffect(() =>
sendRPCPacket(RpcMessage.SettingsRequest, new SettingsResetRequestT())
);
useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => {
if (settings.resetsSettings) setSettings(settings.resetsSettings);
});
return {
update: (resetSettingsForm: Partial<ResetSettingsForm>) => {
const req = new ChangeSettingsRequestT();
const res = loadResetSettings({ ...settings, ...resetSettingsForm });
req.resetsSettings = res;
sendRPCPacket(RpcMessage.ChangeSettingsRequest, req);
},
};
}

151
gui/src/hooks/reset.ts Normal file
View File

@@ -0,0 +1,151 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
BodyPart,
ResetRequestT,
ResetResponseT,
ResetStatus,
ResetType,
RpcMessage,
} from 'solarxr-protocol';
import { useWebsocketAPI } from './websocket-api';
import { useAtomValue } from 'jotai';
import { assignedTrackersAtom } from '@/store/app-store';
import { FEET_BODY_PARTS, FINGER_BODY_PARTS } from './body-parts';
import { useLocaleConfig } from '@/i18n/config';
export type ResetBtnStatus = 'idle' | 'counting' | 'finished';
export type MountingResetGroup = 'default' | 'feet' | 'fingers';
export type UseResetOptions =
| { type: ResetType.Full | ResetType.Yaw }
| { type: ResetType.Mounting; group: MountingResetGroup };
export const BODY_PARTS_GROUPS: Record<MountingResetGroup, BodyPart[]> = {
default: [],
feet: FEET_BODY_PARTS,
fingers: FINGER_BODY_PARTS,
};
export function useReset(options: UseResetOptions, onReseted?: () => void) {
if (options.type === ResetType.Mounting && !options.group) options.group = 'default';
const { currentLocales } = useLocaleConfig();
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const finishedTimeoutRef = useRef<NodeJS.Timeout>();
const [status, setStatus] = useState<ResetBtnStatus>('idle');
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const parts = BODY_PARTS_GROUPS['group' in options ? options.group : 'default'];
const triggerReset = () => {
const req = new ResetRequestT();
req.resetType = options.type;
req.bodyParts = parts;
sendRPCPacket(RpcMessage.ResetRequest, req);
};
const onResetFinished = () => {
setStatus('finished');
if (onReseted) onReseted();
};
const onResetCanceled = () => {
if (status !== 'finished') setStatus('idle');
};
useEffect(() => {
if (status === 'finished') {
finishedTimeoutRef.current = setTimeout(() => {
setStatus('idle'); // only do that if we were on finished status. Allows to reset the outlined border
}, 2000);
} else {
clearTimeout(finishedTimeoutRef.current);
}
return () => {
clearTimeout(finishedTimeoutRef.current);
};
}, [status]);
const onResetProgress = (progress: number, duration: number) => {
setProgress(progress / 1000);
setDuration(duration / 1000);
};
useRPCPacket(
RpcMessage.ResetResponse,
({ status, resetType, progress, duration, bodyParts }: ResetResponseT) => {
if (
resetType !== options.type ||
(resetType == ResetType.Mounting &&
JSON.stringify(parts) !== JSON.stringify(bodyParts))
) {
onResetCanceled();
return;
}
onResetProgress(progress, duration);
switch (status) {
case ResetStatus.FINISHED: {
onResetFinished();
break;
}
case ResetStatus.STARTED: {
setStatus('counting');
break;
}
}
}
);
const name = useMemo(() => {
switch (options.type) {
case ResetType.Yaw:
return 'reset-yaw';
case ResetType.Full:
return 'reset-full';
case ResetType.Mounting:
if (options.group !== 'default') return `reset-mounting-${options.group}`;
return 'reset-mounting';
default:
return 'unhandled';
}
}, [options.type]);
let disabled = status === 'counting';
if (options.type === ResetType.Mounting && options.group !== 'default') {
const assignedTrackers = useAtomValue(assignedTrackersAtom);
if (
!assignedTrackers.some(
({ tracker }) =>
tracker.info?.bodyPart &&
BODY_PARTS_GROUPS[options.group].includes(tracker.info?.bodyPart)
)
)
disabled = true;
}
const localized = useMemo(
() =>
Intl.NumberFormat('en-US', {
maximumFractionDigits: 1,
unit: 'second',
unitDisplay: 'narrow',
style: 'unit',
}),
[currentLocales]
);
return {
triggerReset,
progress,
duration,
status,
disabled,
name,
timer: localized.format(duration - progress),
};
}
export function useMountingReset() {}

View File

@@ -1,207 +0,0 @@
import { createContext, useEffect, useReducer, useContext } from 'react';
import {
BodyPart,
RpcMessage,
StatusData,
StatusMessageT,
StatusPublicNetworkT,
StatusSteamVRDisconnectedT,
StatusSystemFixedT,
StatusSystemRequestT,
StatusSystemResponseT,
StatusSystemUpdateT,
StatusTrackerErrorT,
StatusTrackerResetT,
StatusUnassignedHMDT,
TrackerDataT,
} from 'solarxr-protocol';
import { useWebsocketAPI } from './websocket-api';
import { FluentVariable } from '@fluent/bundle';
import { ReactLocalization } from '@fluent/react';
import { FlatDeviceTracker } from '@/store/app-store';
type StatusSystemStateAction =
| StatusSystemStateFixedAction
| StatusSystemStateNewAction
| StatusSystemStateUpdateAction;
interface StatusSystemStateFixedAction {
type: RpcMessage.StatusSystemFixed;
data: number;
}
interface StatusSystemStateUpdateAction {
type: RpcMessage.StatusSystemUpdate;
data: StatusMessageT;
}
interface StatusSystemStateNewAction {
type: RpcMessage.StatusSystemResponse;
data: StatusMessageT[];
}
interface StatusSystemState {
statuses: {
[id: number]: StatusMessageT;
};
}
export interface StatusSystemContext {
statuses: {
[id: number]: StatusMessageT;
};
}
function reducer(
state: StatusSystemState,
action: StatusSystemStateAction
): StatusSystemState {
switch (action.type) {
case RpcMessage.StatusSystemFixed: {
const newState = {
statuses: { ...state.statuses },
};
delete newState.statuses[action.data];
return newState;
}
case RpcMessage.StatusSystemUpdate: {
return {
statuses: { ...state.statuses, [action.data.id]: action.data },
};
}
case RpcMessage.StatusSystemResponse: {
return {
// Convert the array into an object, we dont want to have an array on our map!
statuses: action.data.reduce((prev, cur) => ({ ...prev, [cur.id]: cur }), {}),
};
}
}
}
export function useProvideStatusContext(): StatusSystemContext {
const { useRPCPacket, sendRPCPacket, isConnected } = useWebsocketAPI();
const [state, dispatch] = useReducer(reducer, { statuses: {} });
useRPCPacket(
RpcMessage.StatusSystemResponse,
({ currentStatuses }: StatusSystemResponseT) =>
dispatch({ type: RpcMessage.StatusSystemResponse, data: currentStatuses })
);
useRPCPacket(RpcMessage.StatusSystemFixed, ({ fixedStatusId }: StatusSystemFixedT) =>
dispatch({ type: RpcMessage.StatusSystemFixed, data: fixedStatusId })
);
useRPCPacket(
RpcMessage.StatusSystemUpdate,
({ newStatus }: StatusSystemUpdateT) =>
newStatus && dispatch({ type: RpcMessage.StatusSystemUpdate, data: newStatus })
);
useEffect(() => {
if (!isConnected) return;
sendRPCPacket(RpcMessage.StatusSystemRequest, new StatusSystemRequestT());
}, [isConnected]);
return state;
}
export const StatusSystemC = createContext<StatusSystemContext>(undefined as never);
export function useStatusContext() {
const context = useContext<StatusSystemContext>(StatusSystemC);
if (!context) {
throw new Error('useStatusContext must be within a StatusSystemContext Provider');
}
return context;
}
export function parseStatusToLocale(
status: StatusMessageT,
trackers: FlatDeviceTracker[] | null,
l10n: ReactLocalization
): Record<string, FluentVariable> {
switch (status.dataType) {
case StatusData.NONE:
case StatusData.StatusTrackerReset:
case StatusData.StatusUnassignedHMD:
return {};
case StatusData.StatusPublicNetwork: {
const data = status.data as StatusPublicNetworkT;
return {
adapters: data.adapters.join(', '),
count: data.adapters.length,
};
}
case StatusData.StatusSteamVRDisconnected: {
const data = status.data as StatusSteamVRDisconnectedT;
if (typeof data.bridgeSettingsName === 'string') {
return { type: data.bridgeSettingsName };
}
return {};
}
case StatusData.StatusTrackerError: {
const data = status.data as StatusTrackerErrorT;
if (data.trackerId?.trackerNum === undefined || !trackers) {
return {};
}
const tracker = trackers.find(
({ tracker }) =>
tracker?.trackerId?.trackerNum == data.trackerId?.trackerNum &&
tracker?.trackerId?.deviceId?.id == data.trackerId?.deviceId?.id
);
if (!tracker)
return {
trackerName: 'unknown',
};
const name = tracker.tracker.info?.customName
? tracker.tracker.info?.customName
: tracker.tracker.info?.bodyPart
? l10n.getString('body_part-' + BodyPart[tracker.tracker.info?.bodyPart])
: tracker.tracker.info?.displayName || 'unknown';
if (typeof name !== 'string') {
return {
trackerName: new TextDecoder().decode(name),
};
}
return { trackerName: name };
}
}
}
export const doesntContainTrackerInfo: readonly StatusData[] = [StatusData.NONE];
export function trackerStatusRelated(
tracker: TrackerDataT,
status: StatusMessageT
): boolean {
if (doesntContainTrackerInfo.includes(status.dataType)) {
return false;
}
switch (status.dataType) {
case StatusData.StatusTrackerReset: {
const data = status.data as StatusTrackerResetT;
return (
data.trackerId?.trackerNum == tracker.trackerId?.trackerNum &&
data.trackerId?.deviceId?.id === tracker.trackerId?.deviceId?.id
);
}
case StatusData.StatusTrackerError: {
const data = status.data as StatusTrackerErrorT;
return (
data.trackerId?.trackerNum == tracker.trackerId?.trackerNum &&
data.trackerId?.deviceId?.id === tracker.trackerId?.deviceId?.id
);
}
case StatusData.StatusUnassignedHMD: {
const data = status.data as StatusUnassignedHMDT;
return (
data.trackerId?.trackerNum == tracker.trackerId?.trackerNum &&
data.trackerId?.deviceId?.id === tracker.trackerId?.deviceId?.id
);
}
default:
return false;
}
}

View File

@@ -0,0 +1,193 @@
import {
TrackingChecklistRequestT,
TrackingChecklistResponseT,
TrackingChecklistStepId,
TrackingChecklistStepT,
TrackingChecklistStepVisibility,
IgnoreTrackingChecklistStepRequestT,
RpcMessage,
TrackerIdT,
} from 'solarxr-protocol';
import { useWebsocketAPI } from './websocket-api';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
export const trackingchecklistIdtoLabel: Record<TrackingChecklistStepId, string> = {
[TrackingChecklistStepId.UNKNOWN]: '',
[TrackingChecklistStepId.TRACKERS_REST_CALIBRATION]:
'tracking_checklist-TRACKERS_REST_CALIBRATION',
[TrackingChecklistStepId.FULL_RESET]: 'tracking_checklist-FULL_RESET',
[TrackingChecklistStepId.VRCHAT_SETTINGS]: 'tracking_checklist-VRCHAT_SETTINGS',
[TrackingChecklistStepId.STEAMVR_DISCONNECTED]:
'tracking_checklist-STEAMVR_DISCONNECTED',
[TrackingChecklistStepId.UNASSIGNED_HMD]: 'tracking_checklist-UNASSIGNED_HMD',
[TrackingChecklistStepId.TRACKER_ERROR]: 'tracking_checklist-TRACKER_ERROR',
[TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC]:
'tracking_checklist-NETWORK_PROFILE_PUBLIC',
[TrackingChecklistStepId.MOUNTING_CALIBRATION]:
'tracking_checklist-MOUNTING_CALIBRATION',
[TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION]:
'tracking_checklist-FEET_MOUNTING_CALIBRATION',
[TrackingChecklistStepId.STAY_ALIGNED_CONFIGURED]:
'tracking_checklist-STAY_ALIGNED_CONFIGURED',
};
export type TrackingChecklistStepStatus =
| 'complete'
| 'skipped'
| 'blocked'
| 'invalid';
export type TrackingChecklistStep = TrackingChecklistStepT & {
status: TrackingChecklistStepStatus;
firstRequired: boolean;
};
export type highlightedTrackers = {
step: TrackingChecklistStep;
trackers: Array<TrackerIdT>;
};
const stepVisibility = ({ visibility, status, firstRequired }: TrackingChecklistStep) =>
firstRequired ||
visibility === TrackingChecklistStepVisibility.ALWAYS ||
(visibility === TrackingChecklistStepVisibility.WHEN_INVALID && status != 'complete');
const createStep = (
steps: TrackingChecklistStepT[],
step: TrackingChecklistStepT,
index: number
): TrackingChecklistStep => {
const previousSteps = steps.slice(0, index);
const previousBlocked = previousSteps.some(
({ valid, optional }) => !valid && !optional
);
let status: TrackingChecklistStepStatus = 'complete';
if (previousBlocked && !step.valid) status = 'blocked';
if (!previousBlocked && !step.valid) status = 'invalid';
const firstRequiredIndex = steps.findIndex(
(s, index) => !s.valid || (index === steps.length - 1 && !s.valid)
);
const skipped =
steps.find(
(s, sIndex) =>
(sIndex > index && s.valid && !s.optional) || sIndex === steps.length - 1
) || index === steps.length - 1;
if (!step.valid && step.optional && skipped) status = 'skipped';
return {
...step,
status,
firstRequired:
firstRequiredIndex === index ||
(firstRequiredIndex === -1 && index === steps.length - 1 && !step.valid),
pack: () => 0,
};
};
export type TrackingChecklistContext = ReturnType<typeof provideTrackingChecklist>;
export type Steps = {
steps: TrackingChecklistStepT[];
visibleSteps: TrackingChecklistStep[];
ignoredSteps: TrackingChecklistStepId[];
};
export function provideTrackingChecklist() {
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const [steps, setSteps] = useState<Steps>({
steps: [],
visibleSteps: [],
ignoredSteps: [],
});
useRPCPacket(
RpcMessage.TrackingChecklistResponse,
(data: TrackingChecklistResponseT) => {
const activeSteps = data.steps.filter(
(step) => !data.ignoredSteps.includes(step.id) && step.enabled
);
setSteps({
steps: data.steps,
visibleSteps: activeSteps
.map((step: TrackingChecklistStepT, index) =>
createStep(activeSteps, step, index)
)
.filter(stepVisibility),
ignoredSteps: data.ignoredSteps,
});
}
);
useEffect(() => {
sendRPCPacket(RpcMessage.TrackingChecklistRequest, new TrackingChecklistRequestT());
}, []);
const firstRequired = useMemo(
() =>
steps.visibleSteps.find(
(step) => !step.valid && step.status != 'blocked' && !step.optional
),
[steps]
);
const highlightedTrackers: highlightedTrackers | undefined = useMemo(() => {
if (!firstRequired || !firstRequired.extraData) return undefined;
if ('trackersId' in firstRequired.extraData) {
return { step: firstRequired, trackers: firstRequired.extraData.trackersId };
}
if ('trackerId' in firstRequired.extraData && firstRequired.extraData.trackerId) {
return { step: firstRequired, trackers: [firstRequired.extraData.trackerId] };
}
return { step: firstRequired, trackers: [] };
}, [firstRequired]);
const progress = useMemo(() => {
const completeSteps = steps.visibleSteps.filter(
(step) => step.status === 'complete' || step.status === 'skipped'
);
return Math.min(1, completeSteps.length / steps.visibleSteps.length);
}, [steps]);
const completion: 'complete' | 'partial' | 'incomplete' = useMemo(() => {
if (progress === 1 && steps.visibleSteps.find((step) => step.status === 'skipped'))
return 'partial';
return progress === 1 || steps.visibleSteps.length === 0
? 'complete'
: 'incomplete';
}, [progress, steps]);
const warnings = useMemo(
() => steps.visibleSteps.filter((step) => !step.valid),
[steps]
);
const ignoreStep = (step: TrackingChecklistStepId, ignore: boolean) => {
const res = new IgnoreTrackingChecklistStepRequestT();
res.stepId = step;
res.ignore = ignore;
sendRPCPacket(RpcMessage.IgnoreTrackingChecklistStepRequest, res);
};
return {
...steps,
firstRequired,
highlightedTrackers,
progress,
completion,
warnings,
ignoreStep,
toggle: (step: TrackingChecklistStepId) =>
ignoreStep(step, !steps.ignoredSteps.includes(step)),
};
}
export const TrackingChecklistContectC = createContext<TrackingChecklistContext>(
undefined as never
);
export function useTrackingChecklist() {
const context = useContext(TrackingChecklistContectC);
if (!context) {
throw new Error('useTrackingChecklist must be within a TrackingChecklistProvider');
}
return context;
}

View File

@@ -3,19 +3,19 @@ import { useWebsocketAPI } from './websocket-api';
import {
RpcMessage,
VRCAvatarMeasurementType,
VRCConfigSettingToggleMuteT,
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'>
Pick<VRCConfigStateChangeResponseT, 'recommended' | 'state' | 'validity' | 'muted'>
>;
export type VRCConfigState = { isSupported: false } | VRCConfigStateSupported;
@@ -45,7 +45,6 @@ export const avatarMeasurementTypeTranslationMap: Record<
export function useVRCConfig() {
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
const { config, setConfig } = useConfig();
const [state, setState] = useState<VRCConfigState | null>(null);
useLayoutEffect(() => {
@@ -59,31 +58,20 @@ export function useVRCConfig() {
}
);
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))
.filter(([k]) => !state.muted.includes(k))
.some(([, v]) => !v);
}, [state, config]);
}, [state]);
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);
const req = new VRCConfigSettingToggleMuteT();
req.key = key;
sendRPCPacket(RpcMessage.VRCConfigSettingToggleMute, req);
},
};
}

View File

@@ -63,12 +63,11 @@ body {
background: theme('colors.background.20');
--navbar-w: 101px;
--widget-w: 274px;
--topbar-h: 38px;
@screen mobile {
--topbar-h: 44px;
--navbar-h: 90px;
--navbar-h: 73px;
}
}
@@ -91,7 +90,7 @@ body {
--accent-background-50: 46, 33, 69;
--success: 80, 232, 151;
--warning: 216, 205, 55;
--warning: 255, 225, 53;
--critical: 223, 109, 140;
--special: 164, 79, 237;
--window-icon-stroke: 192, 161, 216;
@@ -153,7 +152,7 @@ body {
--accent-background-50: 19, 57, 19;
--success: 80, 232, 151;
--warning: 216, 205, 55;
--warning: 255, 225, 53;
--critical: 223, 109, 140;
--special: 54, 161, 54;
--window-icon-stroke: 129, 213, 130;
@@ -179,7 +178,7 @@ body {
--accent-background-50: 57, 57, 19;
--success: 80, 232, 151;
--warning: 216, 205, 55;
--warning: 255, 225, 53;
--critical: 223, 109, 140;
--special: 161, 160, 54;
--window-icon-stroke: 213, 212, 129;
@@ -205,7 +204,7 @@ body {
--accent-background-50: 57, 34, 19;
--success: 80, 232, 151;
--warning: 216, 205, 55;
--warning: 255, 225, 53;
--critical: 223, 109, 140;
--special: 161, 95, 54;
--window-icon-stroke: 213, 162, 129;
@@ -231,7 +230,7 @@ body {
--accent-background-50: 57, 19, 19;
--success: 80, 232, 151;
--warning: 216, 205, 55;
--warning: 255, 225, 53;
--critical: 223, 109, 140;
--special: 161, 54, 54;
--window-icon-stroke: 213, 129, 129;
@@ -257,7 +256,7 @@ body {
--accent-background-50: 39, 39, 39;
--success: 80, 232, 151;
--warning: 216, 205, 55;
--warning: 255, 225, 53;
--critical: 223, 109, 140;
--special: 121, 121, 121;
--window-icon-stroke: 172, 172, 172;

View File

@@ -1,4 +1,4 @@
import { ResetType } from 'solarxr-protocol';
import { ResetResponseT, ResetStatus, ResetType } from 'solarxr-protocol';
import Xylophone, { ValidNote } from './xylophone';
const tones: ValidNote[][] = [
@@ -10,47 +10,46 @@ const tones: ValidNote[][] = [
];
const xylophone = new Xylophone();
export async function playSoundOnResetEnded(resetType: ResetType, volume = 1) {
switch (resetType) {
case ResetType.Yaw: {
xylophone.play({
notes: ['C4'],
offset: 0.15,
type: 'custom',
volume,
});
break;
}
case ResetType.Full: {
xylophone.play({
notes: ['E3', 'G3'],
offset: 0.15,
type: 'custom',
volume,
});
break;
}
case ResetType.Mounting: {
xylophone.play({
notes: ['G3', 'B3', 'D4'],
offset: 0.15,
type: 'custom',
volume,
});
break;
}
const mew = createAudio('/sounds/mew.ogg');
const resetSounds: Record<
ResetType,
{
initial: HTMLAudioElement | null;
tick: HTMLAudioElement[] | null;
end: HTMLAudioElement;
mew: HTMLAudioElement | null;
}
}
> = {
[ResetType.Full]: {
initial: createAudio('/sounds/full-reset/init-full-reset-with-tail.ogg'),
tick: [
createAudio('/sounds/full-reset/full-click-1.ogg'),
createAudio('/sounds/full-reset/full-click-2.ogg'),
createAudio('/sounds/full-reset/full-click-3.ogg'),
],
end: createAudio('/sounds/full-reset/end-full-reset-with-tail.ogg'),
mew,
},
[ResetType.Yaw]: {
initial: null,
tick: null,
end: createAudio('/sounds/yaw-reset/yaw-reset.ogg'),
mew: null,
},
[ResetType.Mounting]: {
initial: createAudio('/sounds/mounting-reset/init-mounting-reset-with-tail.ogg'),
tick: [
createAudio('/sounds/mounting-reset/mount-click-1.ogg'),
createAudio('/sounds/mounting-reset/mount-click-2.ogg'),
createAudio('/sounds/mounting-reset/mount-click-3.ogg'),
],
end: createAudio('/sounds/mounting-reset/end-mounting-reset-with-tail.ogg'),
mew,
},
};
export async function playSoundOnResetStarted(volume = 1) {
await xylophone.play({
notes: ['A4'],
offset: 0.4,
type: 'custom',
volume,
});
}
export const trackingPauseSound = createAudio('/sounds/tracking/pause.ogg');
export const trackingPlaySound = createAudio('/sounds/tracking/play.ogg');
let lastTap = 0;
export async function playTapSetupSound(volume = 1) {
@@ -84,3 +83,58 @@ export async function playTapSetupSound(volume = 1) {
lastTap = 0;
}
}
function createAudio(path: string): HTMLAudioElement {
const audio = new Audio(path);
audio.preload = 'auto';
audio.load();
return audio;
}
export function restartAndPlay(audio: HTMLAudioElement | null, volume: number) {
if (!audio) return;
try {
audio.load(); // LINUX: Solves wierd bug where webkit would unload sounds wierdly and make the sounds not play anymore
audio.volume = Math.min(1, Math.pow(volume, Math.E) + 0.05);
audio.currentTime = 0;
const playPromise = audio.play();
if (playPromise !== undefined) {
playPromise.catch((error) => {
console.error('Audio playback failed:', error);
});
}
} catch (error) {
console.error('Audio error:', error);
}
}
export function handleResetSounds(
volume: number,
{ progress, status, resetType }: ResetResponseT
) {
if (!resetSounds) throw 'sounds not loaded';
const sounds = resetSounds[resetType];
if (status === ResetStatus.STARTED) {
if (progress === 0) {
performance.mark('sound_start');
restartAndPlay(sounds.initial, volume);
}
if (sounds.tick) {
const tickIndex = (progress / 1000) % sounds.tick.length;
if (progress >= 1000 && sounds.tick[tickIndex]) {
restartAndPlay(sounds.tick[tickIndex], volume);
}
}
}
if (status === ResetStatus.FINISHED) {
performance.mark('sound_end');
console.log(performance.measure('sound', 'sound_start', 'sound_end'));
restartAndPlay(sounds.end, volume);
restartAndPlay(sounds.mew, volume);
}
}

View File

@@ -9,6 +9,7 @@ import {
} from 'solarxr-protocol';
import { selectAtom } from 'jotai/utils';
import { isEqual } from '@react-hookz/deep-equal';
import { FEET_BODY_PARTS, FINGER_BODY_PARTS } from '@/hooks/body-parts';
export interface FlatDeviceTracker {
device?: DeviceDataT;
@@ -45,14 +46,18 @@ export const unassignedTrackersAtom = atom((get) => {
return trackers.filter(({ tracker }) => tracker.info?.bodyPart === BodyPart.NONE);
});
export const connectedIMUTrackersAtom = atom((get) => {
export const connectedTrackersAtom = atom((get) => {
const trackers = get(flatTrackersAtom);
return trackers.filter(
({ tracker }) =>
tracker.status !== TrackerStatus.DISCONNECTED && tracker.info?.isImu
({ tracker }) => tracker.status !== TrackerStatus.DISCONNECTED
);
});
export const connectedIMUTrackersAtom = atom((get) => {
const trackers = get(connectedTrackersAtom);
return trackers.filter(({ tracker }) => tracker.info?.isImu);
});
export const computedTrackersAtom = selectAtom(
datafeedAtom,
(datafeed) => datafeed.syntheticTrackers.map((tracker) => ({ tracker })),
@@ -95,3 +100,16 @@ export const trackerFromIdAtom = ({
(a) => a,
isEqual
);
export const feetAssignedTrackers = atom((get) =>
get(assignedTrackersAtom).some(
(t) => t.tracker.info?.bodyPart && FEET_BODY_PARTS.includes(t.tracker.info.bodyPart)
)
);
export const fingerAssignedTrackers = atom((get) =>
get(assignedTrackersAtom).some(
(t) =>
t.tracker.info?.bodyPart && FINGER_BODY_PARTS.includes(t.tracker.info.bodyPart)
)
);

View File

@@ -188,11 +188,11 @@ export class BoneKind extends Bone {
case BodyPart.NONE:
throw 'Unexpected body part';
case BodyPart.HEAD:
return new Color('black');
return new Color('gold');
case BodyPart.NECK:
return new Color('silver');
case BodyPart.UPPER_CHEST:
return new Color('blue');
return new Color('chartreuse');
case BodyPart.CHEST:
return new Color('purple');
case BodyPart.WAIST:
@@ -201,13 +201,13 @@ export class BoneKind extends Bone {
return new Color('orange');
case BodyPart.LEFT_UPPER_LEG:
case BodyPart.RIGHT_UPPER_LEG:
return new Color('blue');
return new Color('chartreuse');
case BodyPart.LEFT_LOWER_LEG:
case BodyPart.RIGHT_LOWER_LEG:
return new Color('teal');
case BodyPart.LEFT_FOOT:
case BodyPart.RIGHT_FOOT:
return new Color('#00ffcc');
return new Color('gold');
case BodyPart.LEFT_LOWER_ARM:
case BodyPart.RIGHT_LOWER_ARM:
return new Color('red');

View File

@@ -3,6 +3,7 @@ import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';
import gradient from 'tailwind-gradient-mask-image';
import type { Config } from 'tailwindcss';
import { transform } from 'typescript';
const colors = {
'blue-gray': {
@@ -172,6 +173,7 @@ const config = {
nsm: { raw: 'not (min-width: 900px)' },
sm: '900px',
md: '1100px',
nmd: { raw: 'not (min-width: 1100px)' },
'md-max': { raw: 'not (min-width: 1100px)' },
lg: '1300px',
xl: '1600px',
@@ -228,6 +230,54 @@ const config = {
'animation-timing-function': 'cubic-bezier(0.8, 0, 1, 1)',
},
},
'timer-tick': {
"0%, 40%": {
transform: 'scale(1)',
},
"20%": {
transform: 'scale(1.3)',
},
},
'spin-ccw': {
'0%': {
transform: 'rotate(0deg)',
},
'100%': {
transform: 'rotate(-360deg)',
},
},
skiing: {
'0%, 100%': {
transform: 'rotate(0deg) translateX(0%) translateY(0%)',
},
'10%': {
transform: 'rotate(12deg) translateX(-5%) translateY(5%)',
},
'20%': {
transform: 'rotate(10deg) translateX(0%) translateY(0%)',
},
'30%': {
transform: 'rotate(12deg) translateX(5%) translateY(-5%)',
},
'40%': {
transform: 'rotate(10deg) translateX(0%) translateY(0%)',
},
'50%': {
transform: 'rotate(12deg) translateX(-5%) translateY(5%)',
},
'60%': {
transform: 'rotate(10deg) translateX(0%) translateY(0%)',
},
'70%': {
transform: 'rotate(12deg) translateX(5%) translateY(-5%)',
},
'80%': {
transform: 'rotate(10deg) translateX(0%) translateY(0%)',
},
'90%': {
transform: 'rotate(10deg) translateX(-5%) translateY(5%)',
},
},
},
backgroundImage: {
slime: `linear-gradient(135deg, ${colors.purple[100]} 50%, ${colors['blue-gray'][700]} 50% 100%)`,
@@ -240,6 +290,11 @@ const config = {
'trans-flag': `linear-gradient(135deg, ${colors['trans-blue'][800]} 40%, ${colors['trans-blue'][700]} 40% 70%, ${colors['trans-blue'][600]} 70% 100%)`,
'asexual-flag': `linear-gradient(135deg, ${colors['asexual'][100]} 30%, ${colors['asexual'][200]} 30% 50%, ${colors['asexual'][300]} 50% 70%, ${colors['asexual'][400]} 70% 100%)`,
},
animation: {
'spin-ccw': 'spin-ccw 1s linear infinite',
'timer-tick': 'timer-tick 1s linear infinite',
skiing: 'skiing 1s linear infinite',
},
},
data: {
checked: 'checked=true',

View File

@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["dom", "dom.iterable", "ES2023"],
"target": "es2023",
"lib": ["dom", "dom.iterable", "es2023"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,

3777
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
package dev.slimevr
data class NetworkInfo(
val name: String?,
val description: String?,
val category: NetworkCategory?,
val connectivity: Set<ConnectivityFlags>?,
val connected: Boolean?,
)
/**
* @see <a href="https://learn.microsoft.com/en-us/windows/win32/api/netlistmgr/ne-netlistmgr-nlm_network_category">NLM_NETWORK_CATEGORY enumeration (netlistmgr.h)</a>
*/
enum class NetworkCategory(val value: Int) {
PUBLIC(0),
PRIVATE(1),
DOMAIN_AUTHENTICATED(2),
;
companion object {
fun fromInt(value: Int) = values().find { it.value == value }
}
}
/**
* @see <a href="https://learn.microsoft.com/en-us/windows/win32/api/netlistmgr/ne-netlistmgr-nlm_connectivity">NLM_CONNECTIVITY enumeration (netlistmgr.h)</a>
*/
enum class ConnectivityFlags(val value: Int) {
DISCONNECTED(0),
IPV4_NOTRAFFIC(0x1),
IPV6_NOTRAFFIC(0x2),
IPV4_SUBNET(0x10),
IPV4_LOCALNETWORK(0x20),
IPV4_INTERNET(0x40),
IPV6_SUBNET(0x100),
IPV6_LOCALNETWORK(0x200),
IPV6_INTERNET(0x400),
;
companion object {
fun fromInt(value: Int): Set<ConnectivityFlags> = if (value == 0) {
setOf(DISCONNECTED)
} else {
values().filter { it != DISCONNECTED && (value and it.value) != 0 }.toSet()
}
}
}
abstract class NetworkProfileChecker {
abstract val isSupported: Boolean
abstract val publicNetworks: List<NetworkInfo>
}
class NetworkProfileCheckerStub : NetworkProfileChecker() {
override val isSupported: Boolean
get() = false
override val publicNetworks: List<NetworkInfo>
get() = listOf()
}

View File

@@ -18,6 +18,8 @@ import dev.slimevr.posestreamer.BVHRecorder
import dev.slimevr.protocol.ProtocolAPI
import dev.slimevr.protocol.rpc.settings.RPCSettingsHandler
import dev.slimevr.reset.ResetHandler
import dev.slimevr.reset.ResetTimerManager
import dev.slimevr.reset.resetTimer
import dev.slimevr.serial.ProvisioningHandler
import dev.slimevr.serial.SerialHandler
import dev.slimevr.serial.SerialHandlerStub
@@ -28,6 +30,7 @@ import dev.slimevr.tracking.processor.HumanPoseManager
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
import dev.slimevr.tracking.trackers.*
import dev.slimevr.tracking.trackers.udp.TrackersUDPServer
import dev.slimevr.trackingchecklist.TrackingChecklistManager
import dev.slimevr.util.ann.VRServerThread
import dev.slimevr.websocketapi.WebSocketVRBridge
import io.eiren.util.ann.ThreadSafe
@@ -55,6 +58,7 @@ class VRServer @JvmOverloads constructor(
serialHandlerProvider: (VRServer) -> SerialHandler = { _ -> SerialHandlerStub() },
flashingHandlerProvider: (VRServer) -> SerialFlashingHandler? = { _ -> null },
vrcConfigHandlerProvider: (VRServer) -> VRCConfigHandler = { _ -> VRCConfigHandlerStub() },
networkProfileProvider: (VRServer) -> NetworkProfileChecker = { _ -> NetworkProfileCheckerStub() },
acquireMulticastLock: () -> Any? = { null },
@JvmField val configManager: ConfigManager,
) : Thread("VRServer") {
@@ -99,6 +103,7 @@ class VRServer @JvmOverloads constructor(
@JvmField
val protocolAPI: ProtocolAPI
private val timer = Timer()
private val resetTimerManager = ResetTimerManager()
val fpsTimer = NanoTimer()
@JvmField
@@ -113,6 +118,10 @@ class VRServer @JvmOverloads constructor(
@JvmField
val handshakeHandler = HandshakeHandler()
val trackingChecklistManager: TrackingChecklistManager
val networkProfileChecker: NetworkProfileChecker
init {
// UwU
deviceManager = DeviceManager(this)
@@ -126,6 +135,8 @@ class VRServer @JvmOverloads constructor(
autoBoneHandler = AutoBoneHandler(this)
firmwareUpdateHandler = FirmwareUpdateHandler(this)
vrcConfigManager = VRChatConfigManager(this, vrcConfigHandlerProvider(this))
networkProfileChecker = networkProfileProvider(this)
trackingChecklistManager = TrackingChecklistManager(this)
protocolAPI = ProtocolAPI(this)
val computedTrackers = humanPoseManager.computedTrackers
@@ -164,6 +175,7 @@ class VRServer @JvmOverloads constructor(
for (tracker in computedTrackers) {
registerTracker(tracker)
}
instance = this
}
@@ -300,7 +312,7 @@ class VRServer @JvmOverloads constructor(
queueTask { humanPoseManager.resetTrackersYaw(resetSourceName, bodyParts) }
}
fun resetTrackersMounting(resetSourceName: String?, bodyParts: List<Int> = TrackerUtils.allBodyPartsButFingers) {
fun resetTrackersMounting(resetSourceName: String?, bodyParts: List<Int>? = null) {
queueTask { humanPoseManager.resetTrackersMounting(resetSourceName, bodyParts) }
}
@@ -330,40 +342,52 @@ class VRServer @JvmOverloads constructor(
}
}
fun scheduleResetTrackersFull(resetSourceName: String?, delay: Long) {
if (delay > 0) {
resetHandler.sendStarted(ResetType.Full)
}
timer.schedule(delay) {
queueTask {
humanPoseManager.resetTrackersFull(resetSourceName)
resetHandler.sendFinished(ResetType.Full)
}
}
fun scheduleResetTrackersFull(resetSourceName: String?, delay: Long, bodyParts: List<Int> = ArrayList()) {
resetTimer(
resetTimerManager,
delay,
onTick = { progress ->
resetHandler.sendStarted(ResetType.Full, bodyParts, progress, delay.toInt())
},
onComplete = {
queueTask {
humanPoseManager.resetTrackersFull(resetSourceName, bodyParts)
resetHandler.sendFinished(ResetType.Full, bodyParts, delay.toInt())
}
},
)
}
fun scheduleResetTrackersYaw(resetSourceName: String?, delay: Long) {
if (delay > 0) {
resetHandler.sendStarted(ResetType.Yaw)
}
timer.schedule(delay) {
queueTask {
humanPoseManager.resetTrackersYaw(resetSourceName)
resetHandler.sendFinished(ResetType.Yaw)
}
}
fun scheduleResetTrackersYaw(resetSourceName: String?, delay: Long, bodyParts: List<Int> = TrackerUtils.allBodyPartsButFingers) {
resetTimer(
resetTimerManager,
delay,
onTick = { progress ->
resetHandler.sendStarted(ResetType.Yaw, bodyParts, progress, delay.toInt())
},
onComplete = {
queueTask {
humanPoseManager.resetTrackersYaw(resetSourceName, bodyParts)
resetHandler.sendFinished(ResetType.Yaw, bodyParts, delay.toInt())
}
},
)
}
fun scheduleResetTrackersMounting(resetSourceName: String?, delay: Long) {
if (delay > 0) {
resetHandler.sendStarted(ResetType.Mounting)
}
timer.schedule(delay) {
queueTask {
humanPoseManager.resetTrackersMounting(resetSourceName)
resetHandler.sendFinished(ResetType.Mounting)
}
}
fun scheduleResetTrackersMounting(resetSourceName: String?, delay: Long, bodyParts: List<Int>? = null) {
resetTimer(
resetTimerManager,
delay,
onTick = { progress ->
resetHandler.sendStarted(ResetType.Mounting, bodyParts, progress, delay.toInt())
},
onComplete = {
queueTask {
humanPoseManager.resetTrackersMounting(resetSourceName, bodyParts)
resetHandler.sendFinished(ResetType.Mounting, bodyParts, delay.toInt())
}
},
)
}
fun scheduleSetPauseTracking(pauseTracking: Boolean, sourceName: String?, delay: Long) {

View File

@@ -53,4 +53,6 @@ interface ISteamVRBridge : Bridge {
fun getAutomaticSharedTrackers(): Boolean
fun setAutomaticSharedTrackers(value: Boolean)
fun getBridgeConfigKey(): String
}

View File

@@ -29,6 +29,24 @@ enum class ArmsResetModes(val id: Int) {
}
}
enum class MountingMethods(val id: Int) {
MANUAL(0),
AUTOMATIC(1),
;
companion object {
val values = MountingMethods.entries.toTypedArray()
@JvmStatic
fun fromId(id: Int): MountingMethods? {
for (filter in values) {
if (filter.id == id) return filter
}
return null
}
}
}
class ResetsConfig {
// Always reset mounting for feet
@@ -46,6 +64,12 @@ class ResetsConfig {
// Reset the HMD's pitch upon full reset
var resetHmdPitch = false
var lastMountingMethod = MountingMethods.AUTOMATIC
var yawResetDelay = 0.0f
var fullResetDelay = 3.0f
var mountingResetDelay = 3.0f
fun updateTrackersResetsSettings() {
for (t in VRServer.instance.allTrackers) {
t.resetsHandler.readResetConfig(this)

View File

@@ -14,20 +14,16 @@ class TapDetectionConfig {
var mountingResetEnabled = true
var setupMode = false
var yawResetTaps = 2
// clamp to 2-3 to prevent errors
set(yawResetTaps) {
field = FastMath.clamp(yawResetTaps.toFloat(), 2f, 10f).toInt()
field = yawResetTaps
field = yawResetTaps.coerceIn(2, 10)
}
var fullResetTaps = 3
set(fullResetTaps) {
field = FastMath.clamp(fullResetTaps.toFloat(), 2f, 10f).toInt()
field = fullResetTaps
field = fullResetTaps.coerceIn(2, 10)
}
var mountingResetTaps = 3
set(mountingResetTaps) {
field = FastMath.clamp(mountingResetTaps.toFloat(), 2f, 10f).toInt()
field = mountingResetTaps
field = mountingResetTaps.coerceIn(2, 10)
}
var numberTrackersOverThreshold = 1
}

View File

@@ -0,0 +1,5 @@
package dev.slimevr.config
class TrackingChecklistConfig {
val ignoredStepsIds: MutableList<Int> = mutableListOf()
}

View File

@@ -0,0 +1,6 @@
package dev.slimevr.config
class VRCConfig {
// List of fields ignored in vrc warnings - @see VRCConfigValidity
val mutedWarnings: MutableList<String> = mutableListOf()
}

View File

@@ -54,6 +54,10 @@ class VRConfig {
val overlay: OverlayConfig = OverlayConfig()
val trackingChecklist: TrackingChecklistConfig = TrackingChecklistConfig()
val vrcConfig: VRCConfig = VRCConfig()
init {
// Initialize default settings for OSC Router
oscRouter.portIn = 9002
@@ -104,7 +108,7 @@ class VRConfig {
tracker.readConfig(config)
if (tracker.isImu()) tracker.resetsHandler.readDriftCompensationConfig(driftCompensation)
tracker.resetsHandler.readResetConfig(resetsConfig)
if (tracker.needsReset) {
if (tracker.allowReset) {
tracker.saveMountingResetOrientation(config)
}
if (tracker.allowFiltering) {

View File

@@ -78,11 +78,11 @@ data class VRCConfigValidity(
val shoulderTrackingOk: Boolean,
val shoulderWidthCompensationOk: Boolean,
val userHeightOk: Boolean,
val calibrationOk: Boolean,
val calibrationRangeOk: Boolean,
val calibrationVisualsOk: Boolean,
val tackerModelOk: Boolean,
val trackerModelOk: Boolean,
val spineModeOk: Boolean,
val avatarMeasurementOk: Boolean,
val avatarMeasurementTypeOk: Boolean,
)
abstract class VRCConfigHandler {
@@ -98,13 +98,14 @@ class VRCConfigHandlerStub : VRCConfigHandler() {
}
interface VRCConfigListener {
fun onChange(validity: VRCConfigValidity, values: VRCConfigValues, recommended: VRCConfigRecommendedValues)
fun onChange(validity: VRCConfigValidity, values: VRCConfigValues, recommended: VRCConfigRecommendedValues, muted: List<String>)
}
class VRChatConfigManager(val server: VRServer, private val handler: VRCConfigHandler) {
private val listeners: MutableList<VRCConfigListener> = CopyOnWriteArrayList()
var currentValues: VRCConfigValues? = null
var currentValidity: VRCConfigValidity? = null
val isSupported: Boolean
get() = handler.isSupported
@@ -113,6 +114,31 @@ class VRChatConfigManager(val server: VRServer, private val handler: VRCConfigHa
handler.initHandler(::onChange)
}
fun toggleMuteWarning(key: String) {
val keys = VRCConfigValidity::class.java.declaredFields.asSequence().map { p -> p.name }
if (!keys.contains(key)) return
if (!server.configManager.vrConfig.vrcConfig.mutedWarnings.contains(key)) {
server.configManager.vrConfig.vrcConfig.mutedWarnings.add(key)
} else {
server.configManager.vrConfig.vrcConfig.mutedWarnings.remove(key)
}
server.configManager.saveConfig()
val recommended = recommendedValues()
val validity = currentValidity ?: return
val values = currentValues ?: return
listeners.forEach {
it.onChange(
validity,
values,
recommended,
server.configManager.vrConfig.vrcConfig.mutedWarnings,
)
}
}
/**
* shoulderTrackingDisabled should be true if:
* The user isn't tracking their whole arms from their controllers:
@@ -160,20 +186,28 @@ class VRChatConfigManager(val server: VRServer, private val handler: VRCConfigHa
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,
trackerModelOk = values.trackerModel == recommended.trackerModel,
calibrationRangeOk = 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,
avatarMeasurementTypeOk = values.avatarMeasurementType == recommended.avatarMeasurementType,
shoulderWidthCompensationOk = values.shoulderWidthCompensation == recommended.shoulderWidthCompensation,
)
fun forceUpdate() {
val values = currentValues
if (values != null) {
this.onChange(values)
}
}
fun onChange(values: VRCConfigValues) {
val recommended = recommendedValues()
val validity = checkValidity(values, recommended)
currentValidity = validity
currentValues = values
listeners.forEach {
it.onChange(validity, values, recommended)
it.onChange(validity, values, recommended, server.configManager.vrConfig.vrcConfig.mutedWarnings)
}
}
}

Some files were not shown because too many files have changed in this diff Show More