Files
gods-eye-view/src/ui/radioBindings.js
T
Sameh Khamis b20c80e590 Switch language in place, without a reload
Choosing a language in the picker now applies it at once. The choice is
stored and ?lang is kept in step through replaceState, and the picker keeps
focus.

Code that writes translated text itself re-presents it on a switch:
onLocaleChange() lets an owner listen, removed at teardown, and status
writers hand over a function of the locale (voice cards, scene director,
SDR, draw hints, imagery split, map sources, Bhote Koshi media) so the
words are rebuilt rather than re-sent. The HUD, cockpit, radio, CCTV,
launch, awareness and credit texts follow the same hook, and
applyDocumentTranslations can be applied again without undoing text code
wrote since.

The unsaved-work registry and persistLocaleAndReload existed only to guard
the reload, so they go, with their catalog entries.
2026-11-01 16:47:39 -08:00

601 lines
21 KiB
JavaScript

import { t, msg } from '../i18n/index.js';
import {
showRadioEnableBusy,
showRadioTuner,
showRadioVolume,
} from './radioPresentation.js';
import {
radioTunerSlot,
radioTunerCommitSlot,
radioTunerPointerPosition,
} from './radioTunerModel.js';
/** Bind Radio input using a RadioControls receiver and supplied actions. */
export function bindRadioControls() {
if (!this._radioPanel) return;
const tunerListenerOptions = {};
const setCockpitDisclosure = (
kind,
expanded,
{ returnFocus = false } = {},
) => {
if (this.destroyed) return;
const displayOpen = kind === 'display' && Boolean(expanded);
const radioOpen = kind === 'radio' && Boolean(expanded);
if (displayOpen || radioOpen) this.actions.setSignalCollapsed(true);
this._cockpitDisclosure = { display: displayOpen, radio: radioOpen };
this.store?.publishUtility({ ...this._cockpitDisclosure });
if (displayOpen) this.actions.revealStyleParameters();
if (!expanded && returnFocus) {
(kind === 'display'
? this._cockpitDisplayToggleBtn
: this._cockpitRadioToggleBtn
)?.focus({ preventScroll: true });
}
if (
!displayOpen &&
!radioOpen &&
this.actions.isCockpitActive() &&
!this.actions.signalUserCollapsed()
) {
this.actions.setSignalCollapsed(false);
}
this.actions.layoutCockpit();
};
this._setCockpitDisclosure = setCockpitDisclosure;
/** @param {'display'|'radio'} kind @returns {boolean} Whether it is open. */
this._cockpitDisclosureOpen = (kind) =>
Boolean(this._cockpitDisclosure?.[kind]);
const tunerPreview = ({
coordinate = this._radioTunerCoordinate,
syncStatic = true,
rotate = true,
} = {}) => {
const slot = radioTunerSlot(
this._radioTunerView.slot,
this._radioTunerStations.length,
);
const station = slot.locked
? this._radioTunerStations[slot.stationIndex] || null
: null;
const resolvedCoordinate =
this._radioTunerStations.length <= 1
? 0
: Math.min(
this._radioTunerStations.length - 1,
Math.max(0, Number(coordinate) || 0),
);
const ratio =
this._radioTunerStations.length === 1
? 0.5
: resolvedCoordinate / Math.max(1, this._radioTunerStations.length - 1);
this._radioTunerCoordinate = resolvedCoordinate;
showRadioTuner.call(this, {
ratio,
static: syncStatic ? false : Boolean(this._radioState?.tuningStatic),
coordinate: resolvedCoordinate,
count: this._radioTunerStations.length,
value: station
? t('CH {index} / {total}', {
index: String(slot.stationIndex + 1).padStart(2, '0'),
total: String(this._radioTunerStations.length).padStart(2, '0'),
})
: t('NO STATIONS'),
station: station?.name || t('NO STATION AVAILABLE'),
valuetext: station
? t('{name}, station {index} of {total}', {
name: station.name,
index: slot.stationIndex + 1,
total: this._radioTunerStations.length,
})
: t('No station available'),
});
if (syncStatic)
this.radio.previewTuningStation(station?.id || null, { rotate });
return station;
};
const setTunerDirectory = (pool) => {
this._radioTunerPool = [...pool];
this._radioTunerStations = [...pool];
this._radioTunerBandSignature = this._radioTunerStations
.map((station) => station.id)
.join('|');
showRadioTuner.call(this, {
max: Math.max(0, this._radioTunerStations.length - 1),
});
};
const refreshTunerBand = ({ force = false } = {}) => {
if (this._radioTunerDragging || !this._radioTunerView.available)
return false;
const selectedId = this._radioState?.selected?.id || null;
const pool = this.radio.getTunerStations(750);
const poolSignature = pool.map((station) => station.id).join('|');
const currentPoolSignature = this._radioTunerPool
.map((station) => station.id)
.join('|');
if (
!force &&
poolSignature === currentPoolSignature &&
selectedId === this._radioTunerSelectedId
)
return false;
setTunerDirectory(pool);
this._radioTunerSelectedId = selectedId;
const selectedPoolIndex = pool.findIndex(
(station) => station.id === selectedId,
);
const slot = radioTunerSlot(
selectedPoolIndex >= 0 ? selectedPoolIndex : 0,
this._radioTunerStations.length,
);
showRadioTuner.call(this, { slot: slot.slot });
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false });
return true;
};
this._refreshRadioTunerBand = refreshTunerBand;
this._refreshRadioTunerText = () =>
tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false });
const beginTuner = () => {
if (this._radioTunerDragging || !this._radioTunerView.available)
return false;
refreshTunerBand();
if (!this._radioTunerStations.length || !this.radio.beginTuning())
return false;
// A tuner-owned camera preview must never replace the frozen directory.
// Only an explicit globe pointer/wheel gesture releases camera pinning.
this._radioTunerBandPinnedForNavigation = true;
this._radioTunerDragging = true;
showRadioTuner.call(this, { dragging: true });
const selectedIndex = this._radioTunerStations.findIndex(
(station) => station.id === this._radioState?.selected?.id,
);
const slot = radioTunerSlot(
selectedIndex >= 0 ? selectedIndex : 0,
this._radioTunerStations.length,
);
showRadioTuner.call(this, { slot: slot.slot });
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
this._radioTunerDragStartSlot = slot.slot;
this._radioTunerDragSnapshot = {
stations: [...this._radioTunerStations],
bandSignature: this._radioTunerBandSignature,
selectedId: this._radioTunerSelectedId,
slot: slot.slot,
coordinate: this._radioTunerCoordinate,
};
this._radioTunerLastSlot = slot.slot;
this._radioTunerDragDirection = 0;
tunerPreview({ coordinate: this._radioTunerCoordinate });
return true;
};
const finishTuner = (commit) => {
if (!this._radioTunerDragging) return;
const dragSnapshot = this._radioTunerDragSnapshot;
if (!commit && dragSnapshot) {
this._radioTunerStations = [...dragSnapshot.stations];
this._radioTunerPool = [...dragSnapshot.stations];
this._radioTunerBandSignature = dragSnapshot.bandSignature;
this._radioTunerSelectedId = dragSnapshot.selectedId;
const startSlot = radioTunerSlot(
dragSnapshot.slot,
this._radioTunerStations.length,
);
showRadioTuner.call(this, { max: startSlot.max, slot: startSlot.slot });
this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate)
? dragSnapshot.coordinate
: dragSnapshot.slot;
} else if (!commit) {
showRadioTuner.call(this, { slot: this._radioTunerDragStartSlot });
this._radioTunerCoordinate = this._radioTunerDragStartSlot;
}
let station = tunerPreview({
coordinate: this._radioTunerCoordinate,
rotate: commit,
});
if (commit && !station) {
const snapped = radioTunerCommitSlot(
this._radioTunerView.slot,
this._radioTunerStations.length,
);
showRadioTuner.call(this, { slot: snapped.slot });
this._radioTunerCoordinate = snapped.stationIndex;
station = tunerPreview({ coordinate: this._radioTunerCoordinate });
}
let result = null;
if (commit && station) {
// Keep the exact band used by the drag so the selected channel cannot
// jump to a refreshed catalog slot while its camera flight settles.
this._radioTunerBandPinnedForNavigation = true;
result = this.radio.commitTuningStation(station.id, { origin: 'user' });
} else if (!commit) {
this.radio.cancelTuning();
} else {
this.radio.endTuning();
}
// Radio emits selection/tuning state synchronously. Keep both the logical
// drag and the no-transition class active until that state has settled,
// then restore the exact selected slot before permitting CSS motion.
this._radioTunerDragging = false;
this._radioTunerPointerId = null;
this._radioTunerKeyboardKey = null;
if (commit && (!result || result.ok)) refreshTunerBand();
this._radioTunerDragSnapshot = null;
// The tuner lays out the snapped position while transitions are still
// disabled before it drops the drag class, so the needle cannot
// interpolate from the released gap.
showRadioTuner.call(this, { dragging: false });
if (result && !result.ok) {
this._radioTunerBandPinnedForNavigation = false;
if (result.reason === 'station-unavailable') {
showRadioTuner.call(this, {
value: t('OFF AIR'),
station: t('STATION UNAVAILABLE'),
valuetext: t('Station unavailable after directory refresh'),
});
}
}
};
const cycleRadio = (direction, { rotate = true } = {}) => {
this._radioTunerBandPinnedForNavigation = true;
const pool = this._radioTunerPool.length
? this._radioTunerPool
: this._radioTunerStations;
const cycled = this.radio.cycleStation(direction, {
rotate,
stationIds: pool.map((station) => station.id),
origin: 'user',
});
if (!cycled) {
this._radioTunerBandPinnedForNavigation = false;
return;
}
};
// Both Enable buttons render from the Radio store; before its first render
// there is nothing shown to mark, so the button is marked directly.
const setTriggerBusy = (trigger, busy) => {
const where =
trigger === this._radioEnableBtn
? 'panel'
: trigger === this._cockpitRadioEnableBtn
? 'cockpit'
: null;
if (where && showRadioEnableBusy.call(this, busy, where)) return;
trigger.setAttribute('aria-disabled', String(busy));
trigger.setAttribute('aria-busy', String(busy));
};
// The store patches aria-busy on the next render, so a second press before
// it lands is turned away by this set.
const busyTriggers = new Set();
const toggleRadio = async (trigger) => {
if (!this.actions.isRegistered()) return;
if (busyTriggers.has(trigger)) return;
if (trigger.getAttribute('aria-busy') === 'true') return;
const enabling = !this.actions.isEnabled();
const revealAfterEnable = enabling && trigger === this._radioEnableBtn;
busyTriggers.add(trigger);
setTriggerBusy(trigger, true);
try {
const toggled = await this.actions.runUserAction(
(notificationToken) =>
this.actions.setEnabled(enabling, {
origin: 'user',
notificationToken,
}),
// contextSessionOrdering.test.mjs pins this English argument; the
// toast shows localizedMessage.
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
{
localizedMessage: t('Radio could not {action} cleanly', {
action: enabling ? t('start') : t('stop'),
}),
},
);
if (this.destroyed || toggled === false) return;
if (
enabling &&
trigger === this._radioEnableBtn &&
!document
.getElementById('global-context-panel')
?.classList.contains('collapsed')
) {
this.actions.setPanelCollapsed('radio-panel', false, {
explicit: true,
});
}
if (revealAfterEnable)
await this._revealRadioControlsAfterExplicitEnable(trigger);
} finally {
busyTriggers.delete(trigger);
if (!this.destroyed) {
setTriggerBusy(trigger, false);
if (revealAfterEnable && trigger.isConnected)
trigger.focus({ preventScroll: true });
}
}
};
this.listen(
document,
'pointerdown',
(event) => {
if (
!this._cockpitUtilityControls ||
event.target?.closest?.('#cockpit-utility-controls')
)
return;
if (event.target?.closest?.('.cockpit-vision-controls')) return;
if (event.target?.closest?.('#left-panel-stack, #cockpit-context'))
return;
setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false);
},
tunerListenerOptions,
);
this.listen(
document,
'keydown',
(event) => {
if (event.key !== 'Escape') return;
const displayOpen = this._cockpitDisclosureOpen('display');
const radioOpen = this._cockpitDisclosureOpen('radio');
if (!displayOpen && !radioOpen) return;
const nestedPanel = event.target?.closest?.(
'.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)',
);
if (nestedPanel) return;
event.preventDefault();
event.stopImmediatePropagation();
const kind = displayOpen ? 'display' : 'radio';
const disclosure = displayOpen
? this._cockpitDisplayToggleBtn
: this._cockpitRadioToggleBtn;
const escapedFromDisclosure =
event.target === disclosure || disclosure?.contains?.(event.target);
setCockpitDisclosure(kind, false, {
returnFocus: !escapedFromDisclosure,
});
if (escapedFromDisclosure) disclosure?.blur?.();
},
{ capture: true },
);
this.listen(
window,
'gev:cockpit-mode-changed',
(event) => {
if (event?.detail?.active) return;
setCockpitDisclosure('display', false);
setCockpitDisclosure('radio', false);
},
tunerListenerOptions,
);
this.listen(
window,
'gev:cockpit-signal-expanded',
() => {
setCockpitDisclosure('display', false);
},
tunerListenerOptions,
);
this.listen(
window,
'gev:cockpit-context-expanded',
() => {
this.actions.setPanelCollapsed('data-panel', true);
},
tunerListenerOptions,
);
const updateTunerFromPointer = (event) => {
const rect = this._radioTunerSlider?.getBoundingClientRect();
if (!rect || !this._radioTunerStations.length) return false;
const position = radioTunerPointerPosition(
event.clientX,
rect.left,
rect.width,
this._radioTunerStations.length,
);
if (position.stationIndex > this._radioTunerLastSlot)
this._radioTunerDragDirection = 1;
else if (position.stationIndex < this._radioTunerLastSlot)
this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = position.stationIndex;
this._radioTunerCoordinate = position.coordinate;
showRadioTuner.call(this, { slot: position.stationIndex });
tunerPreview({ coordinate: position.coordinate });
return true;
};
const releaseNavigationBand = () => {
this._radioTunerBandPinnedForNavigation = false;
};
this.listen(
this.canvas,
'pointerdown',
releaseNavigationBand,
tunerListenerOptions,
);
this.listen(
this.canvas,
'wheel',
releaseNavigationBand,
tunerListenerOptions,
);
// The directory order is catalog/filter authority, not camera authority.
// Globe motion therefore never rebuilds or re-ranks the frequency band.
this._radioSelectedHandler = () =>
this.actions.setPanelCollapsed('radio-panel', false);
this.listen(document, 'gev:radio-selected', this._radioSelectedHandler);
// The Radio panel's and Cockpit's controls, and the tuner slider's events,
// call these through the store.
// Cockpit owns the Cesium camera even though it intentionally clears
// viewer.trackedEntity, so its station steps pass `rotate: false`: they
// must never start the map-view rotation/fallback flights that would
// compete with its preUpdate pose.
this._storeToken = this.store?.connect?.({
/** @param {'panel'|'cockpit'} where Which Enable button was pressed. */
toggleRadio: (where) =>
void toggleRadio(
where === 'cockpit'
? this._cockpitRadioEnableBtn
: this._radioEnableBtn,
),
toggleCockpitRadio: () =>
setCockpitDisclosure('radio', !this._cockpitDisclosureOpen('radio')),
/**
* @param {string} filter
* @returns {string} The station tag the filter shows afterwards.
*/
setRadioFilter: (filter) => {
if (!this.radio.getUIState().presentationActive)
return this.radio.getUIState().filter;
if (this._radioTunerDragging) finishTuner(false);
if (!this.actions.setParams({ filter }, { origin: 'user' }))
return this.radio.getUIState().filter;
this._radioTunerBandPinnedForNavigation = false;
this._radioTunerPool = [];
refreshTunerBand({ force: true });
return filter;
},
cycleRadio,
toggleRadioPlayback: () =>
void this.radio.togglePlayback({ origin: 'user' }),
stopRadio: () => this.radio.stopPlayback({ origin: 'user' }),
setRadioVolume: (percent) => {
const value = Number(percent);
showRadioVolume.call(this, value);
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
},
tunerPointerDown: (event) => {
if (!beginTuner()) return;
this._radioTunerPointerId = event.pointerId;
this._radioTunerSlider.focus({ preventScroll: true });
try {
this._radioTunerSlider.setPointerCapture(event.pointerId);
} catch {
/* capture is best effort */
}
updateTunerFromPointer(event);
event.preventDefault();
},
tunerPointerMove: (event) => {
if (
!this._radioTunerDragging ||
this._radioTunerPointerId !== event.pointerId
)
return;
updateTunerFromPointer(event);
event.preventDefault();
},
tunerInput: () => {
if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey)
return;
// A drag the input starts begins from the selected station's slot.
if (this._radioTunerDragging)
showRadioTuner.call(this, {
slot: Number(this._radioTunerSlider.value),
});
else if (!beginTuner()) return;
const inputSlot = radioTunerSlot(
this._radioTunerView.slot,
this._radioTunerStations.length,
);
if (inputSlot.slot > this._radioTunerLastSlot)
this._radioTunerDragDirection = 1;
else if (inputSlot.slot < this._radioTunerLastSlot)
this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = inputSlot.slot;
this._radioTunerCoordinate = inputSlot.stationIndex;
tunerPreview({ coordinate: this._radioTunerCoordinate });
},
tunerChange: () => {
if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey)
finishTuner(true);
},
tunerPointerUp: (event) => {
if (this._radioTunerPointerId !== event.pointerId) return;
updateTunerFromPointer(event);
finishTuner(true);
try {
this._radioTunerSlider.releasePointerCapture(event.pointerId);
} catch {
/* already released */
}
event.preventDefault();
},
tunerPointerCancel: (event) => {
if (
this._radioTunerPointerId !== null &&
this._radioTunerPointerId !== event.pointerId
)
return;
try {
this._radioTunerSlider.releasePointerCapture(event.pointerId);
} catch {
/* already released */
}
finishTuner(false);
},
tunerLostPointerCapture: (event) => {
if (
this._radioTunerDragging &&
this._radioTunerPointerId === event.pointerId
)
finishTuner(false);
},
tunerKeyDown: (event) => {
if (event.key === 'Escape' && this._radioTunerDragging) {
event.preventDefault();
finishTuner(false);
return;
}
if (
![
'ArrowLeft',
'ArrowRight',
'Home',
'End',
'PageUp',
'PageDown',
].includes(event.key)
)
return;
if (!this._radioTunerDragging && !beginTuner()) return;
event.preventDefault();
this._radioTunerKeyboardKey = event.key;
const max = Math.max(0, this._radioTunerStations.length - 1);
const current = radioTunerSlot(
this._radioTunerView.slot,
this._radioTunerStations.length,
).slot;
const page = Math.max(1, Math.round(max / 10));
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? max
: event.key === 'PageUp'
? current + page
: event.key === 'PageDown'
? current - page
: current + (event.key === 'ArrowRight' ? 1 : -1);
const slot = radioTunerSlot(next, this._radioTunerStations.length);
if (slot.slot > this._radioTunerLastSlot)
this._radioTunerDragDirection = 1;
else if (slot.slot < this._radioTunerLastSlot)
this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = slot.slot;
this._radioTunerCoordinate = slot.stationIndex;
showRadioTuner.call(this, { slot: slot.slot });
tunerPreview({ coordinate: this._radioTunerCoordinate });
},
tunerKeyUp: (event) => {
if (
!this._radioTunerKeyboardKey ||
event.key !== this._radioTunerKeyboardKey
)
return;
event.preventDefault();
finishTuner(true);
},
tunerBlur: () => finishTuner(true),
});
}