Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 78 additions & 3 deletions e2e/radio.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -37,6 +41,7 @@ async function mockStreams(page) {
await route.fulfill({
status: 200,
contentType: 'video/mp2t',
headers: CORS_HEADERS,
body: HLS_TEST_SEGMENT,
});
return;
Expand All @@ -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',
});
});
Expand All @@ -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',
});
});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
Expand All @@ -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('/');
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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',
});
},
Expand Down Expand Up @@ -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('/');
Expand Down
82 changes: 82 additions & 0 deletions src/css/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ <h1 class="uppercase text-sm text-Brown mt-6">Radio Player Romania
<!-- Gradient overlay -->
<div class="absolute top-0 left-0 w-[104%] h-[100%] -m-[2%] bg-gradient-to-b from-Bg to-transparent"></div>

<!-- Sound visualizer: mirrors whatever is audible right now (radio
stream, loading tone or error tone) — still while the app is silent.
The state machine drives data-viz; visualizer.ts fills in the bars
and moves them on the real audio signal (CSS animation as fallback). -->
<div id="visualizer" data-viz="off" role="img" aria-label="Vizualizator sunet"
class="relative z-10 mb-2 flex h-8 w-full items-end justify-center gap-[2px] px-6"></div>

<div>

Expand Down Expand Up @@ -214,7 +220,9 @@ <h1 class="uppercase text-sm text-Brown mt-6">Radio Player Romania
<option value="https://urbanfm.ro:8777/live">Urban FM</option>
<option value="https://live.rockfm.ro/rockfm.aacp">Rock FM</option>
<option value="https://live.guerrillaradio.ro:8443/guerrilla.aac">Radio Guerrilla</option>
<option value="https://asculta.nationalfm.ro:9102/nfm2">National FM</option>
<!-- data-no-cors: serverul nu trimite Access-Control-Allow-Origin, deci
se încarcă opac (fără crossorigin) — altfel nu ar porni deloc. -->
<option value="https://asculta.nationalfm.ro:9102/nfm2" data-no-cors>National FM</option>
<option value="https://edge126.rcs-rds.ro/profm/dancefm.mp3?1741969012508">Dance FM</option>
<option value="https://live.radiovibefm.eu/8052/stream">Vibe FM</option>
<option value="https://stream4.srr.ro:8443/romania-cultural">Radio România Cultural</option>
Expand Down
1 change: 1 addition & 0 deletions src/js/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const loadingNoise = el<HTMLAudioElement>('loadingNoise');
export const errorNoise = el<HTMLAudioElement>('errorNoise');
export const loadingMsg = el<HTMLElement>('loadingMsg');
export const errorMsg = el<HTMLElement>('errorMsg');
export const visualizer = el<HTMLElement>('visualizer');

export const prevButton = el<HTMLButtonElement>('prevButton');
export const playButton = el<HTMLButtonElement>('playButton');
Expand Down
12 changes: 11 additions & 1 deletion src/js/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +68,7 @@ function warmUpFeedbackSounds() {
preloadAudioBlobs();
loadingNoiseInstance.warmUp();
errorNoiseInstance.warmUp();
warmUpVisualizer();
}

// --- Playback control buttons ---
Expand Down Expand Up @@ -110,14 +112,22 @@ 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,
errorSound: errorNoiseInstance,
showButton,
setLoadingMsg: (v) => loadingMsg.classList.toggle('invisible', !v),
setErrorMsg: (v) => errorMsg.classList.toggle('invisible', !v),
setVisualizer: setVisualizerMode,
updateMediaSession: (s) => {
updateMediaSession(s);
maybeReloadForPendingServiceWorkerUpdate(s);
Expand Down
Loading