diff --git a/Cargo.lock b/Cargo.lock index 1ba7d96111..52e51f2b63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,7 +147,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-protocols", - "zbus", + "zbus 4.0.1", ] [[package]] @@ -174,6 +174,31 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-executor" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.3.4" @@ -1146,7 +1171,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.0", + "toml 0.9.2", "vswhom", "winreg 0.55.0", ] @@ -2600,6 +2625,19 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3303,6 +3341,15 @@ dependencies = [ "toml_edit 0.20.2", ] +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit 0.22.27", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -4105,6 +4152,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-http", + "tauri-plugin-opener", "tauri-plugin-os", "tauri-plugin-shell", "tauri-plugin-store", @@ -4583,6 +4631,28 @@ dependencies = [ "urlpattern", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecee219f11cdac713ab32959db5d0cceec4810ba4f4458da992292ecf9660321" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit 0.3.1", + "objc2-foundation 0.3.1", + "open", + "schemars", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "url", + "windows", + "zbus 5.4.0", +] + [[package]] name = "tauri-plugin-os" version = "2.0.1" @@ -4603,9 +4673,9 @@ dependencies = [ [[package]] name = "tauri-plugin-shell" -version = "2.0.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "371fb9aca2823990a2d0db7970573be5fdf07881fcaa2b835b29631feb84aec1" +checksum = "2b9ffadec5c3523f11e8273465cacb3d86ea7652a28e6e2a2e9b5c182f791d25" dependencies = [ "encoding_rs", "log", @@ -4618,7 +4688,7 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 1.0.64", + "thiserror 2.0.12", "tokio", ] @@ -4935,9 +5005,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", @@ -4945,7 +5015,7 @@ dependencies = [ "toml_datetime 0.7.0", "toml_parser", "toml_writer", - "winnow 0.7.11", + "winnow 0.7.12", ] [[package]] @@ -4999,16 +5069,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]] @@ -5019,9 +5089,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" @@ -6069,9 +6139,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", ] @@ -6239,7 +6309,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.27.1", "ordered-stream", "rand 0.8.5", "serde", @@ -6251,9 +6321,45 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.0.1", + "zbus_names 3.0.0", + "zvariant 4.0.0", +] + +[[package]] +name = "zbus" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbddd8b6cb25d5d8ec1b23277b45299a98bfb220f1761ca11e186d5c702507f8" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "serde", + "serde_repr", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.59.0", + "winnow 0.7.12", + "xdg-home", + "zbus_macros 5.4.0", + "zbus_names 4.2.0", + "zvariant 5.6.0", ] [[package]] @@ -6267,7 +6373,22 @@ dependencies = [ "quote", "regex", "syn 1.0.109", - "zvariant_utils", + "zvariant_utils 1.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac404d48b4e9cf193c8b49589f3280ceca5ff63519e7e64f55b4cf9c47ce146" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.87", + "zbus_names 4.2.0", + "zvariant 5.6.0", + "zvariant_utils 3.2.0", ] [[package]] @@ -6278,7 +6399,19 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.0.0", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.7.12", + "zvariant 5.6.0", ] [[package]] @@ -6373,7 +6506,21 @@ dependencies = [ "serde", "static_assertions", "url", - "zvariant_derive", + "zvariant_derive 4.0.0", +] + +[[package]] +name = "zvariant" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91b3680bb339216abd84714172b5138a4edac677e641ef17e1d8cb1b3ca6e6f" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.12", + "zvariant_derive 5.6.0", + "zvariant_utils 3.2.0", ] [[package]] @@ -6386,7 +6533,20 @@ dependencies = [ "proc-macro2", "quote", "syn 1.0.109", - "zvariant_utils", + "zvariant_utils 1.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8c68501be459a8dbfffbe5d792acdd23b4959940fc87785fb013b32edbc208" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.87", + "zvariant_utils 3.2.0", ] [[package]] @@ -6399,3 +6559,17 @@ dependencies = [ "quote", "syn 1.0.109", ] + +[[package]] +name = "zvariant_utils" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "static_assertions", + "syn 2.0.87", + "winnow 0.7.12", +] diff --git a/TRADEMARK.md b/TRADEMARK.md index a4332e3a3b..919ae71886 100644 --- a/TRADEMARK.md +++ b/TRADEMARK.md @@ -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*. \ No newline at end of file diff --git a/gui/package.json b/gui/package.json index 376770e7f2..70c581ad31 100644 --- a/gui/package.json +++ b/gui/package.json @@ -19,9 +19,10 @@ "@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-os": "^2.0.0", - "@tauri-apps/plugin-shell": "^2.0.0", + "@tauri-apps/plugin-shell": "^2.3.0", "@tauri-apps/plugin-store": "^2.0.0", "@tweenjs/tween.js": "^25.0.0", "@twemoji/svg": "^15.0.0", diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index 3cf68761ad..82196362a3 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -236,9 +236,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 @@ -260,6 +260,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 @@ -275,8 +276,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 @@ -452,6 +453,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 @@ -459,9 +461,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 @@ -648,7 +653,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 @@ -864,6 +869,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 @@ -1117,7 +1132,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, both feets 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. @@ -1290,6 +1309,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 @@ -1547,3 +1568,57 @@ 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 didnt perform the tracker calibration. Please let your slimes, highlited in yellow, rest on a static 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. + See how to fix it here. + *[many] Some of your network adapters are set to public: + {$adapters} + This is not recommended for SlimeVR to function properly. + See how to fix it here. +} +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-connected_trackers = {$count} trackers connected diff --git a/gui/public/images/mounting/MountingFeets.webp b/gui/public/images/mounting/MountingFeets.webp new file mode 100644 index 0000000000..e56c6fc138 Binary files /dev/null and b/gui/public/images/mounting/MountingFeets.webp differ diff --git a/gui/public/images/mounting/MountingFeetsSide.webp b/gui/public/images/mounting/MountingFeetsSide.webp new file mode 100644 index 0000000000..3b1240e065 Binary files /dev/null and b/gui/public/images/mounting/MountingFeetsSide.webp differ diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index f7e5ea6135..cdce815b7f 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -33,7 +33,7 @@ tauri-runtime = "2.0" tauri-plugin-dialog = "2.0" tauri-plugin-fs = "2.4.1" tauri-plugin-os = "2.0" -tauri-plugin-shell = "2.0" +tauri-plugin-shell = "2.3.0" tauri-plugin-store = "2.0" flexi_logger = "0.29" log-panics = { version = "2", features = ["with-backtrace"] } @@ -54,6 +54,7 @@ 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" [target.'cfg(windows)'.dependencies] diff --git a/gui/src-tauri/capabilities/migrated.json b/gui/src-tauri/capabilities/migrated.json index 6cd8fc3dfc..ec2dc46c21 100644 --- a/gui/src-tauri/capabilities/migrated.json +++ b/gui/src-tauri/capabilities/migrated.json @@ -30,7 +30,25 @@ "fs:allow-exists", { "identifier": "fs:scope", - "allow": [{ "path": "$APPDATA" }, { "path": "$APPDATA/**" }] + "allow": [ + { + "path": "$APPDATA" + }, + { + "path": "$APPDATA/**" + } + ] + }, + { + "identifier": "opener:allow-open-url", + "allow": [ + { + "url": "steam:*" + }, + { + "url": "ms-settings:network" + } + ] }, { "identifier": "http:default", diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 4968ae2f38..2230f0a05a 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -249,6 +249,7 @@ fn setup_tauri( ) -> Result { let exit_flag_terminated = exit_flag.clone(); tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_os::init()) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 84c869f554..38165e3f2c 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -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() { + } @@ -91,7 +93,7 @@ function Layout() { + } @@ -99,11 +101,19 @@ function Layout() { + } /> + + + + } + /> + } @@ -135,6 +145,7 @@ function Layout() { } /> } /> } /> + } /> } /> - +
@@ -307,7 +318,7 @@ export default function App() { {websocketAPI.isConnected && }
-
+
diff --git a/gui/src/components/ClearMountingButton.tsx b/gui/src/components/ClearMountingButton.tsx deleted file mode 100644 index 384ab4aa26..0000000000 --- a/gui/src/components/ClearMountingButton.tsx +++ /dev/null @@ -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 ( - - } - onClick={clearMounting} - disabled={!trackerWithMounting} - /> - - ); -} diff --git a/gui/src/components/MainLayout.scss b/gui/src/components/MainLayout.scss index 7ab819adbd..acc2601b6d 100644 --- a/gui/src/components/MainLayout.scss +++ b/gui/src/components/MainLayout.scss @@ -1,22 +1,67 @@ +:root { + --toolbar-h: 140px; +} + .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%; } } diff --git a/gui/src/components/MainLayout.tsx b/gui/src/components/MainLayout.tsx index 3cd488eaa3..2fce31f130 100644 --- a/gui/src/components/MainLayout.tsx +++ b/gui/src/components/MainLayout.tsx @@ -9,20 +9,26 @@ 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, + showToolbarSettings = false, }: { 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 +64,42 @@ export function MainLayout({ }); return ( -
-
-
- -
-
- -
-
- {children} -
- {!isMobile && widgets && ( -
- -
+
+
+ +
+
+ +
+ +
+ {children}
+ {full && isMobile && completion !== 'complete' && ( + + )} + {full && ( +
+ +
+ )} + {!isMobile && full && ( +
+ +
+ )}
); } diff --git a/gui/src/components/Navbar.tsx b/gui/src/components/Navbar.tsx index 50a5b0d43b..71dffc54e9 100644 --- a/gui/src/components/Navbar.tsx +++ b/gui/src/components/Navbar.tsx @@ -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({ >
{icon}
- }> + }> {l10n.getString('navbar-home')} } + icon={} > {l10n.getString('navbar-mounting')} diff --git a/gui/src/components/Sidebar.tsx b/gui/src/components/Sidebar.tsx new file mode 100644 index 0000000000..90689a1c24 --- /dev/null +++ b/gui/src/components/Sidebar.tsx @@ -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 ( + <> + + } + > +
+ {userHeight} +
+
+
+
+ {bvhAvailable && ( + + } + preferedDirection="top" + > +
toggleBVH()} + > + {bvhState === 'idle' && } + {bvhState !== 'idle' && ( +
+ )} +
+ + )} + + } + preferedDirection="top" + > +
toggleTracking()} + > + {!paused && } + {paused && } +
+
+ + } + preferedDirection="top" + > +
+ +
+
+
+
+ + ); +} + +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 ( +
+ 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; + }, + }); + }} + > + } + > +
toggleRender()} + > + +
+
+ +
+ ); +} + +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 ( + <> +
+ +
+
+ +
+ + ); +} diff --git a/gui/src/components/Toolbar.tsx b/gui/src/components/Toolbar.tsx new file mode 100644 index 0000000000..c48dc254a0 --- /dev/null +++ b/gui/src/components/Toolbar.tsx @@ -0,0 +1,196 @@ +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, connectedTrackersAtom } from '@/store/app-store'; +import { useBreakpoint } from '@/hooks/breakpoint'; +import { useMemo, useState } from 'react'; +import { HomeSettingsModal } from './home/HomeSettingsModal'; +import { LayoutIcon } from './commons/icon/LayoutIcon'; +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 ( +
+ ); +} + +function BasicResetButton(options: UseResetOptions & { customName?: string }) { + const { isMd } = useBreakpoint('md'); + const { + triggerReset, + status, + name: resetName, + timer, + disabled, + duration, + } = useReset(options); + + const progress = status === 'counting' ? 1 - (timer - 1) / duration : 0; + + const name = options.customName || resetName; + + const skiReset = + options.type === ResetType.Mounting && options.group === 'default'; + + return ( + } + preferedDirection="top" + > +
!disabled && triggerReset()} + > +
+ +
+ +
+ +
+ +
+
+ ); +} + +export function Toolbar({ showSettings }: { showSettings: boolean }) { + const trackers = useAtomValue(connectedTrackersAtom); + const assignedTrackers = useAtomValue(assignedTrackersAtom); + + const settingsOpenState = useState(false); + const [, setSettingsOpen] = settingsOpenState; + + 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 + ); + + return { + groupVisibility, + visibleGroups: Object.values(groupVisibility).filter((v) => v).length, + }; + }, [assignedTrackers]); + + return ( + <> + +
+
+
+ +
+ + +
+
+
+ +
+ + + {groupVisibility['fingers'] && ( + + )} +
+
+
+
+ +
+ {showSettings && ( +
setSettingsOpen(true)} + > + +
+ )} +
+
+ + ); +} diff --git a/gui/src/components/WidgetsComponent.tsx b/gui/src/components/WidgetsComponent.tsx deleted file mode 100644 index 2c926faf2d..0000000000 --- a/gui/src/components/WidgetsComponent.tsx +++ /dev/null @@ -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 ( -
- {unprioritizedStatuses.map((status) => ( - - ), - }} - > - - {`Warning, you should fix ${StatusData[status.dataType]}`} - - - ))} -
- ); -} - -export function WidgetsComponent() { - const { config } = useConfig(); - - return ( - <> -
- - - - - - - {!window.__ANDROID__?.isThere() && } - -
-
- -
-
- -
- - {config?.debug && ( -
- -
- )} - - ); -} diff --git a/gui/src/components/commons/A.tsx b/gui/src/components/commons/A.tsx index 9085b8a976..834f0abca3 100644 --- a/gui/src/components/commons/A.tsx +++ b/gui/src/components/commons/A.tsx @@ -1,14 +1,23 @@ 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 ( href && open(href).catch(() => window.open(href, '_blank')) } - className="underline" + className={classNames(className, 'underline', 'cursor-pointer')} > {children} diff --git a/gui/src/components/commons/Button.tsx b/gui/src/components/commons/Button.tsx index c85b579f85..7e7a4542a3 100644 --- a/gui/src/components/commons/Button.tsx +++ b/gui/src/components/commons/Button.tsx @@ -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({
{icon && ( -
+
{icon}
)} @@ -44,7 +45,9 @@ export type ButtonProps = { loading?: boolean; rounded?: boolean; state?: any; -} & React.ButtonHTMLAttributes; + id?: string; +} & React.ButtonHTMLAttributes & + Omit; 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 ? ( disabled && ev.preventDefault()} > - {children} + {id && ( + + {children} + + )} + {!id && children} ) : ( ); + + return content; } diff --git a/gui/src/components/commons/Checkbox.tsx b/gui/src/components/commons/Checkbox.tsx index c3adbd5803..c80b388e3d 100644 --- a/gui/src/components/commons/Checkbox.tsx +++ b/gui/src/components/commons/Checkbox.tsx @@ -3,7 +3,7 @@ import { 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 function CheckBox({ @@ -29,7 +29,9 @@ export function CheckBox({ const classes = useMemo(() => { const vriantsMap = { checkbox: { - checkbox: CHECKBOX_CLASSES, + checkbox: classNames(CHECKBOX_CLASSES, { + 'brightness-50 hover:cursor-not-allowed': disabled, + }), toggle: '', pin: '', }, @@ -42,7 +44,7 @@ export function CheckBox({ }, }; return vriantsMap[variant]; - }, [variant]); + }, [variant, disabled]); return ( diff --git a/gui/src/components/commons/ProgressBar.tsx b/gui/src/components/commons/ProgressBar.tsx index 61afbae020..7095fa4d01 100644 --- a/gui/src/components/commons/ProgressBar.tsx +++ b/gui/src/components/commons/ProgressBar.tsx @@ -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 (
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; + childRef: MutableRefObject; children: ReactNode; -} & Pick) { +} & Pick< + TooltipProps, + 'mode' | 'preferedDirection' | 'blockedDirections' | 'spacing' +>) { const tooltipRef = useRef(null); const [tooltipStyle, setTooltipStyle] = useState(); @@ -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; + childRef: MutableRefObject; }) { const touchTimestamp = useRef(0); const touchTimeout = useRef(0); @@ -396,6 +410,7 @@ export function DrawerTooltip({ }; } }, []); + // FIXME: Completely broken not sure why. Will be solved when tooltips on mobile actually work return ( <> @@ -444,33 +459,53 @@ export function Tooltip({ content, children, preferedDirection, + blockedDirections = [], mode = 'center', + variant = 'auto', disabled = false, + tag = 'div', + spacing = 20, }: TooltipProps) { - const childRef = useRef(null); + const childRef = useRef(null); const { isMobile } = useBreakpoint('mobile'); - const portal = createPortal( - isMobile ? ( + let portal = null; + if (variant === 'auto') { + portal = isMobile ? ( {content} ) : ( {content} - ), - document.body - ); + ); + } + + if (variant === 'drawer') + portal = {content}; + + if (variant === 'floating') + portal = ( + + {content} + + ); return ( <> -
- {children} -
- {!disabled && portal} + {createElement(tag, { className: 'contents', ref: childRef }, children)} + {!disabled && createPortal(portal, document.body)} ); } diff --git a/gui/src/components/commons/Typography.tsx b/gui/src/components/commons/Typography.tsx index fb1917ede7..03de1ea738 100644 --- a/gui/src/components/commons/Typography.tsx +++ b/gui/src/components/commons/Typography.tsx @@ -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) { 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 ( + + {element} + + ); + } + + return element; } diff --git a/gui/src/components/commons/icon/ChecklistIcon.tsx b/gui/src/components/commons/icon/ChecklistIcon.tsx new file mode 100644 index 0000000000..e710a40b3f --- /dev/null +++ b/gui/src/components/commons/icon/ChecklistIcon.tsx @@ -0,0 +1,12 @@ +export function Checklist({ size = 24 }: { size?: number }) { + return ( + + + + ); +} diff --git a/gui/src/components/commons/icon/ClearIcon.tsx b/gui/src/components/commons/icon/ClearIcon.tsx new file mode 100644 index 0000000000..85bbde08d3 --- /dev/null +++ b/gui/src/components/commons/icon/ClearIcon.tsx @@ -0,0 +1,12 @@ +export function ClearIcon({ size = 24 }: { size?: number }) { + return ( + + + + ); +} diff --git a/gui/src/components/commons/icon/GearIcon.tsx b/gui/src/components/commons/icon/GearIcon.tsx index 668ea636bd..87a46a1b32 100644 --- a/gui/src/components/commons/icon/GearIcon.tsx +++ b/gui/src/components/commons/icon/GearIcon.tsx @@ -1,8 +1,8 @@ -export function GearIcon() { +export function GearIcon({ size = 20 }: { size?: number }) { return ( diff --git a/gui/src/components/commons/icon/HomeIcon.tsx b/gui/src/components/commons/icon/HomeIcon.tsx new file mode 100644 index 0000000000..18d4f03df9 --- /dev/null +++ b/gui/src/components/commons/icon/HomeIcon.tsx @@ -0,0 +1,12 @@ +export function HomeIcon() { + return ( + + + + ); +} diff --git a/gui/src/components/commons/icon/LayoutIcon.tsx b/gui/src/components/commons/icon/LayoutIcon.tsx new file mode 100644 index 0000000000..a82a78480d --- /dev/null +++ b/gui/src/components/commons/icon/LayoutIcon.tsx @@ -0,0 +1,12 @@ +export function LayoutIcon({ size = 16 }: { size?: number }) { + return ( + + + + ); +} diff --git a/gui/src/components/commons/icon/SkiIcon.tsx b/gui/src/components/commons/icon/SkiIcon.tsx new file mode 100644 index 0000000000..e6dee66267 --- /dev/null +++ b/gui/src/components/commons/icon/SkiIcon.tsx @@ -0,0 +1,12 @@ +export function SkiIcon({ size = 24 }: { size?: number }) { + return ( + + + + ); +} diff --git a/gui/src/components/home/Home.tsx b/gui/src/components/home/Home.tsx index 8685c27252..ae6228bf5a 100644 --- a/gui/src/components/home/Home.tsx +++ b/gui/src/components/home/Home.tsx @@ -1,31 +1,21 @@ -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 { useTrackingChecklist } from '@/hooks/tracking-checklist'; +import { Checklist } from '@/components/commons/icon/ChecklistIcon'; export function Home() { const { l10n } = useLocalization(); const { config } = useConfig(); const trackers = useAtomValue(flatTrackersAtom); - const { statuses } = useStatusContext(); - const { invalidConfig } = useVRCConfig(); + const { highlightedTrackers } = useTrackingChecklist(); const navigate = useNavigate(); const sendToSettings = (tracker: TrackerDataT) => { @@ -34,15 +24,6 @@ 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]); - return (
-
-
- {filteredStatuses - .filter(([, status]) => status.prioritized) - .map(([, status]) => ( - - - {`Warning, you should fix ${StatusData[status.dataType]}`} - - - ))} - {invalidConfig && ( - -
-
- -
-
- -
- -
- -
-
-
- )} -
-
- {trackers.length === 0 && ( -
- - {l10n.getString('home-no_trackers')} - -
- )} + + + +
+ {trackers.length === 0 && ( +
+ + {l10n.getString('home-no_trackers')} + +
+ )} - {!config?.debug && trackers.length > 0 && ( -
- {trackers.map(({ tracker, device }, index) => ( - sendToSettings(tracker)} - smol - showUpdates - interactable - warning={Object.values(statuses).some((status) => - trackerStatusRelated(tracker, status) - )} - /> - ))} -
- )} - {config?.debug && trackers.length > 0 && ( -
- sendToSettings(tracker)} - > -
- )} -
+ {config?.homeLayout == 'default' && trackers.length > 0 && ( +
+ {trackers.map(({ tracker, device }, index) => ( + sendToSettings(tracker)} + smol + showUpdates + interactable + warning={ + !!highlightedTrackers?.trackers.find( + (t) => + t?.deviceId?.id === tracker.trackerId?.deviceId?.id && + t?.trackerNum === tracker.trackerId?.trackerNum + ) && highlightedTrackers.step + } + /> + ))} +
+ )} + {config?.homeLayout === 'table' && trackers.length > 0 && ( +
+ sendToSettings(tracker)} + > +
+ )}
); diff --git a/gui/src/components/home/HomeSettingsModal.tsx b/gui/src/components/home/HomeSettingsModal.tsx new file mode 100644 index 0000000000..f9bae41e66 --- /dev/null +++ b/gui/src/components/home/HomeSettingsModal.tsx @@ -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>]; +}) { + return ( + { + open[1](false); + }} + > +
+ + +
+
+
+
+ ); +} diff --git a/gui/src/components/home/ResetButton.tsx b/gui/src/components/home/ResetButton.tsx index 1f809e891f..12d0f50efd 100644 --- a/gui/src/components/home/ResetButton.tsx +++ b/gui/src/components/home/ResetButton.tsx @@ -1,219 +1,68 @@ -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 classNames from 'classnames'; +import { useReset, UseResetOptions } from '@/hooks/reset'; import { - MountingResetIcon, - YawResetIcon, FullResetIcon, + YawResetIcon, } from '@/components/commons/icon/ResetIcon'; -import { useStatusContext } from '@/hooks/status-system'; -import classNames from 'classnames'; +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 ; + if (options.type === ResetType.Full) return ; + if (options.type === ResetType.Mounting) { + if (options.group === 'default') return ; + if (options.group === 'feet') return ; + if (options.group === 'fingers') + return ; + } +} + 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); - 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 ; - case ResetType.Mounting: - switch (bodyPartsToReset) { - case 'default': - return ; - case 'feet': - return ; - case 'fingers': - return ; - } - } - return ; - }; - - 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 ( - ) : ( - - {!isCounting || type === ResetType.Yaw ? text : String(timer)} - ); } diff --git a/gui/src/components/onboarding/OnboardingLayout.tsx b/gui/src/components/onboarding/OnboardingLayout.tsx index 671ada5e6d..3302ac65d0 100644 --- a/gui/src/components/onboarding/OnboardingLayout.tsx +++ b/gui/src/components/onboarding/OnboardingLayout.tsx @@ -34,8 +34,6 @@ export function OnboardingLayout({ children }: { children: ReactNode }) {
) : ( - - {children} - + {children} ); } diff --git a/gui/src/components/onboarding/pages/CalibrationTutorial.tsx b/gui/src/components/onboarding/pages/CalibrationTutorial.tsx index ab70701705..395b59d3e4 100644 --- a/gui/src/components/onboarding/pages/CalibrationTutorial.tsx +++ b/gui/src/components/onboarding/pages/CalibrationTutorial.tsx @@ -9,7 +9,7 @@ import { useCountdown } from '@/hooks/countdown'; import classNames from 'classnames'; import { TaybolIcon } from '@/components/commons/icon/TaybolIcon'; import { useRestCalibrationTrackers } from '@/hooks/imu-logic'; -import { averageVector, Vector3FromVec3fT } from '@/maths/vector3'; +import { Vector3FromVec3fT } from '@/maths/vector3'; import { Vector3 } from 'three'; import { useTimeout } from '@/hooks/timeout'; import { useAtomValue } from 'jotai'; @@ -23,7 +23,9 @@ export enum CalibrationStatus { } export const IMU_CALIBRATION_TIME = 4; -const ACCEL_TOLERANCE = 0.2; // m/s^2 +export const IMU_SETTLE_TIME = 1; +const ACCEL_TOLERANCE = 0.5; // m/s^2 +const ACCEL_HYSTERESIS = 0.1; // m/s^2 export function CalibrationTutorialPage() { const { l10n } = useLocalization(); @@ -32,42 +34,62 @@ export function CalibrationTutorialPage() { CalibrationStatus.WAITING ); const [skipButton, setSkipButton] = useState(false); + const [settled, setSettled] = useState(false); const { timer, isCounting, startCountdown, abortCountdown } = useCountdown({ - duration: IMU_CALIBRATION_TIME, - onCountdownEnd: () => setCalibrationStatus(CalibrationStatus.SUCCESS), + duration: settled ? IMU_CALIBRATION_TIME : IMU_SETTLE_TIME, + onCountdownEnd: () => + settled + ? setCalibrationStatus(CalibrationStatus.SUCCESS) + : setSettled(true), }); useTimeout(() => setSkipButton(true), 10000); const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom); const restCalibrationTrackers = useRestCalibrationTrackers(connectedIMUTrackers); const [rested, setRested] = useState(false); - const lastValueMap = useRef(new Map()); + const lastValueMap = useRef(new Map()); useEffect(() => { const accelLength = restCalibrationTrackers.every((x) => { if ( + x.device?.id?.id === undefined || x.tracker.trackerId?.trackerNum === undefined || x.tracker.trackerId.deviceId?.id === undefined || !x.tracker.linearAcceleration ) return false; - const trackerId = - x.tracker.trackerId.trackerNum + (x.tracker.trackerId.trackerNum << 8); - const lastValues = lastValueMap.current.get(trackerId) ?? []; - lastValueMap.current.set(trackerId, lastValues); + const trackerId = x.device.id.id; + // x.tracker.trackerId.trackerNum + (x.tracker.trackerId.deviceId.id << 8); + const lastValue = lastValueMap.current.get(trackerId) ?? new Vector3(); + lastValueMap.current.set(trackerId, lastValue); const vec3 = Vector3FromVec3fT(x.tracker.linearAcceleration); - if (lastValues.length > 5) { - lastValues.shift(); - const avg = averageVector(lastValues).lengthSq(); - lastValues.push(vec3); - return vec3.lengthSq() <= avg + ACCEL_TOLERANCE ** 2; + + if (vec3.lengthSq() > ACCEL_TOLERANCE ** 2) { + return false; + } + + const delta = new Vector3(); + delta.subVectors(lastValue, vec3); + + if (delta.lengthSq() > ACCEL_HYSTERESIS ** 2) { + lastValue.copy(vec3); + return false; } - lastValues.push(vec3); - return false; + + return true; }); - setRested(accelLength || restCalibrationTrackers.length === 0); + if (accelLength && !settled && !isCounting) { + abortCountdown(); + startCountdown(); + } else if (!accelLength && !settled && isCounting) { + abortCountdown(); + } else if (!accelLength && settled) { + setSettled(false); + } + + setRested(settled || restCalibrationTrackers.length === 0); }, [restCalibrationTrackers]); useEffect(() => { @@ -145,7 +167,7 @@ export function CalibrationTutorialPage() {
+ + ), + }} + vars={{ + count: extraData.adapters.length, + adapters: extraData.adapters.join(', '), + }} + > + WARNING + +
+ ); +} + 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,23 +271,13 @@ export function ConnectTrackersPage() { > Conditional tip - {filteredStatuses.map(([, status]) => ( -
- - ), - }} - > - - {`Warning, you should fix ${StatusData[status.dataType]}`} - - -
- ))} + {invalidNetworkProfile && ( + + )}
diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx b/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx index 8a23b2cb29..5afa368eea 100644 --- a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx +++ b/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx @@ -69,11 +69,7 @@ export function PreparationStep({ > {l10n.getString('onboarding-automatic_mounting-prev_step')} - +
diff --git a/gui/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx b/gui/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx index bdedd32385..47340f82c8 100644 --- a/gui/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx +++ b/gui/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx @@ -58,8 +58,8 @@ export function MountingResetStep({ {l10n.getString('onboarding-automatic_mounting-prev_step')}
diff --git a/gui/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx b/gui/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx index ba5a145e74..ceb8072bc6 100644 --- a/gui/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx +++ b/gui/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx @@ -69,11 +69,7 @@ export function PreparationStep({ > {l10n.getString('onboarding-automatic_mounting-prev_step')} - + diff --git a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx b/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx index 940bfbc2e2..0a5159ade3 100644 --- a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx +++ b/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx @@ -53,11 +53,7 @@ export function PreparationStep({ - + ); diff --git a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx b/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx index 0c1ce3971f..c40614b81d 100644 --- a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx +++ b/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx @@ -281,7 +281,7 @@ export function TrackersAssignPage() {
- + {l10n.getString('onboarding-assign_trackers-title')} diff --git a/gui/src/components/providers/StatusSystemContext.tsx b/gui/src/components/providers/StatusSystemContext.tsx deleted file mode 100644 index f04bf420b5..0000000000 --- a/gui/src/components/providers/StatusSystemContext.tsx +++ /dev/null @@ -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 ( - {children} - ); -} diff --git a/gui/src/components/settings/SettingsPageLayout.tsx b/gui/src/components/settings/SettingsPageLayout.tsx index 29329894fd..d69057ca48 100644 --- a/gui/src/components/settings/SettingsPageLayout.tsx +++ b/gui/src/components/settings/SettingsPageLayout.tsx @@ -64,7 +64,7 @@ export function SettingsPagePaneLayout({ {...props} >
-
+
{icon}
diff --git a/gui/src/components/settings/SettingsSidebar.tsx b/gui/src/components/settings/SettingsSidebar.tsx index ffb09640a8..3628c55e69 100644 --- a/gui/src/components/settings/SettingsSidebar.tsx +++ b/gui/src/components/settings/SettingsSidebar.tsx @@ -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} + ); } export function SettingsSidebar() { - const { l10n } = useLocalization(); const { state: vrcConfigState } = useVRCConfig(); return (
- - {l10n.getString('settings-sidebar-title')} - +
- - {l10n.getString('settings-sidebar-general')} - +
- - SteamVR - - - {l10n.getString('settings-sidebar-stay_aligned')} - - - {l10n.getString('settings-sidebar-tracker_mechanics')} - - - {l10n.getString('settings-sidebar-fk_settings')} - - - {l10n.getString('settings-sidebar-gesture_control')} - + + + + +
- - {l10n.getString('settings-sidebar-interface')} - +
- - {l10n.getString('settings-sidebar-notifications')} - - - {l10n.getString('settings-sidebar-behavior')} - - - {l10n.getString('settings-sidebar-appearance')} - + + + + +
OSC
- - {l10n.getString('settings-sidebar-osc_router')} - - - {l10n.getString('settings-sidebar-osc_trackers')} - - - VMC - + + +
- - {l10n.getString('settings-sidebar-utils')} - +
- - {l10n.getString('settings-sidebar-serial')} - - - {l10n.getString('settings-sidebar-firmware-tool')} - + +
{vrcConfigState?.isSupported && (
- - {l10n.getString('settings-sidebar-vrc_warnings')} - +
)}
- - {l10n.getString('settings-sidebar-advanced')} - +
diff --git a/gui/src/components/settings/pages/GeneralSettings.tsx b/gui/src/components/settings/pages/GeneralSettings.tsx index 958a58b447..d5ed37eb8b 100644 --- a/gui/src/components/settings/pages/GeneralSettings.tsx +++ b/gui/src/components/settings/pages/GeneralSettings.tsx @@ -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(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); diff --git a/gui/src/components/settings/pages/HomeScreenSettings.tsx b/gui/src/components/settings/pages/HomeScreenSettings.tsx new file mode 100644 index 0000000000..33c2753b98 --- /dev/null +++ b/gui/src/components/settings/pages/HomeScreenSettings.tsx @@ -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 }; +export function TrackingChecklistSettings({ + variant, +}: { + variant: 'settings' | 'modal'; +}) { + const { l10n } = useLocalization(); + const { ignoredSteps, steps, ignoreStep } = useTrackingChecklist(); + + const { control, reset, handleSubmit } = useForm({ + 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 ( +
+
+ + +
+
+ {steps + .filter((step) => step.enabled) + .map((step) => ( +
+ +
+ ))} +
+
+ ); +} + +export function LayoutSelector({ + children, + name, + active = false, + onClick, +}: { + children: ReactNode; + name: string; + active: boolean; + onClick: () => void; +}) { + return ( +
+
+ +
+
+ {children} +
+ ); +} + +export function HomeLayoutSettings() { + const { config, setConfig } = useConfig(); + + const setLayout = (layout: Config['homeLayout']) => + setConfig({ homeLayout: layout }); + + return ( +
+
+ + +
+
+ setLayout('default')} + > +
+
+
+
+
+
+
+ setLayout('table')} + > +
+
+
+
+
+
+
+
+
+ ); +} + +export function HomeScreenSettings() { + return ( + +
+ }> + + + + }> + + + +
+
+ ); +} diff --git a/gui/src/components/settings/pages/InterfaceSettings.tsx b/gui/src/components/settings/pages/InterfaceSettings.tsx index 62ce63ceab..4cfdd0d668 100644 --- a/gui/src/components/settings/pages/InterfaceSettings.tsx +++ b/gui/src/components/settings/pages/InterfaceSettings.tsx @@ -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: { @@ -283,16 +284,19 @@ export function InterfaceSettings() { )}
-
- +
+
+ +
+ {config?.debug && }
diff --git a/gui/src/components/tracker/TrackerCard.tsx b/gui/src/components/tracker/TrackerCard.tsx index 3bf142548b..90e4867ba1 100644 --- a/gui/src/components/tracker/TrackerCard.tsx +++ b/gui/src/components/tracker/TrackerCard.tsx @@ -4,6 +4,7 @@ import { DeviceDataT, TrackerDataT, TrackerStatus as TrackerStatusEnum, + TrackingChecklistStepT, } from 'solarxr-protocol'; import { Typography } from '@/components/commons/Typography'; import { TrackerBattery } from './TrackerBattery'; @@ -18,6 +19,8 @@ import { useAppContext } from '@/hooks/app'; import { Tooltip } from '@/components/commons/Tooltip'; import { Localized } from '@fluent/react'; import { checkForUpdate } from '@/hooks/firmware-update'; +import { WarningIcon } from '@/components/commons/icon/WarningIcon'; +import { trackingchecklistIdtoLabel } from '@/hooks/tracking-checklist'; function UpdateIcon({ showUpdate, @@ -33,7 +36,7 @@ function UpdateIcon({
@@ -125,21 +128,42 @@ function TrackerBig({ function TrackerSmol({ device, tracker, + warning, }: { tracker: TrackerDataT; device?: DeviceDataT; + warning?: TrackingChecklistStepT | boolean; }) { const { useName } = useTracker(tracker); const trackerName = useName(); return ( -
-
- +
+
+ {warning && ( +
+ +
+ )} +
+ +
-
- + +
+ {trackerName} @@ -191,7 +215,7 @@ export function TrackerCard({ bg?: string; shakeHighlight?: boolean; onClick?: MouseEventHandler; - warning?: boolean; + warning?: TrackingChecklistStepT | boolean; showUpdates?: boolean; }) { const { currentFirmwareRelease } = useAppContext(); @@ -212,8 +236,8 @@ 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', + // warning && + // 'outline outline-2 -outline-offset-2 outline-status-warning', bg )} style={ @@ -226,7 +250,27 @@ export function TrackerCard({ : {} } > - {smol && } + {smol && ( + + + +
+ ) + } + > + + + )} {!smol && }
{showUpdate && diff --git a/gui/src/components/tracker/TrackerWifi.tsx b/gui/src/components/tracker/TrackerWifi.tsx index d7a10c6ed2..96a1082510 100644 --- a/gui/src/components/tracker/TrackerWifi.tsx +++ b/gui/src/components/tracker/TrackerWifi.tsx @@ -6,7 +6,7 @@ export function TrackerWifi({ ping, rssiShowNumeric, disabled, - textColor = 'secondary', + textColor = 'primary', }: { rssi: number | null; ping: number | null; diff --git a/gui/src/components/tracker/TrackersTable.tsx b/gui/src/components/tracker/TrackersTable.tsx index 78064204b8..c7e241f944 100644 --- a/gui/src/components/tracker/TrackersTable.tsx +++ b/gui/src/components/tracker/TrackersTable.tsx @@ -1,11 +1,10 @@ -import { useLocalization } from '@fluent/react'; import classNames from 'classnames'; import { IPv4 } from 'ip-num/IPNumber'; -import { MouseEventHandler, ReactNode, useMemo, useState } from 'react'; +import { createContext, ReactNode, useContext, useMemo } from 'react'; import { TrackerDataT, - TrackerIdT, TrackerStatus as TrackerStatusEnum, + TrackingChecklistStepT, } from 'solarxr-protocol'; import { useConfig } from '@/hooks/config'; import { useTracker } from '@/hooks/tracker'; @@ -15,37 +14,15 @@ 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 { + highlightedTrackers, + trackingchecklistIdtoLabel, + useTrackingChecklist, +} from '@/hooks/tracking-checklist'; +import { Tooltip } from '@/components/commons/Tooltip'; +import { WarningIcon } from '@/components/commons/icon/WarningIcon'; const isSlime = ({ device }: FlatDeviceTracker) => device?.hardwareInfo?.manufacturer === 'SlimeVR' || @@ -57,15 +34,36 @@ const getDeviceName = ({ 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 ( -
-
- +
+
+ {warning && ( +
+ +
+ )} +
+ +
@@ -102,60 +100,191 @@ 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 ( + +
+ +
+ + ); +} + +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; - onMouseOver?: MouseEventHandler; - onMouseOut?: MouseEventHandler; - warning: boolean; + first?: boolean; + last?: boolean; + show?: boolean; }) { + const { tracker } = useContext(TrackerRowProvider); const { useVelocity } = useTracker(tracker); const velocity = useVelocity(); return ( -
+
{children}
-
+ + ); +} + +const TrackerRowProvider = createContext(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 ( + + + + +
+ ) + } + tag="tr" + spacing={-5} + > + clickedTracker(tracker)}> + + + + + + {device?.hardwareInfo?.manufacturer || '--'} + + + + {device?.hardwareStatus?.batteryPctEstimate != null && ( + + )} + + + {(device?.hardwareStatus?.rssi != null || + device?.hardwareStatus?.ping != null) && ( + + )} + + + {tracker.tps && ( + {tracker.tps} + )} + + + + + + {tracker?.temp && tracker?.temp?.temp != 0 && ( + + {tracker.temp.temp.toFixed(2)} + + )} + + + {tracker.linearAcceleration && ( + + {formatVector3(tracker.linearAcceleration, 1)} + + )} + + + {tracker.position && ( + + {formatVector3(tracker.position, 2)} + + )} + + + + + + + udp:// + {IPv4.fromNumber( + device?.hardwareInfo?.ipAddress?.addr || 0 + ).toString()} + + + + + ); } @@ -166,14 +295,8 @@ export function TrackersTable({ clickedTracker: (tracker: TrackerDataT) => void; flatTrackers: FlatDeviceTracker[]; }) { - const { l10n } = useLocalization(); - const [hoverTracker, setHoverTracker] = useState(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; @@ -190,203 +313,54 @@ 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 ( -
-
- {label} -
- {filteredSortedTrackers.map((data, index) => ( - 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) || <>} - - ))} -
- ); - } - return ( -
- {column({ - id: DisplayColumn.NAME, - label: l10n.getString('tracker-table-column-name'), - row: ({ tracker }) => ( - - ), - })} - - {column({ - id: DisplayColumn.TYPE, - label: l10n.getString('tracker-table-column-type'), - row: ({ device }) => ( - - {device?.hardwareInfo?.manufacturer || '--'} - - ), - })} - - {column({ - id: DisplayColumn.BATTERY, - label: l10n.getString('tracker-table-column-battery'), - row: ({ device, tracker }) => - device?.hardwareStatus?.batteryPctEstimate != null && ( - - ), - })} - - {column({ - id: DisplayColumn.PING, - label: l10n.getString('tracker-table-column-ping'), - row: ({ device, tracker }) => - (device?.hardwareStatus?.rssi != null || - device?.hardwareStatus?.ping != null) && ( - - ), - })} - - {column({ - id: DisplayColumn.TPS, - label: l10n.getString('tracker-table-column-tps'), - row: ({ tracker }) => ( - - {tracker?.tps != null ? <>{tracker.tps} : <>} - - ), - })} - - {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 }) => ( - - ), - })} - - {column({ - id: DisplayColumn.TEMPERATURE, - label: l10n.getString('tracker-table-column-temperature'), - row: ({ tracker }) => - tracker?.temp && - tracker?.temp?.temp != 0 && ( - - {`${tracker.temp.temp.toFixed(2)}`} - - ), - })} - - {column({ - id: DisplayColumn.LINEAR_ACCELERATION, - label: l10n.getString('tracker-table-column-linear-acceleration'), - labelClassName: 'w-36', - row: ({ tracker }) => - tracker.linearAcceleration && ( - - {formatVector3(tracker.linearAcceleration, 1)} - - ), - })} - - {column({ - id: DisplayColumn.POSITION, - label: l10n.getString('tracker-table-column-position'), - labelClassName: 'w-36', - row: ({ tracker }) => - tracker.position && ( - - {formatVector3(tracker.position, 2)} - - ), - })} - - {column({ - id: DisplayColumn.STAY_ALIGNED, - label: l10n.getString('tracker-table-column-stay_aligned'), - labelClassName: 'w-36', - row: ({ tracker }) => ( - - ), - })} - - {column({ - id: DisplayColumn.URL, - label: l10n.getString('tracker-table-column-url'), - row: ({ device }) => ( - - udp:// - {IPv4.fromNumber( - device?.hardwareInfo?.ipAddress?.addr || 0 - ).toString()} - - ), - })} +
+ + +
+
+
+
+
+
+
+
+
+
+
+ + {filteredSortedTrackers.map((data) => ( + + ))} +
); } diff --git a/gui/src/components/tracking-checklist/TrackingChecklist.tsx b/gui/src/components/tracking-checklist/TrackingChecklist.tsx new file mode 100644 index 0000000000..7245369f25 --- /dev/null +++ b/gui/src/components/tracking-checklist/TrackingChecklist.tsx @@ -0,0 +1,549 @@ +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 ( +
+
{ + if (canBeOpened) setOpen((open) => !open); + }} + > +
+ {status === 'skipped' && } + {status === 'complete' && } + {(status === 'invalid' || status === 'blocked') && ( +
+ )} +
+
+ + {canBeOpened && ( +
+ +
+ )} +
+
+ {(firstRequired || open) && children && ( +
{children}
+ )} +
+ ); +} + +const stepContentLookup: Record< + number, + ( + step: TrackingChecklistStep, + context: TrackingChecklistContext + ) => JSX.Element +> = { + [TrackingChecklistStepId.TRACKERS_REST_CALIBRATION]: (step, { toggle }) => { + return ( +
+ +
+ {step.ignorable && ( + + )} +
+
+ ); + }, + [TrackingChecklistStepId.FULL_RESET]: () => { + return ( +
+ +
+ + + +
+
+
+ + Reset position +
+
+ + Reset position side +
+
+ + Reset position wrong +
+
+
+ +
+
+ ); + }, + [TrackingChecklistStepId.STEAMVR_DISCONNECTED]: (step, { toggle }) => { + return ( + <> +
+ +
+ + {step.ignorable && ( + + )} +
+
+ + ); + }, + [TrackingChecklistStepId.TRACKER_ERROR]: () => { + return ; + }, + [TrackingChecklistStepId.UNASSIGNED_HMD]: () => { + return ( + + ); + }, + [TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC]: (step, { toggle }) => { + const data = step.extraData as TrackingChecklistPublicNetworksT | null; + return ( + <> +
+ + ), + }} + whitespace="whitespace-pre-wrap" + > +
+ + {step.ignorable && ( + + )} +
+
+ + ); + }, + [TrackingChecklistStepId.VRCHAT_SETTINGS]: (step, { toggle }) => { + return ( + <> +
+ +
+ + {step.ignorable && ( + + )} +
+
+ + ); + }, + [TrackingChecklistStepId.MOUNTING_CALIBRATION]: (step, { toggle }) => { + return ( +
+ + +
+ mounting reset ski pose +
+
+ + {step.ignorable && ( + + )} +
+
+ ); + }, + [TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION]: (step, { toggle }) => { + return ( +
+ + +
+
+ mounting reset ski pose +
+
+ mounting reset ski pose +
+
+
+ + {step.ignorable && ( + + )} +
+
+ ); + }, + [TrackingChecklistStepId.STAY_ALIGNED_CONFIGURED]: (step, { toggle }) => { + return ( + <> +
+ +
+ + {step.ignorable && ( + + )} +
+
+ + ); + }, +}; + +export function TrackingChecklistMobile() { + const context = useTrackingChecklist(); + const { completion, firstRequired, warnings } = context; + + return ( +
+ +
+ {completion === 'incomplete' ? 'Required:' : 'Warning:'}{' '} + +
+ +
+
+ ); +} + +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 ( + <> +
+
+
+ +
+
+
setSettingsOpen(true)} + > + +
+ {closable && ( +
toggleClosed()} + > + {closed && } + {!closed && } +
+ )} +
+
+
+ {visibleSteps.map((step, index) => ( + + {stepContentLookup[step.id]?.(step, context) || undefined} + + ))} +
+
+
toggleClosed()} + > +
+
+
+
+ {completion === 'incomplete' && ( + + )} + {completion === 'partial' && ( + + )} + {completion == 'complete' && ( + + )} +
+
+
+
+ {!closed && ( + + )} + +
+
+ +
+
+
+
+ + + ); +} + +export function ChecklistPage() { + const nav = useNavigate(); + const { isMobile } = useBreakpoint('mobile'); + + useEffect(() => { + if (!isMobile) nav('/'); + }, [isMobile]); + + return ( +
+ {}} + > +
+ ); +} diff --git a/gui/src/components/tracking-checklist/TrackingChecklistModal.tsx b/gui/src/components/tracking-checklist/TrackingChecklistModal.tsx new file mode 100644 index 0000000000..9478e260e6 --- /dev/null +++ b/gui/src/components/tracking-checklist/TrackingChecklistModal.tsx @@ -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>]; +}) { + return ( + { + open[1](false); + }} + > +
+ + +
+
+
+
+ ); +} diff --git a/gui/src/components/tracking-checklist/TrackingChecklistProvider.tsx b/gui/src/components/tracking-checklist/TrackingChecklistProvider.tsx new file mode 100644 index 0000000000..0f65226dc4 --- /dev/null +++ b/gui/src/components/tracking-checklist/TrackingChecklistProvider.tsx @@ -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 ( + + {children} + + ); +} diff --git a/gui/src/components/vr-mode/VRModePage.tsx b/gui/src/components/vr-mode/VRModePage.tsx index 093384cd94..96ff6ed5a8 100644 --- a/gui/src/components/vr-mode/VRModePage.tsx +++ b/gui/src/components/vr-mode/VRModePage.tsx @@ -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 ( -
- +
+ { + 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; + }, + }); + }} + > + + + +
); } diff --git a/gui/src/components/vrc/VRCWarningsPage.tsx b/gui/src/components/vrc/VRCWarningsPage.tsx index 7c048b0cff..22214b285f 100644 --- a/gui/src/components/vrc/VRCWarningsPage.tsx +++ b/gui/src/components/vrc/VRCWarningsPage.tsx @@ -104,7 +104,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, { @@ -119,7 +119,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, }); diff --git a/gui/src/components/widgets/DeveloperModeWidget.tsx b/gui/src/components/widgets/DeveloperModeWidget.tsx index 860c5fec2b..67f3d14fd7 100644 --- a/gui/src/components/widgets/DeveloperModeWidget.tsx +++ b/gui/src/components/widgets/DeveloperModeWidget.tsx @@ -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 ( -
-
- {l10n.getString('widget-developer_mode')} -
+ {Object.entries(toggles).map(makeToggle)}
); diff --git a/gui/src/components/widgets/SkeletonVisualizerWidget.tsx b/gui/src/components/widgets/SkeletonVisualizerWidget.tsx index 1a8cef4bd3..e81a62a232 100644 --- a/gui/src/components/widgets/SkeletonVisualizerWidget.tsx +++ b/gui/src/components/widgets/SkeletonVisualizerWidget.tsx @@ -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 && ( - - )} - {enabled && ( -
- -
- -
-
- )} - - ); -} - 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: () => { + cancelAnimationFrame(animationFrameId); skeletonHelper.dispose(); + if (!renderer) return; renderer.dispose(); - cancelAnimationFrame(animationFrameId); + 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; 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 (
@@ -444,20 +398,56 @@ export function SkeletonVisualizerWidget({ }, }); }, + disabled = false, + toggleDisabled, }: { onInit?: (context: PreviewContext) => void; + disabled?: boolean; + toggleDisabled?: () => void; }) { const { l10n } = useLocalization(); + const [error, setError] = useState(false); return ( - - {l10n.getString('tips-failed_webgl')} - - } - > - - +
+
+ setError(true)} fallback={<>}> + + +
+
+
toggleDisabled?.()} + > + + +
+
+
+
+ + {l10n.getString('tips-failed_webgl')} + +
+
+
); } diff --git a/gui/src/hooks/body-parts.ts b/gui/src/hooks/body-parts.ts new file mode 100644 index 0000000000..a2ac2766f0 --- /dev/null +++ b/gui/src/hooks/body-parts.ts @@ -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, +]; diff --git a/gui/src/hooks/bvh.ts b/gui/src/hooks/bvh.ts new file mode 100644 index 0000000000..e9202d4321 --- /dev/null +++ b/gui/src/hooks/bvh.ts @@ -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, + }; +} diff --git a/gui/src/hooks/config.ts b/gui/src/hooks/config.ts index e5693b9d0e..4b2ad485a3 100644 --- a/gui/src/hooks/config.ts +++ b/gui/src/hooks/config.ts @@ -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 { diff --git a/gui/src/hooks/pause-tracking.ts b/gui/src/hooks/pause-tracking.ts new file mode 100644 index 0000000000..981929552a --- /dev/null +++ b/gui/src/hooks/pause-tracking.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import { useWebsocketAPI } from './websocket-api'; +import { + RpcMessage, + SetPauseTrackingRequestT, + TrackingPauseStateRequestT, + TrackingPauseStateResponseT, +} from 'solarxr-protocol'; + +export function usePauseTracking() { + const { useRPCPacket, sendRPCPacket } = useWebsocketAPI(); + const [paused, setPaused] = useState(false); + + const toggle = () => { + const pause = new SetPauseTrackingRequestT(!paused); + sendRPCPacket(RpcMessage.SetPauseTrackingRequest, pause); + }; + + useRPCPacket( + RpcMessage.TrackingPauseStateResponse, + (data: TrackingPauseStateResponseT) => { + setPaused(data.trackingPaused); + } + ); + + useEffect(() => { + sendRPCPacket( + RpcMessage.TrackingPauseStateRequest, + new TrackingPauseStateRequestT() + ); + }, []); + + return { + toggle, + paused, + }; +} diff --git a/gui/src/hooks/reset-settings.ts b/gui/src/hooks/reset-settings.ts new file mode 100644 index 0000000000..8b94e20fad --- /dev/null +++ b/gui/src/hooks/reset-settings.ts @@ -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(defaultResetSettings); + + useEffect(() => + sendRPCPacket(RpcMessage.SettingsRequest, new SettingsResetRequestT()) + ); + + useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { + if (settings.resetsSettings) setSettings(settings.resetsSettings); + }); + + return { + update: (resetSettingsForm: Partial) => { + const req = new ChangeSettingsRequestT(); + const res = loadResetSettings({ ...settings, ...resetSettingsForm }); + req.resetsSettings = res; + sendRPCPacket(RpcMessage.ChangeSettingsRequest, req); + }, + }; +} diff --git a/gui/src/hooks/reset.ts b/gui/src/hooks/reset.ts new file mode 100644 index 0000000000..c62938b91b --- /dev/null +++ b/gui/src/hooks/reset.ts @@ -0,0 +1,144 @@ +import { playSoundOnResetEnded, playSoundOnResetStarted } from '@/sounds/sounds'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + BodyPart, + ResetRequestT, + ResetResponseT, + ResetStatus, + ResetType, + RpcMessage, +} from 'solarxr-protocol'; +import { useConfig } from './config'; +import { useWebsocketAPI } from './websocket-api'; +import { useCountdown } from './countdown'; +import { useAtomValue } from 'jotai'; +import { assignedTrackersAtom } from '@/store/app-store'; +import { FEET_BODY_PARTS, FINGER_BODY_PARTS } from './body-parts'; + +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 = { + 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 { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + + const { config } = useConfig(); + const finishedTimeoutRef = useRef(-1); + const [status, setStatus] = useState('idle'); + + const reset = () => { + const req = new ResetRequestT(); + req.resetType = options.type; + req.bodyParts = BODY_PARTS_GROUPS['group' in options ? options.group : 'default']; + sendRPCPacket(RpcMessage.ResetRequest, req); + }; + + const duration = 3; + const { startCountdown, timer, abortCountdown } = useCountdown({ + duration: options.type === ResetType.Yaw ? 0 : duration, + onCountdownEnd: () => { + maybePlaySoundOnResetEnd(options.type); + reset(); + onResetFinished(); + }, + }); + + const onResetFinished = () => { + setStatus('finished'); + + // If a timer was already running / clear it + abortCountdown(); + if (finishedTimeoutRef.current !== -1) clearTimeout(finishedTimeoutRef.current); + + // After 2s go back to idle state + finishedTimeoutRef.current = setTimeout(() => { + setStatus('idle'); + finishedTimeoutRef.current = -1; + }, 2000); + + if (onReseted) onReseted(); + }; + + const maybePlaySoundOnResetEnd = (type: ResetType) => { + if (!config?.feedbackSound) return; + playSoundOnResetEnded(type, config?.feedbackSoundVolume); + }; + + const maybePlaySoundOnResetStart = () => { + if (!config?.feedbackSound) return; + if (options.type !== ResetType.Yaw) + playSoundOnResetStarted(config?.feedbackSoundVolume); + }; + + const triggerReset = () => { + setStatus('counting'); + startCountdown(); + maybePlaySoundOnResetStart(); + }; + + useEffect(() => { + return () => { + if (finishedTimeoutRef.current !== -1) clearTimeout(finishedTimeoutRef.current); + }; + }, []); + + useRPCPacket(RpcMessage.ResetResponse, ({ status, resetType }: ResetResponseT) => { + if (resetType !== options.type) return; + switch (status) { + case ResetStatus.FINISHED: { + onResetFinished(); + 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; + } + + return { + triggerReset, + timer, + status, + disabled, + name, + duration, + }; +} + +export function useMountingReset() {} diff --git a/gui/src/hooks/status-system.ts b/gui/src/hooks/status-system.ts deleted file mode 100644 index 38226301e6..0000000000 --- a/gui/src/hooks/status-system.ts +++ /dev/null @@ -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(undefined as never); - -export function useStatusContext() { - const context = useContext(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 { - 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; - } -} diff --git a/gui/src/hooks/tracking-checklist.ts b/gui/src/hooks/tracking-checklist.ts new file mode 100644 index 0000000000..b63cae9c97 --- /dev/null +++ b/gui/src/hooks/tracking-checklist.ts @@ -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.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; +}; + +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; +export type Steps = { + steps: TrackingChecklistStepT[]; + visibleSteps: TrackingChecklistStep[]; + ignoredSteps: TrackingChecklistStepId[]; +}; +export function provideTrackingChecklist() { + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + const [steps, setSteps] = useState({ + 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( + undefined as never +); + +export function useTrackingChecklist() { + const context = useContext(TrackingChecklistContectC); + if (!context) { + throw new Error('useTrackingChecklist must be within a TrackingChecklistProvider'); + } + return context; +} diff --git a/gui/src/hooks/vrc-config.ts b/gui/src/hooks/vrc-config.ts index 6251807b5e..e38305df4d 100644 --- a/gui/src/hooks/vrc-config.ts +++ b/gui/src/hooks/vrc-config.ts @@ -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 = { [P in keyof T]: NonNullable; }; export type VRCConfigStateSupported = { isSupported: true } & NonNull< - Pick + Pick >; 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(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); }, }; } diff --git a/gui/src/index.scss b/gui/src/index.scss index 1cadf718da..28c5673d6f 100644 --- a/gui/src/index.scss +++ b/gui/src/index.scss @@ -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; diff --git a/gui/src/store/app-store.ts b/gui/src/store/app-store.ts index 3b48ab44a2..5786b8cc89 100644 --- a/gui/src/store/app-store.ts +++ b/gui/src/store/app-store.ts @@ -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) + ) +); diff --git a/gui/src/utils/skeletonHelper.ts b/gui/src/utils/skeletonHelper.ts index 0ff8f8e23b..eb77d95a48 100644 --- a/gui/src/utils/skeletonHelper.ts +++ b/gui/src/utils/skeletonHelper.ts @@ -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'); diff --git a/gui/tailwind.config.ts b/gui/tailwind.config.ts index 34ab01a0bb..55c1c02437 100644 --- a/gui/tailwind.config.ts +++ b/gui/tailwind.config.ts @@ -172,6 +172,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 +229,46 @@ const config = { 'animation-timing-function': 'cubic-bezier(0.8, 0, 1, 1)', }, }, + '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 +281,10 @@ 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', + skiing: 'skiing 1s linear infinite', + }, }, data: { checked: 'checked=true', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ffeb45faa..59f79ffca5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,12 +65,15 @@ importers: '@tauri-apps/plugin-http': specifier: ^2.5.0 version: 2.5.0 + '@tauri-apps/plugin-opener': + specifier: ^2.4.0 + version: 2.4.0 '@tauri-apps/plugin-os': specifier: ^2.0.0 version: 2.0.0 '@tauri-apps/plugin-shell': - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^2.3.0 + version: 2.3.0 '@tauri-apps/plugin-store': specifier: ^2.0.0 version: 2.0.0 @@ -1260,11 +1263,14 @@ packages: '@tauri-apps/plugin-http@2.5.0': resolution: {integrity: sha512-l4M2DUIsOBIMrbj4dJZwrB4mJiB7OA/2Tj3gEbX2fjq5MOpETklJPKfDvzUTDwuq4lIKCKKykz8E8tpOgvi0EQ==} + '@tauri-apps/plugin-opener@2.4.0': + resolution: {integrity: sha512-43VyN8JJtvKWJY72WI/KNZszTpDpzHULFxQs0CJBIYUdCRowQ6Q1feWTDb979N7nldqSuDOaBupZ6wz2nvuWwQ==} + '@tauri-apps/plugin-os@2.0.0': resolution: {integrity: sha512-M7hG/nNyQYTJxVG/UhTKhp9mpXriwWzrs9mqDreB8mIgqA3ek5nHLdwRZJWhkKjZrnDT4v9CpA9BhYeplTlAiA==} - '@tauri-apps/plugin-shell@2.0.0': - resolution: {integrity: sha512-OpW2+ycgJLrEoZityWeWYk+6ZWP9VyiAfbO+N/O8VfLkqyOym8kXh7odKDfINx9RAotkSGBtQM4abyKfJDkcUg==} + '@tauri-apps/plugin-shell@2.3.0': + resolution: {integrity: sha512-6GIRxO2z64uxPX4CCTuhQzefvCC0ew7HjdBhMALiGw74vFBDY95VWueAHOHgNOMV4UOUAFupyidN9YulTe5xlA==} '@tauri-apps/plugin-store@2.0.0': resolution: {integrity: sha512-l4xsbxAXrKGdBdYNNswrLfcRv3v1kOatdycOcVPYW+jKwkznCr1HEOrPXkPhXsZLSLyYmNXpgfOmdSZNmcykDg==} @@ -5472,13 +5478,17 @@ snapshots: dependencies: '@tauri-apps/api': 2.6.0 + '@tauri-apps/plugin-opener@2.4.0': + dependencies: + '@tauri-apps/api': 2.6.0 + '@tauri-apps/plugin-os@2.0.0': dependencies: '@tauri-apps/api': 2.0.2 - '@tauri-apps/plugin-shell@2.0.0': + '@tauri-apps/plugin-shell@2.3.0': dependencies: - '@tauri-apps/api': 2.0.2 + '@tauri-apps/api': 2.6.0 '@tauri-apps/plugin-store@2.0.0': dependencies: diff --git a/server/core/src/main/java/dev/slimevr/NetworkProfileChecker.kt b/server/core/src/main/java/dev/slimevr/NetworkProfileChecker.kt new file mode 100644 index 0000000000..787e634acb --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/NetworkProfileChecker.kt @@ -0,0 +1,60 @@ +package dev.slimevr + +data class NetworkInfo( + val name: String?, + val description: String?, + val category: NetworkCategory?, + val connectivity: Set?, + val connected: Boolean?, +) + +/** + * @see NLM_NETWORK_CATEGORY enumeration (netlistmgr.h) + */ +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 NLM_CONNECTIVITY enumeration (netlistmgr.h) + */ +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 = + 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 +} + +class NetworkProfileCheckerStub : NetworkProfileChecker() { + override val isSupported: Boolean + get() = false + override val publicNetworks: List + get() = listOf() +} diff --git a/server/core/src/main/java/dev/slimevr/VRServer.kt b/server/core/src/main/java/dev/slimevr/VRServer.kt index e675dc633a..283c4c792a 100644 --- a/server/core/src/main/java/dev/slimevr/VRServer.kt +++ b/server/core/src/main/java/dev/slimevr/VRServer.kt @@ -28,6 +28,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 +56,7 @@ class VRServer @JvmOverloads constructor( serialHandlerProvider: (VRServer) -> SerialHandler = { _ -> SerialHandlerStub() }, flashingHandlerProvider: (VRServer) -> SerialFlashingHandler? = { _ -> null }, vrcConfigHandlerProvider: (VRServer) -> VRCConfigHandler = { _ -> VRCConfigHandlerStub() }, + networkProfileProvider: (VRServer) -> NetworkProfileChecker = { _ -> NetworkProfileCheckerStub() }, acquireMulticastLock: () -> Any? = { null }, // configPath is used by VRWorkout, do not remove! configPath: String, @@ -117,6 +119,10 @@ class VRServer @JvmOverloads constructor( @JvmField val handshakeHandler = HandshakeHandler() + val trackingChecklistManager: TrackingChecklistManager + + val networkProfileChecker: NetworkProfileChecker + init { // UwU configManager = ConfigManager(configPath) @@ -132,6 +138,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 @@ -170,6 +178,7 @@ class VRServer @JvmOverloads constructor( for (tracker in computedTrackers) { registerTracker(tracker) } + instance = this } @@ -306,7 +315,7 @@ class VRServer @JvmOverloads constructor( queueTask { humanPoseManager.resetTrackersYaw(resetSourceName, bodyParts) } } - fun resetTrackersMounting(resetSourceName: String?, bodyParts: List = TrackerUtils.allBodyPartsButFingers) { + fun resetTrackersMounting(resetSourceName: String?, bodyParts: List? = null) { queueTask { humanPoseManager.resetTrackersMounting(resetSourceName, bodyParts) } } diff --git a/server/core/src/main/java/dev/slimevr/bridge/Bridge.kt b/server/core/src/main/java/dev/slimevr/bridge/Bridge.kt index ca869e3670..5750b53b3b 100644 --- a/server/core/src/main/java/dev/slimevr/bridge/Bridge.kt +++ b/server/core/src/main/java/dev/slimevr/bridge/Bridge.kt @@ -53,4 +53,6 @@ interface ISteamVRBridge : Bridge { fun getAutomaticSharedTrackers(): Boolean fun setAutomaticSharedTrackers(value: Boolean) + + fun getBridgeConfigKey(): String } diff --git a/server/core/src/main/java/dev/slimevr/config/ResetsConfig.kt b/server/core/src/main/java/dev/slimevr/config/ResetsConfig.kt index ce33bddd81..025cc5ece0 100644 --- a/server/core/src/main/java/dev/slimevr/config/ResetsConfig.kt +++ b/server/core/src/main/java/dev/slimevr/config/ResetsConfig.kt @@ -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,8 @@ class ResetsConfig { // Reset the HMD's pitch upon full reset var resetHmdPitch = false + var preferedMountingMethod = MountingMethods.AUTOMATIC + fun updateTrackersResetsSettings() { for (t in VRServer.instance.allTrackers) { t.resetsHandler.readResetConfig(this) diff --git a/server/core/src/main/java/dev/slimevr/config/TrackingChecklistConfig.kt b/server/core/src/main/java/dev/slimevr/config/TrackingChecklistConfig.kt new file mode 100644 index 0000000000..182a97bfff --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/TrackingChecklistConfig.kt @@ -0,0 +1,5 @@ +package dev.slimevr.config + +class TrackingChecklistConfig { + val ignoredStepsIds: MutableList = mutableListOf() +} diff --git a/server/core/src/main/java/dev/slimevr/config/VRCConfig.kt b/server/core/src/main/java/dev/slimevr/config/VRCConfig.kt new file mode 100644 index 0000000000..fd40b13dda --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/VRCConfig.kt @@ -0,0 +1,6 @@ +package dev.slimevr.config + +class VRCConfig { + // List of fields ignored in vrc warnings - @see VRCConfigValidity + val mutedWarnings: MutableList = mutableListOf() +} diff --git a/server/core/src/main/java/dev/slimevr/config/VRConfig.kt b/server/core/src/main/java/dev/slimevr/config/VRConfig.kt index de6bc93192..0a01a4d42b 100644 --- a/server/core/src/main/java/dev/slimevr/config/VRConfig.kt +++ b/server/core/src/main/java/dev/slimevr/config/VRConfig.kt @@ -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) { diff --git a/server/core/src/main/java/dev/slimevr/games/vrchat/VRCConfigHandler.kt b/server/core/src/main/java/dev/slimevr/games/vrchat/VRCConfigHandler.kt index 8e54d5de8a..fc2829e803 100644 --- a/server/core/src/main/java/dev/slimevr/games/vrchat/VRCConfigHandler.kt +++ b/server/core/src/main/java/dev/slimevr/games/vrchat/VRCConfigHandler.kt @@ -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) } class VRChatConfigManager(val server: VRServer, private val handler: VRCConfigHandler) { private val listeners: MutableList = 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) } } } diff --git a/server/core/src/main/java/dev/slimevr/osc/VMCHandler.kt b/server/core/src/main/java/dev/slimevr/osc/VMCHandler.kt index e3833ec954..2e4f146658 100644 --- a/server/core/src/main/java/dev/slimevr/osc/VMCHandler.kt +++ b/server/core/src/main/java/dev/slimevr/osc/VMCHandler.kt @@ -290,7 +290,7 @@ class VMCHandler( userEditable = true, isComputed = position != null, usesTimeout = true, - needsReset = position != null, + allowReset = position != null, ) trackerDevice!!.trackers[trackerDevice!!.trackers.size] = tracker byTrackerNameTracker[name] = tracker diff --git a/server/core/src/main/java/dev/slimevr/osc/VRCOSCHandler.kt b/server/core/src/main/java/dev/slimevr/osc/VRCOSCHandler.kt index 7199082c08..8723f96f76 100644 --- a/server/core/src/main/java/dev/slimevr/osc/VRCOSCHandler.kt +++ b/server/core/src/main/java/dev/slimevr/osc/VRCOSCHandler.kt @@ -275,7 +275,7 @@ class VRCOSCHandler( hasPosition = true, userEditable = true, isComputed = true, - needsReset = trackerPosition != TrackerPosition.HEAD, + allowReset = trackerPosition != TrackerPosition.HEAD, usesTimeout = true, ) vrsystemTrackersDevice!!.trackers[trackerPosition.ordinal] = tracker @@ -368,7 +368,7 @@ class VRCOSCHandler( hasPosition = true, userEditable = true, isComputed = true, - needsReset = true, + allowReset = true, usesTimeout = true, ) oscTrackersDevice!!.trackers[trackerId] = tracker diff --git a/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedBuilder.java b/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedBuilder.java index b2efbd0ab6..3c256ff2fd 100644 --- a/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedBuilder.java +++ b/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedBuilder.java @@ -141,7 +141,7 @@ public static int createTrackerInfos( TrackerInfo.addAllowDriftCompensation(fbb, false); } - if (tracker.getNeedsMounting()) { + if (tracker.getAllowMounting()) { Quaternion quaternion = tracker.getResetsHandler().getMountingOrientation(); Quaternion mountResetFix = tracker.getResetsHandler().getMountRotFix(); TrackerInfo.addMountingOrientation(fbb, createQuat(fbb, quaternion)); @@ -215,7 +215,7 @@ public static int createTrackerData( if (trackerTemperatureOffset != 0) TrackerData.addTemp(fbb, trackerTemperatureOffset); } - if (tracker.getNeedsMounting() && tracker.getHasRotation()) { + if (tracker.getAllowMounting() && tracker.getHasRotation()) { if (mask.getRotationReferenceAdjusted()) { TrackerData .addRotationReferenceAdjusted(fbb, createQuat(fbb, tracker.getRotation())); @@ -227,7 +227,7 @@ public static int createTrackerData( createQuat(fbb, tracker.getIdentityAdjustedRotation()) ); } - } else if (tracker.getNeedsReset() && tracker.getHasRotation()) { + } else if (tracker.getAllowReset() && tracker.getHasRotation()) { if (mask.getRotationReferenceAdjusted()) { TrackerData .addRotationReferenceAdjusted(fbb, createQuat(fbb, tracker.getRotation())); diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt index 79a82265a6..def48d864c 100644 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt +++ b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt @@ -1,6 +1,7 @@ package dev.slimevr.protocol.rpc import com.google.flatbuffers.FlatBufferBuilder +import dev.slimevr.config.MountingMethods import dev.slimevr.config.config import dev.slimevr.protocol.GenericConnection import dev.slimevr.protocol.ProtocolAPI @@ -18,6 +19,7 @@ import dev.slimevr.protocol.rpc.setup.RPCHandshakeHandler import dev.slimevr.protocol.rpc.setup.RPCTapSetupHandler import dev.slimevr.protocol.rpc.setup.RPCUtil.getLocalIp import dev.slimevr.protocol.rpc.status.RPCStatusHandler +import dev.slimevr.protocol.rpc.trackingchecklist.RPCTrackingChecklistHandler import dev.slimevr.protocol.rpc.trackingpause.RPCTrackingPause import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets import dev.slimevr.tracking.processor.stayaligned.poses.RelaxedPose @@ -48,6 +50,7 @@ class RPCHandler(private val api: ProtocolAPI) : ProtocolHandler, ): Int { if (!isSupported) { VRCConfigStateChangeResponse.startVRCConfigStateChangeResponse(fbb) @@ -71,11 +72,13 @@ fun buildVRCConfigStateResponse( val validityOffset = buildVRCConfigValidity(fbb, validity) val valuesOffset = buildVRCConfigValues(fbb, values) val recommendedOffset = buildVRCConfigRecommendedValues(fbb, recommended) + val mutedOffset = VRCConfigStateChangeResponse.createMutedVector(fbb, muted.map { fbb.createString(it) }.toIntArray()) VRCConfigStateChangeResponse.startVRCConfigStateChangeResponse(fbb) VRCConfigStateChangeResponse.addIsSupported(fbb, true) VRCConfigStateChangeResponse.addValidity(fbb, validityOffset) VRCConfigStateChangeResponse.addState(fbb, valuesOffset) VRCConfigStateChangeResponse.addRecommended(fbb, recommendedOffset) + VRCConfigStateChangeResponse.addMuted(fbb, mutedOffset) return VRCConfigStateChangeResponse.endVRCConfigStateChangeResponse(fbb) } diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRChatHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRChatHandler.kt index 2d4bd92776..481e918d35 100644 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRChatHandler.kt +++ b/server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRChatHandler.kt @@ -18,12 +18,8 @@ class RPCVRChatHandler( init { api.server.vrcConfigManager.addListener(this) - rpcHandler.registerPacketListener(RpcMessage.VRCConfigStateRequest) { conn: GenericConnection, messageHeader: RpcMessageHeader -> - this.onConfigStateRequest( - conn, - messageHeader, - ) - } + rpcHandler.registerPacketListener(RpcMessage.VRCConfigStateRequest, ::onConfigStateRequest) + rpcHandler.registerPacketListener(RpcMessage.VRCConfigSettingToggleMute, ::onToggleMuteRequest) } private fun onConfigStateRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { @@ -41,6 +37,7 @@ class RPCVRChatHandler( validity = validity, values = values, recommended = api.server.vrcConfigManager.recommendedValues(), + muted = api.server.configManager.vrConfig.vrcConfig.mutedWarnings, ) val outbound = rpcHandler.createRPCMessage( @@ -52,7 +49,13 @@ class RPCVRChatHandler( conn.send(fbb.dataBuffer()) } - override fun onChange(validity: VRCConfigValidity, values: VRCConfigValues, recommended: VRCConfigRecommendedValues) { + private fun onToggleMuteRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { + val req = messageHeader.message(VRCConfigSettingToggleMute()) as VRCConfigSettingToggleMute? + ?: return + api.server.vrcConfigManager.toggleMuteWarning(req.key()) + } + + override fun onChange(validity: VRCConfigValidity, values: VRCConfigValues, recommended: VRCConfigRecommendedValues, muted: List) { val fbb = FlatBufferBuilder(32) val response = buildVRCConfigStateResponse( @@ -61,6 +64,7 @@ class RPCVRChatHandler( validity = validity, values = values, recommended = recommended, + muted, ) val outbound = rpcHandler.createRPCMessage( diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/trackingchecklist/RPCTrackingChecklistHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/trackingchecklist/RPCTrackingChecklistHandler.kt new file mode 100644 index 0000000000..ca6f6b34e7 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/protocol/rpc/trackingchecklist/RPCTrackingChecklistHandler.kt @@ -0,0 +1,73 @@ +package dev.slimevr.protocol.rpc.trackingchecklist + +import com.google.flatbuffers.FlatBufferBuilder +import dev.slimevr.protocol.GenericConnection +import dev.slimevr.protocol.ProtocolAPI +import dev.slimevr.protocol.rpc.RPCHandler +import dev.slimevr.trackingchecklist.TrackingChecklistListener +import solarxr_protocol.rpc.* + +class RPCTrackingChecklistHandler( + private val rpcHandler: RPCHandler, + var api: ProtocolAPI, +) : TrackingChecklistListener { + + init { + api.server.trackingChecklistManager.addListener(this) + + rpcHandler.registerPacketListener(RpcMessage.TrackingChecklistRequest, ::onTrackingChecklistRequest) + rpcHandler.registerPacketListener(RpcMessage.IgnoreTrackingChecklistStepRequest, ::onToggleTrackingChecklistRequest) + } + + fun buildTrackingChecklistResponse(fbb: FlatBufferBuilder): Int = TrackingChecklistResponse.pack( + fbb, + TrackingChecklistResponseT().apply { + steps = api.server.trackingChecklistManager.steps.toTypedArray() + ignoredSteps = api.server.configManager.vrConfig.trackingChecklist.ignoredStepsIds.toIntArray() + }, + ) + + private fun onTrackingChecklistRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { + val fbb = FlatBufferBuilder(32) + val response = buildTrackingChecklistResponse(fbb) + val outbound = rpcHandler.createRPCMessage( + fbb, + RpcMessage.TrackingChecklistResponse, + response, + ) + fbb.finish(outbound) + conn.send(fbb.dataBuffer()) + } + + private fun onToggleTrackingChecklistRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { + val req = messageHeader.message(IgnoreTrackingChecklistStepRequest()) as IgnoreTrackingChecklistStepRequest? + ?: return + val step = api.server.trackingChecklistManager.steps.find { it.id == req.stepId() } ?: error("invalid step id requested") + + api.server.trackingChecklistManager.ignoreStep(step, req.ignore()) + + val fbb = FlatBufferBuilder(32) + val response = buildTrackingChecklistResponse(fbb) + val outbound = rpcHandler.createRPCMessage( + fbb, + RpcMessage.TrackingChecklistResponse, + response, + ) + fbb.finish(outbound) + conn.send(fbb.dataBuffer()) + } + + override fun onStepsUpdate() { + val fbb = FlatBufferBuilder(32) + val response = buildTrackingChecklistResponse(fbb) + val outbound = rpcHandler.createRPCMessage( + fbb, + RpcMessage.TrackingChecklistResponse, + response, + ) + fbb.finish(outbound) + this.api.apiServers.forEach { apiServer -> + apiServer.apiConnections.forEach { it.send(fbb.dataBuffer()) } + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/status/StatusSystem.kt b/server/core/src/main/java/dev/slimevr/status/StatusSystem.kt index fe590b6be8..bd521f9a81 100644 --- a/server/core/src/main/java/dev/slimevr/status/StatusSystem.kt +++ b/server/core/src/main/java/dev/slimevr/status/StatusSystem.kt @@ -28,36 +28,6 @@ class StatusSystem { status }.toTypedArray() - /** - * @return the ID of the status, 0 is not a valid ID, can be used as replacement of null - */ - @JvmName("addStatusInt") - fun addStatus(statusData: StatusDataUnion, prioritized: Boolean = false): UInt { - val id = idCounter.getAndUpdate { - (it.toUInt() + 1u).toInt() // the simple way of making unsigned math - } - statuses[id] = statusData - if (prioritized) { - prioritizedStatuses.add(id) - } - - listeners.forEach { - it.onStatusChanged(id.toUInt(), statusData, prioritized) - } - - return id.toUInt() - } - - @JvmName("removeStatusInt") - fun removeStatus(id: UInt) { - statuses.remove(id.toInt()) - prioritizedStatuses.remove(id.toInt()) - - listeners.forEach { - it.onStatusRemoved(id) - } - } - fun hasStatusType(dataType: Byte): Boolean = statuses.any { it.value.type == dataType } diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/HumanPoseManager.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/HumanPoseManager.kt index e2935f4dfa..e261d4e48c 100644 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/HumanPoseManager.kt +++ b/server/core/src/main/java/dev/slimevr/tracking/processor/HumanPoseManager.kt @@ -20,11 +20,6 @@ import io.github.axisangles.ktmath.Quaternion.Companion.IDENTITY import io.github.axisangles.ktmath.Vector3 import io.github.axisangles.ktmath.Vector3.Companion.POS_Y import org.apache.commons.math3.util.Precision -import solarxr_protocol.datatypes.DeviceIdT -import solarxr_protocol.datatypes.TrackerIdT -import solarxr_protocol.rpc.StatusData -import solarxr_protocol.rpc.StatusDataUnion -import solarxr_protocol.rpc.StatusUnassignedHMDT import java.util.function.Consumer import kotlin.math.* @@ -525,7 +520,7 @@ class HumanPoseManager(val server: VRServer?) { for (tracker in server!!.allTrackers) { if (( tracker.isImu() && - tracker.needsReset + tracker.allowReset ) && tracker.resetsHandler.lastResetQuaternion != null ) { if (trackersDriftText.isNotEmpty()) { @@ -571,8 +566,15 @@ class HumanPoseManager(val server: VRServer?) { } @JvmOverloads - fun resetTrackersMounting(resetSourceName: String?, bodyParts: List = TrackerUtils.allBodyPartsButFingers) { - skeleton.resetTrackersMounting(resetSourceName, bodyParts) + fun resetTrackersMounting(resetSourceName: String?, bodyParts: List? = null) { + val finalBodyParts = bodyParts + ?: if (server?.configManager?.vrConfig?.resetsConfig?.resetMountingFeet == true) { + TrackerUtils.allBodyPartsButFingers + } else { + TrackerUtils.allBodyPartsButFingersAndFeets + } + + skeleton.resetTrackersMounting(resetSourceName, finalBodyParts) } fun clearTrackersMounting(resetSourceName: String?) { @@ -669,51 +671,12 @@ class HumanPoseManager(val server: VRServer?) { return } server.allTrackers - .filter { !it.isInternal && it.trackerPosition != null } + .filter { it.trackerPosition != null } .forEach { - it.checkReportRequireReset() - } - } - - private var lastMissingHmdStatus = 0u - fun checkReportMissingHmd() { - // Check if this is main skeleton, there is no head tracker currently, - // and there is an available HMD one - if (server == null) return - val tracker = VRServer.instance.allTrackers.firstOrNull { it.isHmd && !it.isInternal && it.status.sendData } - if (skeleton.headTracker == null && - lastMissingHmdStatus == 0u && - tracker != null - ) { - reportMissingHmd(tracker) - } else if (lastMissingHmdStatus != 0u && - (skeleton.headTracker != null || tracker == null) - ) { - server.statusSystem.removeStatus(lastMissingHmdStatus) - lastMissingHmdStatus = 0u - } - } - - private fun reportMissingHmd(tracker: Tracker) { - require(lastMissingHmdStatus == 0u) { - "${::lastMissingHmdStatus.name} must be 0u, but was $lastMissingHmdStatus" - } - require(server != null) { - "${::server.name} must not be null" - } - - val status = StatusDataUnion().apply { - type = StatusData.StatusUnassignedHMD - value = StatusUnassignedHMDT().apply { - trackerId = TrackerIdT().apply { - if (tracker.device != null) { - deviceId = DeviceIdT().apply { id = tracker.device.id } - } - trackerNum = tracker.trackerNum + if (it.allowReset && !it.needReset) { + it.needReset = true } } - } - lastMissingHmdStatus = server.statusSystem.addStatus(status, true) } // #endregion diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigManager.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigManager.kt index 62adbaec45..7cdec98b7a 100644 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigManager.kt +++ b/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigManager.kt @@ -91,6 +91,9 @@ class SkeletonConfigManager( // Re-calculate user height userHeightFromOffsets = calculateUserHeight() userNeckHeightFromOffsets = userHeightFromOffsets - getOffset(SkeletonConfigOffsets.NECK) + + // Update vrc config checker if user height change + humanPoseManager?.server?.vrcConfigManager?.forceUpdate() } fun setOffset(config: SkeletonConfigOffsets, newValue: Float?) { diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/HumanSkeleton.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/HumanSkeleton.kt index 1678d6db65..5dbb57f4d2 100644 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/HumanSkeleton.kt +++ b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/HumanSkeleton.kt @@ -1,6 +1,7 @@ package dev.slimevr.tracking.processor.skeleton import dev.slimevr.VRServer +import dev.slimevr.config.MountingMethods import dev.slimevr.config.StayAlignedConfig import dev.slimevr.tracking.processor.Bone import dev.slimevr.tracking.processor.BoneType @@ -124,7 +125,6 @@ class HumanSkeleton( var headTracker: Tracker? by Delegates.observable(null) { _, old, new -> if (old == new) return@observable - humanPoseManager.checkReportMissingHmd() humanPoseManager.checkTrackersRequiringReset() } var neckTracker: Tracker? = null @@ -1531,7 +1531,7 @@ class HumanSkeleton( // Resets all axes of the trackers with the HMD as reference. for (tracker in trackersToReset) { // Only reset if tracker needsReset - if (tracker != null && (tracker.needsReset || tracker.isHmd) && (bodyParts.isEmpty() || bodyParts.contains(tracker.trackerPosition?.bodyPart))) { + if (tracker != null && (tracker.allowReset || tracker.isHmd) && (bodyParts.isEmpty() || bodyParts.contains(tracker.trackerPosition?.bodyPart))) { tracker.resetsHandler.resetFull(referenceRotation) } } @@ -1553,16 +1553,16 @@ class HumanSkeleton( var referenceRotation = IDENTITY headTracker?.let { if (bodyParts.isEmpty() || bodyParts.contains(BodyPart.HEAD)) { - // Only reset if head needsReset and isn't computed - if (it.needsReset && !it.isComputed) { + // Only reset if head allowReset and isn't computed + if (it.allowReset && !it.isComputed) { it.resetsHandler.resetYaw(referenceRotation) } } referenceRotation = it.getRotation() } for (tracker in trackersToReset) { - // Only reset if tracker needsReset - if (tracker != null && tracker.needsReset && (bodyParts.isEmpty() || bodyParts.contains(tracker.trackerPosition?.bodyPart))) { + // Only reset if tracker allowReset + if (tracker != null && tracker.allowReset && (bodyParts.isEmpty() || bodyParts.contains(tracker.trackerPosition?.bodyPart))) { tracker.resetsHandler.resetYaw(referenceRotation) } } @@ -1584,7 +1584,7 @@ class HumanSkeleton( // If there's a server present (required for status) and any tracker reports a // non-zero reset status (indicates reset required), then block mounting reset, // as it requires a full reset first - if (humanPoseManager.server != null && trackersToReset.any { it != null && it.lastResetStatus != 0u }) { + if (humanPoseManager.server != null && trackersToReset.any { it != null && it.needReset }) { LogManager.info("[HumanSkeleton] Reset: mounting ($resetSourceName) failed, reset required") return } @@ -1593,41 +1593,66 @@ class HumanSkeleton( var referenceRotation = IDENTITY headTracker?.let { if (bodyParts.isEmpty() || bodyParts.contains(BodyPart.HEAD)) { - // Only reset if head needsMounting or is computed but not HMD - if (it.needsMounting || (it.isComputed && !it.isHmd)) { + // Only reset if head allowMounting or is computed but not HMD + if (it.allowMounting || (it.isComputed && !it.isHmd)) { it.resetsHandler.resetMounting(referenceRotation) } } referenceRotation = it.getRotation() } - // If onlyFeet is true, feet will be forced to be mounting reset in their reset handlers. - val onlyFeet = bodyParts.isNotEmpty() && bodyParts.all { it == BodyPart.LEFT_FOOT || it == BodyPart.RIGHT_FOOT } for (tracker in trackersToReset) { // Only reset if tracker needsMounting - if (tracker != null && tracker.needsMounting && (bodyParts.isEmpty() || bodyParts.contains(tracker.trackerPosition?.bodyPart))) { - tracker.resetsHandler.resetMounting(referenceRotation, onlyFeet) + if (tracker != null && tracker.allowMounting && (bodyParts.isEmpty() || bodyParts.contains(tracker.trackerPosition?.bodyPart))) { + tracker.resetsHandler.resetMounting(referenceRotation) } } legTweaks.resetBuffer() localizer.reset() + + if (humanPoseManager.server != null) { + humanPoseManager.server.configManager.vrConfig.resetsConfig.preferedMountingMethod = + MountingMethods.AUTOMATIC + if (!humanPoseManager.server.trackingChecklistManager.resetMountingCompleted) { + humanPoseManager.server.trackingChecklistManager.resetMountingCompleted = bodyParts.any { it -> + val defaultParts = if (humanPoseManager.server.configManager.vrConfig.resetsConfig.resetMountingFeet) { + TrackerUtils.allBodyPartsButFingers + } else { + TrackerUtils.allBodyPartsButFingersAndFeets + } + + return@any defaultParts.contains(it) + } + } + if (!humanPoseManager.server.trackingChecklistManager.feetResetMountingCompleted) { + humanPoseManager.server.trackingChecklistManager.feetResetMountingCompleted = bodyParts.any { TrackerUtils.feetsBodyParts.contains(it) } + } + humanPoseManager.server.configManager.saveConfig() + } + LogManager.info("[HumanSkeleton] Reset: mounting ($resetSourceName)") } @VRServerThread fun clearTrackersMounting(resetSourceName: String?) { headTracker?.let { - if (it.needsMounting) it.resetsHandler.clearMounting() + if (it.allowMounting) it.resetsHandler.clearMounting() } for (tracker in trackersToReset) { if (tracker != null && - tracker.needsMounting + tracker.allowMounting ) { tracker.resetsHandler.clearMounting() } } legTweaks.resetBuffer() LogManager.info("[HumanSkeleton] Clear: mounting ($resetSourceName)") + + if (humanPoseManager.server != null) { + humanPoseManager.server.trackingChecklistManager.resetMountingCompleted = false + humanPoseManager.server.trackingChecklistManager.feetResetMountingCompleted = false + humanPoseManager.server.configManager.saveConfig() + } } fun updateTapDetectionConfig() { diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/TapDetectionManager.java b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/TapDetectionManager.java index 404f38c6a5..2d345fea16 100644 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/TapDetectionManager.java +++ b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/TapDetectionManager.java @@ -7,7 +7,6 @@ import dev.slimevr.setup.TapSetupHandler; import dev.slimevr.tracking.processor.HumanPoseManager; import dev.slimevr.tracking.trackers.Tracker; -import dev.slimevr.tracking.trackers.TrackerUtils; import solarxr_protocol.rpc.ResetType; import solarxr_protocol.rpc.StatusData; @@ -221,11 +220,7 @@ private void checkMountingReset() { // However, feet being reset or not will end up being decided on a // per-tracker basis // due to the setting being in ResetsConfig.kt - skeleton - .resetTrackersMounting( - resetSourceName, - TrackerUtils.INSTANCE.getAllBodyPartsButFingers() - ); + humanPoseManager.resetTrackersMounting(resetSourceName); mountingResetDetector.resetDetector(); mountingResetAllowPlaySound = true; diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/Tracker.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/Tracker.kt index 8f639c4407..3d4a8616b6 100644 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/Tracker.kt +++ b/server/core/src/main/java/dev/slimevr/tracking/trackers/Tracker.kt @@ -11,12 +11,6 @@ import dev.slimevr.util.InterpolationHandler import io.eiren.util.BufferedTimer import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 -import solarxr_protocol.datatypes.DeviceIdT -import solarxr_protocol.datatypes.TrackerIdT -import solarxr_protocol.rpc.StatusData -import solarxr_protocol.rpc.StatusDataUnion -import solarxr_protocol.rpc.StatusTrackerErrorT -import solarxr_protocol.rpc.StatusTrackerResetT import kotlin.properties.Delegates const val TIMEOUT_MS = 2_000L @@ -71,9 +65,23 @@ class Tracker @JvmOverloads constructor( * [trackRotDirection] is set to false. */ val allowFiltering: Boolean = false, - val needsReset: Boolean = false, - val needsMounting: Boolean = false, + + /** + * If true, the tracker can be reset + */ + val allowReset: Boolean = false, + /** + * If true, the tracker can do mounting calibration + */ + val allowMounting: Boolean = false, + val isHmd: Boolean = false, + + /** + * If true, the tracker need the user to perform a reset + */ + var needReset: Boolean = false, + /** * Whether to track the direction of the tracker's rotation * (positive vs negative rotation). This needs to be disabled for AutoBone and @@ -108,33 +116,27 @@ class Tracker @JvmOverloads constructor( var magStatus: MagnetometerStatus = magStatus private set + /** + * Watch the rest calibration status + */ + var hasCompletedRestCalibration: Boolean? = null + /** * If the tracker has gotten disconnected after it was initialized first time */ - var statusResetRecently = false - private var alreadyInitialized = false var status: TrackerStatus by Delegates.observable(TrackerStatus.DISCONNECTED) { _, old, new -> if (old == new) return@observable - if (!new.reset) { - if (alreadyInitialized) { - statusResetRecently = true - } - alreadyInitialized = true + if (allowReset && !old.reset && new.reset && !needReset) { + needReset = true } + if (!isInternal && VRServer.instanceInitialized) { // If the status of a non-internal tracker has changed, inform // the VRServer to recreate the skeleton, as it may need to // assign or un-assign the tracker to a body part VRServer.instance.updateSkeletonModel() VRServer.instance.refreshTrackersDriftCompensationEnabled() - - if (isHmd) { - VRServer.instance.humanPoseManager.checkReportMissingHmd() - } - checkReportErrorStatus() - checkReportRequireReset() - VRServer.instance.trackerStatusChanged(this, old, new) } } @@ -142,16 +144,18 @@ class Tracker @JvmOverloads constructor( var trackerPosition: TrackerPosition? by Delegates.observable(trackerPosition) { _, old, new -> if (old == new) return@observable + if (allowReset && !needReset) { + needReset = true + } + if (!isInternal) { // Set default mounting orientation for that body part new?.let { resetsHandler.mountingOrientation = it.defaultMounting() } - - checkReportRequireReset() } } // Computed value to simplify availability checks - val hasAdjustedRotation = hasRotation && (allowFiltering || needsReset) + val hasAdjustedRotation = hasRotation && (allowFiltering || allowReset) /** * It's like the ID, but it should be local to the device if it has one @@ -163,11 +167,11 @@ class Tracker @JvmOverloads constructor( init { // IMPORTANT: Look here for the required states of inputs - require(!needsReset || (hasRotation && needsReset)) { - "If ${::needsReset.name} is true, then ${::hasRotation.name} must also be true" + require(!allowReset || (hasRotation && allowReset)) { + "If ${::allowReset.name} is true, then ${::hasRotation.name} must also be true" } - require(!needsMounting || (needsReset && needsMounting)) { - "If ${::needsMounting.name} is true, then ${::needsReset.name} must also be true" + require(!allowMounting || (allowReset && allowMounting)) { + "If ${::allowMounting.name} is true, then ${::allowReset.name} must also be true" } require(!isHmd || (hasPosition && isHmd)) { "If ${::isHmd.name} is true, then ${::hasPosition.name} must also be true" @@ -177,73 +181,6 @@ class Tracker @JvmOverloads constructor( // } } - fun checkReportRequireReset() { - if (needsReset && trackerPosition != null && lastResetStatus == 0u && - !status.reset && (isImu() || !statusResetRecently && trackerDataType != TrackerDataType.FLEX_ANGLE) - ) { - reportRequireReset() - } else if (lastResetStatus != 0u && (trackerPosition == null || status.reset)) { - VRServer.instance.statusSystem.removeStatus(lastResetStatus) - lastResetStatus = 0u - } - } - - /** - * If 0 then it's null - */ - var lastResetStatus = 0u - private fun reportRequireReset() { - require(lastResetStatus == 0u) { - "lastResetStatus must be 0u, but was $lastResetStatus" - } - - val tempTrackerNum = this.trackerNum - val statusMsg = StatusTrackerResetT().apply { - trackerId = TrackerIdT().apply { - if (device != null) { - deviceId = DeviceIdT().apply { id = device.id } - } - trackerNum = tempTrackerNum - } - } - val status = StatusDataUnion().apply { - type = StatusData.StatusTrackerReset - value = statusMsg - } - lastResetStatus = VRServer.instance.statusSystem.addStatus(status, true) - } - - private fun checkReportErrorStatus() { - if (status == TrackerStatus.ERROR && lastErrorStatus == 0u) { - reportErrorStatus() - } else if (lastErrorStatus != 0u && status != TrackerStatus.ERROR) { - VRServer.instance.statusSystem.removeStatus(lastErrorStatus) - lastErrorStatus = 0u - } - } - - var lastErrorStatus = 0u - private fun reportErrorStatus() { - require(lastErrorStatus == 0u) { - "lastResetStatus must be 0u, but was $lastErrorStatus" - } - - val tempTrackerNum = this.trackerNum - val statusMsg = StatusTrackerErrorT().apply { - trackerId = TrackerIdT().apply { - if (device != null) { - deviceId = DeviceIdT().apply { id = device.id } - } - trackerNum = tempTrackerNum - } - } - val status = StatusDataUnion().apply { - type = StatusData.StatusTrackerError - value = statusMsg - } - lastErrorStatus = VRServer.instance.statusSystem.addStatus(status, true) - } - /** * Reads/loads from the given config */ @@ -254,7 +191,7 @@ class Tracker @JvmOverloads constructor( config.designation?.let { designation -> getByDesignation(designation)?.let { trackerPosition = it } } ?: run { trackerPosition = null } - if (needsMounting) { + if (allowMounting) { // Load manual mounting config.mountingOrientation?.let { resetsHandler.mountingOrientation = it.toValue() } } @@ -268,11 +205,6 @@ class Tracker @JvmOverloads constructor( resetsHandler.allowDriftCompensation = it } } - if (!isInternal && - !(!isImu() && (trackerPosition == TrackerPosition.LEFT_HAND || trackerPosition == TrackerPosition.RIGHT_HAND)) - ) { - checkReportRequireReset() - } } /** @@ -281,7 +213,7 @@ class Tracker @JvmOverloads constructor( fun writeConfig(config: TrackerConfig) { trackerPosition?.let { config.designation = it.designation } ?: run { config.designation = null } customName?.let { config.customName = it } - if (needsMounting) { + if (allowMounting) { // Save manual mounting config.mountingOrientation = resetsHandler.mountingOrientation.toObject() } @@ -360,7 +292,7 @@ class Tracker @JvmOverloads constructor( } // Reset if needed and is not computed and internal - return if (needsReset && !(isComputed && isInternal) && trackerDataType == TrackerDataType.ROTATION) { + return if (allowReset && !(isComputed && isInternal) && trackerDataType == TrackerDataType.ROTATION) { // Adjust to reset, mounting and drift compensation resetsHandler.getReferenceAdjustedDriftRotationFrom(rot) } else { @@ -380,7 +312,7 @@ class Tracker @JvmOverloads constructor( rot = Quaternion.rotationAroundYAxis(stayAligned.yawCorrection.toRad()) * rot // Reset if needed and is not computed and internal - return if (needsReset && !(isComputed && isInternal) && trackerDataType == TrackerDataType.ROTATION) { + return if (needReset && !(isComputed && isInternal) && trackerDataType == TrackerDataType.ROTATION) { // Adjust to reset, mounting and drift compensation resetsHandler.getReferenceAdjustedDriftRotationFrom(rot) } else { @@ -402,7 +334,7 @@ class Tracker @JvmOverloads constructor( } // Reset if needed or is a computed tracker besides head - return if (needsReset && !(isComputed && trackerPosition != TrackerPosition.HEAD) && trackerDataType == TrackerDataType.ROTATION) { + return if (allowReset && !(isComputed && trackerPosition != TrackerPosition.HEAD) && trackerDataType == TrackerDataType.ROTATION) { // Adjust to reset and mounting resetsHandler.getIdentityAdjustedDriftRotationFrom(rot) } else { @@ -431,7 +363,7 @@ class Tracker @JvmOverloads constructor( /** * Gets the world-adjusted acceleration */ - fun getAcceleration(): Vector3 = if (needsReset) { + fun getAcceleration(): Vector3 = if (allowReset) { resetsHandler.getReferenceAdjustedAccel(_rotation, _acceleration) } else { _acceleration @@ -477,7 +409,7 @@ class Tracker @JvmOverloads constructor( /** * Gets the magnetic field vector, in mGauss. */ - fun getMagVector() = if (needsReset) { + fun getMagVector() = if (allowReset) { resetsHandler.getReferenceAdjustedAccel(_rotation, _magVector) } else { _magVector diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerResetsHandler.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerResetsHandler.kt index 0cb5d94f7e..751d57eaeb 100644 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerResetsHandler.kt +++ b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerResetsHandler.kt @@ -15,10 +15,7 @@ import kotlin.math.* private const val DRIFT_COOLDOWN_MS = 50000L -/** - * Class taking care of full reset, yaw reset, mounting reset, - * and drift compensation logic. - */ +/** Class taking care of full reset, yaw reset, mounting reset, and drift compensation logic. */ class TrackerResetsHandler(val tracker: Tracker) { private val HalfHorizontal = EulerAngles( @@ -38,7 +35,6 @@ class TrackerResetsHandler(val tracker: Tracker) { private var compensateDrift = false private var driftPrediction = false private var driftCompensationEnabled = false - private var resetMountingFeet = false private var armsResetMode = ArmsResetModes.BACK private var yawResetSmoothTime = 0.0f var saveMountingReset = false @@ -164,7 +160,6 @@ class TrackerResetsHandler(val tracker: Tracker) { * Reads/loads reset settings from the given config */ fun readResetConfig(config: ResetsConfig) { - resetMountingFeet = config.resetMountingFeet armsResetMode = config.mode yawResetSmoothTime = config.yawResetSmoothTime saveMountingReset = config.saveMountingReset @@ -274,7 +269,7 @@ class TrackerResetsHandler(val tracker: Tracker) { val mountingAdjustedRotation = tracker.getRawRotation() * mountingOrientation // Gyrofix - if (tracker.needsMounting || (tracker.trackerPosition == TrackerPosition.HEAD && !tracker.isHmd)) { + if (tracker.allowMounting || (tracker.trackerPosition == TrackerPosition.HEAD && !tracker.isHmd)) { gyroFix = if (tracker.isComputed) { fixGyroscope(tracker.getRawRotation()) } else { @@ -306,7 +301,7 @@ class TrackerResetsHandler(val tracker: Tracker) { } // Rotate attachmentFix by 180 degrees as a workaround for t-pose (down) - if (tposeDownFix != Quaternion.IDENTITY && tracker.needsMounting) { + if (tposeDownFix != Quaternion.IDENTITY && tracker.allowMounting) { attachmentFix *= HalfHorizontal } @@ -328,9 +323,8 @@ class TrackerResetsHandler(val tracker: Tracker) { } private fun postProcessResetFull(reference: Quaternion) { - if (this.tracker.lastResetStatus != 0u) { - VRServer.instance.statusSystem.removeStatus(this.tracker.lastResetStatus) - this.tracker.lastResetStatus = 0u + if (this.tracker.needReset) { + this.tracker.needReset = false } tracker.resetFilteringQuats(reference) @@ -375,13 +369,7 @@ class TrackerResetsHandler(val tracker: Tracker) { ) } - // Remove the status if yaw reset was performed after the tracker - // was disconnected and connected. - if (this.tracker.lastResetStatus != 0u && this.tracker.statusResetRecently) { - VRServer.instance.statusSystem.removeStatus(this.tracker.lastResetStatus) - this.tracker.statusResetRecently = false - this.tracker.lastResetStatus = 0u - } + this.tracker.needReset = false // Reset Stay Aligned (before resetting filtering, which depends on the // tracker's rotation) @@ -393,17 +381,14 @@ class TrackerResetsHandler(val tracker: Tracker) { /** * Perform the math to align the tracker to go forward * and stores it in mountRotFix, and adjusts yawFix - * If forceFeet is true, always reset feet regardless of resetMountingFeet's value. */ - fun resetMounting(reference: Quaternion, forceFeet: Boolean = false) { + fun resetMounting(reference: Quaternion) { if (tracker.trackerDataType == TrackerDataType.FLEX_RESISTANCE) { tracker.trackerFlexHandler.resetMax() tracker.resetFilteringQuats(reference) return } else if (tracker.trackerDataType == TrackerDataType.FLEX_ANGLE) { return - } else if (!resetMountingFeet && tracker.trackerPosition.isFoot() && !forceFeet) { - return } constraintFix = Quaternion.IDENTITY diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerUtils.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerUtils.kt index 8aa27052df..f3e73b3eee 100644 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerUtils.kt +++ b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerUtils.kt @@ -102,4 +102,18 @@ object TrackerUtils { BodyPart.RIGHT_HAND, BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER, BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT, ) + + val allBodyPartsButFingersAndFeets = listOf( + BodyPart.HEAD, BodyPart.NECK, BodyPart.UPPER_CHEST, + BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP, + BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG, BodyPart.LEFT_LOWER_LEG, + BodyPart.RIGHT_LOWER_LEG, 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, + ) + + val feetsBodyParts = listOf( + BodyPart.LEFT_FOOT, + BodyPart.RIGHT_FOOT, + ) } diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/hid/HIDCommon.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/hid/HIDCommon.kt index 6e18e05ec6..79879fa41a 100644 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/hid/HIDCommon.kt +++ b/server/core/src/main/java/dev/slimevr/tracking/trackers/hid/HIDCommon.kt @@ -90,8 +90,8 @@ class HIDCommon { userEditable = true, imuType = sensorType, allowFiltering = true, - needsReset = true, - needsMounting = true, + allowReset = true, + allowMounting = true, usesTimeout = false, magStatus = magStatus, ) diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/TrackersUDPServer.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/TrackersUDPServer.kt index ac9f15d8d0..79a91f97ef 100644 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/TrackersUDPServer.kt +++ b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/TrackersUDPServer.kt @@ -174,7 +174,16 @@ class TrackersUDPServer(private val port: Int, name: String, private val tracker // Set up new sensor for older firmware. // Firmware after 7 should send sensor status packet and sensor // will be created when it's received - setUpSensor(connection, 0, handshake.imuType, 1, MagnetometerStatus.NOT_SUPPORTED, null, TrackerDataType.ROTATION) + setUpSensor( + connection, + 0, + handshake.imuType, + 1, + MagnetometerStatus.NOT_SUPPORTED, + null, + TrackerDataType.ROTATION, + null, + ) } connection } @@ -186,7 +195,16 @@ class TrackersUDPServer(private val port: Int, name: String, private val tracker } private val mainScope = CoroutineScope(SupervisorJob()) - private fun setUpSensor(connection: UDPDevice, trackerId: Int, sensorType: IMUType, sensorStatus: Int, magStatus: MagnetometerStatus, trackerPosition: TrackerPosition?, trackerDataType: TrackerDataType) { + private fun setUpSensor( + connection: UDPDevice, + trackerId: Int, + sensorType: IMUType, + sensorStatus: Int, + magStatus: MagnetometerStatus, + trackerPosition: TrackerPosition?, + trackerDataType: TrackerDataType, + hasCompletedRestCalibration: Boolean?, + ) { LogManager.info("[TrackerServer] Sensor $trackerId for ${connection.name} status: $sensorStatus") var imuTracker = connection.getTracker(trackerId) if (imuTracker == null) { @@ -210,8 +228,8 @@ class TrackersUDPServer(private val port: Int, name: String, private val tracker userEditable = true, imuType = if (trackerDataType == TrackerDataType.ROTATION) sensorType else null, allowFiltering = true, - needsReset = true, - needsMounting = true, + allowReset = true, + allowMounting = true, usesTimeout = true, magStatus = magStatus, trackerDataType = trackerDataType, @@ -223,6 +241,8 @@ class TrackersUDPServer(private val port: Int, name: String, private val tracker val status = UDPPacket15SensorInfo.getStatus(sensorStatus) if (status != null) imuTracker.status = status + imuTracker.hasCompletedRestCalibration = hasCompletedRestCalibration + if (magStatus == MagnetometerStatus.NOT_SUPPORTED) return if (magStatus == MagnetometerStatus.ENABLED && (!VRServer.instance.configManager.vrConfig.server.useMagnetometerOnAllTrackers || imuTracker.config.shouldHaveMagEnabled == false) @@ -483,6 +503,7 @@ class TrackersUDPServer(private val port: Int, name: String, private val tracker magStatus, packet.trackerPosition, packet.trackerDataType, + packet.hasCompletedRestCalibration, ) // Send ack bb.limit(bb.capacity()) diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/TrackingChecklistManager.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/TrackingChecklistManager.kt new file mode 100644 index 0000000000..a1181f42b7 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/TrackingChecklistManager.kt @@ -0,0 +1,344 @@ +package dev.slimevr.trackingchecklist + +import dev.slimevr.VRServer +import dev.slimevr.bridge.ISteamVRBridge +import dev.slimevr.config.MountingMethods +import dev.slimevr.games.vrchat.VRCConfigListener +import dev.slimevr.games.vrchat.VRCConfigRecommendedValues +import dev.slimevr.games.vrchat.VRCConfigValidity +import dev.slimevr.games.vrchat.VRCConfigValues +import dev.slimevr.tracking.trackers.Tracker +import dev.slimevr.tracking.trackers.TrackerStatus +import dev.slimevr.tracking.trackers.TrackerUtils +import dev.slimevr.tracking.trackers.udp.TrackerDataType +import solarxr_protocol.datatypes.DeviceIdT +import solarxr_protocol.datatypes.TrackerIdT +import solarxr_protocol.rpc.* +import java.util.* +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.concurrent.timerTask + +interface TrackingChecklistListener { + fun onStepsUpdate() +} + +class TrackingChecklistManager(private val vrServer: VRServer) : VRCConfigListener { + + private val listeners: MutableList = CopyOnWriteArrayList() + val steps: MutableList = mutableListOf() + + private val updateTrackingChecklistTimer = Timer("TrackingChecklistTimer") + + // Simple flag set to true if reset mounting was performed at least once. + // This value is only runtime and never saved + var resetMountingCompleted = false + var feetResetMountingCompleted = false + + init { + vrServer.vrcConfigManager.addListener(this) + + createSteps() + updateTrackingChecklistTimer.scheduleAtFixedRate( + timerTask { + updateChecklist() + }, + 0, + 1000, + ) + } + + fun addListener(channel: TrackingChecklistListener) { + listeners.add(channel) + } + + fun removeListener(channel: TrackingChecklistListener) { + listeners.removeIf { channel == it } + } + + fun buildTrackersIds(trackers: List): Array = trackers.map { tracker -> + TrackerIdT().apply { + if (tracker.device != null) { + deviceId = DeviceIdT().apply { id = tracker.device.id } + } + trackerNum = tracker.trackerNum + } + }.toTypedArray() + + private fun createSteps() { + steps.add( + TrackingChecklistStepT().apply { + id = TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC + enabled = vrServer.networkProfileChecker.isSupported + optional = false + ignorable = true + visibility = TrackingChecklistStepVisibility.WHEN_INVALID + }, + ) + + steps.add( + TrackingChecklistStepT().apply { + id = TrackingChecklistStepId.STEAMVR_DISCONNECTED + enabled = true + optional = false + ignorable = true + visibility = TrackingChecklistStepVisibility.WHEN_INVALID + }, + ) + + steps.add( + TrackingChecklistStepT().apply { + id = TrackingChecklistStepId.TRACKER_ERROR + valid = true // Default to valid + enabled = true + optional = false + ignorable = false + visibility = TrackingChecklistStepVisibility.WHEN_INVALID + }, + ) + + steps.add( + TrackingChecklistStepT().apply { + id = TrackingChecklistStepId.TRACKERS_REST_CALIBRATION + enabled = true + optional = false + ignorable = true + visibility = TrackingChecklistStepVisibility.ALWAYS + }, + ) + + steps.add( + TrackingChecklistStepT().apply { + id = TrackingChecklistStepId.FULL_RESET + enabled = true + optional = false + ignorable = false + visibility = TrackingChecklistStepVisibility.ALWAYS + }, + ) + + steps.add( + TrackingChecklistStepT().apply { + id = TrackingChecklistStepId.MOUNTING_CALIBRATION + valid = false + enabled = vrServer.configManager.vrConfig.resetsConfig.preferedMountingMethod == MountingMethods.AUTOMATIC + optional = false + ignorable = true + visibility = TrackingChecklistStepVisibility.ALWAYS + }, + ) + + steps.add( + TrackingChecklistStepT().apply { + id = TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION + valid = false + enabled = false + optional = false + ignorable = true + visibility = TrackingChecklistStepVisibility.ALWAYS + }, + ) + + steps.add( + TrackingChecklistStepT().apply { + id = TrackingChecklistStepId.UNASSIGNED_HMD + enabled = true + optional = false + ignorable = false + visibility = TrackingChecklistStepVisibility.WHEN_INVALID + }, + ) + + steps.add( + TrackingChecklistStepT().apply { + id = TrackingChecklistStepId.STAY_ALIGNED_CONFIGURED + enabled = true + optional = true + ignorable = true + visibility = TrackingChecklistStepVisibility.WHEN_INVALID + }, + ) + + steps.add( + TrackingChecklistStepT().apply { + id = TrackingChecklistStepId.VRCHAT_SETTINGS + enabled = vrServer.vrcConfigManager.isSupported + optional = true + ignorable = true + visibility = TrackingChecklistStepVisibility.WHEN_INVALID + }, + ) + } + + fun updateChecklist() { + val assignedTrackers = + vrServer.allTrackers.filter { it.trackerPosition != null && it.status != TrackerStatus.DISCONNECTED } + val imuTrackers = + assignedTrackers.filter { it.isImu() && it.trackerDataType != TrackerDataType.FLEX_ANGLE } + + val trackersWithError = + imuTrackers.filter { it.status == TrackerStatus.ERROR } + updateValidity( + TrackingChecklistStepId.TRACKER_ERROR, + trackersWithError.isEmpty(), + ) { + if (trackersWithError.isNotEmpty()) { + it.extraData = TrackingChecklistExtraDataUnion().apply { + type = TrackingChecklistExtraData.TrackingChecklistTrackerError + value = TrackingChecklistTrackerErrorT().apply { + trackersId = buildTrackersIds(trackersWithError) + } + } + } else { + it.extraData = null + } + } + + val trackerRequireReset = imuTrackers.filter { + it.status !== TrackerStatus.ERROR && !it.isInternal && it.allowReset && it.needReset + } + updateValidity(TrackingChecklistStepId.FULL_RESET, trackerRequireReset.isEmpty()) { + if (trackerRequireReset.isNotEmpty()) { + it.extraData = TrackingChecklistExtraDataUnion().apply { + type = TrackingChecklistExtraData.TrackingChecklistTrackerReset + value = TrackingChecklistTrackerResetT().apply { + trackersId = buildTrackersIds(trackerRequireReset) + } + } + } else { + it.extraData = null + } + } + + val hmd = + assignedTrackers.firstOrNull { it.isHmd && !it.isInternal && it.status.sendData } + val assignedHmd = hmd == null || vrServer.humanPoseManager.skeleton.headTracker != null + updateValidity(TrackingChecklistStepId.UNASSIGNED_HMD, assignedHmd) { + if (!assignedHmd) { + it.extraData = TrackingChecklistExtraDataUnion().apply { + type = TrackingChecklistExtraData.TrackingChecklistUnassignedHMD + value = TrackingChecklistUnassignedHMDT().apply { + trackerId = TrackerIdT().apply { + if (hmd.device != null) { + deviceId = DeviceIdT().apply { id = hmd.device.id } + } + trackerNum = hmd.trackerNum + } + } + } + } else { + it.extraData = null + } + } + + val trackersNeedCalibration = imuTrackers.filter { + it.hasCompletedRestCalibration == false + } + updateValidity( + TrackingChecklistStepId.TRACKERS_REST_CALIBRATION, + trackersNeedCalibration.isEmpty(), + ) { + // Don't show the step if none of the trackers connected support IMU calibration + it.enabled = imuTrackers.any { t -> + t.hasCompletedRestCalibration != null + } + if (trackersNeedCalibration.isNotEmpty()) { + it.extraData = TrackingChecklistExtraDataUnion().apply { + type = TrackingChecklistExtraData.TrackingChecklistNeedCalibration + value = TrackingChecklistNeedCalibrationT().apply { + trackersId = buildTrackersIds(trackersNeedCalibration) + } + } + } else { + it.extraData = null + } + } + + val steamVRBridge = vrServer.getVRBridge(ISteamVRBridge::class.java) + if (steamVRBridge != null) { + val steamvrConnected = steamVRBridge.isConnected() + updateValidity( + TrackingChecklistStepId.STEAMVR_DISCONNECTED, + steamvrConnected, + ) { + if (!steamvrConnected) { + it.extraData = TrackingChecklistExtraDataUnion().apply { + type = TrackingChecklistExtraData.TrackingChecklistSteamVRDisconnected + value = TrackingChecklistSteamVRDisconnectedT().apply { + bridgeSettingsName = steamVRBridge.getBridgeConfigKey() + } + } + } else { + it.extraData = null + } + } + } + + if (vrServer.networkProfileChecker.isSupported) { + updateValidity(TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC, vrServer.networkProfileChecker.publicNetworks.isEmpty()) { + if (vrServer.networkProfileChecker.publicNetworks.isNotEmpty()) { + it.extraData = TrackingChecklistExtraDataUnion().apply { + type = TrackingChecklistExtraData.TrackingChecklistPublicNetworks + value = TrackingChecklistPublicNetworksT().apply { + adapters = vrServer.networkProfileChecker.publicNetworks.map { it.name }.toTypedArray() + } + } + } else { + it.extraData = null + } + } + } + + updateValidity(TrackingChecklistStepId.MOUNTING_CALIBRATION, resetMountingCompleted) { + it.enabled = vrServer.configManager.vrConfig.resetsConfig.preferedMountingMethod == MountingMethods.AUTOMATIC + } + + updateValidity(TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION, feetResetMountingCompleted) { + it.enabled = + vrServer.configManager.vrConfig.resetsConfig.preferedMountingMethod == MountingMethods.AUTOMATIC && + !vrServer.configManager.vrConfig.resetsConfig.resetMountingFeet && + imuTrackers.any { t -> TrackerUtils.feetsBodyParts.contains(t.trackerPosition?.bodyPart) } + } + + updateValidity(TrackingChecklistStepId.STAY_ALIGNED_CONFIGURED, vrServer.configManager.vrConfig.stayAlignedConfig.enabled) + + listeners.forEach { it.onStepsUpdate() } + } + + private fun updateValidity(id: Int, valid: Boolean, beforeUpdate: ((step: TrackingChecklistStepT) -> Unit)? = null) { + require(id != TrackingChecklistStepId.UNKNOWN) { + "id is unknown" + } + val step = steps.find { it.id == id } ?: return + step.valid = valid + if (beforeUpdate != null) { + beforeUpdate(step) + } + } + + override fun onChange( + validity: VRCConfigValidity, + values: VRCConfigValues, + recommended: VRCConfigRecommendedValues, + muted: List, + ) { + updateValidity( + TrackingChecklistStepId.VRCHAT_SETTINGS, + VRCConfigValidity::class.java.declaredFields.asSequence().all { p -> + p.isAccessible = true + return@all p.get(validity) == true || muted.contains(p.name) + }, + ) + listeners.forEach { it.onStepsUpdate() } + } + + fun ignoreStep(step: TrackingChecklistStepT, ignore: Boolean) { + if (!step.ignorable) return + val ignoredSteps = vrServer.configManager.vrConfig.trackingChecklist.ignoredStepsIds + if (ignore && !ignoredSteps.contains(step.id)) { + ignoredSteps.add(step.id) + } else if (!ignore) { + ignoredSteps.remove(step.id) + } + vrServer.configManager.saveConfig() + } +} diff --git a/server/core/src/test/java/dev/slimevr/unit/LegTweaksTests.kt b/server/core/src/test/java/dev/slimevr/unit/LegTweaksTests.kt index 4676adc5fd..830a60f9db 100644 --- a/server/core/src/test/java/dev/slimevr/unit/LegTweaksTests.kt +++ b/server/core/src/test/java/dev/slimevr/unit/LegTweaksTests.kt @@ -26,8 +26,8 @@ class LegTweaksTests { hasRotation = true, isComputed = true, imuType = null, - needsReset = false, - needsMounting = false, + allowReset = false, + allowMounting = false, isHmd = true, trackRotDirection = false, ) diff --git a/server/core/src/test/java/dev/slimevr/unit/MountingResetTests.kt b/server/core/src/test/java/dev/slimevr/unit/MountingResetTests.kt index c046ab9144..c7de28c0d3 100644 --- a/server/core/src/test/java/dev/slimevr/unit/MountingResetTests.kt +++ b/server/core/src/test/java/dev/slimevr/unit/MountingResetTests.kt @@ -45,8 +45,8 @@ class MountingResetTests { null, hasRotation = true, imuType = IMUType.UNKNOWN, - needsReset = true, - needsMounting = true, + allowReset = true, + allowMounting = true, trackRotDirection = false, ) @@ -130,8 +130,8 @@ class MountingResetTests { null, hasRotation = true, imuType = IMUType.UNKNOWN, - needsReset = true, - needsMounting = true, + allowReset = true, + allowMounting = true, trackRotDirection = false, ) diff --git a/server/core/src/test/java/dev/slimevr/unit/ReferenceAdjustmentsTests.kt b/server/core/src/test/java/dev/slimevr/unit/ReferenceAdjustmentsTests.kt index a28d8c85e8..5a90b1ddb9 100644 --- a/server/core/src/test/java/dev/slimevr/unit/ReferenceAdjustmentsTests.kt +++ b/server/core/src/test/java/dev/slimevr/unit/ReferenceAdjustmentsTests.kt @@ -93,7 +93,7 @@ class ReferenceAdjustmentsTests { null, hasRotation = true, imuType = IMUType.UNKNOWN, - needsReset = true, + allowReset = true, ) tracker.setRotation(trackerQuat) tracker.resetsHandler.resetFull(referenceQuat) @@ -125,7 +125,7 @@ class ReferenceAdjustmentsTests { null, hasRotation = true, imuType = IMUType.UNKNOWN, - needsReset = true, + allowReset = true, ) tracker.setRotation(trackerQuat) tracker.resetsHandler.resetYaw(referenceQuat) @@ -153,7 +153,7 @@ class ReferenceAdjustmentsTests { null, hasRotation = true, imuType = IMUType.UNKNOWN, - needsReset = true, + allowReset = true, ) tracker.setRotation(trackerQuat) tracker.resetsHandler.resetFull(referenceQuat) diff --git a/server/core/src/test/java/dev/slimevr/unit/TestTrackerSet.kt b/server/core/src/test/java/dev/slimevr/unit/TestTrackerSet.kt index 92ec22b602..cac3f38514 100644 --- a/server/core/src/test/java/dev/slimevr/unit/TestTrackerSet.kt +++ b/server/core/src/test/java/dev/slimevr/unit/TestTrackerSet.kt @@ -58,8 +58,8 @@ class TestTrackerSet( hasPosition = positional || isHmd, hasRotation = true, isComputed = computed || isHmd, - needsReset = resetHead || !isHmd, - needsMounting = resetHead || !isHmd, + allowReset = resetHead || !isHmd, + allowMounting = resetHead || !isHmd, isHmd = isHmd, trackRotDirection = false, ) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/NetworkProfileChecker.kt b/server/desktop/src/main/java/dev/slimevr/desktop/DesktopNetworkProfileChecker.kt similarity index 77% rename from server/desktop/src/main/java/dev/slimevr/desktop/NetworkProfileChecker.kt rename to server/desktop/src/main/java/dev/slimevr/desktop/DesktopNetworkProfileChecker.kt index 09a7b48810..3fbf796673 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/NetworkProfileChecker.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/DesktopNetworkProfileChecker.kt @@ -13,61 +13,15 @@ import com.sun.jna.platform.win32.WTypes import com.sun.jna.platform.win32.WinNT.HRESULT import com.sun.jna.ptr.IntByReference import com.sun.jna.ptr.PointerByReference +import dev.slimevr.ConnectivityFlags +import dev.slimevr.NetworkCategory +import dev.slimevr.NetworkInfo +import dev.slimevr.NetworkProfileChecker import dev.slimevr.VRServer import io.eiren.util.OperatingSystem -import solarxr_protocol.rpc.StatusData -import solarxr_protocol.rpc.StatusDataUnion -import solarxr_protocol.rpc.StatusPublicNetworkT import java.util.* import kotlin.concurrent.scheduleAtFixedRate -data class NetworkInfo( - val name: String?, - val description: String?, - val category: NetworkCategory?, - val connectivity: Set?, - val connected: Boolean?, -) - -/** - * @see NLM_NETWORK_CATEGORY enumeration (netlistmgr.h) - */ -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 NLM_CONNECTIVITY enumeration (netlistmgr.h) - */ -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 = - if (value == 0) { - setOf(DISCONNECTED) - } else { - values().filter { it != DISCONNECTED && (value and it.value) != 0 }.toSet() - } - } -} - /** * @see INetworkConnection interface (netlistmgr.h) */ @@ -309,38 +263,22 @@ fun enumerateNetworks(): List? { return null } -class NetworkProfileChecker(private val server: VRServer) { +class DesktopNetworkProfileChecker(private val server: VRServer) : NetworkProfileChecker() { private val updateTickTimer = Timer("NetworkProfileCheck") - private var lastPublicNetworkStatus: UInt = 0u - private var numPublicNetworks = 0 + private var publicNetworksLocal: List = listOf() + + override val isSupported: Boolean + get() = OperatingSystem.currentPlatform == OperatingSystem.WINDOWS + + override val publicNetworks: List + get() = publicNetworksLocal init { if (OperatingSystem.currentPlatform == OperatingSystem.WINDOWS) { this.updateTickTimer.scheduleAtFixedRate(0, 3000) { - val currentNumPublicNetworks = enumerateNetworks()?.filter { net -> + publicNetworksLocal = enumerateNetworks()?.filter { net -> net.connected == true && net.category == NetworkCategory.PUBLIC } ?: listOf() - val currentNumPublicNetworksCount = currentNumPublicNetworks.count() - - if (numPublicNetworks != currentNumPublicNetworksCount) { - numPublicNetworks = currentNumPublicNetworksCount - if (lastPublicNetworkStatus != 0u) { - server.statusSystem.removeStatus(lastPublicNetworkStatus) - lastPublicNetworkStatus = 0u - } - - if (lastPublicNetworkStatus == 0u && numPublicNetworks > 0) { - lastPublicNetworkStatus = server.statusSystem.addStatus( - StatusDataUnion().apply { - type = StatusData.StatusPublicNetwork - value = StatusPublicNetworkT().apply { - adapters = currentNumPublicNetworks.map { it.name }.toTypedArray() - } - }, - false, - ) - } - } } } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt index 20d1a8170c..088c670b0f 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -125,12 +125,11 @@ fun main(args: Array) { { _ -> DesktopSerialHandler() }, { _ -> DesktopSerialFlashingHandler() }, { _ -> DesktopVRCConfigHandler() }, + { server -> DesktopNetworkProfileChecker(server) }, configPath = configDir, ) vrServer.start() - NetworkProfileChecker(vrServer) - // Start service for USB HID trackers DesktopHIDManager( "Sensors HID service", diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/SteamVRBridge.kt b/server/desktop/src/main/java/dev/slimevr/desktop/platform/SteamVRBridge.kt index 52fc936115..d5de321b92 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/SteamVRBridge.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/platform/SteamVRBridge.kt @@ -15,20 +15,18 @@ import dev.slimevr.tracking.trackers.TrackerRole.Companion.getById import dev.slimevr.tracking.trackers.TrackerUtils.getTrackerForSkeleton import dev.slimevr.util.ann.VRServerThread import io.eiren.util.collections.FastList -import solarxr_protocol.rpc.StatusData -import solarxr_protocol.rpc.StatusDataUnion -import solarxr_protocol.rpc.StatusSteamVRDisconnectedT abstract class SteamVRBridge( protected val server: VRServer, threadName: String, bridgeName: String, - protected val bridgeSettingsKey: String, + val bridgeSettingsKey: String, protected val shareableTrackers: List, ) : ProtobufBridge(bridgeName), Runnable { protected val runnerThread: Thread = Thread(this, threadName) protected val config: BridgeConfig = server.configManager.vrConfig.getBridge(bridgeSettingsKey) + var connected: Boolean = false @VRServerThread override fun startBridge() { @@ -187,7 +185,7 @@ abstract class SteamVRBridge( hasRotation = true, userEditable = true, isComputed = true, - needsReset = true, + allowReset = true, isHmd = isHmd, ) @@ -427,33 +425,13 @@ abstract class SteamVRBridge( } } - /** - * When 0, then it means null - */ - protected var lastSteamVRStatus: Int = 0 - @BridgeThread protected fun reportDisconnected() { - if (lastSteamVRStatus != 0) { - return - } - val statusData = StatusSteamVRDisconnectedT() - statusData.bridgeSettingsName = bridgeSettingsKey - - val status = StatusDataUnion() - status.type = StatusData.StatusSteamVRDisconnected - status.value = statusData - lastSteamVRStatus = instance.statusSystem - .addStatus(status, false).toInt() + connected = false } @BridgeThread protected fun reportConnected() { - if (lastSteamVRStatus == 0) { - return - } - instance.statusSystem - .removeStatus(lastSteamVRStatus.toUInt()) - lastSteamVRStatus = 0 + connected = true } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketBridge.java b/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketBridge.java index 3b3580c73e..50436a8895 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketBridge.java +++ b/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketBridge.java @@ -8,6 +8,7 @@ import dev.slimevr.tracking.trackers.Tracker; import io.eiren.util.ann.ThreadSafe; import io.eiren.util.logging.LogManager; +import org.jetbrains.annotations.NotNull; import java.io.File; import java.io.IOException; @@ -25,6 +26,7 @@ public class UnixSocketBridge extends SteamVRBridge implements AutoCloseable { public final String socketPath; public final UnixDomainSocketAddress socketAddress; + private final String bridgeSettingsKey; private final ByteBuffer dst = ByteBuffer.allocate(2048).order(ByteOrder.LITTLE_ENDIAN); private final ByteBuffer src = ByteBuffer.allocate(2048).order(ByteOrder.LITTLE_ENDIAN); @@ -41,6 +43,7 @@ public UnixSocketBridge( List shareableTrackers ) { super(server, "Named socket thread", bridgeName, bridgeSettingsKey, shareableTrackers); + this.bridgeSettingsKey = bridgeSettingsKey; this.socketPath = socketPath; this.socketAddress = UnixDomainSocketAddress.of(socketPath); @@ -241,5 +244,11 @@ public void close() throws Exception { public boolean isConnected() { return channel != null && channel.isConnected(); } + + @NotNull + @Override + public String getBridgeConfigKey() { + return bridgeSettingsKey; + } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/WindowsNamedPipeBridge.java b/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/WindowsNamedPipeBridge.java index ab577eb9af..5099fccd8d 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/WindowsNamedPipeBridge.java +++ b/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/WindowsNamedPipeBridge.java @@ -14,6 +14,7 @@ import dev.slimevr.tracking.trackers.Tracker; import io.eiren.util.ann.ThreadSafe; import io.eiren.util.logging.LogManager; +import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.util.List; @@ -37,6 +38,7 @@ public class WindowsNamedPipeBridge extends SteamVRBridge { private static final Advapi32 adv32 = Advapi32.INSTANCE; protected final String pipeName; + protected final String bridgeSettingsKey; private final byte[] buffArray = new byte[2048]; protected WindowsPipe pipe; protected WinNT.HANDLE openEvent = k32.CreateEvent(null, false, false, null); @@ -63,6 +65,7 @@ public WindowsNamedPipeBridge( ) { super(server, "Named pipe thread", bridgeName, bridgeSettingsKey, shareableTrackers); this.pipeName = pipeName; + this.bridgeSettingsKey = bridgeSettingsKey; overlappedWait.hEvent = rxEvent; } @@ -321,4 +324,10 @@ private boolean tryOpeningPipe(WindowsPipe pipe) { public boolean isConnected() { return pipe != null && pipe.state == PipeState.OPEN; } + + @NotNull + @Override + public String getBridgeConfigKey() { + return this.bridgeSettingsKey; + } } diff --git a/solarxr-protocol b/solarxr-protocol index df26226d10..7e6cc3b425 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit df26226d104f75527a669e03879be675777885e3 +Subproject commit 7e6cc3b42593fdd7022af11ad3eb2e8a29f3c42f