users: add shift-u alphabetical list with per-user listen volume controls
This commit is contained in:
@@ -36,6 +36,10 @@
|
|||||||
"keys": "U",
|
"keys": "U",
|
||||||
"description": "Speak connected users"
|
"description": "Speak connected users"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"keys": "Shift+U",
|
||||||
|
"description": "List users alphabetically; Left/Right adjusts selected user volume"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"keys": "Slash",
|
"keys": "Slash",
|
||||||
"description": "Start chat"
|
"description": "Start chat"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Maintainer-controlled web client version.
|
// Maintainer-controlled web client version.
|
||||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
||||||
window.CHGRID_WEB_VERSION = "2026.02.22 R170";
|
window.CHGRID_WEB_VERSION = "2026.02.22 R171";
|
||||||
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type SpatialPeerRuntime = {
|
|||||||
nickname: string;
|
nickname: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
listenGain?: number;
|
||||||
gain?: GainNode;
|
gain?: GainNode;
|
||||||
panner?: StereoPannerNode;
|
panner?: StereoPannerNode;
|
||||||
audioElement?: HTMLAudioElement;
|
audioElement?: HTMLAudioElement;
|
||||||
@@ -297,8 +298,9 @@ export class AudioEngine {
|
|||||||
nearFieldGain: 1,
|
nearFieldGain: 1,
|
||||||
});
|
});
|
||||||
const gainValue = mix?.gain ?? 0;
|
const gainValue = mix?.gain ?? 0;
|
||||||
|
const listenGain = Number.isFinite(peer.listenGain) ? Math.max(0, peer.listenGain as number) : 1;
|
||||||
const panValue = mix?.pan ?? 0;
|
const panValue = mix?.pan ?? 0;
|
||||||
peer.gain.gain.linearRampToValueAtTime(gainValue, this.audioCtx.currentTime + 0.1);
|
peer.gain.gain.linearRampToValueAtTime(gainValue * listenGain, this.audioCtx.currentTime + 0.1);
|
||||||
if (peer.panner) {
|
if (peer.panner) {
|
||||||
const resolvedPan = this.outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
|
const resolvedPan = this.outputMode === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
|
||||||
peer.panner.pan.setValueAtTime(resolvedPan, this.audioCtx.currentTime);
|
peer.panner.pan.setValueAtTime(resolvedPan, this.audioCtx.currentTime);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type MainModeCommand =
|
|||||||
| 'calibrateMicrophone'
|
| 'calibrateMicrophone'
|
||||||
| 'useItem'
|
| 'useItem'
|
||||||
| 'speakUsers'
|
| 'speakUsers'
|
||||||
|
| 'listUsersAlphabetical'
|
||||||
| 'addItem'
|
| 'addItem'
|
||||||
| 'locateOrListItems'
|
| 'locateOrListItems'
|
||||||
| 'pickupDropOrDelete'
|
| 'pickupDropOrDelete'
|
||||||
@@ -52,7 +53,7 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod
|
|||||||
if (code === 'KeyC') return shiftKey ? null : 'speakCoordinates';
|
if (code === 'KeyC') return shiftKey ? null : 'speakCoordinates';
|
||||||
if (code === 'KeyV') return shiftKey ? 'calibrateMicrophone' : 'openMicGainEdit';
|
if (code === 'KeyV') return shiftKey ? 'calibrateMicrophone' : 'openMicGainEdit';
|
||||||
if (code === 'Enter') return 'useItem';
|
if (code === 'Enter') return 'useItem';
|
||||||
if (code === 'KeyU') return shiftKey ? null : 'speakUsers';
|
if (code === 'KeyU') return shiftKey ? 'listUsersAlphabetical' : 'speakUsers';
|
||||||
if (code === 'KeyA') return shiftKey ? null : 'addItem';
|
if (code === 'KeyA') return shiftKey ? null : 'addItem';
|
||||||
if (code === 'KeyI') return 'locateOrListItems';
|
if (code === 'KeyI') return 'locateOrListItems';
|
||||||
if (code === 'KeyD') return 'pickupDropOrDelete';
|
if (code === 'KeyD') return 'pickupDropOrDelete';
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ let heartbeatAwaitingPong = false;
|
|||||||
let reconnectInFlight = false;
|
let reconnectInFlight = false;
|
||||||
let activeServerInstanceId: string | null = null;
|
let activeServerInstanceId: string | null = null;
|
||||||
let reloadScheduledForVersionMismatch = false;
|
let reloadScheduledForVersionMismatch = false;
|
||||||
|
let peerListenGainByNickname = settings.loadPeerListenGains();
|
||||||
let audioLayers: AudioLayerState = {
|
let audioLayers: AudioLayerState = {
|
||||||
voice: true,
|
voice: true,
|
||||||
item: true,
|
item: true,
|
||||||
@@ -489,6 +490,33 @@ function persistMasterVolume(value: number): void {
|
|||||||
settings.saveMasterVolume(value);
|
settings.saveMasterVolume(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Normalizes nickname for local per-user listen-gain preference keys. */
|
||||||
|
function peerListenGainKey(nickname: string): string {
|
||||||
|
return nickname.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns configured listen gain for a nickname (default 1.0). */
|
||||||
|
function getPeerListenGainForNickname(nickname: string): number {
|
||||||
|
const key = peerListenGainKey(nickname);
|
||||||
|
const raw = peerListenGainByNickname[key];
|
||||||
|
if (!Number.isFinite(raw)) return 1;
|
||||||
|
return clampMicInputGain(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists local listen gain preference for a nickname. */
|
||||||
|
function setPeerListenGainForNickname(nickname: string, gain: number): void {
|
||||||
|
const key = peerListenGainKey(nickname);
|
||||||
|
peerListenGainByNickname = { ...peerListenGainByNickname, [key]: clampMicInputGain(gain) };
|
||||||
|
settings.savePeerListenGains(peerListenGainByNickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies stored listen-gain preferences to currently known peer runtimes. */
|
||||||
|
function applyConfiguredPeerListenGains(): void {
|
||||||
|
for (const [peerId, peerState] of state.peers.entries()) {
|
||||||
|
peerManager.setPeerListenGain(peerId, getPeerListenGainForNickname(peerState.nickname));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Applies current layer toggles to peer voice, media streams, and item emitters. */
|
/** Applies current layer toggles to peer voice, media streams, and item emitters. */
|
||||||
async function applyAudioLayerState(): Promise<void> {
|
async function applyAudioLayerState(): Promise<void> {
|
||||||
audio.setVoiceLayerEnabled(audioLayers.voice);
|
audio.setVoiceLayerEnabled(audioLayers.voice);
|
||||||
@@ -1308,6 +1336,7 @@ async function onSignalingMessage(message: IncomingMessage): Promise<void> {
|
|||||||
startHeartbeat();
|
startHeartbeat();
|
||||||
}
|
}
|
||||||
await onAppMessage(message);
|
await onAppMessage(message);
|
||||||
|
applyConfiguredPeerListenGains();
|
||||||
if (restartAnnouncement) {
|
if (restartAnnouncement) {
|
||||||
pushChatMessage(restartAnnouncement);
|
pushChatMessage(restartAnnouncement);
|
||||||
audio.sfxUiConfirm();
|
audio.sfxUiConfirm();
|
||||||
@@ -1440,6 +1469,30 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
|
|||||||
audio.sfxUiBlip();
|
audio.sfxUiBlip();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'listUsersAlphabetical':
|
||||||
|
if (state.peers.size === 0) {
|
||||||
|
updateStatus('No users to list.');
|
||||||
|
audio.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.sortedPeerIds = Array.from(state.peers.entries())
|
||||||
|
.sort((a, b) => a[1].nickname.localeCompare(b[1].nickname, undefined, { sensitivity: 'base' }))
|
||||||
|
.map(([id]) => id);
|
||||||
|
state.listIndex = 0;
|
||||||
|
state.mode = 'listUsers';
|
||||||
|
{
|
||||||
|
const first = state.peers.get(state.sortedPeerIds[0]);
|
||||||
|
if (first) {
|
||||||
|
const userCount = state.sortedPeerIds.length;
|
||||||
|
const userLabelText = userCount === 1 ? 'user' : 'users';
|
||||||
|
const gain = getPeerListenGainForNickname(first.nickname);
|
||||||
|
updateStatus(
|
||||||
|
`${userCount} ${userLabelText}. ${first.nickname}, volume ${formatSteppedNumber(gain, MIC_INPUT_GAIN_STEP)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audio.sfxUiBlip();
|
||||||
|
return;
|
||||||
case 'addItem': {
|
case 'addItem': {
|
||||||
const itemTypeSequence = getItemTypeSequence();
|
const itemTypeSequence = getItemTypeSequence();
|
||||||
if (itemTypeSequence.length === 0) {
|
if (itemTypeSequence.length === 0) {
|
||||||
@@ -1813,13 +1866,32 @@ function handleListModeInput(code: string, key: string): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (code === 'ArrowLeft' || code === 'ArrowRight') {
|
||||||
|
const peer = state.peers.get(state.sortedPeerIds[state.listIndex]);
|
||||||
|
if (!peer) return;
|
||||||
|
const current = getPeerListenGainForNickname(peer.nickname);
|
||||||
|
const delta = code === 'ArrowRight' ? MIC_INPUT_GAIN_STEP : -MIC_INPUT_GAIN_STEP;
|
||||||
|
const attempted = snapNumberToStep(current + delta, MIC_INPUT_GAIN_STEP, MIC_CALIBRATION_MIN_GAIN);
|
||||||
|
const next = clampMicInputGain(attempted);
|
||||||
|
setPeerListenGainForNickname(peer.nickname, next);
|
||||||
|
peerManager.setPeerListenGain(peer.id, next);
|
||||||
|
updateStatus(`${peer.nickname} volume ${formatSteppedNumber(next, MIC_INPUT_GAIN_STEP)}.`);
|
||||||
|
if (Math.abs(next - current) < 1e-9 || Math.abs(next - attempted) > 1e-9) {
|
||||||
|
audio.sfxUiCancel();
|
||||||
|
} else {
|
||||||
|
audio.sfxUiBlip();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const control = handleListControlKey(code, key, state.sortedPeerIds, state.listIndex, (peerId) => state.peers.get(peerId)?.nickname ?? '');
|
const control = handleListControlKey(code, key, state.sortedPeerIds, state.listIndex, (peerId) => state.peers.get(peerId)?.nickname ?? '');
|
||||||
if (control.type === 'move') {
|
if (control.type === 'move') {
|
||||||
state.listIndex = control.index;
|
state.listIndex = control.index;
|
||||||
const peer = state.peers.get(state.sortedPeerIds[state.listIndex]);
|
const peer = state.peers.get(state.sortedPeerIds[state.listIndex]);
|
||||||
if (!peer) return;
|
if (!peer) return;
|
||||||
|
const gain = getPeerListenGainForNickname(peer.nickname);
|
||||||
updateStatus(
|
updateStatus(
|
||||||
`${peer.nickname}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`,
|
`${peer.nickname}, volume ${formatSteppedNumber(gain, MIC_INPUT_GAIN_STEP)}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`,
|
||||||
);
|
);
|
||||||
if (control.reason === 'initial') {
|
if (control.reason === 'initial') {
|
||||||
audio.sfxUiBlip();
|
audio.sfxUiBlip();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const AUDIO_OUTPUT_MODE_STORAGE_KEY = 'chatGridAudioOutputMode';
|
|||||||
const AUDIO_LAYER_STATE_STORAGE_KEY = 'chatGridAudioLayers';
|
const AUDIO_LAYER_STATE_STORAGE_KEY = 'chatGridAudioLayers';
|
||||||
const MIC_INPUT_GAIN_STORAGE_KEY = 'chatGridMicInputGain';
|
const MIC_INPUT_GAIN_STORAGE_KEY = 'chatGridMicInputGain';
|
||||||
const MASTER_VOLUME_STORAGE_KEY = 'chatGridMasterVolume';
|
const MASTER_VOLUME_STORAGE_KEY = 'chatGridMasterVolume';
|
||||||
|
const PEER_LISTEN_GAINS_STORAGE_KEY = 'chatGridPeerListenGains';
|
||||||
const NICKNAME_STORAGE_KEY = 'spatialChatNickname';
|
const NICKNAME_STORAGE_KEY = 'spatialChatNickname';
|
||||||
|
|
||||||
type DevicePreference = {
|
type DevicePreference = {
|
||||||
@@ -83,6 +84,27 @@ export class SettingsStore {
|
|||||||
localStorage.setItem(MASTER_VOLUME_STORAGE_KEY, String(value));
|
localStorage.setItem(MASTER_VOLUME_STORAGE_KEY, String(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadPeerListenGains(): Record<string, number> {
|
||||||
|
const raw = localStorage.getItem(PEER_LISTEN_GAINS_STORAGE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
const normalized: Record<string, number> = {};
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!key || !Number.isFinite(numeric)) continue;
|
||||||
|
normalized[key] = numeric;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
savePeerListenGains(values: Record<string, number>): void {
|
||||||
|
localStorage.setItem(PEER_LISTEN_GAINS_STORAGE_KEY, JSON.stringify(values));
|
||||||
|
}
|
||||||
|
|
||||||
loadNickname(): string {
|
loadNickname(): string {
|
||||||
return localStorage.getItem(NICKNAME_STORAGE_KEY) || '';
|
return localStorage.getItem(NICKNAME_STORAGE_KEY) || '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export class PeerManager {
|
|||||||
nickname: userData.nickname ?? 'user...',
|
nickname: userData.nickname ?? 'user...',
|
||||||
x: userData.x ?? 20,
|
x: userData.x ?? 20,
|
||||||
y: userData.y ?? 20,
|
y: userData.y ?? 20,
|
||||||
|
listenGain: 1,
|
||||||
pc,
|
pc,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,6 +146,18 @@ export class PeerManager {
|
|||||||
peer.nickname = nickname;
|
peer.nickname = nickname;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPeerListenGain(id: string, gain: number): void {
|
||||||
|
const peer = this.peers.get(id);
|
||||||
|
if (!peer) return;
|
||||||
|
peer.listenGain = gain;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPeerListenGain(id: string): number {
|
||||||
|
const peer = this.peers.get(id);
|
||||||
|
if (!peer) return 1;
|
||||||
|
return Number.isFinite(peer.listenGain) ? Math.max(0, peer.listenGain as number) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
async setOutputDevice(deviceId: string): Promise<void> {
|
async setOutputDevice(deviceId: string): Promise<void> {
|
||||||
this.outputDeviceId = deviceId;
|
this.outputDeviceId = deviceId;
|
||||||
for (const peer of this.peers.values()) {
|
for (const peer of this.peers.values()) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ This document is the authoritative keymap for the client.
|
|||||||
- `L`: Locate nearest user
|
- `L`: Locate nearest user
|
||||||
- `Shift+L`: List users and teleport to selected user with `Enter`
|
- `Shift+L`: List users and teleport to selected user with `Enter`
|
||||||
- `U`: Speak connected users
|
- `U`: Speak connected users
|
||||||
|
- `Shift+U`: List users alphabetically
|
||||||
- `N`: Edit nickname
|
- `N`: Edit nickname
|
||||||
- `/`: Start chat
|
- `/`: Start chat
|
||||||
- `,` / `.`: Previous/next message
|
- `,` / `.`: Previous/next message
|
||||||
@@ -68,6 +69,7 @@ This document is the authoritative keymap for the client.
|
|||||||
Applies to effect select, user/item list modes, item selection, item property list, and property option select.
|
Applies to effect select, user/item list modes, item selection, item property list, and property option select.
|
||||||
|
|
||||||
- `ArrowUp` / `ArrowDown`: Move selection
|
- `ArrowUp` / `ArrowDown`: Move selection
|
||||||
|
- `ArrowLeft` / `ArrowRight` in user list: Lower/raise selected user listen volume (`0.5..4.0`)
|
||||||
- `Enter`: Confirm selection
|
- `Enter`: Confirm selection
|
||||||
- `Escape`: Exit/cancel
|
- `Escape`: Exit/cancel
|
||||||
- `Space`: Read tooltip/help for current option (where metadata is available)
|
- `Space`: Read tooltip/help for current option (where metadata is available)
|
||||||
|
|||||||
Reference in New Issue
Block a user