项目技术栈:Vue3+Vite+TS、NativeUI、tailwindcss
歌词文件lrc.ts
export default [
'[00:00:00]反方向的钟',
'[00:02:00]周杰伦',
'[00:03:00]Jay',
'',
'[00:04.74]词: 方文山 曲: 周杰伦',
'[00: 45.86]迷迷蒙蒙 你给的梦',
'[00: 47.94]出现裂缝 隐隐作痛',
'[00: 50.53]怎么沟通你都没空',
'[00: 53.04]说我不懂 说了没用',
'[00: 55.64]他的笑容 有何不同',
'[00: 58.17]在你心中 我不再受宠',
'[01:00.64]我的天空 是雨是风 还是彩虹',
'[01:04.54]你在操纵',
'[02: 48.08][01:07.13]恨自己真的没用情绪激动',
'[02: 52.92][01: 11.47]一颗心到现在还在抽痛',
'[02: 57.80][01: 16.42]还为分手前那句抱歉在感动',
'[03: 48.46][03:07.80][01: 26.26]穿梭时间的画面的钟',
'[03: 53.42][03: 12.77][01: 31.31]从反方向 开始移动',
'[03: 58.36][03: 17.79][01: 36.44]回到当初爱你的时空',
'[04:03.43][03: 22.85][01: 41.49]停格内容 不忠',
'[04:08.70][03: 28.06][01: 46.55]所有回忆对着我进攻',
'[03: 33.13][01: 51.60]我的伤口 被你拆封',
'[03: 38.19][01: 56.67]誓言太沉重泪被纵容',
'[03: 43.24][02:01.78]脸上汹涌 失控',
'[02: 17.12]城市霓虹 不安跳动',
'[02: 18.77]染红夜空 过去种种',
'[02: 21.90]像一场梦 不敢去碰',
'[02: 23.22]一想就痛 心碎内容',
'[02: 24.65]每一秒钟 都有不同',
'[02: 25.70]你不懂 连一句珍重',
'[02: 26.25]也有苦衷 也不想送',
'[02: 27.51]寒风中 废墟烟囱',
'[02: 28.61]停止转动 一切落空',
'[02: 30.10]在人海中盲目跟从',
'[02: 31.91]别人的梦 全面放纵',
'[02: 33.51]恨没有用 疗伤止痛',
'[02: 34.82]不在感动 没有梦',
'[02: 35.98]痛不知轻重',
'[02: 36.86]泪水鲜红 全面放纵',
]
<!-- 首页 -->
<template>
<div class="music-container">
<div class="mask" />
<div class="singer-logo" />
<div ref="lrcContainer" class="music-lrc">
<ul ref="lrcUl">
<li v-for="(item, index) in lrcData" :key="index" :class="lrcActive === index && 'active'">{{ item.words }}</li>
</ul>
</div>
<AudioController @change="setOffset" />
</div>
</template>
<script lang='ts' setup>
import lrc from './lrc'
import AudioController from '@/components/Test/Music/AudioController.vue'
interface lrcType {
time: number,
words?: string
}
const lrcData = ref<lrcType[]>() // 歌词
const lrcContainer = ref<HTMLDivElement>() // 歌词外框
const lrcUl = ref<HTMLDivElement>() // 歌词ul
const lrcActive = ref<number | null>(null) // 当前激活歌词
onMounted(() => {
lrcData.value = lrcFormat(lrc)
})
/**
* @description 歌词格式化
*/
const lrcFormat = (lrc: any) => {
const lrcRes: lrcType[] = []
lrc.forEach((line: string) => {
const strArr = line.split(']')
const words = strArr.pop()
strArr.forEach((time: string) => {
const timeStr = time.substring(1)
lrcRes.push({
time: timeFormat(timeStr),
words
})
})
});
return lrcRes.sort((a, b) => a.time - b.time)
}
/**
* @description 时间格式化
*/
const timeFormat = (time: string) => {
const timeArr = time.split(':')
return +timeArr[0] * 60 + +timeArr[1]
}
/**
* @description 设置歌词位置
*/
const setOffset = (time: number) => {
const afterIndex = lrcData.value?.findIndex(v => v.time > time)
const curIndex = afterIndex ? afterIndex - 1 : 0
lrcActive.value = curIndex // 设置当前激活歌词
const lrcContainerHeight = ((lrcContainer.value?.clientHeight || 0) / 2) - 45
const lrcHeight = curIndex * 30
let offsetY = 0
if (lrcContainerHeight <= lrcHeight) {
offsetY = lrcContainerHeight - lrcHeight
}
if (lrcUl.value) {
lrcUl.value.style.transform = `translateY(${offsetY}px)`
}
}
</script>
<style lang='scss'>
.music-container {
height: 100%;
position: relative;
font-family: '微软雅黑';
.mask {
background: rgba(0, 0, 0, .35);
z-index: -1;
}
.singer-logo {
background-image: url('../../../assets/images/Jay.webp');
background-repeat: no-repeat;
background-size: cover;
z-index: -2;
filter: blur(65px);
}
.mask,
.singer-logo {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.music-lrc {
height: 70%;
position: absolute;
top: 50%;
width: 100%;
transform: translateY(-50%);
overflow: hidden;
text-align: center;
}
ul {
list-style: none;
user-select: none;
cursor: pointer;
transition: .2s;
li {
height: 30px;
line-height: 30px;
color: rgba(255, 255, 255, .5);
transition: .2s;
&.active {
color: #31c27c;
transform: scale(1.3);
font-weight: 700;
}
}
}
}
</style>
控制器组件 AudioController.vue
<!-- 音频控制器 -->
<template>
<div class="audio-controller">
<audio class="hidden" ref="audio" src="/static/audio/music.mp3" controls />
<div class="relative logo w-14 h-14">
<img class="w-full h-full rounded-full" :class="[musicPlay && 'rotate']" src="~@/assets/images/Jay.webp">
<div class="mask w-14 h-14 rounded-full" />
<n-icon v-show="!musicPlay" class="absolute p-icon" color="#fff" size="40" :component="PlayCircleOutline"
@click.native="audioPlay" />
<n-icon v-show="musicPlay" class="absolute p-icon" color="#fff" size="40" :component="PauseCircleOutline"
@click.native="audioSuspend" />
</div>
<div class="relative w-2/4 px-4 ">
<div class="text-[#c4c3c3] select-none">周杰伦-反方向的钟</div>
<div class="absolute top-0 right-4 text-[rgba(255,255,255,.4)] select-none">{{ sec_to_time(audio?.currentTime || 0)
}} /
{{ sec_to_time(audio?.duration || 0) }}</div>
<n-slider class="mt-2" v-model:value="musicStep" :tooltip="false" @update-value="handleSliderClick">
<template #thumb>
<n-icon-wrapper :size="20" :border-radius="12">
<n-icon :size="12" :component="MusicalNotes" />
</n-icon-wrapper>
</template>
</n-slider>
</div>
</div>
</template>
<script lang='ts' setup>
import { PlayCircleOutline, PauseCircleOutline, MusicalNotes } from '@vicons/ionicons5'
const musicStep = ref(0) // 进度条进度
const musicPlay = ref<boolean>(false)
const audio = ref<HTMLAudioElement>() // 播放器
const duration = ref<number>() // 歌曲时长
const emit = defineEmits(['change'])
onMounted(() => {
audio.value?.addEventListener('timeupdate', () => {
emit('change', audio.value?.currentTime || 0)
if (audio.value && duration.value) {
// 设置进度条进度
musicStep.value = (audio.value.currentTime / duration.value) * 100
}
})
audio.value?.addEventListener('canplaythrough', () => {
duration.value = audio.value?.duration
})
})
// 进度条点击事件
const handleSliderClick = (curStep: number) => {
if (audio.value) {
audio.value.currentTime = (curStep / 100) * audio.value?.duration
}
}
// 播放
const audioPlay = () => {
musicPlay.value = true
audio.value?.play()
}
// 暂停
const audioSuspend = () => {
musicPlay.value = false
audio.value?.pause()
}
// 将秒转换为分秒格式
const sec_to_time = (s: number) => {
let t = '';
if (s > -1) {
let min = Math.floor(s / 60) % 60;
let sec = ~~s % 60;
if (min < 10) t += "0"
t += min + ":";
if (sec < 10) t += "0"
t += sec;
}
return t;
}
</script>
<style lang='scss' scoped>
.audio-controller {
position: absolute;
bottom: 0;
width: 100%;
height: 80px;
background: rgba(0, 0, 0, .2);
display: flex;
align-items: center;
justify-content: center;
.logo {
cursor: pointer;
&:hover {
.mask,
.p-icon {
transition: .4s;
opacity: 1;
}
}
}
.mask {
opacity: 0;
position: absolute;
background: rgba(0, 0, 0, .5);
z-index: 9;
}
.p-icon {
opacity: 0;
left: 50%;
top: 50%;
z-index: 10;
transform: translate(-50%, -50%);
}
}
.rotate {
animation: rotate 8s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>