refactor: extract session/settings flows and tighten shifted key commands

This commit is contained in:
Jage9
2026-02-22 17:33:31 -05:00
parent 48fd90023e
commit 89c88deb87
9 changed files with 755 additions and 400 deletions

View File

@@ -0,0 +1,162 @@
import type { GameState } from '../state/gameState';
type DomRefs = {
preconnectNickname: HTMLInputElement;
nicknameContainer: HTMLDivElement;
connectButton: HTMLButtonElement;
disconnectButton: HTMLButtonElement;
focusGridButton: HTMLButtonElement;
canvas: HTMLCanvasElement;
instructions: HTMLDivElement;
audioInputSelect: HTMLSelectElement;
};
export type ConnectFlowDeps = {
state: GameState;
dom: DomRefs;
sanitizeName: (value: string) => string;
updateStatus: (message: string) => void;
updateConnectAvailability: () => void;
settingsSaveNickname: (value: string) => void;
mediaIsConnecting: () => boolean;
mediaSetConnecting: (value: boolean) => void;
mediaCheckMicPermission: () => Promise<boolean>;
mediaPopulateAudioDevices: () => Promise<void>;
mediaGetPreferredInputDeviceId: () => string;
mediaSetupLocalMedia: (audioDeviceId: string) => Promise<void>;
mediaDescribeError: (error: unknown) => string;
mediaStopLocalMedia: () => void;
signalingConnect: (onMessage: (message: unknown) => Promise<void>) => Promise<void>;
signalingDisconnect: () => void;
onMessage: (message: unknown) => Promise<void>;
worldGridSize: number;
persistPlayerPosition: () => void;
peerManagerCleanupAll: () => void;
radioCleanupAll: () => void;
emitCleanupAll: () => void;
playLogoutSound: () => void;
};
/**
* Runs connect flow: validate nickname, preflight mic/device setup, then signaling connect.
*/
export async function runConnectFlow(deps: ConnectFlowDeps): Promise<void> {
if (deps.mediaIsConnecting() || deps.state.running) {
return;
}
const nickname = deps.sanitizeName(deps.dom.preconnectNickname.value);
if (!nickname) {
deps.updateStatus('Nickname is required.');
deps.updateConnectAvailability();
return;
}
deps.state.player.nickname = nickname;
deps.dom.preconnectNickname.value = nickname;
deps.settingsSaveNickname(nickname);
deps.mediaSetConnecting(true);
deps.updateConnectAvailability();
const canProceed = await deps.mediaCheckMicPermission();
if (!canProceed) {
deps.updateStatus('Microphone access is required.');
deps.mediaSetConnecting(false);
deps.updateConnectAvailability();
return;
}
deps.state.player.x = Math.floor(Math.random() * deps.worldGridSize);
deps.state.player.y = Math.floor(Math.random() * deps.worldGridSize);
const storedPosition = localStorage.getItem('spatialChatPosition');
if (storedPosition) {
try {
const parsed = JSON.parse(storedPosition) as { x?: number; y?: number };
if (Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) {
const x = Math.floor(parsed.x as number);
const y = Math.floor(parsed.y as number);
if (x >= 0 && x < deps.worldGridSize && y >= 0 && y < deps.worldGridSize) {
deps.state.player.x = x;
deps.state.player.y = y;
}
}
} catch {
// Ignore malformed saved positions.
}
}
try {
await deps.mediaPopulateAudioDevices();
if (deps.dom.audioInputSelect.options.length === 0) {
deps.updateStatus('No audio input device found. Open Settings or connect a microphone.');
deps.mediaSetConnecting(false);
deps.updateConnectAvailability();
return;
}
const inputDeviceId = deps.dom.audioInputSelect.value || deps.mediaGetPreferredInputDeviceId();
await deps.mediaSetupLocalMedia(inputDeviceId);
} catch (error) {
console.error(error);
deps.updateStatus(deps.mediaDescribeError(error));
deps.mediaSetConnecting(false);
deps.updateConnectAvailability();
return;
}
try {
await deps.signalingConnect(deps.onMessage);
} catch (error) {
console.error(error);
deps.mediaStopLocalMedia();
deps.updateStatus('Connect failed. Signaling server may be offline or unreachable.');
deps.mediaSetConnecting(false);
deps.updateConnectAvailability();
}
}
/**
* Runs disconnect flow and resets client runtime state back to pre-connect UI.
*/
export function runDisconnectFlow(deps: ConnectFlowDeps): void {
const wasRunning = deps.state.running;
if (deps.state.running) {
deps.persistPlayerPosition();
}
deps.signalingDisconnect();
deps.mediaStopLocalMedia();
deps.peerManagerCleanupAll();
deps.radioCleanupAll();
deps.emitCleanupAll();
deps.state.running = false;
deps.state.keysPressed = {};
deps.state.peers.clear();
deps.state.items.clear();
deps.state.carriedItemId = null;
deps.state.mode = 'normal';
deps.state.sortedItemIds = [];
deps.state.itemListIndex = 0;
deps.state.selectedItemIds = [];
deps.state.selectionContext = null;
deps.state.selectedItemIndex = 0;
deps.state.selectedItemId = null;
deps.state.itemPropertyKeys = [];
deps.state.itemPropertyIndex = 0;
deps.state.editingPropertyKey = null;
deps.state.itemPropertyOptionValues = [];
deps.state.itemPropertyOptionIndex = 0;
deps.state.effectSelectIndex = 0;
deps.mediaSetConnecting(false);
deps.dom.nicknameContainer.classList.remove('hidden');
deps.dom.connectButton.classList.remove('hidden');
deps.dom.disconnectButton.classList.add('hidden');
deps.dom.focusGridButton.classList.add('hidden');
deps.dom.canvas.classList.add('hidden');
deps.dom.instructions.classList.add('hidden');
deps.updateConnectAvailability();
deps.updateStatus('Disconnected.');
if (wasRunning) {
deps.playLogoutSound();
}
}

