From e196266a25890ca22c9fba8f52b44f553e66c0b3 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 12:09:13 +0700 Subject: [PATCH 1/7] fix(library): reject encoded control characters in station routes --- utils/listening-library.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/listening-library.mjs b/utils/listening-library.mjs index a0cc0ca9..ef4a76bd 100644 --- a/utils/listening-library.mjs +++ b/utils/listening-library.mjs @@ -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; From 9758fa8c951812ceba2fc924e253cd4b339fd867 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 12:09:28 +0700 Subject: [PATCH 2/7] test(library): cover encoded control characters and valid station slug --- tests/listening-library.test.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/listening-library.test.mjs b/tests/listening-library.test.mjs index b802a31a..4863edc0 100644 --- a/tests/listening-library.test.mjs +++ b/tests/listening-library.test.mjs @@ -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'); +}); From 89342bb5f08f8c6cc15dd93897f87477b297d1b1 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 13:17:48 +0700 Subject: [PATCH 3/7] fix(global-radio): resolve dock and queue Play in place without detail navigation --- components/player/Session.vue | 58 ++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/components/player/Session.vue b/components/player/Session.vue index 47179fd6..2b9f4a1c 100644 --- a/components/player/Session.vue +++ b/components/player/Session.vue @@ -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(); @@ -40,20 +41,55 @@ 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 | 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( + 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) 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); }; From d90a1b9c4e66f4d6680aba97abc205fc9f02a945 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 13:18:40 +0700 Subject: [PATCH 4/7] fix(global-radio): do not override a newer page Play while catalog loads --- components/player/Session.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/player/Session.vue b/components/player/Session.vue index 2b9f4a1c..0e932074 100644 --- a/components/player/Session.vue +++ b/components/player/Session.vue @@ -64,7 +64,8 @@ const selectGlobalStation = async (target: RadioStationOption, version: number, throw error; })); if (version !== selectionVersion || playback.station?.url !== previousUrl - || playback.requestedStationUrl !== previousRequested) return; + || 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) { From dc63cfd2f34b5d50a8f1cdc554402b7575b2842b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 13:19:24 +0700 Subject: [PATCH 5/7] docs(player): clarify Global dock Play must not navigate --- components/player/Player.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/player/Player.vue b/components/player/Player.vue index 69aff600..5e4c86f1 100644 --- a/components/player/Player.vue +++ b/components/player/Player.vue @@ -419,8 +419,8 @@ const sourceList = computed(() => { 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); From 39b195780a463279d8d9ebcb3873622034e77ce2 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 13:19:47 +0700 Subject: [PATCH 6/7] test(global-radio): prevent dock and queue Play navigating to detail --- tests/global-radio-player-parity.test.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/global-radio-player-parity.test.mjs b/tests/global-radio-player-parity.test.mjs index f9bfe88f..24ded4c5 100644 --- a/tests/global-radio-player-parity.test.mjs +++ b/tests/global-radio-player-parity.test.mjs @@ -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'); From 4d38f4b13574b196f4bc21d4ba9f6c3fab0728e6 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 10 Nov 2026 13:20:19 +0700 Subject: [PATCH 7/7] test(browser): Global Play after F5 never navigates away from current page --- tests/browser/apple-radio-reload.spec.mjs | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/browser/apple-radio-reload.spec.mjs b/tests/browser/apple-radio-reload.spec.mjs index 04884d9e..8ce04183 100644 --- a/tests/browser/apple-radio-reload.spec.mjs +++ b/tests/browser/apple-radio-reload.spec.mjs @@ -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'); +});