add playtime tracking via emujs

This commit is contained in:
Georges-Antoine Assi
2026-03-22 15:48:56 -04:00
parent 719b98faaf
commit eecdbb06ec
9 changed files with 164 additions and 16 deletions

View File

@@ -80,6 +80,11 @@ export type { OIDCDict } from './models/OIDCDict';
export type { OIDCLogoutResponse } from './models/OIDCLogoutResponse';
export type { PlatformBindingPayload } from './models/PlatformBindingPayload';
export type { PlatformSchema } from './models/PlatformSchema';
export type { PlaySessionEntry } from './models/PlaySessionEntry';
export type { PlaySessionIngestPayload } from './models/PlaySessionIngestPayload';
export type { PlaySessionIngestResponse } from './models/PlaySessionIngestResponse';
export type { PlaySessionIngestResult } from './models/PlaySessionIngestResult';
export type { PlaySessionSchema } from './models/PlaySessionSchema';
export type { RAGameRomAchievement } from './models/RAGameRomAchievement';
export type { RAProgression } from './models/RAProgression';
export type { RAUserGameProgression } from './models/RAUserGameProgression';

View File

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type PlaySessionEntry = {
rom_id?: (number | null);
save_slot?: (string | null);
start_time: string;
end_time: string;
duration_ms: number;
};

View File

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { PlaySessionEntry } from './PlaySessionEntry';
export type PlaySessionIngestPayload = {
device_id?: (string | null);
sessions: Array<PlaySessionEntry>;
};

View File

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { PlaySessionIngestResult } from './PlaySessionIngestResult';
export type PlaySessionIngestResponse = {
results: Array<PlaySessionIngestResult>;
created_count: number;
skipped_count: number;
};

View File

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type PlaySessionIngestResult = {
index: number;
status: 'created' | 'duplicate' | 'error';
id?: (number | null);
detail?: (string | null);
};

View File

@@ -0,0 +1,17 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type PlaySessionSchema = {
id: number;
user_id: number;
device_id: (string | null);
rom_id: (number | null);
save_slot: (string | null);
start_time: string;
end_time: string;
duration_ms: number;
created_at: string;
updated_at: string;
};

View File

