让音乐“看得见”:使用 HTML + JavaScript 实现酷炫的音频可视化播放器

在这个数字时代,音乐不仅是听觉的享受,更可以成为视觉的盛宴!本文用 HTML + JavaScript 实现了一个音频可视化播放器,它不仅能播放本地音乐、控制进度和音量,还能通过 Canvas 绘制炫酷的音频频谱图,让你"听见色彩,看见旋律"。

效果演示

核心功能

本项目主要包含以下核心功能:

  • 音频播放控制:支持播放、暂停、上一首、下一首等基本操作。
  • 进度控制:显示当前播放时间和总时长,并支持点击进度条跳转。
  • 音量调节:提供滑动条调节播放音量。
  • 播放列表管理:支持动态添加本地音乐文件,显示播放列表并高亮当前播放曲目。
  • 音频可视化:通过Canvas实时绘制音频频谱图,增强用户体验。

页面结构

音频可视化容器

使用 HTML5 的 canvas 元素来绘制动态的音频频谱图。

html 复制代码
<div class="visualizer">
    <canvas id="visualizer"></canvas>
</div>
操作控制区域

整个音乐播放器的主要控制区域,包含播放进度条与时间显示、播放控制按钮、音量调节滑块、文件上传控件。

html 复制代码
<div class="controls">
    <div class="progress-container" id="progress-container">
        <div class="progress-bar" id="progress-bar"></div>
    </div>
    <div class="time-display">
        <span id="current-time">0:00</span>
        <span id="total-time">0:00</span>
    </div>
    <div class="control-row">
        <div class="left"></div>
        <div class="buttons">
            <button id="prev-btn">上一首</button>
            <button id="play-btn">播放</button>
            <button id="next-btn">下一首</button>
        </div>
        <div class="volume-control">
            <span>音量</span>
            <input type="range" min="0" max="1" step="0.01" value="0.7" class="volume-slider" id="volume-control">
        </div>
    </div>
    <div class="file-upload">
        <input type="file" id="file-input" accept="audio/*" multiple>
        <label for="file-input">添加音乐文件</label>
    </div>
</div>
播放列表区域

该区域用于展示用户上传的音频文件列表,并提供一个初始为空时的提示信息。

html 复制代码
<div class="playlist">
    <h2>播放列表</h2>
    <div id="playlist-items">
        <div class="empty-playlist">暂无音乐,请添加音乐文件</div>
    </div>
</div>

核心功能实现

添加本地音乐文件

使用 URL.createObjectURL 创建本地文件链接供 audio 播放。

js 复制代码
function addMusicFiles(files) {
    for (let i = 0; i < files.length; i++) {
        const file = files[i];
        const url = URL.createObjectURL(file);
        playlist.push({
            name: file.name.replace(/\.[^/.]+$/, ""), // 移除扩展名
            url: url
        });
    }
    // 如果是第一次添加音乐,自动加载第一首
    if (playlist.length === files.length) {
        currentTrack = 0;
        loadTrack();
    }
    renderPlaylist();
}
加载当前曲目
js 复制代码
function loadTrack() {
    if (playlist.length === 0) return;

    const track = playlist[currentTrack];
    audio.src = track.url;
    audio.load();

    updatePlaylistHighlight();

    if (isPlaying) {
        audio.play().catch(e => console.log('播放错误:', e));
    }
}
播放/暂停控制

判断播放列表是否为空,控制播放状态切换,并更新按钮文本,如果播放失败尝试下一首。

js 复制代码
function togglePlay() {
    if (playlist.length === 0) {
        alert('播放列表为空,请先添加音乐');
        return;
    }

    if (isPlaying) {
        audio.pause();
        playBtn.textContent = '播放';
    } else {
        initAudioContext(); // 首次播放时才初始化音频上下文
        audio.play().catch(e => {
            console.log('播放错误:', e);
            nextTrack(); // 播放失败自动下一首
        });
        playBtn.textContent = '暂停';
    }
    isPlaying = !isPlaying;
}
音频上下文初始化

初始化 AudioContext,创建音频分析节点,将音频元素通过 createMediaElementSource 接入分析器,dataArray 用于后续可视化绘制。

