[READY][i18n][P18] Angular host+player i18n binding med simpel telefon-UX og nul client-audio #211

Merged
integrator-bot merged 2 commits from dev/issue-206-angular-i18n-phone-ux-no-audio into main 2026-03-01 20:38:37 +01:00
4 changed files with 92 additions and 6 deletions
Showing only changes of commit f3bd071322 - Show all commits

View File

@@ -1,11 +1,11 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client';
import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, t } from '../../lobby-i18n';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
interface SessionDetail {
session: { code: string; status: string; current_round: number };
@@ -64,7 +64,7 @@ type LeaderboardResponse = FinishGameResponse;
</div>
`,
})
export class HostShellComponent implements OnInit {
export class HostShellComponent implements OnInit, OnDestroy {
locale = resolvePreferredLocale();
readonly clientHasNoAudioOutput = clientHasNoAudioOutput;
@@ -84,8 +84,12 @@ export class HostShellComponent implements OnInit {
private readonly api = createApiClient();
private readonly controller = createVerticalSliceController(this.api);
private unsubscribeLocale: (() => void) | null = null;
ngOnInit(): void {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale;
});
if (typeof window === 'undefined') {
return;
}
@@ -105,6 +109,11 @@ export class HostShellComponent implements OnInit {
void this.refreshSession();
}
ngOnDestroy(): void {
this.unsubscribeLocale?.();
this.unsubscribeLocale = null;
}
copy(key: string): string {
return t(key, this.locale);
}

View File

@@ -5,7 +5,7 @@ import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, t } from '../../lobby-i18n';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
interface SessionDetail {
session: { code: string; status: string; current_round: number };
@@ -112,6 +112,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeLocale: (() => void) | null = null;
constructor() {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
@@ -125,6 +126,10 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale;
});
const hashRoute = window.location.hash.replace(/^#\/?/, '');
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
const codeFromRoute = match?.[1] ?? '';
@@ -151,6 +156,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
this.clearReconnectTimer();
this.clearStateSyncTimer();
this.unsubscribeLocale?.();
this.unsubscribeLocale = null;
}
private readonly handleOnline = (): void => {

View File

@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
type StorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
};
function storageMock(initial: Record<string, string> = {}): StorageLike {
const data = new Map<string, string>(Object.entries(initial));
return {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
data.set(key, value);
}),
};
}
describe('lobby i18n locale propagation', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.resetModules();
});
it('notifies subscribers immediately and on locale changes', async () => {
const localStorage = storageMock({ 'wpp.locale': 'en' });
vi.stubGlobal('window', {
location: { search: '' },
localStorage,
});
vi.stubGlobal('navigator', { language: 'en-US' });
const i18n = await import('./lobby-i18n');
const updates: string[] = [];
const unsubscribe = i18n.subscribeToLocaleChanges((locale) => updates.push(locale));
expect(updates).toEqual(['en']);
i18n.setPreferredLocale('da');
expect(updates).toEqual(['en', 'da']);
unsubscribe();
i18n.setPreferredLocale('en');
expect(updates).toEqual(['en', 'da']);
});
});

View File

@@ -5,6 +5,9 @@ type SupportedLocale = (typeof lobbyCatalog.locales.supported)[number];
const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
let activeLocale: SupportedLocale | null = null;
const localeSubscribers = new Set<(locale: SupportedLocale) => void>();
export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
const locale = (rawLocale ?? '').trim().toLowerCase();
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
@@ -20,25 +23,45 @@ export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
}
export function resolvePreferredLocale(): SupportedLocale {
if (activeLocale) {
return activeLocale;
}
if (typeof window === 'undefined') {
return DEFAULT_LOCALE;
activeLocale = DEFAULT_LOCALE;
return activeLocale;
}
const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang');
const storedLocale = window.localStorage?.getItem?.('wpp.locale');
const browserLocale = typeof navigator !== 'undefined' ? navigator.language : '';
return normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
activeLocale = normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
return activeLocale;
}
export function setPreferredLocale(locale: string): SupportedLocale {
const normalized = normalizeLocale(locale);
activeLocale = normalized;
if (typeof window !== 'undefined') {
window.localStorage?.setItem?.('wpp.locale', normalized);
}
for (const subscriber of localeSubscribers) {
subscriber(normalized);
}
return normalized;
}
export function subscribeToLocaleChanges(callback: (locale: SupportedLocale) => void): () => void {
localeSubscribers.add(callback);
callback(resolvePreferredLocale());
return () => {
localeSubscribers.delete(callback);
};
}
export function t(key: string, locale: string): string {
const normalizedLocale = normalizeLocale(locale);
const fallbackLocale = DEFAULT_LOCALE;