Secure activity identity, cut Redis churn, remove v1 activity

Backend:
- Resolve the acting user from the authenticated socket session on
  connect instead of trusting the client-supplied user_id, so a client
  can no longer spoof a "now playing" session for another user. Only
  rom_id/device_id come from the payload.
- Emit activity:update/clear through the already-initialised socket
  server instead of opening (and leaking) a fresh AsyncRedisManager per
  REST heartbeat.
- Collapse get_all_active's per-key GET into a single MGET.
- Drop the pure pass-through _build_activity_entry helper.

Frontend:
- Remove all activity emits from the v1 EmulatorJS Player; the v2 shell
  is the single driver of the activity lifecycle.
- Remove activity from the v1 UI entirely (Activity view, ActivityBtn,
  ActivePlayers on game details, navigation, and the now-v2-only route).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Georges-Antoine Assi
2026-06-21 11:12:18 -04:00
parent 8aaa52bcb2
commit 0460133992
14 changed files with 92 additions and 509 deletions

View File

@@ -1,15 +1,14 @@
from datetime import datetime, timezone
import socketio # type: ignore
from fastapi import HTTPException, Request, status
from pydantic import BaseModel, Field
from config import REDIS_URL
from decorators.auth import protected_route
from endpoints.responses.activity import ActivityClearSchema, ActivityEntrySchema
from handler.activity_handler import ActivityEntry, activity_handler
from handler.auth.constants import Scope
from handler.database import db_device_handler, db_rom_handler
from handler.socket_handler import socket_handler
from logger.logger import log
from utils.router import APIRouter
@@ -24,40 +23,6 @@ class DeviceHeartbeatPayload(BaseModel):
device_id: str = Field(min_length=1, max_length=255)
def _get_socket_manager() -> socketio.AsyncRedisManager:
"""Create a write-only Redis manager for emitting from REST endpoints."""
return socketio.AsyncRedisManager(REDIS_URL, write_only=True)
def _build_activity_entry(
*,
user_id: int,
username: str,
avatar_path: str,
rom_id: int,
rom_name: str,
rom_cover_path: str,
platform_slug: str,
platform_name: str,
device_id: str,
device_type: str,
started_at: str,
) -> ActivityEntry:
return ActivityEntry(
user_id=user_id,
username=username,
avatar_path=avatar_path,
rom_id=rom_id,
rom_name=rom_name,
rom_cover_path=rom_cover_path,
platform_slug=platform_slug,
platform_name=platform_name,
device_id=device_id,
device_type=device_type,
started_at=started_at,
)
@protected_route(router.get, "", [Scope.ROMS_USER_READ])
async def get_all_activity(request: Request) -> list[ActivityEntrySchema]:
"""Return every currently active play session across all users."""
@@ -105,7 +70,7 @@ async def device_heartbeat(
)
platform = rom.platform
entry = _build_activity_entry(
entry = ActivityEntry(
user_id=request.user.id,
username=request.user.username,
avatar_path=request.user.avatar_path or "",
@@ -124,10 +89,11 @@ async def device_heartbeat(
# Update the device last_seen as a side-effect (mirrors play session ingest).
db_device_handler.update_last_seen(device_id=device.id, user_id=request.user.id)
# Broadcast to all connected sockets.
# Broadcast to all connected sockets. The REST app shares this process with
# the Socket.IO server, so emit through the already-initialised, Redis-backed
# server (it fans out across workers) rather than opening a manager per call.
try:
sm = _get_socket_manager()
await sm.emit("activity:update", dict(entry))
await socket_handler.socket_server.emit("activity:update", dict(entry))
except Exception as e: # noqa: BLE001
log.warning(
f"Failed to broadcast activity:update for user {request.user.id}: {e}"
@@ -149,8 +115,7 @@ async def clear_device_activity(request: Request, device_id: str) -> None:
return None
try:
sm = _get_socket_manager()
await sm.emit(
await socket_handler.socket_server.emit(
"activity:clear",
ActivityClearSchema(
user_id=request.user.id,

View File

@@ -6,13 +6,18 @@ Handles:
- activity:stop - client reports stopping (emits activity:clear)
- disconnect - safety net: clears any activity registered for the socket
The acting user is never taken from the client payload — it is resolved from
the authenticated socket session (stored on connect, see ``endpoints.sockets``)
so a client cannot broadcast a "now playing" session on behalf of another user.
Only ``rom_id`` / ``device_id`` come from the client.
All events broadcast to every connected client on the main `/ws` namespace.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import TypedDict
from typing import Any, TypedDict
from endpoints.responses.activity import ActivityClearSchema
from handler.activity_handler import ActivityEntry, activity_handler
@@ -20,19 +25,43 @@ from handler.database import db_device_handler, db_rom_handler, db_user_handler
from handler.socket_handler import socket_handler
from logger.logger import log
# Socket-session key holding the authenticated user id, written by the connect
# handler. Identity for every activity event is derived from this, not payload.
AUTH_USER_SESSION_KEY = "activity_auth_user_id"
class ActivityEventPayload(TypedDict, total=False):
rom_id: int
user_id: int
device_id: str
async def _session(sid: str) -> dict[str, Any]:
try:
return await socket_handler.socket_server.get_session(sid) or {}
except KeyError:
return {}
async def store_authenticated_user(sid: str, user_id: int) -> None:
"""Record the authenticated user for a socket so activity events trust it.
Called from the connect handler once the session has been resolved. Without
this, activity events have no identity to act on and are ignored.
"""
existing = await _session(sid)
existing[AUTH_USER_SESSION_KEY] = user_id
await socket_handler.socket_server.save_session(sid, existing)
async def _authenticated_user_id(sid: str) -> int | None:
"""Return the user id resolved at connect time, or ``None`` if unauthenticated."""
user_id = (await _session(sid)).get(AUTH_USER_SESSION_KEY)
return int(user_id) if user_id is not None else None
async def _store_session(sid: str, user_id: int, device_id: str) -> None:
"""Remember the user/device associated with a socket for disconnect cleanup."""
try:
existing = await socket_handler.socket_server.get_session(sid) or {}
except KeyError:
existing = {}
existing = await _session(sid)
existing["activity_user_id"] = user_id
existing["activity_device_id"] = device_id
await socket_handler.socket_server.save_session(sid, existing)
@@ -78,15 +107,14 @@ async def _build_entry(
)
def _extract_payload(data: object) -> tuple[int | None, str | None, int | None]:
"""Return ``(user_id, device_id, rom_id)`` parsed from an event payload."""
def _extract_payload(data: object) -> tuple[str | None, int | None]:
"""Return ``(device_id, rom_id)`` parsed from an event payload.
``user_id`` is deliberately not read from the client — identity comes from
the authenticated socket session, never the payload.
"""
if not isinstance(data, dict):
return None, None, None
try:
data_user_id = data.get("user_id")
user_id = int(data_user_id) if data_user_id else None
except (TypeError, ValueError):
user_id = None
return None, None
device_id = data.get("device_id")
if not isinstance(device_id, str) or not device_id:
device_id = None
@@ -95,14 +123,15 @@ def _extract_payload(data: object) -> tuple[int | None, str | None, int | None]:
rom_id = int(data_rom_id) if data_rom_id else None
except (TypeError, ValueError):
rom_id = None
return user_id, device_id, rom_id
return device_id, rom_id
@socket_handler.socket_server.on("activity:start") # type: ignore
async def activity_start(sid: str, data: ActivityEventPayload) -> None:
user_id, device_id, rom_id = _extract_payload(data)
user_id = await _authenticated_user_id(sid)
device_id, rom_id = _extract_payload(data)
if user_id is None or device_id is None or rom_id is None:
log.debug(f"activity:start ignored (invalid payload): {data}")
log.debug(f"activity:start ignored (unauthenticated or invalid): {data}")
return
entry = await _build_entry(
@@ -121,7 +150,8 @@ async def activity_start(sid: str, data: ActivityEventPayload) -> None:
@socket_handler.socket_server.on("activity:heartbeat") # type: ignore
async def activity_heartbeat(sid: str, data: ActivityEventPayload) -> None:
user_id, device_id, rom_id = _extract_payload(data)
user_id = await _authenticated_user_id(sid)
device_id, rom_id = _extract_payload(data)
if user_id is None or device_id is None or rom_id is None:
return
@@ -141,20 +171,15 @@ async def activity_heartbeat(sid: str, data: ActivityEventPayload) -> None:
@socket_handler.socket_server.on("activity:stop") # type: ignore
async def activity_stop(sid: str, data: ActivityEventPayload | None = None) -> None:
user_id: int | None = None
user_id = await _authenticated_user_id(sid)
device_id: str | None = None
if data:
user_id, device_id, _ = _extract_payload(data)
device_id, _ = _extract_payload(data)
# Fall back to the stored session if the payload is missing fields.
if user_id is None or device_id is None:
try:
session = await socket_handler.socket_server.get_session(sid) or {}
except KeyError:
session = {}
user_id = user_id if user_id is not None else session.get("activity_user_id")
device_id = device_id if device_id else session.get("activity_device_id")
# Fall back to the device stored at start time if the payload omits it.
if not device_id:
device_id = (await _session(sid)).get("activity_device_id")
if user_id is None or not device_id:
return
@@ -174,11 +199,7 @@ async def activity_stop(sid: str, data: ActivityEventPayload | None = None) -> N
@socket_handler.socket_server.on("disconnect") # type: ignore
async def activity_on_disconnect(sid: str) -> None:
"""Safety net: clear any activity tied to a disconnecting socket."""
try:
session = await socket_handler.socket_server.get_session(sid) or {}
except KeyError:
return
session = await _session(sid)
user_id = session.get("activity_user_id")
device_id = session.get("activity_device_id")
if user_id is None or not device_id:

View File

@@ -17,6 +17,7 @@ from typing import Any, Final
import socketio # type: ignore
from config import DISABLE_LOGS_VIEWER, REDIS_URL
from endpoints.sockets.activity import store_authenticated_user
from handler.database import db_user_handler
from handler.redis_handler import async_cache
from handler.socket_handler import socket_handler
@@ -33,14 +34,14 @@ FORWARDER_LOCK_TTL: Final = 30 # seconds
@socket_handler.socket_server.on("connect") # type: ignore
async def connect(sid: str, environ: dict[str, Any], auth: Any = None) -> None:
"""Join admin users to the log-streaming room on socket connect.
"""Resolve the authenticated user on socket connect.
Always returns ``None`` (accepts the connection) — only the room membership
is gated, so the existing scan/sync sockets keep working for everyone.
Stores the user id in the socket session so activity events can trust the
server-resolved identity instead of a client-supplied ``user_id``, and joins
admin users to the log-streaming room. Always returns ``None`` (accepts the
connection) — only identity storage and room membership are gated, so the
existing scan/sync sockets keep working for everyone.
"""
if DISABLE_LOGS_VIEWER:
return
try:
session = await get_session_from_environ(environ)
if session.get("iss") != "romm:auth":
@@ -51,10 +52,15 @@ async def connect(sid: str, environ: dict[str, Any], auth: Any = None) -> None:
return
user = db_user_handler.get_user_by_username(username)
if user and user.enabled and user.role == Role.ADMIN:
if not user or not user.enabled:
return
await store_authenticated_user(sid, user.id)
if not DISABLE_LOGS_VIEWER and user.role == Role.ADMIN:
await socket_handler.socket_server.enter_room(sid, ADMIN_ROOM)
except Exception: # noqa: BLE001 - never let auth resolution refuse a socket
log.exception("Failed to resolve admin for log stream connect")
log.exception("Failed to resolve user on socket connect")
async def get_recent_logs(limit: int) -> list[dict[str, Any]]:

View File

@@ -93,10 +93,14 @@ class ActivityHandler:
async def get_all_active(self) -> list[ActivityEntry]:
"""Get all currently active play sessions across all users."""
entries: list[ActivityEntry] = []
pattern = f"{self.KEY_PREFIX}*"
async for key in async_cache.scan_iter(match=pattern):
raw = await async_cache.get(key)
keys = [key async for key in async_cache.scan_iter(match=pattern)]
if not keys:
return []
# Single round-trip for every value instead of a GET per key.
entries: list[ActivityEntry] = []
for raw in await async_cache.mget(keys):
if not raw:
continue
try:

View File

@@ -1,77 +0,0 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import storeActivity from "@/stores/activity";
const props = defineProps<{ romId: number }>();
const { t } = useI18n();
const activityStore = storeActivity();
onMounted(() => {
activityStore.initSocket();
if (!activityStore.initialized) {
activityStore.fetchAll();
}
});
const activePlayers = computed(() => activityStore.getByRomId(props.romId));
function formatStartedAt(iso: string): string {
try {
const date = new Date(iso);
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
</script>
<template>
<v-card
v-if="activePlayers.length > 0"
class="mb-3"
variant="tonal"
color="success"
density="compact"
>
<v-card-text class="d-flex align-center flex-wrap py-2">
<v-icon class="mr-2" color="success" size="small">
mdi-access-point
</v-icon>
<span class="text-body-2 font-weight-medium mr-3">
{{ t("activity.now-playing") }}
</span>
<v-avatar
v-for="player in activePlayers"
:key="`${player.user_id}-${player.device_id}`"
size="32"
class="mr-1"
>
<v-img
v-if="player.avatar_path"
:src="`/assets/${player.avatar_path}`"
:alt="player.username"
/>
<v-icon v-else>mdi-account-circle</v-icon>
<v-tooltip activator="parent" location="bottom">
<div>
<strong>{{ player.username }}</strong>
<div class="text-caption">
{{ t("activity.playing-on", { device: player.device_type }) }}
</div>
<div class="text-caption">
{{
t("activity.playing-since", {
time: formatStartedAt(player.started_at),
})
}}
</div>
</div>
</v-tooltip>
</v-avatar>
</v-card-text>
</v-card>
</template>

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useI18n } from "vue-i18n";
import storeActivity from "@/stores/activity";
import storeNavigation from "@/stores/navigation";
withDefaults(
defineProps<{
block?: boolean;
height?: string;
rounded?: boolean;
withTag?: boolean;
}>(),
{
block: false,
height: "",
rounded: false,
withTag: false,
},
);
const { t } = useI18n();
const navigationStore = storeNavigation();
const activityStore = storeActivity();
onMounted(() => {
activityStore.initSocket();
if (!activityStore.initialized) {
activityStore.fetchAll();
}
});
</script>
<template>
<v-btn
icon
:block="block"
variant="flat"
color="background"
:height="height"
:class="{ rounded: rounded }"
class="py-4 bg-background d-flex align-center justify-center"
@click="navigationStore.goActivity"
>
<div class="d-flex flex-column align-center">
<v-badge
:model-value="activityStore.activeCount > 0"
:content="activityStore.activeCount"
color="success"
offset-x="-2"
offset-y="-2"
>
<v-icon :color="$route.path.startsWith('/activity') ? 'primary' : ''">
mdi-access-point
</v-icon>
</v-badge>
<v-expand-transition>
<span
v-if="withTag"
class="text-caption text-center"
:class="{ 'text-primary': $route.path.startsWith('/activity') }"
>
{{ t("common.activity") }}
</span>
</v-expand-transition>
</div>
</v-btn>
</template>

View File

@@ -4,7 +4,6 @@ import { storeToRefs } from "pinia";
import { useDisplay } from "vuetify";
import RandomBtn from "@/components/Gallery/AppBar/common/RandomBtn.vue";
import UploadRomDialog from "@/components/common/Game/Dialog/UploadRom.vue";
import ActivityBtn from "@/components/common/Navigation/ActivityBtn.vue";
import CollectionsBtn from "@/components/common/Navigation/CollectionsBtn.vue";
import CollectionsDrawer from "@/components/common/Navigation/CollectionsDrawer.vue";
import ConsoleModeBtn from "@/components/common/Navigation/ConsoleModeBtn.vue";
@@ -49,7 +48,6 @@ function collapse() {
</template>
<template #append>
<ActivityBtn class="mr-2" />
<PatcherBtn class="mr-2" />
<RandomBtn class="mr-2" />
<UploadBtn class="mr-2" />
@@ -114,7 +112,6 @@ function collapse() {
<ConsoleModeBtn :with-tag="!mainBarCollapsed" rounded class="mt-2" block />
<template #append>
<ActivityBtn :with-tag="!mainBarCollapsed" rounded class="mt-2" block />
<PatcherBtn :with-tag="!mainBarCollapsed" rounded class="mt-2" block />
<RandomBtn :with-tag="!mainBarCollapsed" rounded class="mt-2" block />
<UploadBtn

View File

@@ -325,7 +325,9 @@ const routes = [
title: i18n.global.t("activity.active-sessions"),
},
components: {
default: () => import("@/views/Activity.vue"),
// v2-only view; v1 has no activity concept so it redirects
// home if a v1 user deep-links here.
default: () => import("@/views/Home.vue"),
v2: v2For(ROUTES.ACTIVITY),
},
},

View File

@@ -49,10 +49,6 @@ export default defineStore("navigation", {
this.reset();
this.$router.push({ name: ROUTES.PATCHER });
},
goActivity() {
this.reset();
this.$router.push({ name: ROUTES.ACTIVITY });
},
goSearch() {
this.reset();
this.$router.push({ name: ROUTES.SEARCH });

View File

@@ -229,14 +229,6 @@ async function onLogout() {
<div class="r-v2-user-menu__group-label">
{{ t("settings.group-system") }}
</div>
<!-- Live "now playing" board readable by any authenticated user, so
it leads the System group (mirrors SettingsSidebar). -->
<RMenuItem
:to="{ name: ROUTES.ACTIVITY }"
icon="mdi-access-point"
:label="t('activity.active-sessions')"
@click="open = false"
/>
<RMenuItem
v-if="canSeeAdmin"
:to="{ name: ROUTES.ADMINISTRATION }"
@@ -244,6 +236,12 @@ async function onLogout() {
:label="t('common.administration')"
@click="open = false"
/>
<RMenuItem
:to="{ name: ROUTES.ACTIVITY }"
icon="mdi-access-point"
:label="t('activity.active-sessions')"
@click="open = false"
/>
<RMenuItem
v-if="isAdmin"
:to="{ name: ROUTES.SERVER_STATS }"

View File

@@ -90,14 +90,6 @@ const gameRunning = ref(false);
const removeIOSFullscreenShim = ref<(() => void) | null>(null);
// ── Live activity ("now playing") ──────────────────────────────────
// The v2 shell owns the activity lifecycle directly rather than relying
// on the reused v1 <Player>'s `window.EJS_onGameStart` callback — that
// callback doesn't fire reliably in the v2 mount sequence (the async
// <Player> mounts after the shell has already booted the loader), so
// the board never saw the session. Driving it off the shell's own
// deterministic `gameRunning` transition guarantees the emit. The
// backend's set_active / clear_active are idempotent, so a stray emit
// from the v1 layer (were it ever to fire) is harmless.
const ACTIVITY_HEARTBEAT_MS = 30_000;
let activityHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
@@ -110,7 +102,6 @@ function emitActivityStart() {
if (!socket.connected) socket.connect();
socket.emit("activity:start", {
rom_id: rom.value.id,
user_id: auth.user.id,
device_id: activityDeviceId(),
});
}
@@ -119,7 +110,6 @@ function emitActivityHeartbeat() {
if (!auth.user || !rom.value) return;
socket.emit("activity:heartbeat", {
rom_id: rom.value.id,
user_id: auth.user.id,
device_id: activityDeviceId(),
});
}
@@ -127,7 +117,6 @@ function emitActivityHeartbeat() {
function emitActivityStop() {
if (!auth.user) return;
socket.emit("activity:stop", {
user_id: auth.user.id,
device_id: activityDeviceId(),
});
}

View File

@@ -1,192 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeMount, onBeforeUnmount, ref } from "vue";
import { useI18n } from "vue-i18n";
import { ROUTES } from "@/plugins/router";
import type { ActivityEntry } from "@/services/api/activity";
import storeActivity from "@/stores/activity";
import { FRONTEND_RESOURCES_PATH } from "@/utils";
const { t } = useI18n();
const activityStore = storeActivity();
const now = ref(Date.now());
let tickTimer: ReturnType<typeof setInterval> | null = null;
onBeforeMount(async () => {
activityStore.initSocket();
await activityStore.fetchAll();
// Update "elapsed time" labels every 30 seconds.
tickTimer = setInterval(() => {
now.value = Date.now();
}, 30_000);
});
onBeforeUnmount(() => {
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = null;
}
});
const activities = computed(() =>
[...activityStore.activities].sort(
(a, b) =>
new Date(a.started_at).getTime() - new Date(b.started_at).getTime(),
),
);
function coverSrc(entry: ActivityEntry): string {
if (!entry.rom_cover_path) return "";
return `${FRONTEND_RESOURCES_PATH}/${entry.rom_cover_path}`;
}
function avatarSrc(entry: ActivityEntry): string {
if (!entry.avatar_path) return "";
return `/assets/${entry.avatar_path}`;
}
function elapsedLabel(startedAt: string): string {
const started = new Date(startedAt).getTime();
if (Number.isNaN(started)) return "";
const diffMs = Math.max(0, now.value - started);
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 1) return t("activity.just-now");
if (minutes < 60) return t("activity.minutes-ago", { n: minutes });
const hours = Math.floor(minutes / 60);
const remMin = minutes % 60;
if (hours < 24) {
return remMin === 0
? t("activity.hours-ago", { n: hours })
: t("activity.hours-minutes-ago", { h: hours, m: remMin });
}
const days = Math.floor(hours / 24);
return t("activity.days-ago", { n: days });
}
</script>
<template>
<v-container class="py-4">
<div class="d-flex align-center mb-4">
<v-icon class="mr-2" color="success">mdi-access-point</v-icon>
<h2 class="text-h5 font-weight-medium">
{{ t("activity.active-sessions") }}
</h2>
<v-chip
v-if="activities.length > 0"
class="ml-3"
color="success"
size="small"
variant="tonal"
>
{{ activities.length }}
</v-chip>
</div>
<v-alert
v-if="activities.length === 0"
type="info"
variant="tonal"
icon="mdi-information-outline"
>
{{ t("activity.no-activity") }}
</v-alert>
<v-row v-else>
<v-col
v-for="entry in activities"
:key="`${entry.user_id}-${entry.device_id}`"
cols="12"
sm="6"
md="4"
lg="3"
>
<router-link
:to="{ name: ROUTES.ROM, params: { rom: entry.rom_id } }"
class="activity-link"
>
<v-card variant="tonal" class="h-100">
<div class="cover-wrapper">
<v-img
v-if="coverSrc(entry)"
:src="coverSrc(entry)"
:alt="entry.rom_name"
aspect-ratio="2/3"
cover
/>
<div
v-else
class="cover-placeholder d-flex align-center justify-center"
>
<v-icon size="48" color="grey-lighten-1">
mdi-nintendo-game-boy
</v-icon>
</div>
<div class="cover-overlay">
<v-chip
color="success"
variant="flat"
size="x-small"
class="live-chip"
>
<v-icon start size="x-small">mdi-access-point</v-icon>
{{ t("activity.live") }}
</v-chip>
</div>
</div>
<v-card-text class="pa-3">
<div class="text-body-2 font-weight-medium text-truncate">
{{ entry.rom_name }}
</div>
<div class="text-caption text-medium-emphasis text-truncate">
{{ entry.platform_name }}
</div>
<v-divider class="my-2" />
<div class="d-flex align-center">
<v-avatar size="24" class="mr-2">
<v-img
v-if="avatarSrc(entry)"
:src="avatarSrc(entry)"
:alt="entry.username"
/>
<v-icon v-else size="small">mdi-account-circle</v-icon>
</v-avatar>
<div class="text-body-2 text-truncate">
{{ entry.username }}
</div>
</div>
<div class="text-caption text-medium-emphasis mt-1">
{{ elapsedLabel(entry.started_at) }}
<span v-if="entry.device_type">
· {{ entry.device_type }}
</span>
</div>
</v-card-text>
</v-card>
</router-link>
</v-col>
</v-row>
</v-container>
</template>
<style scoped>
.activity-link {
text-decoration: none;
color: inherit;
display: block;
height: 100%;
}
.cover-wrapper {
position: relative;
}
.cover-placeholder {
aspect-ratio: 2 / 3;
background-color: rgba(var(--v-theme-surface-variant, 0.08));
}
.cover-overlay {
position: absolute;
top: 8px;
right: 8px;
}
.live-chip {
box-shadow: 0 0 12px rgba(76, 175, 80, 0.6);
}
</style>

View File

@@ -6,7 +6,6 @@ import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
import ActionBar from "@/components/Details/ActionBar.vue";
import ActivePlayers from "@/components/Details/ActivePlayers.vue";
import AdditionalContent from "@/components/Details/AdditionalContent.vue";
import BackgroundHeader from "@/components/Details/BackgroundHeader.vue";
import GameData from "@/components/Details/GameData.vue";
@@ -234,7 +233,6 @@ watch(
<v-window-item value="details">
<v-row no-gutters>
<v-col>
<ActivePlayers :rom-id="currentRom.id" />
<FileInfo :rom="currentRom" />
<GameInfo :rom="currentRom" />
</v-col>

View File

@@ -13,7 +13,6 @@ import type {
import { ROUTES } from "@/plugins/router";
import playSessionApi from "@/services/api/play-session";
import { saveApi as api } from "@/services/api/save";
import socket from "@/services/socket";
import storeAuth from "@/stores/auth";
import storeConfig from "@/stores/config";
import storeLanguage from "@/stores/language";
@@ -58,54 +57,6 @@ const romRef = ref<DetailedRom>(props.rom);
const saveRef = ref<SaveSchema | null>(props.save);
const sessionStartTime = ref<Date | null>(null);
const deviceIDRef = ref(authStore.user?.current_device_id ?? undefined);
const activityHeartbeatTimer = ref<ReturnType<typeof setInterval> | null>(null);
const ACTIVITY_HEARTBEAT_MS = 30_000;
function activityDeviceId(): string {
return deviceIDRef.value ?? "web";
}
function emitActivityStart() {
if (!authStore.user) return;
if (!socket.connected) socket.connect();
socket.emit("activity:start", {
rom_id: romRef.value.id,
user_id: authStore.user.id,
device_id: activityDeviceId(),
});
}
function emitActivityHeartbeat() {
if (!authStore.user) return;
socket.emit("activity:heartbeat", {
rom_id: romRef.value.id,
user_id: authStore.user.id,
device_id: activityDeviceId(),
});
}
function emitActivityStop() {
if (!authStore.user) return;
socket.emit("activity:stop", {
user_id: authStore.user.id,
device_id: activityDeviceId(),
});
}
function startActivityHeartbeat() {
if (activityHeartbeatTimer.value) return;
activityHeartbeatTimer.value = setInterval(
emitActivityHeartbeat,
ACTIVITY_HEARTBEAT_MS,
);
}
function stopActivityHeartbeat() {
if (activityHeartbeatTimer.value) {
clearInterval(activityHeartbeatTimer.value);
activityHeartbeatTimer.value = null;
}
}
const theme = useTheme();
const emitter = inject<Emitter<Events>>("emitter");
const { playing, fullScreen } = storeToRefs(playingStore);
@@ -275,8 +226,6 @@ onBeforeUnmount(async () => {
window.EJS_emulator?.callEvent("exit");
fullScreen.value = false;
playing.value = false;
stopActivityHeartbeat();
emitActivityStop();
});
function displayMessage(
@@ -411,8 +360,6 @@ window.EJS_onSaveState = async function ({
window.EJS_onGameStart = async () => {
sessionStartTime.value = new Date();
emitActivityStart();
startActivityHeartbeat();
setTimeout(async () => {
if (props.save) await loadSave(props.save);
if (props.state) await loadState(props.state);
@@ -500,9 +447,6 @@ window.EJS_onGameStart = async () => {
};
function immediateExit() {
stopActivityHeartbeat();
emitActivityStop();
if (!sessionStartTime.value) {
return router
.push({ name: ROUTES.ROM, params: { rom: romRef.value.id } })