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
6 changes: 6 additions & 0 deletions .changes/radio-mute-play-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
type: fix
area: player
---

The radio player's Unmute button now always brings sound back, also when the volume was left at zero, and raising the volume while muted is heard. The Live/Paused badge now follows pauses from media keys and stations that fail to play, and Ctrl, Cmd or Alt shortcuts no longer mute the radio or change its volume.
70 changes: 2 additions & 68 deletions apps/web-e2e/src/dialog-accessibility.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@ import {
import { applyUiTheme, rasterTextContrast } from './ui-consistency.fixture';
import type { Locator } from '@playwright/test';
import { configureMockProviderBackend } from './provider-target-route';
import {
addStalkerPortal,
interceptStalkerRequests,
} from './stalker-portal.fixture';
import { playSyntheticStalkerRadio } from './stalker-radio-player.fixture';

test.use({ serviceWorkers: 'block' });

Expand Down Expand Up @@ -247,70 +244,7 @@ test('reading account and shortcuts while Stalker radio plays keeps its volume u
page,
}) => {
test.setTimeout(120_000);
await interceptStalkerRequests(page);
const source = 'http://radio-fixture.invalid/sample.wav';
// One minute of synthetic PCM silence, with no external stream or metadata.
const bytes = 8000 * 60 * 2;
const wav = Buffer.alloc(44 + bytes);
wav.write('RIFF', 0);
wav.writeUInt32LE(36 + bytes, 4);
wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20);
wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24);
wav.writeUInt32LE(16000, 28);
wav.writeUInt16LE(2, 32);
wav.writeUInt16LE(16, 34);
wav.write('data', 36);
wav.writeUInt32LE(bytes, 40);
await page.route(source, (route) =>
route.fulfill({ contentType: 'audio/wav', body: wav })
);
await page.route('**/localhost:3000/stalker**', async (route) => {
const url = new URL(route.request().url());
if (url.searchParams.get('type') !== 'radio') return route.fallback();
const action = url.searchParams.get('action');
if (action === 'create_link') {
await route.fulfill({ json: { payload: { js: { cmd: source } } } });
} else if (action === 'get_ordered_list') {
await route.fulfill({
json: {
payload: {
js: {
data: [
{
id: '9001',
name: 'Synthetic Radio Alpha',
o_name: 'Synthetic Radio Alpha',
cmd: source,
logo: '',
category_id:
url.searchParams.get('genre') ?? '1',
tv_genre_id:
url.searchParams.get('genre') ?? '1',
number: '1',
radio: true,
},
],
total_items: 1,
max_page_items: 20,
cur_page: 1,
total_pages: 1,
},
},
},
});
} else await route.fallback();
});
await addStalkerPortal(page);
await page.getByRole('link', { name: /radio/i }).click();
await page.locator('.category-item').nth(1).click();
await page.getByText('Synthetic Radio Alpha', { exact: true }).click();
const audio = page.locator('app-audio-player audio');
await expect
.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime))
.toBeGreaterThan(0);
const audio = await playSyntheticStalkerRadio(page);
await page
.locator('app-audio-player .radio-hero')
.click({ position: { x: 5, y: 5 } });
Expand Down
64 changes: 64 additions & 0 deletions apps/web-e2e/src/stalker-radio-controls.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect, test } from './fixtures';
import { configureMockProviderBackend } from './provider-target-route';
import { playSyntheticStalkerRadio } from './stalker-radio-player.fixture';

test.use({ serviceWorkers: 'block' });

test.beforeEach(async ({ page }) => {
await page.route('https://**', (route) => route.abort());
await configureMockProviderBackend(page);
await page.goto('/');
});

