马黑黑 发表于 2026-4-5 22:36

天籁之音 - 本地音频播放器

<div class="codebox" data-prev="1">
&lt;!DOCTYPE html&gt;
&lt;html lang="en" xmlns="http://www.w3.org/1999/xhtml"&gt;
&lt;head&gt;
&lt;meta charset="utf-8"&gt;
&lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
&lt;title&gt;天籁之音&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;style&gt;
    body { background: #666; }
    h1 { font-size: 2.5em; text-align: center; color: white; text-shadow: -2px -2px tan;}
    .papa { margin: auto; padding: 20px; width: clamp(600px, 60vw, 1000px); height: 80vh; font-size: 16px; background: linear-gradient(to bottom right, #000, #ffc); box-shadow: 3px 6px 20px #000; border-radius: 10px; display: flex; flex-direction: column; flex-wrap: wrap; gap: 10px; align-content: space-between; justify-content: space-between; position: relative; }
    .papa * { box-sizing: border-box; }
    .son { box-sizing: border-box; width: calc(50% - 5px); height: calc(50% - 5px); display: grid; place-items: center; position: relative; }
    #openFile { position: absolute; left: 15px; top: 10px; }
    #mfile { display: none; }
    #selectSong {margin-right: 8px; border: 2px solid #ccc; border-radius: 6px; outline: none; background: none; color: snow; cursor: pointer;}
    #selectSong:hover { background: darkred; }
    #curSong { color: #eee; padding: 4px; cursor: pointer; }
    #mlist { position: absolute; padding: 12px 20px; left: 10px; top: 60px; width: 90%; height: calc(100% - 12px); color: silver; line-height: 2.5em; overflow: hidden; scrollbar-width: thin; scrollbar-color: tan transparent; z-index: 20; transition: all .5s; }
    #mlist:hover { overflow: auto; }
    .list1 { cursor: pointer; }
    .list2 { color: cyan; cursor:default; }
    .list1:hover { color: white; }
    #mplayer { --bg1: teal; --bg2: snow; --ppLen: 4px; --prog: white; --track: silver; --prg: 0%; --ppCap: white; position: absolute; width: 100px; height: 100px; border-radius: 50%; background: linear-gradient(to right, var(--prog) var(--prg), var(--track) var(--prg), var(--track) 0) no-repeat 0 50%/100% 2px; cursor: pointer; filter: drop-shadow(0 0 10px gray); display: grid; place-items: center; }
    #mplayer:hover { filter: hue-rotate(90deg) drop-shadow(0 0 26px black); }
    #mplayer::before, #mplayer::after { position: absolute; color: snow; }
    #mplayer::before { content: attr(data-cu); top: 20%; }
    #mplayer::after { content: attr(data-du); top: 56%; }
    #playbtn { position: absolute; padding: 2px 20px; bottom: 20px; color: white; font-size: 1.2em; border: 3px solid white; border-radius: 12px; cursor: pointer; user-select: none; }
    #playbtn:hover { background: darkred; }
    .pp { position: absolute; left: calc(50% - 2px); bottom: 50%; width: var(--ppLen); height: 20px; background: linear-gradient(to top, var(--bg1), var(--bg2)); transform-origin: 50% 100%; transform: rotate(var(--deg)) translate(-50px, 0); display: grid; place-items: center; }
    .pp::after { position: absolute; content: ''; width: calc(var(--ppLen) + 4px); height: calc(var(--ppLen) + 4px); top: 0px; background: var(--bg2); border-radius: 50%; }
    .hidden { overflow: hidden; }
    #mpic { position: absolute; width: 65%; border-radius: 8px; object-fit: cover; transition: .35s; }
    #mpic:hover { transform: scale(1.2); }
    #mMsg pre { font-family: monospace; line-height: 30px; white-space: pre-wrap; }
&lt;/style&gt;

&lt;h1&gt;天籁之音&lt;/h1&gt;
&lt;audio id="aud"&gt;&lt;/audio&gt;
&lt;div class="papa"&gt;
    &lt;div class="son"&gt;
      &lt;div id="openFile"&gt;
            &lt;input id="selectSong" type="button" value="选择音乐"&gt;
            &lt;input type="file" id="mfile" accept=".mp3, .ogg, .wav, .acc, .webm" multiple&gt;
            &lt;span id="curSong"&gt;&lt;/span&gt;
      &lt;/div&gt;
      &lt;div id="mlist"&gt;&lt;/div&gt;
    &lt;/div&gt;
   
    &lt;div class="son"&gt;
      &lt;div id="mplayer"&gt;&lt;/div&gt;
      &lt;div id="playbtn" title="随机选择"&gt;⏭ 下一曲&lt;/div&gt;
    &lt;/div&gt;
      
    &lt;div class="son hidden"&gt;
      &lt;!-- 封面图片可以换成本地文件 --&gt;
            &lt;!--img id="mpic" src="./api/piano.svg" alt="" title="歌曲封面"--&gt;
      &lt;img id="mpic" src="https://638183.freep.cn/638183/web/svg/piano.svg" alt="" title="歌曲封面"&gt;
    &lt;/div&gt;

    &lt;div id="mMsg" class="son"&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;!-- 可以下载 jsmediatags 文件到本地使用 --&gt;
&lt;!--script src="./jsmediatags.min.js"&gt;&lt;/script--&gt;
&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/jsmediatags/3.9.5/jsmediatags.min.js"&gt;&lt;/script&gt;

&lt;script&gt;
    let files = [], playAr = [], output = [], pps = [], total = 30, Ac = null, currentIdx, mData = {};

    // 音频信息输出
    const outputData = (data) =&gt; {
      if (!data.size) return;
      mMsg.innerHTML = [
            `&lt;pre&gt;`,
            `文 件 名 :${data.name}`,
            ``,
            `专辑名称 :${data.album}`,
            `歌曲标题 :${data.title}`,
            `艺 术 家 :${data.artist}`,
            `文件大小 :${data.size}`,
            `音频时长 :${data.duration}`,
            `&lt;/pre&gt;`
      ].join('\n');
    };

    // 获取音频元数据
    const getTrackMsg = (file) =&gt; {
      jsmediatags.read(file, {
            onSuccess: (tag) =&gt; {
                if (tag.tags.picture) {
                  mpic.src = URL.createObjectURL(new Blob());
                }
                mData.name = file.name;
                mData.album = tag.tags.album || '未知';
                mData.artist = tag.tags.artist || '未知';
                mData.size = formatFileSize(file.size);
                mData.title = tag.tags.title || file.name.replace(/\..+/, '');
                outputData(mData);
            },
            onError: console.error
      });
    };
   
    //获取波形数据
    const getAcDatas = () =&gt; {
      if (Ac !== null) return;
      Ac = new AudioContext;
      source = Ac.createMediaElementSource(aud);
      analyser = Ac.createAnalyser();
      source.connect(analyser);
      analyser.connect(Ac.destination);
      output = new Uint8Array(total);
    };

    // 格式化文件大小
    const formatFileSize = (bytes) =&gt; {
      if (bytes === 0) return '0 Bytes';
      const k = 1024;
      const sizes = ['Bytes', 'KB', 'MB', 'GB'];
      const i = Math.floor(Math.log(bytes) / Math.log(k));
      return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes;
    };
   
    //生成频谱条
    Array(total).fill(0).forEach((_, k) =&gt; {
      let pp = document.createElement('span');
      pp.className = 'pp';
      pp.style.cssText += `--deg: ${360 / total * k}deg`;
      mplayer.appendChild(pp);
      pps.push(pp);
    });
   
    //波形数据刷新
    (function update() {
      if(aud.src) analyser.getByteFrequencyData(output);
      for(let j = 0; j &lt; total ; j++) {
            pps.style.height = output / 2 + 'px';
      }
      window.requestAnimationFrame(update);
    })();
   
    //播放音频 :idx为空时随机播放
    const mplay = (idx = null) =&gt; {
      if(files.length === 0) return;
      if(idx === null) {
            if(playAr.length === 0) playAr = ranNum(files.length);
            let tmpIdx = Math.floor(Math.random() * playAr.length);
            idx = currentIdx = playAr;
            playAr.splice(tmpIdx, 1);
      } else currentIdx = idx;
      aud.src = URL.createObjectURL(files);
      let name = files.name;
      curSong.innerText = name.substring(0, name.lastIndexOf('.')) + `(${files.length}/${idx+1})`;
      aud.play();
      mlist.innerHTML = showList(files, idx);
      scrollList();
      getTrackMsg(files);
      aud.onloadedmetadata = () =&gt; {
            mData.duration = s2m(aud.duration);
            outputData(mData);
      };
    };
   
    //生成音乐列表
    const showList = (ar, idx) =&gt; {
      let res = '';
      for(let j = 0; j &lt; ar.length; j ++) {
            let item = (j + 1) + '. ';
            item += j === idx ?
                `&lt;span class="list2"&gt;${ar.name}&lt;/span&gt;` :
                `&lt;span class="list1" onclick="mplay(${j})"&gt;${ar.name}&lt;/span&gt;`;
            res += item + '&lt;br&gt;';
      }
      return res;
    };

    // 列表滚动
    const scrollList = () =&gt; {
      const lists = mlist.querySelectorAll('span');
      if(lists.length &gt; 0 && isInViewport(mlist) && mlist.scrollHeight &gt; mlist.clientHeight) {
            lists.scrollIntoView({ behavior: 'smooth', block: 'center' });
            console.log('Yes')
      }
    };

    //生成不重复随机数组
    const ranNum = (total) =&gt; {
      let ar = Array(total).fill().map((_,key) =&gt; key);
      ar.sort(() =&gt; 0.5 - Math.random());
      return ar;
    };
   
    //秒转分
    const s2m = (seconds) =&gt; {
      if (!seconds) return '00:00';
      let min = parseInt(seconds / 60), sec = parseFloat(Math.floor(seconds) % 60);
      if(min &lt; 10) min = '0' + min;
      if(sec &lt; 10) sec = '0' + sec;
      return min + ':' + sec;
    };

    //判断进度条区域
    const innerH = (e, h) =&gt; e.offsetY &gt; h / 2 - 5 && e.offsetY &lt; h / 2 + 5;

    // mlist进入视口时项目滚动
    const observer = new IntersectionObserver((entries) =&gt; {
      entries.forEach(entry =&gt; {
            if (entry.isIntersecting) {
                scrollList();
            }
      });
    });

    observer.observe(mlist);

    // 元素是否进入视口
    const isInViewport = (elm) =&gt; {
      const rect = elm.getBoundingClientRect();
      const viewHeight = window.innerHeight || document.documentElement.clientHeight;
      const viewWidth = window.innerWidth || document.documentElement.clientWidth;
      return (rect.bottom &gt; 0 && rect.right &gt; 0 && rect.top &lt; viewHeight && rect.left &lt; viewWidth);
    };

    //audio timeupdate监听事件
    aud.ontimeupdate = () =&gt; {
      mplayer.style.setProperty('--prg', aud.currentTime / aud.duration * 100 + '%');
      mplayer.dataset.cu = s2m(aud.currentTime);
      mplayer.dataset.du = s2m(aud.duration);
    };
   
    //单曲播放结束
    aud.onended = () =&gt; mplay();
   
    //选择歌曲
    selectSong.onclick = () =&gt; mfile.click();
   
    //文件选择器改变
    mfile.onchange = () =&gt; {
      let filelist = mfile.files;
      if(filelist.length === 0) return;
      files.length = 0;
      for(let j = 0; j &lt; filelist.length; j ++) {
            files.push(filelist);
      }
      playAr = ranNum(files.length);
      mplay();
      getAcDatas();
    }
   
    //播放器点击
    mplayer.onclick = (e) =&gt; {
      if(files.length &lt; 1) return;
      if(innerH(e,mplayer.clientHeight)) {
            aud.currentTime = aud.duration * e.offsetX / mplayer.offsetWidth;
      }else{
            aud.paused ? aud.play() : aud.pause();
      }
    };
   
    //播放器鼠标移过
    mplayer.onmousemove = (e) =&gt; {
      mplayer.title = innerH(e,mplayer.clientHeight) ?
            s2m(aud.duration * e.offsetX / mplayer.offsetWidth) :
            (aud.paused ? '点击播放' : '点击暂停');
    };

    curSong.onclick = () =&gt; scrollList();

    playbtn.onclick = () =&gt; mplay();
&lt;/script&gt;

&lt;/body&gt;
&lt;/html&gt;
</div>

<script type="module">
import linenumber from 'https://638183.freep.cn/638183/web/helight/linenumber.js';
linenumber();
</script>

马黑黑 发表于 2026-4-5 22:42

一楼的代码可以在线预览,如果觉得满意,可以考虑将代码存为本地 .html 文档使用。存为本地页面,建议修改两个地方,以避免网络依赖,详情查看源码的第 59 行 和 67 行注释。具体做法是将图片、JS文件保存在和 .html 文档相同的目录,然后分别解开上述注释下的代码行,并将其下的对应行删掉(建议)或者注释掉。

jsmediatags.min.js 文件保存方法:复制 69 行代码中的JS地址到浏览器地址栏,回车,成功打开后全选、复制,然后用文本编辑器保存文档。

马黑黑 发表于 2026-4-5 22:51

源码中的两个处理项目列表滚动的函数和方法,209行、220行分别是它们的标识注释,它们对于单独使用的页面来讲不是必须的,加入它们是考虑在长页面中使用的情形。就是说,这两个函数以及在源码中的应用可以剔除,因为单独应用时 mlist 元素总是在可视区域。保留也无法,一定要剔除的话需要通过函数名找到对应的应用语句进行准确修改。

红影 发表于 2026-4-6 12:59

这个漂亮,还带响应式频谱的,用来听听自己电脑上的音乐很有美感啊{:4_199:}

红影 发表于 2026-4-6 13:08

马黑黑 发表于 2026-4-5 22:42
一楼的代码可以在线预览,如果觉得满意,可以考虑将代码存为本地 .html 文档使用。存为本地页面,建议修改 ...

“建议修改两个地方,以避免网络依赖”
也就是说,修改好以后即使没网也能玩了吧{:4_173:}

马黑黑 发表于 2026-4-6 16:05

红影 发表于 2026-4-6 13:08
“建议修改两个地方,以避免网络依赖”
也就是说,修改好以后即使没网也能玩了吧

使得,就是介个伊思{:4_170:}

马黑黑 发表于 2026-4-6 16:07

红影 发表于 2026-4-6 12:59
这个漂亮,还带响应式频谱的,用来听听自己电脑上的音乐很有美感啊

之前已经做有一个基础的,现在这个就是修改一些细节、加入元数据展现:


本地音频响应式频谱播放器第二版(2月10日更新) - 马黑黑教程专版 - 花潮论坛 - Powered by Discuz!

梦江南 发表于 2026-4-6 16:15

小白学习来了{:4_173:}

红影 发表于 2026-4-6 20:18

马黑黑 发表于 2026-4-6 16:05
使得,就是介个伊思

本地的东东当然可以不依赖网络。

红影 发表于 2026-4-6 20:19

马黑黑 发表于 2026-4-6 16:07
之前已经做有一个基础的,现在这个就是修改一些细节、加入元数据展现:




哦,25年的2月,是一年前的帖子呢{:4_204:}

马黑黑 发表于 2026-4-6 20:23

红影 发表于 2026-4-6 20:19
哦,25年的2月,是一年前的帖子呢
是的

马黑黑 发表于 2026-4-6 20:24

红影 发表于 2026-4-6 20:18
本地的东东当然可以不依赖网络。

是酱紫的

马黑黑 发表于 2026-4-6 20:30

梦江南 发表于 2026-4-6 16:15
小白学习来了

学习好的奖励小红花

红影 发表于 2026-4-6 22:07

马黑黑 发表于 2026-4-6 20:23
是的

原来黑黑早就有本地播放的代码呢{:4_187:}

红影 发表于 2026-4-6 22:07

马黑黑 发表于 2026-4-6 20:24
是酱紫的

其实里面好像还是有依赖网络的东东,即使把这两样换掉{:4_173:}

马黑黑 发表于 2026-4-6 22:18

红影 发表于 2026-4-6 22:07
其实里面好像还是有依赖网络的东东,即使把这两样换掉

这个不会,我测试过的:保存为本地文档之后,修改好原始图片资源和JS资源为本地的,只有路径准确,就会一切正常,此时,断掉网络,一样运行。

唯一可能出现的报错是无关紧要的错误,主要针对EDGE浏览器,它的最新版本不允许运行本地 .html 文档,认为不安全,但是并不影响 .html 文档的实际运行。

马黑黑 发表于 2026-4-6 22:19

红影 发表于 2026-4-6 22:07
原来黑黑早就有本地播放的代码呢

是的

梦江南 发表于 2026-4-9 14:14

马黑黑 发表于 2026-4-6 20:30
学习好的奖励小红花

是吗,那我得可好好努力,争取奖励小红花{:4_187:}

马黑黑 发表于 2026-4-9 18:30

梦江南 发表于 2026-4-9 14:14
是吗,那我得可好好努力,争取奖励小红花

这个可以有

梦江南 发表于 2026-4-9 18:43

马黑黑 发表于 2026-4-9 18:30
这个可以有

天籁之音 - 本地音频播放器预览画面很漂亮。
页: [1] 2 3
查看完整版本: 天籁之音 - 本地音频播放器