mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-17 22:03:50 +02:00
feat: status bar
This commit is contained in:
@@ -351,3 +351,11 @@ i.x-status-icon.red {
|
||||
[data-sonner-toast] [data-content] [data-description] {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.main-layout-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
609
src/components/StatusBar.vue
Normal file
609
src/components/StatusBar.vue
Normal file
@@ -0,0 +1,609 @@
|
||||
<template>
|
||||
<div class="status-bar" @contextmenu.prevent>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div class="status-bar-inner">
|
||||
<!-- Left section -->
|
||||
<div class="status-bar-left">
|
||||
<TooltipWrapper
|
||||
v-if="visibility.proxy"
|
||||
:content="
|
||||
vrcxStore.proxyServer
|
||||
? `${t('status_bar.proxy')}: ${vrcxStore.proxyServer}`
|
||||
: t('status_bar.proxy')
|
||||
"
|
||||
side="top">
|
||||
<div class="status-bar-item status-bar-clickable" @click="handleProxyClick">
|
||||
<span class="status-dot" :class="vrcxStore.proxyServer ? 'dot-green' : 'dot-gray'" />
|
||||
<span class="status-label">{{ vrcxStore.proxyServer || t('status_bar.proxy') }}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper
|
||||
v-if="!isMacOS && visibility.vrchat"
|
||||
:content="
|
||||
gameStore.isGameRunning
|
||||
? t('status_bar.vrchat_running')
|
||||
: t('status_bar.vrchat_stopped')
|
||||
"
|
||||
side="top">
|
||||
<div class="status-bar-item">
|
||||
<span class="status-dot" :class="gameStore.isGameRunning ? 'dot-green' : 'dot-gray'" />
|
||||
<span class="status-label">{{ t('status_bar.vrchat') }}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper
|
||||
v-if="!isMacOS && visibility.steamvr"
|
||||
:content="
|
||||
gameStore.isSteamVRRunning
|
||||
? t('status_bar.steamvr_running')
|
||||
: t('status_bar.steamvr_stopped')
|
||||
"
|
||||
side="top">
|
||||
<div class="status-bar-item">
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="gameStore.isSteamVRRunning ? 'dot-green' : 'dot-gray'" />
|
||||
<span class="status-label">{{ t('status_bar.steamvr') }}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper v-if="visibility.ws" :content="wsTooltip" side="top">
|
||||
<div class="status-bar-item status-bar-ws">
|
||||
<span class="status-dot" :class="wsState.connected ? 'dot-green' : 'dot-gray'" />
|
||||
<span class="status-label">WebSocket</span>
|
||||
<canvas ref="wsCanvasRef" class="ws-sparkline" />
|
||||
<span class="status-label-mono">{{
|
||||
t('status_bar.ws_avg_per_minute', { count: msgsPerMinuteAvg })
|
||||
}}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
|
||||
<!-- Right section -->
|
||||
<div class="status-bar-right">
|
||||
<template v-if="visibility.clocks">
|
||||
<Popover
|
||||
v-for="(clock, idx) in visibleClocks"
|
||||
:key="idx"
|
||||
v-model:open="clockPopoverOpen[idx]">
|
||||
<PopoverTrigger as-child>
|
||||
<div class="status-bar-item status-bar-clickable">
|
||||
<span class="status-label-mono">{{ formatClock(clock) }}</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="status-bar-clock-popover" side="top" align="center">
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<label class="text-xs font-medium">{{ t('status_bar.timezone') }}</label>
|
||||
<Select
|
||||
:model-value="String(clock.offset)"
|
||||
@update:modelValue="(offset) => updateClockTimezone(idx, offset)">
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue :placeholder="t('status_bar.timezone')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="max-h-60">
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="opt in timezoneOptions"
|
||||
:key="opt.value"
|
||||
:value="String(opt.value)">
|
||||
<div class="flex w-full items-center justify-end font-mono">
|
||||
{{ opt.label }}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<TooltipWrapper
|
||||
v-if="visibility.zoom"
|
||||
:content="t('status_bar.zoom_tooltip')"
|
||||
side="top"
|
||||
:disabled="zoomEditing">
|
||||
<div class="status-bar-item status-bar-clickable" @click="toggleZoomEdit">
|
||||
<template v-if="zoomEditing">
|
||||
<span class="status-label-mono">{{ t('status_bar.zoom') }}</span>
|
||||
<NumberField
|
||||
v-model="zoomLevel"
|
||||
:step="1"
|
||||
:format-options="{ maximumFractionDigits: 0 }"
|
||||
class="status-bar-zoom-field"
|
||||
@update:modelValue="setZoomLevel">
|
||||
<NumberFieldContent>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput
|
||||
ref="zoomInputRef"
|
||||
class="status-bar-zoom-input"
|
||||
@blur="zoomEditing = false"
|
||||
@keydown.enter="zoomEditing = false"
|
||||
@keydown.escape="zoomEditing = false" />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldContent>
|
||||
</NumberField>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="status-label-mono">{{ t('status_bar.zoom') }}</span>
|
||||
<span class="status-label-mono">{{ zoomLevel }}%</span>
|
||||
</template>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper v-if="visibility.uptime" :content="t('status_bar.app_uptime')" side="top">
|
||||
<div class="status-bar-item">
|
||||
<span class="status-label-mono">{{ t('status_bar.app_uptime_short') }}</span>
|
||||
<span class="status-label-mono">{{ appUptimeText }}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuCheckboxItem
|
||||
v-if="!isMacOS"
|
||||
:model-value="visibility.vrchat"
|
||||
@update:model-value="toggleVisibility('vrchat')">
|
||||
{{ t('status_bar.vrchat') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
v-if="!isMacOS"
|
||||
:model-value="visibility.steamvr"
|
||||
@update:model-value="toggleVisibility('steamvr')">
|
||||
{{ t('status_bar.steamvr') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
:model-value="visibility.proxy"
|
||||
@update:model-value="toggleVisibility('proxy')">
|
||||
{{ t('status_bar.proxy') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem :model-value="visibility.ws" @update:model-value="toggleVisibility('ws')">
|
||||
WebSocket
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
:model-value="visibility.uptime"
|
||||
@update:model-value="toggleVisibility('uptime')">
|
||||
{{ t('status_bar.app_uptime_short') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
v-if="!isMacOS"
|
||||
:model-value="visibility.zoom"
|
||||
@update:model-value="toggleVisibility('zoom')">
|
||||
{{ t('status_bar.zoom') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>{{ t('status_bar.clocks') }}</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuRadioGroup :model-value="String(clockCount)" @update:modelValue="setClockCount">
|
||||
<ContextMenuRadioItem value="0">
|
||||
{{ t('status_bar.clocks_none') }}
|
||||
</ContextMenuRadioItem>
|
||||
<ContextMenuRadioItem value="1"> 1 {{ t('status_bar.clock') }} </ContextMenuRadioItem>
|
||||
<ContextMenuRadioItem value="2">
|
||||
2 {{ t('status_bar.clocks_label') }}
|
||||
</ContextMenuRadioItem>
|
||||
<ContextMenuRadioItem value="3">
|
||||
3 {{ t('status_bar.clocks_label') }}
|
||||
</ContextMenuRadioItem>
|
||||
</ContextMenuRadioGroup>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldContent,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput
|
||||
} from '@/components/ui/number-field';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useGameStore, useGeneralSettingsStore, useVrcxStore } from '@/stores';
|
||||
import { useIntervalFn, useNow } from '@vueuse/core';
|
||||
import { TooltipWrapper } from '@/components/ui/tooltip';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { wsState } from '@/service/websocket';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
|
||||
import {
|
||||
defaultVisibility,
|
||||
formatAppUptime,
|
||||
formatUtcHour,
|
||||
loadClockCount,
|
||||
loadClocks,
|
||||
loadVisibility,
|
||||
normalizeClock,
|
||||
normalizeUtcHour,
|
||||
parseClockOffset
|
||||
} from './statusBarUtils';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isMacOS = computed(() => navigator.platform.includes('Mac'));
|
||||
|
||||
const gameStore = useGameStore();
|
||||
const vrcxStore = useVrcxStore();
|
||||
const generalSettingsStore = useGeneralSettingsStore();
|
||||
|
||||
const VISIBILITY_KEY = 'VRCX_statusBarVisibility';
|
||||
|
||||
const visibility = reactive(loadVisibility(localStorage));
|
||||
|
||||
/**
|
||||
*
|
||||
* @param key
|
||||
*/
|
||||
function toggleVisibility(key) {
|
||||
visibility[key] = !visibility[key];
|
||||
localStorage.setItem(VISIBILITY_KEY, JSON.stringify(visibility));
|
||||
}
|
||||
|
||||
// --- WebSocket message rate + sparkline ---
|
||||
|
||||
const GRAPH_POINTS = 60;
|
||||
const WS_CANVAS_WIDTH = 48;
|
||||
const WS_CANVAS_HEIGHT = 12;
|
||||
const msgHistory = ref(new Array(GRAPH_POINTS).fill(0));
|
||||
const msgsLastMinute = ref(0);
|
||||
let lastMsgCount = wsState.messageCount;
|
||||
|
||||
const wsCanvasRef = ref(null);
|
||||
const now = useNow({ interval: 1000 });
|
||||
const appStartAt = dayjs();
|
||||
|
||||
useIntervalFn(() => {
|
||||
const delta = wsState.messageCount - lastMsgCount;
|
||||
lastMsgCount = wsState.messageCount;
|
||||
|
||||
const arr = msgHistory.value;
|
||||
arr.shift();
|
||||
arr.push(delta);
|
||||
msgHistory.value = arr;
|
||||
|
||||
// Sum of messages in the last 60 seconds
|
||||
msgsLastMinute.value = arr.reduce((a, b) => a + b, 0);
|
||||
|
||||
drawSparkline();
|
||||
}, 1000);
|
||||
|
||||
const msgsPerMinuteAvg = computed(() => Math.round(msgsLastMinute.value));
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function drawSparkline() {
|
||||
const canvas = wsCanvasRef.value;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.floor(WS_CANVAS_WIDTH * dpr);
|
||||
canvas.height = Math.floor(WS_CANVAS_HEIGHT * dpr);
|
||||
canvas.style.width = `${WS_CANVAS_WIDTH}px`;
|
||||
canvas.style.height = `${WS_CANVAS_HEIGHT}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = WS_CANVAS_WIDTH;
|
||||
const h = WS_CANVAS_HEIGHT;
|
||||
const data = msgHistory.value;
|
||||
|
||||
const fg = resolveCssColor('--foreground', '#cfd3dc');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const max = Math.max(...data, 1);
|
||||
const step = w / (data.length - 1);
|
||||
|
||||
// Only draw the sparkline stroke (no background, grid, or fill area)
|
||||
ctx.globalAlpha = 0.75;
|
||||
ctx.strokeStyle = fg;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = i * step;
|
||||
const y = h - (data[i] / max) * (h - 2);
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param variableName
|
||||
* @param fallback
|
||||
*/
|
||||
function resolveCssColor(variableName, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(variableName).trim();
|
||||
if (!value) return fallback;
|
||||
if (value.startsWith('#') || value.startsWith('rgb') || value.startsWith('hsl') || value.startsWith('oklch')) {
|
||||
return value;
|
||||
}
|
||||
return `hsl(${value})`;
|
||||
}
|
||||
|
||||
const wsTooltip = computed(() => {
|
||||
const state = wsState.connected ? t('status_bar.ws_connected') : t('status_bar.ws_disconnected');
|
||||
return `WebSocket: ${state}`;
|
||||
});
|
||||
|
||||
const appUptimeText = computed(() => {
|
||||
const elapsedSeconds = dayjs(now.value).diff(appStartAt, 'second');
|
||||
return formatAppUptime(elapsedSeconds);
|
||||
});
|
||||
|
||||
const CLOCKS_KEY = 'VRCX_statusBarClocks';
|
||||
const CLOCK_COUNT_KEY = 'VRCX_statusBarClockCount';
|
||||
const defaultClocks = [{ offset: normalizeUtcHour(dayjs().utcOffset() / 60) }, { offset: 0 }, { offset: -5 }];
|
||||
|
||||
const clocks = ref(loadClocks(localStorage, defaultClocks));
|
||||
const clockCount = ref(loadClockCount(localStorage));
|
||||
const clockPopoverOpen = reactive([false, false, false]);
|
||||
|
||||
const visibleClocks = computed(() => clocks.value.slice(0, clockCount.value));
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function saveClocks() {
|
||||
localStorage.setItem(CLOCKS_KEY, JSON.stringify(clocks.value));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param val
|
||||
*/
|
||||
function setClockCount(val) {
|
||||
clockCount.value = Number(val);
|
||||
localStorage.setItem(CLOCK_COUNT_KEY, String(clockCount.value));
|
||||
if (clockCount.value > 0) {
|
||||
visibility.clocks = true;
|
||||
localStorage.setItem(VISIBILITY_KEY, JSON.stringify(visibility));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param clock
|
||||
*/
|
||||
function formatClock(clock) {
|
||||
try {
|
||||
const current = dayjs(now.value).utcOffset(normalizeUtcHour(clock.offset) * 60);
|
||||
const time = current.format('HH:mm');
|
||||
return `${time} ${formatUtcHour(clock.offset)}`;
|
||||
} catch {
|
||||
return '??:?? UTC+0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param idx
|
||||
* @param offsetValue
|
||||
*/
|
||||
function updateClockTimezone(idx, offsetValue) {
|
||||
clocks.value[idx].offset = parseClockOffset(offsetValue);
|
||||
saveClocks();
|
||||
clockPopoverOpen[idx] = false;
|
||||
}
|
||||
|
||||
const timezoneOptions = computed(() => {
|
||||
return Array.from({ length: 27 }, (_, i) => {
|
||||
const value = i - 12;
|
||||
return { value, label: formatUtcHour(value) };
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
drawSparkline();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => visibility.ws,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
nextTick(() => {
|
||||
drawSparkline();
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const zoomLevel = ref(100);
|
||||
const zoomEditing = ref(false);
|
||||
const zoomInputRef = ref(null);
|
||||
|
||||
if (!isMacOS.value) {
|
||||
initZoom();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function initZoom() {
|
||||
try {
|
||||
zoomLevel.value = ((await AppApi.GetZoom()) + 10) * 10;
|
||||
} catch {
|
||||
// AppApi not available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function setZoomLevel() {
|
||||
try {
|
||||
AppApi.SetZoom(zoomLevel.value / 10 - 10);
|
||||
} catch {
|
||||
// AppApi not available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function toggleZoomEdit() {
|
||||
if (zoomEditing.value) {
|
||||
zoomEditing.value = false;
|
||||
return;
|
||||
}
|
||||
await initZoom();
|
||||
zoomEditing.value = true;
|
||||
await nextTick();
|
||||
zoomInputRef.value?.$el?.focus?.();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleProxyClick() {
|
||||
generalSettingsStore.promptProxySettings();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-bar {
|
||||
flex-shrink: 0;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--sidebar);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-bar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.status-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.status-bar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
height: 22px;
|
||||
white-space: nowrap;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status-bar-left .status-bar-item:first-child {
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.status-bar-right .status-bar-item:last-child {
|
||||
border-right: none;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.status-bar-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-bar-clickable:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot-green {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
.dot-gray {
|
||||
background: #808080;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-label-mono {
|
||||
font-family: 'JetBrains Mono', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.status-bar-ws {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ws-sparkline {
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.status-bar-zoom-field {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.status-bar-zoom-input {
|
||||
height: 18px;
|
||||
font-size: 11px;
|
||||
padding: 0 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-bar-clock-popover {
|
||||
width: 280px;
|
||||
}
|
||||
</style>
|
||||
338
src/components/__tests__/statusBarUtils.test.js
Normal file
338
src/components/__tests__/statusBarUtils.test.js
Normal file
@@ -0,0 +1,338 @@
|
||||
import { describe, expect, test, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
defaultVisibility,
|
||||
formatAppUptime,
|
||||
formatUtcHour,
|
||||
loadClockCount,
|
||||
loadClocks,
|
||||
loadVisibility,
|
||||
normalizeClock,
|
||||
normalizeUtcHour,
|
||||
parseClockOffset
|
||||
} from '../statusBarUtils';
|
||||
|
||||
// ─── normalizeUtcHour ────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeUtcHour', () => {
|
||||
test('passes through normal integer values', () => {
|
||||
expect(normalizeUtcHour(0)).toBe(0);
|
||||
expect(normalizeUtcHour(5)).toBe(5);
|
||||
expect(normalizeUtcHour(-5)).toBe(-5);
|
||||
});
|
||||
|
||||
test('clamps to lower bound -12', () => {
|
||||
expect(normalizeUtcHour(-12)).toBe(-12);
|
||||
expect(normalizeUtcHour(-13)).toBe(-12);
|
||||
expect(normalizeUtcHour(-100)).toBe(-12);
|
||||
});
|
||||
|
||||
test('clamps to upper bound 14', () => {
|
||||
expect(normalizeUtcHour(14)).toBe(14);
|
||||
expect(normalizeUtcHour(15)).toBe(14);
|
||||
expect(normalizeUtcHour(100)).toBe(14);
|
||||
});
|
||||
|
||||
test('rounds fractional values', () => {
|
||||
expect(normalizeUtcHour(5.4)).toBe(5);
|
||||
expect(normalizeUtcHour(5.5)).toBe(6);
|
||||
expect(normalizeUtcHour(-5.7)).toBe(-6);
|
||||
});
|
||||
|
||||
test('returns 0 for NaN and Infinity', () => {
|
||||
expect(normalizeUtcHour(NaN)).toBe(0);
|
||||
expect(normalizeUtcHour(Infinity)).toBe(0);
|
||||
expect(normalizeUtcHour(-Infinity)).toBe(0);
|
||||
});
|
||||
|
||||
test('coerces string numbers', () => {
|
||||
expect(normalizeUtcHour('9')).toBe(9);
|
||||
expect(normalizeUtcHour('-3')).toBe(-3);
|
||||
});
|
||||
|
||||
test('returns 0 for non-numeric strings', () => {
|
||||
expect(normalizeUtcHour('abc')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatUtcHour ───────────────────────────────────────────────────
|
||||
|
||||
describe('formatUtcHour', () => {
|
||||
test('formats positive offsets with plus sign', () => {
|
||||
expect(formatUtcHour(9)).toBe('UTC+9');
|
||||
expect(formatUtcHour(14)).toBe('UTC+14');
|
||||
});
|
||||
|
||||
test('formats negative offsets', () => {
|
||||
expect(formatUtcHour(-5)).toBe('UTC-5');
|
||||
expect(formatUtcHour(-12)).toBe('UTC-12');
|
||||
});
|
||||
|
||||
test('formats zero as positive', () => {
|
||||
expect(formatUtcHour(0)).toBe('UTC+0');
|
||||
});
|
||||
|
||||
test('normalises before formatting', () => {
|
||||
expect(formatUtcHour(20)).toBe('UTC+14');
|
||||
expect(formatUtcHour(-20)).toBe('UTC-12');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseClockOffset ────────────────────────────────────────────────
|
||||
|
||||
describe('parseClockOffset', () => {
|
||||
test('parses numeric input', () => {
|
||||
expect(parseClockOffset(9)).toBe(9);
|
||||
expect(parseClockOffset(-3)).toBe(-3);
|
||||
});
|
||||
|
||||
test('parses plain numeric strings', () => {
|
||||
expect(parseClockOffset('5')).toBe(5);
|
||||
expect(parseClockOffset('-7')).toBe(-7);
|
||||
expect(parseClockOffset('+3')).toBe(3);
|
||||
});
|
||||
|
||||
test('parses numeric strings with whitespace', () => {
|
||||
expect(parseClockOffset(' 5 ')).toBe(5);
|
||||
});
|
||||
|
||||
test('parses UTC+N pattern', () => {
|
||||
expect(parseClockOffset('UTC+9')).toBe(9);
|
||||
expect(parseClockOffset('UTC-5')).toBe(-5);
|
||||
expect(parseClockOffset('utc+0')).toBe(0);
|
||||
});
|
||||
|
||||
test('parses UTC pattern with half-hour offset', () => {
|
||||
expect(parseClockOffset('UTC+5:30')).toBe(6); // 5.5 rounds to 6
|
||||
expect(parseClockOffset('UTC-9:30')).toBe(-9); // -9.5 rounds to -9 (Math.round toward +Infinity)
|
||||
});
|
||||
|
||||
test('returns 0 for non-string non-number input', () => {
|
||||
expect(parseClockOffset(null)).toBe(0);
|
||||
expect(parseClockOffset(undefined)).toBe(0);
|
||||
expect(parseClockOffset(true)).toBe(0);
|
||||
expect(parseClockOffset([])).toBe(0);
|
||||
});
|
||||
|
||||
test('returns 0 for unrecognised string patterns', () => {
|
||||
expect(parseClockOffset('not-a-timezone')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeClock ──────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeClock', () => {
|
||||
test('normalises entry with offset key', () => {
|
||||
expect(normalizeClock({ offset: 9 })).toEqual({ offset: 9 });
|
||||
expect(normalizeClock({ offset: '5' })).toEqual({ offset: 5 });
|
||||
});
|
||||
|
||||
test('normalises legacy entry with timezone key', () => {
|
||||
expect(normalizeClock({ timezone: 'UTC+9' })).toEqual({ offset: 9 });
|
||||
});
|
||||
|
||||
test('prefers offset over timezone when both present', () => {
|
||||
expect(normalizeClock({ offset: 3, timezone: 'UTC+9' })).toEqual({
|
||||
offset: 3
|
||||
});
|
||||
});
|
||||
|
||||
test('returns { offset: 0 } for non-object input', () => {
|
||||
expect(normalizeClock(null)).toEqual({ offset: 0 });
|
||||
expect(normalizeClock(undefined)).toEqual({ offset: 0 });
|
||||
expect(normalizeClock(42)).toEqual({ offset: 0 });
|
||||
expect(normalizeClock('string')).toEqual({ offset: 0 });
|
||||
});
|
||||
|
||||
test('returns { offset: 0 } for object without known keys', () => {
|
||||
expect(normalizeClock({ foo: 'bar' })).toEqual({ offset: 0 });
|
||||
expect(normalizeClock({})).toEqual({ offset: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadVisibility ──────────────────────────────────────────────────
|
||||
|
||||
describe('loadVisibility', () => {
|
||||
let storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMockStorage();
|
||||
});
|
||||
|
||||
test('returns defaults when storage is empty', () => {
|
||||
expect(loadVisibility(storage)).toEqual(defaultVisibility);
|
||||
});
|
||||
|
||||
test('merges saved values with defaults', () => {
|
||||
storage.setItem(
|
||||
'VRCX_statusBarVisibility',
|
||||
JSON.stringify({ vrchat: false, ws: false })
|
||||
);
|
||||
const result = loadVisibility(storage);
|
||||
expect(result.vrchat).toBe(false);
|
||||
expect(result.ws).toBe(false);
|
||||
// Other defaults preserved
|
||||
expect(result.proxy).toBe(true);
|
||||
expect(result.zoom).toBe(true);
|
||||
});
|
||||
|
||||
test('returns defaults on corrupt JSON', () => {
|
||||
storage.setItem('VRCX_statusBarVisibility', '{bad-json');
|
||||
expect(loadVisibility(storage)).toEqual(defaultVisibility);
|
||||
});
|
||||
|
||||
test('returns a new object each time (no shared reference)', () => {
|
||||
const a = loadVisibility(storage);
|
||||
const b = loadVisibility(storage);
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadClocks ──────────────────────────────────────────────────────
|
||||
|
||||
describe('loadClocks', () => {
|
||||
const defaults = [{ offset: 9 }, { offset: 0 }, { offset: -5 }];
|
||||
let storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMockStorage();
|
||||
});
|
||||
|
||||
test('returns defaults when storage is empty', () => {
|
||||
const result = loadClocks(storage, defaults);
|
||||
expect(result).toEqual(defaults);
|
||||
});
|
||||
|
||||
test('loads valid saved clocks', () => {
|
||||
storage.setItem(
|
||||
'VRCX_statusBarClocks',
|
||||
JSON.stringify([{ offset: 1 }, { offset: 2 }, { offset: 3 }])
|
||||
);
|
||||
expect(loadClocks(storage, defaults)).toEqual([
|
||||
{ offset: 1 },
|
||||
{ offset: 2 },
|
||||
{ offset: 3 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns defaults for wrong array length', () => {
|
||||
storage.setItem(
|
||||
'VRCX_statusBarClocks',
|
||||
JSON.stringify([{ offset: 1 }])
|
||||
);
|
||||
expect(loadClocks(storage, defaults)).toEqual(defaults);
|
||||
});
|
||||
|
||||
test('returns defaults for non-array JSON', () => {
|
||||
storage.setItem('VRCX_statusBarClocks', JSON.stringify({ offset: 1 }));
|
||||
expect(loadClocks(storage, defaults)).toEqual(defaults);
|
||||
});
|
||||
|
||||
test('returns defaults on corrupt JSON', () => {
|
||||
storage.setItem('VRCX_statusBarClocks', 'not-json');
|
||||
expect(loadClocks(storage, defaults)).toEqual(defaults);
|
||||
});
|
||||
|
||||
test('normalises clock entries from storage', () => {
|
||||
storage.setItem(
|
||||
'VRCX_statusBarClocks',
|
||||
JSON.stringify([
|
||||
{ offset: '5' },
|
||||
{ timezone: 'UTC+3' },
|
||||
{ offset: 99 }
|
||||
])
|
||||
);
|
||||
expect(loadClocks(storage, defaults)).toEqual([
|
||||
{ offset: 5 },
|
||||
{ offset: 3 },
|
||||
{ offset: 14 } // clamped
|
||||
]);
|
||||
});
|
||||
|
||||
test('returned defaults are independent copies', () => {
|
||||
const a = loadClocks(storage, defaults);
|
||||
const b = loadClocks(storage, defaults);
|
||||
expect(a).not.toBe(b);
|
||||
a[0].offset = 999;
|
||||
expect(b[0].offset).not.toBe(999);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadClockCount ──────────────────────────────────────────────────
|
||||
|
||||
describe('loadClockCount', () => {
|
||||
let storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMockStorage();
|
||||
});
|
||||
|
||||
test('returns 3 when storage is empty', () => {
|
||||
expect(loadClockCount(storage)).toBe(3);
|
||||
});
|
||||
|
||||
test.each([0, 1, 2, 3])('returns valid stored count %i', (n) => {
|
||||
storage.setItem('VRCX_statusBarClockCount', String(n));
|
||||
expect(loadClockCount(storage)).toBe(n);
|
||||
});
|
||||
|
||||
test('returns 3 for out-of-range values', () => {
|
||||
storage.setItem('VRCX_statusBarClockCount', '4');
|
||||
expect(loadClockCount(storage)).toBe(3);
|
||||
|
||||
storage.setItem('VRCX_statusBarClockCount', '-1');
|
||||
expect(loadClockCount(storage)).toBe(3);
|
||||
});
|
||||
|
||||
test('returns 3 for non-numeric values', () => {
|
||||
storage.setItem('VRCX_statusBarClockCount', 'abc');
|
||||
expect(loadClockCount(storage)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatAppUptime ─────────────────────────────────────────────────
|
||||
|
||||
describe('formatAppUptime', () => {
|
||||
test('formats zero seconds', () => {
|
||||
expect(formatAppUptime(0)).toBe('00:00:00');
|
||||
});
|
||||
|
||||
test('formats seconds only', () => {
|
||||
expect(formatAppUptime(45)).toBe('00:00:45');
|
||||
});
|
||||
|
||||
test('formats minutes and seconds', () => {
|
||||
expect(formatAppUptime(125)).toBe('00:02:05');
|
||||
});
|
||||
|
||||
test('formats hours, minutes, and seconds', () => {
|
||||
expect(formatAppUptime(3661)).toBe('01:01:01');
|
||||
});
|
||||
|
||||
test('handles large values (over 24 hours)', () => {
|
||||
// 100 hours = 360000 seconds
|
||||
expect(formatAppUptime(360000)).toBe('100:00:00');
|
||||
});
|
||||
|
||||
test('treats negative values as zero', () => {
|
||||
expect(formatAppUptime(-10)).toBe('00:00:00');
|
||||
});
|
||||
|
||||
test('floors fractional seconds', () => {
|
||||
expect(formatAppUptime(59.9)).toBe('00:00:59');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── test helpers ────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal in-memory Storage-like object for testing. */
|
||||
function createMockStorage() {
|
||||
const data = new Map();
|
||||
return {
|
||||
getItem: (key) => (data.has(key) ? data.get(key) : null),
|
||||
setItem: (key, value) => data.set(key, String(value)),
|
||||
removeItem: (key) => data.delete(key),
|
||||
clear: () => data.clear()
|
||||
};
|
||||
}
|
||||
167
src/components/statusBarUtils.js
Normal file
167
src/components/statusBarUtils.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* Default visibility flags for StatusBar indicators.
|
||||
*/
|
||||
export const defaultVisibility = {
|
||||
vrchat: true,
|
||||
steamvr: true,
|
||||
proxy: true,
|
||||
ws: true,
|
||||
uptime: true,
|
||||
clocks: true,
|
||||
zoom: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Clamp and round a numeric value to a valid UTC offset range [-12, 14].
|
||||
* Returns 0 for non-finite values.
|
||||
* @param {*} value - raw offset value
|
||||
* @returns {number} normalised integer offset
|
||||
*/
|
||||
export function normalizeUtcHour(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(-12, Math.min(14, Math.round(n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a numeric UTC offset as a human-readable string, e.g. "UTC+9", "UTC-5".
|
||||
* @param {number} offset
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatUtcHour(offset) {
|
||||
const n = normalizeUtcHour(offset);
|
||||
return `UTC${n >= 0 ? '+' : ''}${n}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a clock offset value into a normalised integer.
|
||||
*
|
||||
* Accepted inputs:
|
||||
* - number (clamped/rounded)
|
||||
* - numeric string like `"5"` or `"-3"`
|
||||
* - UTC pattern like `"UTC+9"`, `"UTC-5:30"`
|
||||
* - legacy IANA timezone name (resolved via dayjs)
|
||||
* @param {*} value
|
||||
* @returns {number}
|
||||
*/
|
||||
export function parseClockOffset(value) {
|
||||
if (typeof value === 'number') {
|
||||
return normalizeUtcHour(value);
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return 0;
|
||||
}
|
||||
if (/^[+-]?\d+$/.test(value.trim())) {
|
||||
return normalizeUtcHour(Number(value));
|
||||
}
|
||||
const utcMatch = value.match(/^UTC([+-])(\d{1,2})(?::(\d{1,2}))?$/i);
|
||||
if (utcMatch) {
|
||||
const sign = utcMatch[1] === '+' ? 1 : -1;
|
||||
const hours = Number(utcMatch[2]);
|
||||
const minutes = Number(utcMatch[3] || 0);
|
||||
return normalizeUtcHour(sign * (hours + minutes / 60));
|
||||
}
|
||||
// Backward compatibility: old clocks stored IANA timezone names.
|
||||
try {
|
||||
return normalizeUtcHour(dayjs().tz(value).utcOffset() / 60);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a single clock config entry.
|
||||
* Handles current `{ offset }` format and legacy `{ timezone }` format.
|
||||
* @param {*} entry
|
||||
* @returns {{ offset: number }}
|
||||
*/
|
||||
export function normalizeClock(entry) {
|
||||
if (entry && typeof entry === 'object') {
|
||||
if ('offset' in entry) {
|
||||
return { offset: parseClockOffset(entry.offset) };
|
||||
}
|
||||
if ('timezone' in entry) {
|
||||
return { offset: parseClockOffset(entry.timezone) };
|
||||
}
|
||||
}
|
||||
return { offset: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load visibility settings from a Storage-like object, merging with defaults.
|
||||
* @param {Storage} storage - object with `getItem(key)` method
|
||||
* @returns {object}
|
||||
*/
|
||||
export function loadVisibility(storage) {
|
||||
try {
|
||||
const saved = storage.getItem('VRCX_statusBarVisibility');
|
||||
if (saved) {
|
||||
return { ...defaultVisibility, ...JSON.parse(saved) };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { ...defaultVisibility };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved clocks array from a Storage-like object.
|
||||
* Returns the default clocks when stored data is absent or invalid.
|
||||
* @param {Storage} storage
|
||||
* @param {Array} defaults - fallback clock definitions
|
||||
* @returns {Array<{ offset: number }>}
|
||||
*/
|
||||
export function loadClocks(storage, defaults) {
|
||||
try {
|
||||
const saved = storage.getItem('VRCX_statusBarClocks');
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (Array.isArray(parsed) && parsed.length === 3) {
|
||||
return parsed.map(normalizeClock);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return defaults.map((c) => ({ ...c }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the clock count (0-3) from a Storage-like object.
|
||||
* Returns 3 when stored data is absent or invalid.
|
||||
* @param {Storage} storage
|
||||
* @returns {number}
|
||||
*/
|
||||
export function loadClockCount(storage) {
|
||||
try {
|
||||
const saved = storage.getItem('VRCX_statusBarClockCount');
|
||||
if (saved !== null) {
|
||||
const n = Number(saved);
|
||||
if (n >= 0 && n <= 3) return n;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an elapsed-seconds value into an `HH:MM:SS` string.
|
||||
* @param {number} elapsedSeconds
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatAppUptime(elapsedSeconds) {
|
||||
const safe = Math.max(0, Math.floor(elapsedSeconds));
|
||||
const hours = Math.floor(safe / 3600)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const minutes = Math.floor((safe % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const seconds = Math.floor(safe % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
@@ -660,6 +660,7 @@
|
||||
"zoom": "Zoom",
|
||||
"vrcplus_profile_icons": "VRCPlus Profile Icons",
|
||||
"show_instance_id": "Show Instance Name",
|
||||
"show_status_bar": "Show Status Bar",
|
||||
"age_gated_instances": "Age Gated Instances",
|
||||
"nicknames": "Memo Nicknames",
|
||||
"sort_favorite_by": "Sort Favorites by",
|
||||
@@ -2627,5 +2628,26 @@
|
||||
"back_to_top": "Back to top",
|
||||
"toggle_password": "Toggle password visibility",
|
||||
"clear_input": "Clear input"
|
||||
},
|
||||
"status_bar": {
|
||||
"vrchat": "VRChat",
|
||||
"vrchat_running": "VRChat is running",
|
||||
"vrchat_stopped": "VRChat is not running",
|
||||
"steamvr": "SteamVR",
|
||||
"steamvr_running": "SteamVR is running",
|
||||
"steamvr_stopped": "SteamVR is not running",
|
||||
"proxy": "Proxy",
|
||||
"ws_connected": "Connected",
|
||||
"ws_disconnected": "Disconnected",
|
||||
"ws_avg_per_minute": "{count} avg/min",
|
||||
"zoom": "Zoom",
|
||||
"zoom_tooltip": "Click to adjust zoom level",
|
||||
"app_uptime": "Application uptime",
|
||||
"app_uptime_short": "Uptime",
|
||||
"clocks": "Clocks",
|
||||
"clock": "Clock",
|
||||
"clocks_label": "Clocks",
|
||||
"clocks_none": "None",
|
||||
"timezone": "Timezone"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
import Noty from 'noty';
|
||||
|
||||
import {
|
||||
@@ -22,6 +24,20 @@ import * as workerTimers from 'worker-timers';
|
||||
let webSocket = null;
|
||||
let lastWebSocketMessage = '';
|
||||
|
||||
/**
|
||||
* Reactive WebSocket state for status bar telemetry.
|
||||
* - connected: whether the WS is currently open
|
||||
* - messageCount: total messages received (used for rate delta)
|
||||
*/
|
||||
export const wsState = reactive({
|
||||
connected: false,
|
||||
messageCount: 0,
|
||||
bytesReceived: 0
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function initWebsocket() {
|
||||
if (!watchState.isFriendsLoaded || webSocket !== null) {
|
||||
return;
|
||||
@@ -53,11 +69,13 @@ function connectWebSocket(token) {
|
||||
}
|
||||
const socket = new WebSocket(`${AppDebug.websocketDomain}/?auth=${token}`);
|
||||
socket.onopen = () => {
|
||||
wsState.connected = true;
|
||||
if (AppDebug.debugWebSocket) {
|
||||
console.log('WebSocket connected');
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
wsState.connected = false;
|
||||
if (webSocket === socket) {
|
||||
webSocket = null;
|
||||
}
|
||||
@@ -96,6 +114,8 @@ function connectWebSocket(token) {
|
||||
};
|
||||
socket.onmessage = ({ data }) => {
|
||||
try {
|
||||
wsState.messageCount++;
|
||||
wsState.bytesReceived += data.length;
|
||||
if (lastWebSocketMessage === data) {
|
||||
// pls no spam
|
||||
return;
|
||||
|
||||
@@ -109,6 +109,7 @@ export const useAppearanceSettingsStore = defineStore(
|
||||
|
||||
const isDataTableStriped = ref(false);
|
||||
const showPointerOnHover = ref(false);
|
||||
const showStatusBar = ref(true);
|
||||
const tableLimitsDialog = ref({
|
||||
visible: false,
|
||||
maxTableSize: 500,
|
||||
@@ -167,6 +168,7 @@ export const useAppearanceSettingsStore = defineStore(
|
||||
navIsCollapsedConfig,
|
||||
dataTableStripedConfig,
|
||||
showPointerOnHoverConfig,
|
||||
showStatusBarConfig,
|
||||
appFontFamilyConfig,
|
||||
lastDarkThemeConfig
|
||||
] = await Promise.all([
|
||||
@@ -230,6 +232,7 @@ export const useAppearanceSettingsStore = defineStore(
|
||||
configRepository.getBool('VRCX_navIsCollapsed', false),
|
||||
configRepository.getBool('VRCX_dataTableStriped', false),
|
||||
configRepository.getBool('VRCX_showPointerOnHover', false),
|
||||
configRepository.getBool('VRCX_showStatusBar', true),
|
||||
configRepository.getString(
|
||||
'VRCX_fontFamily',
|
||||
APP_FONT_DEFAULT_KEY
|
||||
@@ -330,6 +333,7 @@ export const useAppearanceSettingsStore = defineStore(
|
||||
isNavCollapsed.value = navIsCollapsedConfig;
|
||||
isDataTableStriped.value = dataTableStripedConfig;
|
||||
showPointerOnHover.value = showPointerOnHoverConfig;
|
||||
showStatusBar.value = showStatusBarConfig;
|
||||
|
||||
applyPointerHoverClass();
|
||||
|
||||
@@ -560,6 +564,13 @@ export const useAppearanceSettingsStore = defineStore(
|
||||
showInstanceIdInLocation.value
|
||||
);
|
||||
}
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function setShowStatusBar() {
|
||||
showStatusBar.value = !showStatusBar.value;
|
||||
configRepository.setBool('VRCX_showStatusBar', showStatusBar.value);
|
||||
}
|
||||
/**
|
||||
*
|
||||
*/
|
||||
@@ -1088,6 +1099,7 @@ export const useAppearanceSettingsStore = defineStore(
|
||||
isNavCollapsed,
|
||||
isDataTableStriped,
|
||||
showPointerOnHover,
|
||||
showStatusBar,
|
||||
tableLimitsDialog,
|
||||
TABLE_MAX_SIZE_MIN,
|
||||
TABLE_MAX_SIZE_MAX,
|
||||
@@ -1098,6 +1110,7 @@ export const useAppearanceSettingsStore = defineStore(
|
||||
setDisplayVRCPlusIconsAsAvatar,
|
||||
setHideNicknames,
|
||||
setShowInstanceIdInLocation,
|
||||
setShowStatusBar,
|
||||
setIsAgeGatedInstancesVisible,
|
||||
setSortFavorites,
|
||||
setInstanceUsersSortAlphabetical,
|
||||
|
||||
@@ -1,55 +1,61 @@
|
||||
<template>
|
||||
<template v-if="watchState.isLoggedIn">
|
||||
<SidebarProvider
|
||||
:open="sidebarOpen"
|
||||
:width="navWidth"
|
||||
:width-icon="48"
|
||||
class="relative flex-1 h-full min-w-0"
|
||||
@update:open="handleSidebarOpenChange">
|
||||
<NavMenu />
|
||||
<div class="main-layout-wrapper">
|
||||
<SidebarProvider
|
||||
:open="sidebarOpen"
|
||||
:width="navWidth"
|
||||
:width-icon="48"
|
||||
class="relative flex-1 h-full min-w-0 min-h-0"
|
||||
@update:open="handleSidebarOpenChange">
|
||||
<NavMenu />
|
||||
|
||||
<div
|
||||
v-show="sidebarOpen"
|
||||
class="absolute top-0 bottom-0 z-30 w-1 cursor-ew-resize select-none"
|
||||
:style="{ left: 'var(--sidebar-width)' }"
|
||||
@pointerdown.prevent="startNavResize" />
|
||||
<div
|
||||
v-show="sidebarOpen"
|
||||
class="absolute top-0 bottom-0 z-30 w-1 cursor-ew-resize select-none"
|
||||
:style="{ left: 'var(--sidebar-width)' }"
|
||||
@pointerdown.prevent="startNavResize" />
|
||||
|
||||
<SidebarInset class="min-w-0 bg-sidebar">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
auto-save-id="vrcx-main-layout-right-sidebar"
|
||||
:class="['group/main-layout flex-1 h-full min-w-0', { 'aside-collapsed': isAsideCollapsedStatic }]"
|
||||
@layout="handleLayout">
|
||||
<template #default="{ layout }">
|
||||
<ResizablePanel :default-size="mainDefaultSize" :order="1" :size-unit="'px'">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<KeepAlive exclude="ChartsInstance, ChartsMutual">
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
</RouterView>
|
||||
</ResizablePanel>
|
||||
<SidebarInset class="min-w-0 bg-sidebar">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
auto-save-id="vrcx-main-layout-right-sidebar"
|
||||
:class="[
|
||||
'group/main-layout flex-1 h-full min-w-0',
|
||||
{ 'aside-collapsed': isAsideCollapsedStatic }
|
||||
]"
|
||||
@layout="handleLayout">
|
||||
<template #default="{ layout }">
|
||||
<ResizablePanel :default-size="mainDefaultSize" :order="1" :size-unit="'px'">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<KeepAlive exclude="ChartsInstance, ChartsMutual">
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
</RouterView>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle
|
||||
with-handle
|
||||
:class="[
|
||||
isAsideCollapsed(layout) ? 'opacity-100' : 'opacity-0',
|
||||
'z-20 [&>div]:-translate-x-1/2'
|
||||
]"></ResizableHandle>
|
||||
<ResizablePanel
|
||||
ref="asidePanelRef"
|
||||
:default-size="asideDefaultSize"
|
||||
:min-size="asideMinSize"
|
||||
:collapsed-size="0"
|
||||
collapsible
|
||||
:order="2"
|
||||
:size-unit="'px'"
|
||||
:style="{ maxWidth: `${asideMaxPx}px` }">
|
||||
<Sidebar></Sidebar>
|
||||
</ResizablePanel>
|
||||
</template>
|
||||
</ResizablePanelGroup>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<ResizableHandle
|
||||
with-handle
|
||||
:class="[
|
||||
isAsideCollapsed(layout) ? 'opacity-100' : 'opacity-0',
|
||||
'z-20 [&>div]:-translate-x-1/2'
|
||||
]"></ResizableHandle>
|
||||
<ResizablePanel
|
||||
ref="asidePanelRef"
|
||||
:default-size="asideDefaultSize"
|
||||
:min-size="asideMinSize"
|
||||
:collapsed-size="0"
|
||||
collapsible
|
||||
:order="2"
|
||||
:size-unit="'px'"
|
||||
:style="{ maxWidth: `${asideMaxPx}px` }">
|
||||
<Sidebar></Sidebar>
|
||||
</ResizablePanel>
|
||||
</template>
|
||||
</ResizablePanelGroup>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<StatusBar v-if="showStatusBar" />
|
||||
</div>
|
||||
|
||||
<!-- ## Dialogs ## -->
|
||||
<MainDialogContainer></MainDialogContainer>
|
||||
@@ -107,13 +113,15 @@
|
||||
import PrimaryPasswordDialog from '../Settings/dialogs/PrimaryPasswordDialog.vue';
|
||||
import SendBoopDialog from '../../components/dialogs/SendBoopDialog.vue';
|
||||
import Sidebar from '../Sidebar/Sidebar.vue';
|
||||
import StatusBar from '../../components/StatusBar.vue';
|
||||
import VRChatConfigDialog from '../Settings/dialogs/VRChatConfigDialog.vue';
|
||||
import WorldImportDialog from '../Favorites/dialogs/WorldImportDialog.vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const appearanceSettingsStore = useAppearanceSettingsStore();
|
||||
const { navWidth, isNavCollapsed } = storeToRefs(appearanceSettingsStore);
|
||||
const { navWidth, isNavCollapsed, showStatusBar } =
|
||||
storeToRefs(appearanceSettingsStore);
|
||||
|
||||
const sidebarOpen = computed(() => !isNavCollapsed.value);
|
||||
|
||||
|
||||
@@ -82,6 +82,10 @@
|
||||
:label="t('view.settings.appearance.appearance.show_instance_id')"
|
||||
:value="showInstanceIdInLocation"
|
||||
@change="setShowInstanceIdInLocation" />
|
||||
<simple-switch
|
||||
:label="t('view.settings.appearance.appearance.show_status_bar')"
|
||||
:value="showStatusBar"
|
||||
@change="setShowStatusBar" />
|
||||
<simple-switch
|
||||
:label="t('view.settings.appearance.appearance.nicknames')"
|
||||
:value="!hideNicknames"
|
||||
@@ -388,7 +392,8 @@
|
||||
notificationIconDot,
|
||||
tablePageSizes,
|
||||
isDataTableStriped,
|
||||
showPointerOnHover
|
||||
showPointerOnHover,
|
||||
showStatusBar
|
||||
} = storeToRefs(appearanceSettingsStore);
|
||||
|
||||
const appLanguageDisplayName = computed(() => getLanguageName(String(appLanguage.value)));
|
||||
@@ -399,6 +404,7 @@
|
||||
setDisplayVRCPlusIconsAsAvatar,
|
||||
setHideNicknames,
|
||||
setShowInstanceIdInLocation,
|
||||
setShowStatusBar,
|
||||
setIsAgeGatedInstancesVisible,
|
||||
setInstanceUsersSortAlphabetical,
|
||||
setDtHour12,
|
||||
|
||||
Reference in New Issue
Block a user