mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-17 22:03:50 +02:00
add game session tracking and display in status bar
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="shrink-0 h-[22px] flex items-center bg-sidebar border-t border-border font-mono text-xs select-none overflow-hidden"
|
||||
class="shrink-0 h-[22px] flex items-center bg-sidebar border-t border-border text-xs select-none overflow-hidden"
|
||||
style="font-family: var(--font-mono-cjk)"
|
||||
@contextmenu.prevent>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
@@ -50,19 +51,53 @@
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper
|
||||
v-if="!isMacOS && visibility.vrchat"
|
||||
:content="
|
||||
gameStore.isGameRunning ? t('status_bar.game_running') : t('status_bar.game_stopped')
|
||||
"
|
||||
side="top">
|
||||
<div class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border">
|
||||
<span
|
||||
class="inline-block size-2 rounded-full shrink-0"
|
||||
:class="gameStore.isGameRunning ? 'bg-status-online' : 'bg-status-offline-alt'" />
|
||||
<span class="text-foreground text-[11px]">{{ t('status_bar.game') }}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
<HoverCard v-if="!isMacOS && visibility.vrchat" v-model:open="gameHoverOpen" :open-delay="50" :close-delay="50">
|
||||
<HoverCardTrigger as-child>
|
||||
<div class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border">
|
||||
<span
|
||||
class="inline-block size-2 rounded-full shrink-0"
|
||||
:class="gameStore.isGameRunning ? 'bg-status-online' : 'bg-status-offline-alt'" />
|
||||
<span class="text-foreground text-[11px]">{{ t('status_bar.game') }}</span>
|
||||
<span v-if="gameStore.isGameRunning" class="text-[10px] text-foreground">{{
|
||||
gameSessionText
|
||||
}}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
v-if="gameStore.isGameRunning && userStore.currentUser.$online_for"
|
||||
class="w-auto min-w-[160px] px-3 py-2"
|
||||
side="top"
|
||||
align="start"
|
||||
:side-offset="4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[11px] text-muted-foreground">{{ t('status_bar.game_started_at') }}</span>
|
||||
<span class="text-[11px] text-foreground">{{ gameStartedAtText }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[11px] text-muted-foreground">{{ t('status_bar.game_session_duration') }}</span>
|
||||
<span class="text-[11px] text-foreground">{{ gameSessionDetailText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
<HoverCardContent
|
||||
v-else-if="!gameStore.isGameRunning && gameStore.lastSessionDurationMs > 0"
|
||||
class="w-auto min-w-[160px] px-3 py-2"
|
||||
side="top"
|
||||
align="start"
|
||||
:side-offset="4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[11px] text-muted-foreground">{{ t('status_bar.game_last_session') }}</span>
|
||||
<span class="text-[11px] text-foreground">{{ lastSessionText }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[11px] text-muted-foreground">{{ t('status_bar.game_last_offline') }}</span>
|
||||
<span class="text-[11px] text-foreground">{{ lastOfflineTimeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
<HoverCard v-if="visibility.servers" v-model:open="serversHoverOpen">
|
||||
<HoverCardTrigger as-child>
|
||||
@@ -284,8 +319,9 @@
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput
|
||||
} from '@/components/ui/number-field';
|
||||
import { useGameStore, useGeneralSettingsStore, useVrcStatusStore, useVrcxStore } from '@/stores';
|
||||
import { useGameStore, useGeneralSettingsStore, useUserStore, useVrcStatusStore, useVrcxStore } from '@/stores';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
import { timeToText } from '@/shared/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useIntervalFn, useNow } from '@vueuse/core';
|
||||
import { TooltipWrapper } from '@/components/ui/tooltip';
|
||||
@@ -315,10 +351,41 @@
|
||||
const isMacOS = computed(() => navigator.platform.includes('Mac'));
|
||||
|
||||
const gameStore = useGameStore();
|
||||
const userStore = useUserStore();
|
||||
const vrcxStore = useVrcxStore();
|
||||
const vrcStatusStore = useVrcStatusStore();
|
||||
const generalSettingsStore = useGeneralSettingsStore();
|
||||
|
||||
// --- Game session timer ---
|
||||
const gameHoverOpen = ref(false);
|
||||
|
||||
const gameSessionText = computed(() => {
|
||||
if (!gameStore.isGameRunning || !userStore.currentUser.$online_for) return '';
|
||||
const elapsed = now.value - userStore.currentUser.$online_for;
|
||||
return elapsed > 0 ? timeToText(elapsed) : '';
|
||||
});
|
||||
|
||||
const gameStartedAtText = computed(() => {
|
||||
if (!userStore.currentUser.$online_for) return '-';
|
||||
return dayjs(userStore.currentUser.$online_for).format('MM/DD HH:mm');
|
||||
});
|
||||
|
||||
const gameSessionDetailText = computed(() => {
|
||||
if (!gameStore.isGameRunning || !userStore.currentUser.$online_for) return '-';
|
||||
const elapsed = now.value - userStore.currentUser.$online_for;
|
||||
return elapsed > 0 ? timeToText(elapsed, true) : '-';
|
||||
});
|
||||
|
||||
const lastSessionText = computed(() => {
|
||||
if (gameStore.lastSessionDurationMs <= 0) return '-';
|
||||
return timeToText(gameStore.lastSessionDurationMs);
|
||||
});
|
||||
|
||||
const lastOfflineTimeText = computed(() => {
|
||||
if (gameStore.lastOfflineAt <= 0) return '-';
|
||||
return dayjs(gameStore.lastOfflineAt).format('MM/DD HH:mm');
|
||||
});
|
||||
|
||||
// --- Servers status HoverCard ---
|
||||
const serversHoverOpen = ref(false);
|
||||
let serversHoverTimer = null;
|
||||
|
||||
@@ -189,6 +189,8 @@ function mountStatusBar(storeOverrides = {}) {
|
||||
Game: {
|
||||
isGameRunning: false,
|
||||
isSteamVRRunning: false,
|
||||
lastSessionDurationMs: 0,
|
||||
lastOfflineAt: 0,
|
||||
...storeOverrides.Game
|
||||
},
|
||||
Vrcx: {
|
||||
@@ -202,6 +204,12 @@ function mountStatusBar(storeOverrides = {}) {
|
||||
lastStatusSummary: '',
|
||||
...storeOverrides.VrcStatus
|
||||
},
|
||||
User: {
|
||||
currentUser: {
|
||||
$online_for: Date.now()
|
||||
},
|
||||
...storeOverrides.User
|
||||
},
|
||||
GeneralSettings: {
|
||||
...storeOverrides.GeneralSettings
|
||||
}
|
||||
@@ -269,4 +277,17 @@ describe('StatusBar.vue - Servers indicator', () => {
|
||||
const wrapper = mountStatusBar({ Game: { isSteamVRRunning: true } });
|
||||
expect(wrapper.text()).toContain('SteamVR');
|
||||
});
|
||||
|
||||
test('shows last game session details when game is offline and there is session data', () => {
|
||||
const wrapper = mountStatusBar({
|
||||
Game: {
|
||||
isGameRunning: false,
|
||||
lastSessionDurationMs: 3_600_000,
|
||||
lastOfflineAt: new Date('2026-03-13T14:30:00Z').getTime()
|
||||
}
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Last Session');
|
||||
expect(wrapper.text()).toContain('Offline Since');
|
||||
});
|
||||
});
|
||||
|
||||
165
src/coordinators/__tests__/gameCoordinator.test.js
Normal file
165
src/coordinators/__tests__/gameCoordinator.test.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
userStore: {
|
||||
currentUser: {
|
||||
$online_for: 1000,
|
||||
currentAvatar: 'avtr_test'
|
||||
},
|
||||
markCurrentUserGameStarted: vi.fn(),
|
||||
markCurrentUserGameStopped: vi.fn()
|
||||
},
|
||||
gameStore: {
|
||||
isGameNoVR: false,
|
||||
setLastSession: vi.fn(),
|
||||
setIsGameRunning: vi.fn(),
|
||||
isGameRunning: false,
|
||||
setIsSteamVRRunning: vi.fn(),
|
||||
isSteamVRRunning: false
|
||||
},
|
||||
instanceStore: {
|
||||
removeAllQueuedInstances: vi.fn()
|
||||
},
|
||||
updateLoopStore: {
|
||||
setIpcTimeout: vi.fn(),
|
||||
setNextDiscordUpdate: vi.fn()
|
||||
},
|
||||
gameLogStore: {
|
||||
clearNowPlaying: vi.fn()
|
||||
},
|
||||
vrStore: {
|
||||
updateVRLastLocation: vi.fn(),
|
||||
updateOpenVR: vi.fn()
|
||||
},
|
||||
advancedSettingsStore: {
|
||||
autoSweepVRChatCache: false,
|
||||
relaunchVRChatAfterCrash: false,
|
||||
gameLogDisabled: false
|
||||
},
|
||||
configRepository: {
|
||||
setBool: vi.fn().mockResolvedValue(undefined),
|
||||
setString: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
addAvatarWearTime: vi.fn(),
|
||||
runLastLocationResetFlow: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/utils', () => ({
|
||||
deleteVRChatCache: vi.fn(),
|
||||
isRealInstance: vi.fn(() => false)
|
||||
}));
|
||||
|
||||
vi.mock('../../services/database', () => ({
|
||||
database: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
if (prop === '__esModule') return false;
|
||||
return vi.fn().mockResolvedValue(null);
|
||||
}
|
||||
}
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/settings/advanced', () => ({
|
||||
useAdvancedSettingsStore: () => mocks.advancedSettingsStore
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/avatar', () => ({
|
||||
useAvatarStore: () => ({})
|
||||
}));
|
||||
|
||||
vi.mock('../avatarCoordinator', () => ({
|
||||
addAvatarWearTime: (...args) => mocks.addAvatarWearTime(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/gameLog', () => ({
|
||||
useGameLogStore: () => mocks.gameLogStore
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/game', () => ({
|
||||
useGameStore: () => mocks.gameStore
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/instance', () => ({
|
||||
useInstanceStore: () => mocks.instanceStore
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/launch', () => ({
|
||||
useLaunchStore: () => ({})
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/location', () => ({
|
||||
useLocationStore: () => ({ lastLocation: { location: '', playerList: { size: 0 } } })
|
||||
}));
|
||||
|
||||
vi.mock('../locationCoordinator', () => ({
|
||||
runLastLocationResetFlow: (...args) => mocks.runLastLocationResetFlow(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/modal', () => ({
|
||||
useModalStore: () => ({})
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/notification', () => ({
|
||||
useNotificationStore: () => ({ queueGameLogNoty: vi.fn() })
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/updateLoop', () => ({
|
||||
useUpdateLoopStore: () => mocks.updateLoopStore
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/user', () => ({
|
||||
useUserStore: () => mocks.userStore
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/vr', () => ({
|
||||
useVrStore: () => mocks.vrStore
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/world', () => ({
|
||||
useWorldStore: () => ({ updateVRChatWorldCache: vi.fn() })
|
||||
}));
|
||||
|
||||
vi.mock('../../services/config', () => ({
|
||||
default: mocks.configRepository
|
||||
}));
|
||||
|
||||
vi.mock('worker-timers', () => ({
|
||||
setTimeout: vi.fn()
|
||||
}));
|
||||
|
||||
import { runGameRunningChangedFlow } from '../gameCoordinator';
|
||||
|
||||
describe('runGameRunningChangedFlow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.userStore.currentUser.$online_for = 1000;
|
||||
mocks.gameStore.isGameNoVR = false;
|
||||
});
|
||||
|
||||
test('persists and stores last game session when game stops', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(5000);
|
||||
|
||||
await runGameRunningChangedFlow(false);
|
||||
|
||||
expect(mocks.gameStore.setLastSession).toHaveBeenCalledWith(4000, 5000);
|
||||
expect(mocks.configRepository.setString).toHaveBeenCalledWith('VRCX_lastGameSessionMs', '4000');
|
||||
expect(mocks.configRepository.setString).toHaveBeenCalledWith('VRCX_lastGameOfflineAt', '5000');
|
||||
expect(mocks.userStore.markCurrentUserGameStopped).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips persisting last game session when no valid session start exists', async () => {
|
||||
mocks.userStore.currentUser.$online_for = 0;
|
||||
|
||||
await runGameRunningChangedFlow(false);
|
||||
|
||||
expect(mocks.gameStore.setLastSession).not.toHaveBeenCalled();
|
||||
expect(mocks.configRepository.setString).not.toHaveBeenCalledWith('VRCX_lastGameSessionMs', expect.any(String));
|
||||
expect(mocks.configRepository.setString).not.toHaveBeenCalledWith('VRCX_lastGameOfflineAt', expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,18 @@ export async function runGameRunningChangedFlow(isGameRunning) {
|
||||
userStore.markCurrentUserGameStarted();
|
||||
} else {
|
||||
await configRepository.setBool('isGameNoVR', gameStore.isGameNoVR);
|
||||
// persist last session data before markCurrentUserGameStopped resets $online_for
|
||||
const sessionStart = userStore.currentUser.$online_for;
|
||||
const offlineAt = Date.now();
|
||||
if (sessionStart && sessionStart > 0) {
|
||||
const sessionDuration = offlineAt - sessionStart;
|
||||
// set store state synchronously so UI reads it immediately
|
||||
gameStore.setLastSession(sessionDuration, offlineAt);
|
||||
await Promise.all([
|
||||
configRepository.setString('VRCX_lastGameSessionMs', String(sessionDuration)),
|
||||
configRepository.setString('VRCX_lastGameOfflineAt', String(offlineAt))
|
||||
]);
|
||||
}
|
||||
userStore.markCurrentUserGameStopped();
|
||||
instanceStore.removeAllQueuedInstances();
|
||||
runAutoVRChatCacheManagementFlow();
|
||||
|
||||
@@ -2719,6 +2719,10 @@
|
||||
"game": "Game",
|
||||
"game_running": "VRChat is running",
|
||||
"game_stopped": "VRChat is not running",
|
||||
"game_started_at": "Started",
|
||||
"game_session_duration": "Duration",
|
||||
"game_last_session": "Last Session",
|
||||
"game_last_offline": "Offline Since",
|
||||
"servers": "Servers",
|
||||
"servers_ok": "VRChat servers are operational",
|
||||
"servers_issue": "VRChat Server Issues",
|
||||
|
||||
51
src/stores/__tests__/game.test.js
Normal file
51
src/stores/__tests__/game.test.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
vi.mock('../../services/config.js', () => ({
|
||||
default: {
|
||||
getBool: vi.fn(),
|
||||
getString: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import configRepository from '../../services/config.js';
|
||||
import { useGameStore } from '../game';
|
||||
|
||||
function flushPromises() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe('useGameStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
configRepository.getBool.mockResolvedValue(true);
|
||||
configRepository.getString.mockImplementation((key, defaultValue) => {
|
||||
if (key === 'VRCX_lastGameSessionMs') {
|
||||
return Promise.resolve('7200000');
|
||||
}
|
||||
if (key === 'VRCX_lastGameOfflineAt') {
|
||||
return Promise.resolve('1700000000000');
|
||||
}
|
||||
return Promise.resolve(defaultValue ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
test('loads persisted last session data during init', async () => {
|
||||
const store = useGameStore();
|
||||
await flushPromises();
|
||||
|
||||
expect(store.isGameNoVR).toBe(true);
|
||||
expect(store.lastSessionDurationMs).toBe(7200000);
|
||||
expect(store.lastOfflineAt).toBe(1700000000000);
|
||||
});
|
||||
|
||||
test('setLastSession updates session values', () => {
|
||||
const store = useGameStore();
|
||||
|
||||
store.setLastSession(15000, 25000);
|
||||
|
||||
expect(store.lastSessionDurationMs).toBe(15000);
|
||||
expect(store.lastOfflineAt).toBe(25000);
|
||||
});
|
||||
});
|
||||
@@ -22,11 +22,21 @@ export const useGameStore = defineStore('Game', () => {
|
||||
|
||||
const isHmdAfk = ref(false);
|
||||
|
||||
const lastSessionDurationMs = ref(0);
|
||||
|
||||
const lastOfflineAt = ref(0);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function init() {
|
||||
isGameNoVR.value = await configRepository.getBool('isGameNoVR');
|
||||
const [savedMs, savedAt] = await Promise.all([
|
||||
configRepository.getString('VRCX_lastGameSessionMs', null),
|
||||
configRepository.getString('VRCX_lastGameOfflineAt', null)
|
||||
]);
|
||||
if (savedMs) lastSessionDurationMs.value = Number(savedMs) || 0;
|
||||
if (savedAt) lastOfflineAt.value = Number(savedAt) || 0;
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -59,6 +69,15 @@ export const useGameStore = defineStore('Game', () => {
|
||||
isHmdAfk.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} durationMs Session duration in milliseconds.
|
||||
* @param {number} offlineTimestamp Timestamp when game stopped.
|
||||
*/
|
||||
function setLastSession(durationMs, offlineTimestamp) {
|
||||
lastSessionDurationMs.value = durationMs;
|
||||
lastOfflineAt.value = offlineTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date | null} value Last crashed time.
|
||||
*/
|
||||
@@ -99,11 +118,14 @@ export const useGameStore = defineStore('Game', () => {
|
||||
isGameNoVR,
|
||||
isSteamVRRunning,
|
||||
isHmdAfk,
|
||||
lastSessionDurationMs,
|
||||
lastOfflineAt,
|
||||
|
||||
setIsGameRunning,
|
||||
setIsGameNoVR,
|
||||
setIsSteamVRRunning,
|
||||
setIsHmdAfk,
|
||||
setLastSession,
|
||||
setLastCrashedTime,
|
||||
getVRChatCacheSize,
|
||||
getVRChatRegistryKey
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
--font-primary-cjk:
|
||||
var(--font-cjk-jp-primary), var(--font-cjk-sc-primary),
|
||||
var(--font-cjk-kr-primary), var(--font-cjk-tc-primary);
|
||||
--font-mono-cjk:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New',
|
||||
var(--font-primary-cjk), monospace;
|
||||
}
|
||||
|
||||
:root[lang='zh-CN'] {
|
||||
|
||||
Reference in New Issue
Block a user