mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Merge pull request #398 from bilawalsidhu/refactor/radio-controls
refactor: isolate Radio controls and tuner presentation
This commit is contained in:
22 files changed
+2207
-1107
No files matched your search
@@ -13,6 +13,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Restore UI observer, resize-listener and CCTV subscription cleanup after Location extraction.
|
||||||
|
|
||||||
|
- Extract Radio controls and tuner presentation with explicit actions and complete listener/subscription cleanup.
|
||||||
|
|
||||||
- Extract Location controls and cancellable search presentation; preserve navigation handoff and prevent delayed POI expansion after closing the row.
|
- Extract Location controls and cancellable search presentation; preserve navigation handoff and prevent delayed POI expansion after closing the row.
|
||||||
|
|
||||||
- Separate Layers panel presentation and clear-control bindings from layer lifecycle operations; revoke listeners and subscriptions on replacement or teardown.
|
- Separate Layers panel presentation and clear-control bindings from layer lifecycle operations; revoke listeners and subscriptions on replacement or teardown.
|
||||||
|
|||||||
@@ -287,3 +287,11 @@ the existing location-status formatter. Callers supply city data, search and
|
|||||||
navigation operations. The component owns DOM listeners and pending expansion;
|
navigation operations. The component owns DOM listeners and pending expansion;
|
||||||
it imports no geocoder, camera engine, layer or application bootstrap. Existing
|
it imports no geocoder, camera engine, layer or application bootstrap. Existing
|
||||||
camera authority and search providers remain supplied by the application.
|
camera authority and search providers remain supplied by the application.
|
||||||
|
|
||||||
|
### Radio controls
|
||||||
|
|
||||||
|
`ui/radio` owns Radio input, disclosures, tuner state and presentation. It
|
||||||
|
receives the existing Radio port and explicit layer/layout actions, without
|
||||||
|
importing the renderer or station providers. Pure tuner calculations retain
|
||||||
|
compatibility exports from the data layer. Disposal revokes DOM listeners and
|
||||||
|
subscriptions before ending the active tuning interaction.
|
||||||
@@ -1,5 +1,21 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
## UI disposal
|
||||||
|
|
||||||
|
UI disposal releases the CCTV subscription, command-dock observer, legacy drag
|
||||||
|
resize observer and window resize listener. These remain independent of the
|
||||||
|
Location component and are included in whole-UI browser teardown acceptance.
|
||||||
|
|
||||||
|
## Radio controls
|
||||||
|
|
||||||
|
Radio panel, compact controls and tuner presentation have a dedicated owner.
|
||||||
|
It receives playback, layer actions and layout callbacks; station ingestion,
|
||||||
|
marker placement, audio playback and camera policy stay with their existing
|
||||||
|
owners. Tuner calculations have a pure entry, with existing layer exports
|
||||||
|
preserved. Destruction removes listeners and state subscriptions before ending
|
||||||
|
tuning; a delayed Enable result cannot reveal or refocus removed controls.
|
||||||
|
|
||||||
|
|
||||||
## Location control ownership
|
## Location control ownership
|
||||||
|
|
||||||
City/POI rows, search/reset bindings, location readouts and the orbit indicator
|
City/POI rows, search/reset bindings, location readouts and the orbit indicator
|
||||||
|
|||||||
+2
-1
@@ -129,6 +129,7 @@
|
|||||||
"./ui/maps": "./src/ui/mapSource.js",
|
"./ui/maps": "./src/ui/mapSource.js",
|
||||||
"./ui/layers": "./src/ui/layers.js",
|
"./ui/layers": "./src/ui/layers.js",
|
||||||
"./ui/layers/feedback": "./src/loadingFeedback.js",
|
"./ui/layers/feedback": "./src/loadingFeedback.js",
|
||||||
"./ui/location": "./src/ui/location.js"
|
"./ui/location": "./src/ui/location.js",
|
||||||
|
"./ui/radio": "./src/ui/radio.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,5 +166,12 @@
|
|||||||
"src/locationStatus.js",
|
"src/locationStatus.js",
|
||||||
"src/ui/locationControls.test.mjs",
|
"src/ui/locationControls.test.mjs",
|
||||||
"src/ui/locationSearch.test.mjs",
|
"src/ui/locationSearch.test.mjs",
|
||||||
"scripts/qa-location-controls.mjs"
|
"scripts/qa-location-controls.mjs",
|
||||||
|
"src/ui/radio.js",
|
||||||
|
"src/ui/radioControls.js",
|
||||||
|
"src/ui/radioBindings.js",
|
||||||
|
"src/ui/radioPresentation.js",
|
||||||
|
"src/ui/radioTunerModel.js",
|
||||||
|
"src/ui/radioControls.test.mjs",
|
||||||
|
"scripts/qa-ui-disposal.mjs"
|
||||||
]
|
]
|
||||||
@@ -346,5 +346,16 @@
|
|||||||
"src/locationStatus.js"
|
"src/locationStatus.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
|
},
|
||||||
|
"radio-controls": {
|
||||||
|
"exports": ["./ui/radio"],
|
||||||
|
"modules": [
|
||||||
|
"src/ui/radio.js",
|
||||||
|
"src/ui/radioControls.js",
|
||||||
|
"src/ui/radioBindings.js",
|
||||||
|
"src/ui/radioPresentation.js",
|
||||||
|
"src/ui/radioTunerModel.js"
|
||||||
|
],
|
||||||
|
"external": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+141
-70
@@ -25,6 +25,10 @@ const option = (name, fallback) => {
|
|||||||
};
|
};
|
||||||
const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173');
|
const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173');
|
||||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||||
|
// Radio owns this harness's keyboard and pointer actions; the welcome dialog
|
||||||
|
// has its own acceptance harness and must not intercept those gestures.
|
||||||
|
const RADIO_URL = new URL(APP_URL);
|
||||||
|
RADIO_URL.searchParams.set('welcome', '0');
|
||||||
const HEADFUL = args.includes('--headful');
|
const HEADFUL = args.includes('--headful');
|
||||||
|
|
||||||
const chromeCandidates = [
|
const chromeCandidates = [
|
||||||
@@ -76,6 +80,22 @@ function check(name, ok, detail = '') {
|
|||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// Native pick buffers and clustered overlay entries settle on rendered frames,
|
||||||
|
// not on wall-clock sleeps, particularly with software rendering.
|
||||||
|
async function settleRadioFrames(page) {
|
||||||
|
await page.evaluate(() => new Promise((resolve, reject) => {
|
||||||
|
const scene = window.__godsEyeView.viewer.scene;
|
||||||
|
let remaining = 4;
|
||||||
|
const timeout = setTimeout(() => { remove(); reject(new Error('Radio rendered-frame deadline exceeded')); }, 10_000);
|
||||||
|
const remove = scene.postRender.addEventListener(() => {
|
||||||
|
if (--remaining === 0) { remove(); clearTimeout(timeout); resolve(); }
|
||||||
|
else requestAnimationFrame(() => scene.requestRender());
|
||||||
|
});
|
||||||
|
scene.requestRender();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const response = await fetch(APP_URL).catch(() => null);
|
const response = await fetch(APP_URL).catch(() => null);
|
||||||
if (!response?.ok) {
|
if (!response?.ok) {
|
||||||
@@ -87,7 +107,10 @@ async function main() {
|
|||||||
headless: HEADFUL ? false : 'new',
|
headless: HEADFUL ? false : 'new',
|
||||||
...(chrome ? { executablePath: chrome } : {}),
|
...(chrome ? { executablePath: chrome } : {}),
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle',
|
'--no-sandbox', '--disable-setuid-sandbox',
|
||||||
|
...(process.platform === 'darwin'
|
||||||
|
? ['--use-angle=metal', '--enable-gpu']
|
||||||
|
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||||
'--disable-dev-shm-usage', '--disable-background-timer-throttling',
|
'--disable-dev-shm-usage', '--disable-background-timer-throttling',
|
||||||
'--disable-renderer-backgrounding', '--window-size=1440,900',
|
'--disable-renderer-backgrounding', '--window-size=1440,900',
|
||||||
],
|
],
|
||||||
@@ -213,7 +236,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
await page.goto(RADIO_URL.href, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60_000 });
|
await page.waitForFunction(() => window.__godsEyeView?.dataManager, { timeout: 60_000 });
|
||||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager?._dataManager?.layers?.has('radio'), { timeout: 60_000 });
|
await page.waitForFunction(() => window.__godsEyeView?.styleManager?._dataManager?.layers?.has('radio'), { timeout: 60_000 });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
@@ -281,7 +304,7 @@ async function main() {
|
|||||||
fullPlayEnabled: !document.getElementById('radio-play-btn').disabled,
|
fullPlayEnabled: !document.getElementById('radio-play-btn').disabled,
|
||||||
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
|
fullPlayLabel: document.getElementById('radio-play-btn').getAttribute('aria-label'),
|
||||||
tunerVisible: !document.getElementById('radio-tuner').hidden,
|
tunerVisible: !document.getElementById('radio-tuner').hidden,
|
||||||
tunerCount: window.__godsEyeView.styleManager._radioTunerStations.length,
|
tunerCount: window.__godsEyeView.styleManager._radioControls._radioTunerStations.length,
|
||||||
tunerLabel: document.getElementById('radio-tuner-band-label').textContent,
|
tunerLabel: document.getElementById('radio-tuner-band-label').textContent,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -332,19 +355,19 @@ async function main() {
|
|||||||
const staticResult = module.setTuningStatic(true);
|
const staticResult = module.setTuningStatic(true);
|
||||||
const filterBefore = module.getUIState().filter;
|
const filterBefore = module.getUIState().filter;
|
||||||
const filterControl = document.getElementById('radio-filter');
|
const filterControl = document.getElementById('radio-filter');
|
||||||
const originalPinned = style._radioTunerBandPinnedForNavigation;
|
const originalPinned = style._radioControls._radioTunerBandPinnedForNavigation;
|
||||||
const originalPool = style._radioTunerPool;
|
const originalPool = style._radioControls._radioTunerPool;
|
||||||
style._radioTunerBandPinnedForNavigation = true;
|
style._radioControls._radioTunerBandPinnedForNavigation = true;
|
||||||
filterControl.value = filterBefore === 'news' ? 'all' : 'news';
|
filterControl.value = filterBefore === 'news' ? 'all' : 'news';
|
||||||
filterControl.dispatchEvent(new Event('change', { bubbles: true }));
|
filterControl.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
const rejectedUIFilter = {
|
const rejectedUIFilter = {
|
||||||
moduleFilter: module.getUIState().filter,
|
moduleFilter: module.getUIState().filter,
|
||||||
controlFilter: filterControl.value,
|
controlFilter: filterControl.value,
|
||||||
pinned: style._radioTunerBandPinnedForNavigation,
|
pinned: style._radioControls._radioTunerBandPinnedForNavigation,
|
||||||
poolIdentityPreserved: style._radioTunerPool === originalPool,
|
poolIdentityPreserved: style._radioControls._radioTunerPool === originalPool,
|
||||||
};
|
};
|
||||||
style._radioTunerBandPinnedForNavigation = originalPinned;
|
style._radioControls._radioTunerBandPinnedForNavigation = originalPinned;
|
||||||
style._radioTunerPool = originalPool;
|
style._radioControls._radioTunerPool = originalPool;
|
||||||
const filterResult = module.setFilter('news');
|
const filterResult = module.setFilter('news');
|
||||||
const toggleResult = await module.togglePlayback({ origin: 'user' });
|
const toggleResult = await module.togglePlayback({ origin: 'user' });
|
||||||
const directSelect = module.selectStation(stationId, {
|
const directSelect = module.selectStation(stationId, {
|
||||||
@@ -571,7 +594,14 @@ async function main() {
|
|||||||
await page.evaluate(() => window.__qaReleaseRadioEnable?.());
|
await page.evaluate(() => window.__qaReleaseRadioEnable?.());
|
||||||
await page.waitForFunction(() => window.__godsEyeView.dataManager.isEnabled('radio'));
|
await page.waitForFunction(() => window.__godsEyeView.dataManager.isEnabled('radio'));
|
||||||
await page.evaluate(() => window.__qaRestoreRadioEnable?.());
|
await page.evaluate(() => window.__qaRestoreRadioEnable?.());
|
||||||
await sleep(650);
|
await page.waitForFunction(() => {
|
||||||
|
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
||||||
|
const viewport = scroller.getBoundingClientRect();
|
||||||
|
const directory = document.querySelector('#radio-panel .radio-directory-row').getBoundingClientRect();
|
||||||
|
const play = document.getElementById('radio-play-btn').getBoundingClientRect();
|
||||||
|
return directory.top >= viewport.top && directory.bottom <= viewport.bottom
|
||||||
|
&& play.top >= viewport.top && play.bottom <= viewport.bottom;
|
||||||
|
}, { timeout: 10_000 });
|
||||||
const explicitRevealAfter = await page.evaluate(() => {
|
const explicitRevealAfter = await page.evaluate(() => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
const scroller = document.querySelector('#global-context-panel .global-context-panel-inner');
|
||||||
@@ -638,6 +668,7 @@ async function main() {
|
|||||||
viewer.scene.requestRender();
|
viewer.scene.requestRender();
|
||||||
}, { lon: spec.lon, height: view.height });
|
}, { lon: spec.lon, height: view.height });
|
||||||
await sleep(700);
|
await sleep(700);
|
||||||
|
await settleRadioFrames(page);
|
||||||
const sample = await page.evaluate(async () => {
|
const sample = await page.evaluate(async () => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||||
@@ -1320,12 +1351,9 @@ async function main() {
|
|||||||
&& clusterBadge?.rect?.h > 0,
|
&& clusterBadge?.rect?.h > 0,
|
||||||
JSON.stringify(clusterBadge),
|
JSON.stringify(clusterBadge),
|
||||||
);
|
);
|
||||||
const highGlobalClusterLabels = await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
|
||||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||||
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
|
||||||
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
|
|
||||||
viewer.camera.cancelFlight();
|
viewer.camera.cancelFlight();
|
||||||
viewer.camera.setView({
|
viewer.camera.setView({
|
||||||
destination: ellipsoid.cartographicToCartesian({
|
destination: ellipsoid.cartographicToCartesian({
|
||||||
@@ -1337,6 +1365,14 @@ async function main() {
|
|||||||
});
|
});
|
||||||
viewer.scene.requestRender();
|
viewer.scene.requestRender();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
});
|
||||||
|
await settleRadioFrames(page);
|
||||||
|
const highGlobalClusterLabels = await page.evaluate(async () => {
|
||||||
|
const viewer = window.__godsEyeView.viewer;
|
||||||
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||||
|
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||||
|
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||||
|
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
|
||||||
const source = radio.getOverlayDiagnostics();
|
const source = radio.getOverlayDiagnostics();
|
||||||
const cameraPosition = viewer.camera.positionWC;
|
const cameraPosition = viewer.camera.positionWC;
|
||||||
const cameraRadius = Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z);
|
const cameraRadius = Math.hypot(cameraPosition.x, cameraPosition.y, cameraPosition.z);
|
||||||
@@ -1347,11 +1383,23 @@ async function main() {
|
|||||||
const points = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
const points = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
||||||
.find((item) => item.name === 'Radio stations')?.clustering?._clusterPointCollection;
|
.find((item) => item.name === 'Radio stations')?.clustering?._clusterPointCollection;
|
||||||
let clusterPoint = null;
|
let clusterPoint = null;
|
||||||
|
// Pool order is not visibility order after a camera change. Choose an
|
||||||
|
// on-screen point independently of the native pick result being tested.
|
||||||
|
const visiblePoints = [];
|
||||||
for (let index = 0; index < (points?.length || 0); index += 1) {
|
for (let index = 0; index < (points?.length || 0); index += 1) {
|
||||||
const point = points.get(index);
|
const point = points.get(index);
|
||||||
if (!point?.show || !Array.isArray(point.id) || !point.id.length) continue;
|
if (!point?.show || !Array.isArray(point.id) || !point.id.length) continue;
|
||||||
const anchor = viewer.scene.cartesianToCanvasCoordinates(point.position);
|
const anchor = viewer.scene.cartesianToCanvasCoordinates(point.position);
|
||||||
if (!anchor) continue;
|
if (!anchor) continue;
|
||||||
|
if (anchor.x < 0 || anchor.y < 0 || anchor.x > viewer.canvas.clientWidth || anchor.y > viewer.canvas.clientHeight) continue;
|
||||||
|
visiblePoints.push({ point, anchor });
|
||||||
|
}
|
||||||
|
visiblePoints.sort((a, b) => (
|
||||||
|
Math.hypot(a.anchor.x - viewer.canvas.clientWidth / 2, a.anchor.y - viewer.canvas.clientHeight / 2)
|
||||||
|
- Math.hypot(b.anchor.x - viewer.canvas.clientWidth / 2, b.anchor.y - viewer.canvas.clientHeight / 2)
|
||||||
|
));
|
||||||
|
if (visiblePoints.length) {
|
||||||
|
const { point, anchor } = visiblePoints[0];
|
||||||
const exactPick = (viewer.scene.drillPick(anchor, 16) || []).find((picked) => (
|
const exactPick = (viewer.scene.drillPick(anchor, 16) || []).find((picked) => (
|
||||||
picked?.primitive === point && picked?.id === point.id
|
picked?.primitive === point && picked?.id === point.id
|
||||||
));
|
));
|
||||||
@@ -1359,7 +1407,6 @@ async function main() {
|
|||||||
maxDistance: point.distanceDisplayCondition?.far ?? null,
|
maxDistance: point.distanceDisplayCondition?.far ?? null,
|
||||||
pickable: Boolean(exactPick),
|
pickable: Boolean(exactPick),
|
||||||
};
|
};
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
source,
|
source,
|
||||||
@@ -1875,7 +1922,10 @@ async function main() {
|
|||||||
hudVariant: manager.hud.getVariant(),
|
hudVariant: manager.hud.getVariant(),
|
||||||
focusId: document.activeElement?.id || null,
|
focusId: document.activeElement?.id || null,
|
||||||
};
|
};
|
||||||
const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320));
|
const waitForLayout = async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 320));
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
};
|
||||||
const cases = [];
|
const cases = [];
|
||||||
|
|
||||||
manager.setPanelCollapsed('global-context-panel', true);
|
manager.setPanelCollapsed('global-context-panel', true);
|
||||||
@@ -1952,7 +2002,10 @@ async function main() {
|
|||||||
// owns the real tracked-aircraft camera session. Hold the frame update so
|
// owns the real tracked-aircraft camera session. Hold the frame update so
|
||||||
// the intentionally synthetic Cockpit shell is not auto-exited mid-check.
|
// the intentionally synthetic Cockpit shell is not auto-exited mid-check.
|
||||||
const realCockpitUpdate = manager.cockpitView.update;
|
const realCockpitUpdate = manager.cockpitView.update;
|
||||||
const waitForLayout = () => new Promise((resolve) => setTimeout(resolve, 320));
|
const waitForLayout = async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 320));
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
};
|
||||||
const prior = {
|
const prior = {
|
||||||
cockpit: document.body.classList.contains('cockpit-mode'),
|
cockpit: document.body.classList.contains('cockpit-mode'),
|
||||||
cockpitActive: manager.cockpitView.active,
|
cockpitActive: manager.cockpitView.active,
|
||||||
@@ -2057,7 +2110,10 @@ async function main() {
|
|||||||
};
|
};
|
||||||
// The Intel HUD fades over 400ms and keeps its readout rects for the
|
// The Intel HUD fades over 400ms and keeps its readout rects for the
|
||||||
// whole transition, so both lanes are measured only once it has settled.
|
// whole transition, so both lanes are measured only once it has settled.
|
||||||
const waitForHudSettle = () => new Promise((resolve) => setTimeout(resolve, 560));
|
const waitForHudSettle = async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 560));
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
};
|
||||||
for (let index = 0; index < 5; index += 1) {
|
for (let index = 0; index < 5; index += 1) {
|
||||||
recordLayoutStep();
|
recordLayoutStep();
|
||||||
if (index < 4) {
|
if (index < 4) {
|
||||||
@@ -2208,14 +2264,17 @@ async function main() {
|
|||||||
const stableTop = utility.getBoundingClientRect().top;
|
const stableTop = utility.getBoundingClientRect().top;
|
||||||
const transitionTops = [];
|
const transitionTops = [];
|
||||||
let sampling = true;
|
let sampling = true;
|
||||||
|
let sampleFrame = null;
|
||||||
const sampleTop = () => {
|
const sampleTop = () => {
|
||||||
|
if (!sampling) return;
|
||||||
transitionTops.push(utility.getBoundingClientRect().top);
|
transitionTops.push(utility.getBoundingClientRect().top);
|
||||||
if (sampling) requestAnimationFrame(sampleTop);
|
sampleFrame = requestAnimationFrame(sampleTop);
|
||||||
};
|
};
|
||||||
requestAnimationFrame(sampleTop);
|
sampleFrame = requestAnimationFrame(sampleTop);
|
||||||
await manager._setMapStack('osm', { syncShare: false });
|
await manager._setMapStack('osm', { syncShare: false });
|
||||||
await waitForLayout();
|
await waitForLayout();
|
||||||
sampling = false;
|
sampling = false;
|
||||||
|
cancelAnimationFrame(sampleFrame);
|
||||||
mapProviderUtilityStable = transitionTops.length > 1
|
mapProviderUtilityStable = transitionTops.length > 1
|
||||||
&& transitionTops.every((top) => Math.abs(top - stableTop) < 1);
|
&& transitionTops.every((top) => Math.abs(top - stableTop) < 1);
|
||||||
}
|
}
|
||||||
@@ -2652,9 +2711,15 @@ async function main() {
|
|||||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||||
await radio.play();
|
await radio.play();
|
||||||
const beforeSpace = radio.getUIState().audioState;
|
const beforeSpace = radio.getUIState().audioState;
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, code: 'Space', key: ' ' }));
|
// Simulate an already-started hold-Space session; click-started open mic
|
||||||
|
// deliberately ignores Space takeover, and short taps never claim voice.
|
||||||
|
const priorPushToTalkMode = voice.pushToTalkMode;
|
||||||
|
voice.pushToTalkMode = true;
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||||
const afterSpace = radio.getUIState().audioState;
|
const afterSpace = radio.getUIState().audioState;
|
||||||
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, code: 'Space', key: ' ' }));
|
document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' }));
|
||||||
|
voice.pushToTalkMode = priorPushToTalkMode;
|
||||||
await radio.play();
|
await radio.play();
|
||||||
voice.setVoiceSpeaker('ai');
|
voice.setVoiceSpeaker('ai');
|
||||||
return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState };
|
return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState };
|
||||||
@@ -2719,7 +2784,7 @@ async function main() {
|
|||||||
const dialRect = document.querySelector('.radio-tuner-dial').getBoundingClientRect();
|
const dialRect = document.querySelector('.radio-tuner-dial').getBoundingClientRect();
|
||||||
const sliderRect = slider.getBoundingClientRect();
|
const sliderRect = slider.getBoundingClientRect();
|
||||||
const panelRect = document.querySelector('.radio-panel-inner').getBoundingClientRect();
|
const panelRect = document.querySelector('.radio-panel-inner').getBoundingClientRect();
|
||||||
const count = style._radioTunerStations.length;
|
const count = style._radioControls._radioTunerStations.length;
|
||||||
const usable = sliderRect.width - 14;
|
const usable = sliderRect.width - 14;
|
||||||
const xFor = (coordinate) => sliderRect.left + 7 + usable * coordinate / Math.max(1, count - 1);
|
const xFor = (coordinate) => sliderRect.left + 7 + usable * coordinate / Math.max(1, count - 1);
|
||||||
const pointer = (type, x, pointerId = 71) => slider.dispatchEvent(new PointerEvent(type, {
|
const pointer = (type, x, pointerId = 71) => slider.dispatchEvent(new PointerEvent(type, {
|
||||||
@@ -2745,7 +2810,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
const playsBefore = window.__qaRadioPlayCalls.length;
|
const playsBefore = window.__qaRadioPlayCalls.length;
|
||||||
const frozenSignature = style._radioTunerBandSignature;
|
const frozenSignature = style._radioControls._radioTunerBandSignature;
|
||||||
pointer('pointerdown', xFor(0));
|
pointer('pointerdown', xFor(0));
|
||||||
const left = snapshot();
|
const left = snapshot();
|
||||||
const centerCoordinate = (count - 1) / 2;
|
const centerCoordinate = (count - 1) / 2;
|
||||||
@@ -2765,14 +2830,14 @@ async function main() {
|
|||||||
visible: !document.getElementById('radio-tuner').hidden,
|
visible: !document.getElementById('radio-tuner').hidden,
|
||||||
stationCount: count,
|
stationCount: count,
|
||||||
sliderMax: Number(slider.max),
|
sliderMax: Number(slider.max),
|
||||||
bandFrozen: style._radioTunerBandSignature === frozenSignature,
|
bandFrozen: style._radioControls._radioTunerBandSignature === frozenSignature,
|
||||||
left,
|
left,
|
||||||
center,
|
center,
|
||||||
shifted,
|
shifted,
|
||||||
right,
|
right,
|
||||||
leftExpectedId: style._radioTunerStations[0]?.id || null,
|
leftExpectedId: style._radioControls._radioTunerStations[0]?.id || null,
|
||||||
centerExpectedId: style._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null,
|
centerExpectedId: style._radioControls._radioTunerStations[Math.floor(centerCoordinate + 0.5)]?.id || null,
|
||||||
rightExpectedId: style._radioTunerStations[count - 1]?.id || null,
|
rightExpectedId: style._radioControls._radioTunerStations[count - 1]?.id || null,
|
||||||
centerExpected: Math.floor(centerCoordinate + 0.5),
|
centerExpected: Math.floor(centerCoordinate + 0.5),
|
||||||
tapeDelta,
|
tapeDelta,
|
||||||
needleDelta,
|
needleDelta,
|
||||||
@@ -2817,7 +2882,7 @@ async function main() {
|
|||||||
const slider = document.getElementById('radio-tuner-slider');
|
const slider = document.getElementById('radio-tuner-slider');
|
||||||
// Capture a fresh gesture's complete presentation anchor before moving
|
// Capture a fresh gesture's complete presentation anchor before moving
|
||||||
// to a different absolute directory station.
|
// to a different absolute directory station.
|
||||||
const beforeStations = [...style._radioTunerStations];
|
const beforeStations = [...style._radioControls._radioTunerStations];
|
||||||
const sliderRect = slider.getBoundingClientRect();
|
const sliderRect = slider.getBoundingClientRect();
|
||||||
const xFor = (index) => sliderRect.left + 7
|
const xFor = (index) => sliderRect.left + 7
|
||||||
+ (sliderRect.width - 14) * index / Math.max(1, beforeStations.length - 1);
|
+ (sliderRect.width - 14) * index / Math.max(1, beforeStations.length - 1);
|
||||||
@@ -2834,7 +2899,7 @@ async function main() {
|
|||||||
const flyToCalls = [];
|
const flyToCalls = [];
|
||||||
camera.flyTo = (options) => flyToCalls.push(options);
|
camera.flyTo = (options) => flyToCalls.push(options);
|
||||||
const before = {
|
const before = {
|
||||||
signature: style._radioTunerBandSignature,
|
signature: style._radioControls._radioTunerBandSignature,
|
||||||
ids: beforeStations.map((station) => station.id),
|
ids: beforeStations.map((station) => station.id),
|
||||||
slot: Number(slider.value),
|
slot: Number(slider.value),
|
||||||
selectedId: radio.getUIState().selected?.id || null,
|
selectedId: radio.getUIState().selected?.id || null,
|
||||||
@@ -2848,10 +2913,10 @@ async function main() {
|
|||||||
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
|
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
|
||||||
const flyToCallsBeforeCancel = flyToCalls.length;
|
const flyToCallsBeforeCancel = flyToCalls.length;
|
||||||
pointer('pointercancel', targetIndex);
|
pointer('pointercancel', targetIndex);
|
||||||
const afterStations = style._radioTunerStations;
|
const afterStations = style._radioControls._radioTunerStations;
|
||||||
const afterState = radio.getUIState();
|
const afterState = radio.getUIState();
|
||||||
const after = {
|
const after = {
|
||||||
signature: style._radioTunerBandSignature,
|
signature: style._radioControls._radioTunerBandSignature,
|
||||||
ids: afterStations.map((station) => station.id),
|
ids: afterStations.map((station) => station.id),
|
||||||
slot: Number(slider.value),
|
slot: Number(slider.value),
|
||||||
selectedId: afterState.selected?.id || null,
|
selectedId: afterState.selected?.id || null,
|
||||||
@@ -2972,7 +3037,7 @@ async function main() {
|
|||||||
const tunerRefreshTarget = await page.evaluate(() => {
|
const tunerRefreshTarget = await page.evaluate(() => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const slider = document.getElementById('radio-tuner-slider');
|
const slider = document.getElementById('radio-tuner-slider');
|
||||||
const stations = gev.styleManager._radioTunerStations;
|
const stations = gev.styleManager._radioControls._radioTunerStations;
|
||||||
const targetIndex = 1;
|
const targetIndex = 1;
|
||||||
const rect = slider.getBoundingClientRect();
|
const rect = slider.getBoundingClientRect();
|
||||||
const clientX = rect.left + 7 + (rect.width - 14) * targetIndex / Math.max(1, stations.length - 1);
|
const clientX = rect.left + 7 + (rect.width - 14) * targetIndex / Math.max(1, stations.length - 1);
|
||||||
@@ -3055,7 +3120,7 @@ async function main() {
|
|||||||
const max = Number(slider.max);
|
const max = Number(slider.max);
|
||||||
const direction = startSlot < max ? 1 : -1;
|
const direction = startSlot < max ? 1 : -1;
|
||||||
const targetIndex = startSlot + direction;
|
const targetIndex = startSlot + direction;
|
||||||
const targetId = gev.styleManager._radioTunerStations[targetIndex]?.id || null;
|
const targetId = gev.styleManager._radioControls._radioTunerStations[targetIndex]?.id || null;
|
||||||
const rect = slider.getBoundingClientRect();
|
const rect = slider.getBoundingClientRect();
|
||||||
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, max);
|
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, max);
|
||||||
const pointer = (type, index) => slider.dispatchEvent(new PointerEvent(type, {
|
const pointer = (type, index) => slider.dispatchEvent(new PointerEvent(type, {
|
||||||
@@ -3106,18 +3171,18 @@ async function main() {
|
|||||||
const slider = document.getElementById('radio-tuner-slider');
|
const slider = document.getElementById('radio-tuner-slider');
|
||||||
const targetIndex = 11;
|
const targetIndex = 11;
|
||||||
slider.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, pointerId: 1 }));
|
slider.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true, pointerId: 1 }));
|
||||||
const station = gev.styleManager._radioTunerStations[targetIndex];
|
const station = gev.styleManager._radioControls._radioTunerStations[targetIndex];
|
||||||
const rect = slider.getBoundingClientRect();
|
const rect = slider.getBoundingClientRect();
|
||||||
const currentRatio = Number(slider.value) / Math.max(1, Number(slider.max));
|
const currentRatio = Number(slider.value) / Math.max(1, Number(slider.max));
|
||||||
window.__qaRadioDelayNextPlay = true;
|
window.__qaRadioDelayNextPlay = true;
|
||||||
return {
|
return {
|
||||||
id: station.id,
|
id: station.id,
|
||||||
targetIndex,
|
targetIndex,
|
||||||
count: gev.styleManager._radioTunerStations.length,
|
count: gev.styleManager._radioControls._radioTunerStations.length,
|
||||||
frozenSignature: gev.styleManager._radioTunerBandSignature,
|
frozenSignature: gev.styleManager._radioControls._radioTunerBandSignature,
|
||||||
startX: rect.left + 7 + (rect.width - 14) * currentRatio,
|
startX: rect.left + 7 + (rect.width - 14) * currentRatio,
|
||||||
targetX: rect.left + 7 + (rect.width - 14) * targetIndex
|
targetX: rect.left + 7 + (rect.width - 14) * targetIndex
|
||||||
/ Math.max(1, gev.styleManager._radioTunerStations.length - 1),
|
/ Math.max(1, gev.styleManager._radioControls._radioTunerStations.length - 1),
|
||||||
y: rect.top + rect.height / 2,
|
y: rect.top + rect.height / 2,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3135,11 +3200,11 @@ async function main() {
|
|||||||
audioState: state.audioState,
|
audioState: state.audioState,
|
||||||
tuningStatic: state.tuningStatic,
|
tuningStatic: state.tuningStatic,
|
||||||
awaiting: state.tuningAwaitingStationId,
|
awaiting: state.tuningAwaitingStationId,
|
||||||
signatureStable: gev.styleManager._radioTunerBandSignature === target.frozenSignature,
|
signatureStable: gev.styleManager._radioControls._radioTunerBandSignature === target.frozenSignature,
|
||||||
selectedIndex: gev.styleManager._radioTunerStations.findIndex((item) => item.id === state.selected?.id),
|
selectedIndex: gev.styleManager._radioControls._radioTunerStations.findIndex((item) => item.id === state.selected?.id),
|
||||||
sliderSlot: Number(slider.value),
|
sliderSlot: Number(slider.value),
|
||||||
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
||||||
pinned: gev.styleManager._radioTunerBandPinnedForNavigation,
|
pinned: gev.styleManager._radioControls._radioTunerBandPinnedForNavigation,
|
||||||
};
|
};
|
||||||
}, tunerCommitTarget);
|
}, tunerCommitTarget);
|
||||||
check(
|
check(
|
||||||
@@ -3183,7 +3248,7 @@ async function main() {
|
|||||||
y: rect.top + rect.height / 2,
|
y: rect.top + rect.height / 2,
|
||||||
ratio,
|
ratio,
|
||||||
usableWidth: rect.width - 14,
|
usableWidth: rect.width - 14,
|
||||||
signature: style._radioTunerBandSignature,
|
signature: style._radioControls._radioTunerBandSignature,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const microDragSamples = [];
|
const microDragSamples = [];
|
||||||
@@ -3199,7 +3264,7 @@ async function main() {
|
|||||||
pixelDelta,
|
pixelDelta,
|
||||||
slot: Number(slider.value),
|
slot: Number(slider.value),
|
||||||
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
||||||
signature: style._radioTunerBandSignature,
|
signature: style._radioControls._radioTunerBandSignature,
|
||||||
};
|
};
|
||||||
}, delta));
|
}, delta));
|
||||||
}
|
}
|
||||||
@@ -3238,9 +3303,9 @@ async function main() {
|
|||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const style = gev.styleManager;
|
const style = gev.styleManager;
|
||||||
const before = {
|
const before = {
|
||||||
signature: style._radioTunerBandSignature,
|
signature: style._radioControls._radioTunerBandSignature,
|
||||||
ids: style._radioTunerStations.map((station) => station.id),
|
ids: style._radioControls._radioTunerStations.map((station) => station.id),
|
||||||
selectedIndex: style._radioTunerStations.findIndex((station) => station.id === selectedId),
|
selectedIndex: style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId),
|
||||||
needle: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
needle: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
||||||
};
|
};
|
||||||
const current = gev.viewer.camera.positionCartographic;
|
const current = gev.viewer.camera.positionCartographic;
|
||||||
@@ -3256,11 +3321,11 @@ async function main() {
|
|||||||
});
|
});
|
||||||
gev.viewer.camera.changed.raiseEvent();
|
gev.viewer.camera.changed.raiseEvent();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||||
const stations = gev.styleManager._radioTunerStations;
|
const stations = gev.styleManager._radioControls._radioTunerStations;
|
||||||
const selectedIndex = stations.findIndex((station) => station.id === selectedId);
|
const selectedIndex = stations.findIndex((station) => station.id === selectedId);
|
||||||
return {
|
return {
|
||||||
before,
|
before,
|
||||||
signature: style._radioTunerBandSignature,
|
signature: style._radioControls._radioTunerBandSignature,
|
||||||
ids: stations.map((station) => station.id),
|
ids: stations.map((station) => station.id),
|
||||||
count: stations.length,
|
count: stations.length,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
@@ -3279,14 +3344,14 @@ async function main() {
|
|||||||
const nextNeedleBefore = await page.evaluate(() => {
|
const nextNeedleBefore = await page.evaluate(() => {
|
||||||
const style = window.__godsEyeView.styleManager;
|
const style = window.__godsEyeView.styleManager;
|
||||||
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
|
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
|
||||||
const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId);
|
const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId);
|
||||||
const selectedPoolIndex = style._radioTunerPool.findIndex((station) => station.id === selectedId);
|
const selectedPoolIndex = style._radioControls._radioTunerPool.findIndex((station) => station.id === selectedId);
|
||||||
const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioTunerPool.length;
|
const expectedPoolIndex = (selectedPoolIndex + 1) % style._radioControls._radioTunerPool.length;
|
||||||
return {
|
return {
|
||||||
signature: style._radioTunerBandSignature,
|
signature: style._radioControls._radioTunerBandSignature,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
count: style._radioTunerStations.length,
|
count: style._radioControls._radioTunerStations.length,
|
||||||
expectedId: style._radioTunerPool[expectedPoolIndex]?.id || null,
|
expectedId: style._radioControls._radioTunerPool[expectedPoolIndex]?.id || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
await page.$eval('#radio-next-btn', (button) => button.click());
|
await page.$eval('#radio-next-btn', (button) => button.click());
|
||||||
@@ -3294,15 +3359,15 @@ async function main() {
|
|||||||
const nextNeedleAfter = await page.evaluate(() => {
|
const nextNeedleAfter = await page.evaluate(() => {
|
||||||
const style = window.__godsEyeView.styleManager;
|
const style = window.__godsEyeView.styleManager;
|
||||||
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
|
const selectedId = window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().selected?.id;
|
||||||
const selectedIndex = style._radioTunerStations.findIndex((station) => station.id === selectedId);
|
const selectedIndex = style._radioControls._radioTunerStations.findIndex((station) => station.id === selectedId);
|
||||||
return {
|
return {
|
||||||
signature: style._radioTunerBandSignature,
|
signature: style._radioControls._radioTunerBandSignature,
|
||||||
selectedId,
|
selectedId,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
count: style._radioTunerStations.length,
|
count: style._radioControls._radioTunerStations.length,
|
||||||
sliderSlot: Number(document.getElementById('radio-tuner-slider').value),
|
sliderSlot: Number(document.getElementById('radio-tuner-slider').value),
|
||||||
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
||||||
pinned: style._radioTunerBandPinnedForNavigation,
|
pinned: style._radioControls._radioTunerBandPinnedForNavigation,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
check(
|
check(
|
||||||
@@ -3322,13 +3387,14 @@ async function main() {
|
|||||||
const slider = document.getElementById('radio-tuner-slider');
|
const slider = document.getElementById('radio-tuner-slider');
|
||||||
const key = (type, value) => slider.dispatchEvent(new KeyboardEvent(type, {
|
const key = (type, value) => slider.dispatchEvent(new KeyboardEvent(type, {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
key: value,
|
key: value,
|
||||||
}));
|
}));
|
||||||
const markerId = () => gev.viewer.entities.values
|
const markerId = () => gev.viewer.entities.values
|
||||||
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
|
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null;
|
||||||
const startIndex = Number(slider.value);
|
const startIndex = Number(slider.value);
|
||||||
const expectedIndex = Math.min(style._radioTunerStations.length - 1, startIndex + 1);
|
const expectedIndex = Math.min(style._radioControls._radioTunerStations.length - 1, startIndex + 1);
|
||||||
const expectedId = style._radioTunerStations[expectedIndex]?.id || null;
|
const expectedId = style._radioControls._radioTunerStations[expectedIndex]?.id || null;
|
||||||
const playsBefore = window.__qaRadioPlayCalls.length;
|
const playsBefore = window.__qaRadioPlayCalls.length;
|
||||||
key('keydown', 'ArrowRight');
|
key('keydown', 'ArrowRight');
|
||||||
const preview = {
|
const preview = {
|
||||||
@@ -3353,7 +3419,7 @@ async function main() {
|
|||||||
tuningActive: radio.getUIState().tuningActive,
|
tuningActive: radio.getUIState().tuningActive,
|
||||||
playDelta: window.__qaRadioPlayCalls.length - committedPlays,
|
playDelta: window.__qaRadioPlayCalls.length - committedPlays,
|
||||||
};
|
};
|
||||||
const count = style._radioTunerStations.length;
|
const count = style._radioControls._radioTunerStations.length;
|
||||||
const pageStep = Math.max(1, Math.round((count - 1) / 10));
|
const pageStep = Math.max(1, Math.round((count - 1) / 10));
|
||||||
const previewAndCancel = (value, expectedSlot) => {
|
const previewAndCancel = (value, expectedSlot) => {
|
||||||
const beforePlays = window.__qaRadioPlayCalls.length;
|
const beforePlays = window.__qaRadioPlayCalls.length;
|
||||||
@@ -3376,7 +3442,8 @@ async function main() {
|
|||||||
previewAndCancel('PageUp', Math.min(count - 1, base + pageStep)),
|
previewAndCancel('PageUp', Math.min(count - 1, base + pageStep)),
|
||||||
previewAndCancel('PageDown', Math.max(0, base - pageStep)),
|
previewAndCancel('PageDown', Math.max(0, base - pageStep)),
|
||||||
];
|
];
|
||||||
return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation };
|
return { startIndex, expectedIndex, expectedId, preview, committed, cancelled, navigation,
|
||||||
|
panelStayedOpen: !document.getElementById('radio-panel').classList.contains('collapsed') };
|
||||||
});
|
});
|
||||||
check(
|
check(
|
||||||
'keyboard preview is silent, key release commits once, and Escape restores the committed station',
|
'keyboard preview is silent, key release commits once, and Escape restores the committed station',
|
||||||
@@ -3388,7 +3455,8 @@ async function main() {
|
|||||||
&& !tunerKeyboard.committed.tuningActive && tunerKeyboard.committed.playDelta === 1
|
&& !tunerKeyboard.committed.tuningActive && tunerKeyboard.committed.playDelta === 1
|
||||||
&& tunerKeyboard.cancelled.selectedId === tunerKeyboard.expectedId
|
&& tunerKeyboard.cancelled.selectedId === tunerKeyboard.expectedId
|
||||||
&& tunerKeyboard.cancelled.slot === tunerKeyboard.expectedIndex
|
&& tunerKeyboard.cancelled.slot === tunerKeyboard.expectedIndex
|
||||||
&& !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0,
|
&& !tunerKeyboard.cancelled.tuningActive && tunerKeyboard.cancelled.playDelta === 0
|
||||||
|
&& tunerKeyboard.panelStayedOpen,
|
||||||
JSON.stringify(tunerKeyboard),
|
JSON.stringify(tunerKeyboard),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
@@ -3405,7 +3473,7 @@ async function main() {
|
|||||||
const radio = gev.dataManager.layers.get('radio').module;
|
const radio = gev.dataManager.layers.get('radio').module;
|
||||||
const slider = document.getElementById('radio-tuner-slider');
|
const slider = document.getElementById('radio-tuner-slider');
|
||||||
const rect = slider.getBoundingClientRect();
|
const rect = slider.getBoundingClientRect();
|
||||||
const count = style._radioTunerStations.length;
|
const count = style._radioControls._radioTunerStations.length;
|
||||||
const centerIndex = Math.floor((count - 1) / 2 + 0.5);
|
const centerIndex = Math.floor((count - 1) / 2 + 0.5);
|
||||||
const clientX = rect.left + rect.width / 2;
|
const clientX = rect.left + rect.width / 2;
|
||||||
slider.dispatchEvent(new PointerEvent('pointerdown', {
|
slider.dispatchEvent(new PointerEvent('pointerdown', {
|
||||||
@@ -3425,7 +3493,7 @@ async function main() {
|
|||||||
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
ratio: Number(document.getElementById('radio-tuner').style.getPropertyValue('--radio-tuner-ratio')),
|
||||||
markerId: gev.viewer.entities.values
|
markerId: gev.viewer.entities.values
|
||||||
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null,
|
.find((entity) => String(entity.id).startsWith('radio:selected:'))?.id || null,
|
||||||
expectedId: style._radioTunerStations[centerIndex]?.id || null,
|
expectedId: style._radioControls._radioTunerStations[centerIndex]?.id || null,
|
||||||
};
|
};
|
||||||
slider.dispatchEvent(new PointerEvent('pointercancel', {
|
slider.dispatchEvent(new PointerEvent('pointercancel', {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
@@ -3450,7 +3518,7 @@ async function main() {
|
|||||||
const fullPoolNavigation = await page.evaluate(() => {
|
const fullPoolNavigation = await page.evaluate(() => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const radio = gev.dataManager.layers.get('radio').module;
|
const radio = gev.dataManager.layers.get('radio').module;
|
||||||
const pool = gev.styleManager._radioTunerPool;
|
const pool = gev.styleManager._radioControls._radioTunerPool;
|
||||||
const ids = pool.map((station) => station.id);
|
const ids = pool.map((station) => station.id);
|
||||||
const selectWithoutPlayback = (index) => radio.selectStation(ids[index], {
|
const selectWithoutPlayback = (index) => radio.selectStation(ids[index], {
|
||||||
autoplay: false,
|
autoplay: false,
|
||||||
@@ -3500,7 +3568,7 @@ async function main() {
|
|||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const slider = document.getElementById('radio-tuner-slider');
|
const slider = document.getElementById('radio-tuner-slider');
|
||||||
const rect = slider.getBoundingClientRect();
|
const rect = slider.getBoundingClientRect();
|
||||||
const count = gev.styleManager._radioTunerStations.length;
|
const count = gev.styleManager._radioControls._radioTunerStations.length;
|
||||||
const current = Number(slider.value);
|
const current = Number(slider.value);
|
||||||
const target = Math.min(count - 1, current + 4);
|
const target = Math.min(count - 1, current + 4);
|
||||||
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, count - 1);
|
const xFor = (index) => rect.left + 7 + (rect.width - 14) * index / Math.max(1, count - 1);
|
||||||
@@ -3695,7 +3763,7 @@ async function main() {
|
|||||||
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
Array.from({ length: gev.viewer.dataSources.length }, (_, index) => gev.viewer.dataSources.get(index))
|
||||||
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
.find((item) => item.name === 'Radio stations').clustering.enabled = true;
|
||||||
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
|
radio.selectStation('00000000-0000-4000-8000-000000000001', { autoplay: false, focus: false });
|
||||||
const pool = gev.styleManager._radioTunerPool;
|
const pool = gev.styleManager._radioControls._radioTunerPool;
|
||||||
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
const selectedIndex = pool.findIndex((station) => station.id === radio.getUIState().selected?.id);
|
||||||
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
window.__qaRadioExpectedPrimary = pool[(selectedIndex + 1) % pool.length];
|
||||||
window.__qaRadioExpectedFallback = pool[(selectedIndex + 2) % pool.length];
|
window.__qaRadioExpectedFallback = pool[(selectedIndex + 2) % pool.length];
|
||||||
@@ -4390,7 +4458,10 @@ async function main() {
|
|||||||
});
|
});
|
||||||
await page.click('#context-radio-toggle-btn');
|
await page.click('#context-radio-toggle-btn');
|
||||||
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
|
await page.waitForFunction(() => !document.getElementById('radio-panel').classList.contains('collapsed'));
|
||||||
await sleep(450);
|
await page.waitForFunction((priorScroll) => (
|
||||||
|
document.activeElement?.getAttribute('data-collapse-target') === 'radio-panel'
|
||||||
|
&& document.querySelector('#global-context-panel .global-context-panel-inner').scrollTop > priorScroll
|
||||||
|
), { timeout: 10_000 }, expandedContextRadioBefore.scrollTop);
|
||||||
const expandedContextRadioAfter = await page.evaluate(() => {
|
const expandedContextRadioAfter = await page.evaluate(() => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState());
|
gev.styleManager._renderRadioState(gev.dataManager.layers.get('radio').module.getUIState());
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Exercise the real UI disposal path, including unrelated resource owners. */
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
...(process.platform === 'darwin'
|
||||||
|
? ['--use-angle=metal', '--enable-gpu']
|
||||||
|
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
let failures = 0;
|
||||||
|
function check(name, ok) {
|
||||||
|
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
|
||||||
|
if (!ok) failures++;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await page.goto(
|
||||||
|
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||||
|
{ waitUntil: 'domcontentloaded' },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.styleManager?._dataManager &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const ui = window.__godsEyeView.styleManager;
|
||||||
|
const counts = {};
|
||||||
|
const watchObserver = (name) => {
|
||||||
|
const observer = ui[name];
|
||||||
|
if (!observer) return false;
|
||||||
|
const disconnect = observer.disconnect.bind(observer);
|
||||||
|
counts[name] = 0;
|
||||||
|
observer.disconnect = () => {
|
||||||
|
counts[name]++;
|
||||||
|
return disconnect();
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const observed = [
|
||||||
|
'_commandDockTrayObserver',
|
||||||
|
'_draggableResizeObserver',
|
||||||
|
'_leftStackResizeObserver',
|
||||||
|
'_rightStackResizeObserver',
|
||||||
|
'_leftStackMutationObserver',
|
||||||
|
'_rightStackMutationObserver',
|
||||||
|
].filter(watchObserver);
|
||||||
|
const cctvUnsubscribe = ui._cctvUnsubscribe;
|
||||||
|
counts.cctv = 0;
|
||||||
|
if (cctvUnsubscribe)
|
||||||
|
ui._cctvUnsubscribe = () => {
|
||||||
|
counts.cctv++;
|
||||||
|
return cctvUnsubscribe();
|
||||||
|
};
|
||||||
|
const resizeHandler = ui._windowResizeHandler;
|
||||||
|
const removeEventListener = window.removeEventListener;
|
||||||
|
counts.resize = 0;
|
||||||
|
window.removeEventListener = function (type, callback, options) {
|
||||||
|
if (type === 'resize' && callback === resizeHandler) counts.resize++;
|
||||||
|
return removeEventListener.call(this, type, callback, options);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await ui.dispose();
|
||||||
|
const once = JSON.stringify(counts);
|
||||||
|
await ui.dispose();
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
connected: Boolean(
|
||||||
|
cctvUnsubscribe &&
|
||||||
|
resizeHandler &&
|
||||||
|
observed.includes('_commandDockTrayObserver'),
|
||||||
|
),
|
||||||
|
observersReleased: observed.every(
|
||||||
|
(name) => counts[name] === 1 && ui[name] === null,
|
||||||
|
),
|
||||||
|
subscriptionReleased: counts.cctv === 1 && ui._cctvUnsubscribe === null,
|
||||||
|
resizeReleased: counts.resize === 1 && ui._windowResizeHandler === null,
|
||||||
|
controlsReleased:
|
||||||
|
ui._radioControls.destroyed && ui._locationControls.destroyed,
|
||||||
|
idempotent: once === JSON.stringify(counts),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener = removeEventListener;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'real UI has the expected live resources before disposal',
|
||||||
|
result.connected,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'UI disposal disconnects each active panel observer once',
|
||||||
|
result.observersReleased,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'UI disposal releases the CCTV state subscription',
|
||||||
|
result.subscriptionReleased,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'UI disposal removes the registered window resize handler',
|
||||||
|
result.resizeReleased,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'Radio and Location controls are disposed with the UI',
|
||||||
|
result.controlsReleased,
|
||||||
|
);
|
||||||
|
check('repeated UI disposal is inert', result.idempotent);
|
||||||
|
check(
|
||||||
|
'disposal and subsequent resize produce no uncaught browser errors',
|
||||||
|
errors.length === 0,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
if (failures) process.exitCode = 1;
|
||||||
+25
-27
@@ -1,3 +1,6 @@
|
|||||||
|
import { readFileSync as readRadioSource } from 'node:fs';
|
||||||
|
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||||
|
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -248,8 +251,8 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
|
|||||||
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, \.42\)/);
|
assert.match(css, /is-expanded:has\(\[data-cockpit-launcher="display"\]\)[\s\S]*?box-shadow:\s*0 8px 32px rgba\(0, 0, 0, \.42\)/);
|
||||||
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-launcher\s*\{[\s\S]*?border:\s*0;[\s\S]*?background:\s*transparent;[\s\S]*?box-shadow:\s*none;/);
|
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-launcher\s*\{[\s\S]*?border:\s*0;[\s\S]*?background:\s*transparent;[\s\S]*?box-shadow:\s*none;/);
|
||||||
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, \.22\);/);
|
assert.match(css, /\.cockpit-utility-control\.is-expanded \.cockpit-utility-divider\s*\{[\s\S]*?linear-gradient\(90deg, rgb\(0 212 255 \/ 28%\), rgba\(0, 212, 255, 0\.18\) 58%, transparent\)[\s\S]*?box-shadow:\s*0 0 7px rgba\(0, 212, 255, \.22\);/);
|
||||||
assert.match(ui, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
|
assert.match(radioBindings, /this\._cockpitDisplayToggleBtn\.textContent = displayOpen \? '▶' : '◀';/);
|
||||||
assert.match(ui, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
|
assert.match(radioBindings, /this\._cockpitRadioToggleBtn\.textContent = radioOpen \? '▶' : '◀';/);
|
||||||
assert.match(css, /#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/);
|
assert.match(css, /#cockpit-display-panel\s*\{[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*7px;[\s\S]*?padding:\s*0;[\s\S]*?border-top:\s*0;/);
|
||||||
assert.doesNotMatch(ui, /--cockpit-display-tab-width/);
|
assert.doesNotMatch(ui, /--cockpit-display-tab-width/);
|
||||||
// These two widths size Cockpit Radio and nothing else. They were named for
|
// These two widths size Cockpit Radio and nothing else. They were named for
|
||||||
@@ -307,7 +310,7 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
|
|||||||
assert.match(ui, /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
|
assert.match(ui, /this\._contextModeGeneration[\s\S]*?this\._contextSessionSnapshot = null;[\s\S]*?this\._contextRestoreState = null;/);
|
||||||
assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
|
assert.match(ui, /if \(this\._contextRestoreState\) this\._contextRestoreState\.cancelled = true/);
|
||||||
assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
|
assert.match(ui, /if \(restoreState\.cancelled\) return;[\s\S]*?settleContextIntentReplay/);
|
||||||
assert.match(ui, /!this\._preservePanelStateDuringLayerClear[\s\S]*?this\.setPanelCollapsed\('radio-panel', true\)/);
|
assert.match(radioPresentation, /!this\.actions\.preservePanelStateDuringClear\(\)[\s\S]*?this\.actions\.setPanelCollapsed\('radio-panel', true\)/);
|
||||||
assert.match(ui, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
|
assert.match(ui, /this\._userFacingContextNotificationTokens\.add\(notificationToken\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,16 +323,13 @@ test('Cockpit Display portal retains both scroll owners across round trips', ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Cockpit side surfaces behave as two single-expanded accordions', () => {
|
test('Cockpit side surfaces behave as two single-expanded accordions', () => {
|
||||||
assert.match(
|
assert.match(radioBindings,
|
||||||
ui,
|
/if \(displayOpen \|\| radioOpen\) this\.actions\.setSignalCollapsed\(true\);/,
|
||||||
/if \(displayOpen \|\| radioOpen\) this\.cockpitView\?\.setSignalCollapsed\(true\);/,
|
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(radioBindings,
|
||||||
ui,
|
|
||||||
/'gev:cockpit-signal-expanded'[\s\S]*?setCockpitDisclosure\('display', false\);/,
|
/'gev:cockpit-signal-expanded'[\s\S]*?setCockpitDisclosure\('display', false\);/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(radioBindings,
|
||||||
ui,
|
|
||||||
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
|
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -341,13 +341,11 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
|
|||||||
/nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
/nextCollapsed && this\.cockpitView\?\.active && panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
|
||||||
'closing Data Layers must restore Contact only after an automatic collapse',
|
'closing Data Layers must restore Contact only after an automatic collapse',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(ui,
|
||||||
ui,
|
|
||||||
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
|
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
|
||||||
'Contact expansion must notify only on a collapsed-to-expanded transition',
|
'Contact expansion must notify only on a collapsed-to-expanded transition',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(ui,
|
||||||
ui,
|
|
||||||
/const wasCollapsed = this\.signalCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.signalCollapsed[\s\S]*?'gev:cockpit-signal-expanded'/,
|
/const wasCollapsed = this\.signalCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.signalCollapsed[\s\S]*?'gev:cockpit-signal-expanded'/,
|
||||||
'Live Signals expansion must notify only on a collapsed-to-expanded transition',
|
'Live Signals expansion must notify only on a collapsed-to-expanded transition',
|
||||||
);
|
);
|
||||||
@@ -356,13 +354,13 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
|
|||||||
/setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/,
|
/setSignalCollapsed\(collapsed, \{ user = false \} = \{\}\)[\s\S]*?if \(user\) this\.signalUserCollapsed = this\.signalCollapsed/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
radioBindings,
|
||||||
/!displayOpen && !radioOpen[\s\S]*?this\.cockpitView\?\.active[\s\S]*?!this\.cockpitView\.signalUserCollapsed[\s\S]*?setSignalCollapsed\(false\)/,
|
/!displayOpen\s*&&\s*!radioOpen[\s\S]*?this\.actions\.isCockpitActive\(\)[\s\S]*?!this\.actions\.signalUserCollapsed\(\)[\s\S]*?setSignalCollapsed\(false\)/,
|
||||||
'Live Signals should reopen only after both utility panels close and no manual collapse is retained',
|
'Live Signals should reopen only after both utility panels close and no manual collapse is retained',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
radioBindings,
|
||||||
/event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\) return;[\s\S]*?setCockpitDisclosure\('display', false\);/,
|
/event\.target\?\.closest\?\.\('#left-panel-stack, #cockpit-context'\)\)\s*return;[\s\S]*?setCockpitDisclosure\('display', false\);/,
|
||||||
'left-side interactions must not collapse the independent Cockpit utilities',
|
'left-side interactions must not collapse the independent Cockpit utilities',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -505,18 +503,18 @@ test('Location navigation releases immediate routes before flight and deferred r
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Cockpit Radio station changes preserve first-person camera ownership', () => {
|
test('Cockpit Radio station changes preserve first-person camera ownership', () => {
|
||||||
const cycleHelper = ui.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/);
|
const cycleHelper = radioBindings.match(/const cycleRadio = \(direction, \{ rotate = true \} = \{\}\) => \{([\s\S]*?)\n \};/);
|
||||||
assert.ok(cycleHelper, 'shared Radio cycle helper is missing');
|
assert.ok(cycleHelper, 'shared Radio cycle helper is missing');
|
||||||
assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/);
|
assert.match(cycleHelper[1], /cycleStation\(direction, \{[\s\S]*?rotate,/);
|
||||||
assert.match(ui, /_radioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1\)\)/);
|
assert.match(radioBindings, /listen\(this\._radioPrevBtn, 'click', \(\) => cycleRadio\(-1\)\)/);
|
||||||
assert.match(ui, /_contextRadioMiniNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1\)\)/);
|
assert.match(radioBindings, /listen\(this\._contextRadioMiniNextBtn, 'click', \(\) => cycleRadio\(1\)\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
radioBindings,
|
||||||
/_cockpitRadioPrevBtn\?\.addEventListener\('click', \(\) => cycleRadio\(-1, \{ rotate: false \}\)\)/,
|
/listen\(this\._cockpitRadioPrevBtn, 'click', \(\) =>\s*cycleRadio\(-1, \{ rotate: false \}\),?\s*\)/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
radioBindings,
|
||||||
/_cockpitRadioNextBtn\?\.addEventListener\('click', \(\) => cycleRadio\(1, \{ rotate: false \}\)\)/,
|
/listen\(this\._cockpitRadioNextBtn, 'click', \(\) =>\s*cycleRadio\(1, \{ rotate: false \}\),?\s*\)/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -653,9 +651,9 @@ test('Cockpit Display portals shared HUD, Detection, Parameters, and 3D controls
|
|||||||
assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1);
|
assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1);
|
||||||
assert.match(html, /data-cockpit-display-slot="detection"[\s\S]*?data-cockpit-display-slot="parameters"[\s\S]*?data-cockpit-display-slot="models3d"/);
|
assert.match(html, /data-cockpit-display-slot="detection"[\s\S]*?data-cockpit-display-slot="parameters"[\s\S]*?data-cockpit-display-slot="models3d"/);
|
||||||
assert.match(ui, /_revealStyleParameters\(\) \{[\s\S]*?if \(this\._cockpitDisplayPortalActive\) return;/);
|
assert.match(ui, /_revealStyleParameters\(\) \{[\s\S]*?if \(this\._cockpitDisplayPortalActive\) return;/);
|
||||||
assert.match(ui, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
|
assert.match(radioBindings, /closest\?\.\('\.cockpit-vision-controls'\)\) return;/);
|
||||||
assert.match(ui, /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/);
|
assert.match(ui, /_revealCockpitStyleParameters\(\{ openDisplay = false \} = \{\}\) \{[\s\S]*?openDisplay[\s\S]*?_setCockpitDisclosure\?\.\('display', true\)[\s\S]*?aria-expanded'\) !== 'true'[\s\S]*?classList\.remove\('collapsed'\)/);
|
||||||
assert.match(ui, /if \(displayOpen\) this\._revealCockpitStyleParameters\(\);/);
|
assert.match(radioBindings, /if \(displayOpen\) this\.actions\.revealStyleParameters\(\);/);
|
||||||
assert.match(ui, /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/);
|
assert.match(ui, /setVisionMode\(modes\[nextIndex\], \{ revealParameters: true \}\);/);
|
||||||
assert.match(css, /\.cockpit-display-slot\s*\{[\s\S]*?display:\s*block;[\s\S]*?min-width:\s*0;/);
|
assert.match(css, /\.cockpit-display-slot\s*\{[\s\S]*?display:\s*block;[\s\S]*?min-width:\s*0;/);
|
||||||
assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{\s*width:\s*100%/);
|
assert.match(css, /#cockpit-display-panel \.pp-toggle-group\s*\{\s*width:\s*100%/);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { readFileSync as readRadioSource } from 'node:fs';
|
||||||
|
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||||
|
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||||
|
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||||
// Source-contract pins for StyleManager's Context-session handler ordering.
|
// Source-contract pins for StyleManager's Context-session handler ordering.
|
||||||
// ui.js cannot be imported headlessly (it touches the DOM at module scope), so
|
// ui.js cannot be imported headlessly (it touches the DOM at module scope), so
|
||||||
// these read the shipped source — the idiom established by cockpitMarkup /
|
// these read the shipped source — the idiom established by cockpitMarkup /
|
||||||
@@ -173,23 +177,24 @@ test('every user-facing Context exit route settles through the failure surface',
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => {
|
test('the Radio chip catches lifecycle rejection and semantic false through the toast wrapper', () => {
|
||||||
const radioControls = src.slice(
|
const radioControls = radioBindings.slice(
|
||||||
src.indexOf('const toggleRadio = async (trigger) => {'),
|
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
|
||||||
src.indexOf("this._radioFilter?.addEventListener('change'"),
|
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
|
||||||
);
|
);
|
||||||
assert.match(radioControls, /await this\._runUserFacingContextAction\(/);
|
assert.match(radioControls, /await this\.actions\.runUserAction\(/);
|
||||||
assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/);
|
assert.match(radioControls, /Radio could not \$\{enabling \? 'start' : 'stop'\} cleanly/);
|
||||||
assert.match(radioControls, /if \(toggled === false\) return/);
|
assert.match(radioControls, /if \(this\.destroyed \|\| toggled === false\) return/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => {
|
test('only the expanded Radio Enable gesture requests the contained post-enable reveal', () => {
|
||||||
const radioControls = src.slice(
|
const radioControls = radioBindings.slice(
|
||||||
src.indexOf('const toggleRadio = async (trigger) => {'),
|
radioBindings.indexOf('const toggleRadio = async (trigger) => {'),
|
||||||
src.indexOf("this._radioFilter?.addEventListener('change'"),
|
radioBindings.indexOf("this.listen(this._radioFilter, 'change'"),
|
||||||
);
|
);
|
||||||
assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/);
|
assert.match(radioControls, /revealAfterEnable = enabling && trigger === this\._radioEnableBtn/);
|
||||||
assert.match(radioControls, /if \(revealAfterEnable\) await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
assert.match(radioControls, /if \(revealAfterEnable\)\s*await this\._revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
||||||
assert.equal((src.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 2);
|
assert.equal((radioBindings.match(/_revealRadioControlsAfterExplicitEnable\(trigger\)/g) || []).length, 1);
|
||||||
|
assert.match(radioControlsSource, /async _revealRadioControlsAfterExplicitEnable\(trigger\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('right-rail context entry is transactional: activation result gates the mode', () => {
|
test('right-rail context entry is transactional: activation result gates the mode', () => {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { readFileSync as readRadioSource } from 'node:fs';
|
||||||
|
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||||
|
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||||
|
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -136,14 +140,8 @@ test('Context async action buttons remain focused while busy', () => {
|
|||||||
ui.indexOf('_initGlobalContextPanel() {'),
|
ui.indexOf('_initGlobalContextPanel() {'),
|
||||||
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
|
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
|
||||||
);
|
);
|
||||||
const radio = ui.slice(
|
const radio = radioBindings.slice(radioBindings.indexOf('const toggleRadio = async (trigger) => {'), radioBindings.indexOf('this.listen(this._contextRadioToggleBtn,'));
|
||||||
ui.indexOf('const toggleRadio = async (trigger) => {'),
|
const radioSync = radioPresentation.slice(0, radioPresentation.indexOf('if (this._radioFilter)'));
|
||||||
ui.indexOf('this._contextRadioToggleBtn?.addEventListener', ui.indexOf('const toggleRadio = async (trigger) => {')),
|
|
||||||
);
|
|
||||||
const radioSync = ui.slice(
|
|
||||||
ui.indexOf('_renderRadioState(state) {'),
|
|
||||||
ui.indexOf('if (this._radioFilter)', ui.indexOf('_renderRadioState(state) {')),
|
|
||||||
);
|
|
||||||
const clear = ui.slice(
|
const clear = ui.slice(
|
||||||
ui.indexOf('clearSelectedLayers() {'),
|
ui.indexOf('clearSelectedLayers() {'),
|
||||||
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
|
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
|
||||||
|
|||||||
+1
-87
@@ -1,3 +1,4 @@
|
|||||||
|
export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from '../ui/radioTunerModel.js';
|
||||||
/**
|
/**
|
||||||
* Internet-radio station directory and direct-playback layer.
|
* Internet-radio station directory and direct-playback layer.
|
||||||
*
|
*
|
||||||
@@ -945,93 +946,6 @@ export function radioStationIdFromPick(picked) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map an integer tuner slot directly to one available directory station. */
|
|
||||||
export function radioTunerSlot(value, stationCount) {
|
|
||||||
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
|
|
||||||
if (!count) return { slot: 0, max: 0, locked: false, stationIndex: -1, leftIndex: -1, rightIndex: -1 };
|
|
||||||
const max = Math.max(0, count - 1);
|
|
||||||
const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0)));
|
|
||||||
return {
|
|
||||||
slot,
|
|
||||||
max,
|
|
||||||
locked: true,
|
|
||||||
stationIndex: slot,
|
|
||||||
leftIndex: slot,
|
|
||||||
rightIndex: slot,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Snap a tuner release to the nearest available directory station. */
|
|
||||||
export function radioTunerCommitSlot(value, stationCount) {
|
|
||||||
return radioTunerSlot(value, stationCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map one pointer coordinate to continuous absolute directory progress. */
|
|
||||||
export function radioTunerPointerPosition(clientX, left, width, stationCount, insetPx = 7) {
|
|
||||||
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
|
|
||||||
if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 };
|
|
||||||
if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 };
|
|
||||||
const inset = Math.max(0, Number(insetPx) || 0);
|
|
||||||
const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2);
|
|
||||||
const ratio = Math.min(1, Math.max(0, ((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth));
|
|
||||||
const coordinate = ratio * (count - 1);
|
|
||||||
return {
|
|
||||||
ratio,
|
|
||||||
coordinate,
|
|
||||||
stationIndex: Math.min(count - 1, Math.max(0, Math.floor(coordinate + 0.5))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a bounded virtual tuner tape around one continuous directory coordinate. */
|
|
||||||
export function buildRadioTunerTicks(coordinate, stationCount, width, {
|
|
||||||
insetPx = 7,
|
|
||||||
minPitchPx = 14,
|
|
||||||
speedFactor = 5,
|
|
||||||
overscan = 2,
|
|
||||||
labelStep = 6,
|
|
||||||
} = {}) {
|
|
||||||
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
|
|
||||||
const dialWidth = Math.max(0, Number(width) || 0);
|
|
||||||
const inset = Math.max(0, Number(insetPx) || 0);
|
|
||||||
const usableWidth = Math.max(0, dialWidth - inset * 2);
|
|
||||||
if (!count) return { ticks: [], needleX: inset, pitchPx: 0, ratio: 0 };
|
|
||||||
const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0));
|
|
||||||
if (count === 1) {
|
|
||||||
return {
|
|
||||||
ticks: [{ stationIndex: 0, channel: 1, xPx: inset + usableWidth / 2, current: true, label: '01' }],
|
|
||||||
needleX: inset + usableWidth / 2,
|
|
||||||
pitchPx: Math.max(1, Number(minPitchPx) || 14),
|
|
||||||
ratio: 0.5,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const directoryStep = usableWidth / (count - 1);
|
|
||||||
const pitchPx = Math.max(
|
|
||||||
Math.max(1, Number(minPitchPx) || 14),
|
|
||||||
directoryStep * Math.max(1, Number(speedFactor) || 5),
|
|
||||||
);
|
|
||||||
const needleX = inset + directoryStep * value;
|
|
||||||
const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0);
|
|
||||||
const first = Math.max(0, Math.ceil(value + (-overscanPx - needleX) / pitchPx));
|
|
||||||
const last = Math.min(count - 1, Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx));
|
|
||||||
const currentIndex = Math.min(count - 1, Math.max(0, Math.floor(value + 0.5)));
|
|
||||||
const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6));
|
|
||||||
const labelWidth = Math.max(2, String(count).length);
|
|
||||||
const ticks = [];
|
|
||||||
for (let stationIndex = first; stationIndex <= last; stationIndex += 1) {
|
|
||||||
const channel = stationIndex + 1;
|
|
||||||
const current = stationIndex === currentIndex;
|
|
||||||
const labelled = current || stationIndex === 0 || stationIndex === count - 1 || channel % majorEvery === 0;
|
|
||||||
ticks.push({
|
|
||||||
stationIndex,
|
|
||||||
channel,
|
|
||||||
xPx: needleX + pitchPx * (stationIndex - value),
|
|
||||||
current,
|
|
||||||
label: labelled ? String(channel).padStart(labelWidth, '0') : '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { ticks, needleX, pitchPx, ratio: value / (count - 1) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Plan a station-centered camera move that preserves altitude and view angle. */
|
/** Plan a station-centered camera move that preserves altitude and view angle. */
|
||||||
export function radioStationCameraPlan(station, cameraState = {}) {
|
export function radioStationCameraPlan(station, cameraState = {}) {
|
||||||
const targetLat = Number(station?.lat);
|
const targetLat = Number(station?.lat);
|
||||||
|
|||||||
@@ -263,14 +263,14 @@ test('an overlay with NO class to watch still disarms the launcher', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
|
test('one ESC does one thing — the radio disclosure stops the launcher outright', () => {
|
||||||
const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
const ui = fs.readFileSync(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||||
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
// stopPropagation() does NOT stop later listeners on the SAME document, so the
|
// stopPropagation() does NOT stop later listeners on the SAME document, so the
|
||||||
// disclosure's earlier capture handler closed the disclosure and the launcher
|
// disclosure's earlier capture handler closed the disclosure and the launcher
|
||||||
// dismissed itself off the same key. The earlier listener is the only one that
|
// dismissed itself off the same key. The earlier listener is the only one that
|
||||||
// can stop the later one — and only the immediate form does it.
|
// can stop the later one — and only the immediate form does it.
|
||||||
const radioEsc = ui.slice(ui.indexOf("if (event.key !== 'Escape' || !this._contextRadioDock"));
|
const radioEsc = ui.slice(ui.indexOf("event.key !== 'Escape' ||"));
|
||||||
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
|
const claim = radioEsc.slice(0, radioEsc.indexOf('setRadioDisclosure(false'));
|
||||||
assert.match(claim, /event\.preventDefault\(\);/);
|
assert.match(claim, /event\.preventDefault\(\);/);
|
||||||
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
|
assert.match(claim, /event\.stopImmediatePropagation\(\);/);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { readFileSync as readRadioSource } from 'node:fs';
|
||||||
|
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||||
|
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||||
|
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||||
import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
|
import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
@@ -155,16 +159,16 @@ test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit',
|
|||||||
|
|
||||||
test('Cockpit utility Escape leaves an expanded nested Parameters panel to the shared handler', () => {
|
test('Cockpit utility Escape leaves an expanded nested Parameters panel to the shared handler', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
radioBindings,
|
||||||
/const nestedPanel = event\.target\?\.closest\?\.\('\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)'\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/,
|
/const nestedPanel = event\.target\?\.closest\?\.\(\s*'\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)',?\s*\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
radioBindings,
|
||||||
/const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/,
|
/const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/,
|
||||||
'Cockpit utility disclosures clear their own focus when Escape closes them',
|
'Cockpit utility disclosures clear their own focus when Escape closes them',
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
radioBindings,
|
||||||
/_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/,
|
/_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/,
|
||||||
'compact Radio disclosure clears its own focus when Escape closes it',
|
'compact Radio disclosure clears its own focus when Escape closes it',
|
||||||
);
|
);
|
||||||
|
|||||||
+33
-33
@@ -1,3 +1,7 @@
|
|||||||
|
import { readFileSync as readRadioSource } from 'node:fs';
|
||||||
|
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||||
|
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
|
||||||
|
const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8');
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -249,19 +253,19 @@ test('Radio is nested inside Context with separate disclosure and power controls
|
|||||||
assert.match(css, /--radio-broadcast-opacity: \.17/);
|
assert.match(css, /--radio-broadcast-opacity: \.17/);
|
||||||
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.radio-tuner-needle,[\s\S]*?\.radio-tuner-tick\s*\{\s*transition: none;/);
|
assert.match(css, /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.radio-tuner-needle,[\s\S]*?\.radio-tuner-tick\s*\{\s*transition: none;/);
|
||||||
assert.doesNotMatch(ui, /_radioTunerCameraRemove = this\.viewer\?\.camera\?\.changed/);
|
assert.doesNotMatch(ui, /_radioTunerCameraRemove = this\.viewer\?\.camera\?\.changed/);
|
||||||
assert.match(ui, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/);
|
assert.match(radioPresentation, /classList\.toggle\('radio-broadcasting', state\.audioState === 'playing'\)/);
|
||||||
assert.match(ui, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/);
|
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?rotate,[\s\S]*?stationIds:/);
|
||||||
const cycleStart = ui.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>');
|
const cycleStart = radioBindings.indexOf('const cycleRadio = (direction, { rotate = true } = {}) =>');
|
||||||
const cycleMethod = ui.slice(cycleStart, ui.indexOf('const toggleRadio', cycleStart));
|
const cycleMethod = radioBindings.slice(cycleStart, radioBindings.indexOf('const toggleRadio', cycleStart));
|
||||||
assert.doesNotMatch(cycleMethod, /refreshTunerBand/);
|
assert.doesNotMatch(cycleMethod, /refreshTunerBand/);
|
||||||
assert.match(ui, /_radioTunerBandPinnedForNavigation = true/);
|
assert.match(radioBindings, /_radioTunerBandPinnedForNavigation = true/);
|
||||||
assert.match(ui, /viewer\?\.canvas\?\.addEventListener\('pointerdown', releaseNavigationBand/);
|
assert.match(radioBindings, /listen\(\s*this\.canvas,\s*'pointerdown',\s*releaseNavigationBand/);
|
||||||
assert.match(ui, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/);
|
assert.match(radioBindings, /previewTuningStation\(station\?\.id \|\| null, \{ rotate \}\)/);
|
||||||
assert.match(ui, /tunerPreview\(\{ coordinate: this\._radioTunerCoordinate, rotate: commit \}\)/);
|
assert.match(radioBindings, /tunerPreview\(\{\s*coordinate: this\._radioTunerCoordinate,\s*rotate: commit,?\s*\}\)/);
|
||||||
assert.match(ui, /radioLayer\.cancelTuning\(\)/);
|
assert.match(radioBindings, /this\.radio\.cancelTuning\(\)/);
|
||||||
assert.match(ui, /classList\.remove\('radio-broadcasting'\)/);
|
assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/);
|
||||||
assert.match(ui, /radioLayer\.getTunerStations\(750\)/);
|
assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/);
|
||||||
assert.match(ui, /radioTunerPointerPosition\(/);
|
assert.match(radioBindings, /radioTunerPointerPosition\(/);
|
||||||
assert.doesNotMatch(css, /#right-context-rail\s*>\s*#radio-panel/);
|
assert.doesNotMatch(css, /#right-context-rail\s*>\s*#radio-panel/);
|
||||||
assert.match(css, /#global-context-panel #radio-panel\.collapsed/);
|
assert.match(css, /#global-context-panel #radio-panel\.collapsed/);
|
||||||
assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/);
|
assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/);
|
||||||
@@ -277,16 +281,15 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
|
|||||||
assert.match(voice, /'radio'/);
|
assert.match(voice, /'radio'/);
|
||||||
assert.match(voice, /name:\s*'control_radio'/);
|
assert.match(voice, /name:\s*'control_radio'/);
|
||||||
assert.deepEqual(realtimeTools().find(tool => tool.name === 'control_radio').parameters.properties.action.enum, ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status']);
|
assert.deepEqual(realtimeTools().find(tool => tool.name === 'control_radio').parameters.properties.action.enum, ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status']);
|
||||||
const enableStart = ui.lastIndexOf('\n _initRadioPanel()');
|
const enableMethod = radioBindings;
|
||||||
const enableMethod = ui.slice(enableStart, ui.indexOf('\n _renderRadioState(state)', enableStart));
|
|
||||||
assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i);
|
assert.doesNotMatch(enableMethod, /playSelectedRadio|togglePlayback\(\).*radio-enable/i);
|
||||||
assert.match(enableMethod, /contextRadioToggleBtn/);
|
assert.match(enableMethod, /contextRadioToggleBtn/);
|
||||||
assert.match(enableMethod, /contextRadioMiniEnableBtn/);
|
assert.match(enableMethod, /contextRadioMiniEnableBtn/);
|
||||||
assert.match(enableMethod, /contextRadioDetailsBtn/);
|
assert.match(enableMethod, /contextRadioDetailsBtn/);
|
||||||
assert.match(enableMethod, /contextRadioMiniCloseBtn/);
|
assert.match(enableMethod, /contextRadioMiniCloseBtn/);
|
||||||
assert.match(enableMethod, /contextRadioMiniPlayBtn/);
|
assert.match(enableMethod, /contextRadioMiniPlayBtn/);
|
||||||
const disclosureStart = enableMethod.indexOf('this._contextRadioToggleBtn?.addEventListener');
|
const disclosureStart = enableMethod.indexOf('this.listen(this._contextRadioToggleBtn,');
|
||||||
const disclosureEnd = enableMethod.indexOf("this._radioFilter?.addEventListener", disclosureStart);
|
const disclosureEnd = enableMethod.indexOf("this.listen(this._radioFilter,", disclosureStart);
|
||||||
const disclosureBindings = enableMethod.slice(disclosureStart, disclosureEnd);
|
const disclosureBindings = enableMethod.slice(disclosureStart, disclosureEnd);
|
||||||
assert.ok(disclosureStart >= 0 && disclosureEnd > disclosureStart, 'compact Radio disclosure bindings are missing');
|
assert.ok(disclosureStart >= 0 && disclosureEnd > disclosureStart, 'compact Radio disclosure bindings are missing');
|
||||||
assert.doesNotMatch(disclosureBindings, /toggleRadio\(this\._contextRadioToggleBtn\)/);
|
assert.doesNotMatch(disclosureBindings, /toggleRadio\(this\._contextRadioToggleBtn\)/);
|
||||||
@@ -304,32 +307,29 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => {
|
test('expanded Context routes its Radio icon to the embedded section and keeps collapsed Context compact', () => {
|
||||||
const revealStart = ui.indexOf('\n async _revealRadioPanelInsideContext');
|
const revealStart = radioControlsSource.indexOf('\n async _revealRadioPanelInsideContext');
|
||||||
const revealEnd = ui.indexOf('\n /**', revealStart + 10);
|
const revealEnd = radioControlsSource.indexOf('\n /**', revealStart + 10);
|
||||||
const revealMethod = ui.slice(revealStart, revealEnd);
|
const revealMethod = radioControlsSource.slice(revealStart, revealEnd);
|
||||||
assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing');
|
assert.ok(revealStart >= 0 && revealEnd > revealStart, 'embedded Radio reveal helper is missing');
|
||||||
assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/);
|
assert.match(revealMethod, /requestAnimationFrame\(\(\) => requestAnimationFrame/);
|
||||||
assert.match(revealMethod, /scroller\.scrollTo\(\{ top: next, behavior: reducedMotion \? 'auto' : 'smooth' \}\)/);
|
assert.match(revealMethod, /scroller\.scrollTo\(\{\s*top: next,\s*behavior: reducedMotion \? 'auto' : 'smooth',?\s*\}\)/);
|
||||||
assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/);
|
assert.match(revealMethod, /focus\?\.\(\{ preventScroll: true \}\)/);
|
||||||
assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/);
|
assert.doesNotMatch(revealMethod, /setEnabled|togglePlayback|selectStation|setContextMode/);
|
||||||
|
|
||||||
const syncStart = ui.indexOf('\n _syncContextRadioLauncherState()');
|
const syncStart = radioControlsSource.indexOf('\n _syncContextRadioLauncherState()');
|
||||||
const syncEnd = ui.indexOf('\n /**', syncStart + 10);
|
const syncEnd = radioControlsSource.indexOf('\n destroy()', syncStart + 10);
|
||||||
const syncMethod = ui.slice(syncStart, syncEnd);
|
const syncMethod = radioControlsSource.slice(syncStart, syncEnd);
|
||||||
assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing');
|
assert.ok(syncStart >= 0 && syncEnd > syncStart, 'Context Radio launcher state sync is missing');
|
||||||
assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded', String\(radioExpanded\)/);
|
assert.match(syncMethod, /contextExpanded[\s\S]*?aria-controls', 'radio-panel'[\s\S]*?aria-expanded',\s*String\(radioExpanded\)/);
|
||||||
assert.match(syncMethod, /aria-controls', 'context-radio-mini'[\s\S]*?aria-expanded', String\(compactOpen\)/);
|
assert.match(syncMethod, /aria-controls',\s*'context-radio-mini'[\s\S]*?aria-expanded',\s*String\(compactOpen\)/);
|
||||||
const renderStart = ui.indexOf('\n _renderRadioState(state)');
|
const renderMethod = radioPresentation;
|
||||||
const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart));
|
|
||||||
assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/);
|
assert.match(renderMethod, /this\._syncContextRadioLauncherState\(\)/);
|
||||||
assert.doesNotMatch(renderMethod, /compact Radio controls/);
|
assert.doesNotMatch(renderMethod, /compact Radio controls/);
|
||||||
assert.doesNotMatch(renderMethod, /_contextRadioToggleBtn\.setAttribute\('aria-(?:controls|expanded|label)'/);
|
assert.doesNotMatch(renderMethod, /_contextRadioToggleBtn\.setAttribute\('aria-(?:controls|expanded|label)'/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Radio disclosure is explicit, starts closed while off, and preserves playback state', () => {
|
test('Radio disclosure is explicit, starts closed while off, and preserves playback state', () => {
|
||||||
const renderStart = ui.indexOf('\n _renderRadioState(state)');
|
const renderMethod = radioPresentation;
|
||||||
const renderMethod = ui.slice(renderStart, ui.indexOf('\n _renderRadioTuner', renderStart));
|
|
||||||
assert.ok(renderStart >= 0, 'Radio render method is missing');
|
|
||||||
assert.doesNotMatch(renderMethod, /setPanelCollapsed\('radio-panel', true\).*stopPlayback/s);
|
assert.doesNotMatch(renderMethod, /setPanelCollapsed\('radio-panel', true\).*stopPlayback/s);
|
||||||
assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s);
|
assert.doesNotMatch(renderMethod, /_radioMiniExpanded\s*=\s*false.*audioState === 'playing'/s);
|
||||||
assert.match(ui, /contextRadioDetailsBtn/);
|
assert.match(ui, /contextRadioDetailsBtn/);
|
||||||
@@ -346,8 +346,8 @@ test('successful explicit user playback hands the speaker from voice to Radio',
|
|||||||
assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying);
|
assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying);
|
||||||
assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/);
|
assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/);
|
||||||
assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/);
|
assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/);
|
||||||
assert.match(ui, /togglePlayback\(\{ origin: 'user' \}\)/);
|
assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/);
|
||||||
assert.match(ui, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
|
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
|
||||||
assert.match(ui, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
|
assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
|
||||||
assert.match(realtime, /event\.origin === 'user' && event\.action === 'play' && this\.isActive\(\)[\s\S]*?this\.stop\(\{ preserveRadioPlayback: true \}\)/);
|
assert.match(realtime, /event\.origin === 'user' && event\.action === 'play' && this\.isActive\(\)[\s\S]*?this\.stop\(\{ preserveRadioPlayback: true \}\)/);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { RadioControls } from './radioControls.js';
|
||||||
|
export {
|
||||||
|
radioTunerSlot,
|
||||||
|
radioTunerCommitSlot,
|
||||||
|
radioTunerPointerPosition,
|
||||||
|
buildRadioTunerTicks,
|
||||||
|
} from './radioTunerModel.js';
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
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 setRadioDisclosure = (expanded, { returnFocus = false } = {}) => {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
const open = Boolean(expanded);
|
||||||
|
this._contextRadioDock?.classList.toggle('disclosure-open', open);
|
||||||
|
if (this._contextRadioMini) this._contextRadioMini.hidden = !open;
|
||||||
|
this._syncContextRadioLauncherState();
|
||||||
|
if (!open && returnFocus)
|
||||||
|
this._contextRadioToggleBtn?.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
this._setRadioDisclosure = setRadioDisclosure;
|
||||||
|
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) {
|
||||||
|
const action = displayOpen ? 'Collapse' : 'Expand';
|
||||||
|
this._cockpitDisplayToggleBtn.textContent = displayOpen ? '▶' : '◀';
|
||||||
|
this._cockpitDisplayToggleBtn.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`${action} Cockpit display options`,
|
||||||
|
);
|
||||||
|
this._cockpitDisplayToggleBtn.title = `${action} Cockpit display options`;
|
||||||
|
}
|
||||||
|
if (this._cockpitRadioToggleBtn) {
|
||||||
|
const action = radioOpen ? 'Collapse' : 'Expand';
|
||||||
|
this._cockpitRadioToggleBtn.textContent = radioOpen ? '▶' : '◀';
|
||||||
|
this._cockpitRadioToggleBtn.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`${action} Cockpit Radio controls`,
|
||||||
|
);
|
||||||
|
this._cockpitRadioToggleBtn.title = `${action} 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
|
||||||
|
? `CH ${String(slot.stationIndex + 1).padStart(2, '0')} / ${String(this._radioTunerStations.length).padStart(2, '0')}`
|
||||||
|
: 'NO STATIONS';
|
||||||
|
}
|
||||||
|
if (this._radioTunerStation)
|
||||||
|
this._radioTunerStation.textContent =
|
||||||
|
station?.name || 'NO STATION AVAILABLE';
|
||||||
|
if (this._radioTunerSlider) {
|
||||||
|
this._radioTunerSlider.setAttribute(
|
||||||
|
'aria-valuetext',
|
||||||
|
station
|
||||||
|
? `${station.name}, station ${slot.stationIndex + 1} of ${this._radioTunerStations.length}`
|
||||||
|
: '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 = 'OFF AIR';
|
||||||
|
if (this._radioTunerStation)
|
||||||
|
this._radioTunerStation.textContent = 'STATION UNAVAILABLE';
|
||||||
|
this._radioTunerSlider?.setAttribute(
|
||||||
|
'aria-valuetext',
|
||||||
|
'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,
|
||||||
|
}),
|
||||||
|
`Radio could not ${enabling ? 'start' : 'stop'} cleanly`,
|
||||||
|
);
|
||||||
|
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._contextRadioMiniEnableBtn,
|
||||||
|
'click',
|
||||||
|
() => void toggleRadio(this._contextRadioMiniEnableBtn),
|
||||||
|
);
|
||||||
|
this.listen(
|
||||||
|
this._cockpitRadioEnableBtn,
|
||||||
|
'click',
|
||||||
|
() => void toggleRadio(this._cockpitRadioEnableBtn),
|
||||||
|
);
|
||||||
|
this.listen(this._contextRadioToggleBtn, 'click', () => {
|
||||||
|
const contextPanel = document.getElementById('global-context-panel');
|
||||||
|
if (contextPanel && !contextPanel.classList.contains('collapsed')) {
|
||||||
|
setRadioDisclosure(false);
|
||||||
|
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||||
|
void this._revealRadioPanelInsideContext({
|
||||||
|
focusTarget: this._radioPanel?.querySelector(
|
||||||
|
'[data-collapse-target="radio-panel"]',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRadioDisclosure(
|
||||||
|
!this._contextRadioDock?.classList.contains('disclosure-open'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.listen(this._contextRadioMiniCloseBtn, 'click', () => {
|
||||||
|
setRadioDisclosure(false, { returnFocus: true });
|
||||||
|
});
|
||||||
|
this.listen(this._contextRadioDetailsBtn, 'click', () => {
|
||||||
|
if (!this.actions.isCockpitActive())
|
||||||
|
this.actions.setPanelCollapsed('global-context-panel', false, {
|
||||||
|
explicit: true,
|
||||||
|
});
|
||||||
|
this.actions.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||||
|
setRadioDisclosure(false);
|
||||||
|
this._radioEnableBtn?.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
this.listen(this._cockpitRadioToggleBtn, 'click', () => {
|
||||||
|
const open =
|
||||||
|
this._cockpitRadioToggleBtn.getAttribute('aria-expanded') === 'true';
|
||||||
|
setCockpitDisclosure('radio', !open);
|
||||||
|
});
|
||||||
|
this.listen(
|
||||||
|
document,
|
||||||
|
'pointerdown',
|
||||||
|
(event) => {
|
||||||
|
if (!this._contextRadioDock?.classList.contains('disclosure-open'))
|
||||||
|
return;
|
||||||
|
if (event.target?.closest?.('#context-radio-dock')) return;
|
||||||
|
setRadioDisclosure(false);
|
||||||
|
},
|
||||||
|
tunerListenerOptions,
|
||||||
|
);
|
||||||
|
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' ||
|
||||||
|
!this._contextRadioDock?.classList.contains('disclosure-open')
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
event.preventDefault();
|
||||||
|
// Immediate: a plain stopPropagation() still lets every LATER listener on
|
||||||
|
// this same document run, so closing the disclosure ALSO dismissed the
|
||||||
|
// first-run launcher — one key, two actions. Matches the cockpit
|
||||||
|
// disclosure handler directly below.
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
const escapedFromDisclosure =
|
||||||
|
event.target === this._contextRadioToggleBtn ||
|
||||||
|
this._contextRadioToggleBtn?.contains?.(event.target);
|
||||||
|
setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure });
|
||||||
|
if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.();
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
);
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
this.listen(this._contextRadioMiniPrevBtn, 'click', () => cycleRadio(-1));
|
||||||
|
this.listen(this._contextRadioMiniNextBtn, 'click', () => cycleRadio(1));
|
||||||
|
this.listen(
|
||||||
|
this._contextRadioMiniPlayBtn,
|
||||||
|
'click',
|
||||||
|
() => void this.radio.togglePlayback({ 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._contextRadioMiniVolume, 'input', () => {
|
||||||
|
const value = Number(this._contextRadioMiniVolume.value);
|
||||||
|
if (this._contextRadioMiniVolumeValue)
|
||||||
|
this._contextRadioMiniVolumeValue.textContent = `${value}%`;
|
||||||
|
this.actions.setParams({ volume: value / 100 }, { 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
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 = 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 = 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 Context header Radio shortcut truthful for its current route. */
|
||||||
|
_syncContextRadioLauncherState() {
|
||||||
|
if (this.destroyed || !this._contextRadioToggleBtn) return;
|
||||||
|
const contextPanel = 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;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { RadioControls } from './radioControls.js';
|
||||||
|
|
||||||
|
class Element extends EventTarget {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attrs = new Map();
|
||||||
|
this.classes = new Set();
|
||||||
|
this.focusCount = 0;
|
||||||
|
this.isConnected = true;
|
||||||
|
this.classList = {
|
||||||
|
add: (...names) => names.forEach((n) => this.classes.add(n)),
|
||||||
|
remove: (...names) => names.forEach((n) => this.classes.delete(n)),
|
||||||
|
contains: (name) => this.classes.has(name),
|
||||||
|
toggle: (name, enabled) =>
|
||||||
|
enabled ? this.classes.add(name) : this.classes.delete(name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setAttribute(name, value) {
|
||||||
|
this.attrs.set(name, String(value));
|
||||||
|
}
|
||||||
|
getAttribute(name) {
|
||||||
|
return this.attrs.get(name);
|
||||||
|
}
|
||||||
|
querySelector() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
focus() {
|
||||||
|
this.focusCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const prior = { document: globalThis.document, window: globalThis.window };
|
||||||
|
const document = new EventTarget();
|
||||||
|
document.getElementById = () => null;
|
||||||
|
globalThis.document = document;
|
||||||
|
globalThis.window = new EventTarget();
|
||||||
|
const panel = new Element();
|
||||||
|
const enable = new Element();
|
||||||
|
const next = new Element();
|
||||||
|
const calls = [];
|
||||||
|
const subscribers = [];
|
||||||
|
const radio = {
|
||||||
|
subscribe(callback) {
|
||||||
|
subscribers.push(callback);
|
||||||
|
calls.push('subscribe');
|
||||||
|
return () => calls.push('unsubscribe');
|
||||||
|
},
|
||||||
|
endTuning() {
|
||||||
|
calls.push('endTuning');
|
||||||
|
},
|
||||||
|
cycleStation(direction) {
|
||||||
|
calls.push(['cycle', direction]);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const actions = {
|
||||||
|
isRegistered: () => true,
|
||||||
|
isEnabled: () => false,
|
||||||
|
runUserAction: (operation) => operation(),
|
||||||
|
setEnabled: async () => true,
|
||||||
|
setPanelCollapsed: (...args) => calls.push(['panel', ...args]),
|
||||||
|
layoutCockpit() {},
|
||||||
|
isCockpitActive: () => false,
|
||||||
|
scheduleLayout() {},
|
||||||
|
getLifecycle: () => null,
|
||||||
|
preservePanelStateDuringClear: () => false,
|
||||||
|
};
|
||||||
|
const controls = new RadioControls({
|
||||||
|
elements: {
|
||||||
|
_radioPanel: panel,
|
||||||
|
_radioEnableBtn: enable,
|
||||||
|
_radioNextBtn: next,
|
||||||
|
},
|
||||||
|
radio,
|
||||||
|
actions,
|
||||||
|
canvas: new Element(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
controls,
|
||||||
|
enable,
|
||||||
|
next,
|
||||||
|
actions,
|
||||||
|
calls,
|
||||||
|
subscribers,
|
||||||
|
document,
|
||||||
|
cleanup() {
|
||||||
|
controls.destroy();
|
||||||
|
Object.assign(globalThis, prior);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Radio listeners and subscriptions are revoked once before tuning teardown', () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
f.controls.connect();
|
||||||
|
f.next.dispatchEvent(new Event('click'));
|
||||||
|
assert.deepEqual(f.calls, ['subscribe', ['cycle', 1]]);
|
||||||
|
f.controls.destroy();
|
||||||
|
f.controls.destroy();
|
||||||
|
const snapshot = structuredClone(f.calls);
|
||||||
|
f.controls._setRadioDisclosure(true);
|
||||||
|
f.controls._setCockpitDisclosure('display', true);
|
||||||
|
f.next.dispatchEvent(new Event('click'));
|
||||||
|
f.document.dispatchEvent(new Event('gev:radio-selected'));
|
||||||
|
f.subscribers[0]({ enabled: true });
|
||||||
|
f.controls.connect();
|
||||||
|
assert.deepEqual(f.calls, snapshot);
|
||||||
|
assert.deepEqual(f.calls.slice(-2), ['unsubscribe', 'endTuning']);
|
||||||
|
} finally {
|
||||||
|
f.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Radio reconnect removes its previous state subscription', () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
f.controls.connect();
|
||||||
|
f.controls.connect();
|
||||||
|
assert.deepEqual(f.calls, ['subscribe', 'unsubscribe', 'subscribe']);
|
||||||
|
} finally {
|
||||||
|
f.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a delayed Radio enable cannot reveal or refocus controls after destruction', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
let resolve;
|
||||||
|
const pending = new Promise((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
f.actions.setEnabled = () => pending;
|
||||||
|
f.enable.dispatchEvent(new Event('click'));
|
||||||
|
assert.equal(f.enable.getAttribute('aria-busy'), 'true');
|
||||||
|
f.controls.destroy();
|
||||||
|
const attrs = new Map(f.enable.attrs);
|
||||||
|
resolve(true);
|
||||||
|
await pending;
|
||||||
|
await new Promise((done) => setImmediate(done));
|
||||||
|
assert.deepEqual(f.enable.attrs, attrs);
|
||||||
|
assert.equal(f.enable.focusCount, 0);
|
||||||
|
assert.equal(
|
||||||
|
f.calls.some((call) => Array.isArray(call) && call[0] === 'panel'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
f.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destruction releases an active tuner pointer after revoking listeners', () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
const released = [];
|
||||||
|
f.controls._radioTunerSlider = {
|
||||||
|
releasePointerCapture(id) {
|
||||||
|
assert.equal(f.controls.listeners.signal.aborted, true);
|
||||||
|
released.push(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
f.controls._radioTunerPointerId = 7;
|
||||||
|
f.controls._radioTunerDragging = true;
|
||||||
|
f.controls.destroy();
|
||||||
|
assert.deepEqual(released, [7]);
|
||||||
|
assert.equal(f.controls._radioTunerDragging, false);
|
||||||
|
assert.equal(f.controls._radioTunerPointerId, null);
|
||||||
|
} finally {
|
||||||
|
f.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
/** Render Radio state without making playback or Context decisions. */
|
||||||
|
export function renderRadioState(state) {
|
||||||
|
if (this.destroyed || !state || !this._radioPanel) return;
|
||||||
|
const lifecycle = this.actions.getLifecycle() || null;
|
||||||
|
const lifecycleState =
|
||||||
|
lifecycle?.lifecycleState || (state.enabled ? 'enabled' : 'disabled');
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
enabled: lifecycle ? lifecycle.enabled : state.enabled,
|
||||||
|
lifecycleState,
|
||||||
|
lifecycleUncertain: lifecycle?.uncertain || false,
|
||||||
|
};
|
||||||
|
this._radioState = state;
|
||||||
|
const enabled = Boolean(state.enabled);
|
||||||
|
const transitioning =
|
||||||
|
lifecycleState === 'enabling' || lifecycleState === 'disabling';
|
||||||
|
const uncertain = Boolean(state.lifecycleUncertain);
|
||||||
|
const interactive = enabled && !transitioning && !uncertain;
|
||||||
|
const selected = state.selected || null;
|
||||||
|
const hasStations = state.filteredCount > 0;
|
||||||
|
const activePlayback = ['playing', 'buffering'].includes(state.audioState);
|
||||||
|
document
|
||||||
|
.getElementById('title-bar')
|
||||||
|
?.classList.toggle('radio-broadcasting', state.audioState === 'playing');
|
||||||
|
this._radioPanel.classList.toggle('radio-enabled', enabled);
|
||||||
|
this._radioPanel.classList.toggle('lifecycle-uncertain', uncertain);
|
||||||
|
this._contextRadioDock?.classList.toggle('active', enabled);
|
||||||
|
if (this._contextRadioToggleBtn) {
|
||||||
|
this._contextRadioToggleBtn.classList.toggle('active', enabled);
|
||||||
|
}
|
||||||
|
this._syncContextRadioLauncherState();
|
||||||
|
this._radioLayerState?.classList.toggle('active', enabled);
|
||||||
|
if (this._radioLayerState) {
|
||||||
|
this._radioLayerState.textContent = transitioning
|
||||||
|
? lifecycleState.toUpperCase()
|
||||||
|
: uncertain
|
||||||
|
? 'UNCERTAIN'
|
||||||
|
: state.loading
|
||||||
|
? 'SYNC'
|
||||||
|
: enabled
|
||||||
|
? `${state.filteredCount}/${state.stationCount}`
|
||||||
|
: 'OFF';
|
||||||
|
}
|
||||||
|
if (this._radioEnableBtn) {
|
||||||
|
this._radioEnableBtn.classList.toggle('active', enabled);
|
||||||
|
this._radioEnableBtn.setAttribute('aria-pressed', String(enabled));
|
||||||
|
this._radioEnableBtn.textContent = transitioning
|
||||||
|
? lifecycleState.toUpperCase()
|
||||||
|
: uncertain
|
||||||
|
? 'RECONCILE'
|
||||||
|
: enabled
|
||||||
|
? 'DISABLE'
|
||||||
|
: 'ENABLE';
|
||||||
|
this._radioEnableBtn.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
uncertain
|
||||||
|
? 'Reconcile Radio — lifecycle uncertain'
|
||||||
|
: `${enabled ? 'Disable' : 'Enable'} Radio`,
|
||||||
|
);
|
||||||
|
this._radioEnableBtn.disabled = false;
|
||||||
|
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
|
||||||
|
this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
|
||||||
|
}
|
||||||
|
if (this._contextRadioMiniEnableBtn) {
|
||||||
|
this._contextRadioMiniEnableBtn.classList.toggle('active', enabled);
|
||||||
|
this._contextRadioMiniEnableBtn.setAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
String(enabled),
|
||||||
|
);
|
||||||
|
this._contextRadioMiniEnableBtn.textContent = transitioning
|
||||||
|
? lifecycleState.toUpperCase()
|
||||||
|
: uncertain
|
||||||
|
? 'RECONCILE'
|
||||||
|
: enabled
|
||||||
|
? 'DISABLE'
|
||||||
|
: 'ENABLE';
|
||||||
|
this._contextRadioMiniEnableBtn.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
uncertain
|
||||||
|
? 'Reconcile Radio — lifecycle uncertain'
|
||||||
|
: `${enabled ? 'Disable' : 'Enable'} Radio`,
|
||||||
|
);
|
||||||
|
this._contextRadioMiniEnableBtn.disabled = false;
|
||||||
|
this._contextRadioMiniEnableBtn.setAttribute(
|
||||||
|
'aria-disabled',
|
||||||
|
String(transitioning),
|
||||||
|
);
|
||||||
|
this._contextRadioMiniEnableBtn.setAttribute(
|
||||||
|
'aria-busy',
|
||||||
|
String(transitioning),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this._cockpitRadioEnableBtn) {
|
||||||
|
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
|
||||||
|
this._cockpitRadioEnableBtn.setAttribute('aria-pressed', String(enabled));
|
||||||
|
this._cockpitRadioEnableBtn.textContent = transitioning
|
||||||
|
? lifecycleState.toUpperCase()
|
||||||
|
: uncertain
|
||||||
|
? 'RECONCILE'
|
||||||
|
: enabled
|
||||||
|
? 'DISABLE'
|
||||||
|
: 'ENABLE';
|
||||||
|
this._cockpitRadioEnableBtn.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
uncertain
|
||||||
|
? 'Reconcile Radio — lifecycle uncertain'
|
||||||
|
: `${enabled ? 'Disable' : 'Enable'} Radio`,
|
||||||
|
);
|
||||||
|
this._cockpitRadioEnableBtn.disabled = false;
|
||||||
|
this._cockpitRadioEnableBtn.setAttribute(
|
||||||
|
'aria-disabled',
|
||||||
|
String(transitioning),
|
||||||
|
);
|
||||||
|
this._cockpitRadioEnableBtn.setAttribute(
|
||||||
|
'aria-busy',
|
||||||
|
String(transitioning),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._radioFilter) {
|
||||||
|
const prior = state.filter || 'all';
|
||||||
|
const categorySignature = state.categories
|
||||||
|
.map((category) => `${category.id}:${category.count}:${category.color}`)
|
||||||
|
.join('|');
|
||||||
|
if (categorySignature !== this._radioCategorySignature) {
|
||||||
|
this._radioFilter.replaceChildren(
|
||||||
|
...state.categories.map((category) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = category.id;
|
||||||
|
option.textContent = `● ${category.label} (${category.count})`;
|
||||||
|
option.dataset.radioColor = category.color;
|
||||||
|
option.style.color = category.color;
|
||||||
|
option.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`${category.label} (${category.count})`,
|
||||||
|
);
|
||||||
|
return option;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this._radioCategorySignature = categorySignature;
|
||||||
|
}
|
||||||
|
this._radioFilter.value = prior;
|
||||||
|
const activeCategory = state.categories.find(
|
||||||
|
(category) => category.id === prior,
|
||||||
|
);
|
||||||
|
this._radioFilter.style.color = activeCategory?.color || '';
|
||||||
|
this._radioFilter.disabled = !interactive || !state.stationCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tunerAvailable = interactive && state.filteredCount > 0;
|
||||||
|
if (this._radioTuner) this._radioTuner.hidden = !tunerAvailable;
|
||||||
|
if (this._radioTunerSlider) this._radioTunerSlider.disabled = !tunerAvailable;
|
||||||
|
if (this._radioTunerBandLabel) {
|
||||||
|
const activeCategory = state.categories.find(
|
||||||
|
(category) => category.id === state.filter,
|
||||||
|
);
|
||||||
|
this._radioTunerBandLabel.textContent =
|
||||||
|
state.filter === 'all'
|
||||||
|
? 'DIRECTORY BAND'
|
||||||
|
: `${String(activeCategory?.label || state.filter).toUpperCase()} BAND`;
|
||||||
|
}
|
||||||
|
this._radioTuner?.classList.toggle('is-static', Boolean(state.tuningStatic));
|
||||||
|
if (tunerAvailable) this._refreshRadioTunerBand?.();
|
||||||
|
if (!tunerAvailable && this._radioTunerDragging) {
|
||||||
|
this._radioTunerDragging = false;
|
||||||
|
this._radioTunerDragSnapshot = null;
|
||||||
|
this._radioTunerStations = [];
|
||||||
|
this._radioTuner?.classList.remove('is-static', 'is-dragging');
|
||||||
|
}
|
||||||
|
if (!tunerAvailable) {
|
||||||
|
this._radioTunerStations = [];
|
||||||
|
this._radioTunerPool = [];
|
||||||
|
this._radioTunerBandSignature = '';
|
||||||
|
this._radioTunerSelectedId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._radioStationName)
|
||||||
|
this._radioStationName.textContent =
|
||||||
|
selected?.name || 'NO STATION SELECTED';
|
||||||
|
if (this._radioStationMeta) {
|
||||||
|
const place = selected
|
||||||
|
? [selected.state, selected.countryCode].filter(Boolean).join(' · ')
|
||||||
|
: '';
|
||||||
|
const signal = selected
|
||||||
|
? [selected.codec, selected.bitrate ? `${selected.bitrate} kbps` : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
: '';
|
||||||
|
this._radioStationMeta.textContent = selected
|
||||||
|
? [place, signal].filter(Boolean).join(' / ') ||
|
||||||
|
'Directory metadata only'
|
||||||
|
: state.loading
|
||||||
|
? 'Loading station directory…'
|
||||||
|
: 'Choose a globe marker or use next.';
|
||||||
|
}
|
||||||
|
if (this._radioStationTags) {
|
||||||
|
const tags = Array.isArray(selected?.tags) ? selected.tags.slice(0, 8) : [];
|
||||||
|
this._radioStationTags.textContent = tags.length
|
||||||
|
? `TAGS · ${tags.join(' · ')}`
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
if (this._radioStationHomepage) {
|
||||||
|
const homepage = selected?.homepage || '';
|
||||||
|
this._radioStationHomepage.hidden = !homepage;
|
||||||
|
if (homepage) this._radioStationHomepage.href = homepage;
|
||||||
|
else this._radioStationHomepage.removeAttribute('href');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._radioPrevBtn)
|
||||||
|
this._radioPrevBtn.disabled = !interactive || !hasStations;
|
||||||
|
if (this._radioNextBtn)
|
||||||
|
this._radioNextBtn.disabled = !interactive || !hasStations;
|
||||||
|
if (this._contextRadioMiniPrevBtn)
|
||||||
|
this._contextRadioMiniPrevBtn.disabled = !interactive || !hasStations;
|
||||||
|
if (this._contextRadioMiniNextBtn)
|
||||||
|
this._contextRadioMiniNextBtn.disabled = !interactive || !hasStations;
|
||||||
|
if (this._cockpitRadioPrevBtn)
|
||||||
|
this._cockpitRadioPrevBtn.disabled = !interactive || !hasStations;
|
||||||
|
if (this._cockpitRadioNextBtn)
|
||||||
|
this._cockpitRadioNextBtn.disabled = !interactive || !hasStations;
|
||||||
|
if (this._radioPlayBtn) {
|
||||||
|
const action = activePlayback
|
||||||
|
? 'Pause'
|
||||||
|
: state.audioState === 'paused'
|
||||||
|
? 'Resume'
|
||||||
|
: 'Play';
|
||||||
|
this._radioPlayBtn.disabled = !interactive || !hasStations;
|
||||||
|
this._radioPlayBtn.classList.toggle('active', activePlayback);
|
||||||
|
this._radioPlayBtn.textContent = action.toUpperCase();
|
||||||
|
this._radioPlayBtn.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`${action} ${selected ? 'selected' : 'nearest'} radio station`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this._contextRadioMiniPlayBtn) {
|
||||||
|
const action = activePlayback
|
||||||
|
? 'Pause'
|
||||||
|
: state.audioState === 'paused'
|
||||||
|
? 'Resume'
|
||||||
|
: 'Play';
|
||||||
|
this._contextRadioMiniPlayBtn.disabled = !interactive || !hasStations;
|
||||||
|
this._contextRadioMiniPlayBtn.classList.toggle('active', activePlayback);
|
||||||
|
this._contextRadioMiniPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
|
||||||
|
this._contextRadioMiniPlayBtn.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`${action} ${selected ? 'selected' : 'nearest'} radio station`,
|
||||||
|
);
|
||||||
|
this._contextRadioMiniPlayBtn.title = action;
|
||||||
|
}
|
||||||
|
if (this._cockpitRadioPlayBtn) {
|
||||||
|
const action = activePlayback
|
||||||
|
? 'Pause'
|
||||||
|
: state.audioState === 'paused'
|
||||||
|
? 'Resume'
|
||||||
|
: 'Play';
|
||||||
|
this._cockpitRadioPlayBtn.disabled = !interactive || !hasStations;
|
||||||
|
this._cockpitRadioPlayBtn.classList.toggle('active', activePlayback);
|
||||||
|
this._cockpitRadioPlayBtn.textContent = activePlayback ? 'Ⅱ' : '▶';
|
||||||
|
this._cockpitRadioPlayBtn.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`${action} ${selected ? 'selected' : 'nearest'} radio station`,
|
||||||
|
);
|
||||||
|
this._cockpitRadioPlayBtn.title = action;
|
||||||
|
}
|
||||||
|
if (this._radioStopBtn)
|
||||||
|
this._radioStopBtn.disabled =
|
||||||
|
!interactive || state.audioState === 'stopped';
|
||||||
|
if (this._radioVolume) this._radioVolume.disabled = !interactive;
|
||||||
|
if (this._radioVolume && document.activeElement !== this._radioVolume) {
|
||||||
|
this._radioVolume.value = String(Math.round(state.volume * 100));
|
||||||
|
if (this._radioVolumeValue)
|
||||||
|
this._radioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this._contextRadioMiniVolume &&
|
||||||
|
document.activeElement !== this._contextRadioMiniVolume
|
||||||
|
) {
|
||||||
|
this._contextRadioMiniVolume.value = String(Math.round(state.volume * 100));
|
||||||
|
}
|
||||||
|
if (this._contextRadioMiniVolume)
|
||||||
|
this._contextRadioMiniVolume.disabled = !interactive;
|
||||||
|
if (this._contextRadioMiniVolumeValue) {
|
||||||
|
this._contextRadioMiniVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this._cockpitRadioVolume &&
|
||||||
|
document.activeElement !== this._cockpitRadioVolume
|
||||||
|
) {
|
||||||
|
this._cockpitRadioVolume.value = String(Math.round(state.volume * 100));
|
||||||
|
}
|
||||||
|
if (this._cockpitRadioVolume)
|
||||||
|
this._cockpitRadioVolume.disabled = !interactive;
|
||||||
|
if (this._cockpitRadioVolumeValue) {
|
||||||
|
this._cockpitRadioVolumeValue.textContent = `${Math.round(state.volume * 100)}%`;
|
||||||
|
}
|
||||||
|
if (this._contextRadioMiniStation) {
|
||||||
|
this._contextRadioMiniStation.textContent = uncertain
|
||||||
|
? 'RADIO STATE UNCERTAIN'
|
||||||
|
: selected?.name || (state.loading ? 'SYNCING DIRECTORY' : 'RADIO READY');
|
||||||
|
}
|
||||||
|
if (this._cockpitRadioStation) {
|
||||||
|
this._cockpitRadioStation.textContent = uncertain
|
||||||
|
? 'UNCERTAIN'
|
||||||
|
: selected?.name || (state.loading ? 'SYNCING' : 'READY');
|
||||||
|
}
|
||||||
|
if (this._radioPlaybackState) {
|
||||||
|
const catalogSuffix = state.degraded
|
||||||
|
? state.stale
|
||||||
|
? ' · stale/degraded directory'
|
||||||
|
: ' · degraded directory'
|
||||||
|
: state.stale
|
||||||
|
? ' · stale directory'
|
||||||
|
: '';
|
||||||
|
const outsideFilter =
|
||||||
|
selected && state.selectedIndex < 0 ? ' · outside current filter' : '';
|
||||||
|
const messages = {
|
||||||
|
stopped: enabled
|
||||||
|
? 'Ready — playback starts only from your action'
|
||||||
|
: 'Radio off',
|
||||||
|
loading: 'Connecting directly to broadcaster…',
|
||||||
|
buffering: 'Buffering broadcaster stream…',
|
||||||
|
playing: `Playing ${selected?.name || 'station'}`,
|
||||||
|
paused: `Paused ${selected?.name || 'station'}`,
|
||||||
|
error: state.audioError || 'Broadcaster stream unavailable',
|
||||||
|
};
|
||||||
|
const voiceSuffix = state.voiceDucked
|
||||||
|
? ' · muted during voice interaction'
|
||||||
|
: state.voiceRestoring
|
||||||
|
? ' · restoring volume after voice'
|
||||||
|
: '';
|
||||||
|
const tuningSuffix = state.tuningAwaitingStationId
|
||||||
|
? state.audioState === 'error'
|
||||||
|
? ' · static indicates no broadcaster audio'
|
||||||
|
: ' · tuning static until broadcaster starts'
|
||||||
|
: '';
|
||||||
|
const unavailable = state.tuningUnavailableStationId
|
||||||
|
? 'Station unavailable after directory refresh — choose another channel'
|
||||||
|
: null;
|
||||||
|
const lifecycleMessage = transitioning
|
||||||
|
? lifecycleState === 'enabling'
|
||||||
|
? 'Radio is enabling…'
|
||||||
|
: 'Radio is disabling…'
|
||||||
|
: null;
|
||||||
|
const uncertainMessage = uncertain
|
||||||
|
? 'Radio lifecycle is uncertain — use Enable or Disable to reconcile'
|
||||||
|
: null;
|
||||||
|
this._radioPlaybackState.textContent = `${uncertainMessage || unavailable || lifecycleMessage || state.error || messages[state.audioState] || 'Ready'}${tuningSuffix}${voiceSuffix}${catalogSuffix}${outsideFilter}`;
|
||||||
|
this._radioPlaybackState.classList.toggle(
|
||||||
|
'error',
|
||||||
|
Boolean(
|
||||||
|
uncertainMessage ||
|
||||||
|
unavailable ||
|
||||||
|
state.error ||
|
||||||
|
state.audioState === 'error',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!enabled &&
|
||||||
|
!transitioning &&
|
||||||
|
!this.actions.preservePanelStateDuringClear() &&
|
||||||
|
!this._radioPanel.classList.contains('collapsed')
|
||||||
|
) {
|
||||||
|
this.actions.setPanelCollapsed('radio-panel', true);
|
||||||
|
}
|
||||||
|
this.actions.scheduleLayout();
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/** Map an integer tuner slot directly to one available directory station. */
|
||||||
|
export function radioTunerSlot(value, stationCount) {
|
||||||
|
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
|
||||||
|
if (!count)
|
||||||
|
return {
|
||||||
|
slot: 0,
|
||||||
|
max: 0,
|
||||||
|
locked: false,
|
||||||
|
stationIndex: -1,
|
||||||
|
leftIndex: -1,
|
||||||
|
rightIndex: -1,
|
||||||
|
};
|
||||||
|
const max = Math.max(0, count - 1);
|
||||||
|
const slot = Math.min(max, Math.max(0, Math.round(Number(value) || 0)));
|
||||||
|
return {
|
||||||
|
slot,
|
||||||
|
max,
|
||||||
|
locked: true,
|
||||||
|
stationIndex: slot,
|
||||||
|
leftIndex: slot,
|
||||||
|
rightIndex: slot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snap a tuner release to the nearest available directory station. */
|
||||||
|
export function radioTunerCommitSlot(value, stationCount) {
|
||||||
|
return radioTunerSlot(value, stationCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map one pointer coordinate to continuous absolute directory progress. */
|
||||||
|
export function radioTunerPointerPosition(
|
||||||
|
clientX,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
stationCount,
|
||||||
|
insetPx = 7,
|
||||||
|
) {
|
||||||
|
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
|
||||||
|
if (!count) return { ratio: 0, coordinate: 0, stationIndex: -1 };
|
||||||
|
if (count === 1) return { ratio: 0.5, coordinate: 0, stationIndex: 0 };
|
||||||
|
const inset = Math.max(0, Number(insetPx) || 0);
|
||||||
|
const usableWidth = Math.max(1, (Number(width) || 0) - inset * 2);
|
||||||
|
const ratio = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
((Number(clientX) || 0) - (Number(left) || 0) - inset) / usableWidth,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const coordinate = ratio * (count - 1);
|
||||||
|
return {
|
||||||
|
ratio,
|
||||||
|
coordinate,
|
||||||
|
stationIndex: Math.min(
|
||||||
|
count - 1,
|
||||||
|
Math.max(0, Math.floor(coordinate + 0.5)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a bounded virtual tuner tape around one continuous directory coordinate. */
|
||||||
|
export function buildRadioTunerTicks(
|
||||||
|
coordinate,
|
||||||
|
stationCount,
|
||||||
|
width,
|
||||||
|
{
|
||||||
|
insetPx = 7,
|
||||||
|
minPitchPx = 14,
|
||||||
|
speedFactor = 5,
|
||||||
|
overscan = 2,
|
||||||
|
labelStep = 6,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const count = Math.max(0, Math.floor(Number(stationCount) || 0));
|
||||||
|
const dialWidth = Math.max(0, Number(width) || 0);
|
||||||
|
const inset = Math.max(0, Number(insetPx) || 0);
|
||||||
|
const usableWidth = Math.max(0, dialWidth - inset * 2);
|
||||||
|
if (!count) return { ticks: [], needleX: inset, pitchPx: 0, ratio: 0 };
|
||||||
|
const value = Math.min(count - 1, Math.max(0, Number(coordinate) || 0));
|
||||||
|
if (count === 1) {
|
||||||
|
return {
|
||||||
|
ticks: [
|
||||||
|
{
|
||||||
|
stationIndex: 0,
|
||||||
|
channel: 1,
|
||||||
|
xPx: inset + usableWidth / 2,
|
||||||
|
current: true,
|
||||||
|
label: '01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
needleX: inset + usableWidth / 2,
|
||||||
|
pitchPx: Math.max(1, Number(minPitchPx) || 14),
|
||||||
|
ratio: 0.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const directoryStep = usableWidth / (count - 1);
|
||||||
|
const pitchPx = Math.max(
|
||||||
|
Math.max(1, Number(minPitchPx) || 14),
|
||||||
|
directoryStep * Math.max(1, Number(speedFactor) || 5),
|
||||||
|
);
|
||||||
|
const needleX = inset + directoryStep * value;
|
||||||
|
const overscanPx = pitchPx * Math.max(0, Number(overscan) || 0);
|
||||||
|
const first = Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil(value + (-overscanPx - needleX) / pitchPx),
|
||||||
|
);
|
||||||
|
const last = Math.min(
|
||||||
|
count - 1,
|
||||||
|
Math.floor(value + (dialWidth + overscanPx - needleX) / pitchPx),
|
||||||
|
);
|
||||||
|
const currentIndex = Math.min(
|
||||||
|
count - 1,
|
||||||
|
Math.max(0, Math.floor(value + 0.5)),
|
||||||
|
);
|
||||||
|
const majorEvery = Math.max(1, Math.floor(Number(labelStep) || 6));
|
||||||
|
const labelWidth = Math.max(2, String(count).length);
|
||||||
|
const ticks = [];
|
||||||
|
for (let stationIndex = first; stationIndex <= last; stationIndex += 1) {
|
||||||
|
const channel = stationIndex + 1;
|
||||||
|
const current = stationIndex === currentIndex;
|
||||||
|
const labelled =
|
||||||
|
current ||
|
||||||
|
stationIndex === 0 ||
|
||||||
|
stationIndex === count - 1 ||
|
||||||
|
channel % majorEvery === 0;
|
||||||
|
ticks.push({
|
||||||
|
stationIndex,
|
||||||
|
channel,
|
||||||
|
xPx: needleX + pitchPx * (stationIndex - value),
|
||||||
|
current,
|
||||||
|
label: labelled ? String(channel).padStart(labelWidth, '0') : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ticks, needleX, pitchPx, ratio: value / (count - 1) };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user