music / src /components /player /DesktopFullPlayer.vue
ahutchen's picture
feat(player): 优化播放逻辑并添加状态过期处理
25e7862
<template>
<div class="desktop-full-player">
<!-- 悬浮控制按钮 -->
<button class="floating-back-btn" @click="handleBack">
<i class="fas fa-arrow-left"></i>
</button>
<!-- 主内容区域:左右分栏 -->
<main class="player-main">
<!-- 左侧:音乐播放区域 -->
<section class="left-panel">
<!-- 更多操作按钮 -->
<button class="panel-more-btn" @click="showMoreActions = true" v-if="currentSong">
<i class="fas fa-ellipsis-h"></i>
</button>
<!-- 专辑封面 -->
<div class="album-cover-section">
<div class="album-cover-wrapper">
<img
:src="currentCover"
:alt="currentSong?.name"
class="album-cover"
:class="{ rotating: isPlaying }"
@error="handleImageError"
/>
<!-- CD中心点 -->
<div class="cd-center">
<div class="center-dot"></div>
</div>
</div>
</div>
<!-- 歌曲信息 -->
<div class="song-info-section">
<div class="song-title-row">
<h2 class="song-title">{{ currentSong?.name || '暂无播放' }}</h2>
<button
class="action-button favorite-btn"
:class="{ active: isFavorite }"
@click="toggleFavorite"
v-if="currentSong"
>
<i :class="isFavorite ? 'fas fa-heart' : 'far fa-heart'"></i>
</button>
</div>
<p class="artist-name">{{ formattedArtist }}</p>
<p class="album-name" v-if="currentSong?.album">{{ currentSong.album }}</p>
</div>
<!-- 播放进度 -->
<div class="progress-section">
<ProgressBar @seek="handleSeek"/>
</div>
<!-- 播放控制 -->
<div class="controls-section">
<PlayControls
:loading="loading"
@togglePlay="handleTogglePlay"
@previous="handlePrevious"
@next="handleNext"
@togglePlayMode="handleTogglePlayMode"
@showPlaylist="handleShowPlaylist"
/>
</div>
</section>
<!-- 右侧:实时歌词区域 -->
<section class="right-panel">
<div class="lyrics-content">
<div class="lyrics-scroll-container">
<LyricsView
:lyrics="currentLyrics"
:currentTime="currentTime"
@seekTo="handleSeekTo"
/>
</div>
</div>
</section>
</main>
<!-- 音质选择面板 -->
<div class="quality-selector" v-if="showQualitySelector" @click.self="showQualitySelector = false">
<div class="quality-panel" @click.stop>
<div class="panel-header">
<h3>音质选择</h3>
<button class="close-btn" @click.stop="showQualitySelector = false">
<i class="fas fa-times"></i>
</button>
</div>
<div class="quality-list">
<div
v-for="quality in availableQualities"
:key="quality.value"
class="quality-item"
:class="{
'active': quality.value === currentQuality,
'loading': qualityLoading && selectedQuality === quality.value
}"
@click.stop="selectQuality(quality.value)"
>
<div class="quality-main">
<div class="quality-name">{{ quality.name }}</div>
<div class="quality-desc">{{ quality.description }}</div>
</div>
<div class="quality-actions">
<div class="quality-size" v-if="quality.size">{{ quality.size }}</div>
<i v-if="quality.value === currentQuality" class="fas fa-check quality-check"></i>
<i v-else-if="qualityLoading && selectedQuality === quality.value" class="fas fa-spinner fa-spin"></i>
</div>
</div>
</div>
<div class="quality-note">
<i class="fas fa-info-circle"></i>
<span>更高音质需要更多流量,请在WiFi环境下使用</span>
</div>
</div>
</div>
<!-- 播放列表面板 -->
<PlaylistPanel
v-if="showPlaylist"
@close="showPlaylist = false"
@play="handlePlayFromList"
@remove="handleRemoveFromList"
/>
<!-- 更多操作面板 -->
<MoreActionsPanel
v-if="showMoreActions"
:song="currentSong"
@close="showMoreActions = false"
@action="handleMoreAction"
/>
</div>
</template>
<script setup>
import {ref, computed, onMounted, onUnmounted, watch} from 'vue'
import {useRouter} from 'vue-router'
import {usePlayerStore} from '@/stores/player'
import {usePlayQueueStore} from '@/stores/playqueue'
import {useFavoritesStore} from '@/stores/favorites'
import {useHistoryStore} from '@/stores/history'
import {useToastStore} from '@/stores/toast'
import {useSettingsStore} from '@/stores/settings'
import {musicApi, utils} from '@/services/musicApi'
import ProgressBar from '@/components/player/ProgressBar.vue'
import PlayControls from '@/components/player/PlayControls.vue'
import LyricsView from '@/components/player/LyricsView.vue'
import PlaylistPanel from '@/components/player/PlaylistPanel.vue'
import MoreActionsPanel from '@/components/player/MoreActionsPanel.vue'
// 定义事件发送器
const emit = defineEmits(['close'])
const router = useRouter()
const playerStore = usePlayerStore()
const playQueueStore = usePlayQueueStore()
const favoritesStore = useFavoritesStore()
const historyStore = useHistoryStore()
const toastStore = useToastStore()
const settingsStore = useSettingsStore()
// 响应式数据
const loading = ref(false)
const showPlaylist = ref(false)
const showMoreActions = ref(false)
const showQualitySelector = ref(false)
const currentLyrics = ref([])
const qualityLoading = ref(false)
const selectedQuality = ref(null)
const currentCoverUrl = ref('')
// 音质配置
const availableQualities = ref([
{
value: 128,
name: '标准',
description: '128K 省流模式',
size: '约3MB/首'
},
{
value: 192,
name: '较高',
description: '192K 均衡模式',
size: '约4MB/首'
},
{
value: 320,
name: 'HQ',
description: '320K 高品质',
size: '约7MB/首'
},
{
value: 740,
name: '无损',
description: 'FLAC 原音质',
size: '约30MB/首'
},
{
value: 999,
name: 'Hi-Res',
description: '高解析度音频',
size: '约60MB/首'
}
])
// 计算属性
const currentSong = computed(() => playerStore.currentSong)
const isPlaying = computed(() => playerStore.isPlaying)
const currentTime = computed(() => playerStore.currentTime)
const currentQuality = computed(() => settingsStore.settings.defaultQuality || 320)
const isFavorite = computed(() => {
return currentSong.value ? favoritesStore.isFavorite(currentSong.value) : false
})
const formattedArtist = computed(() => {
if (!currentSong.value?.artist) return ''
return utils.formatArtist(currentSong.value.artist)
})
const currentCover = computed(() => {
// 如果有设置的封面URL,优先使用
if (currentCoverUrl.value) {
return currentCoverUrl.value
}
if (!currentSong.value) return defaultCover.value
// 优先使用缓存的封面
const cachedCover = playerStore.getCachedCover(currentSong.value, 300)
if (cachedCover) {
return cachedCover
}
return defaultCover.value
})
const defaultCover = computed(() => {
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjgwIiBoZWlnaHQ9IjI4MCIgdmlld0JveD0iMCAwIDI4MCAyODAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyODAiIGhlaWdodD0iMjgwIiBmaWxsPSJyZ2JhKDIwMCwyMDAsMjAwLDAuMykiIHJ4PSI0MCIvPgo8Y2lyY2xlIGN4PSIxNDAiIGN5PSIxNDAiIHI9IjgwIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiLz4KPHN2ZyB4PSIxMTAiIHk9IjExMCIgd2lkdGg9IjYwIiBoZWlnaHQ9IjYwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9InJnYmEoMTYwLDE2MCwxNjAsMC42KSI+CjxwYXRoIGQ9Ik0xMiAzdjEwLjU1Yy0uNTktLjM0LTEuMjctLjU1LTItLjU1QzcuNzkgMTMgNiAxNC43OSA2IDE3czEuNzkgNCA0IDRDMS45NCAwIDMuNS0xLjI5IDMuOTEtM0gxNFYzeiIvPgo8L3N2Zz4KPC9zdmc+'
})
// 异步加载专辑封面
const loadAlbumCover = async () => {
if (!currentSong.value) {
currentCoverUrl.value = defaultCover.value
return
}
try {
// 先使用缓存的封面
const cachedCover = playerStore.getCachedCover(currentSong.value, 300)
if (cachedCover) {
currentCoverUrl.value = cachedCover
return
}
const coverUrlResult = await playerStore.getAlbumCover(currentSong.value, 300)
if (coverUrlResult) {
currentCoverUrl.value = coverUrlResult
} else {
currentCoverUrl.value = defaultCover.value
}
} catch (error) {
console.error('加载专辑封面失败:', error)
currentCoverUrl.value = defaultCover.value
}
}
// 图片加载错误处理
const handleImageError = () => {
currentCoverUrl.value = defaultCover.value
}
// 方法
const handleBack = () => {
emit('close')
}
const handleSeek = (time) => {
playerStore.seekTo(time)
}
const handleSeekTo = (time) => {
playerStore.seekTo(time)
}
const handleTogglePlay = async () => {
const audioElement = playerStore.audioElement || document.querySelector('audio')
if (!audioElement || !currentSong.value) {
return
}
try {
if (isPlaying.value) {
audioElement.pause()
playerStore.setPlayingState(false)
} else {
if (!playerStore.audioSrc || !audioElement.src || audioElement.src === location.href) {
window.dispatchEvent(new CustomEvent('loadAndPlaySong', {
detail: {song: currentSong.value}
}))
return
}
if (audioElement.src !== playerStore.audioSrc) {
audioElement.src = playerStore.audioSrc
audioElement.load()
}
await audioElement.play()
playerStore.setPlayingState(true)
}
} catch (error) {
console.error('播放失败:', error)
}
}
const handlePrevious = () => {
const result = playQueueStore.playPrevious()
if (result) {
// 强制从头开始播放上一首歌曲
playerStore.setCurrentTime(0)
playerStore.playSong(result)
}
}
const handleNext = () => {
const result = playQueueStore.playNext()
if (result) {
// 强制从头开始播放下一首歌曲
playerStore.setCurrentTime(0)
playerStore.playSong(result)
}
}
const handleTogglePlayMode = () => {
playQueueStore.togglePlayMode()
}
const handleShowPlaylist = () => {
showPlaylist.value = true
}
const handlePlayFromList = async (song, index) => {
try {
const selectedSong = playQueueStore.playAtIndex(index)
if (selectedSong) {
await playerStore.playSong(selectedSong, true)
historyStore.addToHistory(selectedSong)
toastStore.success(`开始播放 "${selectedSong.name}"`)
}
showPlaylist.value = false
} catch (error) {
console.error('播放失败:', error)
toastStore.error('播放失败,请重试')
}
}
const handleRemoveFromList = (index) => {
try {
const result = playQueueStore.removeFromQueue(index)
if (result.success) {
toastStore.success('已从播放列表移除')
} else {
toastStore.error(result.message)
}
} catch (error) {
console.error('移除失败:', error)
toastStore.error('操作失败,请重试')
}
}
const toggleFavorite = async () => {
if (!currentSong.value) return
try {
const result = await favoritesStore.toggleFavorite(currentSong.value)
const message = result ? '已添加到我喜欢的音乐' : '已从我喜欢的音乐中移除'
toastStore.success(message)
} catch (error) {
console.error('收藏操作失败:', error)
toastStore.error('操作失败,请重试')
}
}
const handleMoreAction = (action) => {
switch (action) {
case 'addToPlaylist':
// 实现添加到播放列表
break
case 'viewAlbum':
// 实现查看专辑
break
case 'viewArtist':
// 实现查看歌手
break
case 'selectQuality':
showQualitySelector.value = true
break
case 'report':
// 实现举报
break
}
showMoreActions.value = false
}
const selectQuality = async (quality) => {
if (quality === currentQuality.value || qualityLoading.value) return
selectedQuality.value = quality
qualityLoading.value = true
try {
await settingsStore.updateSetting('defaultQuality', quality)
if (currentSong.value && isPlaying.value) {
const currentTime = playerStore.currentTime
await playerStore.changeQuality(quality)
if (currentTime > 0) {
setTimeout(() => {
playerStore.seekTo(currentTime)
}, 500)
}
}
showQualitySelector.value = false
} catch (error) {
console.error('切换音质失败:', error)
} finally {
qualityLoading.value = false
selectedQuality.value = null
}
}
// 获取当前时间对应的歌词
const getCurrentLyric = () => {
if (!currentLyrics.value || currentLyrics.value.length === 0) {
return ''
}
const currentTimeSeconds = currentTime.value
let currentLyric = ''
for (let i = 0; i < currentLyrics.value.length; i++) {
const lyric = currentLyrics.value[i]
if (lyric.time <= currentTimeSeconds) {
currentLyric = lyric.text
} else {
break
}
}
return currentLyric
}
// 加载歌词
const loadLyrics = async (song) => {
if (!song) {
currentLyrics.value = []
return
}
const cachedLyrics = playerStore.getCachedLyrics(song)
if (cachedLyrics) {
const parsedLyrics = musicApi.parseLyrics(cachedLyrics.lyric)
currentLyrics.value = parsedLyrics.lyrics
return
}
try {
loading.value = true
const lyricsData = await playerStore.getLyricsWithDedup(song)
const parsedLyrics = musicApi.parseLyrics(lyricsData.lyric)
currentLyrics.value = parsedLyrics.lyrics
} catch (error) {
console.error('加载歌词失败:', error)
currentLyrics.value = []
} finally {
loading.value = false
}
}
// 延时加载歌词的定时器
let lyricsTimer = null
// 监听歌曲变化
watch(currentSong, (newSong, oldSong) => {
if (newSong && newSong !== oldSong) {
if (lyricsTimer) {
clearTimeout(lyricsTimer)
lyricsTimer = null
}
loadAlbumCover()
lyricsTimer = setTimeout(() => {
loadLyrics(newSong)
lyricsTimer = null
}, 200)
}
}, {immediate: true})
// 生命周期
onMounted(() => {
if (currentSong.value) {
loadAlbumCover()
loadLyrics(currentSong.value)
}
})
onUnmounted(() => {
if (lyricsTimer) {
clearTimeout(lyricsTimer)
lyricsTimer = null
}
})
</script>
<style scoped>
.desktop-full-player {
position: fixed;
top: 0;
left: 280px; /* 避开侧边栏 */
right: 0;
bottom: 0;
z-index: 1000;
background: var(--bg-primary);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 悬浮控制按钮 */
.floating-back-btn {
position: absolute;
top: 20px;
left: 20px;
width: 44px;
height: 44px;
border: none;
background: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
backdrop-filter: blur(20px);
transition: var(--transition-fast);
z-index: 20;
font-size: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.floating-back-btn:hover {
background: rgba(255, 255, 255, 0.25);
transform: scale(1.05);
border-color: rgba(255, 255, 255, 0.2);
}
/* 主内容区域:左右分栏 */
.player-main {
display: grid;
grid-template-columns: 400px 1fr;
overflow: hidden;
height: 100%;
flex: 1;
}
/* 左侧音乐播放区域 */
.left-panel {
display: flex;
flex-direction: column;
padding: 20px;
background: var(--bg-card);
border-right: 1px solid var(--border-light);
overflow: hidden;
height: 100%;
position: relative;
}
.panel-more-btn {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--transition-fast);
z-index: 10;
font-size: 14px;
}
.panel-more-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: var(--text-primary);
transform: scale(1.05);
}
.album-cover-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.album-cover-wrapper {
position: relative;
display: inline-block;
width: 180px;
height: 180px;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
background: var(--overlay-light);
border: 3px solid var(--border-light);
}
.album-cover {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
transition: var(--transition-slow);
}
.album-cover.rotating {
animation: cd-rotate 10s linear infinite;
}
@keyframes cd-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.album-cover:hover {
transform: scale(1.02);
}
.cd-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
border: 2px solid var(--border-light);
}
.center-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
}
.song-info-section {
text-align: center;
flex-shrink: 0;
max-width: 100%;
margin-bottom: 16px;
}
.song-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 4px;
position: relative;
}
.song-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
flex: 1;
}
.song-title-row .action-button {
position: absolute;
right: -8px;
flex-shrink: 0;
}
.artist-name {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 3px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.album-name {
font-size: 11px;
color: var(--text-tertiary);
margin: 0 0 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-button {
width: 32px;
height: 32px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
border-radius: 50%;
font-size: 13px;
cursor: pointer;
transition: var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
}
.action-button:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.favorite-btn.active {
background: rgba(255, 107, 107, 0.2);
color: var(--accent-red);
}
.favorite-btn.active:hover {
background: rgba(255, 107, 107, 0.3);
}
.progress-section {
flex-shrink: 0;
width: 100%;
margin-bottom: 6px;
}
.controls-section {
flex-shrink: 0;
}
/* 右侧歌词区域 */
.right-panel {
display: flex;
flex-direction: column;
background: var(--bg-primary);
overflow: hidden;
}
.lyrics-content {
flex: 1;
overflow: hidden;
padding: 0;
background: var(--bg-primary);
border: 1px solid var(--border-light);
}
.lyrics-scroll-container {
height: 100%;
overflow-y: auto;
padding: 20px;
background: var(--bg-card);
border-radius: 12px;
position: relative;
}
.lyrics-scroll-container::-webkit-scrollbar {
width: 6px;
}
.lyrics-scroll-container::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
.lyrics-scroll-container::-webkit-scrollbar-thumb {
background: rgba(255, 107, 107, 0.3);
border-radius: 3px;
transition: var(--transition-fast);
}
.lyrics-scroll-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 107, 107, 0.5);
}
/* 深度选择器优化歌词组件内部样式 */
.lyrics-scroll-container :deep(.lyrics-view) {
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.lyrics-scroll-container :deep(.lyrics-list) {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
min-height: 100%;
gap: 8px;
}
.lyrics-scroll-container :deep(.lyric-line) {
padding: 12px 16px;
font-size: 16px;
line-height: 1.8;
color: var(--text-secondary);
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
border-radius: 8px;
margin: 0;
word-wrap: break-word;
white-space: normal;
}
.lyrics-scroll-container :deep(.lyric-line:hover) {
color: var(--text-primary);
background: rgba(255, 107, 107, 0.05);
}
.lyrics-scroll-container :deep(.lyric-line.active) {
color: var(--accent-red);
font-weight: 600;
font-size: 18px;
background: rgba(255, 107, 107, 0.1);
text-shadow: none;
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.2);
transform: scale(1.02);
}
.lyrics-scroll-container :deep(.lyric-line.next) {
color: var(--text-primary);
font-weight: 500;
background: rgba(255, 255, 255, 0.02);
}
.lyrics-scroll-container :deep(.empty-lyrics) {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-tertiary);
font-size: 16px;
font-style: italic;
}
/* 歌词淡入淡出效果 */
.lyrics-scroll-container :deep(.lyric-line) {
opacity: 0.7;
}
.lyrics-scroll-container :deep(.lyric-line.active),
.lyrics-scroll-container :deep(.lyric-line.next) {
opacity: 1;
}
/* 音质选择面板样式复用原有样式 */
.quality-selector {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(20px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease-out;
}
.quality-panel {
background: var(--bg-card);
border-radius: 16px;
padding: 0;
width: 100%;
max-width: 480px;
max-height: 70vh;
overflow-y: auto;
animation: slideUpPanel 0.3s ease-out;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px;
border-bottom: 1px solid var(--border-light);
}
.panel-header h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--transition-fast);
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: var(--text-primary);
}
.quality-list {
padding: 12px 0;
}
.quality-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
cursor: pointer;
transition: var(--transition-fast);
}
.quality-item:hover {
background: var(--overlay-lighter);
}
.quality-item.active {
background: var(--bg-gradient-3);
}
.quality-item.loading {
pointer-events: none;
}
.quality-main {
flex: 1;
}
.quality-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.quality-item.active .quality-name {
color: var(--accent-red);
}
.quality-desc {
font-size: 13px;
color: var(--text-secondary);
}
.quality-actions {
display: flex;
align-items: center;
gap: 12px;
}
.quality-size {
font-size: 11px;
color: var(--text-tertiary);
min-width: 60px;
text-align: right;
}
.quality-check {
color: var(--accent-red);
font-size: 14px;
}
.quality-note {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 24px;
background: rgba(255, 255, 255, 0.02);
border-top: 1px solid var(--border-lighter);
}
.quality-note i {
color: var(--text-tertiary);
font-size: 12px;
}
.quality-note span {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUpPanel {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>