[ui] THE TUNER DOCK — the radio gets a face
Hidden until the optical fossil is excavated, then it slides in bottom-right:
frequency dial (drag or arrow keys), AM/FM/SW, volume and mute, and a station
readout in the house voice that reads STATIC until something locks. Its keys
are inert while hidden so they never shadow the pre-discovery game.
src/audio is still a stub with no radio export, so radio.ts resolves the
control at runtime and otherwise drives a silent echo stand-in — which is why
every dial is functional and testable today rather than waiting.
LANE-SCREEN: publish window.__fktrySpeaker = { radio } and this drives real
audio with no change here. The interface is exactly the one you documented —
setFrequency / setBand / volume / mute / getState / subscribe — with getState()
returning {freqMHz, band, volume, muted, station}; a non-null station is what
flips the readout to SIGNAL LOCKED.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5d6afdac97
commit
d6d6833b78
80
fktry/src/ui/radio.ts
Normal file
80
fktry/src/ui/radio.ts
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* LANE-UI — the bridge to THE SPEAKER's radio control.
|
||||
*
|
||||
* Order 9's tuner dock drives the audio engine through a narrow control that LANE-SCREEN
|
||||
* exports from `src/audio` (their documented API: setFrequency / setBand / volume / mute /
|
||||
* getState / subscribe). That export does not exist yet — `src/audio` is still a stub — so
|
||||
* this module resolves the control at runtime and, until it lands, drives a local echo
|
||||
* stand-in so every dial is fully functional and testable now.
|
||||
*
|
||||
* COORDINATION (see NOTES): I resolve the control from, in order,
|
||||
* 1. `window.__fktrySpeaker.radio` — the handshake I'm proposing (SCREEN already keeps
|
||||
* a `window.__fktryScreen` debug hook, so this is idiomatic for them), or
|
||||
* 2. a `radio` / `getRadio()` export from `../audio` (dynamic, cast — never breaks the
|
||||
* build if absent).
|
||||
* SCREEN: set one of those to a RadioControl and the dock drives real audio with zero
|
||||
* change here. Tell me in NOTES which name you pick.
|
||||
*
|
||||
* The stand-in is silent — it only echoes state back through `subscribe` so the readout is
|
||||
* honest about what the dock is asking for, even with no engine attached.
|
||||
*/
|
||||
export type Band = 'AM' | 'FM' | 'SW';
|
||||
|
||||
export interface RadioState {
|
||||
freqMHz: number;
|
||||
band: Band;
|
||||
volume: number; // 0..1
|
||||
muted: boolean;
|
||||
/** Station name if the current frequency locks one, else null (static). */
|
||||
station: string | null;
|
||||
}
|
||||
|
||||
export interface RadioControl {
|
||||
setFrequency(mhz: number): void;
|
||||
setBand(band: Band): void;
|
||||
volume(v: number): void;
|
||||
mute(on: boolean): void;
|
||||
getState(): RadioState;
|
||||
subscribe(cb: (s: RadioState) => void): () => void;
|
||||
}
|
||||
|
||||
/** Band frequency ranges (MHz-ish; SW/AM are scaled for a single readable dial). */
|
||||
export const BAND_RANGE: Record<Band, { min: number; max: number; step: number }> = {
|
||||
AM: { min: 0.53, max: 1.7, step: 0.01 },
|
||||
FM: { min: 88.0, max: 108.0, step: 0.1 },
|
||||
SW: { min: 5.9, max: 26.1, step: 0.05 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Local echo stand-in. Holds state, notifies subscribers, locks no stations (the real
|
||||
* station map is SCREEN's). Keeps the dock alive before the engine exists.
|
||||
*/
|
||||
export function createEchoRadio(): RadioControl {
|
||||
let state: RadioState = { freqMHz: BAND_RANGE.FM.min, band: 'FM', volume: 0.7, muted: false, station: null };
|
||||
const subs = new Set<(s: RadioState) => void>();
|
||||
const notify = () => { for (const cb of subs) cb(state); };
|
||||
|
||||
return {
|
||||
setFrequency(mhz) { state = { ...state, freqMHz: mhz }; notify(); },
|
||||
setBand(band) { state = { ...state, band, freqMHz: BAND_RANGE[band].min }; notify(); },
|
||||
volume(v) { state = { ...state, volume: Math.max(0, Math.min(1, v)) }; notify(); },
|
||||
mute(on) { state = { ...state, muted: on }; notify(); },
|
||||
getState: () => state,
|
||||
subscribe(cb) { subs.add(cb); cb(state); return () => subs.delete(cb); },
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the real control if THE SPEAKER has published one; else the echo stand-in. */
|
||||
export function resolveRadio(): { control: RadioControl; live: boolean } {
|
||||
const speaker = (globalThis as any).__fktrySpeaker;
|
||||
if (speaker?.radio && typeof speaker.radio.subscribe === 'function') {
|
||||
return { control: speaker.radio as RadioControl, live: true };
|
||||
}
|
||||
// Dynamic, casted probe of the audio module — safe whether or not the export exists.
|
||||
const audioMod = (globalThis as any).__fktryAudioModule;
|
||||
const fromMod = audioMod?.radio ?? audioMod?.getRadio?.();
|
||||
if (fromMod && typeof fromMod.subscribe === 'function') {
|
||||
return { control: fromMod as RadioControl, live: true };
|
||||
}
|
||||
return { control: createEchoRadio(), live: false };
|
||||
}
|
||||
171
fktry/src/ui/tuner.test.ts
Normal file
171
fktry/src/ui/tuner.test.ts
Normal file
@ -0,0 +1,171 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { BAND_RANGE, createEchoRadio, resolveRadio, type RadioControl } from './radio';
|
||||
import { createTuner } from './tuner';
|
||||
|
||||
const q = (s: string) => document.querySelector(s) as HTMLElement;
|
||||
const bandBtn = (b: string) =>
|
||||
Array.from(document.querySelectorAll('.fk-tuner-band')).find((x) => x.textContent === b) as HTMLElement;
|
||||
|
||||
function mount() {
|
||||
document.body.innerHTML = '';
|
||||
const tuner = createTuner();
|
||||
document.body.append(tuner.el);
|
||||
return tuner;
|
||||
}
|
||||
|
||||
describe('radio control resolution', () => {
|
||||
beforeEach(() => {
|
||||
delete (globalThis as any).__fktrySpeaker;
|
||||
delete (globalThis as any).__fktryAudioModule;
|
||||
});
|
||||
|
||||
it('falls back to a silent echo stand-in when THE SPEAKER has published nothing', () => {
|
||||
const { live } = resolveRadio();
|
||||
expect(live).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the real control the moment SCREEN publishes one', () => {
|
||||
const fake = createEchoRadio();
|
||||
(globalThis as any).__fktrySpeaker = { radio: fake };
|
||||
const { control, live } = resolveRadio();
|
||||
expect(live).toBe(true);
|
||||
expect(control).toBe(fake);
|
||||
delete (globalThis as any).__fktrySpeaker;
|
||||
});
|
||||
|
||||
it('ignores a published object that is not a radio control', () => {
|
||||
(globalThis as any).__fktrySpeaker = { radio: { nope: true } };
|
||||
expect(resolveRadio().live).toBe(false);
|
||||
delete (globalThis as any).__fktrySpeaker;
|
||||
});
|
||||
});
|
||||
|
||||
describe('echo radio', () => {
|
||||
it('notifies subscribers immediately and on every change', () => {
|
||||
const r = createEchoRadio();
|
||||
const seen: number[] = [];
|
||||
r.subscribe((s) => seen.push(s.freqMHz));
|
||||
r.setFrequency(101.1);
|
||||
expect(seen).toHaveLength(2); // initial + change
|
||||
expect(seen[1]).toBe(101.1);
|
||||
});
|
||||
|
||||
it('snaps to the band floor when the band changes', () => {
|
||||
const r = createEchoRadio();
|
||||
r.setBand('AM');
|
||||
expect(r.getState().band).toBe('AM');
|
||||
expect(r.getState().freqMHz).toBe(BAND_RANGE.AM.min);
|
||||
});
|
||||
|
||||
it('clamps volume to 0..1', () => {
|
||||
const r = createEchoRadio();
|
||||
r.volume(5);
|
||||
expect(r.getState().volume).toBe(1);
|
||||
r.volume(-2);
|
||||
expect(r.getState().volume).toBe(0);
|
||||
});
|
||||
|
||||
it('unsubscribes cleanly', () => {
|
||||
const r = createEchoRadio();
|
||||
let n = 0;
|
||||
const off = r.subscribe(() => n++);
|
||||
off();
|
||||
r.setFrequency(99);
|
||||
expect(n).toBe(1); // only the initial call
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tuner dock', () => {
|
||||
let tuner: ReturnType<typeof createTuner>;
|
||||
beforeEach(() => {
|
||||
delete (globalThis as any).__fktrySpeaker;
|
||||
tuner = mount();
|
||||
});
|
||||
|
||||
it('stays hidden until the fossil is found', () => {
|
||||
expect(tuner.isRevealed()).toBe(false);
|
||||
expect(tuner.el.classList.contains('is-revealed')).toBe(false);
|
||||
});
|
||||
|
||||
it('reveals on discovery, and revealing twice is harmless', () => {
|
||||
tuner.reveal();
|
||||
tuner.reveal();
|
||||
expect(tuner.isRevealed()).toBe(true);
|
||||
expect(tuner.el.classList.contains('is-revealed')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores its keys while hidden, so they never shadow the pre-discovery game', () => {
|
||||
expect(tuner.onKey('arrowright')).toBe(false);
|
||||
expect(tuner.onKey('m')).toBe(false);
|
||||
});
|
||||
|
||||
it('tunes with the arrow keys once revealed', () => {
|
||||
tuner.reveal();
|
||||
const before = q('.fk-tuner-readout').textContent!;
|
||||
expect(tuner.onKey('arrowright')).toBe(true);
|
||||
expect(q('.fk-tuner-readout').textContent).not.toBe(before);
|
||||
});
|
||||
|
||||
it('does not tune past the end of the band', () => {
|
||||
tuner.reveal();
|
||||
for (let i = 0; i < 400; i++) tuner.onKey('arrowleft'); // hammer the bottom stop
|
||||
const shown = q('.fk-tuner-readout').textContent!;
|
||||
expect(shown).toContain(BAND_RANGE.FM.min.toFixed(1));
|
||||
});
|
||||
|
||||
it('cycles bands with B and reflects it in the readout and the active button', () => {
|
||||
tuner.reveal();
|
||||
tuner.onKey('b');
|
||||
expect(q('.fk-tuner-readout').textContent).toContain('SW');
|
||||
expect(bandBtn('SW').classList.contains('is-active')).toBe(true);
|
||||
expect(bandBtn('FM').classList.contains('is-active')).toBe(false);
|
||||
});
|
||||
|
||||
it('switches band by clicking a band button', () => {
|
||||
tuner.reveal();
|
||||
bandBtn('AM').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(q('.fk-tuner-readout').textContent).toContain('AM');
|
||||
expect(bandBtn('AM').classList.contains('is-active')).toBe(true);
|
||||
});
|
||||
|
||||
it('mutes and unmutes with M, and says so', () => {
|
||||
tuner.reveal();
|
||||
tuner.onKey('m');
|
||||
expect(q('.fk-tuner-mute').textContent).toBe('MUTED');
|
||||
expect(tuner.el.classList.contains('is-muted')).toBe(true);
|
||||
tuner.onKey('m');
|
||||
expect(q('.fk-tuner-mute').textContent).toBe('MUTE');
|
||||
expect(tuner.el.classList.contains('is-muted')).toBe(false);
|
||||
});
|
||||
|
||||
it('mutes by clicking the mute button', () => {
|
||||
tuner.reveal();
|
||||
q('.fk-tuner-mute').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(q('.fk-tuner-mute').textContent).toBe('MUTED');
|
||||
});
|
||||
|
||||
it('reads out static when no station locks, in the house voice', () => {
|
||||
tuner.reveal();
|
||||
expect(q('.fk-tuner-readout').textContent).toContain('STATIC');
|
||||
expect(q('.fk-tuner-readout').classList.contains('is-locked')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads SIGNAL LOCKED and lights up when the engine reports a station', () => {
|
||||
// What it looks like once THE SPEAKER is driving it for real.
|
||||
const withStation: RadioControl = {
|
||||
...createEchoRadio(),
|
||||
subscribe(cb) {
|
||||
cb({ freqMHz: 88.1, band: 'FM', volume: 0.7, muted: false, station: 'FKTRY' });
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
(globalThis as any).__fktrySpeaker = { radio: withStation };
|
||||
const t = mount();
|
||||
t.reveal();
|
||||
expect(q('.fk-tuner-readout').textContent).toBe('88.1 FKTRY FM — SIGNAL LOCKED');
|
||||
expect(q('.fk-tuner-readout').classList.contains('is-locked')).toBe(true);
|
||||
expect(t.isLive()).toBe(true);
|
||||
delete (globalThis as any).__fktrySpeaker;
|
||||
});
|
||||
});
|
||||
154
fktry/src/ui/tuner.ts
Normal file
154
fktry/src/ui/tuner.ts
Normal file
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* LANE-UI — THE TUNER DOCK (order 9). The radio's face.
|
||||
*
|
||||
* Hidden until the optical fossil is excavated (`snapshot.relics[].found` or a
|
||||
* `relicFound` event). Then it slides in bottom-right with a frequency dial, band select
|
||||
* (AM/FM/SW), volume + mute, and a station readout in the house voice. Every control
|
||||
* drives THE SPEAKER through the resolved `RadioControl` (radio.ts); the readout reflects
|
||||
* the control's own state via `subscribe`, so it's honest whether the real engine or the
|
||||
* echo stand-in is attached.
|
||||
*
|
||||
* Radio state is per-session (it lives in the control); the durable part is the fossil's
|
||||
* `found`, which is sim-side.
|
||||
*/
|
||||
import { cls, el, panel, style, text } from './dom';
|
||||
import { BAND_RANGE, resolveRadio, type Band, type RadioControl, type RadioState } from './radio';
|
||||
import { COPY, stationReadout } from './voice';
|
||||
|
||||
export interface Tuner {
|
||||
el: HTMLElement;
|
||||
/** Reveal the dock (discovery ceremony). Idempotent. */
|
||||
reveal(): void;
|
||||
isRevealed(): boolean;
|
||||
/** Keyboard: ← → tune, B band, M mute — active only once revealed. */
|
||||
onKey(key: string): boolean;
|
||||
/** Whether the dock is driving the real audio engine vs the silent stand-in. */
|
||||
isLive(): boolean;
|
||||
}
|
||||
|
||||
const BANDS: Band[] = ['AM', 'FM', 'SW'];
|
||||
|
||||
export function createTuner(): Tuner {
|
||||
const { control, live } = resolveRadio();
|
||||
|
||||
const root = panel();
|
||||
root.id = 'fk-tuner';
|
||||
|
||||
const head = el('div', 'fk-h', [COPY.tunerHeader]);
|
||||
const readout = el('div', 'fk-tuner-readout');
|
||||
|
||||
// Frequency dial: a click/drag track. The fill shows position within the band.
|
||||
const dialFill = el('div', 'fk-tuner-dial-fill');
|
||||
const dial = el('div', 'fk-tuner-dial', [dialFill]);
|
||||
|
||||
// Band buttons.
|
||||
const bandRow = el('div', 'fk-tuner-bands');
|
||||
const bandBtns = BANDS.map((b) => {
|
||||
const btn = el('button', 'fk-tuner-band', [b]);
|
||||
btn.setAttribute('type', 'button');
|
||||
btn.addEventListener('click', () => control.setBand(b));
|
||||
bandRow.append(btn);
|
||||
return { band: b, btn };
|
||||
});
|
||||
|
||||
// Volume + mute.
|
||||
const volFill = el('div', 'fk-tuner-vol-fill');
|
||||
const volTrack = el('div', 'fk-tuner-vol', [volFill]);
|
||||
const muteBtn = el('button', 'fk-tuner-mute', [COPY.tunerMute]);
|
||||
muteBtn.setAttribute('type', 'button');
|
||||
const volRow = el('div', 'fk-tuner-volrow', [
|
||||
el('span', 'fk-tuner-vol-label', [COPY.tunerVolLabel]), volTrack, muteBtn,
|
||||
]);
|
||||
|
||||
const hint = el('div', 'fk-tuner-hint', [COPY.tunerHint]);
|
||||
|
||||
root.append(head, readout, dial, bandRow, volRow, hint);
|
||||
|
||||
// ---------------------------------------------------------------- interaction
|
||||
|
||||
/** Set frequency from a 0..1 position within the current band. */
|
||||
function tuneToFraction(f: number, band: Band) {
|
||||
const { min, max } = BAND_RANGE[band];
|
||||
control.setFrequency(min + Math.max(0, Math.min(1, f)) * (max - min));
|
||||
}
|
||||
|
||||
function dialFractionFromEvent(clientX: number): number {
|
||||
const r = dial.getBoundingClientRect();
|
||||
return r.width > 0 ? (clientX - r.left) / r.width : 0;
|
||||
}
|
||||
|
||||
let dragging = false;
|
||||
dial.addEventListener('pointerdown', (e) => {
|
||||
dragging = true;
|
||||
dial.setPointerCapture?.(e.pointerId);
|
||||
tuneToFraction(dialFractionFromEvent(e.clientX), control.getState().band);
|
||||
});
|
||||
dial.addEventListener('pointermove', (e) => {
|
||||
if (dragging) tuneToFraction(dialFractionFromEvent(e.clientX), control.getState().band);
|
||||
});
|
||||
const endDrag = () => { dragging = false; };
|
||||
dial.addEventListener('pointerup', endDrag);
|
||||
dial.addEventListener('pointercancel', endDrag);
|
||||
|
||||
volTrack.addEventListener('pointerdown', (e) => {
|
||||
const r = volTrack.getBoundingClientRect();
|
||||
if (r.width > 0) control.volume((e.clientX - r.left) / r.width);
|
||||
});
|
||||
muteBtn.addEventListener('click', () => control.mute(!control.getState().muted));
|
||||
|
||||
function step(dir: number) {
|
||||
const s = control.getState();
|
||||
const { min, max, step } = BAND_RANGE[s.band];
|
||||
control.setFrequency(Math.max(min, Math.min(max, s.freqMHz + dir * step)));
|
||||
}
|
||||
function cycleBand(dir: number) {
|
||||
const s = control.getState();
|
||||
const i = (BANDS.indexOf(s.band) + dir + BANDS.length) % BANDS.length;
|
||||
control.setBand(BANDS[i]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- render from state
|
||||
|
||||
function render(s: RadioState) {
|
||||
text(readout, stationReadout(s.freqMHz, s.band, s.station));
|
||||
cls(readout, 'is-locked', s.station !== null);
|
||||
|
||||
const { min, max } = BAND_RANGE[s.band];
|
||||
const frac = max > min ? (s.freqMHz - min) / (max - min) : 0;
|
||||
style(dialFill, 'width', `${Math.max(0, Math.min(1, frac)) * 100}%`);
|
||||
|
||||
for (const b of bandBtns) cls(b.btn, 'is-active', b.band === s.band);
|
||||
|
||||
style(volFill, 'width', `${s.volume * 100}%`);
|
||||
cls(muteBtn, 'is-on', s.muted);
|
||||
text(muteBtn, s.muted ? COPY.tunerMuted : COPY.tunerMute);
|
||||
cls(root, 'is-muted', s.muted);
|
||||
}
|
||||
const unsubscribe = control.subscribe(render);
|
||||
void unsubscribe; // dock lives for the session; nothing to tear down
|
||||
|
||||
let revealed = false;
|
||||
|
||||
return {
|
||||
el: root,
|
||||
reveal() {
|
||||
if (revealed) return;
|
||||
revealed = true;
|
||||
cls(root, 'is-revealed', true);
|
||||
},
|
||||
isRevealed: () => revealed,
|
||||
onKey(key) {
|
||||
if (!revealed) return false;
|
||||
switch (key) {
|
||||
case 'arrowleft': step(-1); return true;
|
||||
case 'arrowright': step(1); return true;
|
||||
case 'b': cycleBand(1); return true;
|
||||
case 'm': control.mute(!control.getState().muted); return true;
|
||||
default: return false;
|
||||
}
|
||||
},
|
||||
isLive: () => live,
|
||||
};
|
||||
}
|
||||
|
||||
export type { RadioControl };
|
||||
Loading…
Reference in New Issue
Block a user