mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-06 02:01:58 +02:00
Everything
This commit is contained in:
33519
package-lock.json
generated
33519
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,10 +13,11 @@
|
||||
"@types/react-dom": "^17.0.14",
|
||||
"add": "^2.0.6",
|
||||
"classnames": "^2.3.1",
|
||||
"dreampact": "^0.1.10",
|
||||
"flatbuffers": "^2.0.6",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-hook-form": "^7.29.0",
|
||||
"react-modal": "^3.14.4",
|
||||
"react-router-dom": "^6.2.2",
|
||||
"react-scripts": "5.0.0",
|
||||
"slimevr-protocol": "file:../SlimeVR-Server/slimevr_protocol",
|
||||
@@ -49,7 +50,9 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.0",
|
||||
"@tauri-apps/cli": "^1.0.0-rc.8",
|
||||
"@types/react-modal": "^3.13.1",
|
||||
"autoprefixer": "^10.4.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"postcss": "^8.4.12",
|
||||
|
||||
8
src-tauri/.gitignore
vendored
8
src-tauri/.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
WixTools
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
WixTools
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "Slimevr UI",
|
||||
"width": 1033,
|
||||
"height": 639,
|
||||
"width": 1150,
|
||||
"height": 660,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
|
||||
56
src/App.tsx
56
src/App.tsx
@@ -6,25 +6,63 @@ import {
|
||||
Route,
|
||||
} from "react-router-dom";
|
||||
import { Overview } from './components/Overview';
|
||||
import { Manage } from './components/Manage';
|
||||
import { BigButton } from './components/commons/BigButton';
|
||||
import { QuickResetIcon, ResetIcon } from './components/commons/icon/ResetIcon';
|
||||
import { useReset } from './hooks/reset';
|
||||
import { Settings } from './components/Settings';
|
||||
import { Button } from './components/commons/Button';
|
||||
import { useLayout } from './hooks/layout';
|
||||
import { BodyProportions } from './components/BodyProportions';
|
||||
|
||||
|
||||
function Layout() {
|
||||
const { layoutHeight, ref } = useLayout();
|
||||
const { reset, timer, reseting } = useReset()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} className='flex-grow' style={{ height: layoutHeight }}>
|
||||
<div className="flex bg-primary-1 h-full ">
|
||||
<div className="flex flex-grow gap-10 flex-col bg-primary-2 rounded-tr-3xl">
|
||||
<Routes>
|
||||
<Route path="/" element={<Overview/>}/>
|
||||
<Route path="/proportions" element={<BodyProportions/>}/>
|
||||
<Route path="/settings" element={<Settings/>}/>
|
||||
</Routes>
|
||||
</div>
|
||||
<div className="flex flex-col px-8 w-60 gap-8 pb-5 overflow-y-auto">
|
||||
<div className='flex'>
|
||||
<BigButton text={"Fast reset"} icon={<QuickResetIcon/>} onClick={() => reset(true)} ></BigButton>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<BigButton text={!reseting ? "Reset" : `${3 - timer}`} icon={<ResetIcon />} onClick={() => reset(false)} disabled={reseting}></BigButton>
|
||||
</div>
|
||||
<div className='flex flex-grow flex-col justify-end'>
|
||||
<Button variant='primary' className='w-full'>Debug</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function App() {
|
||||
const websocketAPI = useProvideWebsocketApi();
|
||||
|
||||
return (
|
||||
<WebSocketApiContext.Provider value={websocketAPI}>
|
||||
<Router>
|
||||
<div className='bg-primary h-full w-full overflow-hidden'>
|
||||
<div className='flex-col h-full'>
|
||||
<Navbar></Navbar>
|
||||
<div className='flex-grow h-full'>
|
||||
<Routes>
|
||||
<Route path="/" element={<Overview/>}/>
|
||||
<Route path="manage" element={<Manage/>}/>
|
||||
</Routes>
|
||||
</div>
|
||||
{!websocketAPI.isConnected && <div className='flex w-full h-full justify-center items-center text-white p-2'>Connection lost to server</div>}
|
||||
{websocketAPI.isConnected &&
|
||||
<>
|
||||
<Navbar></Navbar>
|
||||
<Layout></Layout>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Router>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ReactChild } from "react";
|
||||
|
||||
|
||||
|
||||
export function BigButton({ text, icon }: { text: string, icon: ReactChild }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-md hover:bg-primary-4 bg-primary-3 py-10 gap-8 cursor-pointer">
|
||||
<div className="flex justify-around">{icon}</div>
|
||||
<div className="flex justify-around text-2xl text-white">{text}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
52
src/components/BodyProportions.tsx
Normal file
52
src/components/BodyProportions.tsx
Normal file
File diff suppressed because one or more lines are too long
28
src/components/Modal.tsx
Normal file
28
src/components/Modal.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ReactChild } from "react";
|
||||
import ReactModal from "react-modal";
|
||||
import { IconButton } from "./commons/ButtonIcon";
|
||||
import { CrossIcon } from "./commons/icon/CrossIcon";
|
||||
|
||||
export function AppModal({ children, name, ...props }: { children?: ReactChild, name: ReactChild } & ReactModal.Props) {
|
||||
|
||||
return (
|
||||
<ReactModal
|
||||
{...props}
|
||||
overlayClassName="fixed top-0 right-0 left-0 bottom-0 bg-black bg-opacity-60 flex justify-center items-center overflow-y-auto border-none"
|
||||
className="items-center w-full max-w-2xl h-full md:h-auto relative rounded-lg bg-primary-3 shadow-lg text-white border-none"
|
||||
>
|
||||
<div className="flex justify-between items-start p-5 rounded-t border-b-2 border-primary-1">
|
||||
<h3 className="text-xl font-semibold lg:text-2x">
|
||||
{name}
|
||||
</h3>
|
||||
<div className="flex">
|
||||
<IconButton icon={<CrossIcon></CrossIcon>} onClick={props.onRequestClose}></IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</ReactModal>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import classnames from 'classnames';
|
||||
import { ReactChild } from 'react';
|
||||
import {
|
||||
useMatch,
|
||||
NavLink,
|
||||
} from "react-router-dom";
|
||||
import { CubeIcon } from './icon/CubeIcon';
|
||||
import { SlimeVRIcon } from './icon/SimevrIcon';
|
||||
import { CubeIcon } from './commons/icon/CubeIcon';
|
||||
import { GearIcon } from './commons/icon/GearIcon';
|
||||
import { SlimeVRIcon } from './commons/icon/SimevrIcon';
|
||||
|
||||
|
||||
export function NavButton({ to, children, match }: { to: string, children: React.ReactChild, match?: string }) {
|
||||
export function NavButton({ to, children, match, icon }: { to: string, children: ReactChild, match?: string, icon: ReactChild }) {
|
||||
|
||||
const doesMatch = useMatch({
|
||||
path: match || to,
|
||||
@@ -16,7 +18,7 @@ export function NavButton({ to, children, match }: { to: string, children: React
|
||||
return (
|
||||
<NavLink to={to} className={classnames("flex flex-grow flex-row gap-3 py-3 px-8 rounded-t-md group ", { 'bg-primary-2': doesMatch, 'hover:bg-primary-3': !doesMatch })}>
|
||||
<div className="flex align-middle justify-center justify-items-center flex-col">
|
||||
<CubeIcon className={ classnames("fill-primary-3 group-hover:fill-white", { 'fill-white': doesMatch })}></CubeIcon>
|
||||
<div className={classnames("fill-primary-3 group-hover:fill-white", { 'fill-misc-3': doesMatch })}>{icon}</div>
|
||||
</div>
|
||||
<div className="flex text-white text-md">{children}</div>
|
||||
</NavLink >
|
||||
@@ -32,13 +34,13 @@ export function Navbar() {
|
||||
<div className="flex justify-around flex-col">
|
||||
<SlimeVRIcon></SlimeVRIcon>
|
||||
</div>
|
||||
<div className="flex text-white text-xl justify-around flex-col">SlimeVR</div>
|
||||
<div className="flex text-white text-xl justify-around flex-col font-bold">SlimeVR</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="flex px-5 gap-5 pt-1">
|
||||
<NavButton to="/">Overview</NavButton>
|
||||
<NavButton to="manage">Manage Trackers</NavButton>
|
||||
<div className="flex px-5 gap-2 pt-2">
|
||||
<NavButton to="/" icon={<CubeIcon></CubeIcon>}>Overview</NavButton>
|
||||
<NavButton to="/proportions" icon={<GearIcon></GearIcon>}>Body proportions</NavButton>
|
||||
<NavButton to="/settings" icon={<GearIcon></GearIcon>}>Settings</NavButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import { OutboundUnion } from "slimevr-protocol/dist/server";
|
||||
import { useMemo, useState } from "react";
|
||||
import { OutboundUnion, TrackerPosition } from "slimevr-protocol/dist/server";
|
||||
import { DeviceStatusT } from "slimevr-protocol/dist/slimevr-protocol/server/device-status";
|
||||
import { TrackersListT } from "slimevr-protocol/dist/slimevr-protocol/server/trackers-list";
|
||||
import { useWebsocketAPI } from "../hooks/websocket-api";
|
||||
import { BigButton } from "./BigButton";
|
||||
import { QuickResetIcon, ResetIcon } from "./icon/ResetIcon";
|
||||
import { TrackerCard } from "./TrackerCard";
|
||||
import { TrackerCard } from "./tracker/TrackerCard";
|
||||
|
||||
|
||||
export function Overview() {
|
||||
|
||||
|
||||
const { usePacket } = useWebsocketAPI();
|
||||
const [list, setTrackersList] = useState<DeviceStatusT[]>([]);
|
||||
|
||||
|
||||
usePacket(OutboundUnion.TrackersList, (packet: TrackersListT) => {
|
||||
setTrackersList(packet.trackers)
|
||||
})
|
||||
|
||||
const unasignedTrackers = useMemo(() => list.filter(({ editable, mountingPosition }) => editable && mountingPosition === TrackerPosition.NONE), [list]);
|
||||
|
||||
return (
|
||||
<div className="flex bg-primary-1 h-full">
|
||||
<div className="flex flex-grow gap-10 flex-col bg-primary-2 rounded-tr-3xl">
|
||||
<div className="flex text-white text-2xl px-8 pt-8">
|
||||
Tracker Overview
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-5 sm:grid-cols-1 overflow-y-auto px-8">
|
||||
{list.map((trackerStatus, index) => <TrackerCard key={index} status={trackerStatus}/>)}
|
||||
</div>
|
||||
<div className="overflow-y-auto flex flex-col gap-8">
|
||||
<div className="flex text-white text-2xl px-8 pt-8 font-bold">
|
||||
Tracker Overview
|
||||
</div>
|
||||
<div className="flex flex-col px-8 xs:w-1/4 sm:w-1/4 gap-8">
|
||||
<BigButton text="Quick reset" icon={<QuickResetIcon/>}></BigButton>
|
||||
<BigButton text="Reset Position" icon={<ResetIcon />}></BigButton>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-5 sm:grid-cols-1 px-8">
|
||||
{list.filter(({ editable, mountingPosition }) => editable && mountingPosition !== TrackerPosition.NONE).map((trackerStatus, index) => <TrackerCard key={index} status={trackerStatus}/>)}
|
||||
{/* {list.filter(({ computed }) => computed).map((trackerStatus, index) => <TrackerCard key={index} status={trackerStatus}/>)} */}
|
||||
</div>
|
||||
{unasignedTrackers.length > 0 &&
|
||||
<>
|
||||
<div className="flex text-white text-2xl px-8 pt-8 font-bold">
|
||||
Unassigned Trackers
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-5 sm:grid-cols-1 px-8">
|
||||
{unasignedTrackers.map((trackerStatus, index) => <TrackerCard key={index} status={trackerStatus}/>)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
109
src/components/Settings.tsx
Normal file
109
src/components/Settings.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useWebsocketAPI } from "../hooks/websocket-api";
|
||||
import { CheckBox } from "./commons/Checkbox";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useEffect } from "react";
|
||||
import { FilteringSettings, FilteringSettingsT, InboundUnion, OutboundUnion, SettingsRequestT, SteamVRTrackersSettingT } from "slimevr-protocol/dist/server";
|
||||
import { ChangeSettingsRequestT } from "slimevr-protocol/dist/slimevr-protocol/server/change-settings-request";
|
||||
import { SettingsResponseT } from "slimevr-protocol/dist/slimevr-protocol/server/settings-response";
|
||||
import { Select } from "./commons/Select";
|
||||
import { NumberSelector } from "./commons/NumberSelector";
|
||||
|
||||
|
||||
interface SettingsForm {
|
||||
trackers: {
|
||||
waist: boolean,
|
||||
chest: boolean,
|
||||
legs: boolean,
|
||||
knees: boolean,
|
||||
elbows: boolean,
|
||||
}
|
||||
filtering: {
|
||||
type: number;
|
||||
intensity: number;
|
||||
ticks: number;
|
||||
}
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
|
||||
const { sendPacket, usePacket } = useWebsocketAPI();
|
||||
const { register, reset, control, watch, handleSubmit } = useForm<SettingsForm>({ defaultValues: { filtering: { intensity: 0, ticks: 0 } } });
|
||||
|
||||
const onSubmit = (values: SettingsForm) => {
|
||||
const settings = new ChangeSettingsRequestT();
|
||||
|
||||
if (values.trackers) {
|
||||
const trackers = new SteamVRTrackersSettingT();
|
||||
trackers.waist = values.trackers.waist;
|
||||
trackers.chest = values.trackers.chest;
|
||||
trackers.legs = values.trackers.legs;
|
||||
trackers.knees = values.trackers.knees;
|
||||
trackers.elbows = values.trackers.elbows;
|
||||
|
||||
settings.steamVrTrackers = trackers;
|
||||
}
|
||||
|
||||
const filtering = new FilteringSettingsT();
|
||||
filtering.type = values.filtering.type;
|
||||
filtering.intensity = values.filtering.intensity;
|
||||
filtering.ticks = values.filtering.ticks;
|
||||
|
||||
settings.filtering = filtering;
|
||||
sendPacket(InboundUnion.ChangeSettingsRequest, settings)
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch(() => handleSubmit(onSubmit)());
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
sendPacket(InboundUnion.SettingsRequest, new SettingsRequestT());
|
||||
}, [])
|
||||
|
||||
usePacket(OutboundUnion.SettingsResponse, (settings: SettingsResponseT) => {
|
||||
reset({
|
||||
...(settings.steamVrTrackers ? {trackers: settings.steamVrTrackers} : {}),
|
||||
...(settings.filtering ? {filtering: settings.filtering} : {})
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<form className="px-8 flex flex-col gap-4 pt-4">
|
||||
<div className="flex text-white text-2xl font-bold">
|
||||
Settings
|
||||
</div>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex text-gray-300 text-xl font-bold gap-5">
|
||||
SteamVR Trackers
|
||||
</div>
|
||||
<div className="flex text-gray-300 text-xl font-bold gap-5">
|
||||
<CheckBox {...register('trackers.waist')} label="Waist" />
|
||||
<CheckBox {...register('trackers.chest')} label="Chest"/>
|
||||
<CheckBox {...register('trackers.legs')} label="Legs"/>
|
||||
<CheckBox {...register('trackers.knees')} label="Knees"/>
|
||||
<CheckBox {...register('trackers.elbows')} label="Elbows"/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex text-gray-300 text-xl font-bold gap-5">
|
||||
Trackers Filtering
|
||||
</div>
|
||||
<div className="flex text-gray-300 text-xl font-bold gap-5">
|
||||
<Select {...register('filtering.type')} label="Filtering Type" options={[{ label: 'None', value: 0 }, { label: 'Interpolation', value: 1 }, {label: 'Extrapolation', value: 2 }]}></Select>
|
||||
<NumberSelector variant="smol" control={control} name="filtering.intensity" label="Intensity" valueLabelFormat={(value) => `${value}%`} min={0} max={100} step={10}></NumberSelector>
|
||||
<NumberSelector variant="smol" control={control} name="filtering.ticks" label="Ticks" min={0} max={80} step={1}></NumberSelector>
|
||||
{/* <CheckBox {...register('trackers.chest')} label="Chest"/>
|
||||
<CheckBox {...register('trackers.legs')} label="Legs"/>
|
||||
<CheckBox {...register('trackers.knees')} label="Knees"/>
|
||||
<CheckBox {...register('trackers.elbows')} label="Elbows"/> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { DeviceStatusT } from "slimevr-protocol/dist/server";
|
||||
import { BatteryIcon } from "./icon/BatteryIcon";
|
||||
import { CircleIcon } from "./icon/CircleIcon";
|
||||
import { GearIcon } from "./icon/GearIcon";
|
||||
import { WifiIcon } from "./icon/WifiIcon";
|
||||
|
||||
export function TrackerCard({ status }: { status: DeviceStatusT }) {
|
||||
return (
|
||||
<div className="flex rounded-l-md rounded-r-xl bg-green-400" >
|
||||
<div className="flex bg-primary-3 rounded-r-md py-3 ml-1 px-5 w-full">
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="flex text-white text-ellipsis">{status.name}</div>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col justify-around">
|
||||
<WifiIcon />
|
||||
</div>
|
||||
<div className="flex text-gray-400 text-sm">{status.ping} ms</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col justify-around">
|
||||
<BatteryIcon />
|
||||
</div>
|
||||
<div className="flex text-gray-400 text-sm">{((status.battery / 256) * 100).toFixed(0)} %</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-around">
|
||||
<GearIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
11
src/components/commons/BigButton.tsx
Normal file
11
src/components/commons/BigButton.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import classNames from "classnames";
|
||||
import React, { ReactChild } from "react";
|
||||
|
||||
export function BigButton({ text, icon, disabled, onClick, ...props }: { text: string, icon: ReactChild } & React.AllHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button disabled={disabled} onClick={onClick} {...props} type="button" className={classNames("flex w-full flex-col rounded-md hover:bg-primary-5 bg-primary-4 py-10 gap-5 cursor-pointer items-center", { 'bg-gray-500 hover:bg-gray-500 cursor-not-allowed': disabled}, props.className)}>
|
||||
<div className="flex justify-around">{icon}</div>
|
||||
<div className="flex justify-around text-2xl text-white font-bold">{text}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
23
src/components/commons/Button.tsx
Normal file
23
src/components/commons/Button.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import classNames from "classnames";
|
||||
import { ReactChild, useMemo } from "react";
|
||||
|
||||
|
||||
|
||||
export function Button({ children, variant, disabled, ...props }: { children: ReactChild, variant: 'primary' | 'secondary' } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
|
||||
const classes = useMemo(() => {
|
||||
const variantsMap = {
|
||||
primary: 'text-white bg-primary-5 hover:bg-primary-1 focus:ring-4 focus:outline-none focus:ring-primary-2',
|
||||
secondary: 'text-white hover:bg-primary-1 focus:ring-primary-2'
|
||||
}
|
||||
|
||||
const variantsMapDisabled = {
|
||||
primary: 'bg-gray-800 hover:bg-gray-800',
|
||||
secondary: 'bg-gray-800 hover:bg-gray-800'
|
||||
}
|
||||
|
||||
return classNames(variantsMap[variant], 'focus:ring-4 rounded-lg text-sm px-5 py-2.5 text-center font-medium', disabled ? variantsMapDisabled[variant] : false);
|
||||
|
||||
}, [variant, disabled])
|
||||
return <button type="button" {...props} className={classes} disabled={disabled}>{children}</button>
|
||||
}
|
||||
11
src/components/commons/ButtonIcon.tsx
Normal file
11
src/components/commons/ButtonIcon.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import classNames from "classnames";
|
||||
import React, { ReactChild } from "react";
|
||||
|
||||
|
||||
export function IconButton({ icon, className, ...props }: { icon: ReactChild, className?: string } & React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div {...props} className={classNames("px-2 rounded-full h-8 w-8 flex justify-center items-center hover:bg-gray-500 fill-gray-200", className)}>
|
||||
{icon}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
src/components/commons/Checkbox.tsx
Normal file
12
src/components/commons/Checkbox.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { forwardRef, useId } from 'react'
|
||||
|
||||
export const CheckBox = forwardRef<HTMLInputElement, { label: string }>(({ label, ...props }, ref) => {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<input ref={ref} id={id} {...props} className="flex flex-col rounded-sm" type="checkbox" />
|
||||
<label htmlFor={id}>{label}</label>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
46
src/components/commons/NumberSelector.tsx
Normal file
46
src/components/commons/NumberSelector.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import classNames from "classnames";
|
||||
import { useMemo } from "react";
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
import { Button } from "./Button";
|
||||
|
||||
|
||||
|
||||
export function NumberSelector({ label, valueLabelFormat, control, name, min, max, step, variant }: { label: string, valueLabelFormat?: (value: number) => string, control: Control<any>, name: string, min: number, max: number, step: number, variant: 'smol' | 'big' }) {
|
||||
|
||||
const variantClass = useMemo(() => {
|
||||
const variantsMap = {
|
||||
smol: {
|
||||
container: 'flex flex-col gap-1',
|
||||
label: 'flex text-sm text-white',
|
||||
value: 'flex justify-center items-center w-10 text-white text-lg font-bold'
|
||||
},
|
||||
big: {
|
||||
container: 'flex flex-row gap-5 ',
|
||||
label: 'flex flex-grow text-lg font-bold text-white justify-start items-center',
|
||||
value: 'flex justify-center items-center w-16 text-white text-lg font-bold'
|
||||
}
|
||||
};
|
||||
return variantsMap[variant];
|
||||
}, [variant])
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className={classNames(variantClass.container)}>
|
||||
<div className={classNames(variantClass.label)}>{label}</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex">
|
||||
<Button variant="primary" onClick={() => onChange(value - step)} disabled={value <= min}>-</Button>
|
||||
</div>
|
||||
<div className={classNames(variantClass.value)}>{valueLabelFormat ? valueLabelFormat(value) : value}</div>
|
||||
<div className="flex">
|
||||
<Button variant="primary" onClick={() => onChange(value + step)} disabled={value >= max}>+</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
14
src/components/commons/Select.tsx
Normal file
14
src/components/commons/Select.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
export type SelectOption = { label: string, value: any };
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, { options: SelectOption[], label?: string }>(({ label, options, ...props }, ref) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && <span className="text-sm">{label}</span>}
|
||||
<select {...props} ref={ref} className="w-full mt-0 rounded-md bg-primary-5 border-primary-1 shadow-sm focus:border-gray-300 focus:ring focus:ring-gray-200 focus:ring-opacity-50">
|
||||
{options && options.map(({ label, value }) => <option value={value} key={value}>{label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
7
src/components/commons/icon/ArrowDownIcon.tsx
Normal file
7
src/components/commons/icon/ArrowDownIcon.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
|
||||
export function ArrowDownIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" /></svg>
|
||||
)
|
||||
}
|
||||
31
src/components/commons/icon/BatteryIcon.tsx
Normal file
31
src/components/commons/icon/BatteryIcon.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import classNames from "classnames";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function BatteryIcon({ value }: { value: number }) {
|
||||
|
||||
const col = useMemo(() => {
|
||||
|
||||
const colorsMap: { [key: number]: string } = {
|
||||
0.4: 'fill-misc-1',
|
||||
0.2: 'fill-misc-4',
|
||||
0: 'fill-misc-2',
|
||||
}
|
||||
|
||||
const val = Object.keys(colorsMap).filter(key => +key < value).sort((a, b) => +b - +a)[0];
|
||||
return colorsMap[+val] || 'fill-gray-800';
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<svg width="19" height="9" viewBox="0 0 19 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.0833 0H1.31203C0.995003 0.00131561 0.691347 0.121213 0.467167 0.333594C0.242986 0.545976 0.116428 0.83365 0.115039 1.134V7.383C0.114754 7.68458 0.240506 7.97399 0.464808 8.18799C0.689109 8.40198 0.993714 8.52315 1.31203 8.525H11.0833V0Z" fill="#4C3755"/>
|
||||
<path d="M15.0005 8.525C15.3175 8.52368 15.6212 8.40379 15.8454 8.19141C16.0696 7.97902 16.1961 7.69135 16.1975 7.391V5.968H17.9972V2.558H16.1975V1.134C16.1961 0.83365 16.0696 0.545976 15.8454 0.333594C15.6212 0.121213 15.3175 0.00131561 15.0005 0H10.9672V8.525H15.0005Z" fill="#4C3755"/>
|
||||
<mask id="mask0_4_39" style={{ 'maskType': 'alpha', }} maskUnits="userSpaceOnUse" x="0" y="0" width="18" height="9">
|
||||
<path d="M11.0833 0H1.31203C0.995003 0.00131561 0.691347 0.121213 0.467167 0.333594C0.242986 0.545976 0.116428 0.83365 0.115039 1.134V7.383C0.114754 7.68458 0.240506 7.97399 0.464808 8.18799C0.689109 8.40198 0.993714 8.52315 1.31203 8.525H11.0833V0Z" fill="#4C3755"/>
|
||||
<path d="M15.0005 8.525C15.3175 8.52368 15.6212 8.40379 15.8454 8.19141C16.0696 7.97902 16.1961 7.69135 16.1975 7.391V5.968H17.9972V2.558H16.1975V1.134C16.1961 0.83365 16.0696 0.545976 15.8454 0.333594C15.6212 0.121213 15.3175 0.00131561 15.0005 0H10.9672V8.525H15.0005Z" fill="#4C3755"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_4_39)" className={classNames(col, 'opacity-100')}>
|
||||
<rect width={value * 18} height="9"/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
5
src/components/commons/icon/CrossIcon.tsx
Normal file
5
src/components/commons/icon/CrossIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
|
||||
export function CrossIcon() {
|
||||
return ( <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd"></path></svg>)
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
export function GearIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 14 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.00099 11.9C7.31948 11.9003 7.63041 11.997 7.89283 12.1775C8.15524 12.3579 8.35682 12.6137 8.47099 12.911C9.11419 12.757 9.72885 12.5018 10.292 12.155C10.2026 11.9531 10.1559 11.7348 10.155 11.514C10.1546 11.2516 10.2198 10.9933 10.3448 10.7626C10.4697 10.5319 10.6505 10.3362 10.8705 10.1932C11.0905 10.0503 11.3427 9.96464 11.6043 9.94417C11.8659 9.92369 12.1284 9.96902 12.368 10.076C12.7147 9.51424 12.9699 8.90091 13.124 8.259C12.8268 8.1447 12.5711 7.9431 12.3906 7.68071C12.2102 7.41833 12.1134 7.10745 12.113 6.789C12.1134 6.47055 12.2102 6.15968 12.3906 5.89729C12.5711 5.6349 12.8268 5.4333 13.124 5.319C12.97 4.6758 12.7148 4.06115 12.368 3.498C12.1284 3.60499 11.8659 3.65031 11.6043 3.62983C11.3427 3.60936 11.0905 3.52374 10.8705 3.38078C10.6505 3.23782 10.4697 3.04207 10.3448 2.81138C10.2198 2.58068 10.1546 2.32237 10.155 2.06C10.1543 1.83899 10.201 1.62041 10.292 1.419C9.72669 1.07498 9.11135 0.820948 8.46799 0.666C8.35405 0.963474 8.15253 1.21938 7.89007 1.39989C7.6276 1.5804 7.31654 1.67703 6.99799 1.677C6.67886 1.67742 6.36711 1.581 6.10393 1.40049C5.84075 1.21998 5.63853 0.963872 5.52399 0.666C4.882 0.821335 4.26872 1.07752 3.70699 1.425C3.83599 1.71551 3.87394 2.03828 3.81586 2.35079C3.75778 2.66329 3.60638 2.95087 3.38162 3.17563C3.15686 3.40039 2.86928 3.55178 2.55678 3.60987C2.24427 3.66795 1.9215 3.62999 1.63099 3.501C1.28471 4.06442 1.02957 4.67899 0.874993 5.322C1.17184 5.43574 1.42719 5.63685 1.60733 5.89878C1.78746 6.1607 1.8839 6.47111 1.8839 6.789C1.8839 7.10689 1.78746 7.4173 1.60733 7.67922C1.42719 7.94115 1.17184 8.14226 0.874993 8.256C1.02899 8.8992 1.28417 9.51385 1.63099 10.077C1.92149 9.94858 2.24405 9.91106 2.55627 9.96935C2.86849 10.0276 3.15577 10.179 3.38036 10.4036C3.60495 10.6282 3.75634 10.9155 3.81464 11.2277C3.87294 11.5399 3.83541 11.8625 3.70699 12.153C4.26924 12.4985 4.88243 12.7533 5.52399 12.908C5.6399 12.6107 5.84296 12.3554 6.10653 12.1755C6.37011 11.9956 6.68189 11.8996 7.00099 11.9ZM4.45399 6.817C4.4536 6.31238 4.60287 5.81897 4.88294 5.3992C5.16301 4.97942 5.56128 4.65214 6.02738 4.45876C6.49348 4.26537 7.00646 4.21457 7.50144 4.31277C7.99642 4.41098 8.45115 4.65378 8.80811 5.01046C9.16508 5.36715 9.40824 5.82169 9.50683 6.31659C9.60542 6.81149 9.55502 7.32451 9.362 7.79076C9.16898 8.25701 8.84201 8.65554 8.42246 8.93594C8.00291 9.21634 7.50962 9.366 7.00499 9.366C6.67012 9.36613 6.3385 9.3003 6.02907 9.17228C5.71964 9.04425 5.43846 8.85653 5.20158 8.61983C4.96469 8.38313 4.77675 8.1021 4.64848 7.79277C4.52021 7.48344 4.45412 7.15187 4.45399 6.817Z" fill="#AF91BE"/>
|
||||
<svg width="20" height="20" viewBox="0 0 14 13" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.00099 11.9C7.31948 11.9003 7.63041 11.997 7.89283 12.1775C8.15524 12.3579 8.35682 12.6137 8.47099 12.911C9.11419 12.757 9.72885 12.5018 10.292 12.155C10.2026 11.9531 10.1559 11.7348 10.155 11.514C10.1546 11.2516 10.2198 10.9933 10.3448 10.7626C10.4697 10.5319 10.6505 10.3362 10.8705 10.1932C11.0905 10.0503 11.3427 9.96464 11.6043 9.94417C11.8659 9.92369 12.1284 9.96902 12.368 10.076C12.7147 9.51424 12.9699 8.90091 13.124 8.259C12.8268 8.1447 12.5711 7.9431 12.3906 7.68071C12.2102 7.41833 12.1134 7.10745 12.113 6.789C12.1134 6.47055 12.2102 6.15968 12.3906 5.89729C12.5711 5.6349 12.8268 5.4333 13.124 5.319C12.97 4.6758 12.7148 4.06115 12.368 3.498C12.1284 3.60499 11.8659 3.65031 11.6043 3.62983C11.3427 3.60936 11.0905 3.52374 10.8705 3.38078C10.6505 3.23782 10.4697 3.04207 10.3448 2.81138C10.2198 2.58068 10.1546 2.32237 10.155 2.06C10.1543 1.83899 10.201 1.62041 10.292 1.419C9.72669 1.07498 9.11135 0.820948 8.46799 0.666C8.35405 0.963474 8.15253 1.21938 7.89007 1.39989C7.6276 1.5804 7.31654 1.67703 6.99799 1.677C6.67886 1.67742 6.36711 1.581 6.10393 1.40049C5.84075 1.21998 5.63853 0.963872 5.52399 0.666C4.882 0.821335 4.26872 1.07752 3.70699 1.425C3.83599 1.71551 3.87394 2.03828 3.81586 2.35079C3.75778 2.66329 3.60638 2.95087 3.38162 3.17563C3.15686 3.40039 2.86928 3.55178 2.55678 3.60987C2.24427 3.66795 1.9215 3.62999 1.63099 3.501C1.28471 4.06442 1.02957 4.67899 0.874993 5.322C1.17184 5.43574 1.42719 5.63685 1.60733 5.89878C1.78746 6.1607 1.8839 6.47111 1.8839 6.789C1.8839 7.10689 1.78746 7.4173 1.60733 7.67922C1.42719 7.94115 1.17184 8.14226 0.874993 8.256C1.02899 8.8992 1.28417 9.51385 1.63099 10.077C1.92149 9.94858 2.24405 9.91106 2.55627 9.96935C2.86849 10.0276 3.15577 10.179 3.38036 10.4036C3.60495 10.6282 3.75634 10.9155 3.81464 11.2277C3.87294 11.5399 3.83541 11.8625 3.70699 12.153C4.26924 12.4985 4.88243 12.7533 5.52399 12.908C5.6399 12.6107 5.84296 12.3554 6.10653 12.1755C6.37011 11.9956 6.68189 11.8996 7.00099 11.9ZM4.45399 6.817C4.4536 6.31238 4.60287 5.81897 4.88294 5.3992C5.16301 4.97942 5.56128 4.65214 6.02738 4.45876C6.49348 4.26537 7.00646 4.21457 7.50144 4.31277C7.99642 4.41098 8.45115 4.65378 8.80811 5.01046C9.16508 5.36715 9.40824 5.82169 9.50683 6.31659C9.60542 6.81149 9.55502 7.32451 9.362 7.79076C9.16898 8.25701 8.84201 8.65554 8.42246 8.93594C8.00291 9.21634 7.50962 9.366 7.00499 9.366C6.67012 9.36613 6.3385 9.3003 6.02907 9.17228C5.71964 9.04425 5.43846 8.85653 5.20158 8.61983C4.96469 8.38313 4.77675 8.1021 4.64848 7.79277C4.52021 7.48344 4.45412 7.15187 4.45399 6.817Z"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
34
src/components/commons/icon/WifiIcon.tsx
Normal file
34
src/components/commons/icon/WifiIcon.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import classNames from "classnames";
|
||||
import { useMemo } from "react"
|
||||
|
||||
|
||||
export function WifiIcon({ value }: { value: number }) {
|
||||
|
||||
const percent = useMemo(() => value ? Math.max(Math.min((value - -95) * (100 - 0) / (-40 - -95) + 0, 100)) / 100 : 0, [value])
|
||||
|
||||
const y = useMemo(() => percent ? (1 - percent) * 13 : 0 , [percent])
|
||||
|
||||
const col = useMemo(() => {
|
||||
|
||||
const colorsMap: { [key: number]: string } = {
|
||||
0.4: 'fill-misc-1',
|
||||
0.2: 'fill-misc-4',
|
||||
0: 'fill-misc-2',
|
||||
}
|
||||
|
||||
const val = Object.keys(colorsMap).filter(key => +key < percent).sort((a, b) => +b - +a)[0];
|
||||
return colorsMap[+val] || 'fill-gray-800';
|
||||
}, [percent])
|
||||
|
||||
return (
|
||||
<svg width="16" height="13" viewBox="0 0 16 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.799 12.378L15.585 2.67801C13.3492 0.95947 10.6129 0.01903 7.793 1.00136e-05C4.9725 0.0172 2.23528 0.95782 0 2.67801L7.786 12.378L7.793 12.385L7.799 12.378Z" fill="#4C3755"/>
|
||||
<mask id="mask0_0_1" style={{ 'maskType': 'alpha', }} maskUnits="userSpaceOnUse" x="0" width="16" height="13" className={classNames(col, 'opacity-100')} >
|
||||
<path d="M0 2.712L7.782 12.392V12.407L7.795 12.396L15.577 2.716C13.3449 0.980306 10.6044 0.026036 7.777 0C4.95656 0.021826 2.22242 0.975276 0 2.712Z"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_0_1)" className={classNames(col)}>
|
||||
<path style={{ transform: `translateY(${y}px)`}} d="M0 2.712L7.782 12.392V12.407L7.795 12.396L15.577 2.716C13.3449 0.980306 10.6044 0.026036 7.777 0C4.95656 0.021826 2.22242 0.975276 0 2.712Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
|
||||
|
||||
export function BatteryIcon() {
|
||||
return (
|
||||
<svg width="18" height="9" viewBox="0 0 18 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.39 0L1.13298 0C0.832634 0.00131561 0.54496 0.121213 0.332579 0.333594C0.120197 0.545976 0.000300407 0.83365 -0.00101566 1.134V7.383C-0.00128555 7.68458 0.117848 7.97399 0.330344 8.18799C0.54284 8.40198 0.831413 8.52315 1.13298 8.525H10.39V0Z" fill="#50E897"/>
|
||||
<path d="M14.211 8.525C14.5113 8.52368 14.799 8.40379 15.0114 8.19141C15.2238 7.97902 15.3437 7.69135 15.345 7.391V5.968H17.05V2.558H15.345V1.134C15.3437 0.83365 15.2238 0.545976 15.0114 0.333594C14.799 0.121213 14.5113 0.00131561 14.211 0L10.39 0V8.525H14.211Z" fill="#4C3755"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
|
||||
|
||||
export function WifiIcon() {
|
||||
return (
|
||||
<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.799 13.378L15.585 3.67801C13.3492 1.95947 10.6129 1.01903 7.793 1.00001C4.9725 1.0172 2.23528 1.95782 0 3.67801L7.786 13.378L7.793 13.385L7.799 13.378Z" fill="#4C3755"/>
|
||||
<path d="M0.00700378 3.691L7.789 13.371V13.386L7.802 13.375L15.584 3.695C13.3519 1.95931 10.6114 1.00504 7.784 0.979004C4.96356 1.00083 2.22942 1.95428 0.00700378 3.691Z" fill="#50E897"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
77
src/components/tracker/TrackerCard.tsx
Normal file
77
src/components/tracker/TrackerCard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DeviceStatusT, TrackerPosition } from "slimevr-protocol/dist/server";
|
||||
import { TrackerStatus } from "slimevr-protocol/dist/slimevr-protocol/server/tracker-status";
|
||||
import { IconButton } from "../commons/ButtonIcon";
|
||||
import { BatteryIcon } from "../commons/icon/BatteryIcon";
|
||||
import { GearIcon } from "../commons/icon/GearIcon";
|
||||
import { WifiIcon } from "../commons/icon/WifiIcon";
|
||||
import { TrackerSettings } from "./TrackerSettings";
|
||||
import { Quaternion } from '../../maths/quaternion';
|
||||
|
||||
|
||||
export function TrackerCard({ status }: { status: DeviceStatusT }) {
|
||||
|
||||
const [previousRot, setPreviousRot] = useState<{ x: number, y: number, z: number, w: number }>({ x: 0, y: 0, z: 0, w: 0 })
|
||||
const [velocity, setVelocity] = useState<number>(0);
|
||||
|
||||
const statusClass = useMemo(() => {
|
||||
const statusMap: { [key: number]: string } = {
|
||||
[TrackerStatus.NONE]: 'bg-cyan-800',
|
||||
[TrackerStatus.BUSY]: 'bg-misc-4',
|
||||
[TrackerStatus.ERROR]: 'bg-misc-2',
|
||||
[TrackerStatus.DISCONNECTED]: 'bg-gray-800',
|
||||
[TrackerStatus.OCCLUDED]: 'bg-misc-4',
|
||||
[TrackerStatus.OK]: 'bg-misc-1'
|
||||
}
|
||||
return statusMap[status.status];
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.rotation) {
|
||||
const rot = Quaternion.from(status.rotation).mult(Quaternion.from(previousRot).inverse());
|
||||
const dif = Math.min(100, (rot.x**2 + rot.y**2 + rot.z**2) * 2.5)
|
||||
setVelocity(dif);
|
||||
setPreviousRot(status.rotation);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status])
|
||||
|
||||
|
||||
return (
|
||||
<TrackerSettings status={status} >
|
||||
<div className={classNames("flex rounded-l-md rounded-r-xl", statusClass)}>
|
||||
<div className="flex bg-primary-4 rounded-r-md py-3 ml-1 pr-2 pl-4 w-full gap-3">
|
||||
<div className="flex flex-grow flex-col truncate gap-2">
|
||||
<div className="flex text-white font-bold">{!status.computed && status.editable ? TrackerPosition[status.mountingPosition] : status.name}</div>
|
||||
<div className="flex flex-row gap-4 ">
|
||||
<div className="flex gap-2 flex-grow">
|
||||
<div className="flex flex-col justify-around">
|
||||
<WifiIcon value={status.signal} />
|
||||
</div>
|
||||
<div className="flex text-gray-400 text-sm w-10">{status.ping} ms</div>
|
||||
</div>
|
||||
<div className="flex w-1/3 gap-2">
|
||||
<div className="flex flex-col justify-around">
|
||||
<BatteryIcon value={status.battery / 256}/>
|
||||
</div>
|
||||
<div className="flex text-gray-400 text-sm">{((status.battery / 255) * 100).toFixed(0)} %</div>
|
||||
</div>
|
||||
<div className="flex w-1/3 gap-0.5 justify-around flex-col">
|
||||
<div className="w-full bg-gray-200 rounded-full h-1 dark:bg-gray-700">
|
||||
<div className="bg-misc-3 h-1 rounded-full" style={{width: `${velocity * 100}%`}}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{status.editable &&
|
||||
<div className="flex flex-col flex-shrink justify-around">
|
||||
<IconButton icon={<GearIcon/>}/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</TrackerSettings>
|
||||
)
|
||||
|
||||
}
|
||||
71
src/components/tracker/TrackerSettings.tsx
Normal file
71
src/components/tracker/TrackerSettings.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ReactChild, useMemo, useState } from "react";
|
||||
import { AssignTrackerRequestT, DeviceStatusT, InboundUnion, TrackerPosition } from "slimevr-protocol/dist/server";
|
||||
import { Button } from "../commons/Button";
|
||||
import { AppModal } from "../Modal";
|
||||
import { Select } from "../commons/Select";
|
||||
import { useWebsocketAPI } from "../../hooks/websocket-api";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
|
||||
export function TrackerSettings({ status, children }: { status: DeviceStatusT, children: ReactChild }) {
|
||||
|
||||
const { sendPacket } = useWebsocketAPI();
|
||||
const { register, handleSubmit, reset } = useForm({ defaultValues: { mountingPosition: 0, mountingRotation: 0 } });
|
||||
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const positions = useMemo(() => Object.keys(TrackerPosition).filter((position: string) => isNaN(+position)).map((role, index) =>( { label: role, value: index })), [])
|
||||
const rotations = useMemo(() => [{ label: 'FRONT', value: 180 }, { label: 'LEFT', value: 90 }, { label: 'RIGHT', value: -90 }, { label: 'BACK', value: 0 }], [])
|
||||
|
||||
|
||||
const handleSaveSettings = ({ mountingPosition, mountingRotation }: { mountingPosition: number, mountingRotation: number }) => {
|
||||
const assignreq = new AssignTrackerRequestT();
|
||||
|
||||
assignreq.mountingRotation = mountingRotation;
|
||||
assignreq.mountingPosition = mountingPosition;
|
||||
assignreq.id = status.id;
|
||||
|
||||
sendPacket(InboundUnion.AssignTrackerRequest, assignreq, true).then((res) => {
|
||||
if (res) setOpen(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const openSettings = () => {
|
||||
|
||||
if (!status.editable)
|
||||
return;
|
||||
|
||||
setOpen(true)
|
||||
reset({
|
||||
mountingRotation: status.mountingRotation,
|
||||
mountingPosition: status.mountingPosition
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={openSettings}>
|
||||
{children}
|
||||
</div>
|
||||
<AppModal
|
||||
isOpen={open}
|
||||
onRequestClose={() => setOpen(false)}
|
||||
name={<>{status.mountingPosition ? `${TrackerPosition[status.mountingPosition]} Settings` : 'Tracker Settings'}</>}
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleSaveSettings)} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Select {...register("mountingPosition")} label="Tracker role" options={positions}></Select>
|
||||
<Select {...register("mountingRotation")} label="Mounting Rotation" options={rotations}></Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="primary" type="submit" >Save</Button>
|
||||
<Button variant="primary" type="button" onClick={() => setOpen(false)}>Close</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
5
src/gobals.d.ts
vendored
Normal file
5
src/gobals.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as react from 'react'
|
||||
|
||||
declare module 'react' {
|
||||
function useId(): string;
|
||||
}
|
||||
32
src/hooks/layout.ts
Normal file
32
src/hooks/layout.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
|
||||
|
||||
export function useLayout() {
|
||||
const [layoutHeight, setLayoutHeigt] = useState(window.innerHeight);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const computeLayoutHeight = (windowHeight: number) => {
|
||||
if (ref.current) {
|
||||
setLayoutHeigt(windowHeight - ref.current.getBoundingClientRect().top)
|
||||
}
|
||||
}
|
||||
|
||||
const onWindowResize = () => {
|
||||
computeLayoutHeight(window.innerHeight)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
computeLayoutHeight(window.innerHeight)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
return {
|
||||
layoutHeight,
|
||||
ref
|
||||
}
|
||||
}
|
||||
45
src/hooks/reset.ts
Normal file
45
src/hooks/reset.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { InboundUnion, ResetRequestT } from "slimevr-protocol/dist/server";
|
||||
import { useWebsocketAPI } from "./websocket-api";
|
||||
|
||||
|
||||
|
||||
export function useReset() {
|
||||
const timerid = useRef<NodeJS.Timer | null>(null);
|
||||
const [reseting, setReseting] = useState(false);
|
||||
const [timer, setTimer] = useState(0);
|
||||
const { sendPacket } = useWebsocketAPI();
|
||||
|
||||
const reset = (quick: boolean) => {
|
||||
const req = new ResetRequestT();
|
||||
req.quick = quick;
|
||||
setReseting(true);
|
||||
if (!quick) {
|
||||
if (timerid.current)
|
||||
clearInterval(timerid.current);
|
||||
timerid.current = setInterval(() => {
|
||||
setTimer((timer) => {
|
||||
if (timer + 1 === 3) {
|
||||
if (timerid.current)
|
||||
clearInterval(timerid.current);
|
||||
sendPacket(InboundUnion.ResetRequest, req, false)
|
||||
.then(() => {
|
||||
setTimer(0);
|
||||
setReseting(false)
|
||||
})
|
||||
}
|
||||
return timer + 1;
|
||||
});
|
||||
}, 1000);
|
||||
} else {
|
||||
sendPacket(InboundUnion.ResetRequest, req, false)
|
||||
setReseting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reset,
|
||||
timer,
|
||||
reseting
|
||||
}
|
||||
}
|
||||
15
src/hooks/timeout.ts
Normal file
15
src/hooks/timeout.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useTimeout = (fn: () => void, delay: number) => {
|
||||
useEffect(() => {
|
||||
const id = setTimeout(fn, delay);
|
||||
return () => clearTimeout(id);
|
||||
});
|
||||
};
|
||||
|
||||
export const useInterval = (fn: () => void, delay: number) => {
|
||||
useEffect(() => {
|
||||
const id = setInterval(fn, delay);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
};
|
||||
@@ -1,97 +1,163 @@
|
||||
import { createContext, MutableRefObject, useContext, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ApplicationType, HandshakeRequestT, InboundPacketT, InboundUnion, OutboundPacket, OutboundUnion } from 'slimevr-protocol/dist/server'
|
||||
import { AcknowledgementT, ApplicationType, ConnectionRequestT, InboundPacketT, InboundUnion, OutboundPacket, OutboundPacketT, OutboundUnion } from 'slimevr-protocol/dist/server'
|
||||
|
||||
import { Builder, ByteBuffer } from 'flatbuffers'
|
||||
import { useInterval } from "./timeout";
|
||||
|
||||
export interface WebSocketApi {
|
||||
isConnected: boolean,
|
||||
eventlistenerRef: MutableRefObject<EventTarget>,
|
||||
usePacket: <T>(type: OutboundUnion, callback: (packet: T) => void) => void
|
||||
sendPacket: (type: InboundUnion, data: InboundPacketType, acknowledgeMe?: boolean) => Promise<boolean>
|
||||
}
|
||||
|
||||
|
||||
export const WebSocketApiContext = createContext<WebSocketApi>(undefined as any);
|
||||
|
||||
export type InboundPacketType = InboundPacketT['packet'];
|
||||
export type OutboundPacketType = OutboundPacketT['packet'];
|
||||
|
||||
export function useProvideWebsocketApi(): WebSocketApi {
|
||||
const packetCounterRef = useRef<number>(0);
|
||||
const toAcknoledgePacketsRef = useRef<{ [key: number]: () => void }>([]);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const eventlistenerRef = useRef<EventTarget>(new EventTarget());
|
||||
const [isConnected, setConnected] = useState(false);
|
||||
|
||||
|
||||
useInterval(() => {
|
||||
if (webSocketRef.current && !isConnected) {
|
||||
disconnect();
|
||||
connect();
|
||||
console.log('Try reconnecting');
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
const onConnected = (event: Event) => {
|
||||
if (!webSocketRef.current) return ;
|
||||
|
||||
setConnected(true);
|
||||
const conn = new ConnectionRequestT();
|
||||
conn.applicationType = ApplicationType.UI;
|
||||
|
||||
let fbb = new Builder(1);
|
||||
|
||||
const hand = new HandshakeRequestT();
|
||||
hand.applicationType = ApplicationType.UI;
|
||||
|
||||
|
||||
const inbound = new InboundPacketT();
|
||||
inbound.acknowledgeMe = true;
|
||||
inbound.packet = hand;
|
||||
inbound.packetType = InboundUnion.HandshakeRequest
|
||||
|
||||
fbb.finish(inbound.pack(fbb));
|
||||
|
||||
webSocketRef.current.send(fbb.asUint8Array());
|
||||
sendPacket(InboundUnion.ConnectionRequest, conn);
|
||||
}
|
||||
|
||||
const onConnectionClose = (event: Event) => {
|
||||
setConnected(false);
|
||||
|
||||
packetCounterRef.current = 0;
|
||||
toAcknoledgePacketsRef.current = [];
|
||||
}
|
||||
|
||||
const onMessage = async (event: { data: Blob }) => {
|
||||
|
||||
if (!event.data.arrayBuffer)
|
||||
return ;
|
||||
const buffer = await event.data.arrayBuffer();
|
||||
|
||||
const fbb = new ByteBuffer(new Uint8Array(buffer));
|
||||
|
||||
const outbountPacket = OutboundPacket.getRootAsOutboundPacket(fbb).unpack();
|
||||
|
||||
eventlistenerRef.current?.dispatchEvent(new CustomEvent(OutboundUnion[outbountPacket.packetType], { detail: outbountPacket }))
|
||||
|
||||
if (outbountPacket.acknowledgeMe && webSocketRef.current) {
|
||||
const fbb = new Builder();
|
||||
|
||||
const acknowledgement = new AcknowledgementT();
|
||||
acknowledgement.packetId = outbountPacket.packetCounter;
|
||||
fbb.finish(acknowledgement.pack(fbb));
|
||||
webSocketRef.current.send(fbb.asUint8Array());
|
||||
}
|
||||
|
||||
if (outbountPacket.packetType === OutboundUnion.slimevr_protocol_misc_Acknowledgement) {
|
||||
const acknowledgement = outbountPacket.packet as AcknowledgementT;
|
||||
const acknoledgePromise = toAcknoledgePacketsRef.current[acknowledgement.packetId];
|
||||
if (!acknoledgePromise)
|
||||
return;
|
||||
delete toAcknoledgePacketsRef.current[acknowledgement.packetId];
|
||||
acknoledgePromise()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const sendPacket = async (type: InboundUnion, data: InboundPacketType, acknowledgeMe = false): Promise<boolean> => {
|
||||
if (!webSocketRef.current)
|
||||
throw new Error('No connection');
|
||||
|
||||
const fbb = new Builder(1);
|
||||
|
||||
const inbound = new InboundPacketT();
|
||||
inbound.acknowledgeMe = acknowledgeMe;
|
||||
inbound.packetCounter = packetCounterRef.current;
|
||||
inbound.packet = data;
|
||||
inbound.packetType = type;
|
||||
|
||||
fbb.finish(inbound.pack(fbb));
|
||||
webSocketRef.current.send(fbb.asUint8Array());
|
||||
|
||||
if (acknowledgeMe) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
// TODO implement retry
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(false);
|
||||
}, 3000)
|
||||
|
||||
const acknoledged = () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(true);
|
||||
}
|
||||
|
||||
toAcknoledgePacketsRef.current[inbound.packetCounter] = acknoledged;
|
||||
})
|
||||
}
|
||||
|
||||
packetCounterRef.current++;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
const connect = () => {
|
||||
webSocketRef.current = new WebSocket('ws://localhost:21110');
|
||||
|
||||
|
||||
// Connection opened
|
||||
webSocketRef.current.addEventListener('open', onConnected);
|
||||
webSocketRef.current.addEventListener('close', onConnectionClose);
|
||||
webSocketRef.current.addEventListener('message', onMessage);
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
if (!webSocketRef.current) return ;
|
||||
|
||||
webSocketRef.current.removeEventListener('open', onConnected);
|
||||
webSocketRef.current.removeEventListener('close', onConnectionClose);
|
||||
webSocketRef.current.removeEventListener('message', onMessage);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
if (!webSocketRef.current) return ;
|
||||
|
||||
webSocketRef.current.removeEventListener('open', onConnected);
|
||||
webSocketRef.current.removeEventListener('close', onConnectionClose);
|
||||
webSocketRef.current.removeEventListener('message', onMessage);
|
||||
disconnect();
|
||||
}
|
||||
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
eventlistenerRef,
|
||||
usePacket: <T>(type: OutboundUnion, callback: (packet: T) => void) => {
|
||||
|
||||
const onEvent = (event: CustomEventInit) => {
|
||||
callback(event.detail.packet)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
eventlistenerRef.current.addEventListener(OutboundUnion[type], onEvent)
|
||||
|
||||
return () => {
|
||||
eventlistenerRef.current.removeEventListener(OutboundUnion[type], onEvent)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
},
|
||||
sendPacket
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
background-color: theme('colors.primary.2');
|
||||
background-color: theme('colors.primary.1');
|
||||
font-variant-numeric: tabular-nums;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import * as ReactDOMClient from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
Modal.setAppElement('#root');
|
||||
|
||||
const container = document.getElementById('root');
|
||||
|
||||
if (container) {
|
||||
const root = ReactDOMClient.createRoot(container);
|
||||
root.render(
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
);
|
||||
}
|
||||
|
||||
39
src/maths/quaternion.ts
Normal file
39
src/maths/quaternion.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { QuatT } from "slimevr-protocol/dist/server";
|
||||
|
||||
|
||||
export class Quaternion {
|
||||
|
||||
constructor(public x: number, public y: number, public z: number, public w: number) {}
|
||||
|
||||
public inverse(): Quaternion {
|
||||
const norm = this.norm();
|
||||
if(norm > 0.0) {
|
||||
const invNorm = 1.0 / norm;
|
||||
return new Quaternion(-this.x * invNorm, -this.y * invNorm, -this.z * invNorm, this.w * invNorm);
|
||||
}
|
||||
// return an invalid result to flag the error
|
||||
return new Quaternion(0,0, 0, 1);
|
||||
}
|
||||
|
||||
|
||||
norm(): number {
|
||||
return this.w * this.w + this.x * this.x + this.y * this.y + this.z * this.z;
|
||||
}
|
||||
|
||||
public mult(q: Quaternion): Quaternion {
|
||||
const res = new Quaternion(0, 0, 0, 1);
|
||||
|
||||
const qw = q.w, qx = q.x, qy = q.y, qz = q.z;
|
||||
res.x = this.x * qw + this.y * qz - this.z * qy + this.w * qx;
|
||||
res.y = -this.x * qz + this.y * qw + this.z * qx + this.w * qy;
|
||||
res.z = this.x * qy - this.y * qx + this.z * qw + this.w * qz;
|
||||
res.w = -this.x * qx - this.y * qy - this.z * qz + this.w * qw;
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
static from(q: { x: number, y: number, z: number, w: number }): Quaternion {
|
||||
return new Quaternion(q.x, q.y, q.z, q.w)
|
||||
}
|
||||
|
||||
}
|
||||
2
src/react-app-env.d.ts
vendored
2
src/react-app-env.d.ts
vendored
@@ -1 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
/// <reference types="react-scripts" />
|
||||
|
||||
@@ -6,12 +6,20 @@ module.exports = {
|
||||
primary: {
|
||||
1: '#201527',
|
||||
2: '#26192E',
|
||||
3: '#362640',
|
||||
4: '#AF91BE',
|
||||
5: '#A44FED'
|
||||
3: '#2F2037',
|
||||
4: '#382740',
|
||||
5: '#432E4D'
|
||||
},
|
||||
misc: {
|
||||
1: '#50E897',
|
||||
2: '#FF486E',
|
||||
3: '#A44FED',
|
||||
4: '#D5C055'
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
}
|
||||
@@ -1,26 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"strictExportPresence": false,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user