js 复制代码
function initAudioContext() {
    if (!audioContext) {
        audioContext = new (window.AudioContext || window.webkitAudioContext)();
        analyser = audioContext.createAnalyser();
        analyser.fftSize = 256;

        source = audioContext.createMediaElementSource(audio);
        source.connect(analyser);
        analyser.connect(audioContext.destination);

        dataArray = new Uint8Array(analyser.frequencyBinCount);
    }
}
音频可视化

使用 requestAnimationFrame 实现动画帧循环,使用 getByteFrequencyData() 获取实时音频数据,使用 HSL 颜色绘制彩色柱状图,形成"跳舞"的视觉效果。

js 复制代码
function visualize() {
    if (!isPlaying || playlist.length === 0) {
        canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
        return;
    }
    requestAnimationFrame(visualize);

    analyser.getByteFrequencyData(dataArray);

    canvasCtx.clearRect(0, 0, canvas.width, canvas.height);

    const barWidth = (canvas.width / analyser.frequencyBinCount) * 2.5;
    let x = 0;

    for (let i = 0; i < analyser.frequencyBinCount; i++) {
        const barHeight = dataArray[i] / 2;
        const hue = i * 360 / analyser.frequencyBinCount;
        canvasCtx.fillStyle = `hsl(${hue}, 100%, 50%)`;
        canvasCtx.fillRect(
            x,
            canvas.height - barHeight,
            barWidth,
            barHeight
        );
        x += barWidth + 1;
    }
}

扩展建议

  • 支持播放模式:单曲循环、随机播放、顺序播放。
  • 增加歌词同步功能:解析 LRC 歌词文件,并根据当前播放时间匹配对应歌词行,在页面中展示滚动歌词。
  • 支持拖拽排序播放列表,使用户可以自定义播放顺序。
  • 添加缓存机制:缓存播放历史、播放列表等信息,避免刷新后丢失
  • 支持在线音乐资源加载

完整代码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>可视化音乐播放器</title>
    <style>
        body {
            font-family: 'Arial', sans-serif;
            margin: 0;
            padding: 20px;
            background-color: #f5f5f5;
            color: #333;
        }

        .player-container {
            max-width: 800px;
            margin: 0 auto;
            background-color: white;
            border-radius: 10px;
            box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
            padding: 20px;
        }

        h1 {
            text-align: center;
            color: #2c3e50;
        }

        .visualizer {
            width: 100%;
            height: 200px;
            background-color: #2c3e50;
            margin-bottom: 20px;
            border-radius: 5px;
            position: relative;
            overflow: hidden;
        }

        canvas {
            width: 100%;
            height: 100%;
        }

        .controls {
            display: flex;
            flex-direction: column;
            gap: 15px;
        }

        .progress-container {
            width: 100%;
            height: 10px;
            background-color: #ecf0f1;
            border-radius: 5px;
            cursor: pointer;
        }

        .progress-bar {
            height: 100%;
            background-color: #3498db;
            border-radius: 5px;
            width: 0%;
        }

        .time-display {
            display: flex;
            justify-content: space-between;
            font-size: 14px;
            color: #7f8c8d;
        }

        .control-row {
            display: flex;
            justify-content: center;
            align-items: center;
            gap: 20px;
        }
        .control-row>div {
            flex: 1;
        }
        .buttons {
            display: flex;
            gap: 10px;
        }

        button {
            background-color: #3498db;
            color: white;
            border: none;
            border-radius: 5px;
            padding: 8px 15px;
            font-size: 14px;
            cursor: pointer;
            transition: all 0.3s;
        }

        button:hover {
            background-color: #2980b9;
            transform: scale(1.05);
        }

        button:active {
            transform: scale(0.95);
        }

        .playlist {
            margin-top: 30px;
        }

        .playlist h2 {
            border-bottom: 1px solid #ecf0f1;
            padding-bottom: 10px;
            margin-bottom: 15px;
        }

        .playlist-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 10px;
            border-radius: 5px;
            cursor: pointer;
            transition: background-color 0.2s;
        }

        .playlist-item:hover {
            background-color: #ecf0f1;
        }

        .playlist-item.active {
            background-color: #3498db;
            color: white;
        }

        .playlist-item-actions {
            display: flex;
            gap: 10px;
        }

        .delete-btn {
            background-color: #e74c3c;
            padding: 2px 8px;
            font-size: 12px;
            border-radius: 3px;
        }

        .delete-btn:hover {
            background-color: #c0392b;
        }

        .volume-control {
            display: flex;
            align-items: center;
            gap: 10px;
        }

        .volume-slider {
            width: 100px;
        }

        .file-upload {
            margin-top: 20px;
            text-align: center;
        }

        .file-upload input {
            display: none;
        }

        .file-upload label {
            background-color: #2ecc71;
            color: white;
            padding: 10px 15px;
            border-radius: 5px;
            cursor: pointer;
            transition: background-color 0.3s;
        }

        .file-upload label:hover {
            background-color: #27ae60;
        }

        .empty-playlist {
            text-align: center;
            color: #7f8c8d;
            padding: 20px;
        }
    </style>
