589 lines
19 KiB
TypeScript
589 lines
19 KiB
TypeScript
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<void>;
|
|
__wppSecondaryDeviceAudioGuard__?: {
|
|
originalPlay: (this: GuardableMediaElement) => Promise<void>;
|
|
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: `
|
|
<h2>{{ copy('player.title') }}</h2>
|
|
<p *ngIf="clientHasNoAudioOutput" class="hint">{{ copy('player.audio_policy_notice') }}</p>
|
|
|
|
<div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput">
|
|
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
|
|
<label *ngIf="showJoinControls">{{ copy('player.nickname') }} <input [(ngModel)]="nickname" /></label>
|
|
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
|
|
<button *ngIf="showJoinControls" (click)="joinSession()" [disabled]="loading">{{ copy('player.join') }}</button>
|
|
</div>
|
|
|
|
<p *ngIf="connectionState === 'reconnecting'" class="error">
|
|
{{ copy('player.reconnecting_text') }}
|
|
<button type="button" (click)="retryReconnect()" [disabled]="loading">{{ copy('player.retry_now') }}</button>
|
|
<button type="button" (click)="returnToJoin()" [disabled]="loading">{{ copy('common.back_to_join') }}</button>
|
|
</p>
|
|
<p *ngIf="connectionState === 'offline'" class="error">
|
|
{{ copy('player.offline_text') }}
|
|
<button type="button" (click)="retryReconnect()" [disabled]="loading">{{ copy('player.retry_now') }}</button>
|
|
<button type="button" (click)="returnToJoin()" [disabled]="loading">{{ copy('common.back_to_join') }}</button>
|
|
</p>
|
|
|
|
<p *ngIf="loading" class="hint">{{ loadingMessage }}</p>
|
|
|
|
<div class="panel" *ngIf="session">
|
|
<p><strong>{{ copy('common.status') }}:</strong> {{ session.session.status }}</p>
|
|
<p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p>
|
|
|
|
<ng-container *ngIf="showLieControls">
|
|
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading" /></label>
|
|
<button (click)="submitLie()" [disabled]="loading">{{ copy('player.submit_lie') }}</button>
|
|
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">{{ copy('player.retry_lie_submit') }}</button>
|
|
</ng-container>
|
|
|
|
<ng-container *ngIf="showGuessControls">
|
|
<div class="answers" *ngIf="session.round_question?.answers?.length">
|
|
<button
|
|
type="button"
|
|
*ngFor="let answer of session.round_question?.answers"
|
|
(click)="selectedGuess = answer.text"
|
|
[class.active]="selectedGuess === answer.text"
|
|
[disabled]="loading"
|
|
>
|
|
{{ answer.text }}
|
|
</button>
|
|
</div>
|
|
|
|
<button (click)="submitGuess()" [disabled]="loading || !selectedGuess">{{ copy('player.submit_guess') }}</button>
|
|
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
|
|
</ng-container>
|
|
|
|
<div *ngIf="showFinalLeaderboard && finalLeaderboard.length">
|
|
<h3>{{ copy('player.final_leaderboard') }}</h3>
|
|
<ol>
|
|
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
|
|
<p *ngIf="error" class="error">{{ error }}</p>
|
|
<p *ngIf="submitError" class="error">{{ submitError.message }}</p>
|
|
|
|
<div class="panel" *ngIf="error || submitError">
|
|
<button type="button" (click)="retryReconnect()" [disabled]="loading">{{ copy('common.retry') }}</button>
|
|
<button type="button" (click)="returnToJoin()" [disabled]="loading">{{ copy('common.back_to_join') }}</button>
|
|
</div>
|
|
`,
|
|
})
|
|
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<typeof setTimeout> | null = null;
|
|
private stateSyncTimer: ReturnType<typeof setTimeout> | 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<void> {
|
|
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>
|
|
| 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<void> {
|
|
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<T>(path: string, method: 'GET' | 'POST', payload?: unknown): Promise<T> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|
|
}
|