import { CommonModule } from '@angular/common'; import { Component, OnDestroy, OnInit } from '@angular/core'; 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, subscribeToLocaleChanges, t } from '../../lobby-i18n'; interface SessionDetail { session: { code: string; status: string; current_round: number }; round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null; players: Array<{ id: number; nickname: string; score: number }>; phase_view_model?: { player?: { can_join?: boolean; can_submit_lie?: boolean; can_submit_guess?: boolean; can_view_final_result?: boolean; }; }; } type ConnectionState = 'online' | 'reconnecting' | 'offline'; type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null; type GuardableMediaElement = { muted?: boolean; defaultMuted?: boolean; volume?: number; pause?: () => void; }; type MediaPrototypeWithGuardState = { play?: (this: GuardableMediaElement) => Promise; __wppSecondaryDeviceAudioGuard__?: { originalPlay: (this: GuardableMediaElement) => Promise; installs: number; }; }; function resolveLocalStorage(): Storage | undefined { if (typeof window === 'undefined') { return undefined; } return window.localStorage; } @Component({ selector: 'app-player-shell', standalone: true, imports: [CommonModule, FormsModule], template: `

{{ copy('player.title') }}

{{ copy('player.audio_policy_notice') }}

{{ copy('player.reconnecting_text') }}

{{ copy('player.offline_text') }}

{{ loadingMessage }}

{{ copy('common.status') }}: {{ session.session.status }}

{{ copy('common.prompt') }}: {{ session.round_question.prompt }}

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

  1. {{ entry.nickname }}: {{ entry.score }}

{{ error }}

{{ submitError.message }}

`, }) export class PlayerShellComponent implements OnInit, OnDestroy { locale = resolvePreferredLocale(); readonly clientHasNoAudioOutput = clientHasNoAudioOutput; sessionCode = ''; nickname = ''; playerId = 0; sessionToken = ''; lieText = ''; selectedGuess = ''; loading = false; error = ''; submitError: { kind: 'lie' | 'guess'; message: string } | null = null; session: SessionDetail | null = null; finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = []; connectionState: ConnectionState = 'online'; loadingTransition: LoadingTransition = null; private readonly sessionContextStore = createSessionContextStore(resolveLocalStorage()); private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); 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) { this.connectionState = 'offline'; } if (typeof window !== 'undefined') { window.addEventListener('online', this.handleOnline); window.addEventListener('offline', this.handleOffline); } } ngOnInit(): void { this.unsubscribeLocale = subscribeToLocaleChanges((locale) => { this.locale = locale; }); this.installSecondaryDeviceAudioGuard(); const hashRoute = window.location.hash.replace(/^#\/?/, ''); const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i); const codeFromRoute = match?.[1] ?? ''; const persistedContext = this.sessionContextStore.get(); if (persistedContext) { this.playerId = persistedContext.playerId; this.sessionToken = persistedContext.token; } const candidate = codeFromRoute || persistedContext?.sessionCode || ''; if (!candidate) { return; } this.sessionCode = this.normalizeCode(candidate); void this.refreshSession(); } ngOnDestroy(): void { if (typeof window !== 'undefined') { window.removeEventListener('online', this.handleOnline); window.removeEventListener('offline', this.handleOffline); } this.clearReconnectTimer(); this.clearStateSyncTimer(); this.unsubscribeLocale?.(); this.unsubscribeLocale = null; this.restoreAudioGuard?.(); this.restoreAudioGuard = null; } private readonly handleOnline = (): void => { this.connectionState = 'reconnecting'; void this.retryReconnect(); }; private readonly handleOffline = (): void => { this.connectionState = 'offline'; this.clearReconnectTimer(); this.clearStateSyncTimer(); }; private clearReconnectTimer(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } private clearStateSyncTimer(): void { if (this.stateSyncTimer) { clearTimeout(this.stateSyncTimer); this.stateSyncTimer = null; } } private installSecondaryDeviceAudioGuard(): void { if (!this.clientHasNoAudioOutput || typeof window === 'undefined') { return; } this.silenceExistingMediaElements(); const mediaPrototype = (window as Window & { HTMLMediaElement?: { prototype?: MediaPrototypeWithGuardState } }).HTMLMediaElement ?.prototype; if (!mediaPrototype || typeof mediaPrototype.play !== 'function') { return; } const guardState = mediaPrototype.__wppSecondaryDeviceAudioGuard__; if (guardState) { guardState.installs += 1; } else { const originalPlay = mediaPrototype.play; mediaPrototype.play = function mediaGuardedPlay(this: GuardableMediaElement): Promise { this.muted = true; this.defaultMuted = true; if (typeof this.volume === 'number') { this.volume = 0; } this.pause?.(); return Promise.resolve(); }; mediaPrototype.__wppSecondaryDeviceAudioGuard__ = { originalPlay, installs: 1, }; } this.restoreAudioGuard = () => { const currentState = mediaPrototype.__wppSecondaryDeviceAudioGuard__; if (!currentState) { return; } currentState.installs -= 1; if (currentState.installs <= 0) { mediaPrototype.play = currentState.originalPlay; delete mediaPrototype.__wppSecondaryDeviceAudioGuard__; } }; } private silenceExistingMediaElements(): void { if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') { return; } const activeElements = document.querySelectorAll('audio,video') as | NodeListOf | GuardableMediaElement[] | undefined; if (!activeElements || typeof (activeElements as { forEach?: unknown }).forEach !== 'function') { return; } activeElements.forEach((element) => { element.muted = true; element.defaultMuted = true; if (typeof element.volume === 'number') { element.volume = 0; } element.pause?.(); }); } private scheduleStateSync(): void { this.clearStateSyncTimer(); if (!this.sessionCode.trim() || this.connectionState !== 'online' || !this.session) { return; } if (this.session.session.status === 'finished') { return; } this.stateSyncTimer = setTimeout(() => { this.stateSyncTimer = null; if (this.loading || this.connectionState !== 'online') { this.scheduleStateSync(); return; } void this.refreshSession(); }, 3000); } get showJoinControls(): boolean { if (!this.session) { return true; } return Boolean(this.session?.phase_view_model?.player?.can_join && !this.playerId && !this.sessionToken); } get showLieControls(): boolean { return Boolean(this.session?.phase_view_model?.player?.can_submit_lie); } get showGuessControls(): boolean { return Boolean(this.session?.phase_view_model?.player?.can_submit_guess); } get showFinalLeaderboard(): boolean { return Boolean(this.session?.phase_view_model?.player?.can_view_final_result); } get loadingMessage(): string { switch (this.loadingTransition) { case 'join': return this.copy('player.loading_join'); case 'submit-lie': return this.copy('player.loading_submit_lie'); case 'submit-guess': return this.copy('player.loading_submit_guess'); case 'refresh': default: return this.copy('player.loading_refresh'); } } copy(key: string): string { return t(key, this.locale); } private normalizeCode(value: string): string { return value.trim().toUpperCase(); } private toMessage(error: unknown): string { if (error instanceof Error && error.message) { return error.message; } return this.copy('common.unknown_error'); } private markOnline(): void { this.connectionState = 'online'; this.clearReconnectTimer(); this.scheduleStateSync(); } private markConnectionIssue(error: unknown): void { this.clearStateSyncTimer(); if (typeof navigator !== 'undefined' && !navigator.onLine) { this.connectionState = 'offline'; return; } const message = this.toMessage(error).toLowerCase(); if ( message.includes('fetch') || message.includes('network') || message.includes('failed to') || message.includes('could not load lobby status') || message.includes('session refresh failed') ) { this.connectionState = 'reconnecting'; this.scheduleReconnect(); } } private scheduleReconnect(): void { if (this.reconnectTimer || !this.sessionCode.trim()) { return; } this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; void this.retryReconnect(); }, 2000); } async retryReconnect(): Promise { if (!this.sessionCode.trim() || this.loading) { return; } await this.refreshSession(); } returnToJoin(): void { this.loadingTransition = null; this.clearReconnectTimer(); this.clearStateSyncTimer(); this.connectionState = typeof navigator !== 'undefined' && !navigator.onLine ? 'offline' : 'online'; this.session = null; this.finalLeaderboard = []; this.selectedGuess = ''; this.lieText = ''; this.submitError = null; this.error = ''; this.playerId = 0; this.sessionToken = ''; this.sessionContextStore.clear(); } private syncFinalLeaderboard(): void { if (!this.session || this.session.session.status !== 'finished') { this.finalLeaderboard = []; return; } this.finalLeaderboard = [...this.session.players].sort((a, b) => { if (b.score !== a.score) { return b.score - a.score; } return a.nickname.localeCompare(b.nickname); }); } private syncRouteFromSession(): void { if (!this.session) { return; } const phase = this.session.session.status || 'lobby'; const code = this.normalizeCode(this.session.session.code || this.sessionCode); if (!code) { return; } const targetPath = `#/player/${encodeURIComponent(phase)}/${encodeURIComponent(code)}`; if (typeof window === 'undefined' || window.location.hash === targetPath) { return; } window.history.replaceState(window.history.state, '', targetPath); } private async request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise { const response = await fetch(path, { method, headers: { Accept: 'application/json', ...(payload === undefined ? {} : { 'Content-Type': 'application/json' }), }, ...(payload === undefined ? {} : { body: JSON.stringify(payload) }), credentials: 'same-origin', }); const body = await response.json().catch(() => ({})); if (!response.ok) { throw new Error((body as { error?: string }).error ?? `HTTP ${response.status}`); } return body as T; } async refreshSession(): Promise { this.loading = true; this.loadingTransition = 'refresh'; this.error = ''; try { const state = await this.controller.hydrateLobby(this.sessionCode); if (!state.session || state.errorMessage) { throw new Error(state.errorMessage ?? this.copy('common.unknown_error')); } this.session = state.session as SessionDetail; this.sessionCode = this.session.session.code; if (this.session.session.status !== 'guess') { this.selectedGuess = ''; } this.syncFinalLeaderboard(); this.syncRouteFromSession(); this.markOnline(); } catch (error) { this.error = `${this.copy('player.session_refresh_failed')}: ${this.toMessage(error)}`; this.markConnectionIssue(error); } finally { this.loading = false; this.loadingTransition = null; } } async joinSession(): Promise { this.loading = true; this.loadingTransition = 'join'; this.error = ''; try { const state = await this.controller.joinLobby(this.sessionCode, this.nickname); if (!state.session || state.errorMessage) { throw new Error(state.errorMessage ?? this.copy('common.unknown_error')); } this.session = state.session as SessionDetail; this.sessionCode = this.session.session.code; const sessionContext = this.sessionContextStore.get(); this.playerId = sessionContext?.playerId ?? 0; this.sessionToken = sessionContext?.token ?? ''; if (this.session.session.status !== 'guess') { this.selectedGuess = ''; } this.syncFinalLeaderboard(); this.syncRouteFromSession(); this.markOnline(); } catch (error) { this.error = `${this.copy('player.join_failed')}: ${this.toMessage(error)}`; this.markConnectionIssue(error); } finally { this.loading = false; this.loadingTransition = null; } } async submitLie(): Promise { if (!this.session?.round_question?.id) { return; } this.loading = true; this.loadingTransition = 'submit-lie'; this.submitError = null; try { await this.request( `/lobby/sessions/${encodeURIComponent(this.normalizeCode(this.sessionCode))}/questions/${this.session.round_question.id}/lies/submit`, 'POST', { player_id: this.playerId, session_token: this.sessionToken, text: this.lieText, } ); await this.refreshSession(); this.markOnline(); } catch (error) { this.submitError = { kind: 'lie', message: `${this.copy('player.lie_submit_failed')}: ${this.toMessage(error)}` }; this.markConnectionIssue(error); } finally { this.loading = false; this.loadingTransition = null; } } async submitGuess(): Promise { if (!this.session?.round_question?.id || !this.selectedGuess) { return; } this.loading = true; this.loadingTransition = 'submit-guess'; this.submitError = null; try { await this.request( `/lobby/sessions/${encodeURIComponent(this.normalizeCode(this.sessionCode))}/questions/${this.session.round_question.id}/guesses/submit`, 'POST', { player_id: this.playerId, session_token: this.sessionToken, selected_text: this.selectedGuess, } ); await this.refreshSession(); this.markOnline(); } catch (error) { this.submitError = { kind: 'guess', message: `${this.copy('player.guess_submit_failed')}: ${this.toMessage(error)}` }; this.markConnectionIssue(error); } finally { this.loading = false; this.loadingTransition = null; } } }