优化讲解详情播放器体验
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-25 00:02:33 +08:00
parent 9037f4d3e5
commit cb6da6590d
3 changed files with 248 additions and 73 deletions

View File

@@ -41,6 +41,10 @@ const pendingLanguage = ref<AudioLanguage | ''>('')
const activeHostId = ref('') const activeHostId = ref('')
const lastClosedSource = ref<GlobalAudioSource | null>(null) const lastClosedSource = ref<GlobalAudioSource | null>(null)
const closeVersion = ref(0) const closeVersion = ref(0)
const playbackRate = ref(1)
const muted = ref(false)
const PLAYBACK_RATES = [1, 1.25, 1.5, 2] as const
let audioElement: HTMLAudioElement | null = null let audioElement: HTMLAudioElement | null = null
let retryOnError: GlobalAudioRetryHandler | null = null let retryOnError: GlobalAudioRetryHandler | null = null
@@ -74,11 +78,17 @@ const syncAudioDuration = () => {
: currentAudio.value?.duration || 0 : currentAudio.value?.duration || 0
} }
const applyAudioPreferences = (audio: HTMLAudioElement) => {
audio.playbackRate = playbackRate.value
audio.muted = muted.value
}
const ensureAudioElement = () => { const ensureAudioElement = () => {
if (audioElement || typeof window === 'undefined' || !window.Audio) return audioElement if (audioElement || typeof window === 'undefined' || !window.Audio) return audioElement
const audio = new window.Audio() const audio = new window.Audio()
audio.preload = 'metadata' audio.preload = 'metadata'
applyAudioPreferences(audio)
audio.addEventListener('loadedmetadata', syncAudioDuration) audio.addEventListener('loadedmetadata', syncAudioDuration)
audio.addEventListener('play', () => { audio.addEventListener('play', () => {
playing.value = true playing.value = true
@@ -124,6 +134,11 @@ const resetState = () => {
duration.value = 0 duration.value = 0
displayMode.value = 'mini' displayMode.value = 'mini'
pendingLanguage.value = '' pendingLanguage.value = ''
playbackRate.value = 1
muted.value = false
if (audioElement) {
applyAudioPreferences(audioElement)
}
retryOnError = null retryOnError = null
retrying = false retrying = false
retryUsed = false retryUsed = false
@@ -227,6 +242,7 @@ const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => {
element.src = audio.audioUrl element.src = audio.audioUrl
element.load() element.load()
} }
applyAudioPreferences(element)
try { try {
await element.play() await element.play()
@@ -448,6 +464,22 @@ const seekToPercent = (percent: number) => {
currentTime.value = targetTime currentTime.value = targetTime
} }
const cyclePlaybackRate = () => {
const currentIndex = PLAYBACK_RATES.indexOf(playbackRate.value as typeof PLAYBACK_RATES[number])
const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % PLAYBACK_RATES.length : 0
playbackRate.value = PLAYBACK_RATES[nextIndex]
if (audioElement) {
audioElement.playbackRate = playbackRate.value
}
}
const toggleMuted = () => {
muted.value = !muted.value
if (audioElement) {
audioElement.muted = muted.value
}
}
const registerHost = () => { const registerHost = () => {
hostSequence += 1 hostSequence += 1
const hostId = `global-audio-host-${hostSequence}` const hostId = `global-audio-host-${hostSequence}`
@@ -490,6 +522,8 @@ export const useGlobalAudioPlayer = () => ({
activeHostId, activeHostId,
lastClosedSource, lastClosedSource,
closeVersion, closeVersion,
playbackRate,
muted,
hasAudio: computed(() => Boolean(currentAudio.value)), hasAudio: computed(() => Boolean(currentAudio.value)),
play, play,
pause, pause,
@@ -502,6 +536,8 @@ export const useGlobalAudioPlayer = () => ({
handleEnded, handleEnded,
handleError, handleError,
seekToPercent, seekToPercent,
cyclePlaybackRate,
toggleMuted,
registerHost, registerHost,
activateHost, activateHost,
unregisterHost, unregisterHost,

View File

@@ -104,6 +104,30 @@
<text class="detail-audio-title">{{ audioDockTitle }}</text> <text class="detail-audio-title">{{ audioDockTitle }}</text>
<text class="detail-audio-subtitle">{{ audioDockSubtitle }}</text> <text class="detail-audio-subtitle">{{ audioDockSubtitle }}</text>
</view> </view>
<button
v-if="audioDockState === 'playable'"
class="detail-audio-rate"
:aria-label="`切换播放速率,当前${detailPlaybackRateLabel}倍`"
@tap.stop="handleCyclePlaybackRate"
>
<text>{{ detailPlaybackRateLabel }}</text>
</button>
<button
v-if="audioDockState === 'playable'"
class="detail-audio-mute"
:class="{ muted: globalAudioPlayer.muted.value }"
:aria-label="detailAudioMuteActionLabel"
@tap.stop="handleToggleMuted"
>
<svg v-if="globalAudioPlayer.muted.value" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 10H8L13 6V18L8 14H4V10Z" fill="currentColor"/>
<path d="M16 9L21 15M21 9L16 15" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
</svg>
<svg v-else width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 10H8L13 6V18L8 14H4V10Z" fill="currentColor"/>
<path d="M16 9C17.25 10.3 17.25 13.7 16 15M18.5 6.5C21.6 9.7 21.6 14.3 18.5 17.5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
</svg>
</button>
<button <button
v-if="audioDockState === 'failed'" v-if="audioDockState === 'failed'"
class="detail-audio-retry" class="detail-audio-retry"
@@ -230,13 +254,13 @@ const languageOptionDefinitions: Array<{
value: 'zh-CN', value: 'zh-CN',
label: '中文' label: '中文'
}, },
{
value: 'yue-HK',
label: '粤语'
},
{ {
value: 'en-US', value: 'en-US',
label: 'English' label: 'English'
},
{
value: 'yue-HK',
label: '粤语'
} }
] ]
@@ -340,16 +364,27 @@ const detailAudioCurrentSeconds = computed(() => (
const detailAudioTimeLabel = computed(() => ( const detailAudioTimeLabel = computed(() => (
`${formatDetailAudioTime(detailAudioCurrentSeconds.value)} / ${formatDetailAudioTime(detailAudioDurationSeconds.value)}` `${formatDetailAudioTime(detailAudioCurrentSeconds.value)} / ${formatDetailAudioTime(detailAudioDurationSeconds.value)}`
)) ))
const audioDockSubtitle = computed(() => [ const audioDockSubtitle = computed(() => {
audioLanguageLabel(selectedAudioLanguage.value), if (audioDockState.value === 'failed') {
audioDockState.value === 'failed' return globalAudioPlayer.error.value || '音频暂时无法播放'
? globalAudioPlayer.error.value || '音频暂时无法播放' }
: audioDockState.value === 'unavailable' if (audioDockState.value === 'unavailable') {
? audioAvailabilityMessage.value return audioAvailabilityMessage.value
: audioDockState.value === 'loading' }
? '正在加载音频' if (audioDockState.value === 'loading') {
: detailAudioTimeLabel.value return '正在加载音频'
].filter(Boolean).join(' · ') || '讲解') }
return detailAudioTimeLabel.value
})
const detailPlaybackRateLabel = computed(() => (
globalAudioPlayer.playbackRate.value === 1
? '1.0'
: String(globalAudioPlayer.playbackRate.value)
))
const detailAudioMuteActionLabel = computed(() => (
globalAudioPlayer.muted.value ? '取消静音' : '静音'
))
const detailAudioPlaying = computed(() => ( const detailAudioPlaying = computed(() => (
isCurrentDetailAudio.value isCurrentDetailAudio.value
&& globalAudioPlayer.playing.value && globalAudioPlayer.playing.value
@@ -795,6 +830,14 @@ const handleRetryAudio = async () => {
await handlePlayAudio({ forceRefresh: true }) await handlePlayAudio({ forceRefresh: true })
} }
const handleCyclePlaybackRate = () => {
globalAudioPlayer.cyclePlaybackRate()
}
const handleToggleMuted = () => {
globalAudioPlayer.toggleMuted()
}
const handleSeekAudio = (event: { detail?: { x?: number }; currentTarget?: { offsetWidth?: number } }) => { const handleSeekAudio = (event: { detail?: { x?: number }; currentTarget?: { offsetWidth?: number } }) => {
if (audioDockState.value !== 'playable' || !detailAudioDurationSeconds.value) return if (audioDockState.value !== 'playable' || !detailAudioDurationSeconds.value) return
@@ -897,33 +940,33 @@ const handleBack = returnToExplainObjectList
.content { .content {
position: absolute; position: absolute;
top: 392px; top: 360px;
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
height: auto; height: auto;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
background: #f7f8f3; background: #fafbf8;
} }
.detail-language-bar { .detail-language-bar {
position: absolute; position: absolute;
top: 320px; top: 290px;
right: 0; right: 0;
left: 0; left: 0;
z-index: 2; z-index: 2;
height: 72px; height: 70px;
padding: 25px 24px 7px; padding: 22px 24px 12px;
box-sizing: border-box; box-sizing: border-box;
background: #f7f8f3; background: #fafbf8;
} }
.immersive-hero { .immersive-hero {
position: relative; position: relative;
min-height: 0; min-height: 0;
height: 320px; height: 290px;
padding: calc(env(safe-area-inset-top) + 0px) 24px 24px; padding: calc(env(safe-area-inset-top) + 0px) 24px 16px;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
background: #111a14; background: #111a14;
@@ -1004,8 +1047,8 @@ const handleBack = returnToExplainObjectList
.detail-meta { .detail-meta {
display: block; display: block;
margin-top: 6px; margin-top: 6px;
font-size: 14px; font-size: 16px;
line-height: 20px; line-height: 22px;
font-weight: 600; font-weight: 600;
color: rgba(255, 255, 255, 0.86); color: rgba(255, 255, 255, 0.86);
overflow: hidden; overflow: hidden;
@@ -1016,10 +1059,10 @@ const handleBack = returnToExplainObjectList
.detail-chip-row { display: none; } .detail-chip-row { display: none; }
.detail-content { .detail-content {
min-height: calc(100vh - 392px - 72px - env(safe-area-inset-bottom)); min-height: calc(100vh - 360px - 66px - env(safe-area-inset-bottom));
padding: 24px 24px calc(32px + 72px + env(safe-area-inset-bottom)); padding: 12px 24px calc(32px + 66px + env(safe-area-inset-bottom));
box-sizing: border-box; box-sizing: border-box;
background: #f7f8f3; background: #fafbf8;
} }
.detail-audio-play { .detail-audio-play {
@@ -1053,21 +1096,21 @@ const handleBack = returnToExplainObjectList
} }
.language-switch { .language-switch {
height: 40px; height: 36px;
margin-top: 0; margin-top: 0;
padding: 0; padding: 0;
display: flex; display: flex;
box-sizing: border-box; box-sizing: border-box;
background: transparent; background: transparent;
border: 1px solid #d8ddd2; border: 1px solid #d9ddd6;
border-radius: 6px; border-radius: 7px;
} }
.language-option { .language-option {
position: relative; position: relative;
min-width: 0; min-width: 0;
flex: 1; flex: 1;
height: 40px; height: 36px;
padding: 0 6px; padding: 0 6px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1076,16 +1119,16 @@ const handleBack = returnToExplainObjectList
border: 0; border: 0;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
color: #5d6659; color: #53615b;
} }
.language-option + .language-option { .language-option + .language-option {
border-left: 1px solid #d8ddd2; border-left: 1px solid #d9ddd6;
} }
.language-option.active { .language-option.active {
background: #e8e800; background: #e7ed42;
color: #141412; color: #315eb9;
} }
.language-option.active::before { .language-option.active::before {
@@ -1095,7 +1138,7 @@ const handleBack = returnToExplainObjectList
left: 10px; left: 10px;
height: 3px; height: 3px;
content: ''; content: '';
background: #141412; background: #3a65b6;
} }
.language-option.disabled { .language-option.disabled {
@@ -1105,7 +1148,7 @@ const handleBack = returnToExplainObjectList
.language-option-text { .language-option-text {
display: block; display: block;
max-width: 100%; max-width: 100%;
font-size: 14px; font-size: 16px;
line-height: 20px; line-height: 20px;
font-weight: 700; font-weight: 700;
overflow: hidden; overflow: hidden;
@@ -1121,14 +1164,18 @@ const handleBack = returnToExplainObjectList
.section-text { .section-text {
display: block; display: block;
margin-top: 0; margin-top: 0;
font-size: 16px; font-size: 17px;
line-height: 26px; line-height: 28px;
color: #31392e; color: #242b26;
text-indent: 2em; text-indent: 2em;
word-break: break-word; word-break: break-word;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.section-text + .section-text {
margin-top: 8px;
}
.section-hint { .section-hint {
margin-top: 14px; margin-top: 14px;
font-size: 14px; font-size: 14px;
@@ -1145,24 +1192,25 @@ const handleBack = returnToExplainObjectList
bottom: 0; bottom: 0;
left: 0; left: 0;
z-index: 10; z-index: 10;
height: calc(72px + env(safe-area-inset-bottom)); height: calc(66px + env(safe-area-inset-bottom));
padding: 0 24px env(safe-area-inset-bottom); padding: 0 16px env(safe-area-inset-bottom);
box-sizing: border-box; box-sizing: border-box;
background: #ffffff; background: #ffffff;
border-top: 1px solid #e8e5de; border-top: 1px solid #e8e5de;
} }
.detail-audio-dock-main { .detail-audio-dock-main {
height: 50px; height: 66px;
padding-left: 58px; padding-left: 52px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 0;
} }
.detail-audio-main { .detail-audio-main {
min-width: 0; min-width: 0;
flex: 1; flex: 0 0 96px;
z-index: 1;
} }
.detail-audio-title, .detail-audio-title,
@@ -1174,17 +1222,15 @@ const handleBack = returnToExplainObjectList
} }
.detail-audio-title { .detail-audio-title {
font-size: 14px; display: none;
line-height: 20px;
font-weight: 500;
color: #141412;
} }
.detail-audio-subtitle { .detail-audio-subtitle {
margin-top: 1px; margin-top: 0;
font-size: 12px; font-size: 13px;
line-height: 16px; line-height: 20px;
color: #666661; font-weight: 600;
color: #192d83;
} }
.detail-audio-play.disabled { .detail-audio-play.disabled {
@@ -1194,12 +1240,20 @@ const handleBack = returnToExplainObjectList
.detail-audio-play { .detail-audio-play {
position: absolute; position: absolute;
top: 14px; top: 13px;
left: 19px; left: 16px;
z-index: 1; z-index: 1;
width: 40px;
height: 40px;
min-width: 40px;
flex-basis: 40px;
} }
.detail-audio-retry { .detail-audio-retry {
position: absolute;
top: 17px;
right: 16px;
z-index: 2;
height: 32px; height: 32px;
padding: 0 10px; padding: 0 10px;
flex-shrink: 0; flex-shrink: 0;
@@ -1216,9 +1270,61 @@ const handleBack = returnToExplainObjectList
border: 0; border: 0;
} }
.detail-audio-rate,
.detail-audio-mute {
position: absolute;
top: 13px;
z-index: 2;
height: 40px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: #192d83;
}
.detail-audio-rate {
right: 50px;
width: 40px;
}
.detail-audio-rate text {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 1px solid currentColor;
border-radius: 50%;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.detail-audio-mute {
right: 12px;
width: 32px;
}
.detail-audio-mute.muted {
color: #7a807a;
}
.detail-audio-rate::after,
.detail-audio-mute::after {
border: 0;
}
.detail-audio-progress-hit { .detail-audio-progress-hit {
height: 22px; position: absolute;
margin: 0 0 0 58px; top: 13px;
right: 16px;
left: 126px;
height: 40px;
margin: 0;
display: flex; display: flex;
align-items: center; align-items: center;
} }
@@ -1230,43 +1336,51 @@ const handleBack = returnToExplainObjectList
.detail-audio-progress { .detail-audio-progress {
position: relative; position: relative;
width: 100%; width: 100%;
height: 4px; height: 3px;
overflow: visible; overflow: visible;
background: #94948f; background: #a4a6a3;
border-radius: 2px; border-radius: 999px;
} }
.detail-audio-progress-fill { .detail-audio-progress-fill {
width: 0; width: 0;
height: 100%; height: 100%;
background: #e0e100; background: #3a65b6;
border-radius: inherit; border-radius: inherit;
} }
.detail-audio-progress-thumb { .detail-audio-progress-thumb {
position: absolute; position: absolute;
top: 50%; top: 50%;
width: 10px; width: 9px;
height: 10px; height: 9px;
border-radius: 50%; border-radius: 50%;
background: #141412; background: #3a65b6;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.detail-audio-progress.is-indeterminate { .detail-audio-progress.is-indeterminate {
width: calc(100% - 58px); position: absolute;
height: 4px; top: 31px;
margin: 8px 0 0 58px; right: 16px;
left: 126px;
width: auto;
height: 3px;
margin: 0;
overflow: hidden; overflow: hidden;
background: #deded6; background: #deded6;
} }
.detail-audio-dock.is-playable .detail-audio-progress-hit {
right: 88px;
}
.detail-audio-progress.is-indeterminate::after { .detail-audio-progress.is-indeterminate::after {
display: block; display: block;
width: 38%; width: 38%;
height: 100%; height: 100%;
content: ''; content: '';
background: #e0e100; background: #3a65b6;
animation: audio-indeterminate 1.1s ease-in-out infinite; animation: audio-indeterminate 1.1s ease-in-out infinite;
} }

View File

@@ -16,7 +16,9 @@ const mocks = vi.hoisted(() => ({
pause: vi.fn(), pause: vi.fn(),
resume: vi.fn(), resume: vi.fn(),
close: vi.fn(), close: vi.fn(),
seekToPercent: vi.fn() seekToPercent: vi.fn(),
cyclePlaybackRate: vi.fn(),
toggleMuted: vi.fn()
})) }))
const audioState = vi.hoisted(() => ({ const audioState = vi.hoisted(() => ({
@@ -26,7 +28,9 @@ const audioState = vi.hoisted(() => ({
loading: { value: false }, loading: { value: false },
error: { value: '' }, error: { value: '' },
currentTime: { value: 0 }, currentTime: { value: 0 },
duration: { value: 0 } duration: { value: 0 },
playbackRate: { value: 1 },
muted: { value: false }
})) }))
vi.mock('@dcloudio/uni-app', () => ({ vi.mock('@dcloudio/uni-app', () => ({
@@ -65,7 +69,9 @@ vi.mock('@/composables/useGlobalAudioPlayer', () => ({
resume: mocks.resume, resume: mocks.resume,
switchLanguage: vi.fn(), switchLanguage: vi.fn(),
close: mocks.close, close: mocks.close,
seekToPercent: mocks.seekToPercent seekToPercent: mocks.seekToPercent,
cyclePlaybackRate: mocks.cyclePlaybackRate,
toggleMuted: mocks.toggleMuted
}) })
})) }))
@@ -111,6 +117,8 @@ describe('讲解详情音频优先布局', () => {
audioState.error.value = '' audioState.error.value = ''
audioState.currentTime.value = 0 audioState.currentTime.value = 0
audioState.duration.value = 0 audioState.duration.value = 0
audioState.playbackRate.value = 1
audioState.muted.value = false
mocks.enterExplainDetail.mockResolvedValue(exhibit) mocks.enterExplainDetail.mockResolvedValue(exhibit)
mocks.selectExplainDetailLanguage.mockImplementation((source, lang) => ({ mocks.selectExplainDetailLanguage.mockImplementation((source, lang) => ({
...source, ...source,
@@ -157,6 +165,9 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.get('.detail-chip').text()).toBe('标准解说') expect(wrapper.get('.detail-chip').text()).toBe('标准解说')
expect(wrapper.find('.detail-audio-dock').exists()).toBe(true) expect(wrapper.find('.detail-audio-dock').exists()).toBe(true)
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-playable') expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-playable')
expect(wrapper.get('.detail-audio-subtitle').text()).toBe('0:00 / 1:21')
expect(wrapper.get('.detail-audio-rate').text()).toBe('1.0')
expect(wrapper.find('.detail-audio-mute').exists()).toBe(true)
expect(wrapper.find('.language-switch').text()).toContain('English') expect(wrapper.find('.language-switch').text()).toContain('English')
expect(wrapper.find('.detail-language-bar').exists()).toBe(true) expect(wrapper.find('.detail-language-bar').exists()).toBe(true)
expect(wrapper.find('.content .language-switch').exists()).toBe(false) expect(wrapper.find('.content .language-switch').exists()).toBe(false)
@@ -165,6 +176,20 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.get('.section-text').text()).toContain('detailed English narration') expect(wrapper.get('.section-text').text()).toContain('detailed English narration')
}) })
it('正常音频底栏可切换播放速率和静音', async () => {
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
await wrapper.get('.detail-audio-rate').trigger('tap')
await wrapper.get('.detail-audio-mute').trigger('tap')
expect(mocks.cyclePlaybackRate).toHaveBeenCalledTimes(1)
expect(mocks.toggleMuted).toHaveBeenCalledTimes(1)
})
it('仅展示 stop-info 声明支持的语言按钮', async () => { it('仅展示 stop-info 声明支持的语言按钮', async () => {
mocks.enterExplainDetail.mockResolvedValue({ mocks.enterExplainDetail.mockResolvedValue({
...exhibit, ...exhibit,
@@ -276,7 +301,7 @@ describe('讲解详情音频优先布局', () => {
expect(mocks.loadExplainDetailText).not.toHaveBeenCalled() expect(mocks.loadExplainDetailText).not.toHaveBeenCalled()
expect(mocks.selectExplainDetailLanguage).toHaveBeenCalledWith(expect.any(Object), 'yue-HK') expect(mocks.selectExplainDetailLanguage).toHaveBeenCalledWith(expect.any(Object), 'yue-HK')
expect(wrapper.get('.section-text').text()).toContain('粤语通道使用普通话文案') expect(wrapper.get('.section-text').text()).toContain('粤语通道使用普通话文案')
expect(wrapper.get('.detail-audio-subtitle').text()).toContain('粤语') expect(wrapper.get('.detail-audio-subtitle').text()).toBe('0:00 / 1:23')
}) })
it('无音频时禁用播放按钮并显示原因', async () => { it('无音频时禁用播放按钮并显示原因', async () => {