/**
* The radio player's state follows its `<audio>` element, unmute always
* sounds, and Ctrl/Alt chords are left to the app.
*/
test('@stalker @playback radio play state, unmute from zero and modifier chords', async ({
page,
}) => {
test.setTimeout(120_000);
const audio = await playSyntheticStalkerRadio(page);
const player = page.locator('app-audio-player');
const playButton = player.locator('.play-btn');
const muteButton = player.locator('.vol-icon');
await expect(player.locator('.station-badge')).toHaveClass(/live/);

// A pause the player did not ask for (media keys, the OS) shows.
await audio.evaluate((element: HTMLAudioElement) => element.pause());
await expect(player.locator('.station-badge')).not.toHaveClass(/live/);
await expect(playButton).toHaveAttribute('aria-label', 'Play');
await audio.evaluate((element: HTMLAudioElement) => element.play());
await expect(player.locator('.station-badge')).toHaveClass(/live/);
await expect(playButton).toHaveAttribute('aria-label', 'Pause');

// Ctrl/Alt chords leave the volume alone.
await player.locator('.radio-hero').click({ position: { x: 5, y: 5 } });
const volume = await audio.evaluate((el: HTMLAudioElement) => el.volume);
for (const chord of ['Control+m', 'Alt+ArrowDown', 'Control+ArrowUp']) {
await page.keyboard.press(chord);
await expect(audio).toHaveJSProperty('volume', volume);
await expect(audio).toHaveJSProperty('muted', false);
}

// The volume taken all the way down: Unmute brings sound back.
for (let step = 0; step < 21; step += 1) {
await page.keyboard.press('ArrowDown');
}
await expect(audio).toHaveJSProperty('volume', 0);
await expect(muteButton).toHaveAttribute('aria-label', 'Unmute');
await muteButton.click();
await expect
.poll(() => audio.evaluate((el: HTMLAudioElement) => el.volume))
.toBeGreaterThan(0);
await expect(audio).toHaveJSProperty('muted', false);
await expect(muteButton).toHaveAttribute('aria-label', 'Mute');

// Muted, then raising the volume is heard.
await muteButton.click();
await expect(audio).toHaveJSProperty('volume', 0);
await player.locator('.radio-hero').click({ position: { x: 5, y: 5 } });
await page.keyboard.press('ArrowUp');
await expect(audio).toHaveJSProperty('volume', 0.05);
await expect(audio).toHaveJSProperty('muted', false);
});
78 changes: 77 additions & 1 deletion apps/web-e2e/src/stalker-radio-player.fixture.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,82 @@
import type { Page } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { pressTab } from './e2e-helpers';
import { expect } from './fixtures';
import {
addStalkerPortal,
interceptStalkerRequests,
} from './stalker-portal.fixture';

/**
* Adds the mock Stalker portal and plays a radio station whose stream is a
* minute of synthetic PCM silence. Resolves once the audio has advanced.
*/
export async function playSyntheticStalkerRadio(page: Page): Promise<Locator> {
await interceptStalkerRequests(page);
const source = 'http://radio-fixture.invalid/sample.wav';
// One minute of synthetic PCM silence, with no external stream or metadata.
const bytes = 8000 * 60 * 2;
const wav = Buffer.alloc(44 + bytes);
wav.write('RIFF', 0);
wav.writeUInt32LE(36 + bytes, 4);
wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20);
wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24);
wav.writeUInt32LE(16000, 28);
wav.writeUInt16LE(2, 32);
wav.writeUInt16LE(16, 34);
wav.write('data', 36);
wav.writeUInt32LE(bytes, 40);
await page.route(source, (route) =>
route.fulfill({ contentType: 'audio/wav', body: wav })
);
await page.route('**/localhost:3000/stalker**', async (route) => {
const url = new URL(route.request().url());
if (url.searchParams.get('type') !== 'radio') return route.fallback();
const action = url.searchParams.get('action');
if (action === 'create_link') {
await route.fulfill({ json: { payload: { js: { cmd: source } } } });
} else if (action === 'get_ordered_list') {
await route.fulfill({
json: {
payload: {
js: {
data: [
{
id: '9001',
name: 'Synthetic Radio Alpha',
o_name: 'Synthetic Radio Alpha',
cmd: source,
logo: '',
category_id:
url.searchParams.get('genre') ?? '1',
tv_genre_id:
url.searchParams.get('genre') ?? '1',
number: '1',
radio: true,
},
],
total_items: 1,
max_page_items: 20,
cur_page: 1,
total_pages: 1,
},
},
},
});
} else await route.fallback();
});
await addStalkerPortal(page);
await page.getByRole('link', { name: /radio/i }).click();
await page.locator('.category-item').nth(1).click();
await page.getByText('Synthetic Radio Alpha', { exact: true }).click();
const audio = page.locator('app-audio-player audio');
await expect
.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime))
.toBeGreaterThan(0);
return audio;
}

/** The computed colour a custom property resolves to on this page. */
async function computedColor(page: Page, property: string): Promise<string> {
Expand Down
7 changes: 7 additions & 0 deletions docs/architecture/player-controls-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -1945,6 +1945,13 @@ live together; routing/integration stays in the M3U player template.
When focus is inside a dialog or alertdialog, its reading region owns the
arrows and M key. Radio leaves those events unconsumed and does not change or
persist background volume; keyboard volume control resumes after dismissal.
Ctrl, Cmd and Alt chords are never radio shortcuts (Cmd+M minimizes on macOS).
Mute works as in the shared controls: it sets the volume to zero, and unmute
restores the last audible volume (`LegacyMuteMemory`, 0.5 when none is known,
e.g. a stored volume of zero), so raising the volume is also an unmute. The
LIVE/paused badge and the play button follow the `<audio>` element's `play`,
`pause`, `ended` and `error` events, so a media-key pause or a failed station
shows; a refused `play()` shows paused unless a newer one already plays.

### Display sleep during playback

Expand Down
Loading
Loading