Vue 3 实战:打造可拖拽歌词、播放列表的嵌入式音乐播放器
前言一个精心设计的音乐播放器可以为博客增添格调让读者在阅读时享受音乐。今天分享如何实现一个功能完备的音乐播放器功能设计音乐播放器 ├── 播放控制 │ ├── 播放/暂停 │ ├── 上一首/下一首 │ ├── 进度条拖拽 │ └── 音量控制 ├── 歌词显示 │ ├── 逐行高亮 │ ├── 自动滚动 │ └── 点击跳转 ├── 播放列表 │ ├── 歌曲列表 │ ├── 收藏功能 │ └── 顺序/随机播放 └── 视觉效果 ├── 播放动画 ├── 频谱可视化 └── 主题切换核心实现1. 播放器组件!-- src/components/music/MusicPlayer.vue -- template div classmusic-player :class{ minimized: isMinimized } !-- 迷你播放器 -- div v-ifisMinimized classmini-player clickexpandPlayer div classmini-info img :srccurrentSong?.cover classalbum-art / div classsong-info div classsong-title{{ currentSong?.title }}/div div classsong-artist{{ currentSong?.artist }}/div /div /div div classmini-controls el-button :iconPlay circle click.stoptogglePlay / el-button :iconRight circle click.stopnextSong / /div /div !-- 完整播放器 -- div v-else classfull-player !-- 头部 -- div classplayer-header span classtitle 音乐播放器/span div classheader-actions el-button text clicktoggleMode {{ playMode order ? : playMode loop ? : }} /el-button el-button :iconMinus clickminimizePlayer / /div /div !-- 专辑封面 -- div classalbum-section div classalbum-wrapper :class{ playing: isPlaying } img :srccurrentSong?.cover classalbum-art / div classvinyl-overlay / /div /div !-- 歌曲信息 -- div classsong-section h2 classsong-title{{ currentSong?.title }}/h2 p classsong-artist{{ currentSong?.artist }}/p /div !-- 进度条 -- div classprogress-section span classtime{{ formatTime(currentTime) }}/span div classprogress-bar clickseekProgress div classprogress-track div classprogress-fill :style{ width: progressPercent % } / div classprogress-thumb :style{ left: progressPercent % } mousedownstartDrag / /div /div span classtime{{ formatTime(duration) }}/span /div !-- 播放控制 -- div classcontrols-section el-button :iconPrevious clickprevSong / el-button typeprimary sizelarge :iconisPlaying ? Pause : Play circle clicktogglePlay / el-button :iconNext clicknextSong / /div !-- 音量控制 -- div classvolume-section el-iconVolume //el-icon el-slider v-modelvolume :show-tooltipfalse inputhandleVolumeChange / span classvolume-value{{ Math.round(volume * 100) }}%/span /div !-- 歌词区域 -- div classlyrics-section reflyricsRef div v-for(line, index) in lyricsLines :keyindex classlyric-line :class{ active: index currentLyricIndex } clickseekToLyric(line.time) {{ line.text }} /div /div !-- 播放列表 -- div classplaylist-section h4播放列表/h4 div classplaylist div v-for(song, index) in playlist :keysong.id classplaylist-item :class{ active: index currentIndex } clickplaySong(index) span classindex{{ index 1 }}/span img :srcsong.cover classthumb / div classinfo div classtitle{{ song.title }}/div div classartist{{ song.artist }}/div /div span classduration{{ formatTime(song.duration) }}/span /div /div /div !-- 音频元素 -- audio refaudioRef :srccurrentSong?.url timeupdatehandleTimeUpdate endedhandleEnded loadedmetadatahandleLoaded / /div /div /template script setup langts 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 refHTMLAudioElement() 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 reforder | loop | random(order) const lyricsRef refHTMLElement() // 计算属性 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添加音频可视化效果支持播放列表云同步