Files
BYW-VoiceHub/app/components/UI/LyricsModal.vue
T

2590 lines
68 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Teleport to="body">
<Transition :name="isMobile ? 'mobile-flow' : 'modal-animation'">
<div
v-if="isVisible"
class="lyrics-modal-overlay"
tabindex="-1"
style="--main-cover-color: 255, 255, 255"
@click="handleOverlayClick"
>
<div
ref="fullscreenContainer"
:class="{ leaving: isExiting }"
class="lyrics-fullscreen-container"
>
<!-- 动态背景 -->
<div ref="backgroundContainer" class="background-layer">
<div
v-if="!currentCoverUrl"
:class="{ dynamic: backgroundConfig.dynamic, visible: showBackgroundFallback }"
class="gradient-background"
/>
<div
v-else
ref="coverBlurContainer"
:class="{ visible: showBackgroundFallback }"
:style="{ backgroundImage: `url(${currentCoverUrl})` }"
class="cover-background"
/>
<!-- 叠加暗化层,提升白色背景下歌词对比度 -->
<div class="background-overlay" />
</div>
<!-- 关闭按钮 -->
<button class="close-button" @click="closeModal">
<Icon :name="isMobile ? 'chevron-down' : 'x'" size="24" />
</button>
<!-- 频谱可视化 (左侧边缘) -->
<canvas
v-if="!isMobile"
ref="spectrumCanvas"
class="spectrum-visualizer"
width="100"
height="800"
/>
<Transition name="comment-lyric-fade" mode="out-in">
<div
v-if="activePanel === 'comments' && currentLyricLine"
:key="currentLyricLine"
class="comment-current-lyric"
>
{{ currentLyricLine }}
</div>
</Transition>
<!-- 移动端浮动封面 (跨页动画) -->
<div
v-if="isMobile && currentSong"
ref="mobileFloatingCoverRef"
class="mobile-floating-cover"
>
<img
v-if="currentSong.cover"
:src="convertToHttps(currentSong.cover)"
class="cover-image"
referrerpolicy="no-referrer"
/>
<div v-else class="default-cover">
<Icon name="music" size="64" />
</div>
</div>
<!-- 主内容区域 -->
<div
ref="mainContent"
class="main-content"
:style="{
zIndex: isMobile && (showQualitySettings || showLyricSourceMenu) ? 101 : undefined
}"
@scroll="onMainContentScroll"
@click="handleOverlayClick"
>
<!-- 左侧区域:封面和歌曲信息 -->
<div class="left-column">
<!-- 桌面端显示,移动端隐藏(透明占位) -->
<div
class="album-cover-wrapper"
:style="{ opacity: isMobile ? 0 : 1, pointerEvents: isMobile ? 'none' : 'auto' }"
>
<div class="song-cover-container">
<div class="song-cover shadow-cover" :class="{ playing: isPlaying }">
<img
v-if="currentSong?.cover"
:alt="currentSong.title"
:src="convertToHttps(currentSong.cover)"
class="cover-image"
referrerpolicy="no-referrer"
@error="handleCoverError"
/>
<div v-else class="default-cover">
<Icon name="music" size="64" />
</div>
</div>
</div>
</div>
<div ref="pageOneInfoRef" class="song-info-container">
<div class="song-details">
<h1 class="song-title">
{{ currentSong?.title || locale.unknownSong }}
</h1>
<p class="song-artist">{{ currentSong?.artist || locale.unknownArtist }}</p>
<!-- 音质标识 (歌手名下方) -->
<div class="badge-row">
<div
v-if="currentSong?.musicPlatform"
class="mobile-quality-badge"
@click.stop="toggleQualityMenu"
>
{{ currentQualityText }}
<!-- 音质切换菜单 -->
<div v-if="showQualitySettings" class="badge-quality-menu" @click.stop>
<div
v-for="option in currentPlatformOptions"
:key="option.value"
class="badge-quality-option"
:class="{ active: isCurrentQuality(option.value) }"
@click="selectQuality(option.value)"
>
{{ option.label }}
</div>
</div>
</div>
<!-- 歌词来源标识 -->
<div
v-if="lyricSourceOptions.length > 0"
class="mobile-quality-badge lyric-source-badge"
@click.stop="toggleLyricSourceMenu"
>
<Icon name="lyrics" size="12" />
{{ currentLyricSourceLabel }}
<!-- 歌词来源切换菜单 -->
<div v-if="showLyricSourceMenu" class="badge-quality-menu" @click.stop>
<div
v-for="option in lyricSourceOptions"
:key="option.value"
class="badge-quality-option"
:class="{ active: isCurrentLyricSource(option.value) }"
@click="selectLyricSource(option.value)"
>
{{ option.label }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧区域:歌词和评论显示 -->
<div class="right-column" :class="{ 'comments-mode': activePanel === 'comments' }">
<!-- 移动端顶部迷你信息栏 -->
<div v-if="isMobile" class="mobile-lyric-header">
<div class="header-spacer" />
<!-- 为缩小后的封面留白 -->
<div ref="pageTwoInfoRef" class="mini-song-info">
<div class="mini-text-col">
<span class="mini-title">{{ currentSong?.title }}</span>
<span class="mini-artist">{{ currentSong?.artist }}</span>
</div>
</div>
</div>
<div v-if="canShowComments" class="content-switcher" @click.stop>
<button
class="switcher-button"
:class="{ active: activePanel === 'lyrics' }"
@click="activePanel = 'lyrics'"
>
<Icon name="lyrics" size="16" />
<span>{{ locale.lyrics }}</span>
</button>
<button
class="switcher-button"
:class="{ active: activePanel === 'comments' }"
@click="activePanel = 'comments'"
>
<Icon name="message-circle" size="16" />
<span>{{ locale.comments }} ({{ formatCompactCommentCount(commentsCount) }})</span>
</button>
</div>
<div v-show="activePanel === 'lyrics'" class="lyrics-display-area">
<div class="lyrics-container">
<!-- 集成 AMLyric 组件 (传入毫秒) -->
<AMLyric v-if="lyricSettings.useAMLyrics.value" :current-time="currentTime" />
<!-- 集成 DefaultLyric 组件 (传入秒) -->
<DefaultLyric v-else :current-time="currentTime" />
</div>
</div>
<div v-show="activePanel === 'comments'" class="comments-display-area">
<SongComments
ref="commentsRef"
:song="currentSong"
:visible="isVisible"
/>
</div>
<!-- 歌词设置工具栏 -->
<div v-if="activePanel === 'lyrics'" class="lyric-toolbar">
<Popover placement="top-end" :offset="12">
<template #trigger>
<div class="toolbar-btn" :title="locale.settings">
<Icon name="settings" size="20" />
</div>
</template>
<template #content>
<div class="lyric-settings-content">
<div class="setting-item switch">
<span class="label">{{ locale.amStyleLyrics }}</span>
<input v-model="lyricSettings.useAMLyrics.value" type="checkbox" />
</div>
<div class="setting-item">
<span class="label">{{ locale.fontSize }}</span>
<div class="control">
<button @click="lyricSettings.lyricFontSize.value -= 2">-</button>
<span>{{ lyricSettings.lyricFontSize.value }}</span>
<button @click="lyricSettings.lyricFontSize.value += 2">+</button>
</div>
</div>
<div class="setting-item">
<span class="label">{{ locale.lyricOffset }}</span>
<div class="control">
<button @click="lyricSettings.lyricOffset.value -= 100">-</button>
<span>{{ lyricSettings.lyricOffset.value }}</span>
<button @click="lyricSettings.lyricOffset.value += 100">+</button>
</div>
</div>
<div v-if="!lyricSettings.useAMLyrics.value" class="setting-item switch">
<span class="label">{{ locale.showTranslation }}</span>
<input v-model="lyricSettings.showTranslation.value" type="checkbox" />
</div>
<div v-if="!lyricSettings.useAMLyrics.value" class="setting-item switch">
<span class="label">{{ locale.showRoma }}</span>
<input v-model="lyricSettings.showRoma.value" type="checkbox" />
</div>
<div v-if="!lyricSettings.useAMLyrics.value" class="setting-item switch">
<span class="label">{{ locale.showYrc }}</span>
<input v-model="lyricSettings.showYrc.value" type="checkbox" />
</div>
<template v-if="lyricSettings.useAMLyrics.value">
<div class="setting-divider" />
<div class="setting-item switch">
<span class="label">{{ locale.amllNormalizeSpaces }}</span>
<input
v-model="lyricSettings.amllNormalizeSpaces.value"
type="checkbox"
/>
</div>
<div class="setting-item switch">
<span class="label">{{ locale.amllResetLineTimestamps }}</span>
<input
v-model="lyricSettings.amllResetLineTimestamps.value"
type="checkbox"
/>
</div>
<div class="setting-item switch">
<span class="label">{{ locale.amllConvertBgLines }}</span>
<input v-model="lyricSettings.amllConvertBgLines.value" type="checkbox" />
</div>
<div class="setting-item switch">
<span class="label">{{ locale.amllSyncBgLines }}</span>
<input v-model="lyricSettings.amllSyncBgLines.value" type="checkbox" />
</div>
<div class="setting-item switch">
<span class="label">{{ locale.amllCleanOverlaps }}</span>
<input v-model="lyricSettings.amllCleanOverlaps.value" type="checkbox" />
</div>
<div class="setting-item switch">
<span class="label">{{ locale.amllTryAdvanceStart }}</span>
<input
v-model="lyricSettings.amllTryAdvanceStart.value"
type="checkbox"
/>
</div>
</template>
</div>
</template>
</Popover>
</div>
</div>
</div>
<!-- 移动端分页指示器 -->
<div class="mobile-pagination-dots">
<span
class="dot"
:class="{ active: currentMobilePage === 0 }"
@click="scrollToPage(0)"
/>
<span
class="dot"
:class="{ active: currentMobilePage === 1 }"
@click="scrollToPage(1)"
/>
</div>
<!-- 播放控制栏 -->
<div
class="playback-controls"
:class="{ 'mobile-hidden': isMobile && currentMobilePage === 1 }"
>
<div class="control-buttons" @touchstart.stop @touchmove.stop @touchend.stop>
<div class="left-control">
<button
class="control-btn secondary-btn"
:class="{ active: playMode !== 'off' }"
:title="playModeTitle"
@click="cyclePlayMode"
>
<Icon :name="playModeIcon" size="20" />
</button>
</div>
<div class="center-control">
<button :disabled="!hasPrevious" class="control-btn" @click="previousSong">
<Icon name="skip-back" size="28" />
</button>
<button class="play-pause-btn" @click="togglePlayPause">
<AppSpinner v-if="isLoadingTrack" :size="32" color="white" />
<Icon v-else :name="isPlaying ? 'pause' : 'play'" size="32" />
</button>
<button :disabled="!hasNext" class="control-btn" @click="nextSong">
<Icon name="skip-forward" size="28" />
</button>
</div>
<div class="right-control">
<VolumeControl />
</div>
</div>
<!-- 进度条 -->
<div class="progress-section" @touchstart.stop @touchmove.stop @touchend.stop>
<span class="time-display">{{ formatTime(currentTime) }}</span>
<div
ref="progressBar"
class="progress-bar"
@click="handleProgressClick"
@mousedown="handleProgressMouseDown"
@touchend="handleProgressTouchEnd"
@touchmove="handleProgressTouchMove"
@touchstart="handleProgressTouchStart"
>
<div :style="{ width: `${progressPercentage}%` }" class="progress-fill" />
<div :style="{ left: `${progressPercentage}%` }" class="progress-thumb" />
</div>
<span class="time-display">{{ formatTime(duration) }}</span>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, nextTick, onUnmounted, ref, watch, onMounted } from 'vue'
import { useAudioPlayer } from '~/composables/useAudioPlayer'
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
import { useLyricSettings } from '~/composables/useLyricSettings'
import { useLyricManager } from '~/composables/useLyricManager'
import { useBackgroundRenderer } from '~/composables/useBackgroundRenderer'
import Icon from '~/components/UI/Icon.vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import { useAudioQuality } from '~/composables/useAudioQuality'
import { useAudioPlayerEnhanced } from '~/composables/useAudioPlayerEnhanced'
import { useAudioVisualizer } from '~/composables/useAudioVisualizer'
import { convertToHttps } from '~/utils/url'
import AMLyric from '~/components/Player/PlayerLyric/AMLyric.vue'
import DefaultLyric from '~/components/Player/PlayerLyric/DefaultLyric.vue'
import Popover from '~/components/UI/Common/Popover.vue'
import VolumeControl from '~/components/UI/AudioPlayer/VolumeControl.vue'
import SongComments from '~/components/UI/SongComments.vue'
import { useLocale } from '~/utils/locale'
const props = defineProps({
isVisible: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close'])
// 音频播放器状态
const audioPlayer = useAudioPlayer()
const audioPlayerControl = useAudioPlayerControl()
const lyricSettings = useLyricSettings()
const { ui } = useLocale()
const locale = computed(() => ui.value?.lyricsModal || {})
// 背景渲染器
const backgroundRenderer = useBackgroundRenderer()
const audioVisualizer = useAudioVisualizer()
// 响应式状态
const showQualitySettings = ref(false)
const showLyricSourceMenu = ref(false)
const progressBar = ref(null)
const backgroundContainer = ref(null)
const coverBlurContainer = ref(null)
const spectrumCanvas = ref(null)
const progressUpdateTimer = ref(null)
const fullscreenContainer = ref(null)
const isExiting = ref(false)
const mainContent = ref(null)
const currentMobilePage = ref(0)
const scrollProgress = ref(0)
const isMobile = ref(false)
const hasPushedHistory = ref(false)
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 375)
const windowHeight = ref(typeof window !== 'undefined' ? window.innerHeight : 812)
const activePanel = ref('lyrics')
const commentsRef = ref(null)
const commentsCount = computed(() => Number(commentsRef.value?.totalCount || 0))
const commentCountUnit = computed(() => ui.value?.songComments?.tenThousand || '万')
const formatCompactCommentCount = (count) => {
const value = Number(count) || 0
if (value < 10000) return String(value)
const compact = (value / 10000).toFixed(value >= 100000 ? 0 : 1).replace(/\.0$/, '')
return `${compact}${commentCountUnit.value}`
}
// 拖拽状态管理
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartTime = ref(0)
// 定时器
const resizeTimer = ref(null)
// 播放状态
const currentSong = computed(() => audioPlayer.getCurrentSong().value)
const isPlaying = computed(() => audioPlayer.getPlayingStatus().value)
const playMode = computed(() => audioPlayerControl.playMode.value)
const isLoadingTrack = computed(() => audioPlayerControl.isLoadingTrack.value)
const currentTime = computed(() => audioPlayer.getCurrentPosition().value)
const duration = computed(() => audioPlayer.getDuration().value)
const { getQuality, getQualityLabel, getQualityOptions, saveQuality } = useAudioQuality()
const enhanced = useAudioPlayerEnhanced()
const lyricManager = useLyricManager()
const currentCoverUrl = computed(() =>
currentSong.value?.cover ? convertToHttps(currentSong.value.cover) : ''
)
const hasCurrentLyrics = computed(() => {
const lyrics = audioPlayerControl.lyrics.currentLyrics.value
return Array.isArray(lyrics) && lyrics.length > 0
})
const showBackgroundFallback = computed(
() => !backgroundRenderer.isInitialized.value || backgroundRenderer.hasRenderError.value
)
const currentLyricLine = computed(() => {
const index = audioPlayerControl.lyrics.currentLyricIndex.value
const lyric = audioPlayerControl.lyrics.currentLyrics.value?.[index]
return lyric?.content?.trim() || ''
})
const canShowComments = computed(() => {
const song = currentSong.value
if (!song || !['netease', 'tencent'].includes(song.musicPlatform)) return false
return /^\d+$/.test(String(song.musicId || '').trim()) || song.musicPlatform === 'tencent'
})
const currentQualityText = computed(() => {
const platform = currentSong.value?.musicPlatform
if (!platform) return locale.value.quality
const quality = getQuality(platform)
const label = getQualityLabel(platform, quality)
return label.replace(/音质|音乐|quality|music/gi, '').trim() || locale.value.quality
})
// 监听音质变化,强制更新显示
watch(
() => {
const platform = currentSong.value?.musicPlatform
return platform ? getQuality(platform) : null
},
() => {
// 触发响应式更新
nextTick()
},
{ deep: true }
)
const currentPlatformOptions = computed(() => {
const platform = currentSong.value?.musicPlatform
if (!platform) return []
return getQualityOptions(platform)
})
const isCurrentQuality = (qualityValue) => {
const platform = currentSong.value?.musicPlatform
if (!platform) return false
return getQuality(platform) === qualityValue
}
const selectQuality = async (qualityValue) => {
if (!currentSong.value || !currentSong.value.musicPlatform) return
if (isCurrentQuality(qualityValue)) {
showQualitySettings.value = false
return
}
const result = await enhanced.enhancedQualitySwitch(currentSong.value, qualityValue)
if (result.success) {
saveQuality(currentSong.value.musicPlatform, qualityValue)
const updatedSong = {
...currentSong.value,
musicUrl: result.url
}
if (audioPlayer.updateCurrentSong) {
audioPlayer.updateCurrentSong(updatedSong)
}
showQualitySettings.value = false
}
}
// ─── 歌词来源切换 ────────────────────────────────────────────
const lyricPriority = lyricSettings.lyricPriority
// 菜单选项:按当前歌曲平台只展示真实可用的来源
// netease → NCM 官方 / VK 第三方 / AMLL;tencent → QM / AMLL;其他平台无可选来源
const lyricSourceOptions = computed(() => {
const platform = currentSong.value?.musicPlatform
if (platform === 'netease') {
return [
{ value: 'auto', label: locale.value.lyricSourceAuto },
{ value: 'official', label: 'NCM' },
{ value: 'qm', label: 'Vkeys' },
{ value: 'ttml', label: 'AMLL' }
]
}
if (platform === 'tencent') {
return [
{ value: 'auto', label: locale.value.lyricSourceAuto },
{ value: 'qm', label: 'QM' },
{ value: 'ttml', label: 'AMLL' }
]
}
return []
})
// 徽章显示当前歌词实际命中的来源与格式,便于定位问题源
const currentLyricSourceLabel = computed(() => {
const source = lyricManager.lyricSource.value
if (!source) return locale.value.lyricSource
const sourceMap = {
official: 'NCM',
qm: 'QM',
vkeys: 'Vkeys',
amll: 'AMLL',
meting: 'Meting'
}
const formatMap = {
ttml: 'TTML',
qrc: 'QRC',
'word-by-word': 'YRC',
line: 'LRC'
}
const sourceText = sourceMap[source] || ''
const formatText = formatMap[lyricManager.lyricFormat.value] || ''
return formatText ? `${sourceText}·${formatText}` : sourceText
})
// 与音质菜单互斥:同一时间只展开一个
const toggleQualityMenu = () => {
showQualitySettings.value = !showQualitySettings.value
if (showQualitySettings.value) {
showLyricSourceMenu.value = false
}
}
const toggleLyricSourceMenu = () => {
showLyricSourceMenu.value = !showLyricSourceMenu.value
if (showLyricSourceMenu.value) {
showQualitySettings.value = false
}
}
// 当前选中的来源(与音质切换行为一致,菜单项高亮)
const isCurrentLyricSource = (sourceValue) => lyricPriority.value === sourceValue
const selectLyricSource = async (sourceValue) => {
showLyricSourceMenu.value = false
if (lyricPriority.value === sourceValue) return
lyricPriority.value = sourceValue
// 强制重新拉取当前歌曲歌词(priority 已参与缓存键,切换后不会命中旧缓存)
const song = currentSong.value
if (!song) return
const trackId = song.id?.toString()
if (!trackId) return
await lyricManager.fetchLyric(song, { force: true })
}
// 移动端状态与动画
const mobileFloatingCoverRef = ref(null)
const pageOneInfoRef = ref(null)
const pageTwoInfoRef = ref(null)
let animationFrameId = null
// 缓存布局参数
let cachedLayout = {
width: 0,
height: 0,
contentWidth: 0,
startTop: 0,
realStartLeft: 0,
totalTranslateX: 0,
totalTranslateY: 0,
targetScale: 1,
startSize: 280
}
const updateLayoutCache = () => {
if (typeof window === 'undefined') return
const w = window.innerWidth
const h = window.innerHeight
const startSize = 280
const startTop = h * 0.15
const realStartLeft = (w - startSize) / 2
const endSize = 48
const endTop = 50
const endLeft = 24
cachedLayout = {
width: w,
height: h,
contentWidth: mainContent.value ? mainContent.value.clientWidth : w,
startTop,
realStartLeft,
totalTranslateX: endLeft - realStartLeft,
totalTranslateY: endTop - startTop,
targetScale: endSize / startSize,
startSize
}
if (mobileFloatingCoverRef.value) {
const el = mobileFloatingCoverRef.value
el.style.width = `${startSize}px`
el.style.height = `${startSize}px`
el.style.top = `${startTop}px`
el.style.left = `${realStartLeft}px`
el.style.transformOrigin = '0 0'
el.style.zIndex = '100'
el.style.borderRadius = '12px'
el.style.boxShadow = '0 16px 36px var(--lyrics-modal-enter-shadow)'
// 确保硬件加速
el.style.transform = 'translate3d(0, 0, 0)'
el.style.willChange = 'transform, border-radius'
}
if (isMobile.value) {
requestAnimationFrame(() => {
const p = currentScrollProgress || 0
updateMobileAnimations(p * cachedLayout.contentWidth, cachedLayout.contentWidth)
})
}
}
const clearResponsiveInlineStyles = () => {
const elements = [pageOneInfoRef.value, pageTwoInfoRef.value, mobileFloatingCoverRef.value]
for (const el of elements) {
if (!el) continue
el.style.removeProperty('opacity')
el.style.removeProperty('transform')
el.style.removeProperty('pointer-events')
}
if (mainContent.value) {
mainContent.value.scrollLeft = 0
}
}
const resetMobileLayoutState = async () => {
currentMobilePage.value = 0
scrollProgress.value = 0
currentScrollProgress = 0
await nextTick()
if (mainContent.value) {
mainContent.value.scrollLeft = 0
}
updateLayoutCache()
requestAnimationFrame(() => {
updateMobileAnimations(0, cachedLayout.contentWidth || window.innerWidth)
})
}
const updateMobileState = async () => {
if (typeof window === 'undefined') return
const newIsMobile = window.innerWidth <= 1024
const modeChanged = newIsMobile !== isMobile.value
if (modeChanged) {
isMobile.value = newIsMobile
lyricSettings.adjustFontSizeForDevice()
if (newIsMobile) {
await resetMobileLayoutState()
} else {
clearResponsiveInlineStyles()
currentMobilePage.value = 0
scrollProgress.value = 0
currentScrollProgress = 0
}
return
}
if (newIsMobile) {
if (!cachedLayout.contentWidth) {
await resetMobileLayoutState()
} else {
updateLayoutCache()
}
}
}
let currentScrollProgress = 0
const updateMobileAnimations = (scrollLeft, width) => {
let p = scrollLeft / width
if (p < 0) p = 0
if (p > 1) p = 1
currentScrollProgress = p
if (mobileFloatingCoverRef.value) {
const { totalTranslateX, totalTranslateY, targetScale } = cachedLayout
const currentScale = 1 + (targetScale - 1) * p
const currentTranslateX = totalTranslateX * p
const currentTranslateY = totalTranslateY * p
const el = mobileFloatingCoverRef.value
const targetVisualRadius = 12 - 6 * p
const radius = targetVisualRadius / currentScale
// 使用 transform 和 will-change 优化性能
el.style.transform = `translate3d(${currentTranslateX}px, ${currentTranslateY}px, 0) scale(${currentScale})`
el.style.borderRadius = `${radius}px`
el.style.opacity = '1'
el.style.pointerEvents = p > 0.8 ? 'none' : 'auto'
}
if (pageOneInfoRef.value) {
const el = pageOneInfoRef.value
const opacity = Math.max(0, 1 - p * 2.5)
const translateY = p * -20
// 使用 transform 优化性能
el.style.opacity = String(opacity)
el.style.transform = `translate3d(0, ${translateY}px, 0)`
}
if (pageTwoInfoRef.value) {
const el = pageTwoInfoRef.value
const opacity = Math.max(0, (p - 0.6) * 2.5)
const translateY = (1 - p) * 10
// 使用 transform 优化性能
el.style.opacity = String(opacity)
el.style.transform = `translate3d(0, ${translateY}px, 0)`
el.style.pointerEvents = opacity > 0.5 ? 'auto' : 'none'
}
}
const onMainContentScroll = (event) => {
if (!isMobile.value) return
const el = event.target
const scrollLeft = el.scrollLeft
if (!cachedLayout.contentWidth) {
updateLayoutCache()
}
const width = cachedLayout.contentWidth || el.clientWidth
const newPage = Math.round(scrollLeft / width)
if (currentMobilePage.value !== newPage) {
currentMobilePage.value = newPage
}
// 使用 requestAnimationFrame 优化性能,避免频繁更新
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
animationFrameId = requestAnimationFrame(() => {
updateMobileAnimations(scrollLeft, width)
})
}
const scrollToPage = (pageIndex) => {
if (!mainContent.value) return
const width = mainContent.value.clientWidth
mainContent.value.scrollTo({
left: pageIndex * width,
behavior: 'smooth'
})
}
// 播放列表状态
const hasPrevious = computed(() => audioPlayer.hasPrevious.value)
const hasNext = computed(() => audioPlayer.hasNext.value)
const progressPercentage = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
// 背景配置
const backgroundConfig = ref({
type: 'gradient',
dynamic: true,
flowSpeed: 2,
fps: 30,
renderScale: 0.5
})
const syncBackgroundState = async () => {
await backgroundRenderer.updateConfig({
...backgroundConfig.value,
hasLyric: hasCurrentLyrics.value
})
await backgroundRenderer.setCoverBackground(currentCoverUrl.value)
if (isPlaying.value) {
backgroundRenderer.resumeRender()
} else {
backgroundRenderer.pauseRender()
}
}
// 定义窗口大小变化处理函数(防抖优化)
const handleResize = () => {
if (resizeTimer.value) {
clearTimeout(resizeTimer.value)
}
resizeTimer.value = setTimeout(() => {
if (typeof window !== 'undefined') {
windowWidth.value = window.innerWidth
windowHeight.value = window.innerHeight
}
updateMobileState()
}, 150) // 增加防抖延迟以减少频繁触发
}
onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('resize', handleResize)
updateMobileState()
// 初始化时调整字体大小
lyricSettings.adjustFontSizeForDevice()
}
})
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', handleResize)
}
})
const handleKeydown = (event) => {
if (!props.isVisible) return
switch (event.key) {
case 'Escape':
event.preventDefault()
closeModal()
break
case ' ':
// 如果焦点在按钮上,忽略此自定义处理,让原生按钮点击生效,防止重复触发
if (document.activeElement && document.activeElement.tagName.toLowerCase() === 'button') {
return
}
event.preventDefault()
togglePlayPause()
break
case 'ArrowLeft':
event.preventDefault()
previousSong()
break
case 'ArrowRight':
event.preventDefault()
nextSong()
break
}
}
const togglePlayPause = async () => {
await audioPlayerControl.togglePlay()
}
const previousSong = () => {
audioPlayer.playPrevious()
}
const nextSong = () => {
audioPlayer.playNext()
}
const playModeIcon = computed(() => {
switch (audioPlayerControl.playMode.value) {
case 'loopOne':
return 'repeat-one'
case 'order':
return 'order'
case 'off':
default:
return 'play-circle'
}
})
const playModeTitle = computed(() => {
switch (audioPlayerControl.playMode.value) {
case 'loopOne':
return locale.value.playMode.loopOne
case 'order':
return locale.value.playMode.order
case 'off':
default:
return locale.value.playMode.off
}
})
const cyclePlayMode = () => {
const current = audioPlayerControl.playMode.value
if (current === 'order') {
audioPlayerControl.setPlayMode('loopOne')
if (window.$showNotification) window.$showNotification(locale.value.notifications.loopOne, 'info')
} else if (current === 'loopOne') {
audioPlayerControl.setPlayMode('off')
if (window.$showNotification) window.$showNotification(locale.value.notifications.off, 'info')
} else {
audioPlayerControl.setPlayMode('order')
if (window.$showNotification) window.$showNotification(locale.value.notifications.order, 'info')
}
}
const handleProgressClick = (event) => {
if (!progressBar.value || isDragging.value) return
const rect = progressBar.value.getBoundingClientRect()
const clickX = event.clientX - rect.left
const percentage = clickX / rect.width
const newTime = percentage * duration.value
const audioElements = document.querySelectorAll('audio')
for (const audio of audioElements) {
if (audio.src && (audio.currentTime > 0 || !audio.paused)) {
const audioElement = audio
audioElement.currentTime = newTime
audioPlayer.setPosition(newTime)
break
}
}
}
const handleProgressTouchStart = (event) => {
// 阻止冒泡,防止触发外层滚动(如翻页)
event.stopPropagation()
isDragging.value = true
dragStartX.value = event.touches[0].clientX
dragStartTime.value = currentTime.value
document.addEventListener('touchmove', handleProgressTouchMove, { passive: false })
}
const handleProgressTouchEnd = () => {
isDragging.value = false
document.removeEventListener('touchmove', handleProgressTouchMove)
}
const calculateTimeFromClientX = (clientX) => {
if (!progressBar.value || duration.value === 0) return 0
const rect = progressBar.value.getBoundingClientRect()
const x = Math.min(Math.max(clientX - rect.left, 0), rect.width)
const percentage = x / rect.width
return Math.max(0, Math.min(percentage * duration.value, duration.value))
}
const seekToTime = (newTime) => {
const audioElements = document.querySelectorAll('audio')
for (const audio of audioElements) {
if (audio.src && (audio.currentTime > 0 || !audio.paused)) {
audio.currentTime = newTime
audioPlayer.setPosition(newTime)
break
}
}
}
const handleProgressMouseDown = (event) => {
isDragging.value = true
const newTime = calculateTimeFromClientX(event.clientX)
seekToTime(newTime)
document.addEventListener('mousemove', handleProgressMouseMove)
document.addEventListener('mouseup', handleProgressMouseUp)
}
const handleProgressMouseMove = (event) => {
if (!isDragging.value) return
const newTime = calculateTimeFromClientX(event.clientX)
seekToTime(newTime)
}
const handleProgressMouseUp = () => {
if (!isDragging.value) return
isDragging.value = false
document.removeEventListener('mousemove', handleProgressMouseMove)
document.removeEventListener('mouseup', handleProgressMouseUp)
}
const handleProgressTouchMove = (event) => {
if (!isDragging.value) return
if (event.touches && event.touches[0]) {
event.preventDefault()
const newTime = calculateTimeFromClientX(event.touches[0].clientX)
seekToTime(newTime)
}
}
const formatTime = (seconds) => {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
const handleCoverError = (event) => {
const img = event.target
img.style.display = 'none'
}
const handlePopState = () => {
if (props.isVisible) {
hasPushedHistory.value = false
emit('close')
}
}
const closeModal = () => {
if (isExiting.value) return
if (isMobile.value && hasPushedHistory.value) {
history.back()
return
}
isExiting.value = true
setTimeout(() => {
emit('close')
isExiting.value = false
}, 300)
}
const handleOverlayClick = (event) => {
if (showQualitySettings.value || showLyricSourceMenu.value) {
showQualitySettings.value = false
showLyricSourceMenu.value = false
if (event.target.classList.contains('lyrics-modal-overlay')) {
closeModal()
}
return
}
if (event.target.classList.contains('lyrics-modal-overlay')) {
closeModal()
}
}
const startProgressTimer = () => {
if (progressUpdateTimer.value) return
progressUpdateTimer.value = setInterval(() => {
if (!props.isVisible) return
const audioElements = document.querySelectorAll('audio')
let actualCurrentTime = 0
let actualDuration = 0
for (const audio of audioElements) {
if (audio.src && (audio.currentTime > 0 || !audio.paused)) {
actualCurrentTime = audio.currentTime
actualDuration = audio.duration || 0
break
}
}
if (actualCurrentTime > 0) {
audioPlayer.updatePosition(actualCurrentTime)
if (duration.value === 0 && actualDuration > 0) {
audioPlayer.setDuration(actualDuration)
}
}
}, 80)
}
const stopProgressTimer = () => {
if (progressUpdateTimer.value) {
clearInterval(progressUpdateTimer.value)
progressUpdateTimer.value = null
}
}
const disablePageScroll = () => {
document.body.style.overflow = 'hidden'
document.documentElement.style.overflow = 'hidden'
document.documentElement.style.setProperty('overscroll-behavior', 'none')
}
const restorePageScroll = () => {
document.body.style.overflow = ''
document.documentElement.style.overflow = ''
document.documentElement.style.removeProperty('overscroll-behavior')
}
watch(
() => props.isVisible,
async (visible) => {
if (visible) {
activePanel.value = 'lyrics'
disablePageScroll()
await nextTick()
// 播放进度同步优先启动,不等待背景(封面经代理加载可能较慢)
if (isPlaying.value) {
startProgressTimer()
}
// 背景渲染异步初始化,避免阻塞播放基础功能
if (backgroundContainer.value) {
void (async () => {
await backgroundRenderer.initializeBackground(backgroundContainer.value)
if (!props.isVisible) return
backgroundRenderer.setCoverBlurElement(coverBlurContainer.value)
await syncBackgroundState()
backgroundRenderer.startRender()
if (!isPlaying.value) {
backgroundRenderer.pauseRender()
}
})()
}
// 歌词由 LyricManager 自动监听 currentTrack 变化并获取,这里不需要手动 fetch
// 但如果首次打开且没有歌词,可以触发一次检查(Manager 已有 immediate watch)
document.addEventListener('keydown', handleKeydown)
await updateMobileState()
if (isMobile.value) {
await resetMobileLayoutState()
} else {
clearResponsiveInlineStyles()
}
} else {
restorePageScroll()
stopProgressTimer()
backgroundRenderer.dispose()
document.removeEventListener('keydown', handleKeydown)
// 清理动画帧
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
}
}
)
watch(
backgroundConfig,
async (newConfig) => {
if (props.isVisible) {
await backgroundRenderer.updateConfig({
...newConfig,
hasLyric: hasCurrentLyrics.value
})
}
},
{ deep: true }
)
watch(currentCoverUrl, async (coverUrl) => {
if (!props.isVisible) return
await backgroundRenderer.setCoverBackground(coverUrl)
})
watch(isPlaying, (playing) => {
if (!props.isVisible) return
if (playing) {
startProgressTimer()
backgroundRenderer.startRender()
} else {
stopProgressTimer()
backgroundRenderer.pauseRender()
}
})
watch(hasCurrentLyrics, async (hasLyric) => {
if (!props.isVisible) return
await backgroundRenderer.updateConfig({ hasLyric })
})
watch(canShowComments, (available) => {
if (!available && activePanel.value === 'comments') {
activePanel.value = 'lyrics'
}
})
const startAnimationLoop = () => {
const frame = () => {
if (!props.isVisible) return
drawSpectrum()
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)
}
// 从 rgba/rgb 颜色字符串中提取 R/G/B 并替换为新的 alpha
const extractColorWithAlpha = (colorStr, alpha) => {
const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
if (!match) return colorStr
const [, r, g, b] = match
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
const drawSpectrum = () => {
if (!spectrumCanvas.value || !audioVisualizer.isInitialized.value) return
const canvas = spectrumCanvas.value
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
const data = audioVisualizer.getFrequencyData()
if (data.length === 0) return
ctx.clearRect(0, 0, width, height)
const barCount = 40
const barHeight = height / barCount / 1.5
const gap = 4
const usableHeight = height - barCount * gap
const blockH = usableHeight / barCount
const step = Math.floor(data.length / 2 / barCount)
// 获取当前主题下 overlay 基准色(Canvas 无法直接使用 CSS 变量)
const style = getComputedStyle(document.documentElement)
const overlayBase = style.getPropertyValue('--overlay-20').trim() || 'rgba(255, 255, 255, 0.2)'
for (let i = 0; i < barCount; i++) {
const dataIndex = i * step
const value = data[dataIndex] || 0
const percent = value / 255
const barW = width * percent * 0.8
const y = height - i * (blockH + gap) - 100
if (y < 0) break
// 根据 percent (0~1) 在 0.2~0.7 之间动态计算 alpha
const dynamicAlpha = 0.2 + percent * 0.5
ctx.fillStyle = extractColorWithAlpha(overlayBase, dynamicAlpha)
ctx.beginPath()
ctx.roundRect(0, y, Math.max(4, barW), blockH, 4)
ctx.fill()
}
}
watch(
() => props.isVisible,
async (visible) => {
if (visible) {
if (isMobile.value) {
history.pushState({ modal: 'lyrics' }, '')
hasPushedHistory.value = true
window.addEventListener('popstate', handlePopState)
}
// 只对支持 CORS 的音频初始化可视化器
// 咪咕音乐等不支持 CORS 的音源不应该初始化可视化器
const audioElements = document.querySelectorAll('audio')
for (const audio of audioElements) {
if (audio.src && audio.crossOrigin === 'anonymous') {
// 只有当音频元素已设置 crossOrigin 时才初始化可视化器
// 这表示音频源支持 CORS(如网易云、QQ音乐)
// 咪咕音乐不支持 CORS,audio.crossOrigin 为空
audioVisualizer.initialize(audio)
break
}
}
startAnimationLoop()
} else {
if (hasPushedHistory.value) {
history.back()
hasPushedHistory.value = false
}
window.removeEventListener('popstate', handlePopState)
}
}
)
onUnmounted(() => {
stopProgressTimer()
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('mousemove', handleProgressMouseMove)
document.removeEventListener('mouseup', handleProgressMouseUp)
document.removeEventListener('touchmove', handleProgressTouchMove)
document.body.style.overflow = ''
// 清理动画帧
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
// 清理定时器
if (resizeTimer.value) {
clearTimeout(resizeTimer.value)
resizeTimer.value = null
}
})
</script>
<style scoped>
.lyrics-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: var(--mask-95);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.lyrics-fullscreen-container {
position: relative;
width: 100%;
height: 100%;
background: var(--bg-primary);
color: var(--lyrics-modal-text);
overflow: hidden;
display: flex;
flex-direction: column;
font-family: 'SF Pro Display', 'PingFang SC', system-ui, sans-serif;
}
/* 背景 */
.background-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.gradient-background {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
opacity: 0;
background:
radial-gradient(circle at 30% 30%, var(--lyrics-modal-bg-accent-blue), transparent 34%),
radial-gradient(circle at 70% 40%, var(--lyrics-modal-bg-accent-pink), transparent 32%),
linear-gradient(135deg, var(--lyrics-modal-bg-base-start) 0%, var(--lyrics-modal-bg-base-end) 100%);
background-size: 180% 180%;
transition: opacity 0.5s ease;
}
.gradient-background.visible {
opacity: 1;
z-index: 1;
}
.gradient-background.dynamic {
animation: gradientFlow 15s ease infinite;
}
@keyframes gradientFlow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.cover-background {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
background-size: cover;
background-position: center;
filter: blur(60px) brightness(0.4) saturate(1.4);
transform: scale(1.3);
opacity: 0;
transition:
background-image 0.8s ease-in-out,
opacity 0.5s ease;
}
.cover-background.visible {
opacity: 1;
z-index: 1;
}
.background-overlay {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, var(--mask-10) 0%, var(--mask-50) 100%);
z-index: 1;
}
/* 关闭按钮 */
.close-button {
position: absolute;
top: 2rem;
right: 2rem;
z-index: 50;
background: var(--lyrics-modal-surface);
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: var(--lyrics-modal-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.spectrum-visualizer {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100px;
height: 100%;
z-index: 5;
pointer-events: none;
opacity: 0.6;
}
.song-cover-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.close-button:hover {
background: var(--lyrics-modal-surface-strong);
transform: scale(1.05);
color: var(--lyrics-modal-text);
}
/* 主内容布局 */
.main-content {
position: relative;
z-index: 50;
flex: 0 0 calc(100% - 120px);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 4rem 6rem;
gap: 6rem;
height: calc(100% - 120px);
min-height: 0;
box-sizing: border-box;
pointer-events: none;
}
/* 左侧栏 */
.left-column {
flex: 0 0 45%;
max-width: 500px;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 2.5rem;
padding-left: 3rem;
pointer-events: auto;
}
.album-cover-wrapper {
width: 100%;
max-width: 380px;
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
}
.song-cover {
width: 100%;
height: 100%;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 16px 32px var(--mask-40);
transform: scale(0.85);
transition:
transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.6s ease;
position: relative;
}
.song-cover.playing {
transform: scale(1);
box-shadow: 0 24px 48px var(--mask-60);
}
.song-cover:hover {
transform: scale(1.02);
}
.song-cover.playing:hover {
transform: scale(1.02);
}
.song-cover:not(.playing):hover {
transform: scale(0.88);
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.default-cover {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--text-primary), var(--text-disabled));
display: flex;
align-items: center;
justify-content: center;
color: var(--lyrics-modal-text-secondary);
}
.song-info-container {
width: 100%;
text-align: center;
}
.song-title {
font-size: 2.2rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
color: var(--lyrics-modal-text);
text-shadow: 0 1px 8px var(--mask-30);
line-height: 1.2;
letter-spacing: -0.02em;
white-space: normal;
overflow: visible;
text-align: center;
width: 100%;
}
.song-artist {
font-size: 1.4rem;
color: var(--lyrics-modal-text-secondary);
text-shadow: 0 1px 6px var(--mask-30);
font-weight: 500;
margin: 0;
letter-spacing: -0.01em;
}
/* 右侧栏 */
.right-column {
flex: 1;
height: 100%;
min-height: 0;
pointer-events: auto;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
padding-top: 48px;
box-sizing: border-box;
}
.right-column:not(.comments-mode) {
padding-top: 0;
}
.lyrics-display-area {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
black 15%,
black 85%,
transparent 100%
);
}
.lyrics-container {
width: 100%;
height: 100%;
}
.content-switcher {
position: absolute;
top: 0;
right: 0;
z-index: 110;
align-self: flex-end;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid var(--lyrics-modal-surface-border);
border-radius: 8px;
background: var(--lyrics-modal-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.switcher-button {
height: 32px;
padding: 0 10px;
border: 0;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--lyrics-modal-text-muted);
background: transparent;
cursor: pointer;
font-size: 0.82rem;
font-weight: 700;
transition: all 0.2s ease;
}
.switcher-button:hover {
color: var(--lyrics-modal-text);
}
.switcher-button.active {
color: var(--lyrics-modal-text);
background: var(--lyrics-modal-surface-strong);
}
.comments-display-area {
width: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
}
.comment-current-lyric {
position: absolute;
top: 2rem;
left: 50%;
z-index: 120;
max-width: min(760px, calc(100vw - 220px));
transform: translateX(-50%);
padding: 0.55rem 1rem;
border-radius: 8px;
color: var(--lyrics-modal-text);
background: var(--mask-18);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
font-size: 1.05rem;
font-weight: 700;
line-height: 1.45;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.comment-lyric-fade-enter-active,
.comment-lyric-fade-leave-active {
transition:
opacity 0.35s ease,
filter 0.35s ease;
}
.comment-lyric-fade-enter-from,
.comment-lyric-fade-leave-to {
opacity: 0;
filter: blur(6px);
}
.comment-lyric-fade-enter-to,
.comment-lyric-fade-leave-from {
opacity: 1;
filter: blur(0);
}
.playback-controls {
position: relative;
z-index: 20;
flex: 0 0 120px;
width: 100%;
min-height: 120px;
padding: 1rem 4rem 2.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.progress-section {
display: flex;
align-items: center;
gap: 1.2rem;
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.time-display {
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
color: var(--lyrics-modal-text-secondary);
min-width: 45px;
text-align: center;
font-weight: 500;
flex-shrink: 0;
}
.progress-bar {
flex: 1;
min-width: 0;
height: 5px;
background: var(--lyrics-modal-track);
border-radius: 3px;
position: relative;
cursor: pointer;
transition: height 0.2s ease;
}
.progress-bar:hover {
height: 5px;
}
.progress-fill {
height: 100%;
background: var(--lyrics-modal-text);
border-radius: 3px;
pointer-events: none;
}
.progress-thumb {
position: absolute;
top: 50%;
width: 14px;
height: 14px;
background: var(--lyrics-modal-text);
border-radius: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform 0.1s ease;
pointer-events: none;
box-shadow: 0 2px 5px var(--mask-30);
}
.progress-bar:hover .progress-thumb {
transform: translate(-50%, -50%) scale(1);
}
.control-buttons {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 800px;
margin: 0 auto;
position: relative;
}
.center-control {
display: flex;
align-items: center;
gap: 2.5rem;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.left-control,
.right-control {
width: 44px;
display: flex;
justify-content: center;
z-index: 10;
}
.control-btn {
background: transparent;
border: none;
color: var(--lyrics-modal-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.control-btn:hover {
color: var(--lyrics-modal-text);
transform: scale(1.1);
}
.control-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
transform: none;
}
.secondary-btn {
opacity: 0.6;
transition: all 0.2s;
}
.secondary-btn.active {
opacity: 1;
color: var(--lyrics-modal-text);
text-shadow: 0 0 8px var(--overlay-40);
}
.play-pause-btn {
background: transparent;
color: white;
width: auto;
height: auto;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.play-pause-btn:hover {
transform: scale(1.1);
}
/* 歌词工具栏 */
.lyric-toolbar {
position: absolute;
bottom: 2rem;
right: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.toolbar-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--lyrics-modal-surface);
color: var(--lyrics-modal-text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
}
.toolbar-btn:hover {
background: var(--lyrics-modal-surface-strong);
color: var(--lyrics-modal-text);
}
.lyric-settings-content {
width: 240px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
color: var(--text-primary);
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-secondary);
font-size: 0.9rem;
}
.setting-item .control {
display: flex;
align-items: center;
gap: 8px;
background: var(--text-primary-10);
border-radius: 6px;
padding: 2px;
}
.setting-item button {
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.setting-item button:hover {
background: var(--text-primary-10);
border-radius: 4px;
}
.setting-item input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: var(--color-error);
}
.setting-divider {
height: 1px;
background: var(--overlay-20);
}
/* 音质菜单动画 */
.badge-quality-menu {
position: absolute;
bottom: 100%; /* 向上弹出 */
top: auto;
left: 50%;
transform: translateX(-50%) scale(0.9);
margin-bottom: 12px; /* 底部间距 */
margin-top: 0;
background: var(--lyrics-modal-badge-menu-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 12px;
padding: 4px;
min-width: 100px;
box-shadow: 0 10px 40px var(--mask-20);
border: 1px solid var(--overlay-40);
z-index: 99999; /* 提高层级 */
display: flex;
flex-direction: column;
gap: 2px;
opacity: 0;
transform-origin: bottom center; /* 动画原点设为底部 */
animation: menu-pop-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
max-height: 240px;
overflow-y: auto;
}
@keyframes menu-pop-in {
0% {
opacity: 0;
transform: translateX(-50%) scale(0.9) translateY(10px);
}
100% {
opacity: 1;
transform: translateX(-50%) scale(1) translateY(0);
}
}
@keyframes menu-pop-up {
0% {
opacity: 0;
transform: translateX(-50%) scale(0.9) translateY(10px);
}
100% {
opacity: 1;
transform: translateX(-50%) scale(1) translateY(0);
}
}
.badge-quality-option {
width: 100%;
text-align: center;
background: transparent;
color: var(--lyrics-modal-badge-menu-text); /* 浅色菜单底上的深色文字 */
padding: 8px 12px; /* 减小内边距 */
border-radius: 8px;
font-size: 0.85rem; /* 稍微减小字体 */
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
font-weight: 500;
user-select: none; /* 禁止选择 */
-webkit-user-select: none;
}
.badge-quality-option:hover {
background: var(--mask-5);
}
.badge-quality-option.active {
color: var(--color-accent); /* 蓝色字 */
background: var(--lyrics-modal-badge-menu-active-bg);
font-weight: 600;
box-shadow: 0 2px 8px var(--mask-8);
}
.mobile-pagination-dots {
display: none; /* 默认隐藏 */
position: absolute;
bottom: calc(12px + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
gap: 6px;
z-index: 100;
pointer-events: none;
align-items: center;
justify-content: center;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--lyrics-modal-track);
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: auto;
}
.dot.active {
width: 20px;
height: 5px;
border-radius: 3px;
background: var(--lyrics-modal-text);
box-shadow: 0 0 10px var(--overlay-30);
}
.mobile-quality-badge {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
margin-top: 12px;
background: var(--lyrics-modal-surface);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 6px;
color: var(--lyrics-modal-text);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--lyrics-modal-surface-border);
transition: all 0.2s ease;
user-select: none; /* 禁止选择 */
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent; /* 移除点击高亮 */
}
.mobile-quality-badge:active {
background: var(--lyrics-modal-surface-strong);
transform: scale(0.96);
}
/* 音质与歌词来源徽章并排 */
.badge-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.lyric-source-badge {
gap: 4px;
font-weight: 500;
}
.lyric-source-badge .badge-quality-menu {
min-width: 110px;
}
/* 响应式 */
@media (max-width: 1024px) {
.main-content {
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
padding: 0;
gap: 0;
width: 100vw;
height: 100%;
overflow-x: scroll;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
position: absolute;
top: 0;
left: 0;
/* 优化滚动性能 */
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
/* Safari 特定修复 */
-webkit-scroll-snap-type: x mandatory;
/* 移动端必须启用 pointer-events 才能滚动 */
pointer-events: auto;
z-index: 70; /* 确保子元素(如设置按钮)在播控栏之上 */
}
.main-content::-webkit-scrollbar {
display: none;
}
.left-column {
flex: 0 0 100vw;
flex-shrink: 0;
width: 100vw;
height: 100%;
scroll-snap-align: start;
flex-direction: column;
justify-content: flex-start;
align-items: center;
text-align: center;
gap: 2.5rem;
padding: 15vh 2rem 160px;
box-sizing: border-box;
max-width: none;
overflow: hidden;
/* 优化渲染性能 */
will-change: transform;
transform: translateZ(0);
-webkit-transform: translateZ(0);
/* Safari 触摸优化 */
-webkit-scroll-snap-align: start;
/* 确保可以接收触摸事件用于滑动 */
pointer-events: auto;
}
.right-column {
flex: 0 0 100vw;
flex-shrink: 0;
width: 100vw;
height: 100%;
scroll-snap-align: start;
padding: 100px 1.5rem 80px;
overflow-y: hidden;
box-sizing: border-box;
mask-image: none;
-webkit-mask-image: none;
position: relative;
display: flex;
flex-direction: column;
/* 优化渲染性能 */
will-change: transform;
transform: translateZ(0);
-webkit-transform: translateZ(0);
/* Safari 触摸优化 */
-webkit-scroll-snap-align: start;
/* 确保可以接收触摸事件用于滑动 */
pointer-events: auto;
}
.right-column.comments-mode {
padding-top: 116px;
}
.content-switcher {
top: calc(94px + env(safe-area-inset-top));
right: 1.5rem;
left: auto;
transform: none;
max-width: calc(100vw - 24px);
}
.lyrics-display-area {
width: 100%;
height: 100%;
overflow: hidden; /* 移除 auto,交给 AMLyric 内部滚动 */
mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
black 15%,
black 85%,
transparent 100%
);
}
.album-cover-wrapper {
width: 280px;
height: 280px;
}
.song-info-container {
text-align: center;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
flex: initial;
opacity: 1;
transform: none;
/* 优化动画性能 */
will-change: opacity, transform;
transform: translateZ(0);
-webkit-transform: translateZ(0);
}
.song-title {
font-size: 2rem;
margin-bottom: 0.5rem;
display: block;
max-width: 100%;
padding: 0 1rem;
box-sizing: border-box;
white-space: normal;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
.song-artist {
font-size: 1.3rem;
}
.mobile-pagination-dots {
display: flex; /* 仅在移动端显示 */
}
.close-button {
top: 54px;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
width: auto;
height: auto;
padding: 8px;
z-index: 200;
}
.playback-controls {
position: fixed;
bottom: 0;
left: 0;
flex: initial;
min-height: 0;
width: 100%;
padding: 2rem 1.5rem calc(2.5rem + env(safe-area-inset-bottom));
gap: 2rem;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-sizing: border-box;
z-index: 80; /* 提高层级,确保高于 .main-content (70) */
display: flex;
flex-direction: column-reverse;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
/* 容器本身不拦截事件,允许滑动穿透 */
pointer-events: none;
}
.playback-controls > * {
/* 子元素(按钮和进度条)拦截点击 */
pointer-events: auto;
}
.playback-controls.mobile-hidden {
opacity: 0;
pointer-events: none;
transform: translateY(40px) scale(0.95);
filter: blur(10px);
/* 完全隐藏,不可见也不可交互 */
visibility: hidden;
}
.playback-controls.mobile-hidden > * {
/* 隐藏时子元素也不可交互 */
pointer-events: none;
}
.playback-controls.mobile-hidden {
opacity: 0;
pointer-events: none;
transform: translateY(40px) scale(0.95);
filter: blur(10px);
}
.progress-section {
gap: 0.8rem;
width: 100%;
margin-bottom: 0.5rem;
}
.progress-bar {
height: 4px;
background: var(--lyrics-modal-track);
}
.progress-fill {
background: var(--lyrics-modal-text);
}
.progress-thumb {
width: 10px;
height: 10px;
display: none; /* 移动端通常不需要 thumb,或者很小 */
}
.time-display {
font-size: 0.75rem;
color: var(--lyrics-modal-text-secondary);
}
.control-buttons {
justify-content: space-between;
width: 100%;
padding: 0;
gap: 0;
}
.center-control {
gap: 30px;
}
.control-btn {
color: var(--lyrics-modal-text-secondary);
/* 增加点击区域 */
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.play-pause-btn {
background: var(--lyrics-modal-surface);
width: 64px;
height: 64px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
/* 确保 z-index 高于可能覆盖的层 */
position: relative;
z-index: 10;
}
.play-pause-btn:active {
transform: scale(0.92);
background: var(--lyrics-modal-surface-strong);
}
.badge-quality-menu {
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: 12px;
transform-origin: bottom center;
animation: menu-pop-up 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.lyric-toolbar {
bottom: calc(130px + env(safe-area-inset-bottom));
right: 1.5rem;
left: auto;
transform: none;
z-index: 100;
}
.toolbar-btn {
width: 36px;
height: 36px;
background: var(--lyrics-modal-surface);
}
.comment-current-lyric {
top: 54px;
max-width: calc(100vw - 96px);
padding: 0.45rem 0.8rem;
font-size: 0.95rem;
}
.mobile-lyric-header {
position: absolute;
top: 50px;
left: 0;
width: 100%;
padding: 0 70px 0 84px;
pointer-events: none;
z-index: 50;
box-sizing: border-box;
display: flex;
align-items: center;
height: 48px;
/* 优化动画性能 */
will-change: opacity, transform;
transform: translateZ(0);
-webkit-transform: translateZ(0);
}
.mini-song-info {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
width: 100%;
overflow: hidden;
/* 优化渲染 */
transform: translateZ(0);
-webkit-transform: translateZ(0);
}
.mini-text-col {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
overflow: hidden;
gap: 2px;
}
.mini-title {
font-size: 1.05rem;
font-weight: 600;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.01em;
text-shadow: 0 1px 4px var(--mask-30);
line-height: 1.2;
display: block;
width: 100%;
}
.mini-artist {
font-size: 0.85rem;
color: var(--lyrics-modal-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
line-height: 1.2;
display: block;
width: 100%;
}
}
@media (max-width: 768px) {
.album-cover-wrapper {
width: 260px;
height: 260px;
}
.song-title {
font-size: 1.8rem;
}
}
/* 过渡动画 */
.modal-animation-enter-active,
.modal-animation-leave-active {
transition: all 0.4s cubic-bezier(0.32, 0.72, 0, 1);
}
.modal-animation-enter-from,
.modal-animation-leave-to {
opacity: 0;
transform: translateY(40px) scale(0.98);
}
/* 移动端更有层次感的 flow 动画 */
.mobile-flow-enter-active,
.mobile-flow-leave-active {
transition:
opacity 0.5s cubic-bezier(0.2, 0, 0.2, 1),
transform 0.5s cubic-bezier(0.2, 0, 0.2, 1),
filter 0.5s cubic-bezier(0.2, 0, 0.2, 1);
}
.mobile-flow-enter-from,
.mobile-flow-leave-to {
opacity: 0;
transform: scale(0.92) translateY(30px);
filter: blur(10px);
}
.mobile-floating-cover {
position: absolute;
overflow: hidden;
pointer-events: none;
will-change: transform, border-radius;
/* 启用硬件加速 */
transform: translateZ(0);
-webkit-transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
perspective: 1000px;
-webkit-perspective: 1000px;
}
.mobile-floating-cover img {
width: 100%;
height: 100%;
object-fit: cover;
/* 优化图片渲染 */
transform: translateZ(0);
-webkit-transform: translateZ(0);
}
/* 音量按钮:共享组件在深色播放页上强制亮色图标 */
.right-control :deep(.volume-btn) {
color: var(--lyrics-modal-text-secondary);
}
.right-control :deep(.volume-btn:hover),
.right-control :deep(.volume-btn.active) {
color: var(--lyrics-modal-text);
}
</style>