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;
{{ copy('host.final_leaderboard') }}
-
{{ copy('host.winner') }}: {{ finalWinner.nickname }} ({{ finalWinner.score }} pts)
+
{{ copy('host.winner') }}: {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }})
- {{ entry.nickname }}: {{ entry.score }}
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..1d5f16c 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();
@@ -232,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": {