mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-07 14:56:06 +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] {
|
[data-sonner-toast] [data-content] [data-description] {
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-layout-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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",
|
"zoom": "Zoom",
|
||||||
"vrcplus_profile_icons": "VRCPlus Profile Icons",
|
"vrcplus_profile_icons": "VRCPlus Profile Icons",
|
||||||
"show_instance_id": "Show Instance Name",
|
"show_instance_id": "Show Instance Name",
|
||||||
|
"show_status_bar": "Show Status Bar",
|
||||||
"age_gated_instances": "Age Gated Instances",
|
"age_gated_instances": "Age Gated Instances",
|
||||||
"nicknames": "Memo Nicknames",
|
"nicknames": "Memo Nicknames",
|
||||||
"sort_favorite_by": "Sort Favorites by",
|
"sort_favorite_by": "Sort Favorites by",
|
||||||
@@ -2627,5 +2628,26 @@
|
|||||||
"back_to_top": "Back to top",
|
"back_to_top": "Back to top",
|
||||||
"toggle_password": "Toggle password visibility",
|
"toggle_password": "Toggle password visibility",
|
||||||
"clear_input": "Clear input"
|
"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 Noty from 'noty';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +24,20 @@ import * as workerTimers from 'worker-timers';
|
|||||||
let webSocket = null;
|
let webSocket = null;
|
||||||
let lastWebSocketMessage = '';
|
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() {
|
export function initWebsocket() {
|
||||||
if (!watchState.isFriendsLoaded || webSocket !== null) {
|
if (!watchState.isFriendsLoaded || webSocket !== null) {
|
||||||
return;
|
return;
|
||||||
@@ -53,11 +69,13 @@ function connectWebSocket(token) {
|
|||||||
}
|
}
|
||||||
const socket = new WebSocket(`${AppDebug.websocketDomain}/?auth=${token}`);
|
const socket = new WebSocket(`${AppDebug.websocketDomain}/?auth=${token}`);
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
|
wsState.connected = true;
|
||||||
if (AppDebug.debugWebSocket) {
|
if (AppDebug.debugWebSocket) {
|
||||||
console.log('WebSocket connected');
|
console.log('WebSocket connected');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
wsState.connected = false;
|
||||||
if (webSocket === socket) {
|
if (webSocket === socket) {
|
||||||
webSocket = null;
|
webSocket = null;
|
||||||
}
|
}
|
||||||
@@ -96,6 +114,8 @@ function connectWebSocket(token) {
|
|||||||
};
|
};
|
||||||
socket.onmessage = ({ data }) => {
|
socket.onmessage = ({ data }) => {
|
||||||
try {
|
try {
|
||||||
|
wsState.messageCount++;
|
||||||
|
wsState.bytesReceived += data.length;
|
||||||
if (lastWebSocketMessage === data) {
|
if (lastWebSocketMessage === data) {
|
||||||
// pls no spam
|
// pls no spam
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export const useAppearanceSettingsStore = defineStore(
|
|||||||
|
|
||||||
const isDataTableStriped = ref(false);
|
const isDataTableStriped = ref(false);
|
||||||
const showPointerOnHover = ref(false);
|
const showPointerOnHover = ref(false);
|
||||||
|
const showStatusBar = ref(true);
|
||||||
const tableLimitsDialog = ref({
|
const tableLimitsDialog = ref({
|
||||||
visible: false,
|
visible: false,
|
||||||
maxTableSize: 500,
|
maxTableSize: 500,
|
||||||
@@ -167,6 +168,7 @@ export const useAppearanceSettingsStore = defineStore(
|
|||||||
navIsCollapsedConfig,
|
navIsCollapsedConfig,
|
||||||
dataTableStripedConfig,
|
dataTableStripedConfig,
|
||||||
showPointerOnHoverConfig,
|
showPointerOnHoverConfig,
|
||||||
|
showStatusBarConfig,
|
||||||
appFontFamilyConfig,
|
appFontFamilyConfig,
|
||||||
lastDarkThemeConfig
|
lastDarkThemeConfig
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
@@ -230,6 +232,7 @@ export const useAppearanceSettingsStore = defineStore(
|
|||||||
configRepository.getBool('VRCX_navIsCollapsed', false),
|
configRepository.getBool('VRCX_navIsCollapsed', false),
|
||||||
configRepository.getBool('VRCX_dataTableStriped', false),
|
configRepository.getBool('VRCX_dataTableStriped', false),
|
||||||
configRepository.getBool('VRCX_showPointerOnHover', false),
|
configRepository.getBool('VRCX_showPointerOnHover', false),
|
||||||
|
configRepository.getBool('VRCX_showStatusBar', true),
|
||||||
configRepository.getString(
|
configRepository.getString(
|
||||||
'VRCX_fontFamily',
|
'VRCX_fontFamily',
|
||||||
APP_FONT_DEFAULT_KEY
|
APP_FONT_DEFAULT_KEY
|
||||||
@@ -330,6 +333,7 @@ export const useAppearanceSettingsStore = defineStore(
|
|||||||
isNavCollapsed.value = navIsCollapsedConfig;
|
isNavCollapsed.value = navIsCollapsedConfig;
|
||||||
isDataTableStriped.value = dataTableStripedConfig;
|
isDataTableStriped.value = dataTableStripedConfig;
|
||||||
showPointerOnHover.value = showPointerOnHoverConfig;
|
showPointerOnHover.value = showPointerOnHoverConfig;
|
||||||
|
showStatusBar.value = showStatusBarConfig;
|
||||||
|
|
||||||
applyPointerHoverClass();
|
applyPointerHoverClass();
|
||||||
|
|
||||||
@@ -560,6 +564,13 @@ export const useAppearanceSettingsStore = defineStore(
|
|||||||
showInstanceIdInLocation.value
|
showInstanceIdInLocation.value
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function setShowStatusBar() {
|
||||||
|
showStatusBar.value = !showStatusBar.value;
|
||||||
|
configRepository.setBool('VRCX_showStatusBar', showStatusBar.value);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@@ -1088,6 +1099,7 @@ export const useAppearanceSettingsStore = defineStore(
|
|||||||
isNavCollapsed,
|
isNavCollapsed,
|
||||||
isDataTableStriped,
|
isDataTableStriped,
|
||||||
showPointerOnHover,
|
showPointerOnHover,
|
||||||
|
showStatusBar,
|
||||||
tableLimitsDialog,
|
tableLimitsDialog,
|
||||||
TABLE_MAX_SIZE_MIN,
|
TABLE_MAX_SIZE_MIN,
|
||||||
TABLE_MAX_SIZE_MAX,
|
TABLE_MAX_SIZE_MAX,
|
||||||
@@ -1098,6 +1110,7 @@ export const useAppearanceSettingsStore = defineStore(
|
|||||||
setDisplayVRCPlusIconsAsAvatar,
|
setDisplayVRCPlusIconsAsAvatar,
|
||||||
setHideNicknames,
|
setHideNicknames,
|
||||||
setShowInstanceIdInLocation,
|
setShowInstanceIdInLocation,
|
||||||
|
setShowStatusBar,
|
||||||
setIsAgeGatedInstancesVisible,
|
setIsAgeGatedInstancesVisible,
|
||||||
setSortFavorites,
|
setSortFavorites,
|
||||||
setInstanceUsersSortAlphabetical,
|
setInstanceUsersSortAlphabetical,
|
||||||
|
|||||||
@@ -1,55 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<template v-if="watchState.isLoggedIn">
|
<template v-if="watchState.isLoggedIn">
|
||||||
<SidebarProvider
|
<div class="main-layout-wrapper">
|
||||||
:open="sidebarOpen"
|
<SidebarProvider
|
||||||
:width="navWidth"
|
:open="sidebarOpen"
|
||||||
:width-icon="48"
|
:width="navWidth"
|
||||||
class="relative flex-1 h-full min-w-0"
|
:width-icon="48"
|
||||||
@update:open="handleSidebarOpenChange">
|
class="relative flex-1 h-full min-w-0 min-h-0"
|
||||||
<NavMenu />
|
@update:open="handleSidebarOpenChange">
|
||||||
|
<NavMenu />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-show="sidebarOpen"
|
v-show="sidebarOpen"
|
||||||
class="absolute top-0 bottom-0 z-30 w-1 cursor-ew-resize select-none"
|
class="absolute top-0 bottom-0 z-30 w-1 cursor-ew-resize select-none"
|
||||||
:style="{ left: 'var(--sidebar-width)' }"
|
:style="{ left: 'var(--sidebar-width)' }"
|
||||||
@pointerdown.prevent="startNavResize" />
|
@pointerdown.prevent="startNavResize" />
|
||||||
|
|
||||||
<SidebarInset class="min-w-0 bg-sidebar">
|
<SidebarInset class="min-w-0 bg-sidebar">
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
auto-save-id="vrcx-main-layout-right-sidebar"
|
auto-save-id="vrcx-main-layout-right-sidebar"
|
||||||
:class="['group/main-layout flex-1 h-full min-w-0', { 'aside-collapsed': isAsideCollapsedStatic }]"
|
:class="[
|
||||||
@layout="handleLayout">
|
'group/main-layout flex-1 h-full min-w-0',
|
||||||
<template #default="{ layout }">
|
{ 'aside-collapsed': isAsideCollapsedStatic }
|
||||||
<ResizablePanel :default-size="mainDefaultSize" :order="1" :size-unit="'px'">
|
]"
|
||||||
<RouterView v-slot="{ Component }">
|
@layout="handleLayout">
|
||||||
<KeepAlive exclude="ChartsInstance, ChartsMutual">
|
<template #default="{ layout }">
|
||||||
<component :is="Component" />
|
<ResizablePanel :default-size="mainDefaultSize" :order="1" :size-unit="'px'">
|
||||||
</KeepAlive>
|
<RouterView v-slot="{ Component }">
|
||||||
</RouterView>
|
<KeepAlive exclude="ChartsInstance, ChartsMutual">
|
||||||
</ResizablePanel>
|
<component :is="Component" />
|
||||||
|
</KeepAlive>
|
||||||
|
</RouterView>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
<ResizableHandle
|
<ResizableHandle
|
||||||
with-handle
|
with-handle
|
||||||
:class="[
|
:class="[
|
||||||
isAsideCollapsed(layout) ? 'opacity-100' : 'opacity-0',
|
isAsideCollapsed(layout) ? 'opacity-100' : 'opacity-0',
|
||||||
'z-20 [&>div]:-translate-x-1/2'
|
'z-20 [&>div]:-translate-x-1/2'
|
||||||
]"></ResizableHandle>
|
]"></ResizableHandle>
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
ref="asidePanelRef"
|
ref="asidePanelRef"
|
||||||
:default-size="asideDefaultSize"
|
:default-size="asideDefaultSize"
|
||||||
:min-size="asideMinSize"
|
:min-size="asideMinSize"
|
||||||
:collapsed-size="0"
|
:collapsed-size="0"
|
||||||
collapsible
|
collapsible
|
||||||
:order="2"
|
:order="2"
|
||||||
:size-unit="'px'"
|
:size-unit="'px'"
|
||||||
:style="{ maxWidth: `${asideMaxPx}px` }">
|
:style="{ maxWidth: `${asideMaxPx}px` }">
|
||||||
<Sidebar></Sidebar>
|
<Sidebar></Sidebar>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</template>
|
</template>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
<StatusBar v-if="showStatusBar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ## Dialogs ## -->
|
<!-- ## Dialogs ## -->
|
||||||
<MainDialogContainer></MainDialogContainer>
|
<MainDialogContainer></MainDialogContainer>
|
||||||
@@ -107,13 +113,15 @@
|
|||||||
import PrimaryPasswordDialog from '../Settings/dialogs/PrimaryPasswordDialog.vue';
|
import PrimaryPasswordDialog from '../Settings/dialogs/PrimaryPasswordDialog.vue';
|
||||||
import SendBoopDialog from '../../components/dialogs/SendBoopDialog.vue';
|
import SendBoopDialog from '../../components/dialogs/SendBoopDialog.vue';
|
||||||
import Sidebar from '../Sidebar/Sidebar.vue';
|
import Sidebar from '../Sidebar/Sidebar.vue';
|
||||||
|
import StatusBar from '../../components/StatusBar.vue';
|
||||||
import VRChatConfigDialog from '../Settings/dialogs/VRChatConfigDialog.vue';
|
import VRChatConfigDialog from '../Settings/dialogs/VRChatConfigDialog.vue';
|
||||||
import WorldImportDialog from '../Favorites/dialogs/WorldImportDialog.vue';
|
import WorldImportDialog from '../Favorites/dialogs/WorldImportDialog.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const appearanceSettingsStore = useAppearanceSettingsStore();
|
const appearanceSettingsStore = useAppearanceSettingsStore();
|
||||||
const { navWidth, isNavCollapsed } = storeToRefs(appearanceSettingsStore);
|
const { navWidth, isNavCollapsed, showStatusBar } =
|
||||||
|
storeToRefs(appearanceSettingsStore);
|
||||||
|
|
||||||
const sidebarOpen = computed(() => !isNavCollapsed.value);
|
const sidebarOpen = computed(() => !isNavCollapsed.value);
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,10 @@
|
|||||||
:label="t('view.settings.appearance.appearance.show_instance_id')"
|
:label="t('view.settings.appearance.appearance.show_instance_id')"
|
||||||
:value="showInstanceIdInLocation"
|
:value="showInstanceIdInLocation"
|
||||||
@change="setShowInstanceIdInLocation" />
|
@change="setShowInstanceIdInLocation" />
|
||||||
|
<simple-switch
|
||||||
|
:label="t('view.settings.appearance.appearance.show_status_bar')"
|
||||||
|
:value="showStatusBar"
|
||||||
|
@change="setShowStatusBar" />
|
||||||
<simple-switch
|
<simple-switch
|
||||||
:label="t('view.settings.appearance.appearance.nicknames')"
|
:label="t('view.settings.appearance.appearance.nicknames')"
|
||||||
:value="!hideNicknames"
|
:value="!hideNicknames"
|
||||||
@@ -388,7 +392,8 @@
|
|||||||
notificationIconDot,
|
notificationIconDot,
|
||||||
tablePageSizes,
|
tablePageSizes,
|
||||||
isDataTableStriped,
|
isDataTableStriped,
|
||||||
showPointerOnHover
|
showPointerOnHover,
|
||||||
|
showStatusBar
|
||||||
} = storeToRefs(appearanceSettingsStore);
|
} = storeToRefs(appearanceSettingsStore);
|
||||||
|
|
||||||
const appLanguageDisplayName = computed(() => getLanguageName(String(appLanguage.value)));
|
const appLanguageDisplayName = computed(() => getLanguageName(String(appLanguage.value)));
|
||||||
@@ -399,6 +404,7 @@
|
|||||||
setDisplayVRCPlusIconsAsAvatar,
|
setDisplayVRCPlusIconsAsAvatar,
|
||||||
setHideNicknames,
|
setHideNicknames,
|
||||||
setShowInstanceIdInLocation,
|
setShowInstanceIdInLocation,
|
||||||
|
setShowStatusBar,
|
||||||
setIsAgeGatedInstancesVisible,
|
setIsAgeGatedInstancesVisible,
|
||||||
setInstanceUsersSortAlphabetical,
|
setInstanceUsersSortAlphabetical,
|
||||||
setDtHour12,
|
setDtHour12,
|
||||||
|
|||||||
Reference in New Issue
Block a user