Files
SlimeVR-Server/gui/src/components/TopBar.tsx
2026-03-26 08:09:10 +01:00

297 lines
9.8 KiB
TypeScript

import { ReactNode, useContext, useEffect, useState } from 'react';
import { NavLink, useMatch } from 'react-router-dom';
import {
RpcMessage,
ServerInfosRequestT,
ServerInfosResponseT,
TrackerStatus,
} from 'solarxr-protocol';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { CloseIcon } from './commons/icon/CloseIcon';
import { MaximiseIcon } from './commons/icon/MaximiseIcon';
import { MinimiseIcon } from './commons/icon/MinimiseIcon';
import { SlimeVRIcon } from './commons/icon/SimevrIcon';
import { ProgressBar } from './commons/ProgressBar';
import { Typography } from './commons/Typography';
import { DownloadIcon } from './commons/icon/DownloadIcon';
import { DOCS_SITE, GH_REPO, VersionContext } from '@/App';
import classNames from 'classnames';
import { QuestionIcon } from './commons/icon/QuestionIcon';
import { useBreakpoint } from '@/hooks/breakpoint';
import { GearIcon } from './commons/icon/GearIcon';
import { TrackersStillOnModal } from './TrackersStillOnModal';
import { useConfig } from '@/hooks/config';
import { TrayOrExitModal } from './TrayOrExitModal';
import { ErrorConsentModal } from './ErrorConsentModal';
import { useAtomValue } from 'jotai';
import { connectedIMUTrackersAtom } from '@/store/app-store';
import { useElectron } from '@/hooks/electron';
import { openUrl } from '@/hooks/crossplatform';
export function VersionTag() {
return (
<div
className={classNames(
'flex justify-around flex-col text-standard-bold',
'text-status-success bg-status-success bg-opacity-20 rounded-lg',
'px-3 select-text cursor-pointer'
)}
onClick={() => {
const url = `https://github.com/${GH_REPO}/releases`;
openUrl(url);
}}
>
{(__VERSION_TAG__ || __COMMIT_HASH__) + (__GIT_CLEAN__ ? '' : '-dirty')}
</div>
);
}
export function TopBar({
progress,
}: {
children?: ReactNode;
progress?: number;
}) {
const electron = useElectron();
const { isMobile } = useBreakpoint('mobile');
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
const connectedIMUTrackers = useAtomValue(connectedIMUTrackersAtom);
const { config, setConfig, saveConfig } = useConfig();
const version = useContext(VersionContext);
const [localIp, setLocalIp] = useState<string | null>(null);
const [showConnectedTrackersWarning, setConnectedTrackerWarning] =
useState(false);
const [showTrayOrExitModal, setShowTrayOrExitModal] = useState(false);
const doesMatchSettings = useMatch({
path: '/settings/*',
});
const closeApp = async () => {
if (!electron.isElectron) throw 'no electron';
await saveConfig();
electron.api.close();
};
const tryCloseApp = async (dontTray = false) => {
if (!electron.isElectron) throw 'no electron';
if (config?.useTray === null) {
setShowTrayOrExitModal(true);
return;
}
if (config?.useTray && !dontTray) {
electron.api.hide();
} else if (
config?.connectedTrackersWarning &&
connectedIMUTrackers.filter(
(t) => t.tracker.status !== TrackerStatus.TIMED_OUT
).length > 0
) {
setConnectedTrackerWarning(true);
} else {
await closeApp();
}
};
// useEffect(() => {
// if (!electron.isElectron) return;
// const unlistenTrayClose = listen('try-close', async () => {
// const window = getCurrentWindow();
// await window.show();
// await window.requestUserAttention(UserAttentionType.Critical);
// await window.setFocus();
// if (isTrayAvailable) await invoke('update_tray_text');
// await tryCloseApp(true);
// });
// const unlistenCloseRequested = getCurrentWindow().listen(
// TauriEvent.WINDOW_CLOSE_REQUESTED,
// async (data) => {
// const ev = new CloseRequestedEvent(data);
// ev.preventDefault();
// await tryCloseApp();
// }
// );
// return () => {
// unlistenTrayClose.then((fn) => fn());
// unlistenCloseRequested.then((fn) => fn());
// };
// }, [
// config?.useTray,
// config?.connectedTrackersWarning,
// JSON.stringify(connectedIMUTrackers.map((t) => t.tracker.status)),
// ]);
useEffect(() => {
sendRPCPacket(RpcMessage.ServerInfosRequest, new ServerInfosRequestT());
}, []);
useRPCPacket(
RpcMessage.ServerInfosResponse,
({ localIp }: ServerInfosResponseT) => {
if (localIp) setLocalIp(localIp.toString());
}
);
return (
<>
<div className="flex gap-0 flex-col">
<div className="h-[3px]" />
<div className="flex gap-2 h-[38px] z-49">
<div className="flex px-2 py-2 justify-around z-49">
<div className="flex gap-2">
{!isMobile && (
<NavLink
to="/"
className="flex justify-around flex-col select-all"
>
<SlimeVRIcon />
</NavLink>
)}
{!isMobile && (
<div
className={classNames('flex justify-around flex-col')}
data-electron-drag-region
>
<Typography>SlimeVR</Typography>
</div>
)}
{(!doesMatchSettings || !isMobile) && <VersionTag />}
{doesMatchSettings && (
<div
className={classNames(
'flex justify-around flex-col text-standard-bold text-status-special',
'bg-status-special bg-opacity-20 rounded-lg px-3 select-text'
)}
>
{localIp || 'unknown local ip'}
</div>
)}
{version && electron.isElectron && (
<div
className="cursor-pointer"
onClick={() => {
const url =
electron.data().os.type === 'windows'
? 'https://slimevr.dev/download'
: `https://github.com/${GH_REPO}/releases/latest`;
openUrl(url);
}}
>
<DownloadIcon />
</div>
)}
</div>
</div>
<div
className="flex flex-grow items-center h-full justify-center z-50"
data-electron-drag-region
>
{!isMobile && (
<>
<div
className="flex max-w-xl h-full items-center w-full"
data-electron-drag-region
>
{progress !== undefined && (
<ProgressBar progress={progress} height={3} parts={3} />
)}
</div>
</>
)}
</div>
<div className="flex justify-end items-center px-2 gap-2 z-50">
<NavLink
to="/settings/trackers"
className="flex justify-around flex-col select-all fill-background-50"
state={{ scrollTo: 'steamvr' }}
>
<GearIcon />
</NavLink>
{!isMobile && (
<div
className={classNames(
'flex items-center justify-center stroke-window-icon',
'hover:bg-background-60 rounded-full w-7 h-7 cursor-pointer'
)}
onClick={() => openUrl(DOCS_SITE)}
>
<QuestionIcon />
</div>
)}
{electron.isElectron && (
<>
<div
className="flex items-center justify-center hover:bg-background-60 rounded-full w-7 h-7"
onClick={() => electron.api.minimize()}
>
<MinimiseIcon />
</div>
<div
className="flex items-center justify-center hover:bg-background-60 rounded-full w-7 h-7"
onClick={() => electron.api.maximize()}
>
<MaximiseIcon />
</div>
<div
className="flex items-center justify-center hover:bg-background-60 rounded-full w-7 h-7"
onClick={() => tryCloseApp()}
>
<CloseIcon />
</div>
</>
)}
</div>
</div>
{isMobile && progress !== undefined && (
<div className="flex gap-2 px-2 h-6 mb-2 justify-center flex-col border-b border-accent-background-30">
<ProgressBar progress={progress} height={3} parts={3} />
</div>
)}
</div>
{electron.isElectron && (
<TrayOrExitModal
isOpen={showTrayOrExitModal}
accept={async (useTray) => {
await setConfig({ useTray });
setShowTrayOrExitModal(false);
// Doing this in here just in case config doesn't get updated in time
if (useTray) {
electron.api.minimize();
// await invoke('update_tray_text');
} else if (
config?.connectedTrackersWarning &&
connectedIMUTrackers.filter(
(t) => t.tracker.status !== TrackerStatus.TIMED_OUT
).length > 0
) {
setConnectedTrackerWarning(true);
} else {
await closeApp();
}
}}
cancel={() => setShowTrayOrExitModal(false)}
/>
)}
<TrackersStillOnModal
isOpen={showConnectedTrackersWarning}
accept={() => closeApp()}
cancel={() => {
setConnectedTrackerWarning(false);
}}
/>
<ErrorConsentModal
isOpen={config?.errorTracking === null}
accept={() => setConfig({ errorTracking: true })}
cancel={() => setConfig({ errorTracking: false })}
/>
</>
);
}