Compare commits
2 Commits
89923ab861
...
180d3e7c96
| Author | SHA1 | Date | |
|---|---|---|---|
| 180d3e7c96 | |||
| 9cdb2c08b2 |
@@ -1,84 +1,181 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="audio-player-host">
|
<view class="audio-player-host">
|
||||||
<view
|
<view
|
||||||
class="audio-player"
|
v-if="visible && displayAudio"
|
||||||
:class="{ 'avoid-bottom-nav': avoidBottomNav }"
|
class="audio-player"
|
||||||
v-if="visible && displayAudio"
|
:class="[`mode-${displayMode}`, { 'avoid-bottom-nav': avoidBottomNav }]"
|
||||||
>
|
>
|
||||||
<!-- 迷你播放器 (底部固定) -->
|
<view v-if="displayMode === 'expanded'" class="expanded-player">
|
||||||
<view class="mini-player" @tap="handleExpand">
|
<view class="player-handle"></view>
|
||||||
<!-- 封面图 -->
|
<view class="expanded-main">
|
||||||
<view class="player-cover">
|
<image
|
||||||
<image
|
v-if="displayAudio.image"
|
||||||
class="cover-image"
|
class="expanded-cover"
|
||||||
:src="displayAudio?.image || EXHIBIT_PLACEHOLDER_IMAGE"
|
:src="displayAudio.image"
|
||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
/>
|
/>
|
||||||
|
<view class="expanded-copy">
|
||||||
|
<text class="player-kicker">讲解音频</text>
|
||||||
|
<text class="expanded-title">{{ displayAudio.name }}</text>
|
||||||
|
<text class="expanded-subtitle" :class="{ error: displayPlaybackError }">{{ expandedSubtitle }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="expanded-side">
|
||||||
|
<view
|
||||||
|
v-if="isLanguageSwitchable"
|
||||||
|
class="player-language-switch expanded-language"
|
||||||
|
@tap.stop
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
v-for="option in audioLanguageOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="player-language-option"
|
||||||
|
:class="{
|
||||||
|
active: effectiveAudioLanguage === option.value,
|
||||||
|
pending: displayPendingLanguage === option.value,
|
||||||
|
disabled: isLanguagePending && displayPendingLanguage !== option.value
|
||||||
|
}"
|
||||||
|
@tap.stop="handleLanguageChange(option.value)"
|
||||||
|
>
|
||||||
|
<text class="player-language-text">{{ formatLanguageOptionText(option) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="player-language-state expanded-language">
|
||||||
|
<text class="player-language-text">{{ displayAudioLanguageShort }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-btn play-btn expanded-play" @tap.stop="togglePlay">
|
||||||
|
<svg v-if="displayPlaying" width="30" height="30" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||||
|
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="30" height="30" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="expanded-progress-row">
|
||||||
|
<text class="time-text active">{{ formattedCurrentTime }}</text>
|
||||||
|
<view class="progress-track expanded-track" @tap="handleSeek">
|
||||||
|
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
|
||||||
|
<view class="progress-thumb" :style="{ left: progressPercent + '%' }"></view>
|
||||||
|
</view>
|
||||||
|
<text class="time-text">{{ formattedDuration }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="expanded-actions">
|
||||||
|
<view class="action-btn collapse-action" @tap.stop="handleCollapse">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M6 15L12 9L18 15" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<text class="action-text">收起</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-divider"></view>
|
||||||
|
<view class="action-btn stop-action" @tap.stop="handleStop">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="7" y="7" width="10" height="10" rx="1.5" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<text class="action-text">停止播放</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 播放信息 -->
|
<view v-else-if="displayMode === 'floating'" class="floating-player" @tap="handleExpand">
|
||||||
<view class="player-info">
|
<view class="floating-ring" :style="{ '--progress': progressPercent + '%' }">
|
||||||
<text class="player-title">{{ displayAudio?.name || '讲解音频' }}</text>
|
<view class="control-btn floating-play" @tap.stop="togglePlay">
|
||||||
<text class="player-subtitle" :class="{ error: displayPlaybackError }">{{ playerSubtitle }}</text>
|
<svg v-if="displayPlaying" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||||
|
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</view>
|
||||||
|
<text class="floating-label">讲解</text>
|
||||||
|
<text class="floating-lang">{{ displayAudioLanguageShort }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 播放/暂停按钮 -->
|
<view v-else class="mini-player" @tap="handleExpand">
|
||||||
<view class="player-controls">
|
<view class="control-btn play-btn mini-play" @tap.stop="togglePlay">
|
||||||
<view class="control-btn play-btn" @tap.stop="togglePlay">
|
<svg v-if="displayPlaying" width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||||||
<svg v-if="displayPlaying" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
||||||
<rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
<rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||||
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none">
|
<svg v-else width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 关闭按钮 -->
|
<view class="mini-copy">
|
||||||
<view class="control-btn close-btn" @tap.stop="handleClose">
|
<text class="player-kicker">讲解音频</text>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
<text class="mini-title">{{ displayAudio.name }}</text>
|
||||||
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
</view>
|
||||||
|
|
||||||
|
<view class="mini-progress">
|
||||||
|
<view class="progress-track" @tap.stop="handleSeek">
|
||||||
|
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isLanguageSwitchable" class="player-language-switch mini-language" @tap.stop>
|
||||||
|
<view
|
||||||
|
v-for="option in audioLanguageOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="player-language-option"
|
||||||
|
:class="{
|
||||||
|
active: effectiveAudioLanguage === option.value,
|
||||||
|
pending: displayPendingLanguage === option.value,
|
||||||
|
disabled: isLanguagePending && displayPendingLanguage !== option.value
|
||||||
|
}"
|
||||||
|
@tap.stop="handleLanguageChange(option.value)"
|
||||||
|
>
|
||||||
|
<text class="player-language-text">{{ formatLanguageOptionText(option) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="player-language-state mini-language">
|
||||||
|
<text class="player-language-text">{{ displayAudioLanguageShort }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="expand-action">
|
||||||
|
<text class="expand-text">展开</text>
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 播放进度条 -->
|
|
||||||
<view class="progress-bar">
|
|
||||||
<view class="progress-track" @tap="handleSeek">
|
|
||||||
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
|
|
||||||
<view class="progress-thumb" :style="{ left: progressPercent + '%' }"></view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import {
|
|
||||||
EXHIBIT_PLACEHOLDER_IMAGE
|
|
||||||
} from '@/utils/placeholders'
|
|
||||||
|
|
||||||
export interface AudioItem {
|
export interface AudioItem {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
audioUrl: string
|
audioUrl: string
|
||||||
image?: string
|
image?: string
|
||||||
duration?: number
|
duration?: number
|
||||||
|
language?: AudioLanguage
|
||||||
|
supportedLanguages?: AudioLanguage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AudioLanguage = 'zh-CN' | 'en-US'
|
||||||
|
export type AudioDisplayMode = 'expanded' | 'mini' | 'floating'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
audio?: AudioItem | null
|
audio?: AudioItem | null
|
||||||
autoPlay?: boolean
|
autoPlay?: boolean
|
||||||
avoidBottomNav?: boolean
|
avoidBottomNav?: boolean
|
||||||
|
mode?: AudioDisplayMode
|
||||||
controlled?: boolean
|
controlled?: boolean
|
||||||
playing?: boolean
|
playing?: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
playbackError?: string
|
playbackError?: string
|
||||||
currentTime?: number
|
currentTime?: number
|
||||||
durationValue?: number
|
durationValue?: number
|
||||||
|
pendingLanguage?: AudioLanguage | ''
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -90,8 +187,11 @@ const emit = defineEmits<{
|
|||||||
error: [audio: AudioItem | null, message: string]
|
error: [audio: AudioItem | null, message: string]
|
||||||
toggle: []
|
toggle: []
|
||||||
close: []
|
close: []
|
||||||
|
collapse: []
|
||||||
|
stop: []
|
||||||
expand: []
|
expand: []
|
||||||
seek: [percent: number]
|
seek: [percent: number]
|
||||||
|
'language-change': [language: AudioLanguage]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
@@ -129,15 +229,39 @@ const displayPlaying = computed(() => props.controlled ? props.playing === true
|
|||||||
const displayLoading = computed(() => props.controlled ? props.loading === true : isLoading.value)
|
const displayLoading = computed(() => props.controlled ? props.loading === true : isLoading.value)
|
||||||
const displayPlaybackError = computed(() => props.controlled ? props.playbackError || '' : localPlaybackError.value)
|
const displayPlaybackError = computed(() => props.controlled ? props.playbackError || '' : localPlaybackError.value)
|
||||||
const displayCurrentTime = computed(() => props.controlled ? props.currentTime || 0 : localCurrentTime.value)
|
const displayCurrentTime = computed(() => props.controlled ? props.currentTime || 0 : localCurrentTime.value)
|
||||||
|
const displayMode = computed<AudioDisplayMode>(() => props.mode || 'mini')
|
||||||
|
const audioLanguageOptions: Array<{ value: AudioLanguage; label: string; shortLabel: string }> = [
|
||||||
|
{ value: 'zh-CN', label: '中文', shortLabel: '中文' },
|
||||||
|
{ value: 'en-US', label: 'English', shortLabel: 'EN' }
|
||||||
|
]
|
||||||
|
const displayAudioLanguage = computed<AudioLanguage>(() => (
|
||||||
|
displayAudio.value?.language === 'en-US' ? 'en-US' : 'zh-CN'
|
||||||
|
))
|
||||||
|
const supportedAudioLanguages = computed(() => {
|
||||||
|
const languages = displayAudio.value?.supportedLanguages?.length
|
||||||
|
? displayAudio.value.supportedLanguages
|
||||||
|
: [displayAudioLanguage.value]
|
||||||
|
|
||||||
const playerSubtitle = computed(() => {
|
return Array.from(new Set(languages.filter((language): language is AudioLanguage => (
|
||||||
|
language === 'zh-CN' || language === 'en-US'
|
||||||
|
))))
|
||||||
|
})
|
||||||
|
const isLanguageSwitchable = computed(() => supportedAudioLanguages.value.length > 1)
|
||||||
|
const displayPendingLanguage = computed<AudioLanguage | ''>(() => props.pendingLanguage || '')
|
||||||
|
const effectiveAudioLanguage = computed<AudioLanguage>(() => (
|
||||||
|
displayPendingLanguage.value || displayAudioLanguage.value
|
||||||
|
))
|
||||||
|
const isLanguagePending = computed(() => Boolean(displayPendingLanguage.value))
|
||||||
|
const displayAudioLanguageShort = computed(() => (
|
||||||
|
displayAudioLanguage.value === 'en-US' ? 'EN' : '中文'
|
||||||
|
))
|
||||||
|
const formattedCurrentTime = computed(() => formatTime(displayCurrentTime.value))
|
||||||
|
const formattedDuration = computed(() => formatTime(resolvedDuration.value))
|
||||||
|
const expandedSubtitle = computed(() => {
|
||||||
if (displayPlaybackError.value) return displayPlaybackError.value
|
if (displayPlaybackError.value) return displayPlaybackError.value
|
||||||
if (displayLoading.value) return '音频加载中'
|
if (displayLoading.value) return '音频加载中'
|
||||||
if (!displayAudio.value) return '请选择讲解'
|
if (!displayAudio.value?.audioUrl) return '当前语言暂无语音讲解'
|
||||||
if (resolvedDuration.value > 0) {
|
return displayAudioLanguage.value === 'en-US' ? '英文语音讲解' : '中文语音讲解'
|
||||||
return `${formatTime(displayCurrentTime.value)} / ${formatTime(resolvedDuration.value)}`
|
|
||||||
}
|
|
||||||
return displayPlaying.value ? '正在播放' : '点击播放按钮开始'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
@@ -147,6 +271,10 @@ const formatTime = (seconds: number): string => {
|
|||||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatLanguageOptionText = (option: { shortLabel: string; value: AudioLanguage }) => (
|
||||||
|
displayPendingLanguage.value === option.value ? `${option.shortLabel}...` : option.shortLabel
|
||||||
|
)
|
||||||
|
|
||||||
// 初始化音频上下文
|
// 初始化音频上下文
|
||||||
const initAudio = () => {
|
const initAudio = () => {
|
||||||
if (h5AudioElement.value) return
|
if (h5AudioElement.value) return
|
||||||
@@ -427,17 +555,18 @@ const handleExpand = () => {
|
|||||||
emit('expand')
|
emit('expand')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭播放器
|
const handleCollapse = () => {
|
||||||
const handleClose = () => {
|
emit('collapse')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
if (props.controlled) {
|
if (props.controlled) {
|
||||||
emit('close')
|
emit('stop')
|
||||||
emit('update:visible', false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stopAudio()
|
stopAudio()
|
||||||
emit('update:visible', false)
|
emit('stop')
|
||||||
emit('close')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleH5LoadedMetadata = () => {
|
const handleH5LoadedMetadata = () => {
|
||||||
@@ -455,6 +584,12 @@ const handleH5Play = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLanguageChange = (language: AudioLanguage) => {
|
||||||
|
if (isLanguagePending.value) return
|
||||||
|
if (!supportedAudioLanguages.value.includes(language) || language === displayAudioLanguage.value) return
|
||||||
|
emit('language-change', language)
|
||||||
|
}
|
||||||
|
|
||||||
const handleH5Pause = () => {
|
const handleH5Pause = () => {
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
if (currentAudio.value) {
|
if (currentAudio.value) {
|
||||||
@@ -558,84 +693,160 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.audio-player {
|
.audio-player {
|
||||||
|
--audio-accent: #e0e100;
|
||||||
|
--audio-surface: #050505;
|
||||||
|
--audio-text: #ffffff;
|
||||||
|
--audio-muted: rgba(255, 255, 255, 0.68);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
left: 50%;
|
||||||
left: 0;
|
right: auto;
|
||||||
right: 0;
|
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||||
|
z-index: 2200;
|
||||||
|
width: calc(min(100vw, 430px) - 28px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: var(--museum-bg-surface);
|
transform: translateX(-50%);
|
||||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
|
color: var(--audio-text);
|
||||||
z-index: 1000;
|
font-family: '鸿蒙黑体', 'HarmonyOS Sans SC', 'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-player.avoid-bottom-nav {
|
.audio-player.avoid-bottom-nav {
|
||||||
bottom: calc(82px + env(safe-area-inset-bottom));
|
bottom: calc(82px + env(safe-area-inset-bottom));
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
.audio-player.mode-floating {
|
||||||
.audio-player {
|
left: auto;
|
||||||
left: 50%;
|
right: max(16px, calc((100vw - 430px) / 2 + 16px));
|
||||||
right: auto;
|
bottom: calc(102px + env(safe-area-inset-bottom));
|
||||||
width: 430px;
|
width: 116px;
|
||||||
transform: translateX(-50%);
|
transform: none;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-player {
|
.audio-player.mode-floating.avoid-bottom-nav {
|
||||||
display: flex;
|
bottom: calc(104px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-player,
|
||||||
|
.mini-player,
|
||||||
|
.floating-ring {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 14% 18%, rgba(224, 225, 0, 0.08), transparent 28%),
|
||||||
|
linear-gradient(135deg, #161714 0%, #050505 100%);
|
||||||
|
border: 1px solid rgba(224, 225, 0, 0.2);
|
||||||
|
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-player::after,
|
||||||
|
.mini-player::after,
|
||||||
|
.floating-ring::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: auto -24px -42px auto;
|
||||||
|
width: 150px;
|
||||||
|
height: 110px;
|
||||||
|
border: 1px solid rgba(224, 225, 0, 0.13);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: rotate(-16deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-player::after {
|
||||||
|
inset: auto 8px 8px auto;
|
||||||
|
width: 120px;
|
||||||
|
height: 78px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-player {
|
||||||
|
min-height: 292px;
|
||||||
|
padding: 28px 22px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 11px;
|
||||||
|
left: 50%;
|
||||||
|
width: 54px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-main {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 92px minmax(0, 1fr);
|
||||||
|
gap: 14px 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 16px;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-cover {
|
.expanded-cover {
|
||||||
flex-shrink: 0;
|
width: 92px;
|
||||||
width: 44px;
|
height: 92px;
|
||||||
height: 44px;
|
border-radius: 14px;
|
||||||
border-radius: var(--radius-small);
|
background: #262421;
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--museum-bg-light);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover-image {
|
.expanded-copy,
|
||||||
width: 100%;
|
.mini-copy {
|
||||||
height: 100%;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-info {
|
.player-kicker,
|
||||||
flex: 1;
|
.expanded-title,
|
||||||
display: flex;
|
.expanded-subtitle,
|
||||||
flex-direction: column;
|
.mini-title,
|
||||||
justify-content: center;
|
.time-text,
|
||||||
overflow: hidden;
|
.action-text,
|
||||||
|
.floating-label,
|
||||||
|
.floating-lang,
|
||||||
|
.expand-text {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-title {
|
.player-kicker {
|
||||||
font-size: 14px;
|
font-size: 17px;
|
||||||
font-weight: 500;
|
line-height: 23px;
|
||||||
color: var(--museum-text-primary);
|
font-weight: 900;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-title {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 37px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #ffffff;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-subtitle {
|
.expanded-subtitle {
|
||||||
font-size: 12px;
|
margin-top: 3px;
|
||||||
color: var(--museum-text-secondary);
|
font-size: 15px;
|
||||||
margin-top: 2px;
|
line-height: 21px;
|
||||||
|
color: var(--audio-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-subtitle.error {
|
.expanded-subtitle.error {
|
||||||
color: #9b3d2e;
|
color: #ffb0a2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-controls {
|
.expanded-side {
|
||||||
flex-shrink: 0;
|
grid-column: 1 / -1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-btn {
|
.control-btn {
|
||||||
@@ -643,60 +854,365 @@ defineExpose({
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.9);
|
transform: scale(0.92);
|
||||||
opacity: 0.8;
|
opacity: 0.84;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-btn {
|
.play-btn,
|
||||||
width: 40px;
|
.floating-play {
|
||||||
height: 40px;
|
flex-shrink: 0;
|
||||||
background-color: var(--museum-accent);
|
background: var(--audio-accent);
|
||||||
|
color: #050505;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: var(--museum-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.expanded-play {
|
||||||
width: 32px;
|
width: 64px;
|
||||||
height: 32px;
|
height: 64px;
|
||||||
color: var(--museum-text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.expanded-progress-row {
|
||||||
width: 100%;
|
position: relative;
|
||||||
padding: 0 16px 12px;
|
z-index: 1;
|
||||||
|
margin-top: 22px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 66px minmax(0, 1fr) 58px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-text {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.66);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-text.active {
|
||||||
|
color: var(--audio-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-track {
|
.progress-track {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 4px;
|
height: 5px;
|
||||||
background-color: var(--museum-border-light);
|
background: rgba(255, 255, 255, 0.24);
|
||||||
border-radius: 2px;
|
border-radius: 999px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
inset: 0 auto 0 0;
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: var(--museum-accent);
|
background: var(--audio-accent);
|
||||||
border-radius: 2px;
|
border-radius: inherit;
|
||||||
transition: width 0.1s linear;
|
transition: width 0.1s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-thumb {
|
.progress-thumb {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
width: 18px;
|
||||||
width: 12px;
|
height: 18px;
|
||||||
height: 12px;
|
background: var(--audio-accent);
|
||||||
background-color: var(--museum-accent);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.28);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-actions {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 15px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
min-width: 0;
|
||||||
|
height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-action {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 26px;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-text {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-player {
|
||||||
|
min-height: 116px;
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr) auto auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"play spacer language expand"
|
||||||
|
"copy copy copy copy"
|
||||||
|
"progress progress progress progress";
|
||||||
|
column-gap: 10px;
|
||||||
|
row-gap: 7px;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-play {
|
||||||
|
grid-area: play;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-copy {
|
||||||
|
grid-area: copy;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-copy .player-kicker {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 28px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #ffffff;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-progress {
|
||||||
|
grid-area: progress;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-progress .progress-track {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-language-state,
|
||||||
|
.player-language-switch {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 56px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(224, 225, 0, 0.52);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: var(--audio-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-language {
|
||||||
|
grid-area: language;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-language-switch {
|
||||||
|
min-width: 92px;
|
||||||
|
padding: 3px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-language-option {
|
||||||
|
min-width: 40px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-language-option.active {
|
||||||
|
background: var(--audio-accent);
|
||||||
|
color: #050505;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-language-option.pending {
|
||||||
|
background: rgba(224, 225, 0, 0.76);
|
||||||
|
color: #050505;
|
||||||
|
box-shadow: 0 0 0 1px rgba(224, 225, 0, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-language-option.disabled {
|
||||||
|
opacity: 0.52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-language-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-action {
|
||||||
|
grid-area: expand;
|
||||||
|
min-width: 70px;
|
||||||
|
height: 42px;
|
||||||
|
padding-left: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-text {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-player {
|
||||||
|
width: 116px;
|
||||||
|
height: 116px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-ring {
|
||||||
|
width: 116px;
|
||||||
|
height: 116px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-ring::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
conic-gradient(var(--audio-accent) 0 var(--progress), rgba(255, 255, 255, 0.24) var(--progress) 100%);
|
||||||
|
mask: radial-gradient(circle, transparent 65%, #000 66%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-play {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-label {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 7px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-lang {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-width: 46px;
|
||||||
|
height: 23px;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid rgba(224, 225, 0, 0.7);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--audio-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.audio-player:not(.mode-floating) {
|
||||||
|
width: 402px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 390px) {
|
||||||
|
.expanded-player {
|
||||||
|
padding-left: 18px;
|
||||||
|
padding-right: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-main {
|
||||||
|
grid-template-columns: 76px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-cover {
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-title {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-player {
|
||||||
|
min-height: 110px;
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
grid-template-columns: 50px minmax(0, 1fr) auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"play language expand"
|
||||||
|
"copy copy copy"
|
||||||
|
"progress progress progress";
|
||||||
|
column-gap: 8px;
|
||||||
|
row-gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-play {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-title {
|
||||||
|
font-size: 21px;
|
||||||
|
line-height: 27px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,21 +9,36 @@
|
|||||||
:playback-error="error"
|
:playback-error="error"
|
||||||
:current-time="currentTime"
|
:current-time="currentTime"
|
||||||
:duration-value="duration"
|
:duration-value="duration"
|
||||||
avoid-bottom-nav
|
:pending-language="pendingLanguage"
|
||||||
|
:avoid-bottom-nav="avoidBottomNav"
|
||||||
|
:mode="currentDisplayMode"
|
||||||
@toggle="handleToggle"
|
@toggle="handleToggle"
|
||||||
@close="player.close"
|
@collapse="handleCollapse"
|
||||||
|
@stop="handleStop"
|
||||||
@seek="player.seekToPercent"
|
@seek="player.seekToPercent"
|
||||||
@expand="handleExpand"
|
@expand="handleExpand"
|
||||||
|
@language-change="handleLanguageChange"
|
||||||
@update:visible="handleVisibleChange"
|
@update:visible="handleVisibleChange"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted } from 'vue'
|
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import AudioPlayer from '@/components/audio/AudioPlayer.vue'
|
import AudioPlayer, {
|
||||||
|
type AudioDisplayMode,
|
||||||
|
type AudioLanguage
|
||||||
|
} from '@/components/audio/AudioPlayer.vue'
|
||||||
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
|
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
avoidBottomNav?: boolean
|
||||||
|
displayMode?: AudioDisplayMode
|
||||||
|
}>(), {
|
||||||
|
avoidBottomNav: true,
|
||||||
|
displayMode: 'mini'
|
||||||
|
})
|
||||||
|
|
||||||
const player = useGlobalAudioPlayer()
|
const player = useGlobalAudioPlayer()
|
||||||
const {
|
const {
|
||||||
currentAudio,
|
currentAudio,
|
||||||
@@ -34,14 +49,14 @@ const {
|
|||||||
error,
|
error,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
activeHostId,
|
displayMode: currentDisplayMode,
|
||||||
|
pendingLanguage,
|
||||||
hasAudio
|
hasAudio
|
||||||
} = player
|
} = player
|
||||||
const hostId = player.registerHost()
|
const hostId = player.registerHost()
|
||||||
const isHostVisible = computed(() => (
|
const isHostVisible = computed(() => (
|
||||||
visible.value
|
visible.value
|
||||||
&& hasAudio.value
|
&& hasAudio.value
|
||||||
&& activeHostId.value === hostId
|
|
||||||
))
|
))
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
@@ -53,15 +68,56 @@ const handleToggle = () => {
|
|||||||
void player.resume()
|
void player.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyHostDisplayMode = () => {
|
||||||
|
if (!hasAudio.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDisplayMode.value !== props.displayMode) {
|
||||||
|
player.setDisplayMode(props.displayMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentH5Route = (route: string) => {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
|
||||||
|
return window.location.hash.replace(/^#/, '') === route
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCollapse = () => {
|
||||||
|
player.collapse(props.displayMode === 'floating' ? 'floating' : 'mini')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLanguageChange = (language: AudioLanguage) => {
|
||||||
|
void player.switchLanguage(language)
|
||||||
|
}
|
||||||
|
|
||||||
const handleVisibleChange = (visible: boolean) => {
|
const handleVisibleChange = (visible: boolean) => {
|
||||||
if (!visible) {
|
if (!visible && hasAudio.value) {
|
||||||
player.close()
|
player.collapse(props.displayMode === 'floating' ? 'floating' : 'mini')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExpand = () => {
|
const handleExpand = () => {
|
||||||
|
if (currentDisplayMode.value === 'floating') {
|
||||||
|
player.setDisplayMode('mini')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
player.setDisplayMode('expanded')
|
||||||
|
|
||||||
const route = currentSource.value?.detailRoute
|
const route = currentSource.value?.detailRoute
|
||||||
if (!route) return
|
if (!route) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCurrentH5Route(route)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: route
|
url: route
|
||||||
@@ -70,13 +126,26 @@ const handleExpand = () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
player.activateHost(hostId)
|
player.activateHost(hostId)
|
||||||
|
applyHostDisplayMode()
|
||||||
})
|
})
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
player.activateHost(hostId)
|
player.activateHost(hostId)
|
||||||
|
applyHostDisplayMode()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
player.unregisterHost(hostId)
|
player.unregisterHost(hostId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.displayMode,
|
||||||
|
() => {
|
||||||
|
applyHostDisplayMode()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(hasAudio, () => {
|
||||||
|
applyHostDisplayMode()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -32,13 +32,17 @@
|
|||||||
<view class="guide-page-frame-body">
|
<view class="guide-page-frame-body">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</view>
|
</view>
|
||||||
<GlobalAudioPlayerHost />
|
<GlobalAudioPlayerHost
|
||||||
|
:avoid-bottom-nav="audioPlayerAvoidBottomNav"
|
||||||
|
:display-mode="audioPlayerMode"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import GuideTopTabs from '@/components/navigation/GuideTopTabs.vue'
|
import GuideTopTabs from '@/components/navigation/GuideTopTabs.vue'
|
||||||
import GlobalAudioPlayerHost from '@/components/audio/GlobalAudioPlayerHost.vue'
|
import GlobalAudioPlayerHost from '@/components/audio/GlobalAudioPlayerHost.vue'
|
||||||
|
import type { AudioDisplayMode } from '@/components/audio/AudioPlayer.vue'
|
||||||
import type { GuideTopTab } from '@/utils/guideTopTabs'
|
import type { GuideTopTab } from '@/utils/guideTopTabs'
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
@@ -51,6 +55,8 @@ withDefaults(defineProps<{
|
|||||||
showCancel?: boolean
|
showCancel?: boolean
|
||||||
backLabel?: string
|
backLabel?: string
|
||||||
cancelLabel?: string
|
cancelLabel?: string
|
||||||
|
audioPlayerAvoidBottomNav?: boolean
|
||||||
|
audioPlayerMode?: AudioDisplayMode
|
||||||
}>(), {
|
}>(), {
|
||||||
activeTab: 'guide',
|
activeTab: 'guide',
|
||||||
variant: 'overlay',
|
variant: 'overlay',
|
||||||
@@ -60,7 +66,9 @@ withDefaults(defineProps<{
|
|||||||
showBack: false,
|
showBack: false,
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
backLabel: '返回',
|
backLabel: '返回',
|
||||||
cancelLabel: '取消'
|
cancelLabel: '取消',
|
||||||
|
audioPlayerAvoidBottomNav: true,
|
||||||
|
audioPlayerMode: 'mini'
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { AudioItem } from '@/components/audio/AudioPlayer.vue'
|
import type {
|
||||||
import type { AudioLanguage } from '@/repositories/AudioPlayInfoRepository'
|
AudioDisplayMode,
|
||||||
|
AudioItem
|
||||||
|
} from '@/components/audio/AudioPlayer.vue'
|
||||||
|
import {
|
||||||
|
audioPlayInfoRepository,
|
||||||
|
type AudioLanguage
|
||||||
|
} from '@/repositories/AudioPlayInfoRepository'
|
||||||
import type { AudioPlayTargetType } from '@/domain/museum'
|
import type { AudioPlayTargetType } from '@/domain/museum'
|
||||||
|
|
||||||
export interface GlobalAudioSource {
|
export interface GlobalAudioSource {
|
||||||
@@ -18,6 +24,7 @@ export interface GlobalAudioPlayOptions {
|
|||||||
source?: GlobalAudioSource
|
source?: GlobalAudioSource
|
||||||
retryOnError?: GlobalAudioRetryHandler
|
retryOnError?: GlobalAudioRetryHandler
|
||||||
retryAttempt?: boolean
|
retryAttempt?: boolean
|
||||||
|
displayMode?: AudioDisplayMode
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentAudio = ref<AudioItem | null>(null)
|
const currentAudio = ref<AudioItem | null>(null)
|
||||||
@@ -28,7 +35,11 @@ const loading = ref(false)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const currentTime = ref(0)
|
const currentTime = ref(0)
|
||||||
const duration = ref(0)
|
const duration = ref(0)
|
||||||
|
const displayMode = ref<AudioDisplayMode>('mini')
|
||||||
|
const pendingLanguage = ref<AudioLanguage | ''>('')
|
||||||
const activeHostId = ref('')
|
const activeHostId = ref('')
|
||||||
|
const lastClosedSource = ref<GlobalAudioSource | null>(null)
|
||||||
|
const closeVersion = ref(0)
|
||||||
|
|
||||||
let audioElement: HTMLAudioElement | null = null
|
let audioElement: HTMLAudioElement | null = null
|
||||||
let retryOnError: GlobalAudioRetryHandler | null = null
|
let retryOnError: GlobalAudioRetryHandler | null = null
|
||||||
@@ -110,6 +121,8 @@ const resetState = () => {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
currentTime.value = 0
|
currentTime.value = 0
|
||||||
duration.value = 0
|
duration.value = 0
|
||||||
|
displayMode.value = 'mini'
|
||||||
|
pendingLanguage.value = ''
|
||||||
retryOnError = null
|
retryOnError = null
|
||||||
retrying = false
|
retrying = false
|
||||||
retryUsed = false
|
retryUsed = false
|
||||||
@@ -134,9 +147,52 @@ const stopAudioElement = () => {
|
|||||||
resetStopping()
|
resetStopping()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stopPlayback = () => {
|
||||||
|
stopAudioElement()
|
||||||
|
playing.value = false
|
||||||
|
loading.value = false
|
||||||
|
pendingLanguage.value = ''
|
||||||
|
currentTime.value = 0
|
||||||
|
duration.value = currentAudio.value?.duration || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateDetailRouteLanguage = (route: string | undefined, lang: AudioLanguage) => {
|
||||||
|
if (!route) return route
|
||||||
|
|
||||||
|
const [path, rawQuery = ''] = route.split('?')
|
||||||
|
const params = new URLSearchParams(rawQuery)
|
||||||
|
params.set('lang', lang)
|
||||||
|
return `${path}?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSwitchedAudioItem = (
|
||||||
|
playInfo: Awaited<ReturnType<typeof audioPlayInfoRepository.getPlayInfo>>,
|
||||||
|
lang: AudioLanguage
|
||||||
|
): AudioItem => ({
|
||||||
|
id: `play-${playInfo.audioId || `${playInfo.targetType}-${playInfo.targetId}-${lang}`}`,
|
||||||
|
name: currentAudio.value?.name || currentSource.value?.title || playInfo.title || '讲解音频',
|
||||||
|
audioUrl: playInfo.playUrl || '',
|
||||||
|
image: currentAudio.value?.image,
|
||||||
|
duration: typeof playInfo.duration === 'number' ? playInfo.duration : currentAudio.value?.duration,
|
||||||
|
language: lang,
|
||||||
|
supportedLanguages: currentAudio.value?.supportedLanguages
|
||||||
|
})
|
||||||
|
|
||||||
const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => {
|
const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => {
|
||||||
if (!audio.audioUrl) {
|
if (!audio.audioUrl) {
|
||||||
handleError('当前语言暂无语音讲解。')
|
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 = '当前语言暂无语音讲解'
|
||||||
|
currentTime.value = 0
|
||||||
|
duration.value = 0
|
||||||
|
showToast(error.value)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +211,9 @@ const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => {
|
|||||||
currentSource.value = options.source || currentSource.value
|
currentSource.value = options.source || currentSource.value
|
||||||
retryOnError = options.retryOnError || retryOnError
|
retryOnError = options.retryOnError || retryOnError
|
||||||
visible.value = true
|
visible.value = true
|
||||||
|
if (options.displayMode) {
|
||||||
|
displayMode.value = options.displayMode
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
@@ -194,17 +253,122 @@ const resume = () => {
|
|||||||
if (!currentAudio.value) return Promise.resolve(false)
|
if (!currentAudio.value) return Promise.resolve(false)
|
||||||
return play(currentAudio.value, {
|
return play(currentAudio.value, {
|
||||||
source: currentSource.value || undefined,
|
source: currentSource.value || undefined,
|
||||||
retryOnError: retryOnError || undefined
|
retryOnError: retryOnError || undefined,
|
||||||
|
displayMode: displayMode.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const switchLanguage = async (lang: AudioLanguage) => {
|
||||||
|
if (pendingLanguage.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = currentSource.value
|
||||||
|
if (!source?.targetType || !source.targetId) {
|
||||||
|
error.value = '当前讲解暂不支持语言切换'
|
||||||
|
showToast(error.value)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAudio.value?.language === lang && source.lang === lang) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const preservedMode = displayMode.value
|
||||||
|
pendingLanguage.value = lang
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
const nextSource: GlobalAudioSource = {
|
||||||
|
...source,
|
||||||
|
lang,
|
||||||
|
detailRoute: updateDetailRouteLanguage(source.detailRoute, lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const playInfo = await audioPlayInfoRepository.getPlayInfo({
|
||||||
|
targetType: source.targetType,
|
||||||
|
targetId: source.targetId,
|
||||||
|
lang,
|
||||||
|
refresh: true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!playInfo.playable || !playInfo.playUrl) {
|
||||||
|
stopAudioElement()
|
||||||
|
currentSource.value = nextSource
|
||||||
|
currentAudio.value = currentAudio.value
|
||||||
|
? {
|
||||||
|
...currentAudio.value,
|
||||||
|
id: `unavailable-${source.targetType}-${source.targetId}-${lang}`,
|
||||||
|
audioUrl: '',
|
||||||
|
duration: 0,
|
||||||
|
language: lang
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
visible.value = Boolean(currentAudio.value)
|
||||||
|
displayMode.value = preservedMode
|
||||||
|
playing.value = false
|
||||||
|
loading.value = false
|
||||||
|
currentTime.value = 0
|
||||||
|
duration.value = 0
|
||||||
|
error.value = '当前语言暂无语音讲解'
|
||||||
|
showToast(error.value)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSource.value = nextSource
|
||||||
|
return await play(toSwitchedAudioItem(playInfo, lang), {
|
||||||
|
source: nextSource,
|
||||||
|
retryOnError: retryOnError || undefined,
|
||||||
|
displayMode: preservedMode
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('讲解音频语言切换失败:', err)
|
||||||
|
stopAudioElement()
|
||||||
|
currentSource.value = nextSource
|
||||||
|
if (currentAudio.value) {
|
||||||
|
currentAudio.value = {
|
||||||
|
...currentAudio.value,
|
||||||
|
id: `unavailable-${source.targetType}-${source.targetId}-${lang}`,
|
||||||
|
audioUrl: '',
|
||||||
|
duration: 0,
|
||||||
|
language: lang
|
||||||
|
}
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
displayMode.value = preservedMode
|
||||||
|
playing.value = false
|
||||||
|
loading.value = false
|
||||||
|
currentTime.value = 0
|
||||||
|
duration.value = 0
|
||||||
|
error.value = '当前语言暂无语音讲解'
|
||||||
|
showToast(error.value)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
pendingLanguage.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
|
stopPlayback()
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
lastClosedSource.value = currentSource.value ? { ...currentSource.value } : null
|
||||||
|
closeVersion.value += 1
|
||||||
stopAudioElement()
|
stopAudioElement()
|
||||||
resetState()
|
resetState()
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
const setDisplayMode = (mode: AudioDisplayMode) => {
|
||||||
stop()
|
displayMode.value = mode
|
||||||
|
if (currentAudio.value) {
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapse = (mode: AudioDisplayMode = 'mini') => {
|
||||||
|
setDisplayMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEnded() {
|
function handleEnded() {
|
||||||
@@ -230,13 +394,14 @@ async function handleError(message: string) {
|
|||||||
return play(retryAudio, {
|
return play(retryAudio, {
|
||||||
source: currentSource.value || undefined,
|
source: currentSource.value || undefined,
|
||||||
retryOnError: retryOnError || undefined,
|
retryOnError: retryOnError || undefined,
|
||||||
retryAttempt: true
|
retryAttempt: true,
|
||||||
|
displayMode: displayMode.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(message || '讲解音频播放失败')
|
showToast(message || '讲解音频播放失败')
|
||||||
stop()
|
stopPlayback()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,13 +452,20 @@ export const useGlobalAudioPlayer = () => ({
|
|||||||
error,
|
error,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
|
displayMode,
|
||||||
|
pendingLanguage,
|
||||||
activeHostId,
|
activeHostId,
|
||||||
|
lastClosedSource,
|
||||||
|
closeVersion,
|
||||||
hasAudio: computed(() => Boolean(currentAudio.value)),
|
hasAudio: computed(() => Boolean(currentAudio.value)),
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
resume,
|
resume,
|
||||||
stop,
|
stop,
|
||||||
close,
|
close,
|
||||||
|
setDisplayMode,
|
||||||
|
collapse,
|
||||||
|
switchLanguage,
|
||||||
handleEnded,
|
handleEnded,
|
||||||
handleError,
|
handleError,
|
||||||
seekToPercent,
|
seekToPercent,
|
||||||
|
|||||||
@@ -473,6 +473,7 @@ export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset
|
|||||||
const visibleHalls = visibleHallsFrom(dataset.halls)
|
const visibleHalls = visibleHallsFrom(dataset.halls)
|
||||||
const visibleOutlines = visibleOutlinesFrom(dataset.outlines)
|
const visibleOutlines = visibleOutlinesFrom(dataset.outlines)
|
||||||
const visibleStops = visibleStopsFrom(dataset.guideStops)
|
const visibleStops = visibleStopsFrom(dataset.guideStops)
|
||||||
|
const visibleExhibits = dataset.exhibits.filter(isVisibleRecord)
|
||||||
const visibleGuides = visibleGuidesFrom(dataset.guideContents)
|
const visibleGuides = visibleGuidesFrom(dataset.guideContents)
|
||||||
|
|
||||||
const hallById = new Map(visibleHalls.map((hall) => [stringifyId(hall.id), hall]))
|
const hallById = new Map(visibleHalls.map((hall) => [stringifyId(hall.id), hall]))
|
||||||
@@ -626,6 +627,10 @@ export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset
|
|||||||
const stop = guide.targetType?.toUpperCase() === 'STOP'
|
const stop = guide.targetType?.toUpperCase() === 'STOP'
|
||||||
? stopById.get(stringifyId(guide.targetId))
|
? stopById.get(stringifyId(guide.targetId))
|
||||||
: undefined
|
: undefined
|
||||||
|
const linkedExhibit = visibleExhibits.find((exhibit) => (
|
||||||
|
stringifyId(exhibit.id) === stringifyId(guide.exhibitId)
|
||||||
|
|| Boolean(stop?.id && stringifyId(exhibit.stopId) === stringifyId(stop.id))
|
||||||
|
))
|
||||||
const outline = outlineById.get(stringifyId(stop?.outlineId))
|
const outline = outlineById.get(stringifyId(stop?.outlineId))
|
||||||
const hall = findHallForStop(stop)
|
const hall = findHallForStop(stop)
|
||||||
const sourcePoiId = stringifyId(guide.poiId || stop?.poiId || hall?.poiId)
|
const sourcePoiId = stringifyId(guide.poiId || stop?.poiId || hall?.poiId)
|
||||||
@@ -644,7 +649,12 @@ export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset
|
|||||||
hallName: hall?.name || undefined,
|
hallName: hall?.name || undefined,
|
||||||
floorId,
|
floorId,
|
||||||
floorLabel: formatFloorLabel(floorId),
|
floorLabel: formatFloorLabel(floorId),
|
||||||
image: resolveImage(stop?.coverImageUrl, guide.mediaGallery),
|
image: resolveImage(
|
||||||
|
stop?.coverImageUrl,
|
||||||
|
linkedExhibit?.coverImageUrl,
|
||||||
|
linkedExhibit?.galleryUrls,
|
||||||
|
guide.mediaGallery
|
||||||
|
) || getExplainUnitPreviewImage(stringifyId(outline?.id)),
|
||||||
description: firstText(guideTextForLanguage(guide, 'zh-CN'), stop?.name),
|
description: firstText(guideTextForLanguage(guide, 'zh-CN'), stop?.name),
|
||||||
poiId: location?.poiId,
|
poiId: location?.poiId,
|
||||||
sourcePoiId,
|
sourcePoiId,
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export interface GuideStaticExplainDataset {
|
|||||||
halls: GuideStaticHallPayload[]
|
halls: GuideStaticHallPayload[]
|
||||||
outlines: GuideStaticOutlinePayload[]
|
outlines: GuideStaticOutlinePayload[]
|
||||||
guideStops: GuideStaticStopPayload[]
|
guideStops: GuideStaticStopPayload[]
|
||||||
|
exhibits: GuideStaticExhibitPayload[]
|
||||||
guideContents: GuideStaticContentPayload[]
|
guideContents: GuideStaticContentPayload[]
|
||||||
poiBridge: GuideStaticPoiBridgePayload
|
poiBridge: GuideStaticPoiBridgePayload
|
||||||
}
|
}
|
||||||
@@ -407,6 +408,7 @@ export const createStaticGuideDataProvider = (
|
|||||||
halls: datasetCache.halls,
|
halls: datasetCache.halls,
|
||||||
outlines: datasetCache.outlines,
|
outlines: datasetCache.outlines,
|
||||||
guideStops: datasetCache.guideStops,
|
guideStops: datasetCache.guideStops,
|
||||||
|
exhibits: datasetCache.exhibits,
|
||||||
guideContents: datasetCache.guideContents,
|
guideContents: datasetCache.guideContents,
|
||||||
poiBridge: datasetCache.poiBridge
|
poiBridge: datasetCache.poiBridge
|
||||||
}
|
}
|
||||||
@@ -417,9 +419,11 @@ export const createStaticGuideDataProvider = (
|
|||||||
explainDatasetRequest = provider.loadExplainNavigationDataset()
|
explainDatasetRequest = provider.loadExplainNavigationDataset()
|
||||||
.then(async (navigationDataset) => {
|
.then(async (navigationDataset) => {
|
||||||
const [
|
const [
|
||||||
|
exhibits,
|
||||||
guideContents,
|
guideContents,
|
||||||
poiBridge
|
poiBridge
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
|
requestJson<GuideStaticPackagePayload<GuideStaticExhibitPayload>>(provider.assetUrl(navigationDataset.manifest.files.exhibits)),
|
||||||
requestJson<GuideStaticPackagePayload<GuideStaticContentPayload>>(provider.assetUrl(navigationDataset.manifest.files.guideContents)),
|
requestJson<GuideStaticPackagePayload<GuideStaticContentPayload>>(provider.assetUrl(navigationDataset.manifest.files.guideContents)),
|
||||||
navigationDataset.manifest.files.poiBridge
|
navigationDataset.manifest.files.poiBridge
|
||||||
? requestJson<GuideStaticPoiBridgePayload>(provider.assetUrl(navigationDataset.manifest.files.poiBridge))
|
? requestJson<GuideStaticPoiBridgePayload>(provider.assetUrl(navigationDataset.manifest.files.poiBridge))
|
||||||
@@ -428,6 +432,7 @@ export const createStaticGuideDataProvider = (
|
|||||||
|
|
||||||
explainDatasetCache = {
|
explainDatasetCache = {
|
||||||
...navigationDataset,
|
...navigationDataset,
|
||||||
|
exhibits: exhibits.rows,
|
||||||
guideContents: guideContents.rows,
|
guideContents: guideContents.rows,
|
||||||
poiBridge
|
poiBridge
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
active-tab="guide"
|
active-tab="guide"
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
:show-top-tabs="false"
|
:show-top-tabs="false"
|
||||||
|
audio-player-mode="floating"
|
||||||
show-back
|
show-back
|
||||||
@tab-change="handleTopTabChange"
|
@tab-change="handleTopTabChange"
|
||||||
@back="handlePageBack"
|
@back="handlePageBack"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
:active-tab="currentTab"
|
:active-tab="currentTab"
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
:show-top-tabs="false"
|
:show-top-tabs="false"
|
||||||
|
:audio-player-mode="currentTab === 'guide' ? 'floating' : 'mini'"
|
||||||
@tab-change="handleTabChange"
|
@tab-change="handleTabChange"
|
||||||
>
|
>
|
||||||
<GuideMapShell
|
<GuideMapShell
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
active-tab="guide"
|
active-tab="guide"
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
:show-top-tabs="false"
|
:show-top-tabs="false"
|
||||||
|
audio-player-mode="floating"
|
||||||
show-back
|
show-back
|
||||||
:show-cancel="isManualLocationPanelOpen"
|
:show-cancel="isManualLocationPanelOpen"
|
||||||
@tab-change="handleTopTabChange"
|
@tab-change="handleTopTabChange"
|
||||||
|
|||||||
@@ -17,12 +17,17 @@
|
|||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
<GlobalAudioPlayerHost
|
||||||
|
display-mode="floating"
|
||||||
|
:avoid-bottom-nav="false"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import GlobalAudioPlayerHost from '@/components/audio/GlobalAudioPlayerHost.vue'
|
||||||
import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue'
|
import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue'
|
||||||
|
|
||||||
const initialKeyword = ref('')
|
const initialKeyword = ref('')
|
||||||
|
|||||||
@@ -191,7 +191,8 @@ export class ExplainUseCase {
|
|||||||
fallback?: MuseumExhibit | null
|
fallback?: MuseumExhibit | null
|
||||||
): MuseumExhibit {
|
): MuseumExhibit {
|
||||||
const linkedPrimary = stopInfo.linkedExhibits[0]
|
const linkedPrimary = stopInfo.linkedExhibits[0]
|
||||||
const coverImage = stopInfo.coverImageUrl || (stopInfo.imageStatus === 'READY' ? fallback?.image : undefined)
|
const linkedCoverImage = stopInfo.linkedExhibits.find((item) => item.coverImageUrl)?.coverImageUrl
|
||||||
|
const coverImage = stopInfo.coverImageUrl || linkedCoverImage || fallback?.image
|
||||||
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
|
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
|
||||||
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
|
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
|
||||||
const audioAvailable = stopInfo.audioStatus === 'READY'
|
const audioAvailable = stopInfo.audioStatus === 'READY'
|
||||||
|
|||||||
Reference in New Issue
Block a user