feat(ui): render the Radio directory tuner from a store

RadioTuner renders the band label, readouts, slider and needle from the
Radio store. RadioControls keeps the drag and its slot and publishes what
the tuner shows; the component lays out the tick tape against the dial's
measured width, drawing no ticks before it has one, and lays out the
snapped needle before it drops the drag class so the needle does not
animate from where the drag let go.
This commit is contained in:
Sameh Khamis committed 2026-10-31 17:15:05 -07:00
1 parent b6d565a83f
commit 1072df325e
17 files changed
+595 -159

No files matched your search

+59 -100
View File
@@ -1,10 +1,13 @@
import { t, msg } from '../i18n/index.js';
import { showRadioEnableBusy, showRadioVolume } from './radioPresentation.js';
import {
showRadioEnableBusy,
showRadioTuner,
showRadioVolume,
} from './radioPresentation.js';
import {
radioTunerSlot,
radioTunerCommitSlot,
radioTunerPointerPosition,
buildRadioTunerTicks,
} from './radioTunerModel.js';
/** Bind Radio input using a RadioControls receiver and supplied actions. */
@@ -43,39 +46,13 @@ export function bindRadioControls() {
/** @param {'display'|'radio'} kind @returns {boolean} Whether it is open. */
this._cockpitDisclosureOpen = (kind) =>
Boolean(this._cockpitDisclosure?.[kind]);
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._radioTunerView.slot,
this._radioTunerStations.length,
);
const station = slot.locked
@@ -93,35 +70,26 @@ export function bindRadioControls() {
? 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
showRadioTuner.call(this, {
ratio,
static: syncStatic ? false : Boolean(this._radioState?.tuningStatic),
coordinate: resolvedCoordinate,
count: this._radioTunerStations.length,
value: station
? t('CH {index} / {total}', {
index: String(slot.stationIndex + 1).padStart(2, '0'),
total: String(this._radioTunerStations.length).padStart(2, '0'),
})
: t('NO STATIONS');
}
if (this._radioTunerStation)
this._radioTunerStation.textContent =
station?.name || t('NO STATION AVAILABLE');
if (this._radioTunerSlider) {
this._radioTunerSlider.setAttribute(
'aria-valuetext',
station
? t('{name}, station {index} of {total}', {
name: station.name,
index: slot.stationIndex + 1,
total: this._radioTunerStations.length,
})
: t('No station available'),
);
}
: t('NO STATIONS'),
station: station?.name || t('NO STATION AVAILABLE'),
valuetext: station
? t('{name}, station {index} of {total}', {
name: station.name,
index: slot.stationIndex + 1,
total: this._radioTunerStations.length,
})
: t('No station available'),
});
if (syncStatic)
this.radio.previewTuningStation(station?.id || null, { rotate });
return station;
@@ -132,20 +100,12 @@ export function bindRadioControls() {
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';
}
showRadioTuner.call(this, {
max: Math.max(0, this._radioTunerStations.length - 1),
});
};
const refreshTunerBand = ({ force = false } = {}) => {
if (
this._radioTunerDragging ||
this._radioTuner?.hidden ||
this._radioTunerSlider?.disabled
)
if (this._radioTunerDragging || !this._radioTunerView.available)
return false;
const selectedId = this._radioState?.selected?.id || null;
const pool = this.radio.getTunerStations(750);
@@ -168,14 +128,14 @@ export function bindRadioControls() {
selectedPoolIndex >= 0 ? selectedPoolIndex : 0,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(slot.slot);
showRadioTuner.call(this, { slot: slot.slot });
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
tunerPreview({ coordinate: this._radioTunerCoordinate, syncStatic: false });
return true;
};
this._refreshRadioTunerBand = refreshTunerBand;
const beginTuner = () => {
if (this._radioTunerDragging || this._radioTunerSlider?.disabled)
if (this._radioTunerDragging || !this._radioTunerView.available)
return false;
refreshTunerBand();
if (!this._radioTunerStations.length || !this.radio.beginTuning())
@@ -184,7 +144,7 @@ export function bindRadioControls() {
// Only an explicit globe pointer/wheel gesture releases camera pinning.
this._radioTunerBandPinnedForNavigation = true;
this._radioTunerDragging = true;
this._radioTuner?.classList.add('is-dragging');
showRadioTuner.call(this, { dragging: true });
const selectedIndex = this._radioTunerStations.findIndex(
(station) => station.id === this._radioState?.selected?.id,
);
@@ -192,7 +152,7 @@ export function bindRadioControls() {
selectedIndex >= 0 ? selectedIndex : 0,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(slot.slot);
showRadioTuner.call(this, { slot: slot.slot });
this._radioTunerCoordinate = slot.stationIndex >= 0 ? slot.stationIndex : 0;
this._radioTunerDragStartSlot = slot.slot;
this._radioTunerDragSnapshot = {
@@ -215,31 +175,28 @@ export function bindRadioControls() {
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);
}
const startSlot = radioTunerSlot(
dragSnapshot.slot,
this._radioTunerStations.length,
);
showRadioTuner.call(this, { max: startSlot.max, slot: startSlot.slot });
this._radioTunerCoordinate = Number.isFinite(dragSnapshot.coordinate)
? dragSnapshot.coordinate
: dragSnapshot.slot;
} else if (!commit && this._radioTunerSlider) {
this._radioTunerSlider.value = String(this._radioTunerDragStartSlot);
} else if (!commit) {
showRadioTuner.call(this, { slot: this._radioTunerDragStartSlot });
this._radioTunerCoordinate = this._radioTunerDragStartSlot;
}
let station = tunerPreview({
coordinate: this._radioTunerCoordinate,
rotate: commit,
});
if (commit && !station && this._radioTunerSlider) {
if (commit && !station) {
const snapped = radioTunerCommitSlot(
this._radioTunerSlider.value,
this._radioTunerView.slot,
this._radioTunerStations.length,
);
this._radioTunerSlider.value = String(snapped.slot);
showRadioTuner.call(this, { slot: snapped.slot });
this._radioTunerCoordinate = snapped.stationIndex;
station = tunerPreview({ coordinate: this._radioTunerCoordinate });
}
@@ -262,21 +219,18 @@ export function bindRadioControls() {
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');
// The tuner lays out the snapped position while transitions are still
// disabled before it drops the drag class, so the needle cannot
// interpolate from the released gap.
showRadioTuner.call(this, { dragging: false });
if (result && !result.ok) {
this._radioTunerBandPinnedForNavigation = false;
if (result.reason === 'station-unavailable') {
if (this._radioTunerValue)
this._radioTunerValue.textContent = t('OFF AIR');
if (this._radioTunerStation)
this._radioTunerStation.textContent = t('STATION UNAVAILABLE');
this._radioTunerSlider?.setAttribute(
'aria-valuetext',
t('Station unavailable after directory refresh'),
);
showRadioTuner.call(this, {
value: t('OFF AIR'),
station: t('STATION UNAVAILABLE'),
valuetext: t('Station unavailable after directory refresh'),
});
}
}
};
@@ -512,7 +466,7 @@ export function bindRadioControls() {
this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = position.stationIndex;
this._radioTunerCoordinate = position.coordinate;
this._radioTunerSlider.value = String(position.stationIndex);
showRadioTuner.call(this, { slot: position.stationIndex });
tunerPreview({ coordinate: position.coordinate });
return true;
};
@@ -553,9 +507,14 @@ export function bindRadioControls() {
() => {
if (this._radioTunerPointerId !== null || this._radioTunerKeyboardKey)
return;
if (!this._radioTunerDragging && !beginTuner()) return;
// A drag the input starts begins from the selected station's slot.
if (this._radioTunerDragging)
showRadioTuner.call(this, {
slot: Number(this._radioTunerSlider.value),
});
else if (!beginTuner()) return;
const inputSlot = radioTunerSlot(
this._radioTunerSlider.value,
this._radioTunerView.slot,
this._radioTunerStations.length,
);
if (inputSlot.slot > this._radioTunerLastSlot)
@@ -648,7 +607,7 @@ export function bindRadioControls() {
this._radioTunerKeyboardKey = event.key;
const max = Math.max(0, this._radioTunerStations.length - 1);
const current = radioTunerSlot(
this._radioTunerSlider.value,
this._radioTunerView.slot,
this._radioTunerStations.length,
).slot;
const page = Math.max(1, Math.round(max / 10));
@@ -669,7 +628,7 @@ export function bindRadioControls() {
this._radioTunerDragDirection = -1;
this._radioTunerLastSlot = slot.slot;
this._radioTunerCoordinate = slot.stationIndex;
this._radioTunerSlider.value = String(slot.slot);
showRadioTuner.call(this, { slot: slot.slot });
tunerPreview({ coordinate: this._radioTunerCoordinate });
},
tunerListenerOptions,