@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user