mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix: preserve keyboard focus and separate Space activation from voice
Keep focus visible across controls and stable during Contacts, Cockpit and mission roster updates. Preserve native short Space activation, and claim push-to-talk only after a 500 ms hold. Add panel Escape handling and keyboard-accessible attribution controls. Retains the Map Source foundation contributed in https://github.com/bilawalsidhu/gods-eye-view/pull/171.
This commit is contained in:
1 parent
c82fdd7478
commit
5228ab9e40
22 files changed
+3500
-157
No files matched your search
+156
-32
@@ -20,6 +20,7 @@ const STATUS = {
|
||||
error: 'ERROR',
|
||||
};
|
||||
const CALL_DEDUPE_MS = 2500;
|
||||
export const PUSH_TO_TALK_HOLD_DELAY_MS = 500;
|
||||
// WebRTC 'disconnected' is frequently momentary (a brief network blip that ICE
|
||||
// recovers on its own). Give it this long to return to 'connected' before we
|
||||
// treat it as a real drop (H8).
|
||||
@@ -307,6 +308,11 @@ export class GevRealtimeController {
|
||||
this.pushToTalkMode = false;
|
||||
this.pushToTalkKeyHeld = false;
|
||||
this.spaceKeyHeld = false;
|
||||
this.pushToTalkHoldTimer = null;
|
||||
this.pushToTalkHoldGeneration = 0;
|
||||
this.pushToTalkHoldFocusOwner = null;
|
||||
this.pushToTalkHoldControl = null;
|
||||
this.pushToTalkHoldPreservesNative = false;
|
||||
this.shortcutKeyDownHandler = null;
|
||||
this.shortcutKeyUpHandler = null;
|
||||
this.shortcutBlurHandler = null;
|
||||
@@ -568,65 +574,122 @@ export class GevRealtimeController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers hold-Space push-to-talk without hijacking typing or modified shortcuts.
|
||||
* @returns {void}
|
||||
*/
|
||||
/** Registers delayed hold-Space push-to-talk while preserving short control taps. */
|
||||
bindPushToTalkShortcut() {
|
||||
if (this.shortcutKeyDownHandler) return;
|
||||
this.shortcutKeyDownHandler = (event) => {
|
||||
if (!shouldHandlePushToTalkKeyDown(event)) return;
|
||||
if (event.repeat) {
|
||||
if (this.spaceKeyHeld) event.preventDefault();
|
||||
if (this.spaceKeyHeld && !this.pushToTalkHoldPreservesNative) event.preventDefault();
|
||||
if (this.pushToTalkKeyHeld) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
this.spaceKeyHeld = true;
|
||||
// Space must not generate the focused mic button's native click on keyup.
|
||||
event.preventDefault();
|
||||
this.pauseRadioForVoice();
|
||||
if (this.pushToTalkKeyHeld) return;
|
||||
this.pushToTalkHoldFocusOwner = document.activeElement;
|
||||
this.pushToTalkHoldControl = isInteractiveSpaceTarget(document.activeElement)
|
||||
? document.activeElement
|
||||
: (isInteractiveSpaceTarget(event.target)
|
||||
? event.target.closest?.(SPACE_INTERACTIVE_SELECTOR) || event.target
|
||||
: null);
|
||||
this.pushToTalkHoldPreservesNative = Boolean(this.pushToTalkHoldControl);
|
||||
// Background Space is reserved immediately to avoid scrolling. A focused
|
||||
// control keeps its native keydown and release unless the hold is claimed.
|
||||
if (!this.pushToTalkHoldPreservesNative) event.preventDefault();
|
||||
// A click-started session is intentionally open-mic. Space only claims an
|
||||
// idle session (or a session it already started) so releasing the key can
|
||||
// never surprise the user by muting a click-started conversation.
|
||||
if (this.isActive() && !this.pushToTalkMode) return;
|
||||
this.pushToTalkKeyHeld = true;
|
||||
this.ui.root.dataset.pushToTalk = 'held';
|
||||
if (this.isActive()) {
|
||||
this.setMicrophoneEnabled(true);
|
||||
if (this.status === 'listening') this.setStatus('listening', 'Release Space to send');
|
||||
} else {
|
||||
this.start({ pushToTalk: true });
|
||||
}
|
||||
this.cancelPushToTalkHold();
|
||||
const holdGeneration = ++this.pushToTalkHoldGeneration;
|
||||
this.pushToTalkHoldTimer = setTimeout(() => {
|
||||
this.pushToTalkHoldTimer = null;
|
||||
if (holdGeneration !== this.pushToTalkHoldGeneration) return;
|
||||
if (!this.spaceKeyHeld || this.pushToTalkKeyHeld) return;
|
||||
// A pointer or Tab focus change during the delay cancels the original
|
||||
// gesture rather than letting voice claim a key held for another owner.
|
||||
if (document.activeElement !== this.pushToTalkHoldFocusOwner) return;
|
||||
if (
|
||||
document.visibilityState === 'hidden'
|
||||
|| (typeof document.hasFocus === 'function' && !document.hasFocus())
|
||||
) return;
|
||||
if (this.isActive() && !this.pushToTalkMode) return;
|
||||
// Blur before voice starts. This removes the focused state and ensures
|
||||
// the eventual Space release cannot activate the old control.
|
||||
if (
|
||||
this.pushToTalkHoldControl
|
||||
&& document.activeElement === this.pushToTalkHoldControl
|
||||
&& typeof this.pushToTalkHoldControl.blur === 'function'
|
||||
) {
|
||||
this.pushToTalkHoldControl.blur();
|
||||
}
|
||||
this.pauseRadioForVoice();
|
||||
this.pushToTalkKeyHeld = true;
|
||||
if (this.isActive()) {
|
||||
this.ui.root.dataset.pushToTalk = 'held';
|
||||
this.setMicrophoneEnabled(true);
|
||||
if (this.status === 'listening') this.setStatus('listening', 'Release Space to send');
|
||||
} else {
|
||||
this.start({ pushToTalk: true });
|
||||
// start() performs a controlled stop() before connecting. Restore the
|
||||
// marker it clears so the UI reflects this still-held gesture.
|
||||
if (this.pushToTalkKeyHeld) this.ui.root.dataset.pushToTalk = 'held';
|
||||
}
|
||||
}, PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
};
|
||||
this.shortcutKeyUpHandler = (event) => {
|
||||
if (!isPushToTalkKey(event)) return;
|
||||
const wasHoldingSpace = this.spaceKeyHeld;
|
||||
const preservedNativeActivation = this.pushToTalkHoldPreservesNative;
|
||||
this.spaceKeyHeld = false;
|
||||
this.cancelPushToTalkHold();
|
||||
if (!this.pushToTalkKeyHeld) {
|
||||
if (wasHoldingSpace) event.preventDefault();
|
||||
if (wasHoldingSpace && !preservedNativeActivation) event.preventDefault();
|
||||
this.resetPushToTalkGesture();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.releasePushToTalkKey();
|
||||
this.resetPushToTalkGesture();
|
||||
};
|
||||
this.shortcutBlurHandler = () => {
|
||||
this.spaceKeyHeld = false;
|
||||
this.cancelPushToTalkHold();
|
||||
this.releasePushToTalkKey();
|
||||
this.resetPushToTalkGesture();
|
||||
};
|
||||
this.shortcutVisibilityHandler = () => {
|
||||
if (document.visibilityState === 'hidden') this.shortcutBlurHandler();
|
||||
};
|
||||
document.addEventListener('keydown', this.shortcutKeyDownHandler);
|
||||
document.addEventListener('keyup', this.shortcutKeyUpHandler);
|
||||
// Observe before custom controls call preventDefault for their own Space
|
||||
// behavior. This lets the same physical hold cross the 500ms voice
|
||||
// threshold while short taps still reach the control unchanged.
|
||||
document.addEventListener('keydown', this.shortcutKeyDownHandler, true);
|
||||
document.addEventListener('keyup', this.shortcutKeyUpHandler, true);
|
||||
window.addEventListener('blur', this.shortcutBlurHandler);
|
||||
document.addEventListener('visibilitychange', this.shortcutVisibilityHandler);
|
||||
}
|
||||
|
||||
/** Cancels an unclaimed Space hold before it starts voice. */
|
||||
cancelPushToTalkHold() {
|
||||
this.pushToTalkHoldGeneration++;
|
||||
if (this.pushToTalkHoldTimer === null) return;
|
||||
clearTimeout(this.pushToTalkHoldTimer);
|
||||
this.pushToTalkHoldTimer = null;
|
||||
}
|
||||
|
||||
/** Clears the owner metadata for the current physical Space gesture. */
|
||||
resetPushToTalkGesture() {
|
||||
this.pushToTalkHoldFocusOwner = null;
|
||||
this.pushToTalkHoldControl = null;
|
||||
this.pushToTalkHoldPreservesNative = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutes a keyboard-started microphone while leaving WebRTC alive for the reply.
|
||||
* @returns {void}
|
||||
*/
|
||||
releasePushToTalkKey() {
|
||||
this.cancelPushToTalkHold();
|
||||
if (!this.pushToTalkKeyHeld) return;
|
||||
this.pushToTalkKeyHeld = false;
|
||||
delete this.ui.root.dataset.pushToTalk;
|
||||
@@ -773,6 +836,7 @@ export class GevRealtimeController {
|
||||
// releases its own resources instead of promoting them onto a stopped
|
||||
// controller (H7).
|
||||
this.startEpoch++;
|
||||
this.cancelPushToTalkHold();
|
||||
this.radioHandoffEpoch++;
|
||||
for (const controller of this.activeToolAbortControllers) controller.abort();
|
||||
this.activeToolAbortControllers.clear();
|
||||
@@ -844,6 +908,7 @@ export class GevRealtimeController {
|
||||
this.pushToTalkMode = false;
|
||||
this.pushToTalkKeyHeld = false;
|
||||
this.spaceKeyHeld = false;
|
||||
this.resetPushToTalkGesture();
|
||||
if (this.ui?.root) {
|
||||
delete this.ui.root.dataset.pushToTalk;
|
||||
delete this.ui.root.dataset.microphone;
|
||||
@@ -857,8 +922,8 @@ export class GevRealtimeController {
|
||||
this.tierHandler = null;
|
||||
}
|
||||
if (removeUi) {
|
||||
if (this.shortcutKeyDownHandler) document.removeEventListener('keydown', this.shortcutKeyDownHandler);
|
||||
if (this.shortcutKeyUpHandler) document.removeEventListener('keyup', this.shortcutKeyUpHandler);
|
||||
if (this.shortcutKeyDownHandler) document.removeEventListener('keydown', this.shortcutKeyDownHandler, true);
|
||||
if (this.shortcutKeyUpHandler) document.removeEventListener('keyup', this.shortcutKeyUpHandler, true);
|
||||
if (this.shortcutBlurHandler) window.removeEventListener('blur', this.shortcutBlurHandler);
|
||||
if (this.shortcutVisibilityHandler) {
|
||||
document.removeEventListener('visibilitychange', this.shortcutVisibilityHandler);
|
||||
@@ -2454,18 +2519,77 @@ export function isPushToTalkKey(event) {
|
||||
return event?.code === 'Space' || event?.key === ' ';
|
||||
}
|
||||
|
||||
const SPACE_INTERACTIVE_SELECTOR = [
|
||||
'button',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'option',
|
||||
'a[href]',
|
||||
'summary',
|
||||
'audio[controls]',
|
||||
'video[controls]',
|
||||
'[contenteditable]',
|
||||
'[tabindex]',
|
||||
'[role="button"]',
|
||||
'[role="checkbox"]',
|
||||
'[role="combobox"]',
|
||||
'[role="link"]',
|
||||
'[role="listbox"]',
|
||||
'[role="menuitem"]',
|
||||
'[role="menuitemcheckbox"]',
|
||||
'[role="menuitemradio"]',
|
||||
'[role="option"]',
|
||||
'[role="radio"]',
|
||||
'[role="searchbox"]',
|
||||
'[role="slider"]',
|
||||
'[role="spinbutton"]',
|
||||
'[role="switch"]',
|
||||
'[role="tab"]',
|
||||
'[role="textbox"]',
|
||||
'[role="treeitem"]',
|
||||
].join(', ');
|
||||
|
||||
/**
|
||||
* Protects text entry and modified shortcuts from the global push-to-talk key.
|
||||
* @param {KeyboardEvent|object|null} event
|
||||
* @returns {boolean}
|
||||
* Returns whether Space began on the map surface reserved for push-to-talk.
|
||||
* Cesium gives its canvas `tabindex="0"` for camera input, which otherwise
|
||||
* makes the generic focus guard treat a map click like a button activation.
|
||||
*/
|
||||
export function isPushToTalkSurface(target) {
|
||||
if (target?.tagName?.toUpperCase?.() !== 'CANVAS') return false;
|
||||
if (target.id === 'world-overlay-canvas') return true;
|
||||
return Boolean(target.closest?.('#cesiumContainer'));
|
||||
}
|
||||
|
||||
/** Returns whether a focused target owns Space for UI interaction. */
|
||||
export function isInteractiveSpaceTarget(target) {
|
||||
if (!target) return false;
|
||||
if (isPushToTalkSurface(target)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
return Boolean(target.closest?.(SPACE_INTERACTIVE_SELECTOR));
|
||||
}
|
||||
|
||||
/** Returns whether Space belongs to a text-entry surface. */
|
||||
export function isEditingSpaceTarget(target) {
|
||||
if (!target) return false;
|
||||
if (target.isContentEditable || target.closest?.('[contenteditable]')) return true;
|
||||
if (target.closest?.('textarea, [role="textbox"], [role="searchbox"], [role="spinbutton"]')) {
|
||||
return true;
|
||||
}
|
||||
const input = target.closest?.('input');
|
||||
if (!input) return false;
|
||||
const type = String(input.type || 'text').toLowerCase();
|
||||
return [
|
||||
'text', 'search', 'email', 'url', 'tel', 'password', 'number',
|
||||
'date', 'datetime-local', 'month', 'week', 'time',
|
||||
].includes(type);
|
||||
}
|
||||
|
||||
/** Protects text entry and modified shortcuts from push-to-talk arbitration. */
|
||||
export function shouldHandlePushToTalkKeyDown(event) {
|
||||
if (!isPushToTalkKey(event) || event.defaultPrevented) return false;
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false;
|
||||
const target = event.target;
|
||||
if (target?.isContentEditable) return false;
|
||||
const editingControl = target?.closest?.('input, textarea, select, [contenteditable], [role="textbox"]');
|
||||
return !editingControl;
|
||||
return !isEditingSpaceTarget(event.target);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2510,7 +2634,7 @@ export function resolveVoiceVisualizerSpeaker(currentSpeaker, nextSpeaker, keepC
|
||||
export function resolveVoiceControlHint(pushToTalkMode, pushToTalkKeyHeld) {
|
||||
return pushToTalkMode && pushToTalkKeyHeld
|
||||
? 'Release Space to send'
|
||||
: 'Hold Space to speak · click mic to toggle voice';
|
||||
: 'Hold Space to speak · tap Space to activate focused controls';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2559,7 +2683,7 @@ function createVoiceControl({ reset = false } = {}) {
|
||||
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="Estimated session cost">~$0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="gev-voice-button" type="button" aria-label="Voice control — hold Space to speak; click to toggle voice" aria-describedby="gev-voice-help">
|
||||
<button id="gev-voice-button" type="button" aria-label="Voice control — activate to toggle voice; hold Space to speak" aria-describedby="gev-voice-help">
|
||||
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
|
||||
<span class="gev-mic-label">ON/OFF</span>
|
||||
</button>
|
||||
@@ -2571,7 +2695,7 @@ function createVoiceControl({ reset = false } = {}) {
|
||||
</div>
|
||||
<div id="gev-voice-help" class="gev-voice-help-tray" role="tooltip">
|
||||
<span class="gev-voice-help-kicker">VOICE CONTROL</span>
|
||||
<span class="gev-voice-help-detail">Hold Space to speak · click mic to toggle voice</span>
|
||||
<span class="gev-voice-help-detail">Hold Space to speak · tap Space to activate focused controls</span>
|
||||
</div>
|
||||
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
|
||||
<div class="gev-voice-error-header">
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
GevRealtimeController,
|
||||
gateVoiceVisualizerLevel,
|
||||
isBenignViewportDeleteError,
|
||||
isEditingSpaceTarget,
|
||||
isInteractiveSpaceTarget,
|
||||
isPushToTalkKey,
|
||||
isPushToTalkSurface,
|
||||
PUSH_TO_TALK_HOLD_DELAY_MS,
|
||||
resolveVoiceControlHint,
|
||||
resolveVoiceVisualizerSpeaker,
|
||||
selectVoiceVisualizerSignal,
|
||||
@@ -30,23 +34,442 @@ import {
|
||||
import { createVoiceCostTracker } from './voiceCost.js';
|
||||
|
||||
test('push-to-talk recognizes Space by code or key', () => {
|
||||
assert.equal(PUSH_TO_TALK_HOLD_DELAY_MS, 500);
|
||||
assert.equal(isPushToTalkKey({ code: 'Space', key: 'Unidentified' }), true);
|
||||
assert.equal(isPushToTalkKey({ code: '', key: ' ' }), true);
|
||||
assert.equal(isPushToTalkKey({ code: 'KeyM', key: 'm' }), false);
|
||||
});
|
||||
|
||||
test('push-to-talk ignores typing targets and modified shortcuts', () => {
|
||||
test('push-to-talk protects text entry but arbitrates non-editing controls', () => {
|
||||
const plainTarget = { isContentEditable: false, closest: () => null };
|
||||
assert.equal(shouldHandlePushToTalkKeyDown({ code: 'Space', target: plainTarget }), true);
|
||||
assert.equal(shouldHandlePushToTalkKeyDown({ code: 'Space', target: plainTarget, metaKey: true }), false);
|
||||
assert.equal(shouldHandlePushToTalkKeyDown({
|
||||
code: 'Space',
|
||||
target: { isContentEditable: false, closest: () => ({ tagName: 'INPUT' }) },
|
||||
target: pushToTalkTarget({ tagName: 'INPUT', type: 'text' }),
|
||||
}), false);
|
||||
assert.equal(shouldHandlePushToTalkKeyDown({
|
||||
code: 'Space',
|
||||
target: { isContentEditable: true, closest: () => null },
|
||||
}), false);
|
||||
assert.equal(shouldHandlePushToTalkKeyDown({
|
||||
code: 'Space',
|
||||
target: pushToTalkTarget({ tagName: 'BUTTON' }),
|
||||
}), true);
|
||||
assert.equal(isEditingSpaceTarget(pushToTalkTarget({ tagName: 'INPUT', type: 'range' })), false);
|
||||
});
|
||||
|
||||
// Dispatch through the installed document/window listeners, keeping WebRTC at
|
||||
// the start boundary. Native button activation itself belongs to browser QA.
|
||||
function createPushToTalkFixture(t) {
|
||||
const savedGlobals = new Map(['document', 'window', 'setTimeout', 'clearTimeout'].map((name) => (
|
||||
[name, Object.getOwnPropertyDescriptor(globalThis, name)]
|
||||
)));
|
||||
const eventHub = () => {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
addEventListener(type, listener) {
|
||||
if (!listeners.has(type)) listeners.set(type, new Set());
|
||||
listeners.get(type).add(listener);
|
||||
},
|
||||
removeEventListener(type, listener) { listeners.get(type)?.delete(listener); },
|
||||
dispatch(type, event = {}) {
|
||||
for (const listener of listeners.get(type) || []) listener(event);
|
||||
return event;
|
||||
},
|
||||
count(type) { return listeners.get(type)?.size || 0; },
|
||||
};
|
||||
};
|
||||
let clock = 0;
|
||||
let nextTimerId = 1;
|
||||
const timers = new Map();
|
||||
const setTimeout = (callback, delay = 0) => {
|
||||
const id = nextTimerId++;
|
||||
timers.set(id, { callback, due: clock + Number(delay || 0) });
|
||||
return id;
|
||||
};
|
||||
const clearTimeout = (id) => timers.delete(id);
|
||||
const advance = (milliseconds) => {
|
||||
const end = clock + milliseconds;
|
||||
while (true) {
|
||||
const due = [...timers.entries()]
|
||||
.filter(([, timer]) => timer.due <= end)
|
||||
.sort((a, b) => a[1].due - b[1].due || a[0] - b[0])[0];
|
||||
if (!due) break;
|
||||
const [id, timer] = due;
|
||||
timers.delete(id);
|
||||
clock = timer.due;
|
||||
timer.callback();
|
||||
}
|
||||
clock = end;
|
||||
};
|
||||
const document = { ...eventHub(), visibilityState: 'visible', activeElement: null };
|
||||
const window = eventHub();
|
||||
Object.assign(globalThis, { document, window, setTimeout, clearTimeout });
|
||||
const starts = [];
|
||||
const radio = [];
|
||||
const controllers = [];
|
||||
const makeController = () => {
|
||||
const microphone = { enabled: false, stopped: false, stop() { this.stopped = true; } };
|
||||
const controller = new GevRealtimeController({
|
||||
ui: {
|
||||
root: { dataset: {}, querySelectorAll: () => [], remove() {} },
|
||||
status: {}, detail: {},
|
||||
},
|
||||
runner: async () => ({}),
|
||||
radioLayer: {
|
||||
pause: () => radio.push('pause'),
|
||||
setVoiceDucked: (ducked) => radio.push(ducked ? 'duck' : 'unduck'),
|
||||
},
|
||||
});
|
||||
controller.debugLog = () => {};
|
||||
controller.start = ({ pushToTalk }) => {
|
||||
starts.push({ pushToTalk });
|
||||
controller.pushToTalkMode = pushToTalk;
|
||||
controller.status = 'listening';
|
||||
controller.stream = {
|
||||
getAudioTracks: () => [microphone],
|
||||
getTracks: () => [microphone],
|
||||
};
|
||||
controller.setMicrophoneEnabled(true);
|
||||
};
|
||||
controllers.push(controller);
|
||||
controller.bindPushToTalkShortcut();
|
||||
return { controller, microphone };
|
||||
};
|
||||
t.after(() => {
|
||||
for (const controller of controllers) controller.stop({ removeUi: true });
|
||||
for (const [name, descriptor] of savedGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else delete globalThis[name];
|
||||
}
|
||||
});
|
||||
const key = (type, target, options = {}) => {
|
||||
document.activeElement = options.activeElement === undefined ? target : options.activeElement;
|
||||
return document.dispatch(type, {
|
||||
code: 'Space', key: ' ', target, defaultPrevented: false,
|
||||
preventDefault() { this.defaultPrevented = true; },
|
||||
...options,
|
||||
});
|
||||
};
|
||||
return { advance, document, window, starts, radio, key, makeController, timers, ...makeController() };
|
||||
}
|
||||
|
||||
function pushToTalkTarget({
|
||||
tagName = null,
|
||||
id = null,
|
||||
style = null,
|
||||
parentElement = null,
|
||||
role = null,
|
||||
tabIndex = null,
|
||||
href = null,
|
||||
controls = false,
|
||||
type = null,
|
||||
contentEditable = false,
|
||||
mapSurface = false,
|
||||
} = {}) {
|
||||
const resolvedTagName = (tagName || (style ? 'BUTTON' : 'DIV')).toUpperCase();
|
||||
return {
|
||||
tagName: resolvedTagName,
|
||||
id,
|
||||
style,
|
||||
parentElement,
|
||||
role,
|
||||
tabIndex,
|
||||
href,
|
||||
controls,
|
||||
type,
|
||||
blurred: 0,
|
||||
blur() { this.blurred += 1; },
|
||||
isContentEditable: contentEditable,
|
||||
closest(selector) {
|
||||
for (let node = this; node; node = node.parentElement) {
|
||||
if (selector === '#cesiumContainer' && (node.id === 'cesiumContainer' || node.mapSurface)) {
|
||||
return node;
|
||||
}
|
||||
for (const part of selector.split(/,\s*/)) {
|
||||
if (part.toUpperCase() === node.tagName) return node;
|
||||
if (part === 'a[href]' && node.tagName === 'A' && node.href !== null) return node;
|
||||
if (part === 'audio[controls]' && node.tagName === 'AUDIO' && node.controls) return node;
|
||||
if (part === 'video[controls]' && node.tagName === 'VIDEO' && node.controls) return node;
|
||||
if (part === '[contenteditable]' && node.isContentEditable) return node;
|
||||
if (part === '[tabindex]' && node.tabIndex !== null) return node;
|
||||
const roleMatch = part.match(/^\[role="([^"]+)"\]$/);
|
||||
if (roleMatch && node.role === roleMatch[1]) return node;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
mapSurface,
|
||||
};
|
||||
}
|
||||
|
||||
test('push-to-talk starts from Cesium canvases and the document background', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const cesiumContainer = pushToTalkTarget({ tagName: 'DIV', id: 'cesiumContainer' });
|
||||
const cesiumCanvas = pushToTalkTarget({
|
||||
tagName: 'CANVAS',
|
||||
tabIndex: 0,
|
||||
parentElement: cesiumContainer,
|
||||
});
|
||||
const overlayCanvas = pushToTalkTarget({
|
||||
tagName: 'CANVAS',
|
||||
id: 'world-overlay-canvas',
|
||||
tabIndex: 0,
|
||||
});
|
||||
|
||||
for (const [index, canvas] of [cesiumCanvas, overlayCanvas].entries()) {
|
||||
assert.equal(isPushToTalkSurface(canvas), true);
|
||||
assert.equal(isInteractiveSpaceTarget(canvas), false);
|
||||
assert.equal(f.key('keydown', canvas).defaultPrevented, true);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.starts.length, 1, 'the first map hold opens one reusable voice session');
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, true);
|
||||
assert.equal(f.microphone.enabled, true, `map hold ${index + 1} enables the microphone`);
|
||||
assert.equal(f.key('keyup', canvas).defaultPrevented, true);
|
||||
assert.equal(f.microphone.enabled, false, `map release ${index + 1} mutes the microphone`);
|
||||
}
|
||||
const background = pushToTalkTarget();
|
||||
assert.equal(f.key('keydown', background).defaultPrevented, true);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, true);
|
||||
assert.equal(f.key('keyup', background).defaultPrevented, true);
|
||||
});
|
||||
|
||||
test('short Space on a focused control preserves release activation', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const button = pushToTalkTarget({ tagName: 'BUTTON' });
|
||||
let activations = 0;
|
||||
assert.equal(f.key('keydown', button).defaultPrevented, false);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
|
||||
const release = f.key('keyup', button);
|
||||
if (!release.defaultPrevented) activations += 1;
|
||||
assert.equal(release.defaultPrevented, false);
|
||||
assert.equal(activations, 1);
|
||||
assert.equal(button.blurred, 0);
|
||||
assert.deepEqual(f.starts, []);
|
||||
});
|
||||
|
||||
test('long Space blurs a focused control before voice and consumes release', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const button = pushToTalkTarget({ tagName: 'BUTTON' });
|
||||
const order = [];
|
||||
button.blur = () => {
|
||||
button.blurred += 1;
|
||||
order.push('blur');
|
||||
f.document.activeElement = null;
|
||||
};
|
||||
f.controller.pauseRadioForVoice = () => order.push('voice');
|
||||
assert.equal(f.key('keydown', button).defaultPrevented, false);
|
||||
assert.equal(f.key('keydown', button, { repeat: true }).defaultPrevented, false);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.deepEqual(order, ['blur', 'voice']);
|
||||
assert.equal(button.blurred, 1);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, true);
|
||||
assert.equal(f.key('keydown', button, { repeat: true, activeElement: null }).defaultPrevented, true);
|
||||
let activations = 0;
|
||||
const release = f.key('keyup', button, { activeElement: null });
|
||||
if (!release.defaultPrevented) activations += 1;
|
||||
assert.equal(release.defaultPrevented, true);
|
||||
assert.equal(activations, 0);
|
||||
assert.equal(f.microphone.enabled, false);
|
||||
});
|
||||
|
||||
test('push-to-talk still ignores modified, already-handled and typing keydowns', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const target = pushToTalkTarget();
|
||||
for (const modifier of ['altKey', 'ctrlKey', 'metaKey', 'shiftKey']) {
|
||||
assert.equal(f.key('keydown', target, { [modifier]: true }).defaultPrevented, false);
|
||||
}
|
||||
f.key('keydown', target, { defaultPrevented: true });
|
||||
f.key('keydown', target, { code: 'Enter', key: 'Enter' });
|
||||
for (const tagName of ['INPUT', 'TEXTAREA']) f.key('keydown', pushToTalkTarget({ tagName }));
|
||||
f.key('keydown', pushToTalkTarget({ role: 'textbox' }));
|
||||
f.key('keydown', { isContentEditable: true });
|
||||
assert.deepEqual(f.starts, []);
|
||||
assert.deepEqual(f.radio, []);
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
});
|
||||
|
||||
test('push-to-talk starts once at 500ms and repeat does not reset the threshold', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const background = pushToTalkTarget();
|
||||
assert.equal(f.key('keydown', background).defaultPrevented, true);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
|
||||
assert.deepEqual(f.starts, []);
|
||||
assert.deepEqual(f.radio, []);
|
||||
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, true);
|
||||
f.advance(1);
|
||||
assert.deepEqual(f.starts, [{ pushToTalk: true }]);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, true);
|
||||
assert.equal(f.microphone.enabled, true);
|
||||
const radioAfterStart = f.radio.slice();
|
||||
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, true);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS * 2);
|
||||
assert.deepEqual(f.radio, radioAfterStart);
|
||||
assert.equal(f.starts.length, 1);
|
||||
assert.equal(
|
||||
f.key('keyup', pushToTalkTarget({ tagName: 'BUTTON' })).defaultPrevented,
|
||||
true,
|
||||
'release follows a voice-owned hold after focus moves',
|
||||
);
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||||
assert.equal(f.microphone.enabled, false);
|
||||
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
|
||||
assert.equal(f.controller.ui.detail.textContent, 'Hold Space to talk');
|
||||
});
|
||||
|
||||
test('push-to-talk treats a Space release before 500ms as a background tap', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const background = pushToTalkTarget();
|
||||
assert.equal(f.key('keydown', background).defaultPrevented, true);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
|
||||
assert.equal(f.key('keyup', background).defaultPrevented, true);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
|
||||
assert.deepEqual(f.starts, []);
|
||||
assert.deepEqual(f.radio, []);
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test('push-to-talk rechecks focus before the hold threshold claims voice', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const background = pushToTalkTarget();
|
||||
const control = pushToTalkTarget({ tagName: 'BUTTON' });
|
||||
assert.equal(f.key('keydown', background).defaultPrevented, true);
|
||||
f.document.activeElement = control;
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.deepEqual(f.starts, []);
|
||||
assert.deepEqual(f.radio, []);
|
||||
assert.equal(f.key('keyup', control).defaultPrevented, true, 'release follows the background gesture owner');
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
});
|
||||
|
||||
test('push-to-talk never claims a control-started hold after focus moves outside', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const control = pushToTalkTarget({ tagName: 'BUTTON' });
|
||||
const background = pushToTalkTarget();
|
||||
assert.equal(f.key('keydown', control).defaultPrevented, false);
|
||||
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, false);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
|
||||
assert.equal(f.key('keyup', background).defaultPrevented, false);
|
||||
assert.deepEqual(f.starts, []);
|
||||
assert.deepEqual(f.radio, []);
|
||||
});
|
||||
|
||||
test('push-to-talk cancels pending and active holds on blur or hidden document', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const background = pushToTalkTarget();
|
||||
for (const exit of ['blur', 'hidden']) {
|
||||
const startsBeforePending = f.starts.length;
|
||||
const radioBeforePending = f.radio.length;
|
||||
f.key('keydown', background);
|
||||
const staleCallback = [...f.timers.values()][0].callback;
|
||||
if (exit === 'blur') f.window.dispatch('blur');
|
||||
else {
|
||||
f.document.visibilityState = 'hidden';
|
||||
f.document.dispatch('visibilitychange');
|
||||
}
|
||||
staleCallback();
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.starts.length, startsBeforePending);
|
||||
assert.equal(f.radio.length, radioBeforePending);
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
f.document.visibilityState = 'visible';
|
||||
|
||||
f.key('keydown', background);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.microphone.enabled, true);
|
||||
if (exit === 'blur') f.window.dispatch('blur');
|
||||
else {
|
||||
f.document.visibilityState = 'hidden';
|
||||
f.document.dispatch('visibilitychange');
|
||||
}
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||||
assert.equal(f.microphone.enabled, false);
|
||||
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
|
||||
assert.equal(f.key('keyup', background).defaultPrevented, false);
|
||||
f.document.visibilityState = 'visible';
|
||||
}
|
||||
});
|
||||
|
||||
test('push-to-talk timer refuses hidden or unfocused documents before lifecycle events arrive', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const background = pushToTalkTarget();
|
||||
for (const state of ['hidden', 'unfocused']) {
|
||||
f.key('keydown', background);
|
||||
if (state === 'hidden') f.document.visibilityState = 'hidden';
|
||||
else f.document.hasFocus = () => false;
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.deepEqual(f.starts, []);
|
||||
assert.deepEqual(f.radio, []);
|
||||
assert.equal(f.key('keyup', background).defaultPrevented, true);
|
||||
f.document.visibilityState = 'visible';
|
||||
delete f.document.hasFocus;
|
||||
}
|
||||
});
|
||||
|
||||
test('focused controls and background Space preserve a click-started open mic', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
f.controller.start({ pushToTalk: false });
|
||||
f.starts.length = 0;
|
||||
const style = pushToTalkTarget({ style: 'retro' });
|
||||
f.key('keydown', style);
|
||||
f.key('keydown', style, { repeat: true });
|
||||
assert.equal(f.key('keyup', style).defaultPrevented, false);
|
||||
assert.deepEqual(f.radio, []);
|
||||
assert.equal(f.microphone.enabled, true);
|
||||
assert.equal(f.controller.pushToTalkMode, false);
|
||||
assert.equal(f.key('keydown', pushToTalkTarget()).defaultPrevented, true);
|
||||
assert.equal(f.controller.spaceKeyHeld, true);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||||
assert.equal(f.timers.size, 0);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
|
||||
assert.equal(f.key('keyup', style).defaultPrevented, true);
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
assert.equal(f.microphone.enabled, true, 'an outside Space gesture must not claim or mute an open-mic session');
|
||||
assert.deepEqual(f.starts, []);
|
||||
});
|
||||
|
||||
test('push-to-talk teardown removes installed listeners and replacement binds once', (t) => {
|
||||
const f = createPushToTalkFixture(t);
|
||||
const outside = pushToTalkTarget();
|
||||
f.controller.bindPushToTalkShortcut();
|
||||
assert.equal(f.document.count('keydown'), 1);
|
||||
f.key('keydown', outside);
|
||||
const staleCallback = [...f.timers.values()][0].callback;
|
||||
f.controller.stop({ removeUi: true });
|
||||
staleCallback();
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.microphone.stopped, false);
|
||||
assert.equal(f.controller.spaceKeyHeld, false);
|
||||
assert.equal(f.controller.pushToTalkKeyHeld, false);
|
||||
assert.equal(f.controller.pushToTalkMode, false);
|
||||
assert.equal(f.document.count('keydown'), 0);
|
||||
assert.equal(f.document.count('keyup'), 0);
|
||||
assert.equal(f.document.count('visibilitychange'), 0);
|
||||
assert.equal(f.window.count('blur'), 0);
|
||||
f.key('keydown', outside);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.starts.length, 0, 'the removed controller must not respond');
|
||||
const replacement = f.makeController();
|
||||
replacement.controller.bindPushToTalkShortcut();
|
||||
assert.equal(f.document.count('keydown'), 1);
|
||||
assert.equal(f.document.count('keyup'), 1);
|
||||
assert.equal(f.document.count('visibilitychange'), 1);
|
||||
assert.equal(f.window.count('blur'), 1);
|
||||
f.key('keydown', pushToTalkTarget({ tagName: 'INPUT', type: 'text' }));
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.starts.length, 0);
|
||||
f.key('keydown', outside);
|
||||
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
|
||||
assert.equal(f.starts.length, 1);
|
||||
assert.equal(replacement.microphone.enabled, true);
|
||||
f.key('keyup', outside);
|
||||
assert.equal(replacement.microphone.enabled, false);
|
||||
});
|
||||
|
||||
test('mic clicks are ignored while Space is physically held', () => {
|
||||
@@ -57,12 +480,12 @@ test('mic clicks are ignored while Space is physically held', () => {
|
||||
test('voice control help tray reflects the push-to-talk key state', () => {
|
||||
assert.equal(
|
||||
resolveVoiceControlHint(false, false),
|
||||
'Hold Space to speak · click mic to toggle voice',
|
||||
'Hold Space to speak · tap Space to activate focused controls',
|
||||
);
|
||||
assert.equal(resolveVoiceControlHint(true, true), 'Release Space to send');
|
||||
assert.equal(
|
||||
resolveVoiceControlHint(true, false),
|
||||
'Hold Space to speak · click mic to toggle voice',
|
||||
'Hold Space to speak · tap Space to activate focused controls',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user