🎵 为博客添加沉浸式音乐播放器(Vue 3 + Web Audio API)

0 阅读5分钟

前言

一个精心设计的音乐播放器可以为博客增添格调,让读者在阅读时享受音乐。今天分享如何实现一个功能完备的音乐播放器!

功能设计

音乐播放器
├── 播放控制
│   ├── 播放/暂停
│   ├── 上一首/下一首
│   ├── 进度条拖拽
│   └── 音量控制
├── 歌词显示
│   ├── 逐行高亮
│   ├── 自动滚动
│   └── 点击跳转
├── 播放列表
│   ├── 歌曲列表
│   ├── 收藏功能
│   └── 顺序/随机播放
└── 视觉效果
    ├── 播放动画
    ├── 频谱可视化
    └── 主题切换

核心实现

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
  • 添加音频可视化效果
  • 支持播放列表云同步