2590 lines
68 KiB
Vue
2590 lines
68 KiB
Vue
<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>
|