Files
gods-eye-view/src/ui/radioControls.js
T
513dc87a0e feat(hud): Cyber HUD theme with GPU contact sonar (#706)
Add the opt-in Cyber HUD layout with coordinated map and cockpit panel
styling, GPU-only contact sonar (rings, range, power, opacity, sector) with
native-rendering fallback, and set_cyber_sonar voice controls. Sonar visual
style inspired by kk376's tactical naval sonar (#214).

Co-authored-by: Sameh Khamis <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-23 20:31:56 -07:00

236 lines
8.0 KiB
JavaScript

import { bindRadioControls } from './radioBindings.js';
import { renderRadioState } from './radioPresentation.js';
/** Own Radio DOM, tuner interaction and subscription; receive playback and application actions. */
export class RadioControls {
constructor({ elements, radio, actions, canvas }) {
Object.assign(this, elements);
this.radio = radio;
this.actions = actions;
this.canvas = canvas;
this.destroyed = false;
this.listeners = new AbortController();
this._radioUnsubscribe = null;
this._radioState = null;
this._radioCategorySignature = '';
this._radioTunerStations = [];
this._radioTunerPool = [];
this._radioTunerDragging = false;
this._radioTunerDragStartSlot = 0;
this._radioTunerDragSnapshot = null;
this._radioTunerLastSlot = 0;
this._radioTunerDragDirection = 0;
this._radioTunerCoordinate = 0;
this._radioTunerPointerId = null;
this._radioTunerKeyboardKey = null;
this._radioTunerBandSignature = '';
this._radioTunerSelectedId = null;
this._radioTunerBandPinnedForNavigation = false;
this._refreshRadioTunerBand = null;
bindRadioControls.call(this);
}
listen(target, type, handler, options = {}) {
target?.addEventListener(type, handler, {
...options,
signal: this.listeners.signal,
});
}
connect() {
this._radioUnsubscribe?.();
this._radioUnsubscribe = null;
if (this.destroyed) return;
this._radioUnsubscribe = this.radio.subscribe?.((state) =>
this._renderRadioState(state),
);
}
_renderRadioState(state) {
renderRadioState.call(this, state);
}
/**
* Reveal the newly enabled directory and transport inside Context without
* moving focus, the page, or the globe. Only the expanded Enable path calls
* this helper.
* @param {HTMLElement} trigger Initiating Radio Enable button.
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioControlsAfterExplicitEnable(trigger) {
const contextPanel = document.getElementById('global-context-panel');
const scroller =
(document.documentElement?.dataset.uiTheme === 'cyber' &&
contextPanel?.querySelector('.cyber-panel-body')) ||
contextPanel?.querySelector('.global-context-panel-inner');
const directory = this._radioPanel?.querySelector('.radio-directory-row');
const transport = this._radioPanel?.querySelector('.radio-transport');
if (
!contextPanel ||
contextPanel.classList.contains('collapsed') ||
!scroller ||
!directory ||
!transport ||
!this._radioState?.enabled
)
return false;
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
if (this.destroyed || !this._radioState?.enabled || !trigger?.isConnected)
return false;
const viewport = scroller.getBoundingClientRect();
const directoryRect = directory.getBoundingClientRect();
const transportRect = transport.getBoundingClientRect();
const margin = 10;
const minimum =
scroller.scrollTop + transportRect.bottom - (viewport.bottom - margin);
const maximum =
scroller.scrollTop + directoryRect.top - (viewport.top + margin);
const desired =
minimum <= maximum
? Math.min(Math.max(scroller.scrollTop, minimum), maximum)
: minimum;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
if (Math.abs(next - scroller.scrollTop) < 1) return false;
const reducedMotion = window.matchMedia?.(
'(prefers-reduced-motion: reduce)',
)?.matches;
scroller.scrollTo({
top: next,
behavior: reducedMotion ? 'auto' : 'smooth',
});
return true;
}
/**
* Bring the embedded Radio section into the expanded Context scroller.
* This never changes Radio power, playback, selection, or Context mode.
* @param {{focusTarget?: HTMLElement|null}} [options]
* @returns {Promise<boolean>} Whether the internal scroller moved.
*/
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
const contextPanel = document.getElementById('global-context-panel');
const scroller =
(document.documentElement?.dataset.uiTheme === 'cyber' &&
contextPanel?.querySelector('.cyber-panel-body')) ||
contextPanel?.querySelector('.global-context-panel-inner');
if (
!contextPanel ||
contextPanel.classList.contains('collapsed') ||
!scroller ||
!this._radioPanel ||
this._radioPanel.classList.contains('collapsed')
)
return false;
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
if (
this.destroyed ||
contextPanel.classList.contains('collapsed') ||
this._radioPanel.classList.contains('collapsed')
)
return false;
const viewport = scroller.getBoundingClientRect();
const radioRect = this._radioPanel.getBoundingClientRect();
const desired = scroller.scrollTop + radioRect.top - viewport.top - 10;
const next = Math.min(
Math.max(0, scroller.scrollHeight - scroller.clientHeight),
Math.max(0, desired),
);
const moved = Math.abs(next - scroller.scrollTop) >= 1;
if (moved) {
const reducedMotion = window.matchMedia?.(
'(prefers-reduced-motion: reduce)',
)?.matches;
scroller.scrollTo({
top: next,
behavior: reducedMotion ? 'auto' : 'smooth',
});
}
focusTarget?.focus?.({ preventScroll: true });
return moved;
}
/** Keep the launcher truthful for the active theme and Context route. */
_syncContextRadioLauncherState() {
if (this.destroyed || !this._contextRadioToggleBtn) return;
const contextPanel = globalThis.document?.getElementById(
'global-context-panel',
);
const contextExpanded = Boolean(
contextPanel && !contextPanel.classList.contains('collapsed'),
);
if (contextExpanded) {
const radioExpanded = Boolean(
this._radioPanel && !this._radioPanel.classList.contains('collapsed'),
);
this._contextRadioToggleBtn.setAttribute('aria-controls', 'radio-panel');
this._contextRadioToggleBtn.setAttribute(
'aria-expanded',
String(radioExpanded),
);
const label = radioExpanded
? 'Go to expanded Radio section'
: 'Expand Radio section in Context';
this._contextRadioToggleBtn.setAttribute('aria-label', label);
this._contextRadioToggleBtn.title = label;
return;
}
// This launcher toggles the compact player even when the detailed panel
// is open elsewhere. Its ARIA state must describe that same disclosure.
const compactOpen = Boolean(
this._contextRadioDock?.classList.contains('disclosure-open'),
);
this._contextRadioToggleBtn.setAttribute(
'aria-controls',
'context-radio-mini',
);
this._contextRadioToggleBtn.setAttribute(
'aria-expanded',
String(compactOpen),
);
const action = compactOpen ? 'Close' : 'Open';
this._contextRadioToggleBtn.setAttribute(
'aria-label',
`${action} compact Radio controls`,
);
this._contextRadioToggleBtn.title = `${action} compact Radio controls`;
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.listeners.abort();
this._radioUnsubscribe?.();
this._radioUnsubscribe = null;
const pointer = this._radioTunerPointerId;
this._radioTunerPointerId = null;
this._radioTunerDragging = false;
this._radioTunerKeyboardKey = null;
this._radioTunerDragSnapshot = null;
this._radioTunerPool = [];
this._radioTunerStations = [];
this._radioTunerBandPinnedForNavigation = false;
try {
if (pointer !== null)
this._radioTunerSlider?.releasePointerCapture(pointer);
} catch {
/* capture already released */
}
this._radioTuner?.classList.remove('is-dragging', 'is-static');
document
.getElementById('title-bar')
?.classList.remove('radio-broadcasting');
this.radio.endTuning();
}
}