diff --git a/fktry/src/ui/radio.ts b/fktry/src/ui/radio.ts new file mode 100644 index 0000000..70d9a53 --- /dev/null +++ b/fktry/src/ui/radio.ts @@ -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 = { + 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 }; +} diff --git a/fktry/src/ui/tuner.test.ts b/fktry/src/ui/tuner.test.ts new file mode 100644 index 0000000..6077950 --- /dev/null +++ b/fktry/src/ui/tuner.test.ts @@ -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; + 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; + }); +}); diff --git a/fktry/src/ui/tuner.ts b/fktry/src/ui/tuner.ts new file mode 100644 index 0000000..1f91964 --- /dev/null +++ b/fktry/src/ui/tuner.ts @@ -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 };