diff --git a/e2e/radio.spec.js b/e2e/radio.spec.js index 999541f..3014441 100644 --- a/e2e/radio.spec.js +++ b/e2e/radio.spec.js @@ -3,6 +3,9 @@ import { test, expect } from '@playwright/test'; const STREAM_URL_RE = /live\.kissfm|europafm|digifm|magicfm|virginradio|srr\.ro|profm|rockfm|guerrillaradio|nationalfm|dancefm|radiovibefm|radioprob|vanillaradio|radiofrance|\/accs3\/fip\/test-tone-\d+\.ts/; const SOUND_URL_RE = /\/sounds\/(?:loading-low|error-low)\.mp3(?:\?.*)?$/; const SOUND_CACHE_NAME = 'radio-sounds-v2'; +// The player loads streams in CORS mode (crossorigin, for the visualizer), +// so mocked stream responses must carry the CORS header too. +const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*' }; const HLS_TEST_SEGMENT = Buffer.from( 'R0AREABC8CUAAcEAAP8B/wAB/IAUSBIBBkZGbXBlZwlTZXJ2aWNlMDF3fEPK//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9HQAAQAACwDQABwQAAAAHwACqxBLL//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////0dQABAAArASAAHBAADhAPAAD+EA8AC2m8DZ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////R0EAMAdQAAB7DH4AAAABwADpgIAFIQAH2GH/8VCAA9/83gIATGF2YzYyLjI4LjEwMgBCIAjBGDj/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FHAQAxeAD//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////1CAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHEdAABEAALANAAHBAAAAAfAAKrEEsv//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////R1AAEQACsBIAAcEAAOEA8AAP4QDwALabwNn///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9HQQAyB1AAALxa/gAAAAHAANiAgAUhAAndm//xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb0cBADOJAP/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwcR0AREQBC8CUAAcEAAP8B/wAB/IAUSBIBBkZGbXBlZwlTZXJ2aWNlMDF3fEPK//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9HQAASAACwDQABwQAAAAHwACqxBLL//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////0dQABIAArASAAHBAADhAPAAD+EA8AC2m8DZ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////R0EANAdQAAD9qX4AAAABwADYgIAFIQAL4tX/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb9HAQA1iQD//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHEdAABMAALANAAHBAAAAAfAAKrEEsv//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////R1AAEwACsBIAAcEAAOEA8AAP4QDwALabwNn///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9HQQA2J1AAAT73fgD//////////////////////////////////////////wAAAcAAioCABSEADegN//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHP/xUIABv/whEARgjBz/8VCAAb/8IRAEYIwc//FQgAG//CEQBGCMHA==', 'base64', @@ -16,6 +19,7 @@ async function mockStreams(page) { await route.fulfill({ status: 200, contentType: 'application/vnd.apple.mpegurl', + headers: CORS_HEADERS, body: [ '#EXTM3U', '#EXT-X-VERSION:3', @@ -37,6 +41,7 @@ async function mockStreams(page) { await route.fulfill({ status: 200, contentType: 'video/mp2t', + headers: CORS_HEADERS, body: HLS_TEST_SEGMENT, }); return; @@ -45,6 +50,7 @@ async function mockStreams(page) { await route.fulfill({ status: 200, contentType: 'audio/mpeg', + headers: CORS_HEADERS, path: 'src/public/sounds/test-tone.mp3', }); }); @@ -65,6 +71,7 @@ async function mockStreamsDelayed(page, delayMs) { await route.fulfill({ status: 200, contentType: 'audio/mpeg', + headers: CORS_HEADERS, path: 'src/public/sounds/test-tone.mp3', }); }); @@ -96,9 +103,31 @@ function ui(page) { stationOptions: page.getByRole('listbox', { name: 'Posturi de radio' }).getByRole('option'), loadingMsg: page.getByText(/Se încarcă/), errorMsg: page.getByText(/Eroare la încărcarea/), + visualizer: page.getByRole('img', { name: 'Vizualizator sunet' }), }; } +// --- Visualizer motion --- +// A headless test can't watch an animation, but it can do what a user does: +// look twice. Two screenshots of the visualizer that differ = the bars move. +async function expectVisualizerMoving(viz) { + const before = await viz.screenshot(); + await expect + .poll(async () => (await viz.screenshot()).equals(before), { + message: 'visualizer bars should be moving', + timeout: 5000, + }) + .toBe(false); +} + +async function expectVisualizerStill(page, viz) { + await page.waitForTimeout(600); // let the mode-change transition settle + const before = await viz.screenshot(); + await page.waitForTimeout(400); + const after = await viz.screenshot(); + expect(after.equals(before), 'visualizer bars should be still').toBe(true); +} + // Synchronization only (not an assertion): wait until the app finished its // sound preload so later steps aren't racing page init. async function waitForSoundBlobs(page) { @@ -295,6 +324,7 @@ test.describe('Radio Player E2E', () => { await route.fulfill({ status: 200, contentType: 'application/vnd.apple.mpegurl', + headers: CORS_HEADERS, body: [ '#EXTM3U', '#EXT-X-VERSION:3', @@ -312,10 +342,10 @@ test.describe('Radio Player E2E', () => { return; } if (url.includes('/accs3/fip/test-tone-')) { - await route.fulfill({ status: 200, contentType: 'video/mp2t', body: HLS_TEST_SEGMENT }); + await route.fulfill({ status: 200, contentType: 'video/mp2t', headers: CORS_HEADERS, body: HLS_TEST_SEGMENT }); return; } - await route.fulfill({ status: 200, contentType: 'audio/mpeg', path: 'src/public/sounds/test-tone.mp3' }); + await route.fulfill({ status: 200, contentType: 'audio/mpeg', headers: CORS_HEADERS, path: 'src/public/sounds/test-tone.mp3' }); }); await page.goto('/'); @@ -556,6 +586,50 @@ test.describe('Radio Player E2E', () => { await expect(c.loadingMsg).toContainText('Kiss FM'); }); + // --- Sound visualizer --- + // The visualizer mirrors the always-audible promise visually: whenever a + // sound plays (stream, loading tone, error tone) the bars move; in silence + // (idle/paused) they stand still. + + test('visualizer is still while idle and moves while playing', async ({ page }) => { + const c = ui(page); + await mockStreams(page); + await page.goto('/'); + + await expect(c.visualizer).toBeVisible(); + await expectVisualizerStill(page, c.visualizer); + + await c.playButton.click(); + await expect(c.pauseButton).toBeVisible({ timeout: 8000 }); + await expectVisualizerMoving(c.visualizer); + + // Pausing goes back to silence — the bars stop + await c.pauseButton.click(); + await expect(c.playButton).toBeVisible(); + await expectVisualizerStill(page, c.visualizer); + }); + + test('visualizer moves while the loading sound plays', async ({ page }) => { + const c = ui(page); + await mockStreamsHang(page); + await page.goto('/'); + + await c.playButton.click(); + await expect(c.loadingMsg).toBeVisible({ timeout: 3000 }); + await expectVisualizerMoving(c.visualizer); + }); + + test('visualizer moves while the error sound plays', async ({ page }) => { + const c = ui(page); + await page.goto('/'); + await waitForSoundBlobs(page); + await page.context().setOffline(true); + + await c.nextButton.click(); + await expect(c.errorMsg).toBeVisible({ timeout: 3000 }); + await expectVisualizerMoving(c.visualizer); + }); + // --- Accessibility --- test('all control buttons have aria-labels', async ({ page }) => { @@ -723,6 +797,7 @@ test.describe('Offline — cached resources', () => { await route.fulfill({ status: 200, contentType: 'audio/mpeg', + headers: CORS_HEADERS, path: 'src/public/sounds/test-tone.mp3', }); }, @@ -845,7 +920,7 @@ test.describe('Offline mid-playback — always audible', () => { await route.abort('internetdisconnected'); return; } - await route.fulfill({ status: 200, contentType: 'audio/mpeg', path: 'src/public/sounds/test-tone.mp3' }); + await route.fulfill({ status: 200, contentType: 'audio/mpeg', headers: CORS_HEADERS, path: 'src/public/sounds/test-tone.mp3' }); }); await page.goto('/'); diff --git a/src/css/input.css b/src/css/input.css index de9967b..3b3d654 100644 --- a/src/css/input.css +++ b/src/css/input.css @@ -38,6 +38,88 @@ } } +/* --- Sound visualizer ------------------------------------------------- + A full-width row of bars under #visualizer (spans generated by + visualizer.ts). The state machine sets data-viz (which sound is audible); + visualizer.ts adds .viz-live while the REAL audio signal drives the bars + (captureStream + analyser, 60fps inline transforms). The keyframe + animations below are the declarative fallback for when no signal is + readable — no captureStream support (Safari/iOS, Firefox), data-no-cors + stations, AudioContext not yet unlocked. The 5-bar rhythm patterns repeat + across the row via :nth-child(5n+k). */ +@layer components { + .viz-bar { + flex: 1 1 0%; + min-width: 2px; + height: 100%; + border-radius: 999px; + background-color: var(--color-Red); + transform: scaleY(0.12); + transform-origin: bottom; + transition: background-color 250ms ease, opacity 250ms ease; + } + + /* Mode changes ease smoothly — except live bars, which move at 60fps */ + #visualizer:not(.viz-live) .viz-bar { + transition: transform 250ms ease, background-color 250ms ease, opacity 250ms ease; + } + + /* Silence (idle/paused): short, dim, still */ + [data-viz="off"] .viz-bar { + opacity: 0.35; + } + + /* Fallback: radio stream — lively, desynchronized bounce (music-like) */ + [data-viz="playing"]:not(.viz-live) .viz-bar { + animation: viz-play 1s ease-in-out infinite; + } + [data-viz="playing"]:not(.viz-live) .viz-bar:nth-child(5n+1) { animation-duration: 0.9s; animation-delay: -0.3s; } + [data-viz="playing"]:not(.viz-live) .viz-bar:nth-child(5n+2) { animation-duration: 0.7s; animation-delay: -0.8s; } + [data-viz="playing"]:not(.viz-live) .viz-bar:nth-child(5n+3) { animation-duration: 1.15s; animation-delay: -0.1s; } + [data-viz="playing"]:not(.viz-live) .viz-bar:nth-child(5n+4) { animation-duration: 0.8s; animation-delay: -0.5s; } + [data-viz="playing"]:not(.viz-live) .viz-bar:nth-child(5n) { animation-duration: 1.05s; animation-delay: -0.65s; } + + /* Loading tone: brown like the loading message */ + [data-viz="loading"] .viz-bar { + background-color: var(--color-Brown); + } + [data-viz="loading"]:not(.viz-live) .viz-bar { + animation: viz-load 1.6s ease-in-out infinite; + } + [data-viz="loading"]:not(.viz-live) .viz-bar:nth-child(5n+2) { animation-delay: 0.15s; } + [data-viz="loading"]:not(.viz-live) .viz-bar:nth-child(5n+3) { animation-delay: 0.3s; } + [data-viz="loading"]:not(.viz-live) .viz-bar:nth-child(5n+4) { animation-delay: 0.45s; } + [data-viz="loading"]:not(.viz-live) .viz-bar:nth-child(5n) { animation-delay: 0.6s; } + + /* Error tone: red like the error message; fallback pulses all bars together */ + [data-viz="error"]:not(.viz-live) .viz-bar { + animation: viz-error 0.55s ease-in-out infinite; + } + + @keyframes viz-play { + 0%, 100% { transform: scaleY(0.25); } + 30% { transform: scaleY(1); } + 55% { transform: scaleY(0.45); } + 80% { transform: scaleY(0.8); } + } + @keyframes viz-load { + 0%, 100% { transform: scaleY(0.15); } + 50% { transform: scaleY(0.55); } + } + @keyframes viz-error { + 0%, 100% { transform: scaleY(0.9); } + 50% { transform: scaleY(0.35); } + } + + /* Reduced motion: no animation — audible modes show as steady bars */ + @media (prefers-reduced-motion: reduce) { + .viz-bar { animation: none !important; } + [data-viz="playing"] .viz-bar, + [data-viz="loading"] .viz-bar, + [data-viz="error"] .viz-bar { transform: scaleY(0.6); } + } +} + @layer base { :where( a, diff --git a/src/index.html b/src/index.html index fb70334..f791b5a 100644 --- a/src/index.html +++ b/src/index.html @@ -85,6 +85,12 @@