</head>
<body>
<div class="player-container">
    <h1>可视化音乐播放器</h1>
    <div class="visualizer">
        <canvas id="visualizer"></canvas>
    </div>
    <div class="controls">
        <div class="progress-container" id="progress-container">
            <div class="progress-bar" id="progress-bar"></div>
        </div>
        <div class="time-display">
            <span id="current-time">0:00</span>
            <span id="total-time">0:00</span>
        </div>
        <div class="control-row">
            <div class="left"></div>
            <div class="buttons">
                <button id="prev-btn">上一首</button>
                <button id="play-btn">播放</button>
                <button id="next-btn">下一首</button>
            </div>
            <div class="volume-control">
                <span>音量</span>
                <input type="range" min="0" max="1" step="0.01" value="0.7" class="volume-slider" id="volume-control">
            </div>
        </div>
        <div class="file-upload">
            <input type="file" id="file-input" accept="audio/*" multiple>
            <label for="file-input">添加音乐文件</label>
        </div>
    </div>

    <div class="playlist">
        <h2>播放列表</h2>
        <div id="playlist-items">
            <div class="empty-playlist">暂无音乐,请添加音乐文件</div>
        </div>
    </div>
</div>

<script>
    document.addEventListener('DOMContentLoaded', function() {
        // 音频上下文和分析器
        let audioContext;
        let analyser;
        let dataArray;
        let source;

        // 播放器状态
        let currentTrack = 0;
        let isPlaying = false;
        let audio = new Audio();

        // 播放列表 - 初始为空
        let playlist = [];

        // DOM 元素
        const playBtn = document.getElementById('play-btn');
        const prevBtn = document.getElementById('prev-btn');
        const nextBtn = document.getElementById('next-btn');
        const progressContainer = document.getElementById('progress-container');
        const progressBar = document.getElementById('progress-bar');
        const currentTimeDisplay = document.getElementById('current-time');
        const totalTimeDisplay = document.getElementById('total-time');
        const playlistItems = document.getElementById('playlist-items');
        const volumeControl = document.getElementById('volume-control');
        const fileInput = document.getElementById('file-input');
        const canvas = document.getElementById('visualizer');
        const canvasCtx = canvas.getContext('2d');

        // 初始化音频上下文
        function initAudioContext() {
            if (!audioContext) {
                audioContext = new (window.AudioContext || window.webkitAudioContext)();
                analyser = audioContext.createAnalyser();
                analyser.fftSize = 256;

                source = audioContext.createMediaElementSource(audio);
                source.connect(analyser);
                analyser.connect(audioContext.destination);

                dataArray = new Uint8Array(analyser.frequencyBinCount);
            }
        }

        // 加载当前曲目
        function loadTrack() {
            if (playlist.length === 0) {
                audio.src = '';
                return;
            }

            // 确保当前曲目索引有效
            if (currentTrack >= playlist.length) {
                currentTrack = playlist.length - 1;
            }
            if (currentTrack < 0) {
                currentTrack = 0;
            }

            const track = playlist[currentTrack];
            audio.src = track.url;
            audio.load();

            // 更新播放列表高亮
            updatePlaylistHighlight();

            // 如果正在播放,继续播放
            if (isPlaying) {
                audio.play().catch(e => console.log('播放错误:', e));
            }
        }

        // 播放/暂停
        function togglePlay() {
            if (playlist.length === 0) {
                alert('播放列表为空,请先添加音乐');
                return;
            }

            if (isPlaying) {
                audio.pause();
                playBtn.textContent = '播放';
            } else {
                initAudioContext();
                audio.play().catch(e => {
                    console.log('播放错误:', e);
                    // 如果播放失败,尝试下一首
                    nextTrack();
                });
                playBtn.textContent = '暂停';
            }
            isPlaying = !isPlaying;
        }

        // 下一曲
        function nextTrack() {
            if (playlist.length === 0) return;

            currentTrack = (currentTrack + 1) % playlist.length;
            loadTrack();
            if (isPlaying) {
                audio.play().catch(e => console.log('播放错误:', e));
            }
        }

        // 上一曲
        function prevTrack() {
            if (playlist.length === 0) return;

            currentTrack = (currentTrack - 1 + playlist.length) % playlist.length;
            loadTrack();
            if (isPlaying) {
                audio.play().catch(e => console.log('播放错误:', e));
            }
        }

        // 删除曲目
        function deleteTrack(index) {
            // 如果删除的是当前正在播放的曲目
            if (index === currentTrack && isPlaying) {
                audio.pause();
                isPlaying = false;
                playBtn.textContent = '播放';
            }

            // 调整当前曲目索引
            if (index < currentTrack || (currentTrack === playlist.length - 1 && currentTrack > 0)) {
                currentTrack--;
            }

            // 从播放列表中移除
            playlist.splice(index, 1);

            // 重新渲染播放列表
            renderPlaylist();

            // 如果播放列表不为空,加载当前曲目
            if (playlist.length > 0) {
                loadTrack();
            } else {
                audio.src = '';
                currentTimeDisplay.textContent = '0:00';
                totalTimeDisplay.textContent = '0:00';
                progressBar.style.width = '0%';
            }
        }

        // 更新进度条
        function updateProgress() {
            const { currentTime, duration } = audio;
            const progressPercent = (currentTime / duration) * 100;
            progressBar.style.width = `${progressPercent}%`;

            // 更新时间显示
            currentTimeDisplay.textContent = formatTime(currentTime);
            totalTimeDisplay.textContent = formatTime(duration);
        }

        // 设置进度
        function setProgress(e) {
            if (playlist.length === 0) return;

            const width = this.clientWidth;
            const clickX = e.offsetX;
            const duration = audio.duration;
            audio.currentTime = (clickX / width) * duration;
        }

        // 格式化时间 (秒 -> MM:SS)
        function formatTime(seconds) {
            if (isNaN(seconds)) return '0:00';

            const minutes = Math.floor(seconds / 60);
            const secs = Math.floor(seconds % 60);
            return `${minutes}:${secs < 10 ? '0' : ''}${secs}`;
        }

        // 更新播放列表高亮
        function updatePlaylistHighlight() {
            const items = playlistItems.querySelectorAll('.playlist-item');
            items.forEach((item, index) => {
                item.classList.toggle('active', index === currentTrack);
            });
        }

        // 渲染播放列表
        function renderPlaylist() {
            if (playlist.length === 0) {
                playlistItems.innerHTML = '<div class="empty-playlist">暂无音乐,请添加音乐文件</div>';
                return;
            }

            playlistItems.innerHTML = '';
            playlist.forEach((track, index) => {
                console.log(index, currentTrack, isPlaying)
                const item = document.createElement('div');
                item.className = `playlist-item ${index === currentTrack ? 'active' : ''}`;
                item.innerHTML = `<span>${track.name}</span>
                        <div class="playlist-item-actions">
                            <button class="delete-btn">删除</button>
                        </div>`;

                // 点击曲目切换播放
                item.addEventListener('click', (e) => {
                    // 防止点击删除按钮时触发
                    if (e.target.classList.contains('delete-btn')) return;

                    currentTrack = index;
                    loadTrack();
                    if (!isPlaying) {
                        togglePlay();
                    }
                });

                // 删除按钮事件
                const deleteBtn = item.querySelector('.delete-btn');
                deleteBtn.addEventListener('click', (e) => {
                    e.stopPropagation(); // 阻止事件冒泡
                    deleteTrack(index);
                });

                playlistItems.appendChild(item);
            });
        }

        // 可视化音频
        function visualize() {
            if (!isPlaying || playlist.length === 0) {
                canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
                return;
            }

            requestAnimationFrame(visualize);

            analyser.getByteFrequencyData(dataArray);

            canvasCtx.clearRect(0, 0, canvas.width, canvas.height);

            const barWidth = (canvas.width / analyser.frequencyBinCount) * 2.5;
            let x = 0;

            for (let i = 0; i < analyser.frequencyBinCount; i++) {
                const barHeight = dataArray[i] / 2;

                const hue = i * 360 / analyser.frequencyBinCount;
                canvasCtx.fillStyle = `hsl(${hue}, 100%, 50%)`;
                canvasCtx.fillRect(
                    x,
                    canvas.height - barHeight,
                    barWidth,
                    barHeight
                );

                x += barWidth + 1;
            }
        }

        // 添加音乐文件
        function addMusicFiles(files) {
            for (let i = 0; i < files.length; i++) {
                const file = files[i];
                const url = URL.createObjectURL(file);
                playlist.push({
                    name: file.name.replace(/\.[^/.]+$/, ""), // 移除扩展名
                    url: url
                });
            }

            // 如果是第一次添加音乐,自动加载第一首
            if (playlist.length === files.length) {
                currentTrack = 0;
                loadTrack();
            }

            renderPlaylist();
        }

        // 事件监听
        playBtn.addEventListener('click', togglePlay);
        nextBtn.addEventListener('click', nextTrack);
        prevBtn.addEventListener('click', prevTrack);
        audio.addEventListener('timeupdate', updateProgress);
        audio.addEventListener('ended', nextTrack);
        audio.addEventListener('loadedmetadata', updateProgress);
        progressContainer.addEventListener('click', setProgress);
        volumeControl.addEventListener('input', () => {
            audio.volume = volumeControl.value;
        });
        fileInput.addEventListener('change', (e) => {
            addMusicFiles(e.target.files);
            fileInput.value = ''; // 重置输入,允许重复选择相同文件
        });

        // 初始化
        renderPlaylist();
        audio.volume = volumeControl.value;

        // 设置canvas尺寸
        function resizeCanvas() {
            canvas.width = canvas.offsetWidth;
            canvas.height = canvas.offsetHeight;
        }

        window.addEventListener('resize', resizeCanvas);
        resizeCanvas();

        // 开始可视化
        setInterval(visualize, 30);
    });
</script>
</body>
</html>
相关推荐
Lazy_zheng10 分钟前
🚀 前端开发福音:用 json-server 快速搭建本地 Mock 数据服务
前端·javascript·vue.js
用户25191624271110 分钟前
ES6之块级绑定
javascript
ZzMemory11 分钟前
藏起来的JS(四) - GC(垃圾回收机制)
前端·javascript·面试
LuckySusu15 分钟前
【HTML篇】HTML 语义化标签:构建更清晰的网页结构
前端·html
林太白15 分钟前
前端必会之Nuxt.js
前端·javascript·vue.js
晓晓莺歌21 分钟前
vue-router路由问题:可以通过$router.push()跳转,但刷新后又变成空白页面
前端·javascript·vue.js
前端Hardy37 分钟前
HTML&CSS:高颜值视差滚动3D卡片
前端·javascript·html
前端无涯40 分钟前
Vue---vue使用AOS(滚动动画)库
前端·javascript·vue.js
前端Hardy41 分钟前
HTML&CSS:超好看的数据卡片
前端·javascript·html
牧码岛42 分钟前
Web前端之隐藏元素方式的区别、Vue循环标签的时候在同一标签上隐藏元素的解决办法、hidden、display、visibility
前端·css·vue·html·web·web前端