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