前言
最近我为博客制作了一个悬浮在页面左下角的音乐播放器。它平时缩在侧边,只显示一个音符按钮;鼠标悬浮或点击后便会展开,可以切歌、调整音量、查看歌单,并选择列表循环、随机播放、顺序播放或单曲循环。
本来Butterfly主题是自带音乐播放器组件的,但是它的播放器有点大,挡住了我的部分内容。所以在AI鼎力相助之下,有了这个新的播放器。
这个播放器没有直接套用现成播放器的界面,而是使用原生 JavaScript、CSS 和浏览器的 <audio> 元素完成。这样做的好处是外观和交互都可以自由调整,也更容易让它与博客主题保持一致。
本文会分享当前版本的完整源码,并说明它如何接入 Hexo + Butterfly、为什么切换文章时音乐不会中断,以及如何迁移到普通 HTML、WordPress、Vue 或 React 网站。
先回答一个重要问题:它并不是 Hexo 专属。
播放器的核心只是浏览器原生的 HTML、CSS 和 JavaScript,任何能够引入静态资源的网站都可以使用。Hexo 只决定“文件放在哪里”和“怎样把它注入每个页面”。
一、播放器现在具备哪些功能
左下角悬浮,收起时仅保留一个音符按钮
悬浮或点击后展开,兼顾电脑端和触屏设备
从网易云歌单读取歌曲、歌手、封面和音频地址
播放、暂停、上一首、下一首和进度拖动
音量调节与静音
列表循环、随机播放、顺序播放和单曲循环
展开歌单并直接选择曲目
自动跳过只能播放约 30 秒的试听歌曲(这个功能目前还在调试中,并且我导入的那个歌单目前还是有很多网易云需要VIP的歌曲。所以有很多歌还是得听30s不能跳过……正在火速修改中)
记住访客上一次选择的音量和播放模式
支持亮色、暗色主题及移动端布局
在使用 PJAX 的主题中切换页面时保持播放器和播放状态
播放器不会尝试绕过音乐平台的版权限制。需要会员授权、地区受限或没有公开播放地址的歌曲,仍可能无法完整播放。(就是刚刚上面说的还是有很多歌只能播放一段)
二、它只能用在 Hexo 吗
不能说“只能”,更准确的说法是:核心组件与网站生成器无关,接入方式与网站框架有关。
网站类型
能否使用
接入方式
普通 HTML 网站
可以
在页面中引入一个 CSS 和一个 JS 文件
Hexo
可以
将文件放入 source,再由主题配置注入
WordPress
可以
通过主题或 wp_enqueue_script 引入资源
Vue / React
可以
在最外层布局挂载一次,避免路由切换时重复初始化
MkDocs / Hugo
可以
把文件放入静态资源目录,并加入公共模板
当前代码中真正与 Butterfly 相关的只有两点:
我在 _config.butterfly.yml 中把资源注入所有页面。
我监听了 pjax:complete,处理 Butterfly 开启 PJAX 后的无刷新页面切换。
如果你的网站没有 PJAX,这段兼容逻辑不会造成影响;如果使用其他前端路由,则需要换成该框架对应的生命周期处理。
三、源码下载
你可以下载本站正在使用的完整版本:
压缩包内包含:
1 2 3 4 5 unmyic-music-player/ ├── music-player.js ├── music-player.css ├── butterfly-inject.yml └── README.txt
其中 music-player.js 负责创建界面、请求歌单和控制播放,music-player.css 负责全部外观与响应式布局,另外两个文件提供快速安装说明。
为了方便直接复制,下面也贴出当前版本的完整代码。本站已将过长代码块的显示高度限制为 400px,阅读时可以点击代码块下方的展开按钮查看全部内容,也可以使用右上角的复制按钮一键复制。
music-player.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 (function ( ) { 'use strict' ; var PLAYLIST_ID = '2791970710' ; var PLAYLIST_LIMIT = 100 ; var METING_API = 'https://api.i-meto.com/meting/api' + '?server=netease&type=playlist&id=' + PLAYLIST_ID ; var root = null ; var audio = null ; var tracks = []; var currentIndex = 0 ; var playRequested = false ; var closeTimer = null ; var skippedPreviewUrls = {}; var mountObserver = null ; var playbackMode = 'list' ; var lastAudibleVolume = 0.55 ; var PLAYBACK_MODES = { list : '列表循环' , shuffle : '随机播放' , sequential : '顺序播放' , single : '单曲循环' }; function readSetting (key ) { try { return window .localStorage .getItem (key); } catch (error) { return null ; } } function saveSetting (key, value ) { try { window .localStorage .setItem (key, String (value)); } catch (error) { } } function formatTime (seconds ) { if (!Number .isFinite (seconds) || seconds < 0 ) return '00:00' ; var minutes = Math .floor (seconds / 60 ); var remainder = Math .floor (seconds % 60 ); return String (minutes).padStart (2 , '0' ) + ':' + String (remainder).padStart (2 , '0' ); } function normalizeTracks (rawTracks ) { if (!Array .isArray (rawTracks)) return []; return rawTracks .slice (0 , PLAYLIST_LIMIT ) .filter (function (track ) { return track && track.url ; }) .map (function (track ) { return { name : track.title || '未知歌曲' , artist : track.author || '未知歌手' , url : track.url , cover : track.pic || '' }; }); } function createInterface ( ) { root = document .createElement ('div' ); root.id = 'custom-music-player' ; root.className = 'custom-music-player is-loading' ; root.innerHTML = '<section class="cmp-panel" aria-label="音乐播放器">' + '<div class="cmp-current">' + '<div class="cmp-cover-wrap">' + '<img class="cmp-cover" alt="" src="" referrerpolicy="no-referrer">' + '<span class="cmp-cover-fallback" aria-hidden="true">♫</span>' + '</div>' + '<div class="cmp-meta">' + '<div class="cmp-title">正在载入歌单</div>' + '<div class="cmp-artist">网易云音乐</div>' + '</div>' + '</div>' + '<div class="cmp-progress-row">' + '<span class="cmp-time-current">00:00</span>' + '<input class="cmp-progress" type="range" min="0" max="100" value="0" step="0.1" aria-label="播放进度">' + '<span class="cmp-time-total">00:00</span>' + '</div>' + '<div class="cmp-controls">' + '<button class="cmp-control cmp-prev" type="button" aria-label="上一首" title="上一首">‹</button>' + '<button class="cmp-control cmp-play" type="button" aria-label="播放" title="播放">▶</button>' + '<button class="cmp-control cmp-next" type="button" aria-label="下一首" title="下一首">›</button>' + '<button class="cmp-control cmp-list-toggle" type="button" aria-label="显示歌单" aria-expanded="false" title="歌单">☷</button>' + '</div>' + '<div class="cmp-options">' + '<div class="cmp-volume-control">' + '<button class="cmp-volume-toggle" type="button" aria-label="静音" title="静音">🔊</button>' + '<input class="cmp-volume" type="range" min="0" max="1" value="0.55" step="0.01" aria-label="音量">' + '</div>' + '<label class="cmp-mode-wrap">' + '<span class="cmp-mode-icon" aria-hidden="true">↻</span>' + '<select class="cmp-mode" aria-label="播放模式">' + '<option value="list">列表循环</option>' + '<option value="shuffle">随机播放</option>' + '<option value="sequential">顺序播放</option>' + '<option value="single">单曲循环</option>' + '</select>' + '</label>' + '</div>' + '<div class="cmp-status" role="status" aria-live="polite">正在连接网易云歌单…</div>' + '<div class="cmp-playlist" hidden>' + '<ol class="cmp-playlist-items"></ol>' + '</div>' + '</section>' + '<button class="cmp-toggle" type="button" aria-label="展开音乐播放器" aria-expanded="false" title="音乐播放器">' + '<span class="cmp-toggle-icon" aria-hidden="true">♫</span>' + '</button>' ; audio = document .createElement ('audio' ); audio.preload = 'metadata' ; root.appendChild (audio); document .body .appendChild (root); var savedMode = readSetting ('cmp-playback-mode' ); if (PLAYBACK_MODES [savedMode]) playbackMode = savedMode; var savedVolume = Number (readSetting ('cmp-volume' )); audio.volume = Number .isFinite (savedVolume) && savedVolume >= 0 && savedVolume <= 1 ? savedVolume : 0.55 ; if (audio.volume > 0 ) lastAudibleVolume = audio.volume ; root.querySelector ('.cmp-mode' ).value = playbackMode; root.querySelector ('.cmp-volume' ).value = String (audio.volume ); updateVolumeButton (); bindInterfaceEvents (); bindAudioEvents (); } function setOpen (open ) { if (!root) return ; root.classList .toggle ('is-open' , open); var toggle = root.querySelector ('.cmp-toggle' ); toggle.setAttribute ('aria-expanded' , String (open)); toggle.setAttribute ( 'aria-label' , open ? '收起音乐播放器' : '展开音乐播放器' ); root.querySelector ('.cmp-toggle-icon' ).textContent = open ? '‹' : '♫' ; } function setStatus (message, isError ) { if (!root) return ; var status = root.querySelector ('.cmp-status' ); status.textContent = message || '' ; status.classList .toggle ('is-error' , Boolean (isError)); } function updatePlayButton ( ) { if (!root) return ; var button = root.querySelector ('.cmp-play' ); var playing = audio && !audio.paused ; button.textContent = playing ? '❚❚' : '▶' ; button.setAttribute ('aria-label' , playing ? '暂停' : '播放' ); button.title = playing ? '暂停' : '播放' ; root.classList .toggle ('is-playing' , playing); } function updateVolumeButton ( ) { if (!root || !audio) return ; var button = root.querySelector ('.cmp-volume-toggle' ); var effectiveVolume = audio.muted ? 0 : audio.volume ; var icon = effectiveVolume === 0 ? '🔇' : effectiveVolume < 0.5 ? '🔉' : '🔊' ; button.textContent = icon; button.setAttribute ( 'aria-label' , effectiveVolume === 0 ? '恢复音量' : '静音' ); button.title = effectiveVolume === 0 ? '恢复音量' : '静音' ; } function renderPlaylist ( ) { var list = root.querySelector ('.cmp-playlist-items' ); list.textContent = '' ; tracks.forEach (function (track, index ) { var item = document .createElement ('li' ); var button = document .createElement ('button' ); button.type = 'button' ; button.className = 'cmp-track' ; button.dataset .index = String (index); button.innerHTML = '<span class="cmp-track-index">' + (index + 1 ) + '</span>' + '<span class="cmp-track-text">' + '<span class="cmp-track-title"></span>' + '<span class="cmp-track-artist"></span>' + '</span>' ; button.querySelector ('.cmp-track-title' ).textContent = track.name ; button.querySelector ('.cmp-track-artist' ).textContent = track.artist ; item.appendChild (button); list.appendChild (item); }); } function updateActiveTrack ( ) { if (!root) return ; var buttons = root.querySelectorAll ('.cmp-track' ); Array .prototype .forEach .call (buttons, function (button, index ) { var active = index === currentIndex; button.classList .toggle ('is-active' , active); button.setAttribute ('aria-current' , active ? 'true' : 'false' ); }); } function loadTrack (index, shouldPlay ) { if (!tracks.length ) return ; currentIndex = (index + tracks.length ) % tracks.length ; var track = tracks[currentIndex]; var cover = root.querySelector ('.cmp-cover' ); root.querySelector ('.cmp-title' ).textContent = track.name ; root.querySelector ('.cmp-artist' ).textContent = track.artist ; cover.src = track.cover ; cover.alt = track.name + ' 封面' ; root.classList .remove ('has-cover' ); root.querySelector ('.cmp-progress' ).value = '0' ; root.querySelector ('.cmp-time-current' ).textContent = '00:00' ; root.querySelector ('.cmp-time-total' ).textContent = '00:00' ; audio.src = track.url ; audio.load (); updateActiveTrack (); setStatus ('第 ' + (currentIndex + 1 ) + ' 首,共 ' + tracks.length + ' 首' ); if (shouldPlay) { playRequested = true ; playCurrent (); } else { updatePlayButton (); } } function isPreviewTrack ( ) { return Number .isFinite (audio.duration ) && audio.duration >= 28 && audio.duration <= 32.5 ; } function skipPreviewIfNeeded ( ) { var track = tracks[currentIndex]; if (!playRequested || !track || !isPreviewTrack ()) return false ; if (skippedPreviewUrls[track.url ]) return false ; skippedPreviewUrls[track.url ] = true ; setStatus ('已跳过 30 秒试听歌曲:' + track.name ); window .setTimeout (function ( ) { nextTrack (true , 'skip' ); }, 350 ); return true ; } function playCurrent ( ) { if (!tracks.length ) return ; playRequested = true ; if (skipPreviewIfNeeded ()) return ; var promise = audio.play (); if (promise && typeof promise.catch === 'function' ) { promise.catch (function ( ) { setStatus ('当前歌曲无法播放,正在尝试下一首…' , true ); window .setTimeout (function ( ) { nextTrack (true , 'skip' ); }, 650 ); }); } } function randomTrackIndex ( ) { if (tracks.length < 2 ) return currentIndex; var nextIndex = currentIndex; while (nextIndex === currentIndex) { nextIndex = Math .floor (Math .random () * tracks.length ); } return nextIndex; } function nextTrack (shouldPlay, reason ) { reason = reason || 'manual' ; if (reason === 'ended' && playbackMode === 'single' ) { audio.currentTime = 0 ; playCurrent (); return ; } if ( reason === 'ended' && playbackMode === 'sequential' && currentIndex === tracks.length - 1 ) { playRequested = false ; audio.pause (); audio.currentTime = 0 ; updatePlayButton (); setStatus ('顺序播放已完成' ); return ; } var nextIndex = playbackMode === 'shuffle' ? randomTrackIndex () : currentIndex + 1 ; loadTrack (nextIndex, shouldPlay); } function previousTrack ( ) { if (audio.currentTime > 5 ) { audio.currentTime = 0 ; return ; } loadTrack (currentIndex - 1 , playRequested); } function togglePlaylist ( ) { var playlist = root.querySelector ('.cmp-playlist' ); var button = root.querySelector ('.cmp-list-toggle' ); var willOpen = playlist.hidden ; playlist.hidden = !willOpen; button.classList .toggle ('is-active' , willOpen); button.setAttribute ('aria-expanded' , String (willOpen)); button.setAttribute ('aria-label' , willOpen ? '隐藏歌单' : '显示歌单' ); if (willOpen) { var active = root.querySelector ('.cmp-track.is-active' ); if (active) active.scrollIntoView ({ block : 'nearest' }); } } function bindInterfaceEvents ( ) { var toggle = root.querySelector ('.cmp-toggle' ); toggle.addEventListener ('click' , function ( ) { setOpen (!root.classList .contains ('is-open' )); }); root.querySelector ('.cmp-play' ).addEventListener ('click' , function ( ) { if (!tracks.length ) return ; if (audio.paused ) { playCurrent (); } else { playRequested = false ; audio.pause (); } }); root.querySelector ('.cmp-prev' ).addEventListener ('click' , previousTrack); root.querySelector ('.cmp-next' ).addEventListener ('click' , function ( ) { nextTrack (playRequested, 'manual' ); }); root.querySelector ('.cmp-list-toggle' ).addEventListener ('click' , togglePlaylist); root.querySelector ('.cmp-volume' ).addEventListener ('input' , function (event ) { var volume = Number (event.target .value ); audio.muted = false ; audio.volume = volume; if (volume > 0 ) lastAudibleVolume = volume; saveSetting ('cmp-volume' , volume); updateVolumeButton (); }); root.querySelector ('.cmp-volume-toggle' ).addEventListener ('click' , function ( ) { if (audio.muted || audio.volume === 0 ) { audio.muted = false ; audio.volume = lastAudibleVolume || 0.55 ; root.querySelector ('.cmp-volume' ).value = String (audio.volume ); } else { lastAudibleVolume = audio.volume ; audio.muted = true ; } saveSetting ('cmp-volume' , audio.muted ? 0 : audio.volume ); updateVolumeButton (); }); root.querySelector ('.cmp-mode' ).addEventListener ('change' , function (event ) { var selectedMode = event.target .value ; if (!PLAYBACK_MODES [selectedMode]) return ; playbackMode = selectedMode; saveSetting ('cmp-playback-mode' , playbackMode); setStatus ('播放模式:' + PLAYBACK_MODES [playbackMode]); }); root.querySelector ('.cmp-progress' ).addEventListener ('input' , function (event ) { if (!Number .isFinite (audio.duration )) return ; audio.currentTime = audio.duration * (Number (event.target .value ) / 100 ); }); root.querySelector ('.cmp-playlist-items' ).addEventListener ('click' , function (event ) { var button = event.target .closest ('.cmp-track' ); if (!button) return ; loadTrack (Number (button.dataset .index ), true ); }); var cover = root.querySelector ('.cmp-cover' ); cover.addEventListener ('load' , function ( ) { root.classList .add ('has-cover' ); }); cover.addEventListener ('error' , function ( ) { root.classList .remove ('has-cover' ); }); if (window .matchMedia ('(hover: hover) and (pointer: fine)' ).matches ) { root.addEventListener ('mouseenter' , function ( ) { window .clearTimeout (closeTimer); setOpen (true ); }); root.addEventListener ('mouseleave' , function ( ) { closeTimer = window .setTimeout (function ( ) { setOpen (false ); }, 500 ); }); } } function bindAudioEvents ( ) { audio.addEventListener ('play' , updatePlayButton); audio.addEventListener ('pause' , updatePlayButton); audio.addEventListener ('ended' , function ( ) { nextTrack (true , 'ended' ); }); audio.addEventListener ('loadedmetadata' , function ( ) { root.querySelector ('.cmp-time-total' ).textContent = formatTime (audio.duration ); skipPreviewIfNeeded (); }); audio.addEventListener ('timeupdate' , function ( ) { var ratio = Number .isFinite (audio.duration ) && audio.duration > 0 ? (audio.currentTime / audio.duration ) * 100 : 0 ; root.querySelector ('.cmp-progress' ).value = String (ratio); root.querySelector ('.cmp-time-current' ).textContent = formatTime (audio.currentTime ); }); audio.addEventListener ('error' , function ( ) { if (!playRequested) { setStatus ('当前歌曲暂时无法播放' , true ); return ; } setStatus ('当前歌曲不可用,正在跳到下一首…' , true ); window .setTimeout (function ( ) { nextTrack (true , 'skip' ); }, 650 ); }); } function loadPlaylist ( ) { window .fetch (METING_API , { mode : 'cors' }) .then (function (response ) { if (!response.ok ) { throw new Error ('Playlist request failed: ' + response.status ); } return response.json (); }) .then (function (data ) { tracks = normalizeTracks (data); if (!tracks.length ) throw new Error ('No playable tracks returned' ); renderPlaylist (); root.classList .remove ('is-loading' ); root.classList .add ('is-ready' ); loadTrack (0 , false ); }) .catch (function (error ) { root.classList .remove ('is-loading' ); root.classList .add ('has-error' ); root.querySelector ('.cmp-title' ).textContent = '歌单加载失败' ; root.querySelector ('.cmp-artist' ).textContent = '请稍后重试' ; setStatus ('无法连接网易云歌单' , true ); console .error ('[custom-music-player]' , error); }); } function initialize ( ) { var existing = document .getElementById ('custom-music-player' ); if (existing) { root = existing; audio = existing.querySelector ('audio' ); return ; } createInterface (); loadPlaylist (); } function ensurePlayerMounted ( ) { if (!document .body ) return ; if (!root) { initialize (); return ; } if (!root.isConnected || root.parentElement !== document .body ) { document .body .appendChild (root); } root.hidden = false ; root.setAttribute ('data-current-page' , window .location .pathname ); } function handlePageReady ( ) { window .requestAnimationFrame (function ( ) { ensurePlayerMounted (); }); } function observePlayerMount ( ) { if (mountObserver || !document .body ) return ; mountObserver = new MutationObserver (function ( ) { if (root && !root.isConnected ) ensurePlayerMounted (); }); mountObserver.observe (document .body , { childList : true }); } document .addEventListener ('pjax:complete' , handlePageReady); window .addEventListener ('pageshow' , handlePageReady); if (document .readyState === 'loading' ) { document .addEventListener ('DOMContentLoaded' , function ( ) { initialize (); observePlayerMount (); }); } else { initialize (); observePlayerMount (); } window .__unmyicMusicPlayer = { ensureMounted : ensurePlayerMounted, open : function ( ) { setOpen (true ); }, close : function ( ) { setOpen (false ); } }; })();
music-player.css
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 :root { --cmp-accent : #3b82f6 ; --cmp-accent-strong : #2563eb ; --cmp-panel-width : min (350px , calc (100vw - 68px )); }.custom-music-player { position : fixed; left : 0 ; bottom : 24px ; z-index : 9998 ; width : var (--cmp-panel-width); color : #243246 ; font-size : 14px ; line-height : 1.4 ; pointer-events : none; }body > #custom-music-player { display : block !important ; visibility : visible !important ; }.custom-music-player audio { display : none; }.cmp-panel { width : var (--cmp-panel-width); box-sizing : border-box; padding : 14px ; border : 1px solid rgba (59 , 130 , 246 , 0.18 ); border-radius : 0 18px 18px 0 ; background : rgba (250 , 252 , 255 , 0.94 ); box-shadow : 0 14px 42px rgba (25 , 55 , 96 , 0.22 ); backdrop-filter : blur (18px ) saturate (130% ); -webkit-backdrop-filter : blur (18px ) saturate (130% ); transform : translateX (calc (-100% - 18px )); opacity : 0 ; pointer-events : none; transition : transform 0.32s cubic-bezier (.22 , .8 , .25 , 1 ), opacity 0.22s ease; }.custom-music-player .is-open .cmp-panel { transform : translateX (0 ); opacity : 1 ; pointer-events : auto; }.cmp-toggle { position : absolute; left : 0 ; bottom : 12px ; display : grid; width : 46px ; height : 52px ; padding : 0 ; place-items : center; border : 0 ; border-radius : 0 15px 15px 0 ; background : linear-gradient (145deg , #60a5fa , #2563eb ); box-shadow : 0 8px 24px rgba (37 , 99 , 235 , 0.32 ); color : #fff ; cursor : pointer; pointer-events : auto; transition : left 0.32s cubic-bezier (.22 , .8 , .25 , 1 ), transform 0.2s ease, box-shadow 0.2s ease; }.custom-music-player .is-open .cmp-toggle { left : calc (var (--cmp-panel-width) + 10px ); border-radius : 14px ; }.cmp-toggle :hover { transform : translateY (-2px ); box-shadow : 0 11px 30px rgba (37 , 99 , 235 , 0.42 ); }.cmp-toggle :focus-visible ,.cmp-control :focus-visible ,.cmp-track :focus-visible ,.cmp-progress :focus-visible { outline : 3px solid rgba (59 , 130 , 246 , 0.35 ); outline-offset : 2px ; }.cmp-toggle-icon { font-size : 25px ; line-height : 1 ; }.custom-music-player .is-playing :not (.is-open ) .cmp-toggle-icon { animation : cmp-note-pulse 1.2s ease-in-out infinite; }.cmp-current { display : flex; min-width : 0 ; align-items : center; gap : 12px ; }.cmp-cover-wrap { position : relative; flex : 0 0 56px ; width : 56px ; height : 56px ; overflow : hidden; border-radius : 14px ; background : linear-gradient (145deg , #dbeafe , #93c5fd ); box-shadow : inset 0 0 0 1px rgba (255 , 255 , 255 , 0.55 ); }.cmp-cover ,.cmp-cover-fallback { position : absolute; inset : 0 ; width : 100% ; height : 100% ; }.cmp-cover { display : none; object-fit : cover; }.has-cover .cmp-cover { display : block; }.cmp-cover-fallback { display : grid; place-items : center; color : #fff ; font-size : 26px ; }.has-cover .cmp-cover-fallback { display : none; }.is-playing .cmp-cover { animation : cmp-cover-breathe 2.8s ease-in-out infinite; }.cmp-meta { min-width : 0 ; flex : 1 ; }.cmp-title ,.cmp-artist { overflow : hidden; white-space : nowrap; text-overflow : ellipsis; }.cmp-title { margin-bottom : 5px ; color : #1e293b ; font-size : 15px ; font-weight : 650 ; }.cmp-artist { color : #718096 ; font-size : 12px ; }.cmp-progress-row { display : grid; grid-template-columns : 38px 1 fr 38px ; align-items : center; gap : 7px ; margin-top : 13px ; color : #718096 ; font-size : 10px ; font-variant-numeric : tabular-nums; }.cmp-time-total { text-align : right; }.cmp-progress { width : 100% ; height : 4px ; margin : 0 ; border-radius : 999px ; background : #dbe6f4 ; cursor : pointer; accent-color : var (--cmp-accent); }.cmp-controls { display : flex; align-items : center; justify-content : center; gap : 10px ; margin-top : 10px ; }.cmp-control { display : grid; width : 34px ; height : 34px ; padding : 0 ; place-items : center; border : 0 ; border-radius : 50% ; background : transparent; color : #52647b ; cursor : pointer; font-family : inherit; font-size : 24px ; line-height : 1 ; transition : color 0.18s ease, background 0.18s ease, transform 0.18s ease; }.cmp-control :hover ,.cmp-control .is-active { background : rgba (59 , 130 , 246 , 0.1 ); color : var (--cmp-accent-strong); }.cmp-control :active { transform : scale (0.92 ); }.cmp-play { width : 42px ; height : 42px ; padding-left : 2px ; background : linear-gradient (145deg , #60a5fa , #2563eb ); box-shadow : 0 6px 18px rgba (37 , 99 , 235 , 0.28 ); color : #fff ; font-size : 15px ; }.cmp-play :hover { background : linear-gradient (145deg , #74b2ff , #2c6ff0 ); color : #fff ; }.cmp-list-toggle { margin-left : 9px ; font-size : 22px ; }.cmp-options { display : flex; align-items : center; justify-content : space-between; gap : 12px ; margin-top : 8px ; padding : 7px 9px ; border-radius : 11px ; background : rgba (59 , 130 , 246 , 0.06 ); }.cmp-volume-control { display : flex; min-width : 0 ; flex : 1 ; align-items : center; gap : 6px ; }.cmp-volume-toggle { width : 25px ; height : 25px ; padding : 0 ; border : 0 ; background : transparent; cursor : pointer; font-size : 14px ; line-height : 1 ; }.cmp-volume { width : 88px ; min-width : 45px ; height : 4px ; margin : 0 ; background : #dbe6f4 ; cursor : pointer; accent-color : var (--cmp-accent); }.cmp-mode-wrap { display : flex; flex : 0 0 auto; align-items : center; gap : 3px ; color : #63748a ; }.cmp-mode-icon { font-size : 15px ; }.cmp-mode { max-width : 86px ; padding : 3px 19px 3px 5px ; border : 0 ; border-radius : 7px ; background-color : transparent; color : inherit; cursor : pointer; font-family : inherit; font-size : 11px ; }.cmp-mode :hover ,.cmp-mode :focus { background-color : rgba (59 , 130 , 246 , 0.1 ); color : var (--cmp-accent-strong); }.cmp-status { min-height : 16px ; margin-top : 7px ; overflow : hidden; color : #8a99ad ; font-size : 10px ; text-align : center; white-space : nowrap; text-overflow : ellipsis; }.cmp-status .is-error { color : #dc6b6b ; }.cmp-playlist { max-height : 274px ; margin : 10px -5px -4px ; overflow-y : auto; border-top : 1px solid rgba (100 , 116 , 139 , 0.13 ); scrollbar-color : rgba (59 , 130 , 246 , 0.55 ) transparent; scrollbar-width : thin; }.cmp-playlist [hidden] { display : none; }.cmp-playlist-items { margin : 0 ; padding : 7px 0 0 ; list-style : none; }.cmp-track { display : grid; width : 100% ; grid-template-columns : 27px minmax (0 , 1 fr); align-items : center; gap : 7px ; padding : 7px 8px ; border : 0 ; border-radius : 9px ; background : transparent; color : inherit; cursor : pointer; text-align : left; transition : background 0.16s ease, color 0.16s ease; }.cmp-track :hover ,.cmp-track .is-active { background : rgba (59 , 130 , 246 , 0.1 ); color : var (--cmp-accent-strong); }.cmp-track-index { color : #94a3b8 ; font-size : 11px ; text-align : center; }.cmp-track-text { display : block; min-width : 0 ; }.cmp-track-title ,.cmp-track-artist { display : block; overflow : hidden; white-space : nowrap; text-overflow : ellipsis; }.cmp-track-title { font-size : 12px ; font-weight : 550 ; }.cmp-track-artist { margin-top : 2px ; color : #8b98aa ; font-size : 10px ; }[data-theme='dark' ] .custom-music-player { color : rgba (255 , 255 , 255 , 0.84 ); }[data-theme='dark' ] .cmp-panel { border-color : rgba (96 , 165 , 250 , 0.18 ); background : rgba (20 , 29 , 43 , 0.94 ); box-shadow : 0 16px 46px rgba (0 , 0 , 0 , 0.42 ); }[data-theme='dark' ] .cmp-title { color : #edf5ff ; }[data-theme='dark' ] .cmp-artist ,[data-theme='dark' ] .cmp-progress-row ,[data-theme='dark' ] .cmp-status ,[data-theme='dark' ] .cmp-track-artist { color : #94a5ba ; }[data-theme='dark' ] .cmp-control { color : #afbed1 ; }[data-theme='dark' ] .cmp-control :hover ,[data-theme='dark' ] .cmp-control .is-active { background : rgba (96 , 165 , 250 , 0.14 ); color : #93c5fd ; }[data-theme='dark' ] .cmp-play { color : #fff ; }[data-theme='dark' ] .cmp-progress { background : #334155 ; }[data-theme='dark' ] .cmp-options { background : rgba (96 , 165 , 250 , 0.08 ); }[data-theme='dark' ] .cmp-volume { background : #334155 ; }[data-theme='dark' ] .cmp-mode-wrap ,[data-theme='dark' ] .cmp-mode { color : #afbed1 ; }[data-theme='dark' ] .cmp-mode option { background : #1d2939 ; color : #edf5ff ; }[data-theme='dark' ] .cmp-playlist { border-top-color : rgba (255 , 255 , 255 , 0.08 ); }[data-theme='dark' ] .cmp-track :hover ,[data-theme='dark' ] .cmp-track .is-active { background : rgba (96 , 165 , 250 , 0.13 ); color : #93c5fd ; }@keyframes cmp-note-pulse { 0% , 100% { transform : scale (1 ); opacity : 0.9 ; } 50% { transform : scale (1.15 ); opacity : 1 ; } }@keyframes cmp-cover-breathe { 0% , 100% { transform : scale (1 ); } 50% { transform : scale (1.045 ); } }@media (max-width : 600px ) { :root { --cmp-panel-width : calc (100vw - 64px ); } .custom-music-player { bottom : 18px ; } .cmp-panel { max-height : min (620px , calc (100vh - 100px )); } .cmp-playlist { max-height : min (250px , calc (100vh - 330px )); } .custom-music-player .is-open .cmp-toggle { left : calc (var (--cmp-panel-width) + 7px ); } }@media (prefers-reduced-motion : reduce) { .cmp-panel , .cmp-toggle { transition : none !important ; } .cmp-toggle-icon , .cmp-cover { animation : none !important ; } }
四、它是怎样工作的
1. 使用浏览器原生 Audio
播放器的核心并不是某个第三方 UI 库,而是一个原生音频对象:
1 2 var audio = document .createElement ('audio' ); audio.preload = 'metadata' ;
JavaScript 根据当前歌曲修改 audio.src,并监听 play、pause、timeupdate、ended 等事件,将真实播放状态同步到界面。
这种方式减少了对播放器框架的依赖,但浏览器的自动播放限制仍然存在:访客通常需要先点击一次播放按钮,网页才有权播放声音。
2. 动态创建界面
脚本会自行创建播放器并放入 document.body,因此接入时不需要在每个页面手写播放器容器:
1 2 3 4 5 6 var root = document .createElement ('div' ); root.id = 'custom-music-player' ; root.className = 'custom-music-player is-loading' ;document .body .appendChild (root);
这样特别适合静态博客:只要公共模板成功加载脚本,每篇文章都会自动得到同一个组件。
3. 从歌单读取曲目
当前版本使用第三方 Meting 兼容接口读取网易云歌单:
1 2 3 4 5 var PLAYLIST_ID = '12345678' ;var PLAYLIST_LIMIT = 100 ;var METING_API = 'https://api.i-meto.com/meting/api' + '?server=netease&type=playlist&id=' + PLAYLIST_ID ;
修改 PLAYLIST_ID 就可以换成自己的歌单。歌单链接通常类似:
1 https://music.163.com/playlist?id=12345678
其中 id= 后面的数字就是歌单 ID。PLAYLIST_LIMIT 用来限制一次加载的曲目数量,歌单很大或移动网络较慢时,可以适当调低。
这个接口不是网易云音乐官方接口,它可能受到接口服务状态、跨域策略或音乐平台规则变化的影响。正式长期使用时,建议准备自己可控的接口或使用有授权的音频文件。
4. 播放模式
播放器维护四种模式:
1 2 3 4 5 6 var PLAYBACK_MODES = { list : '列表循环' , shuffle : '随机播放' , sequential : '顺序播放' , single : '单曲循环' };
模式和音量会保存到 localStorage。访客下次打开网站时,播放器仍会使用上一次的偏好。
5. 跳过 30 秒试听歌曲(还在努力实现中……)
部分受版权限制的歌曲只能取得大约 30 秒的试听地址。当前代码会识别持续时间在约 28~32.5 秒之间的音频,并在播放时自动跳到下一首:
1 2 3 4 5 function isPreviewTrack ( ) { return Number .isFinite (audio.duration ) && audio.duration >= 28 && audio.duration <= 32.5 ; }
这只是改善连续播放体验,并不能让受限歌曲变成完整版。访客自己的网易云 VIP 身份也不会自动传递给一个独立博客中的 <audio> 元素。
五、部署到 Hexo + Butterfly
1. 放置静态文件
将下载包中的两个源码文件放到 Hexo 项目:
1 2 3 4 5 6 你的博客/ └── source/ ├── css/ │ └── music-player.css └── js/ └── music-player.js
Hexo 构建后,它们会分别成为:
1 2 /css/music-player.css /js/music-player.js
2. 修改歌单
打开 source/js/music-player.js,修改顶部配置:
1 2 var PLAYLIST_ID = '你的网易云歌单ID' ;var PLAYLIST_LIMIT = 100 ;
如果你使用自己的音乐接口,也可以替换 METING_API,但返回数据需要包含曲名、歌手、音频地址和封面等字段,或者同步修改 normalizeTracks() 中的字段映射。
3. 注入 Butterfly 的所有页面
打开博客根目录的 _config.butterfly.yml,在 inject 中加入:
1 2 3 4 5 inject: head: - <link rel="stylesheet" href="/css/music-player.css"> bottom: - <script defer src="/js/music-player.js"></script>
如果你以前启用过 APlayer 或主题自带的 Meting 播放器,为避免出现两个按钮,可以关闭主题的自动注入:
1 2 3 aplayerInject: enable: false per_page: true
请注意 YAML 的缩进;同一层级应保持一致,不要使用 Tab。
4. 本地检查
在博客根目录运行:
1 2 3 npm run clean npm run build npm run server
然后打开 http://localhost:4000/。除了首页,还应该进入一篇文章、分类页和标签页检查播放器是否都存在。
确认无误后,再使用你自己的部署命令:
六、为什么切换文章后音乐不会消失
Butterfly 可以通过 PJAX 只替换页面主体,而不进行完整刷新。但不同主题或插件可能会重建 body 中的一部分节点,导致普通悬浮组件被移除。
当前版本使用三层保护:
1 2 document .addEventListener ('pjax:complete' , handlePageReady);window .addEventListener ('pageshow' , handlePageReady);
首次打开页面时初始化
PJAX 切换完成后确认播放器仍在页面中
浏览器从前进/后退缓存恢复时重新确认
同时使用 MutationObserver 观察页面结构。如果播放器节点被主题移除,就把原来的节点 重新挂回页面,而不是创建新的播放器。这一点很重要:复用原节点才能保留同一个 <audio>、当前歌曲、进度和播放状态。
因此,“每个页面都有播放器”和“切换页面时不断歌”其实是两个不同要求:
全局注入,保证任何地址直接打开时都能初始化。
保留同一个实例,保证站内切换时音乐不中断。
七、部署到普通 HTML 网站
如果不是 Hexo,只需把两个文件上传到网站,并在所有需要播放器的页面中引入:
1 2 3 4 5 6 7 8 <head > <link rel ="stylesheet" href ="/assets/music-player.css" > </head > <body > <script defer src ="/assets/music-player.js" > </script > </body >
不需要额外添加 HTML 容器。脚本会在 DOM 准备完成后自动创建播放器。
多页面网站每次跳转都会真正刷新,音乐也会随之中断。这不是播放器失效,而是浏览器销毁了整个旧页面。若希望跨页面不断歌,需要使用 PJAX、单页应用路由,或将播放器放在不会被替换的外层页面中。
八、迁移到 WordPress、Vue 或 React
WordPress
可以在子主题的 functions.php 中用 wp_enqueue_style() 和 wp_enqueue_script() 引入文件,或者通过主题提供的“自定义代码”功能全站加载资源。不要直接修改会随升级被覆盖的父主题文件。
Vue / React
最简单的方法是仍将这个脚本作为静态资源引入,并确保只执行一次。更符合框架习惯的做法,则是把 DOM 模板改写成组件:
将播放器放在应用最外层布局,而不是路由页面内部
将歌曲、播放状态和模式存入全局状态
在组件卸载时清理事件监听
不再监听 pjax:complete,改用框架的路由或组件生命周期
无论使用哪种框架,核心的 Audio 控制、歌单数据格式和 CSS 设计都可以继续复用。
九、自定义外观
播放器的颜色、尺寸、阴影和展开距离都集中在 music-player.css 中。建议先从这些选择器开始:
1 2 3 4 5 6 7 8 9 10 11 .custom-music-player { }.cmp-launcher { }.cmp-panel { }
当前样式也提供暗色主题适配。不同主题标记暗色模式的方式不完全相同,如果你的主题使用 data-theme="dark",可以这样覆盖:
1 2 3 4 [data-theme='dark' ] .cmp-panel { background : rgba (18 , 24 , 38 , 0.92 ); color : #eef5ff ; }
修改后可以为资源地址增加版本号,避免浏览器继续使用旧缓存:
1 2 - <link rel="stylesheet" href="/css/music-player.css?v=2"> - <script defer src="/js/music-player.js?v=2"></script>
十、常见问题
1. 只有首页有播放器
通常是脚本只写进了首页模板,而没有全站注入。Butterfly 用户应检查 _config.butterfly.yml 的 inject.bottom,并在修改后执行一次 npm run clean。
2. 从首页进入文章后播放器消失
这通常与 PJAX 替换页面节点有关。请保留源码中的 pjax:complete、pageshow 和节点恢复逻辑,并确保没有另一段脚本重复删除播放器。
3. 面板出现了,但歌单全黑或一直加载
优先打开浏览器开发者工具的 Network 和 Console,检查:
歌单接口是否能够访问
请求是否被跨域策略拦截
接口是否返回错误或空数组
当前网络是否限制了音乐域名
HTTPS 网站是否请求了不安全的 HTTP 资源
歌单曲目很多会让加载稍慢,但通常不会让整个界面永久全黑。
4. VIP 歌曲为什么只能播放 30 秒
博客拿到的是接口公开返回的试听地址,不会携带访客在网易云客户端中的会员登录态。当前播放器会跳过识别到的 30 秒试听曲目,但无法取得其完整版。
5. 手机端不能通过悬浮展开
触屏设备没有真正的 hover 状态,因此必须保留点击展开逻辑。也要避免让其他侧边按钮覆盖播放器的 z-index 和点击区域。
6. 为什么浏览器不允许自动播放
现代浏览器通常禁止网页在没有用户操作时自动播放有声媒体。让访客主动点击播放,是兼容性最好也最尊重访问体验的方案。
十一、使用与版权说明
这份代码可以作为个人博客组件进行学习、修改和二次开发。代码本身不包含音乐文件,也不提供破解或绕过会员限制的能力。
部署时请注意:
只播放自己拥有版权、得到授权或平台允许公开播放的内容
不要把第三方接口的可用性视为永久保证
对公开站点准备接口故障时的降级提示
尊重访客,不默认自动播放有声音乐
结语
这个播放器最值得复用的并不只是它的外观,而是“把组件真正变成全站组件”的思路:原生 Audio 管理播放、CSS 管理呈现、公共模板负责注入、页面生命周期负责持久化。
因此,无论你使用 Hexo、Hugo、MkDocs、WordPress,还是自己编写的前端应用,都可以从同一份源码出发。Hexo 让部署过程更方便,但它不是实现播放器的前提。
如果你准备修改源码,建议先从歌单 ID、主题色和面板尺寸开始,再逐步调整数据接口与框架生命周期。愿每一次翻阅与写作,都有合适的声音陪伴。
AI 内容声明
本文包含 AI 辅助生成内容。文章的结构整理、部分文字表述与播放器代码由 AI 协助完成,并经过作者审阅、修改和实际部署验证。