feat: status bar

This commit is contained in:
pa
2026-03-07 01:35:03 +09:00
parent c42b126131
commit e4f0abe74a
9 changed files with 1240 additions and 49 deletions

View File

@@ -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;
}

View 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>

View 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()
};
}

View 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}`;
}

View File

@@ -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"
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);

View File

@@ -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,