From ed52d22b6374205a3f176d146ee0995f7fe16ea8 Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Thu, 27 Aug 2026 11:00:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=B2=E8=A7=A3=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E5=88=87=E6=8D=A2=E5=90=8E=E6=92=AD=E6=94=BE=E5=99=A8?= =?UTF-8?q?=E6=9C=AA=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 暂停时切换语言或男女声同步刷新音轨、时长和进度 - 保持暂停状态,播放中继续自动切换新音轨 - 补充语音切换播放器刷新回归测试 Made-with: Proma --- src/composables/useGlobalAudioPlayer.ts | 26 +++++++ src/pages/exhibit/detail.vue | 56 ++++++++------ tests/unit/ExhibitDetailAudioFirst.spec.ts | 85 +++++++++++++++++++++- 3 files changed, 144 insertions(+), 23 deletions(-) diff --git a/src/composables/useGlobalAudioPlayer.ts b/src/composables/useGlobalAudioPlayer.ts index 86ca469..4779416 100644 --- a/src/composables/useGlobalAudioPlayer.ts +++ b/src/composables/useGlobalAudioPlayer.ts @@ -205,6 +205,31 @@ const toSwitchedAudioItem = ( supportedLanguages: currentAudio.value?.supportedLanguages }) +const prepare = (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => { + const preservedMode = options.displayMode || displayMode.value + stopAudioElement() + currentAudio.value = audio + currentSource.value = options.source || currentSource.value + retryOnError = options.retryOnError || retryOnError + visible.value = true + displayMode.value = preservedMode + playing.value = false + loading.value = false + error.value = audio.audioUrl ? '' : '当前语言暂无语音讲解' + currentTime.value = 0 + duration.value = audio.duration || 0 + + if (audio.audioUrl) { + const element = ensureAudioElement() + if (element) { + element.src = audio.audioUrl + element.load() + element.playbackRate = playbackRate.value + element.muted = muted.value + } + } +} + const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => { if (!audio.audioUrl) { const preservedMode = options.displayMode || displayMode.value @@ -500,6 +525,7 @@ export const useGlobalAudioPlayer = () => ({ closeVersion, hasAudio: computed(() => Boolean(currentAudio.value)), play, + prepare, pause, resume, stop, diff --git a/src/pages/exhibit/detail.vue b/src/pages/exhibit/detail.vue index 98af532..4668b55 100644 --- a/src/pages/exhibit/detail.vue +++ b/src/pages/exhibit/detail.vue @@ -695,10 +695,10 @@ const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?: } } void loadFullDetailTextForLanguage(request, lang, exhibit.value) - if (shouldContinueAudio) { - // The player must become actionable before resuming with the new language track. + if (options.syncAudio !== false && isCurrentDetailAudioTarget.value) { + // 语言切换后立即刷新播放器;原先暂停时不会更新音轨,导致组件仍显示旧内容。 languageSwitchLoading.value = false - await handlePlayAudio() + await handlePlayAudio({ autoplay: shouldContinueAudio }) } } catch (error) { if (requestSequence !== languageSwitchSequence) return @@ -730,11 +730,12 @@ const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?: const handleToggleDetailAudioVoice = async () => { if (audioDockState.value !== 'playable' || !canSwitchDetailAudioVoice.value) return - const shouldContinueAudio = isCurrentDetailAudioTarget.value && globalAudioPlayer.playing.value + const hasCurrentDetailAudio = isCurrentDetailAudioTarget.value + const shouldContinueAudio = hasCurrentDetailAudio && globalAudioPlayer.playing.value selectedAudioGender.value = currentDetailAudioGender.value === 'male' ? 'female' : 'male' - if (shouldContinueAudio) { - await handlePlayAudio() + if (hasCurrentDetailAudio) { + await handlePlayAudio({ autoplay: shouldContinueAudio }) } } @@ -821,24 +822,30 @@ const refreshCurrentAudioOnce = async (message: string) => { return null } +const detailAudioPlayOptions = (audio: AudioItem) => ({ + source: { + exhibitId: exhibit.value.id, + stopId: currentAudioTarget.value.stopId, + lang: selectedAudioLanguage.value, + channelCode: selectedDetailAudioOption.value?.channelCode, + voiceGender: currentDetailAudioGender.value, + audioOptions: activeDetailAudioOptions.value, + title: audio.name, + detailRoute: buildDetailRoute() + }, + retryOnError: refreshCurrentAudioOnce, + displayMode: 'mini' as const +}) + const playDetailAudio = async (audio: AudioItem) => { - await globalAudioPlayer.play(audio, { - source: { - exhibitId: exhibit.value.id, - stopId: currentAudioTarget.value.stopId, - lang: selectedAudioLanguage.value, - channelCode: selectedDetailAudioOption.value?.channelCode, - voiceGender: currentDetailAudioGender.value, - audioOptions: activeDetailAudioOptions.value, - title: audio.name, - detailRoute: buildDetailRoute() - }, - retryOnError: refreshCurrentAudioOnce, - displayMode: 'mini' - }) + await globalAudioPlayer.play(audio, detailAudioPlayOptions(audio)) } -const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => { +const prepareDetailAudio = (audio: AudioItem) => { + globalAudioPlayer.prepare(audio, detailAudioPlayOptions(audio)) +} + +const handlePlayAudio = async (options: { forceRefresh?: boolean; autoplay?: boolean } = {}) => { if (audioDockState.value === 'unavailable' || audioDockState.value === 'loading') return if (audioDockState.value === 'failed' && !options.forceRefresh) { @@ -859,7 +866,8 @@ const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => { ? await refreshCurrentAudioOnce(globalAudioPlayer.error.value) : null if (refreshedAudio) { - await playDetailAudio(refreshedAudio) + if (options.autoplay === false) prepareDetailAudio(refreshedAudio) + else await playDetailAudio(refreshedAudio) return } @@ -895,6 +903,10 @@ const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => { const audio = toAudioItem(selection) if (!audio) return + if (options.autoplay === false) { + prepareDetailAudio(audio) + return + } await playDetailAudio(audio) } diff --git a/tests/unit/ExhibitDetailAudioFirst.spec.ts b/tests/unit/ExhibitDetailAudioFirst.spec.ts index 0420d6e..a51e9df 100644 --- a/tests/unit/ExhibitDetailAudioFirst.spec.ts +++ b/tests/unit/ExhibitDetailAudioFirst.spec.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ loadExplainDetailText: vi.fn(), selectAudioForExplainDetail: vi.fn(), play: vi.fn(), + prepare: vi.fn(), pause: vi.fn(), resume: vi.fn(), close: vi.fn(), @@ -61,8 +62,15 @@ vi.mock('@/usecases/guideUseCase', () => ({ vi.mock('@/composables/useGlobalAudioPlayer', () => ({ useGlobalAudioPlayer: () => ({ ...audioState, - isCurrentSource: () => false, + isCurrentSource: (source: { stopId?: string, lang?: string, channelCode?: string }) => Boolean( + audioState.currentAudio.value + && audioState.currentSource.value + && audioState.currentSource.value.stopId === source.stopId + && (!source.lang || audioState.currentSource.value.lang === source.lang) + && (!source.channelCode || audioState.currentSource.value.channelCode === source.channelCode) + ), play: mocks.play, + prepare: mocks.prepare, pause: mocks.pause, resume: mocks.resume, switchLanguage: vi.fn(), @@ -346,6 +354,81 @@ describe('讲解详情音频优先布局', () => { })) }) + it('暂停时切换语言立即刷新播放器但不自动播放', async () => { + const bilingualExhibit = { + ...exhibit, + audioLanguage: 'en-US', + audioOptions: [ + { channelCode: 'standard.zh-CN.female', version: 'standard', languageCode: 'zh-CN', gender: 'female', playUrl: '/assets/zh-female.mp3', duration: 50 }, + { channelCode: 'standard.en-US.female', version: 'standard', languageCode: 'en-US', gender: 'female', playUrl: '/assets/en-female.mp3', duration: 80 } + ] + } + mocks.enterExplainDetail.mockResolvedValue(bilingualExhibit) + mocks.selectAudioForExplainDetail.mockImplementation(async (data: { audioLanguage: string }) => { + const option = bilingualExhibit.audioOptions.find((item) => item.languageCode === data.audioLanguage)! + return { + playable: true, + exhibit: bilingualExhibit, + media: { id: option.channelCode, url: option.playUrl, duration: option.duration, language: option.languageCode }, + playInfo: { title: exhibit.name } + } + }) + audioState.currentAudio.value = { id: 'standard.en-US.female', audioUrl: '/assets/en-female.mp3' } + audioState.currentSource.value = { stopId: 'stop-1', lang: 'en-US', channelCode: 'standard.en-US.female' } + audioState.playing.value = false + const wrapper = mount(ExhibitDetail, { + global: { stubs: { GuidePageFrame: GuidePageFrameStub } } + }) + + await mocks.onLoadHandler?.({ id: exhibit.id, stopId: 'stop-1', lang: 'en-US' }) + await flushPromises() + await wrapper.findAll('.language-option').find((button) => button.text() === '中文')!.trigger('tap') + await flushPromises() + + expect(mocks.prepare).toHaveBeenCalledWith( + expect.objectContaining({ audioUrl: '/assets/zh-female.mp3', language: 'zh-CN', duration: 50 }), + expect.objectContaining({ source: expect.objectContaining({ lang: 'zh-CN', channelCode: 'standard.zh-CN.female' }) }) + ) + expect(mocks.play).not.toHaveBeenCalled() + }) + + it('暂停时切换男女声立即刷新播放器但不自动播放', async () => { + const voiceExhibit = { + ...exhibit, + audioOptions: [ + { channelCode: 'standard.en-US.female', version: 'standard', languageCode: 'en-US', gender: 'female', playUrl: '/assets/en-female.mp3', duration: 80 }, + { channelCode: 'standard.en-US.male', version: 'standard', languageCode: 'en-US', gender: 'male', playUrl: '/assets/en-male.mp3', duration: 75 } + ] + } + mocks.enterExplainDetail.mockResolvedValue(voiceExhibit) + mocks.selectAudioForExplainDetail.mockImplementation(async (_data: unknown, options: { voiceGender?: string }) => { + const option = voiceExhibit.audioOptions.find((item) => item.gender === options.voiceGender)! + return { + playable: true, + exhibit: voiceExhibit, + media: { id: option.channelCode, url: option.playUrl, duration: option.duration, language: option.languageCode }, + playInfo: { title: exhibit.name } + } + }) + audioState.currentAudio.value = { id: 'standard.en-US.female', audioUrl: '/assets/en-female.mp3' } + audioState.currentSource.value = { stopId: 'stop-1', lang: 'en-US', channelCode: 'standard.en-US.female' } + audioState.playing.value = false + const wrapper = mount(ExhibitDetail, { + global: { stubs: { GuidePageFrame: GuidePageFrameStub } } + }) + + await mocks.onLoadHandler?.({ id: exhibit.id, stopId: 'stop-1', lang: 'en-US' }) + await flushPromises() + await wrapper.get('.detail-audio-voice').trigger('tap') + await flushPromises() + + expect(mocks.prepare).toHaveBeenCalledWith( + expect.objectContaining({ audioUrl: '/assets/en-male.mp3', duration: 75 }), + expect.objectContaining({ source: expect.objectContaining({ voiceGender: 'male', channelCode: 'standard.en-US.male' }) }) + ) + expect(mocks.play).not.toHaveBeenCalled() + }) + it('播放器单行提供倍速循环和男女声切换', async () => { mocks.enterExplainDetail.mockResolvedValue({ ...exhibit,