Radio Player Romania
+ +
@@ -214,7 +220,9 @@

Radio Player Romania - + + diff --git a/src/js/dom.ts b/src/js/dom.ts index 1a4a099..7cca27e 100644 --- a/src/js/dom.ts +++ b/src/js/dom.ts @@ -17,6 +17,7 @@ export const loadingNoise = el('loadingNoise'); export const errorNoise = el('errorNoise'); export const loadingMsg = el('loadingMsg'); export const errorMsg = el('errorMsg'); +export const visualizer = el('visualizer'); export const prevButton = el('prevButton'); export const playButton = el('playButton'); diff --git a/src/js/main.ts b/src/js/main.ts index 83d59a4..23553e1 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -8,6 +8,7 @@ import { radioSelect, player, loadingNoise, errorNoise, loadingMsg, errorMsg, prevButton, playButton, pauseButton, stopButton, nextButton, logoButton, } from './dom'; +import { setVisualizerMode, warmUpVisualizer } from './visualizer'; import { LABELS } from './labels'; import { precacheStatusImages } from './cloudinary'; import { getStoredStationIndex, saveLastIndex } from './storage'; @@ -67,6 +68,7 @@ function warmUpFeedbackSounds() { preloadAudioBlobs(); loadingNoiseInstance.warmUp(); errorNoiseInstance.warmUp(); + warmUpVisualizer(); } // --- Playback control buttons --- @@ -110,7 +112,14 @@ const core = createRadioCore({ setSelectedIndex: (i) => { radioSelect.selectedIndex = i; }, playerPlay: () => player.play(), playerPause: () => player.pause(), - playerSetSrc: (url) => { player.src = url; }, + playerSetSrc: (url) => { + // CORS-mode loads keep the visualizer's captureStream un-muted; stations + // that send no CORS headers (data-no-cors) load the classic opaque way — + // they still play, their bars just use the CSS fallback animation. + const station = Array.from(radioSelect.options).find(o => o.value === url); + player.crossOrigin = station?.dataset.noCors !== undefined ? null : 'anonymous'; + player.src = url; + }, playerLoad: () => player.load(), playerCurrentTime: () => player.currentTime, loadingSound: loadingNoiseInstance, @@ -118,6 +127,7 @@ const core = createRadioCore({ showButton, setLoadingMsg: (v) => loadingMsg.classList.toggle('invisible', !v), setErrorMsg: (v) => errorMsg.classList.toggle('invisible', !v), + setVisualizer: setVisualizerMode, updateMediaSession: (s) => { updateMediaSession(s); maybeReloadForPendingServiceWorkerUpdate(s); diff --git a/src/js/radioCore.test.ts b/src/js/radioCore.test.ts index 30b793a..7651533 100644 --- a/src/js/radioCore.test.ts +++ b/src/js/radioCore.test.ts @@ -6,6 +6,7 @@ import { isErrorLike, isFeedbackAudible, playbackStateFor, + vizModeFor, LOADING_TIMEOUT_MS, RETRY_DELAY_MS, RECOVERY_DELAY_MS, @@ -29,6 +30,7 @@ function makeDeps(overrides: Partial = {}) { errorSound: [] as string[], loadingMsg: [] as boolean[], errorMsg: [] as boolean[], + visualizer: [] as string[], mediaSession: [] as string[], playerPlay: [] as string[], playerPause: [] as string[], @@ -74,6 +76,7 @@ function makeDeps(overrides: Partial = {}) { showButton: (which: string) => calls.showButton.push(which), setLoadingMsg: (v: boolean) => calls.loadingMsg.push(v), setErrorMsg: (v: boolean) => calls.errorMsg.push(v), + setVisualizer: (m: string) => calls.visualizer.push(m), updateMediaSession: (s: string) => calls.mediaSession.push(s), saveLastIndex: (i: number) => calls.savedIndex.push(i), setTimeout: vi.fn((fn: () => void, ms: number) => { @@ -169,6 +172,7 @@ describe('side-effects per state', () => { expect(calls.showButton.at(-1)).toBe('play'); expect(calls.loadingMsg.at(-1)).toBe(false); expect(calls.errorMsg.at(-1)).toBe(false); + expect(calls.visualizer.at(-1)).toBe('off'); }); it('loading: stop button, loading sound, loading message', () => { @@ -181,6 +185,7 @@ describe('side-effects per state', () => { expect(calls.loadingSound).toContain('play'); expect(calls.loadingMsg.at(-1)).toBe(true); expect(calls.errorMsg.at(-1)).toBe(false); + expect(calls.visualizer.at(-1)).toBe('loading'); }); it('playing: pause button, sounds stopped, no messages', async () => { @@ -196,6 +201,7 @@ describe('side-effects per state', () => { expect(calls.errorSound.at(-1)).toBe('stop'); expect(calls.loadingMsg.at(-1)).toBe(false); expect(calls.errorMsg.at(-1)).toBe(false); + expect(calls.visualizer.at(-1)).toBe('playing'); }); it('paused: play button, sounds stopped', async () => { @@ -209,6 +215,7 @@ describe('side-effects per state', () => { expect(core.getState()).toBe('paused'); expect(calls.showButton.at(-1)).toBe('play'); expect(calls.loadingMsg.at(-1)).toBe(false); + expect(calls.visualizer.at(-1)).toBe('off'); }); it('retrying: stop button, loading sound keeps playing', async () => { @@ -224,6 +231,7 @@ describe('side-effects per state', () => { // loading sound was NOT stopped — last loading action is 'play' from loading state expect(calls.loadingSound.at(-1)).toBe('play'); expect(calls.errorMsg.at(-1)).toBe(false); + expect(calls.visualizer.at(-1)).toBe('loading'); }); it('error: stop button, error sound, error message', async () => { @@ -242,6 +250,7 @@ describe('side-effects per state', () => { expect(calls.errorSound.at(-1)).toBe('play'); expect(calls.errorMsg.at(-1)).toBe(true); expect(calls.loadingMsg.at(-1)).toBe(false); + expect(calls.visualizer.at(-1)).toBe('error'); }); it('stopRadio → idle: play button, all sounds stopped', () => { @@ -257,6 +266,7 @@ describe('side-effects per state', () => { expect(calls.errorSound.at(-1)).toBe('stop'); expect(calls.loadingMsg.at(-1)).toBe(false); expect(calls.errorMsg.at(-1)).toBe(false); + expect(calls.visualizer.at(-1)).toBe('off'); }); }); @@ -1478,20 +1488,28 @@ describe('state classification predicates', () => { it('classifies every state exactly as STATE_FX presents it', () => { const table: Record = { - idle: { loadingLike: false, errorLike: false, playback: 'none' }, - loading: { loadingLike: true, errorLike: false, playback: 'playing' }, - playing: { loadingLike: false, errorLike: false, playback: 'playing' }, - paused: { loadingLike: false, errorLike: false, playback: 'paused' }, - retrying: { loadingLike: true, errorLike: false, playback: 'playing' }, - error: { loadingLike: false, errorLike: true, playback: 'playing' }, - recovering: { loadingLike: false, errorLike: true, playback: 'playing' }, + idle: { loadingLike: false, errorLike: false, playback: 'none', viz: 'off' }, + loading: { loadingLike: true, errorLike: false, playback: 'playing', viz: 'loading' }, + playing: { loadingLike: false, errorLike: false, playback: 'playing', viz: 'playing' }, + paused: { loadingLike: false, errorLike: false, playback: 'paused', viz: 'off' }, + retrying: { loadingLike: true, errorLike: false, playback: 'playing', viz: 'loading' }, + error: { loadingLike: false, errorLike: true, playback: 'playing', viz: 'error' }, + recovering: { loadingLike: false, errorLike: true, playback: 'playing', viz: 'error' }, }; for (const [state, expected] of Object.entries(table) as Array<[RadioState, typeof table[RadioState]]>) { expect(isLoadingLike(state), `isLoadingLike(${state})`).toBe(expected.loadingLike); expect(isErrorLike(state), `isErrorLike(${state})`).toBe(expected.errorLike); expect(isFeedbackAudible(state), `isFeedbackAudible(${state})`).toBe(expected.loadingLike || expected.errorLike); expect(playbackStateFor(state), `playbackStateFor(${state})`).toBe(expected.playback); + expect(vizModeFor(state), `vizModeFor(${state})`).toBe(expected.viz); + } + // The visualizer invariant mirrors "always audible": bars move exactly + // when something plays, never in silence. + for (const state of Object.keys(table) as RadioState[]) { + expect(vizModeFor(state) !== 'off', `viz moves in ${state}`) + .toBe(playbackStateFor(state) === 'playing'); } }); }); diff --git a/src/js/radioCore.ts b/src/js/radioCore.ts index 52c9e39..580a233 100644 --- a/src/js/radioCore.ts +++ b/src/js/radioCore.ts @@ -24,12 +24,13 @@ type ActorOptions = NonNullable[1]>; // Shared domain types & timing constants live with the machine; re-exported // here so the rest of the app keeps a single import surface. -export type { RadioState, FeedbackSound, RadioDeps } from './radioMachine'; +export type { RadioState, FeedbackSound, RadioDeps, VizMode } from './radioMachine'; export { isLoadingLike, isErrorLike, isFeedbackAudible, playbackStateFor, + vizModeFor, } from './radioMachine'; export { MAX_RETRIES, diff --git a/src/js/radioMachine.ts b/src/js/radioMachine.ts index b4176b5..ab2e89f 100644 --- a/src/js/radioMachine.ts +++ b/src/js/radioMachine.ts @@ -62,6 +62,7 @@ export interface RadioDeps { showButton(which: PlaybackButton): void; setLoadingMsg(visible: boolean): void; setErrorMsg(visible: boolean): void; + setVisualizer(mode: VizMode): void; updateMediaSession(state: RadioState): void; saveLastIndex(index: number): void; setInterval(fn: () => void, ms: number): TimerId; @@ -116,6 +117,14 @@ export const isFeedbackAudible = (s: RadioState): boolean => export const playbackStateFor = (s: RadioState): 'playing' | 'paused' | 'none' => s === 'playing' || isFeedbackAudible(s) ? 'playing' : s === 'paused' ? 'paused' : 'none'; +export type VizMode = 'playing' | 'loading' | 'error' | 'off'; + +/** Which sound the visualizer should move to. Every audible state maps to a + * moving mode ('playing' = stream, otherwise the feedback tone); the bars + * are 'off' exactly when the app is silent (idle/paused). */ +export const vizModeFor = (s: RadioState): VizMode => + s === 'playing' ? 'playing' : isLoadingLike(s) ? 'loading' : isErrorLike(s) ? 'error' : 'off'; + const STATE_FX: Record = { idle: { button: 'play', loading: 'stop', error: 'stop', loadingMsg: false, errorMsg: false }, loading: { button: 'stop', loading: 'play', error: 'stop', loadingMsg: true, errorMsg: false }, @@ -287,6 +296,7 @@ export function createRadioMachine(deps: RadioDeps) { if (fx.error === 'stop') deps.errorSound.stop(); deps.setLoadingMsg(fx.loadingMsg); deps.setErrorMsg(fx.errorMsg); + deps.setVisualizer(vizModeFor(params.state)); deps.updateMediaSession(params.state); }, diff --git a/src/js/visualizer.ts b/src/js/visualizer.ts new file mode 100644 index 0000000..4b2fab8 --- /dev/null +++ b/src/js/visualizer.ts @@ -0,0 +1,174 @@ +/** + * Sound visualizer — a row of bars that move on the ACTUAL audio signal. + * + * The signal is read through element.captureStream() + AnalyserNode. Unlike + * createMediaElementSource (the Web Audio approach reverted in 69a58f2), + * capturing does NOT reroute playback through the AudioContext — the