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); diff --git a/components/player/Session.vue b/components/player/Session.vue index 47179fd6..0e932074 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,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 | 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 + || (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); }; 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'); +}); 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');