Skip to content
Merged
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
4 changes: 2 additions & 2 deletions components/player/Player.vue
Original file line number Diff line number Diff line change
Expand Up @@ -419,8 +419,8 @@ const sourceList = computed<RadioSource[]>(() => {

const hasPlayableSource = computed(() => sourceList.value.length > 0);
// After a reload, a Global Radio station has a safe catalog identity but no
// reusable signed HLS source. Keep Play enabled: the user click must resolve a
// NEW URL via the same detail-page action as the regular Apple Music button.
// reusable signed HLS source. Keep Play enabled: a user click resolves a
// NEW URL in the app-scoped Session without navigating to the detail page.
const canRequestGlobalStream = computed(() => !hasPlayableSource.value
&& Boolean(props.stationUrl?.startsWith('/global-radio/apple-music/'))
&& playback.station?.url === props.stationUrl);
Expand Down
59 changes: 48 additions & 11 deletions components/player/Session.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import { storeToRefs } from 'pinia';
import { usePlaybackStore } from '~/stores/playback';
import type { RadioSource, RadioStationOption } from '~/types/radio';
import type { GlobalRadioProvider } from '~/types/global-radio';

const playback = usePlaybackStore();
useRestoreLastStation();
Expand All @@ -40,20 +41,56 @@ const { program, playlist, song, lyrics, lyricsOpen, loading, isZingStation, sch
const emptySource: RadioSource = { src: '', type: 'audio/mpeg' };
const emptySources: RadioSource[] = [];

// The dock and queue stay mounted across route changes. A Global Play action
// must resolve the signed HLS here, not navigate to a station detail page as
// the country queue does. Keep public metadata in this session only.
const siteAssetUrl = useSiteAssetUrl();
let globalCatalog: GlobalRadioProvider | null = null;
let globalCatalogRequest: Promise<GlobalRadioProvider> | null = null;
let selectionVersion = 0;
const { play: playGlobalRadio } = useAuthorizedGlobalRadioPlayback(
() => globalCatalog?.stations || [],
);

const selectGlobalStation = async (target: RadioStationOption, version: number, previousUrl: string, previousRequested: string) => {
try {
// Fetch the public catalog only on an explicit Play/Next/Previous gesture.
// Reuse the in-flight lookup when the listener taps more than once, and
// retry a failed catalog request on the next user gesture.
const provider = await (globalCatalogRequest ||= $fetch<GlobalRadioProvider>(
siteAssetUrl('/catalog/providers/apple-music.json'), { timeout: 10_000 },
).catch(error => {
globalCatalogRequest = null;
throw error;
}));
if (version !== selectionVersion || playback.station?.url !== previousUrl
|| playback.requestedStationUrl !== previousRequested
|| (playback.globalRadioPendingId && playback.globalRadioPendingId !== target.id)) return;
globalCatalog = provider;
const chosen = provider.stations?.find(item => '/global-radio/apple-music/' + item.id === target.url);
if (!chosen) {
playback.globalRadioError = 'unavailable';
return;
}
// The app-scoped resolver handles fresh leases and races. It never
// requires a route change and never reuses an expired accessKey.
await playGlobalRadio(chosen);
} catch {
if (version === selectionVersion) playback.globalRadioError = 'unavailable';
}
};

const selectStation = (target: RadioStationOption) => {
if (!target.url) return;
// Queue and Next/Previous are explicit playback actions. Request the target
// before navigating; a regular station-detail link never requests playback.
// If a signed Global HLS URL expired and the shared player cleared it,
// route the explicit queue click through the Global resolver rather than
// requesting playback from an empty/stale source.
if (target.url === playback.station?.url
&& target.url.startsWith('/global-radio/apple-music/')
&& !playback.station.sources.length) {
playback.requestedStationUrl = target.url;
} else {
playback.requestStation(target.url);
const version = ++selectionVersion;
if (target.url.startsWith('/global-radio/apple-music/')) {
void selectGlobalStation(target, version, playback.station?.url || '', playback.requestedStationUrl);
return;
}

// Country queue navigation remains unchanged: it prepares the explicit
// request before the target Country detail route loads its stream catalog.
playback.requestStation(target.url);
void navigateTo(target.url);
};
</script>
35 changes: 35 additions & 0 deletions tests/browser/apple-radio-reload.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,38 @@ test('Global details without listening history stay passive and never auto-start
const state = await currentPlayback(page);
expect(state).toMatchObject({ url: '', playRequest: 0, playing: false, audioSrc: '' });
});

test('Global dock Play after F5 resolves in place and never opens station detail', async ({ page }) => {
await page.addInitScript(library => {
localStorage.setItem('radio_listening_library_v1', JSON.stringify(library));
}, savedLibrary(hits));

// From the directory, the saved station is selected in the dock but the
// browser stays on the directory. The dock Play must not turn into a link.
await page.goto('/global-radio/apple-music');
await expect(page.locator('.radio-player-dock .radio-player-title')).toHaveText(hits.title);
await page.reload();
await expect(page.locator('.radio-player-dock .radio-player-title')).toHaveText(hits.title);
const originalUrl = page.url();
await page.locator('.radio-player-dock .radio-player-main-action').click();
await page.waitForTimeout(500);
expect(page.url()).toBe(originalUrl, 'Global Play may fetch a fresh stream but must not navigate to details');

// The source may be unavailable in a secret-free preview, but the selected
// station still must not change to a different route or auto-play on reload.
expect((await currentPlayback(page)).url).toBe(pathFor(hits));
});

test('Global dock Play preserves another Global detail route currently being viewed', async ({ page }) => {
await page.addInitScript(library => {
localStorage.setItem('radio_listening_library_v1', JSON.stringify(library));
}, savedLibrary(hits));
await page.goto('/global-radio/apple-music/apple-music-1');
await expect(page.locator('main h1')).toHaveText('Apple Music 1');
await expect(page.locator('.radio-player-dock .radio-player-title')).toHaveText(hits.title);
const originalUrl = page.url();
await page.locator('.radio-player-dock .radio-player-main-action').click();
await page.waitForTimeout(500);
expect(page.url()).toBe(originalUrl);
await expect(page.locator('main h1')).toHaveText('Apple Music 1');
});
19 changes: 19 additions & 0 deletions tests/global-radio-player-parity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,25 @@ test('saved Global station restores its identity with no HLS lease, accessKey or
assert.doesNotMatch(restore, /localStorage\.setItem\([^\n]*accessKey/);
});

test('Global dock and queue Play resolve in the app session without a detail route navigation', async () => {
const session = await read('components/player/Session.vue');
const player = await read('components/player/Player.vue');
assert.match(player, /emit\('select-station', \{/,
'metadata-only dock Play still goes through the shared session');
assert.match(session, /useAuthorizedGlobalRadioPlayback\(/);
assert.match(session, /const chosen = provider\.stations\?\.find/);
assert.match(session, /await playGlobalRadio\(chosen\)/);
assert.match(session, /if \(target\.url\.startsWith\('\/global-radio\/apple-music\/'\)\) \{/);
const directGlobal = session.split("if (target.url.startsWith('/global-radio/apple-music/')) {")[1].split('return;')[0];
assert.match(directGlobal, /selectGlobalStation\(/);
assert.doesNotMatch(directGlobal, /navigateTo|requestedStationUrl\s*=/,
'clicking Play on Global station must not navigate into its detail');
assert.match(session, /playback\.requestStation\(target\.url\)/,
'Country navigation still requests the target before routing');
assert.match(session, /void navigateTo\(target\.url\)/,
'Country navigation remains unchanged');
});

test('Global queue and Next/Previous requests use the same shared player navigation contract', async () => {
const session = await read('components/player/Session.vue');
const detail = await read('pages/global-radio/apple-music/[station].vue');
Expand Down
13 changes: 13 additions & 0 deletions tests/listening-library.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,16 @@ test('encoded path validation preserves valid percent-encoded Unicode station sl
const valid = station('%E1%BB%95n-%C4%91%E1%BB%8Bnh');
assert.equal(rememberStation(emptyListeningLibrary(), valid, 'pause').lastStation?.url, valid.url);
});


test('rejects percent-encoded control characters in station slugs', () => {
for (const url of [
'/asia/viet-nam/radio%00test',
'/asia/viet-nam/radio%0Atest',
'/asia/viet-nam/radio%09test',
'/asia/viet-nam/radio%7Ftest',
]) {
assert.equal(rememberStation(emptyListeningLibrary(), { ...station('test'), url }, 'play').lastStation, null, url);
}
assert.equal(rememberStation(emptyListeningLibrary(), station('radio-1'), 'pause').lastStation?.url, '/asia/viet-nam/radio-1');
});
2 changes: 1 addition & 1 deletion utils/listening-library.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function stationIdentity(value) {
try {
if (value.url.split('/').some(part => {
const decoded = decodeURIComponent(part);
return decoded === '.' || decoded === '..' || /[\\/?#]/.test(decoded);
return decoded === '.' || decoded === '..' || /[\\/?#\u0000-\u001f\u007f]/.test(decoded);
})) return null;
} catch { return null; }
if (typeof value.title !== 'string' || !value.title.trim()) return null;
Expand Down
Loading