Files
gods-eye-view/src/ui/radioBindings.js
T
Said Turksever 66e19f0c50 Drop the Radio icon from Context and make the panel easier to read
The Context header carried a Radio icon that opened a second, compact
Radio player on top of the panel, duplicating the Radio section that
already lives inside Context. Remove the icon, the compact player and
everything that kept the two in step. Radio stays in Context as its own
section with one Enable control; Cockpit's compact Radio is unchanged.

Context itself was hard to read: 7-8 px labels at low contrast, a dashed
placeholder box, and a glow halo. The two modes are now one segmented
control, labels and guidance are 10 px at higher contrast, the standby
hint is plain text, section titles use --text-secondary, and the panel
draws no glow.

Unused translations are pruned, tests and QA harnesses that pinned the
compact player are updated or retired, and CURRENT-STATE and the
changelog describe the new header.
2026-10-29 11:28:56 +00:00

750 lines
25 KiB
JavaScript

import { t, msg } from '../i18n/index.js';
import {
radioTunerSlot,
radioTunerCommitSlot,
radioTunerPointerPosition,
buildRadioTunerTicks,
} 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);
if (this._cockpitDisplayPanel)
this._cockpitDisplayPanel.hidden = !displayOpen;
if (this._cockpitRadioPanel) this._cockpitRadioPanel.hidden = !radioOpen;
this._cockpitDisplayToggleBtn
?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', displayOpen);
this._cockpitRadioToggleBtn
?.closest('.cockpit-utility-control')
?.classList.toggle('is-expanded', radioOpen);
this._cockpitDisplayToggleBtn?.setAttribute(
'aria-expanded',
String(displayOpen),
);
this._cockpitRadioToggleBtn?.setAttribute(
'aria-expanded',
String(radioOpen),
);
if (displayOpen) this.actions.revealStyleParameters();
if (this._cockpitDisplayToggleBtn) {
this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀';
this._cockpitDisplayToggleBtn.setAttribute(
'aria-label',
t(
displayOpen
? msg('Collapse Cockpit display options')
: msg('Expand Cockpit display options'),
),
);
this._cockpitDisplayToggleBtn.title = t(
displayOpen
? msg('Collapse Cockpit display options')
: msg('Expand Cockpit display options'),
);
}
if (this._cockpitRadioToggleBtn) {
this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀';
this._cockpitRadioToggleBtn.setAttribute(
'aria-label',
t(
radioOpen
? msg('Collapse Cockpit Radio controls')
: msg('Expand Cockpit Radio controls'),
),
);
this._cockpitRadioToggleBtn.title = t(
radioOpen
? msg('Collapse Cockpit Radio controls')
: msg('Expand Cockpit Radio controls'),
);
}
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;
const syncTunerTape = (coordinate) => {
const scale = this._radioTuner?.querySelector('.radio-tuner-scale');
const dial = this._radioTuner?.querySelector('.radio-tuner-dial');
if (!scale || !dial) return null;
const model = buildRadioTunerTicks(
coordinate,
this._radioTunerStations.length,
dial.getBoundingClientRect().width,
);
while (scale.children.length < model.ticks.length) {
const tick = document.createElement('span');
tick.className = 'radio-tuner-tick';
scale.append(tick);
}
while (scale.children.length > model.ticks.length)
scale.lastElementChild?.remove();
model.ticks.forEach((entry, index) => {
const tick = scale.children[index];
tick.style.left = `${entry.xPx}px`;
tick.textContent = entry.label;
tick.dataset.stationIndex = String(entry.stationIndex);
tick.classList.toggle('is-current', entry.current);
});
scale.style.setProperty('--radio-tuner-tick-pitch', `${model.pitchPx}px`);
return model;
};
const tunerPreview = ({
coordinate = this._radioTunerCoordinate,
syncStatic = true,
rotate = true,
} = {}) => {
const slot = radioTunerSlot(
this._radioTunerSlider?.value,
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;
this._radioTuner?.style.setProperty('--radio-tuner-ratio', String(ratio));
this._radioTuner?.classList.toggle(
'is-static',
syncStatic ? false : Boolean(this._radioState?.tuningStatic),
);
syncTunerTape(resolvedCoordinate);
if (this._radioTunerValue) {
this._radioTunerValue.textContent = station
? t('CH {index} / {total}', {
index: String(slot.stationIndex + 1).padStart(2, '0'),
total: String(this._radioTunerStations.length).padStart(2, '0'),
})
: t('NO STATIONS');
}
if (this._radioTunerStation)
this._radioTunerStation.textContent =
station?.name || t('NO STATION AVAILABLE');
if (this._radioTunerSlider) {
this._radioTunerSlider.setAttribute(
'aria-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('|');
if (this._radioTunerSlider) {
this._radioTunerSlider.min = '0';
this._radioTunerSlider.max = String(
Math.max(0, this._radioTunerStations.length - 1),
);
this._radioTunerSlider.step = '1';
}
};
const refreshTunerBand = ({ force = false } = {}) => {
if (
this._radioTunerDragging ||
this._radioTuner?.hidden ||
this._radioTunerSlider?.disabled
)
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,
);
this._radioTunerSlider.value = String(slot.slot);
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false });
return true;
};
this._refreshRadioTunerBand = refreshTunerBand;
const beginTuner = () => {
if (this._radioTunerDragging || this._radioTunerSlider?.disabled)
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;
this._radioTuner?.classList.add('is-dragging');
const selectedIndex = this._radioTunerStations.findIndex(
(station) => station.id === this._radioState?.selected?.id,
);
const slot = radioTunerSlot(
selectedIndex >= 0 ? selectedIndex : 0,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(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;
if (this._radioTunerSlider) {
const startSlot = radioTunerSlot(
dragSnapshot.slot,
this._radioTunerStations.length,
);
this._radioTunerSlider.max = String(startSlot.max);
this._radioTunerSlider.value = String(startSlot.slot);
}
this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate)
? dragSnapshot.coordinate
: dragSnapshot.slot;
} else if (!commit && this._radioTunerSlider) {
this._radioTunerSlider.value = String(this._radioTunerDragStartSlot);
this._radioTunerCoordinate = this._radioTunerDragStartSlot;
}
let station = tunerPreview({
coordinate: this._radioTunerCoordinate,
rotate: commit,
});
if (commit && !station && this._radioTunerSlider) {
const snapped = radioTunerCommitSlot(
this._radioTunerSlider.value,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(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;
// Flush the snapped position while transitions are still disabled so
// removing the drag class cannot interpolate from the released gap.
void this._radioTunerNeedle?.offsetLeft;
this._radioTuner?.classList.remove('is-dragging');
if (result && !result.ok) {
this._radioTunerBandPinnedForNavigation = false;
if (result.reason === 'station-unavailable') {
if (this._radioTunerValue)
this._radioTunerValue.textContent = t('OFF AIR');
if (this._radioTunerStation)
this._radioTunerStation.textContent = t('STATION UNAVAILABLE');
this._radioTunerSlider?.setAttribute(
'aria-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;
}
};
const toggleRadio = async (trigger) => {
if (!this.actions.isRegistered()) return;
if (trigger.getAttribute('aria-busy') === 'true') return;
const enabling = !this.actions.isEnabled();
const revealAfterEnable = enabling && trigger === this._radioEnableBtn;
trigger.setAttribute('aria-disabled', 'true');
trigger.setAttribute('aria-busy', '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 {
if (!this.destroyed) {
trigger.setAttribute('aria-disabled', 'false');
trigger.setAttribute('aria-busy', 'false');
if (revealAfterEnable && trigger.isConnected)
trigger.focus({ preventScroll: true });
}
}
};
this.listen(
this._radioEnableBtn,
'click',
() => void toggleRadio(this._radioEnableBtn),
);
this.listen(
this._cockpitRadioEnableBtn,
'click',
() => void toggleRadio(this._cockpitRadioEnableBtn),
);
this.listen(this._cockpitRadioToggleBtn, 'click', () => {
const open =
this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true';
setCockpitDisclosure('radio', !open);
});
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._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true';
const radioOpen =
this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true';
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,
);
this.listen(this._radioFilter, 'change', () => {
const presentation = this.radio.getUIState();
if (!presentation.presentationActive) {
this._radioFilter.value = presentation.filter;
return;
}
if (this._radioTunerDragging) finishTuner(false);
if (
!this.actions.setParams(
{
filter: this._radioFilter.value,
},
{ origin: 'user' },
)
) {
this._radioFilter.value = this.radio.getUIState().filter;
return;
}
this._radioTunerBandPinnedForNavigation = false;
this._radioTunerPool = [];
refreshTunerBand({ force: true });
});
this.listen(this._radioPrevBtn, 'click', () => cycleRadio(-1));
this.listen(this._radioNextBtn, 'click', () => cycleRadio(1));
this.listen(
this._radioPlayBtn,
'click',
() => void this.radio.togglePlayback({ origin: 'user' }),
);
this.listen(this._radioStopBtn, 'click', () =>
this.radio.stopPlayback({ origin: 'user' }),
);
this.listen(this._radioVolume, 'input', () => {
const value = Number(this._radioVolume.value);
if (this._radioVolumeValue)
this._radioVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
// Cockpit owns the Cesium camera even though it intentionally clears
// viewer.trackedEntity. Station changes must never start the map-view
// rotation/fallback flights that would compete with its preUpdate pose.
this.listen(this._cockpitRadioPrevBtn, 'click', () =>
cycleRadio(-1, { rotate: false }),
);
this.listen(this._cockpitRadioNextBtn, 'click', () =>
cycleRadio(1, { rotate: false }),
);
this.listen(
this._cockpitRadioPlayBtn,
'click',
() => void this.radio.togglePlayback({ origin: 'user' }),
);
this.listen(this._cockpitRadioVolume, 'input', () => {
const value = Number(this._cockpitRadioVolume.value);
if (this._cockpitRadioVolumeValue)
this._cockpitRadioVolumeValue.textContent = `${value}%`;
this.actions.setParams({ volume: value / 100 }, { origin: 'user' });
});
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;
this._radioTunerSlider.value = String(position.stationIndex);
tunerPreview({ coordinate: position.coordinate });
return true;
};
this.listen(
this._radioTunerSlider,
'pointerdown',
(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();
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'pointermove',
(event) => {
if (
!this._radioTunerDragging ||
this._radioTunerPointerId !== event.pointerId
)
return;
updateTunerFromPointer(event);
event.preventDefault();
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'input',
() => {
if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey)
return;
if (!this._radioTunerDragging && !beginTuner()) return;
const inputSlot = radioTunerSlot(
this._radioTunerSlider.value,
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 });
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'change',
() => {
if (this._radioTunerPointerId === null && !this._radioTunerKeyboardKey)
finishTuner(true);
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'pointerup',
(event) => {
if (this._radioTunerPointerId !== event.pointerId) return;
updateTunerFromPointer(event);
finishTuner(true);
try {
this._radioTunerSlider.releasePointerCapture(event.pointerId);
} catch {
/* already released */
}
event.preventDefault();
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'pointercancel',
(event) => {
if (
this._radioTunerPointerId !== null &&
this._radioTunerPointerId !== event.pointerId
)
return;
try {
this._radioTunerSlider.releasePointerCapture(event.pointerId);
} catch {
/* already released */
}
finishTuner(false);
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'lostpointercapture',
(event) => {
if (
this._radioTunerDragging &&
this._radioTunerPointerId === event.pointerId
)
finishTuner(false);
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'keydown',
(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._radioTunerSlider.value,
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;
this._radioTunerSlider.value = String(slot.slot);
tunerPreview({ coordinate: this._radioTunerCoordinate });
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'keyup',
(event) => {
if (
!this._radioTunerKeyboardKey ||
event.key !== this._radioTunerKeyboardKey
)
return;
event.preventDefault();
finishTuner(true);
},
tunerListenerOptions,
);
this.listen(
this._radioTunerSlider,
'blur',
() => finishTuner(true),
tunerListenerOptions,
);
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);
}