From 4e300e4631d91f49376f987a3320253352cb6966 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 21:51:54 +0000 Subject: [PATCH 1/2] feat(player): guard against audio playback on secondary device --- .../player/player-shell.component.spec.ts | 25 +++++++++++++++++++ .../features/player/player-shell.component.ts | 23 +++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index 839025f..1024766 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -350,4 +350,29 @@ describe('PlayerShellComponent gameplay wiring', () => { component.ngOnDestroy(); }); + + it('installs secondary-device audio guard while player shell is mounted', async () => { + const originalPlay = vi.fn().mockRejectedValue(new Error('original play')); + const mediaPrototype = { play: originalPlay }; + + vi.stubGlobal('window', { + location: { hash: '', search: '' }, + history: { state: null, replaceState: vi.fn() }, + localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() }, + sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }, + HTMLMediaElement: { prototype: mediaPrototype }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); + + const component = new PlayerShellComponent(); + component.ngOnInit(); + + await expect(mediaPrototype.play()).resolves.toBeUndefined(); + + component.ngOnDestroy(); + + await expect(mediaPrototype.play()).rejects.toThrow('original play'); + }); }); diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index 3d4a099..8f0015a 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -113,6 +113,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { private reconnectTimer: ReturnType | null = null; private stateSyncTimer: ReturnType | null = null; private unsubscribeLocale: (() => void) | null = null; + private restoreAudioGuard: (() => void) | null = null; constructor() { if (typeof navigator !== 'undefined' && !navigator.onLine) { @@ -129,6 +130,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.unsubscribeLocale = subscribeToLocaleChanges((locale) => { this.locale = locale; }); + this.installSecondaryDeviceAudioGuard(); const hashRoute = window.location.hash.replace(/^#\/?/, ''); const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i); @@ -158,6 +160,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.clearStateSyncTimer(); this.unsubscribeLocale?.(); this.unsubscribeLocale = null; + this.restoreAudioGuard?.(); + this.restoreAudioGuard = null; } private readonly handleOnline = (): void => { @@ -185,6 +189,25 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } } + private installSecondaryDeviceAudioGuard(): void { + if (!this.clientHasNoAudioOutput || typeof window === 'undefined') { + return; + } + + const mediaPrototype = (window as Window & { HTMLMediaElement?: { prototype?: { play?: () => Promise } } }).HTMLMediaElement + ?.prototype; + + if (!mediaPrototype || typeof mediaPrototype.play !== 'function') { + return; + } + + const originalPlay = mediaPrototype.play; + mediaPrototype.play = () => Promise.resolve(); + this.restoreAudioGuard = () => { + mediaPrototype.play = originalPlay; + }; + } + private scheduleStateSync(): void { this.clearStateSyncTimer(); -- 2.39.5 From ddf8e874e2158df7fce1f8e7f1343b3d1c699858 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 21:54:32 +0000 Subject: [PATCH 2/2] feat(issue-222): wire angular host/player i18n to backend shell locale --- .../app/features/host/host-shell.component.ts | 2 +- .../features/player/player-shell.component.ts | 2 +- frontend/angular/src/app/lobby-i18n.spec.ts | 16 ++++++++++++++++ frontend/angular/src/app/lobby-i18n.ts | 5 ++++- lobby/templates/lobby/spa_shell.html | 6 +++--- lobby/ui_views.py | 3 ++- shared/i18n/lobby.json | 8 ++++++++ 7 files changed, 35 insertions(+), 7 deletions(-) diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index f9d8c72..59087b0 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -55,7 +55,7 @@ type LeaderboardResponse = FinishGameResponse;
{{ scoreboardPayload }}

{{ copy('host.final_leaderboard') }}

-

{{ copy('host.winner') }}: {{ finalWinner.nickname }} ({{ finalWinner.score }} pts)

+

{{ copy('host.winner') }}: {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }})

  1. {{ entry.nickname }}: {{ entry.score }}
diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index 8f0015a..1d5f16c 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -255,7 +255,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { if (error instanceof Error && error.message) { return error.message; } - return 'Unknown error'; + return this.copy('common.unknown_error'); } private markOnline(): void { diff --git a/frontend/angular/src/app/lobby-i18n.spec.ts b/frontend/angular/src/app/lobby-i18n.spec.ts index a9ed37b..d0d17ff 100644 --- a/frontend/angular/src/app/lobby-i18n.spec.ts +++ b/frontend/angular/src/app/lobby-i18n.spec.ts @@ -45,6 +45,22 @@ describe('lobby i18n locale propagation', () => { expect(updates).toEqual(['en', 'da']); }); + it('prefers backend-provided shell locale over browser defaults', async () => { + vi.stubGlobal('window', { + location: { search: '' }, + localStorage: storageMock(), + }); + vi.stubGlobal('document', { + body: { dataset: { wppLocale: 'da' } }, + querySelector: vi.fn(() => null), + }); + vi.stubGlobal('navigator', { language: 'en-US' }); + + const i18n = await import('./lobby-i18n'); + + expect(i18n.resolvePreferredLocale()).toBe('da'); + }); + it('falls back to default en translation when da key is intentionally missing', async () => { vi.stubGlobal('window', { location: { search: '' }, diff --git a/frontend/angular/src/app/lobby-i18n.ts b/frontend/angular/src/app/lobby-i18n.ts index 1637560..9011691 100644 --- a/frontend/angular/src/app/lobby-i18n.ts +++ b/frontend/angular/src/app/lobby-i18n.ts @@ -32,11 +32,14 @@ export function resolvePreferredLocale(): SupportedLocale { return activeLocale; } + const rootLocale = + typeof document !== 'undefined' ? document.querySelector('app-root')?.dataset?.['wppLocale'] : null; + const shellLocale = typeof document !== 'undefined' ? document.body?.dataset?.['wppLocale'] : null; const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang'); const storedLocale = window.localStorage?.getItem?.('wpp.locale'); const browserLocale = typeof navigator !== 'undefined' ? navigator.language : ''; - activeLocale = normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE); + activeLocale = normalizeLocale(rootLocale || shellLocale || queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE); return activeLocale; } diff --git a/lobby/templates/lobby/spa_shell.html b/lobby/templates/lobby/spa_shell.html index 7846c7a..fb51798 100644 --- a/lobby/templates/lobby/spa_shell.html +++ b/lobby/templates/lobby/spa_shell.html @@ -1,13 +1,13 @@ - + WPP SPA Shell - - Indlæser Angular app-shell… + + Indlæser Angular app-shell… diff --git a/lobby/ui_views.py b/lobby/ui_views.py index c7531d9..c1d2fcb 100644 --- a/lobby/ui_views.py +++ b/lobby/ui_views.py @@ -5,7 +5,7 @@ from django.shortcuts import render from fupogfakta.models import Category from .feature_flags import use_spa_ui -from .i18n import lobby_i18n_catalog +from .i18n import lobby_i18n_catalog, resolve_locale def _render_spa_shell(request, shell_route: str, shell_kind: str): @@ -18,6 +18,7 @@ def _render_spa_shell(request, shell_route: str, shell_kind: str): "spa_asset_base": settings.WPP_SPA_ASSET_BASE, "spa_asset_version": getattr(settings, "WPP_SPA_ASSET_VERSION", "dev"), "lobby_i18n": lobby_i18n_catalog(), + "shell_locale": resolve_locale(request), }, ) diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json index c02c50c..2fb4e32 100644 --- a/shared/i18n/lobby.json +++ b/shared/i18n/lobby.json @@ -74,6 +74,14 @@ "round": { "en": "round", "da": "runde" + }, + "points_short": { + "en": "pts", + "da": "point" + }, + "unknown_error": { + "en": "Unknown error", + "da": "Ukendt fejl" } }, "app": { -- 2.39.5