View File

@@ -0,0 +1,324 @@
import { type GameState } from '../state/gameState';
import { AudioEngine } from '../audio/audioEngine';
import { PeerManager } from '../webrtc/peerManager';
import { SettingsStore } from '../settings/settingsStore';
import { formatSteppedNumber, snapNumberToStep } from '../input/numeric';
type DeviceDom = {
audioInputSelect: HTMLSelectElement;
audioOutputSelect: HTMLSelectElement;
audioInputCurrent: HTMLParagraphElement;
audioOutputCurrent: HTMLParagraphElement;
};
type SessionOptions = {
state: GameState;
audio: AudioEngine;
peerManager: PeerManager;
settings: SettingsStore;
dom: DeviceDom;
updateStatus: (message: string) => void;
micCalibrationDurationMs: number;
micCalibrationSampleIntervalMs: number;
micCalibrationMinGain: number;
micCalibrationMaxGain: number;
micCalibrationTargetRms: number;
micCalibrationActiveRmsThreshold: number;
micInputGainScaleMultiplier: number;
micInputGainStep: number;
};
/**
* Owns browser media/session lifecycle state and related device preference handling.
*/
export class MediaSession {
private localStream: MediaStream | null = null;
private outboundStream: MediaStream | null = null;
private connecting = false;
private calibratingMicInput = false;
private preferredInputDeviceId: string;
private preferredOutputDeviceId: string;
private preferredInputDeviceName: string;
private preferredOutputDeviceName: string;
constructor(private readonly options: SessionOptions) {
const prefs = this.options.settings.loadAudioDevicePreferences();
this.preferredInputDeviceId = prefs.input.id;
this.preferredOutputDeviceId = prefs.output.id;
this.preferredInputDeviceName = prefs.input.name;
this.preferredOutputDeviceName = prefs.output.name;
}
/** Returns the current outbound stream used for peer send tracks. */
getOutboundStream(): MediaStream | null {
return this.outboundStream;
}
/** Returns whether a connect flow is currently in progress. */
isConnecting(): boolean {
return this.connecting;
}
/** Sets connecting flag for external message handlers. */
setConnecting(value: boolean): void {
this.connecting = value;
}
/** Returns stored preferred input device id, if any. */
getPreferredInputDeviceId(): string {
return this.preferredInputDeviceId;
}
/** Returns browser-selected audio output mode from persisted settings. */
loadOutputMode(): 'mono' | 'stereo' {
return this.options.settings.loadOutputMode();
}
/** Persists audio output mode selection. */
saveOutputMode(value: 'mono' | 'stereo'): void {
this.options.settings.saveOutputMode(value);
}
/** Updates stored preferred input device and persists it. */
setPreferredInput(id: string, name: string): void {
this.preferredInputDeviceId = id;
this.preferredInputDeviceName = name || this.preferredInputDeviceName;
this.options.settings.savePreferredInput(this.preferredInputDeviceId, this.preferredInputDeviceName);
}
/** Updates stored preferred output device and persists it. */
setPreferredOutput(id: string, name: string): void {
this.preferredOutputDeviceId = id;
this.preferredOutputDeviceName = name || this.preferredOutputDeviceName;
this.options.settings.savePreferredOutput(this.preferredOutputDeviceId, this.preferredOutputDeviceName);
}
/** Applies saved device labels to pre-connect status summary rows. */
updateDeviceSummary(): void {
const { dom } = this.options;
if (this.preferredInputDeviceId) {
const text = dom.audioInputSelect.selectedOptions[0]?.text || this.preferredInputDeviceName || 'Saved microphone';
dom.audioInputCurrent.textContent = `Input: ${text}`;
dom.audioInputCurrent.classList.remove('hidden');
} else {
dom.audioInputCurrent.classList.add('hidden');
}
if (this.preferredOutputDeviceId) {
const text = dom.audioOutputSelect.selectedOptions[0]?.text || this.preferredOutputDeviceName || 'Saved speakers';
dom.audioOutputCurrent.textContent = `Output: ${text}`;
dom.audioOutputCurrent.classList.remove('hidden');
} else {
dom.audioOutputCurrent.classList.add('hidden');
}
}
/** Enumerates audio input/output devices and restores saved choices where possible. */
async populateAudioDevices(): Promise<void> {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
}
let temporaryStream: MediaStream | null = null;
try {
temporaryStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const devices = await navigator.mediaDevices.enumerateDevices();
const { dom } = this.options;
dom.audioInputSelect.innerHTML = '';
dom.audioOutputSelect.innerHTML = '';
for (const device of devices) {
if (device.kind === 'audioinput') {
dom.audioInputSelect.add(new Option(device.label || `Microphone ${dom.audioInputSelect.length + 1}`, device.deviceId));
}
if (device.kind === 'audiooutput') {
dom.audioOutputSelect.add(new Option(device.label || `Speaker ${dom.audioOutputSelect.length + 1}`, device.deviceId));
}
}
if (this.preferredInputDeviceId && Array.from(dom.audioInputSelect.options).some((option) => option.value === this.preferredInputDeviceId)) {
dom.audioInputSelect.value = this.preferredInputDeviceId;
this.preferredInputDeviceName = dom.audioInputSelect.selectedOptions[0]?.text || this.preferredInputDeviceName;
} else if (dom.audioInputSelect.options.length > 0) {
this.preferredInputDeviceId = dom.audioInputSelect.value;
this.preferredInputDeviceName = dom.audioInputSelect.selectedOptions[0]?.text || this.preferredInputDeviceName;
this.options.settings.savePreferredInput(this.preferredInputDeviceId, this.preferredInputDeviceName);
}
if (this.preferredOutputDeviceId && Array.from(dom.audioOutputSelect.options).some((option) => option.value === this.preferredOutputDeviceId)) {
dom.audioOutputSelect.value = this.preferredOutputDeviceId;
this.preferredOutputDeviceName = dom.audioOutputSelect.selectedOptions[0]?.text || this.preferredOutputDeviceName;
void this.options.peerManager.setOutputDevice(this.preferredOutputDeviceId);
}
const sinkCapable = typeof (HTMLMediaElement.prototype as HTMLMediaElement & { setSinkId?: unknown }).setSinkId === 'function';
dom.audioOutputSelect.disabled = !sinkCapable;
this.updateDeviceSummary();
} catch {
this.options.updateStatus('Could not list devices.');
} finally {
temporaryStream?.getTracks().forEach((track) => track.stop());
}
}
/** Returns true when microphone permission is available or cannot be preflight-checked. */
async checkMicPermission(): Promise<boolean> {
const permissionApi = navigator.permissions;
if (!permissionApi?.query) return true;
try {
const result = await permissionApi.query({ name: 'microphone' as PermissionName });
return result.state !== 'denied';
} catch {
return true;
}
}
/** Maps capture/setup exceptions to user-facing text. */
describeMediaError(error: unknown): string {
if (error instanceof DOMException) {
if (error.name === 'NotAllowedError') return 'Microphone blocked. Allow mic access in browser site settings.';
if (error.name === 'NotFoundError') return 'No microphone found. Check that an input device is connected and enabled.';
if (error.name === 'NotReadableError') return 'Microphone is busy or unavailable. Close other apps using the mic and retry.';
if (error.name === 'OverconstrainedError') return 'Selected audio device is unavailable. Choose another input device.';
if (error.name === 'SecurityError') return 'Microphone access requires a secure context (HTTPS) in production.';
}
return 'Audio setup failed. Check browser permissions and selected input device.';
}
/** Starts local capture and replaces outbound peer tracks. */
async setupLocalMedia(audioDeviceId = ''): Promise<void> {
this.stopLocalMedia();
await this.options.audio.ensureContext();
const constraints: MediaStreamConstraints = {
audio: {
deviceId: audioDeviceId ? { exact: audioDeviceId } : undefined,
sampleRate: 48000,
channelCount: 2,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
video: false,
};
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
const audioTrack = this.localStream.getAudioTracks()[0];
if (audioTrack) {
audioTrack.enabled = !this.options.state.isMuted;
}
this.outboundStream = await this.options.audio.configureOutboundStream(this.localStream);
await this.options.peerManager.replaceOutgoingTrack(this.outboundStream);
}
/** Stops local media tracks and clears outbound references. */
stopLocalMedia(): void {
if (this.localStream) {
this.localStream.getTracks().forEach((track) => track.stop());
this.localStream = null;
}
this.outboundStream = null;
}
/** Applies mute state to active local track when present. */
applyMuteToTrack(isMuted: boolean): void {
if (!this.localStream) return;
const track = this.localStream.getAudioTracks()[0];
if (track) {
track.enabled = !isMuted;
}
}
/** Calibrates mic gain from a short speech sample and persists applied value. */
async calibrateMicInputGain(
clampMicInputGain: (value: number) => number,
persistMicInputGain: (value: number) => void,
): Promise<void> {
const {
updateStatus,
audio,
micCalibrationDurationMs,
micCalibrationSampleIntervalMs,
micCalibrationActiveRmsThreshold,
micCalibrationTargetRms,
micInputGainScaleMultiplier,
micInputGainStep,
micCalibrationMinGain,
} = this.options;
if (this.calibratingMicInput) {
updateStatus('Mic calibration already running.');
return;
}
if (!this.options.state.running || !this.localStream) {
updateStatus('Connect first, then use Shift+C to calibrate.');
audio.sfxUiCancel();
return;
}
const track = this.localStream.getAudioTracks()[0];
if (!track || track.readyState !== 'live') {
updateStatus('No active microphone track for calibration.');
audio.sfxUiCancel();
return;
}
await audio.ensureContext();
const audioContext = audio.context;
if (!audioContext) {
updateStatus('Audio context unavailable.');
audio.sfxUiCancel();
return;
}
this.calibratingMicInput = true;
updateStatus('Speak for 5 seconds to calibrate your audio.');
audio.sfxUiBlip();
const source = audioContext.createMediaStreamSource(new MediaStream([track]));
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.2;
source.connect(analyser);
const samples = new Float32Array(analyser.fftSize);
const rmsValues: number[] = [];
try {
const startedAt = performance.now();
while (performance.now() - startedAt < micCalibrationDurationMs) {
analyser.getFloatTimeDomainData(samples);
let sumSquares = 0;
for (let i = 0; i < samples.length; i += 1) {
const sample = samples[i];
sumSquares += sample * sample;
}
rmsValues.push(Math.sqrt(sumSquares / samples.length));
await new Promise((resolve) => window.setTimeout(resolve, micCalibrationSampleIntervalMs));
}
} finally {
source.disconnect();
analyser.disconnect();
this.calibratingMicInput = false;
}
const activeRms = rmsValues.filter((value) => value >= micCalibrationActiveRmsThreshold);
if (activeRms.length < 10) {
updateStatus('No audio detected, please try again.');
audio.sfxUiCancel();
return;
}
activeRms.sort((a, b) => a - b);
const percentileIndex = Math.min(activeRms.length - 1, Math.floor(activeRms.length * 0.9));
const observedRms = activeRms[percentileIndex];
if (!(observedRms > 0)) {
updateStatus('No audio detected, please try again.');
audio.sfxUiCancel();
return;
}
const calibratedGain = clampMicInputGain((micCalibrationTargetRms / observedRms) * micInputGainScaleMultiplier);
const roundedGain = clampMicInputGain(snapNumberToStep(calibratedGain, micInputGainStep, micCalibrationMinGain));
const appliedGain = audio.setOutboundInputGain(roundedGain);
persistMicInputGain(appliedGain);
updateStatus(`Mic calibration set to ${formatSteppedNumber(appliedGain, micInputGainStep)}x.`);
audio.sfxUiConfirm();
}
}