@@ -21,6 +21,7 @@ import { useThemeAssets } from "@/console/composables/useThemeAssets";
import { ROUTES } from "@/plugins/router";
import api from "@/services/api";
import firmwareApi from "@/services/api/firmware";
import playSessionApi from "@/services/api/play-session";
import romApi from "@/services/api/rom";
import storeConfig from "@/stores/config";
import storeLanguage from "@/stores/language";
@@ -72,8 +73,6 @@ const loaderStatus = ref<
"idle" | "loading-local" | "loading-cdn" | "loaded" | "failed"
>("idle");
let pausedByPrompt = false;
const exitOptions = computed(() => [
{
id: "save",
@@ -94,16 +93,50 @@ const exitOptions = computed(() => [
const { subscribe } = useInputScope();
let exitScopeOff: (() => void) | null = null;
let pausedByPrompt = false;
let sessionStartTime: Date | null = null;
let requestedAnimationFrame: number | null = null;
let lastPressedKeys: Record<number, number> = { 8: 0, 9: 0 };
const INVALID_CHARS_REGEX = /[#<$+%>!`&*'|{}/\\?"=@:^\r\n]/gi;
function immediateExit() {
router
.push({ name: ROUTES.CONSOLE_ROM, params: { rom: romId } })
.catch((error) => {
console.error("Error navigating to console rom", error);
if (!sessionStartTime || !romRef.value) {
return router
.push({ name: ROUTES.CONSOLE_ROM, params: { rom: romId } })
.catch((error) => {
console.error("Error navigating to console rom", error);
});
}
const endTime = new Date();
const durationMs = endTime.getTime() - sessionStartTime.getTime();
if (durationMs < 1000) {
// Don't log sessions under 1s, likely accidental opens
console.info("Play session too short, not logging");
return;
}
playSessionApi
.ingestPlaySessions({
sessions: [
{
rom_id: romRef.value.id,
start_time: sessionStartTime.toISOString(),
end_time: endTime.toISOString(),
duration_ms: durationMs,
},
],
})
.catch((err) => console.error("Failed to submit play session:", err))
.finally(() => {
sessionStartTime = null;
router
.push({ name: ROUTES.CONSOLE_ROM, params: { rom: romId } })
.catch((error) => {
console.error("Error navigating to console rom", error);
});
});
}
@@ -527,6 +560,7 @@ async function boot() {
// Ensure a controller is auto-assigned to Player 1 when available
window.EJS_onGameStart = () => {
sessionStartTime = new Date();
if (!window.EJS_emulator) return;
const waitForGameManager = async () => {
const deadline = Date.now() + 5000; // 5s timeout
@@ -700,9 +734,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div
class="play-root fixed inset-0 bg-black text-white z-[70] overflow-hidden"
>
<div class="play-root fixed inset-0 bg-black text-white z-70 overflow-hidden">
<div id="game" class="w-full h-full" />
<div
v-if="bezelSrc"
@@ -741,7 +773,7 @@ onBeforeUnmount(() => {
<div class="text-red-300 font-medium">
{{ t("console.emulator-failed") }}
</div>
<div class="mt-1 text-[11px] max-w-xs leading-snug break-words">
<div class="mt-1 text-[11px] max-w-xs leading-snug wrap-break-word">
{{ loaderError }}
</div>
</template>
@@ -771,7 +803,7 @@ onBeforeUnmount(() => {
borderColor: 'var(--console-modal-border)',
boxShadow: 'var(--console-modal-shadow)',
}"
class="relative w-full max-w-[560px] mx-auto rounded-2xl pa-10 md:p-9 flex flex-col gap-6 focus:outline-none border"
class="relative w-full max-w-140 mx-auto rounded-2xl pa-10 md:p-9 flex flex-col gap-6 focus:outline-none border"
>
<div class="flex items-center justify-between">
<h2
@@ -799,7 +831,7 @@ onBeforeUnmount(() => {
? 'opacity-40 cursor-not-allowed'
: '',
focusedExitIndex === i
? 'shadow-[0_0_0_2px_var(--console-modal-tile-selected-border),_0_0_18px_-4px_var(--console-modal-tile-selected-border)]'
? 'shadow-[0_0_0_2px_var(--console-modal-tile-selected-border),0_0_18px_-4px_var(--console-modal-tile-selected-border)]'
: '',
]"
:style="

View File

@@ -0,0 +1,21 @@
import api from "@/services/api";
async function ingestPlaySessions({
deviceId = null,
sessions,
}: {
deviceId?: string | null;
sessions: {
rom_id: number;
start_time: string;
end_time: string;
duration_ms: number;
}[];
}) {
return api.post("/play-sessions", {
device_id: deviceId,
sessions,
});
}
export default { ingestPlaySessions };

View File

@@ -11,6 +11,7 @@ import type {
NetplayICEServer,
} from "@/__generated__";
import { ROUTES } from "@/plugins/router";
import playSessionApi from "@/services/api/play-session";
import { saveApi as api } from "@/services/api/save";
import storeConfig from "@/stores/config";
import storeLanguage from "@/stores/language";
@@ -51,6 +52,7 @@ const props = defineProps<{
}>();
const romRef = ref<DetailedRom>(props.rom);
const saveRef = ref<SaveSchema | null>(props.save);
const sessionStartTime = ref<Date | null>(null);
const theme = useTheme();
const emitter = inject<Emitter<Events>>("emitter");
const { playing, fullScreen } = storeToRefs(playingStore);
@@ -348,6 +350,7 @@ window.EJS_onSaveState = async function ({
};
window.EJS_onGameStart = async () => {
sessionStartTime.value = new Date();
setTimeout(async () => {
if (props.save) await loadSave(props.save);
if (props.state) await loadState(props.state);
@@ -434,10 +437,36 @@ window.EJS_onGameStart = async () => {
};
function immediateExit() {
router
.push({ name: ROUTES.ROM, params: { rom: romRef.value.id } })
.catch((error) => {
console.error("Error navigating to console rom", error);
if (!sessionStartTime.value) {
return router
.push({ name: ROUTES.ROM, params: { rom: romRef.value.id } })
.catch((error) => {
console.error("Error navigating to console rom", error);
});
}
const endTime = new Date();
const durationMs = endTime.getTime() - sessionStartTime.value.getTime();
playSessionApi
.ingestPlaySessions({
sessions: [
{
rom_id: romRef.value.id,
start_time: sessionStartTime.value.toISOString(),
end_time: endTime.toISOString(),
duration_ms: durationMs,
},
],
})
.catch((err) => console.error("Failed to submit play session:", err))
.finally(() => {
sessionStartTime.value = null;
router
.push({ name: ROUTES.ROM, params: { rom: romRef.value.id } })
.catch((error) => {
console.error("Error navigating to console rom", error);
});
});
}