试着加了一个 MIDI 播放器
起因
用了 Swup.js 和 Alpine.js 。想做一个简单的音乐播放器来着。以前搞 Hexo Next 的时候,就搞过全站音乐,pjax 支持下,换页音乐不断。
正好电脑上有很多 MIDI 文件,MIDI 文件很小。一个音乐几 KB。用来做博客的背景音乐刚好合适。不过缺点也很明显,MIDI 文件相当于谱子,不是音频文件,不支持人声,播放效果依赖音源库。
MIDI 是一种很老的技术了,不过现在一些领域还是在用。文件体积可以做到非常小——一首几分钟的曲子通常只有几十 KB,甚至几 KB。因为它存储的不是音频波形,而是音符、力度、音色这些演奏指令。缺点就是播放效果依赖音源库,同样的 MIDI 文件在不同设备上听起来可能不太一样。
实际数据大概是 30MB 125h
实现
MIDI 播放器放哪是个小问题。带有音乐播放器的博客一般放在左下角。我这直接模仿手机电脑搞了一个控制中心,放里面了。
使用了 MIDIjs - https://www.midijs.net/ ↗ 这是一个纯 JavaScript 的 MIDI 播放器,不需要任何插件,浏览器原生就能跑。
MIDI.js 有好几个同名的库,我也没弄明白哪个是哪个,能用就行。音源也不知道哪来的。好像是这个 https://github.com/babelsberg/babelsberg-js/blob/master/midijs/ ↗ 里的。
写起来不麻烦。很快写了个 shortcode 验证一下。
shortcode
这个 Hugo shortcode {{< midi >}},用在文章里可以直接嵌入一个曲目列表供读者点播。样式是简单的链接列表,点击曲目名称就开始播放。
这个 shortcode 的核心 Alpine.js 组件和控制中心大同小异。播放界面比较简洁(简陋),显示当前曲目、当前时间和总时长。
这个 shortcode 会自动加载 MIDIjs,MIDIjs 会先加载一个 3.5MB 的 midijs/lib/pat/arachno-127.pat ,加载完成后就可以播放那些只使用钢琴的 .mid 文件了。然后有其他乐器再加载其他的 .pat 文件。
<div x-data="{playing:'Nothing',time:0,length:0,loop:false,async init(){typeof MIDIjs==='undefined'&&await Somnia.prototype.loadResource({href:'//www.midijs.net/lib/midi.js',dataSomnia:'midi.js'});MIDIjs.player_callback=(msg)=>{this.time=parseInt(msg.time);if(this.loop&&msg.status==='finished'){this.play(this.playing)}}},play(path){this.playing=path;MIDIjs.play(path);MIDIjs.get_duration(path,(duration)=>{this.length=duration})},stop(){MIDIjs.stop()},pause(){console.log(this.time);MIDIjs.pause()},resume(){MIDIjs.resume()}}" class="flex flex-col p-4 midi-player">
<div class="pb-4">
Playing: <span x-text="playing"></span>
<span x-text="time"></span>s
<span x-text="length"></span>s
<br>
<a @click="stop();">Stop</a>
<a @click="pause();">Pause</a>
<a @click="resume();">Resume</a>
<a @click="loop = !loop">Loop <span x-text="loop ? 'On' : 'Off'"></span></a>
</div>
<a @click="play('/midi/midi_某科学的超电磁炮 - only my railgun.mid');">Play midi_某科学的超电磁炮 - only my
railgun.mid</a>
<a @click="play('/midi/未闻花名secret-base.mid');">Play 未闻花名secret-base.mid</a>
<a @click="play('/midi/卡农.mid');">Play 卡农.mid</a>
<a @click="play('/midi/崩坏3「最后一课」插曲-Night Glow.mid');">Play 崩坏3「最后一课」插曲-Night Glow.mid</a>
<a @click="play('/midi/团子大家族.mid');">Play 团子大家族.mid</a>
<a @click="play('/midi/midi_久石让 - 天空之城.mid');">Play midi_久石让 - 天空之城.mid</a>
<a @click="play('/midi/原神-我们终将重逢.mid');">Play 原神-我们终将重逢.mid</a>
<a @click="play('/midi/千与千寻的神隐 - あの日の川「铃声版」.mid');">Play 千与千寻的神隐 - あの日の川「铃声版」.mid</a>
<a @click="play('/midi/[67018]【缘之空】Old Memory(完整版).midi');">Play [67018]【缘之空】Old
Memory(完整版).midi</a>
<a @click="play('/midi/midi_崩坏3rd - Rubia.mid');">Play midi_崩坏3rd - Rubia.mid</a>
<a @click="play('/midi/昔涟.mid');">Play 昔涟.mid</a>
</div>
<style>.midi-player a {cursor: pointer;}</style>
<!--
<script>
function midiComponent() {
return {
playing: 'Nothing',
time: 0,
length: 0,
// 循环
loop: false,
async init() {
// https://www.midijs.net/
typeof MIDIjs === 'undefined' && await Somnia.prototype.loadResource({ href: '//www.midijs.net/lib/midi.js', dataSomnia: 'midi.js' });
MIDIjs.player_callback = (msg) => {
// console.log(msg);
this.time = parseInt(msg.time);
if(this.loop && msg.status === 'finished') {
this.play(this.playing);
}
};
},
play(path) {
this.playing = path;
MIDIjs.play(path);
MIDIjs.get_duration(path, (duration) => {
this.length = duration;
})
},
stop() {
MIDIjs.stop();
},
pause() {
console.log(this.time);
MIDIjs.pause();
},
resume() {
MIDIjs.resume();
}
}
}
</script>
-->html
控制中心版
控制中心里的实现大概是这样:
- 在控制中心面板的 music 区域绑了一个
@mousemove="initMusic()",鼠标移过去才开始加载,避免不必要的请求。 - 曲目列表存在
/midi/midi.txt里,按行分隔。播放时从列表里随机选一首。如果开启了循环模式,当前曲目播完后会重新播放同一首,否则自动随机切下一首。
这个版本读取的 txt 做音乐列表。随时可以加新的,往 /content/midi/ 目录下丢个 .mid 文件,在 midi.txt 里加一行就行。
<div x-data="controlCenterComponent()" x-ignore id="control-center-container">
<button
class="group/dark box-content size-5 rounded-md border p-1.5 transition-colors hover:bg-border sm:group-[.not-top]:rounded-xl"
@click="btnClick()" x-ref="controlCenterBtn">
<!-- https://www.svgrepo.com/svg/332223/appstore COLLECTION: Ant Design Outlined Icons LICENSE: MIT License AUTHOR: Ant Design-->
<svg fill="currentColor" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" class="icon">
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
<g id="SVGRepo_iconCarrier">
<path
d="M464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H212V212h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H612V212h200v200zM464 544H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H212V612h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H612V612h200v200z">
</path>
</g>
</svg>
</button>
<template x-teleport="#main-container">
<div class="w-full" x-show="controlCenterOpen" id="control-center" x-transition>
{{/* <div class="fixed left-0 w-full h-full cc-bg" style="top:0rem;z-index: 25;"></div> */}}
<div class="fixed p-2 rounded-xl cc-bg border-b border-l" @click.outside="controlCenterOpen = false"
x-ref="controlCenter">
<div class="animate flex flex-col gap-y-3 this w-full p-2"
style="overflow-y: scroll;height: calc(100vh - 7rem);">
<div class="rounded-2xl p-2 border">
<a class="text-sm mb-3 px-1 text-muted-foreground" href='{{"about"|relURL}}'>恐咖兵糖</a>
</div>
<div class="rounded-2xl p-4 border" @mousemove="initMusic()">
<!-- Music -->
<h3 class="text-sm mb-3 px-1 text-muted-foreground">Music <span x-text="musicInfo"></span></h3>
<div class="px-1">
Playing: <span x-text="playing"></span>
<span x-text="time"></span>s
<span x-text="length"></span>s
<br>
<a @click="play()" class="cursor-pointer">Random</a>
<a @click="stop()" class="cursor-pointer">Stop</a>
<a @click="pause()" class="cursor-pointer">Pause</a>
<a @click="resume()" class="cursor-pointer">Resume</a>
<a @click="loop = !loop" class="cursor-pointer" :class="loop ? 'text-primary' : ''">Loop</a>
</div>
</div>
<div class="rounded-2xl p-4 border">
<h3 class="text-sm mb-3 px-1 text-muted-foreground">Background</h3>
<div class="flex gap-x-2 px-1">
<button @click="setBackground('#203880')">Default</button>
<button @click="setBackground('#fff')">Light</button>
<button @click="setBackground('#000')">Dark</button>
<button @click="setBackground()">Random</button>
</div>
</div>
<div class="rounded-2xl p-4 border">
<h3 class="text-sm mb-3 px-1 text-muted-foreground">
<a href='{{"posts"|relURL}}'>Recent</a>
</h3>
<div class="flex flex-col gap-y-2 px-1">
{{ $posts := where site.RegularPages "Type" "posts" }}
{{ range first 3 $posts }}
<a href='{{.Permalink}}'>{{.Title}}</a>
{{ end }}
</div>
</div>
<div class="rounded-2xl p-4 border">
<h3 class="text-sm mb-3 px-1 text-muted-foreground"><a href='{{"whispers"|relURL}}'>Whispers</a>
</h3>
<a x-html="whisper" class="px-1" href='{{"whispers"|relURL}}'
style="white-space:pre-wrap;max-height: 10px"></a>
</div>
</div>
</div>
</div>
</template>
</div>
<script>
function controlCenterComponent() {
return {
controlCenterOpen: false,
observer: null,
whisper: '我们的征途是星辰大海!',
playing: '',
musicInit: false,
musicInfo: '',
time: 0,
length: 0,
loop: false,
musicList: ['昔涟.mid'],
init() {
// console.log("[Somnia] [ControlCenter] Loaded");
this.observer = new ResizeObserver(e => {
if (this.controlCenterOpen) {
this.controlCenterOpen = false;
}
})
this.observer.observe(document.body);
this.fetchWhisper();
},
// Music
async initMusic() {
if (!this.musicInit && typeof MIDIjs === 'undefined') {
this.musicInit = true;
await Somnia.prototype.loadResource({ href: '//www.midijs.net/lib/midi.js', dataSomnia: 'midi.js' });
MIDIjs.player_callback = (msg) => {
this.time = parseInt(msg.time);
if (this.loop && msg.status === 'finished') {
this.play(this.playing);
} else if (msg.status === 'finished') {
this.play();
}
};
fetch('/midi/midi.txt?v=' + VERSION)
.then(res => res.text())
.then(text => {
this.musicList = text.trim().split('\n');
})
}
},
sleep: (delay) => new Promise((resolve) => setTimeout(resolve, delay)),
retry: 0,
async play(next = null) {
this.musicInfo = '';
try {
this.playing = next || this.musicList[Math.floor(Math.random() * this.musicList.length)];
console.log("[Somnia] [Music]", this.playing);
MIDIjs.play('/midi/' + this.playing);
MIDIjs.get_duration('/midi/' + this.playing, (duration) => {
this.length = duration;
})
} catch (e) {
console.error(e);
this.musicInfo = 'Not Ready';
await this.sleep(1000);
if (this.retry < 5) {
this.retry++;
this.play();
} else {
this.musicInfo = 'Load Failed';
}
}
},
stop() {
MIDIjs.stop();
},
pause() {
MIDIjs.pause();
},
resume() {
MIDIjs.resume();
},
fetchWhisper() {
const date = `${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-')}`;
fetch(`https://note.ftls.xyz/microblog/whispers1.json?v=${date}`)
.then(res => res.json())
.then(obj => {
this.whisper = this.mdToHtml(obj.whispers[0].content);
});
},
mdToHtml(md) {
let html = md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="" loading="lazy">');
html = html.replace(/\[([^\]]*)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
return html;
},
setBackground(color) {
if (color) {
document.documentElement.style.setProperty('--highlightColor', color)
return;
}
const r = Math.round(Math.random() * 255);
const g = Math.round(Math.random() * 255);
const b = Math.round(Math.random() * 255);
document.documentElement.style.setProperty('--highlightColor', `rgb(${r}, ${g}, ${b})`);
},
btnClick() {
const rect = this.$refs.controlCenterBtn.getBoundingClientRect();
const panel = this.$refs.controlCenter;
// 判断屏幕 >=640
if (window.innerWidth >= 640) {
panel.style.top = rect.bottom + 16 + 'px';
panel.style.left = rect.left - 384 + 30 + 'px';
// panel.style.width = '384px';
panel.style.width = '384px';
panel.style.height = 'auto';
panel.style.paddingTop = 0;
panel.style.zIndex = 50;
} else {
panel.style.top = 0;
panel.style.left = 0;
panel.style.width = '100%';
panel.style.height = '100%';
panel.style.paddingTop = 5 + 'rem';
panel.style.zIndex = 25;
}
this.controlCenterOpen = !this.controlCenterOpen;
},
destroy() {
this.observer?.disconnect();
this.observer = null;
}
}
}
</script>
<style>
#main-container .cc-bg {
backdrop-filter: saturate(130%) blur(64px);
-webkit-backdrop-filter: blur(64px);
transform: translateZ(0);
}
</style>html
写完发现几个小问题,源码 5kb 有点大。一个 404 页面总共才 不到 10kb。所以我搞成了单独的 HTML ,js fetch 加载。所以初次进入博客的时候会闪一下。
然后就是由于放的位置比较靠后,元素聚焦顺序靠后,键盘导航体验不是很好。比较反直觉。再就是比较经典的遮蔽罩滚动问题。不过没再优化。
搞成一个 HTML 文件,放在对应位置,然后 js 加载一下 /control-center/index.html里的 js 就行了。
// 全局变量
const VERSION = 'v{{ now.Format "2006-0102-1504" }}';
// ......
function initControlCenter() {
fetch('/control-center/index.html?v=' + VERSION)
.then(response => response.text())
.then(html => {
const container = document.createElement('div');
container.innerHTML = html;
document.getElementById('toggleMenu').after(container);
const jsElement = document.getElementById('control-center-container').nextElementSibling;
if (jsElement.tagName.toLowerCase() === 'script') {
const newScript = document.createElement('script');
newScript.innerHTML = jsElement.innerHTML;
document.head.appendChild(newScript);
jsElement.remove();
document.getElementById('control-center-container').removeAttribute('x-ignore');
}
})
.catch(error => {
console.error('[Somnia] 加载控制中心失败:', error);
});
}
initControlCenter();js
最后
有兴趣的可以点开控制中心试试。