import { Img, Span, Button, List, ListItem, UploadMusic, createElement } from './weigets.js' export default class MusicList { constructor({ list = [], EventListeners = {}, onplay, onstop, onadd, onremove, onlike, onunlike, onban, onload }) { this.event = { onplay, onstop, onadd, onremove, onlike, onunlike, onban, onload } // 添加音乐播放器 this.audio = new Audio() this.audio.autoplay = true this.audio.controls = true this.audio.style.margin = '0 auto' this.audio.style.flexShrink = 0 // 防止在flex中被挤压变形 this.audio.addEventListener('play', () => { this.event.onplay(this.playing) }) this.audio.addEventListener('ended', () => { this.next() }) //this.audio.addEventListener('timeupdate', () => { // console.log(this.audio.currentTime) //}) //this.audio.addEventListener('error', event => { // console.error('音乐播放器错误:', event) //}) this.ul = List({ classList: ['music-list'], style: { flex: 1, // 防止在flex中被挤压变形 textOverflow: 'ellipsis', // 文本溢出时省略号 whiteSpace: 'nowrap', // 不换行 overflowX: 'hidden', // 溢出时隐藏 overflowY: 'auto', // 溢出时显示滚动条 listStyle: 'disc', // 实心圆 padding: '0 1.1rem', // 列表左右留白 gap: '.1rem', // 列表项间隔 display: 'flex', // 列表垂直排列 flexDirection: 'column', // 列表垂直排列 } }) this.EventListeners = EventListeners this.list = [] list.forEach(item => this.add(item)) // 列表逐一添加 this.封面 = createElement({ style: { width: '6rem', height: '6rem', border: 'none', borderRadius: '1rem', backgroundImage: "url('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7')", backgroundSize: 'cover', position: 'relative' }, children: [ createElement({ style: { width: '100%', height: '100%', borderRadius: '1rem', backgroundColor: '#f8f8f8', color: '#f2f2f2', display: 'flex', justifyContent: 'center', alignItems: 'center', fontSize: '4rem', fontWeight: 'bold', textShadow: 'rgb(255, 255, 255) 0px 2px 2px', position: 'absolute', zIndex: -1, }, textContent: '♫', }), ] }) this.标题 = Span({ style: { fontSize: '1.2rem', fontWeight: 'bold', margin: '0 0 .5rem 0', }, textContent: '', }) // 收起到右上角, 音乐播放器基于浮窗定位, 不再占用页面空间 const element = createElement({ style: { position: 'fixed', top: '5rem', right: '1rem', backgroundColor: '#fff', padding: '.5rem', borderRadius: '1rem', cursor: 'pointer', width: '20rem', Height: '70vh', minWidth: '20rem', minHeight: '13rem', maxWidth: '80vw', maxHeight: '80vh', overflow: 'hidden', boxShadow: '0 0 1rem #eee', display: 'flex', flexDirection: 'column', fontSize: '12px', userSelect: 'none', zIndex: '10', resize: 'auto', }, onclick: event => { this.ul.classList.toggle('disable') }, onmousedown({ srcElement: dom, clientX, clientY, offsetX, offsetY }) { if (dom !== element) return if (offsetX > dom.clientWidth - 20 && offsetY > dom.clientHeight - 20) { document.onmouseup = () => { const [w, h] = [innerWidth - dom.clientWidth, innerHeight - dom.clientHeight] if (dom.offsetLeft > w) dom.style.width = innerWidth - dom.offsetLeft + 'px' if (dom.offsetTop > h) dom.style.height = innerHeight - dom.offsetTop + 'px' document.onmouseup = null localStorage.setItem('playerWH', dom.style.width + ',' + dom.style.height) } return } document.onmouseup = () => { const [w, h] = [innerWidth - dom.clientWidth, innerHeight - dom.clientHeight] if (dom.offsetLeft < 0) dom.style.left = '0px' if (dom.offsetTop < 0) dom.style.top = '0px' if (dom.offsetLeft > w) dom.style.left = w + 'px' if (dom.offsetTop > h) dom.style.top = h + 'px' document.onmouseup = document.onmousemove = null localStorage.setItem('playerXY', dom.style.left + ',' + dom.style.top) } document.onmousemove = ({ clientX: x, clientY: y }) => { dom.style.left = dom.offsetLeft - clientX + x + 'px' dom.style.top = dom.offsetTop - clientY + y + 'px'; [clientX, clientY] = [x, y] } }, children: [ createElement({ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, marginBottom: '.5rem', }, children: [ this.封面, createElement({ style: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, padding: '0 .5rem', }, children: [ this.标题, createElement({ style: { display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, }, children: [ Button({ textContent: '上一首', onclick: event => { event.stopPropagation() this.prev() } }), Button({ textContent: '暂停', onclick: event => { event.stopPropagation() if (this.audio.paused) { this.audio.play() event.target.textContent = '暂停' } else { this.audio.pause() event.target.textContent = '开始' } } }), Button({ textContent: '下一首', onclick: event => { event.stopPropagation() this.next() } }), ] }), ] }), ] }), this.audio, this.ul, UploadMusic({ style: { height: '5rem', margin: '1rem 0' }, onchange: files => { for (const file of files) { const id = 'music' + Date.now() const { name, size, type } = file const reader = new FileReader() reader.onload = async event => { const arrayBuffer = event.target.result this.add({ id, name, size, type, arrayBuffer }) // 添加到列表(默认并不存储) this.like({ id, name, size, type, arrayBuffer }) // 本地缓存的必要条件是喜欢 } reader.readAsArrayBuffer(file) } } }) ] }) document.body.appendChild(element) // 写入 css 样式到 head const style = document.createElement('style') style.innerText = ` ul.music-list { scrollbar-width: none; ms-overflow-style: none; } ul.music-list::-webkit-scrollbar { display: none; } ul.music-list > li { padding: 0; margin: 0; } ul.music-list > li > span { cursor: pointer; } ul.music-list > li.play > span { color: #02be08; } ul.music-list > li::before { content: '●'; color: #cccccc; font-size: 1em; } ul.music-list > li.cache::before { color: #02be08; } ul.music-list > li.disable { color: #999999; } ul.music-list > li > button { margin-left: 10px; border: none; border-radius: 1em; cursor: pointer; user-select: none; font-size: .5rem; padding: 0 .5rem; color: #555555; } ul.music-list > li > button:hover { background-color: #ccc; } ` document.head.appendChild(style) } add(item) { // 如果ID已存在则不添加 if (this.list.find(i => i.id === item.id)) { return } // 将字节转换为可读的单位 const bytesToSize = bytes => { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i] } this.list.push(item) this.ul.appendChild(ListItem({ id: item.id, classList: item.arrayBuffer ? ['cache'] : [], style: { display: 'flex', gap: '.25rem', maxWidth: '100%', alignItems: 'center', justifyContent: 'space-between', }, children: [ Img({ src: item.picture || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', style: { width: '2em', height: '2em', borderRadius: '.25em', backgroundColor: '#eee' }, }), Span({ style: { flex: 1, textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflowX: 'hidden', }, title: `${item.name} - ${bytesToSize(item.size)} - ${item.type}`, textContent: `${item.name} - ${bytesToSize(item.size)}`, onclick: event => { event.stopPropagation() // !如果使用async则此处不能阻止冒泡传递 const li = event.target.parentElement // ListItem const ul = li.parentElement // List const list = Array.from(ul.children) // ListItems list.forEach(li => li.classList.remove('play')) if (!this.audio.paused && this.playing === item) { li.classList.remove('play') this.stop(item) } else { li.classList.add('play') this.play(item) } } }), Button({ textContent: item.save ? '移除' : '缓存', onclick: event => { event.stopPropagation() // !如果使用async则此处不能阻止冒泡传递 if (item.save) { event.target.textContent = '缓存' this.ul.querySelector(`#${item.id}`).classList.remove('cache') this.unlike(item) } else { item.save = true event.target.textContent = '移除' this.ul.querySelector(`#${item.id}`).classList.add('cache') this.like(item) } } }) ] })) this.event.onadd(item, this.list) } async remove(item) { this.ul.querySelector(`#${item.id}`)?.remove() if (!this.audio.paused && item.id === this.playing?.id) this.stop() // 停止播放 this.list = this.list.filter(i => i.id !== item.id) this.event.onremove(item) } async load(item) { await this.event.onload(item) } async play(item) { if (!item.arrayBuffer) { console.log('加载音乐类型:', item.type) // 不支持流式加载wav和flac和m4a, 需要全部加载完毕才能播放 if (item.type === 'audio/wav' || item.type === 'audio/flac' || item.type === 'audio/x-m4a') { await this.load(item) this.audio.src = URL.createObjectURL(new Blob([item.arrayBuffer], { type: item.type })) this.audio.play() } else { // 边加载边播放 const mediaSource = new MediaSource() this.audio.src = URL.createObjectURL(mediaSource) if (!item.arrayBufferChunks) item.arrayBufferChunks = [] mediaSource.addEventListener('sourceopen', async () => { const sourceBuffer = mediaSource.addSourceBuffer(item.type) const arrayBufferLoader = async (index = 0) => { console.log('开始加载====================================') // 按照数据长度计算出分片应有数量, 如果数量不到且没有停止加载则一直读取 const chunkNumber = Math.ceil(item.size / 1024 / 64) // 64KB每片 console.log({ index, chunkNumber, paused: this.audio.paused }) while (index < chunkNumber && !this.audio.paused) { const 播放状态 = !this.audio.paused && this.playing === item const 加载状态 = item.arrayBufferChunks.length < chunkNumber const 结束时间 = sourceBuffer.buffered.length && sourceBuffer.buffered.end(0) const 缓冲时间 = 结束时间 - this.audio.currentTime if (!播放状态 && !加载状态) break // 播放停止且加载完毕则退出 if (this.audio.paused || this.playing !== item) break // 播放停止或已经切歌则退出 if (缓冲时间 > 60) { // 缓冲超过60秒则等待30秒 await new Promise(resolve => setTimeout(resolve, 30000)) continue } if (sourceBuffer.updating) { // sourceBuffer正在更新则等待更新结束 await new Promise(resolve => sourceBuffer.addEventListener('updateend', resolve)) continue } if (item.arrayBufferChunks.length <= index) { // 分片数量不足则等待 await new Promise(resolve => setTimeout(resolve, 200)) continue } console.log('播放器加载分片:', item.name, `${index + 1}/${chunkNumber}`) const chunk = item.arrayBufferChunks[index] // 顺序取出一个arrayBuffer分片 sourceBuffer.appendBuffer(chunk) // 添加到sourceBuffer index++ } console.log('加载完毕====================================') item.arrayBufferChunks = null // 加载完毕释放分片内存 } this.event.onload(item) this.audio.play() arrayBufferLoader() }) } } else { this.标题.textContent = item.name // 替换标题 this.封面.style.backgroundImage = `url(${item.picture})` // 替换封面图像 // 替换浏览器媒体信息(使系统通知栏显示歌曲信息) if ('mediaSession' in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ title: item.name, artist: '艺术家名', album: '专辑名', artwork: [ { src: item.picture, sizes: '96x96', type: 'image/jpeg' }, ] }) } // 本地缓存直接播放 this.audio.src = URL.createObjectURL(new Blob([item.arrayBuffer], { type: item.type })) this.audio.play() } this.playing = item this.event.onplay(item) } async stop() { if (this.audio.paused) { return console.error('暂停播放:音乐播放器不是播放状态!') } this.audio.pause() this.event.onstop(this.playing) this.playing = null } async like(item) { if (!item.arrayBuffer) { await this.load(item) } this.event.onlike(item, this.list) } async unlike(item) { this.event.onunlike(item, this.list) } async ban(item) { this.event.onban(item) } next() { } prev() { } }