diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 60e4521d1..5d640f43f 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -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'; diff --git a/frontend/src/__generated__/models/PlaySessionEntry.ts b/frontend/src/__generated__/models/PlaySessionEntry.ts new file mode 100644 index 000000000..8e719dbd9 --- /dev/null +++ b/frontend/src/__generated__/models/PlaySessionEntry.ts @@ -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; +}; + diff --git a/frontend/src/__generated__/models/PlaySessionIngestPayload.ts b/frontend/src/__generated__/models/PlaySessionIngestPayload.ts new file mode 100644 index 000000000..28a601262 --- /dev/null +++ b/frontend/src/__generated__/models/PlaySessionIngestPayload.ts @@ -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; +}; + diff --git a/frontend/src/__generated__/models/PlaySessionIngestResponse.ts b/frontend/src/__generated__/models/PlaySessionIngestResponse.ts new file mode 100644 index 000000000..a3bced2a1 --- /dev/null +++ b/frontend/src/__generated__/models/PlaySessionIngestResponse.ts @@ -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; + created_count: number; + skipped_count: number; +}; + diff --git a/frontend/src/__generated__/models/PlaySessionIngestResult.ts b/frontend/src/__generated__/models/PlaySessionIngestResult.ts new file mode 100644 index 000000000..4ffbdbf32 --- /dev/null +++ b/frontend/src/__generated__/models/PlaySessionIngestResult.ts @@ -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); +}; + diff --git a/frontend/src/__generated__/models/PlaySessionSchema.ts b/frontend/src/__generated__/models/PlaySessionSchema.ts new file mode 100644 index 000000000..54d56da7e --- /dev/null +++ b/frontend/src/__generated__/models/PlaySessionSchema.ts @@ -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; +}; + diff --git a/frontend/src/console/views/Play.vue b/frontend/src/console/views/Play.vue index e9e6e1b03..7dfff8346 100644 --- a/frontend/src/console/views/Play.vue +++ b/frontend/src/console/views/Play.vue @@ -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 = { 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(() => { @@ -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" >

{ ? '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=" diff --git a/frontend/src/services/api/play-session.ts b/frontend/src/services/api/play-session.ts new file mode 100644 index 000000000..60374f821 --- /dev/null +++ b/frontend/src/services/api/play-session.ts @@ -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 }; diff --git a/frontend/src/views/Player/EmulatorJS/Player.vue b/frontend/src/views/Player/EmulatorJS/Player.vue index b2fb9c86c..f38ca8244 100644 --- a/frontend/src/views/Player/EmulatorJS/Player.vue +++ b/frontend/src/views/Player/EmulatorJS/Player.vue @@ -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(props.rom); const saveRef = ref(props.save); +const sessionStartTime = ref(null); const theme = useTheme(); const emitter = inject>("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); + }); }); }