前言
一个精心设计的音乐播放器可以为博客增添格调,让读者在阅读时享受音乐。今天分享如何实现一个功能完备的音乐播放器!
功能设计
音乐播放器
├── 播放控制
│ ├── 播放/暂停
│ ├── 上一首/下一首
│ ├── 进度条拖拽
│ └── 音量控制
├── 歌词显示
│ ├── 逐行高亮
│ ├── 自动滚动
│ └── 点击跳转
├── 播放列表
│ ├── 歌曲列表
│ ├── 收藏功能
│ └── 顺序/随机播放
└── 视觉效果
├── 播放动画
├── 频谱可视化
└── 主题切换
核心实现
1. 播放器组件
<!-- src/components/music/MusicPlayer.vue -->
<template>
<div class="music-player" :class="{ minimized: isMinimized }">
<!-- 迷你播放器 -->
<div v-if="isMinimized" class="mini-player" @click="expandPlayer">
<div class="mini-info">
<img :src="currentSong?.cover" class="album-art" />
<div class="song-info">
<div class="song-title">{{ currentSong?.title }}</div>
<div class="song-artist">{{ currentSong?.artist }}</div>
</div>
</div>
<div class="mini-controls">
<el-button :icon="Play" circle @click.stop="togglePlay" />
<el-button :icon="Right" circle @click.stop="nextSong" />
</div>
</div>
<!-- 完整播放器 -->
<div v-else class="full-player">
<!-- 头部 -->
<div class="player-header">
<span class="title">🎵 音乐播放器</span>
<div class="header-actions">
<el-button text @click="toggleMode">
{{ playMode === 'order' ? '🔁' : playMode === 'loop' ? '🔂' : '🔀' }}
</el-button>
<el-button :icon="Minus" @click="minimizePlayer" />
</div>
</div>
<!-- 专辑封面 -->
<div class="album-section">
<div class="album-wrapper" :class="{ playing: isPlaying }">
<img :src="currentSong?.cover" class="album-art" />
<div class="vinyl-overlay" />
</div>
</div>
<!-- 歌曲信息 -->
<div class="song-section">
<h2 class="song-title">{{ currentSong?.title }}</h2>
<p class="song-artist">{{ currentSong?.artist }}</p>
</div>
<!-- 进度条 -->
<div class="progress-section">
<span class="time">{{ formatTime(currentTime) }}</span>
<div class="progress-bar" @click="seekProgress">
<div class="progress-track">
<div class="progress-fill" :style="{ width: progressPercent + '%' }" />
<div
class="progress-thumb"
:style="{ left: progressPercent + '%' }"
@mousedown="startDrag"
/>
</div>
</div>
<span class="time">{{ formatTime(duration) }}</span>
</div>
<!-- 播放控制 -->
<div class="controls-section">
<el-button :icon="Previous" @click="prevSong" />
<el-button
type="primary"
size="large"
:icon="isPlaying ? Pause : Play"
circle
@click="togglePlay"
/>
<el-button :icon="Next" @click="nextSong" />
</div>
<!-- 音量控制 -->
<div class="volume-section">
<el-icon><Volume /></el-icon>
<el-slider
v-model="volume"
:show-tooltip="false"
@input="handleVolumeChange"
/>
<span class="volume-value">{{ Math.round(volume * 100) }}%</span>
</div>
<!-- 歌词区域 -->
<div class="lyrics-section" ref="lyricsRef">
<div
v-for="(line, index) in lyricsLines"
:key="index"
class="lyric-line"
:class="{ active: index === currentLyricIndex }"
@click="seekToLyric(line.time)"
>
{{ line.text }}
</div>
</div>
<!-- 播放列表 -->
<div class="playlist-section">
<h4>播放列表</h4>
<div class="playlist">
<div
v-for="(song, index) in playlist"
:key="song.id"
class="playlist-item"
:class="{ active: index === currentIndex }"
@click="playSong(index)"
>
<span class="index">{{ index + 1 }}</span>
<img :src="song.cover" class="thumb" />
<div class="info">
<div class="title">{{ song.title }}</div>
<div class="artist">{{ song.artist }}</div>
</div>
<span class="duration">{{ formatTime(song.duration) }}</span>
</div>
</div>
</div>
<!-- 音频元素 -->
<audio
ref="audioRef"
:src="currentSong?.url"
@timeupdate="handleTimeUpdate"
@ended="handleEnded"
@loadedmetadata="handleLoaded"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { Play, Pause, Previous, Next, Volume, Minus } from '@element-plus/icons-vue'
// 歌曲类型
interface Song {
id: string
title: string
artist: string
cover: string
url: string
duration: number
lyrics?: string
}
interface LyricLine {
time: number
text: string
}
// 示例播放列表
const playlist: Song[] = [
{
id: '1',
title: 'Summer Breeze',
artist: 'Relaxing Music',
cover: 'https://picsum.photos/200',
url: 'https://example.com/music1.mp3',
duration: 240,
lyrics: '[00:00] Summer breeze\n[00:05] Makes me feel fine\n[00:10] Blowing through the jasmine in my mind'
},
// 更多歌曲...
]
// 状态
const audioRef = ref<HTMLAudioElement>()
const isPlaying = ref(false)
const isMinimized = ref(true)
const currentIndex = ref(0)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(0.7)
const playMode = ref<'order' | 'loop' | 'random'>('order')
const lyricsRef = ref<HTMLElement>()
// 计算属性
const currentSong = computed(() => playlist[currentIndex.value])
const progressPercent = computed(() => {
return duration.value ? (currentTime.value / duration.value) * 100 : 0
})
const lyricsLines = computed(() => {
if (!currentSong.value?.lyrics) return []
return currentSong.value.lyrics
.split('\n')
.map(line => {
const match = line.match(/[(\d{2}):(\d{2})](.*)/)
if (match) {
return {
time: parseInt(match[1]) * 60 + parseInt(match[2]),
text: match[3].trim()
}
}
return { time: 0, text: line }
})
.filter(line => line.text)
})
const currentLyricIndex = computed(() => {
for (let i = lyricsLines.value.length - 1; i >= 0; i--) {
if (currentTime.value >= lyricsLines.value[i].time) {
return i
}
}
return -1
})
// 方法
function togglePlay() {
if (isPlaying.value) {
audioRef.value?.pause()
} else {
audioRef.value?.play()
}
isPlaying.value = !isPlaying.value
}
function prevSong() {
currentIndex.value = currentIndex.value > 0
? currentIndex.value - 1
: playlist.length - 1
}
function nextSong() {
if (playMode.value === 'random') {
currentIndex.value = Math.floor(Math.random() * playlist.length)
} else {
currentIndex.value = (currentIndex.value + 1) % playlist.length
}
}
function playSong(index: number) {
currentIndex.value = index
nextTick(() => {
audioRef.value?.play()
isPlaying.value = true
})
}
function handleTimeUpdate() {
currentTime.value = audioRef.value?.currentTime || 0
scrollLyrics()
}
function handleLoaded() {
duration.value = audioRef.value?.duration || 0
}
function handleEnded() {
if (playMode.value === 'loop') {
audioRef.value?.play()
} else {
nextSong()
}
}
function seekProgress(e: MouseEvent) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const time = percent * duration.value
audioRef.value!.currentTime = time
}
function handleVolumeChange() {
if (audioRef.value) {
audioRef.value.volume = volume.value
}
}
function seekToLyric(time: number) {
if (audioRef.value) {
audioRef.value.currentTime = time
}
}
function scrollLyrics() {
if (lyricsRef.value && currentLyricIndex.value >= 0) {
const activeLine = lyricsRef.value.children[currentLyricIndex.value] as HTMLElement
if (activeLine) {
activeLine.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
}
function toggleMode() {
const modes: ('order' | 'loop' | 'random')[] = ['order', 'loop', 'random']
const currentIdx = modes.indexOf(playMode.value)
playMode.value = modes[(currentIdx + 1) % modes.length]
}
function minimizePlayer() {
isMinimized.value = true
}
function expandPlayer() {
isMinimized.value = false
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// 监听歌曲切换
watch(currentIndex, () => {
isPlaying.value = true
nextTick(() => {
audioRef.value?.play()
})
})
// 拖拽进度条
let isDragging = false
function startDrag(e: MouseEvent) {
isDragging = true
seekProgress(e)
const handleMove = (e: MouseEvent) => seekProgress(e)
const handleUp = () => {
isDragging = false
document.removeEventListener('mousemove', handleMove)
document.removeEventListener('mouseup', handleUp)
}
document.addEventListener('mousemove', handleMove)
document.addEventListener('mouseup', handleUp)
}
</script>
<style scoped>
.music-player {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
background: white;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
transition: all 0.3s;
overflow: hidden;
}
.music-player.minimized {
width: auto;
}
.mini-player {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
}
.mini-info {
display: flex;
align-items: center;
gap: 12px;
}
.song-info {
max-width: 150px;
}
.song-title {
font-weight: 600;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.song-artist {
font-size: 12px;
color: #999;
}
.mini-controls {
display: flex;
gap: 8px;
}
.full-player {
width: 350px;
padding: 20px;
}
.player-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.player-header .title {
font-weight: 600;
}
.album-section {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.album-wrapper {
position: relative;
width: 180px;
height: 180px;
}
.album-art {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.album-wrapper.playing .album-art {
animation: rotate 20s linear infinite;
}
.vinyl-overlay {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.1);
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.song-section {
text-align: center;
margin-bottom: 20px;
}
.song-section .song-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px;
}
.song-section .song-artist {
color: #666;
margin: 0;
}
.progress-section {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.progress-bar {
flex: 1;
height: 20px;
cursor: pointer;
}
.progress-track {
position: relative;
height: 4px;
background: #e0e0e0;
border-radius: 2px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 2px;
transition: width 0.1s;
}
.progress-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
cursor: grab;
}
.time {
font-size: 12px;
color: #999;
min-width: 40px;
}
.controls-section {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.volume-section {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.volume-value {
font-size: 12px;
color: #999;
min-width: 35px;
}
.lyrics-section {
max-height: 150px;
overflow-y: auto;
margin-bottom: 20px;
text-align: center;
}
.lyric-line {
padding: 8px;
font-size: 14px;
color: #999;
cursor: pointer;
transition: all 0.3s;
}
.lyric-line.active {
color: #667eea;
font-weight: 600;
font-size: 16px;
}
.lyric-line:hover {
color: #333;
}
.playlist-section h4 {
font-size: 14px;
margin: 0 0 12px;
}
.playlist {
max-height: 200px;
overflow-y: auto;
}
.playlist-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.playlist-item:hover {
background: #f5f5f5;
}
.playlist-item.active {
background: linear-gradient(135deg, #667eea20, #764ba220);
}
.playlist-item .index {
width: 20px;
text-align: center;
color: #999;
font-size: 12px;
}
.playlist-item .thumb {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
}
.playlist-item .info {
flex: 1;
min-width: 0;
}
.playlist-item .title {
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-item .artist {
font-size: 12px;
color: #999;
}
.playlist-item .duration {
font-size: 12px;
color: #999;
}
</style>
使用效果
博客音乐播放器可以:
- 🎵 为阅读增添氛围
- 🎨 提升博客格调
- 📱 提供沉浸式体验
💡 进阶功能
- 接入网易云音乐 API
- 添加音频可视化效果
- 支持播放列表云同步