Compare commits

..

28 Commits

Author SHA1 Message Date
b72be4e004 加入 git 地址 2024-09-04 20:15:57 +08:00
c3c0a37a41 替换发送为图标 2024-03-08 14:04:12 +08:00
03e19fbf58 移除对 indexeddb 的额外封装 2024-03-08 11:03:09 +08:00
099e36e1b5 使用 idb 简化 indexeddb 操作 2024-03-08 11:02:37 +08:00
db8b33299c Merge branch 'main' of git.satori.love:LaniakeaSupercluster/webrtc
没有说明
2023-12-21 13:00:33 +08:00
231e7f4376 补充安装文档 2023-12-21 13:00:01 +08:00
3dd7e0fa0f 更改重载方式 2023-12-11 17:32:02 +08:00
73d895dc2b 棋子 2023-11-01 20:00:22 +08:00
da3b4e19e9 修正线条和文字 2023-11-01 18:15:38 +08:00
5e0552030a 棋盘可调尺寸 2023-11-01 18:04:26 +08:00
8e18d19b7d 可调边距 2023-11-01 17:47:42 +08:00
5915c717a0 收起音乐列表 2023-11-01 16:42:06 +08:00
2629f262ef 聊天气泡背景色 2023-11-01 01:04:20 +08:00
de06553a8d 棋盘无边距 2023-11-01 00:45:38 +08:00
9dfbb9cd29 棋盘染色 2023-11-01 00:42:02 +08:00
ca924af85b 象棋 2023-10-31 23:51:21 +08:00
6c30ebfc5d 避免列表还未生成 2023-10-31 15:57:58 +08:00
662db2a75b 使用默认图像 2023-10-26 03:18:58 +08:00
a232ece058 防止加载不存在的封面 2023-10-26 03:09:06 +08:00
253a4b7134 前台运行时也播放消息提示音 2023-10-25 23:05:37 +08:00
4ea9a51740 微弱提示音 2023-10-25 22:56:51 +08:00
ae224bd983 通知支持头像 2023-10-25 22:37:28 +08:00
175db8c90c 消息通知 2023-10-25 22:21:37 +08:00
65554afb2f 音乐列表间隙 2023-10-24 10:45:04 +08:00
d43adff7b4 使用 before 取代 marker 2023-10-24 10:40:02 +08:00
3f1fc3b709 修正初始滚动 2023-10-24 10:26:22 +08:00
8b2fd539b4 移除废弃文件 2023-10-24 10:11:19 +08:00
4519a6701e Refactor music player UI 2023-10-24 08:55:24 +08:00
17 changed files with 523 additions and 777 deletions

View File

@@ -1,14 +1,23 @@
# webRTC # webRTC
webrtc 实现的 p2p 信道 webrtc 实现的 p2p 信道
rtc rtc rtc: 稳定, 多重连接 ```bash
channel channel channel: 细流 # 使用 git 克隆到本地或者直接下载zip压缩包
part-server: 调谐, 从不同服务器请求资源分片 git clone git@git.satori.love:LaniakeaSupercluster/webrtc.git
webrtc://用户@域名:端口/信道标识/资源ID cd webrtc
封包格式 # 安装依赖
资源ID 分片信息(位置) 分片数据 npm i
# 编译
npm run build
# 运行服务
npm run start
# 或者使用 pm2 作为守护进程
pm2 start npm --name webrtc -- run start
```
插件市场 插件市场
1. 从浏览器创建插件(单文件) 1. 从浏览器创建插件(单文件)

38
demo.html Normal file
View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>demo</title>
</head>
<body>
<script type="module">
import GUN from 'gun'
import SEA from 'gun/sea'
const gun = GUN({
peers: ['http://localhost:8000/gun'],
localStorage: false
})
gun.get('text').once((node) => {
console.log("Receiving Initial Data")
console.log(node)
})
gun.get('text').put({
text: 'Hello World'
})
gun.get('text').on((node) => {
console.log("Receiving Update")
console.log(node)
})
document.addEventListener('click', () => {
gun.get('text').put({
text: 'Hello World'
})
})
</script>
</body>
</html>

12
demo.js Normal file
View File

@@ -0,0 +1,12 @@
import express from 'express'
import gun from 'gun'
const app = express()
const port = 8000
app.use(gun.serve)
const server = app.listen(port, () => {
console.log("Listening at: http://localhost:" + port)
})
gun({web: server})

View File

@@ -121,11 +121,8 @@ app.ws('/entanglement', (ws, req) => {
// WEBHOOK 处理 GitHub 事件 // WEBHOOK 处理 GitHub 事件
app.post('/webhook', (req, res) => { app.post('/webhook', (req, res) => {
console.log('WEBHOOK:' + new Date().toLocaleString()) console.log('WEBHOOK:' + new Date().toLocaleString())
exec('git pull;npm i;npm run build') exec('git pull;npm i;npm run build;pm2 reload webrtc;')
return res.json({ success: true }) return res.json({ success: true })
}) })
// 启动时build
exec('npm run build')
app.listen(4096, () => console.log('Server started on port 4096')) app.listen(4096, () => console.log('Server started on port 4096'))

View File

@@ -23,6 +23,7 @@
"buffer": "^6.0.3", "buffer": "^6.0.3",
"express": "^4.18.2", "express": "^4.18.2",
"express-ws": "^5.0.2", "express-ws": "^5.0.2",
"gun": "^0.2020.1239",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"iconv-lite-umd": "^0.6.10", "iconv-lite-umd": "^0.6.10",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",

View File

@@ -1,119 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Cloud Upload Icon</title>
<style>
#box {
width: 20rem;
height: 5rem;
backdrop-filter: blur(5px);
background-color: rgb(252, 252, 252);
border-radius: 1em;
border: 1px solid rgb(241, 241, 241);
position: relative;
user-select: none;
cursor: pointer;
margin: 1rem 2rem;
}
#box:hover > #drop {
display: block;
}
#cloud {
width: 82px;
height: 30px;
background: #e7f4fd;
background: -webkit-linear-gradient(top,#e7f4fd 5%,#ceedfd 100%);
border-radius: 25px;
position: relative;
margin: 33px auto 5px;
}
#cloud:before {
width: 45px;
height: 45px;
top: -22px;
right: 12px;
border-radius: 50px;
}
#cloud:after {
width: 25px;
height: 25px;
top: -12px;
left: 12px;
border-radius: 25px;
}
#cloud:before,
#cloud:after {
content: "";
background: #e7f4fd;
position: absolute;
z-index: -1;
}
#cloud span {
width: 87px;
position: absolute;
bottom: -2px;
background: #000;
z-index: -1;
box-shadow: 0 0 6px 2px rgba(0,0,0,0.1);
border-radius: 50%;
}
#drop {
width: 100%;
color: #999;
line-height: 5rem;
text-align: center;
position: absolute;
top: 0;
display: none;
}
</style>
<script>
onload = () => {
const file = document.createElement('input')
file.type = 'file'
file.onchange = event => {
for (const i of file.files) {
console.log('已获取文件:', i)
}
file.value = null
}
box.onclick = () => file.click()
let dragStats = 0
document.ondragover = e => {
e.preventDefault()
if(dragStats) return
drop.style.display = 'block'
}
document.ondragleave = e => {
e.preventDefault()
if(dragStats) return
dragStats = setTimeout(() => {
dragStats = 0
drop.style.display = ''
}, 1000)
}
document.ondrop = e => {
e.preventDefault()
dragStats = 0
drop.style.display = ''
const files = e.dataTransfer.files
for (const i of files) {
console.log('已获取文件:', i)
}
}
}
</script>
</head>
<body>
<div id="box">
<div id="cloud"><span></span></div>
<span id="drop">点击或拖拽音乐到此处共享您的音乐</span>
</div>
</body>
</html>

View File

@@ -1,36 +0,0 @@
// 动态分片流
// 1. 默认提供分片流
// 2. 允许请求者指定数据段, 返回相应分片, 发送队列可以并行, 接收队列在检查没有新数据时, 补充要求失败的分片
// 3. 可靠模式下分片是完整的, 但响应不是立即的, 当请求超时时放弃接受和发送队列中的分片
// 4. 同一个资源可以向不同的提供者请求, 将不同区段的分片合并然后存储
// 5. 不需要验证hash, 但是需要验证顺序, 发送者应当标记这个分片的开始和结束位置
// 资源id, 开始位置, 结束位置, 数据
// 资源的其他信息, 如hash, 大小, 类型等, 由其他方式获取
// 并不信任发送者, 所以必须验证发送者的分片HASH, 所以资源信息里应当包含每个分片的HASH
// 但如果每个HASH都要验证, 那么验证的成本太高, 所以应当有一个HASH验证的阈值, 例如每100个分片验证一次, 如果验证失败, 则放弃这个分片, 重新请求
// 数据块, 每个数据块验证一次HASH, 如果验证失败, 则放弃这个数据块, 重新请求
// 数据块的大小应当是2的幂, 例如2, 4, 8, 16, 32, 64, 128, 256, 512, 1024等
// 数据块的大小应当是分片的倍数, 例如分片大小是1024, 那么数据块大小应当是1024的倍数, 例如1024, 2048, 4096等
/**
* {
* id: 'resource id',
* type: 'resource type',
* size: 'resource size',
* hash: 'resource hash',
* blocks: [
* 'md5 hash of block 1',
* 'md5 hash of block 2',
* 'md5 hash of block 3',
* ]
* }
*/
// 分片交叉验证
// 1. 每个分片都没有hash
// 2. 文件有一个完整的hash
// 3. 文件取4个交叉验证维度
// 4. 16个HASH验证1GB文件
// 5. 且只在完整hash验证失败时才验证分片

View File

@@ -1,192 +0,0 @@
export default class Entanglement {
constructor({ options }) {
this.event = {}
this.options = options
this.store = {}
this.users = []
this.channels = [{ name: 'json' }]
this.ws = this.__create_websocket()
}
async __create_webrtc(user) {
const pc = new RTCPeerConnection(this.options)
// 当有新的媒体流加入时触发
pc.onicecandidate = (event) => {
if (event.candidate) {
console.debug(user.name, '发出 candidate 候选通道')
this.ws.send(JSON.stringify({ type: 'candidate', user, candidate: event.candidate }))
}
}
// 当连接状态发生改变时触发
pc.oniceconnectionstatechange = (event) => {
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
console.error(user.name, '需要添加新的 candidate')
} else if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
console.debug(user.name, 'WebRTC 连接已经建立成功')
}
}
// 协商新的会话, 建立初始连接或在网络条件发生变化后重新协商连接
pc.onnegotiationneeded = async (event) => {
console.log('onnegotiationneeded', event)
//const offer = await pc.createOffer()
//await pc.setLocalDescription(offer)
//this.ws.send(JSON.stringify({ type: 'offer', user, offer }))
}
// 当有新的媒体流加入时触发
pc.ontrack = (event) => {
console.log('ontrack', event)
}
// 当有新的数据通道加入时触发
pc.ondatachannel = event => {
console.log(user.name, '建立', event.channel.label, '接收通道')
event.channel.onmessage = event => {
console.log(user.name, '发来', event.target.label, '通道消息', event.data)
}
event.channel.onopen = () => {
console.log(user.name, '打开', event.channel.label, '接收通道')
//event.channel.send(JSON.stringify({ name: 'sato', hello: 'world' }))
}
event.channel.onclose = () => {
console.log(user.name, '关闭', event.channel.label, '接收通道')
}
event.channel.onerror = () => {
console.log(user.name, '通道', event.channel.label, '发生错误')
}
}
// 创建数据通道
user.channels = this.channels.map(item => {
const channel = pc.createDataChannel(item.name, { reliable: true })
channel.onopen = () => {
console.log('打开数据发送通道')
channel.send(JSON.stringify({ name: 'sato', hello: 'world' }))
}
channel.onmessage = event => {
console.log('收到数据发送通道消息', event.data)
}
channel.onclose = () => {
console.log('关闭数据发送通道')
}
channel.onerror = () => {
console.log('发送通道发生错误')
}
return { channel, ...item }
})
return pc
}
async __create_websocket() {
const host = window.location.host
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${protocol}://${host}/entanglement?name=sato&channel=chat`)
ws.onopen = async () => {
console.log('websocket 连接成功')
if (this.ws instanceof Promise) {
this.ws = await this.ws
}
}
ws.onclose = async () => {
console.log('websocket 连接关闭, 3s后尝试重新连接...')
await new Promise(resolve => setTimeout(resolve, 3000))
this.ws = await this.__create_websocket()
}
ws.onerror = async () => {
console.log('websocket 连接错误, 3s后尝试重新连接...')
await new Promise(resolve => setTimeout(resolve, 3000))
this.ws = await this.__create_websocket()
}
ws.onmessage = async event => {
const data = JSON.parse(event.data)
if (data.type === 'list') {
console.debug('收到在线列表', data.list)
await Promise.all(data.list.map(async user => {
console.debug('发送给', user.name, 'offer')
const pc = await this.__create_webrtc(user)
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
this.users.push({ ...user, webrtc: pc }) // 必须在send之前存入
this.ws.send(JSON.stringify({ type: 'offer', user, offer }))
}))
return
}
if (data.type === 'push') {
console.debug(data.user.name, '上线', data)
return
}
if (data.type === 'pull') {
console.debug(data.user.name, '下线', data)
return
}
if (data.type === 'offer') {
console.debug(data.user.name, '发来 offer')
const pc = await this.__create_webrtc(data.user)
await pc.setRemoteDescription(data.offer)
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
this.ws.send(JSON.stringify({ type: 'answer', user: data.user, answer }))
this.users.push({ ...data.user, webrtc: pc })
return
}
if (data.type === 'answer') {
console.debug(data.user.name, '发来 answer')
const pc = this.users.find(user => user.id === data.user.id).webrtc
await pc.setRemoteDescription(data.answer)
return
}
if (data.type === 'candidate') {
console.debug(data.user.name, '发来 candidate 候选通道', JSON.stringify(this.users))
const pc = this.users.find(user => user.id === data.user.id).webrtc
await pc.addIceCandidate(data.candidate)
return
}
console.error('收到未知数据:', data)
}
return ws
}
// 向所有在线的用户广播消息(webrtc)
send_all(channel_name, data) {
console.log('向', channel_name, '频道广播消息:', data)
console.log('在线用户:', this.users)
this.users.forEach(async user => {
console.log('向', user.name, '发送', channel_name, '频道消息:', data)
const ch = user.channels.find(item => item.name === channel_name).channel
// 等待 datachannel 打开(临时解决方案)
while (ch.readyState !== 'open') {
await new Promise(resolve => setTimeout(resolve, 100))
}
console.log('完成发送', channel_name, '频道消息:', data)
ch.send(JSON.stringify(data))
})
}
// 数据被修改时触发
set(name, data) {
// 递归创建代理对象
const useProxy = (obj, path = []) => {
const proxy = new Proxy(obj, {
set: (target, key, value) => {
if (!Array.isArray(target) || key !== 'length') {
console.log('对象被修改', [...path, key], value)
this.send_all(name, { key: [...path, key], value }) // 向所有在线的用户广播消息
}
return Reflect.set(target, key, value)
},
get: (target, key) => {
return target[key]
}
})
Object.keys(obj).forEach(key => {
if (typeof obj[key] === 'object') {
obj[key] = useProxy(obj[key], [...path, key])
}
})
return proxy
}
return Reflect.set(this.store, name, useProxy(data))
}
// 读取一个通道
get(name) {
return this.store[name]
}
}

5
public/remove.js Normal file
View File

@@ -0,0 +1,5 @@
import { Chessboard } from './ChineseChess.js'
// 中国象棋
const chessboard = new Chessboard()
chessboard.绘制棋盘({比例: 48, 边距: 20})

13
public/send.svg Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 495.003 495.003" xml:space="preserve">
<g id="XMLID_51_">
<path id="XMLID_53_" d="M164.711,456.687c0,2.966,1.647,5.686,4.266,7.072c2.617,1.385,5.799,1.207,8.245-0.468l55.09-37.616
l-67.6-32.22V456.687z"/>
<path id="XMLID_52_" d="M492.431,32.443c-1.513-1.395-3.466-2.125-5.44-2.125c-1.19,0-2.377,0.264-3.5,0.816L7.905,264.422
c-4.861,2.389-7.937,7.353-7.904,12.783c0.033,5.423,3.161,10.353,8.057,12.689l125.342,59.724l250.62-205.99L164.455,364.414
l156.145,74.4c1.918,0.919,4.012,1.376,6.084,1.376c1.768,0,3.519-0.322,5.186-0.977c3.637-1.438,6.527-4.318,7.97-7.956
L494.436,41.257C495.66,38.188,494.862,34.679,492.431,32.443z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 973 B

View File

@@ -1,94 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>webRTC</title>
</head>
<body>
<div>
<h1>Entanglement</h1>
<p>同步</p>
</div>
<script type="module">
import { List, ListItem, useProxy } from './weigets.js'
//import Entanglement from './entanglement.js'
//const entanglement = new Entanglement({
// options: {
// iceServers: [
// {
// urls: 'turn:satori.love:3478?transport=udp',
// username: 'x-username',
// credential: 'x-password'
// },
// {
// urls: [
// 'stun:stun.1und1.de',
// 'stun:stun.callwithus.com',
// 'stun:stun.ekiga.net',
// 'stun:stun.fwdnet.net',
// 'stun:stun.fwdnet.net:3478',
// 'stun:stun.gmx.net',
// 'stun:stun.iptel.org',
// 'stun:stun.internetcalls.com',
// 'stun:stun.minisipserver.com',
// 'stun:stun.schlund.de',
// 'stun:stun.sipgate.net',
// 'stun:stun.sipgate.net:10000',
// 'stun:stun.softjoys.com',
// 'stun:stun.softjoys.com:3478',
// 'stun:stun.voip.aebc.com',
// 'stun:stun.voipbuster.com',
// 'stun:stun.voipstunt.com',
// 'stun:stun.voxgratia.org',
// 'stun:stun.wirlab.net',
// 'stun:stun.xten.com',
// 'stun:stunserver.org',
// 'stun:stun01.sipphone.com',
// 'stun:stun.zoiper.com'
// ]
// }
// ],
// iceCandidatePoolSize: 10, // 限制 ICE 候选者的数量
// iceTransportPolicy: 'all', // 使用所有可用的候选者
// bundlePolicy: 'balanced', // 每種類型的內容建立一個單獨的傳輸
// }, server: {}
//})
//entanglement.set('json', {
// users: { name: 'users', list: [{ name: 'satori' }] },
// music: { name: 'music', list: [{ name: 'satori' }] },
// image: { name: 'image', list: [{ name: 'satori' }] }
//})
//await new Promise(resolve => setTimeout(resolve, 3000))
const store = useProxy({
users: { name: 'users', list: [{ name: 'satori' },{ name: 'sato' }] },
music: { name: 'music', list: [{ name: 'satori' },{ name: 'sato' }] },
image: { name: 'image', list: [{ name: 'satori' },{ name: 'sato' }] },
}, data => {
//console.log('触发了:', data.key, data.value)
console.log('当前数据:', store)
})
//console.log('当前数据:', JSON.stringify(store))
delete store.users.list[1]
//console.log('当前数据:', JSON.stringify(store))
store.users.list.push({ name: 'koishi' })
//store.users.list.push({ name: 'koishi' })
//store.users.list.push({ name: 'koishi' })
//console.log('当前数据:', JSON.stringify(store))
//document.body.appendChild(List({
// children: store.users.list.map(user => ListItem({ textContent: user.name })),
// onclick: () => {
// if (event.target.tagName === 'LI') {
// console.log('点击了:', event.target.textContent)
// }
// //store.users.name = 'koishi'
// store.users.list.push({ name: 'koishi' })
// //store.users.list.pop()
// // 在数据从其他位置被删除时也能删除这里的元素
// }
//}))
</script>
</body>
</html>

228
src/ChineseChess.js Normal file
View File

@@ -0,0 +1,228 @@
import { createElement } from './weigets.js';
// 棋子
class Chessman {
constructor(color) {
this.color = color;
this.name = '';
this.position = '';
this.status = '';
this.move = '';
this.rule = '';
this.display = '';
this.operation = '';
}
// 绘制棋子
draw() {
const chessman = createElement({
style: {
backgroundColor: this.color,
},
});
}
}
// 单独绘制棋盘
export function 棋盘(比例, 边距, 背景色 = '#ffeedd', 线条色 = '#784518', 线条宽度 = 2) {
const 宽度 = (比例 * 8) + (边距 * 2)
const 高度 = (比例 * 9) + (边距 * 2)
const 画布 = createElement({ width: 宽度, height: 高度 }, 'canvas')
const ctx = 画布.getContext('2d')
ctx.strokeStyle = 线条色
ctx.lineWidth = 线条宽度
// 横线竖线
for (let i = 0; i < 10; i++) {
ctx.moveTo(边距, 比例 * i + 边距)
ctx.lineTo(比例 * 8 + 边距, 比例 * i + 边距)
ctx.moveTo(比例 * i + 边距, 边距)
ctx.lineTo(比例 * i + 边距, 比例 * 9 + 边距)
}
画布.style.backgroundColor = 背景色
document.body.appendChild(画布)
}
// 棋盘
export class Chessboard {
constructor() {
this.position = '';
this.move = '';
this.rule = '';
this.display = '';
this.operation = '';
}
// 初始化绘制棋盘
绘制棋盘({ 比例, 边距, 背景色 = '#ffeedd', 线条色 = '#784518', 线条宽度 = 2 }) {
const 宽度 = (比例 * 8) + (边距 * 2)
const 高度 = (比例 * 9) + (边距 * 2)
const canvas = createElement({ width: 宽度, height: 高度 }, 'canvas')
const context = canvas.getContext('2d')
context.strokeStyle = 线条色
context.lineWidth = 线条宽度
// 横线竖线
for (let i = 0; i < 5; i++) {
context.moveTo(0, 比例 * i)
context.lineTo(比例 * 4, 比例 * i)
context.moveTo(比例 * i, 比例 * 0)
context.lineTo(比例 * i, 比例 * 4)
}
// 斜线
context.moveTo(比例 * 3, 比例 * 0)
context.lineTo(比例 * 4, 比例 * 1)
context.moveTo(比例 * 4, 比例 * 1)
context.lineTo(比例 * 3, 比例 * 2)
// 炮位置
context.moveTo(比例 * 1 - 4, 比例 * 2 - 9)
context.lineTo(比例 * 1 - 4, 比例 * 2 - 4)
context.lineTo(比例 * 1 - 9, 比例 * 2 - 4)
context.moveTo(比例 * 1 + 4, 比例 * 2 - 9)
context.lineTo(比例 * 1 + 4, 比例 * 2 - 4)
context.lineTo(比例 * 1 + 9, 比例 * 2 - 4)
context.moveTo(比例 * 1 - 4, 比例 * 2 + 9)
context.lineTo(比例 * 1 - 4, 比例 * 2 + 4)
context.lineTo(比例 * 1 - 9, 比例 * 2 + 4)
context.moveTo(比例 * 1 + 4, 比例 * 2 + 9)
context.lineTo(比例 * 1 + 4, 比例 * 2 + 4)
context.lineTo(比例 * 1 + 9, 比例 * 2 + 4)
// 兵位置
context.moveTo(比例 * 0 - 4, 比例 * 3 - 9)
context.lineTo(比例 * 0 - 4, 比例 * 3 - 4)
context.lineTo(比例 * 0 - 9, 比例 * 3 - 4)
context.moveTo(比例 * 0 + 4, 比例 * 3 - 9)
context.lineTo(比例 * 0 + 4, 比例 * 3 - 4)
context.lineTo(比例 * 0 + 9, 比例 * 3 - 4)
context.moveTo(比例 * 2 - 4, 比例 * 3 - 9)
context.lineTo(比例 * 2 - 4, 比例 * 3 - 4)
context.lineTo(比例 * 2 - 9, 比例 * 3 - 4)
context.moveTo(比例 * 2 + 4, 比例 * 3 - 9)
context.lineTo(比例 * 2 + 4, 比例 * 3 - 4)
context.lineTo(比例 * 2 + 9, 比例 * 3 - 4)
context.moveTo(比例 * 4 - 4, 比例 * 3 - 9)
context.lineTo(比例 * 4 - 4, 比例 * 3 - 4)
context.lineTo(比例 * 4 - 9, 比例 * 3 - 4)
// 兵位置向上镜像
context.moveTo(比例 * 0 - 4, 比例 * 3 + 9)
context.lineTo(比例 * 0 - 4, 比例 * 3 + 4)
context.lineTo(比例 * 0 - 9, 比例 * 3 + 4)
context.moveTo(比例 * 0 + 4, 比例 * 3 + 9)
context.lineTo(比例 * 0 + 4, 比例 * 3 + 4)
context.lineTo(比例 * 0 + 9, 比例 * 3 + 4)
context.moveTo(比例 * 2 - 4, 比例 * 3 + 9)
context.lineTo(比例 * 2 - 4, 比例 * 3 + 4)
context.lineTo(比例 * 2 - 9, 比例 * 3 + 4)
context.moveTo(比例 * 2 + 4, 比例 * 3 + 9)
context.lineTo(比例 * 2 + 4, 比例 * 3 + 4)
context.lineTo(比例 * 2 + 9, 比例 * 3 + 4)
context.moveTo(比例 * 4 - 4, 比例 * 3 + 9)
context.lineTo(比例 * 4 - 4, 比例 * 3 + 4)
context.lineTo(比例 * 4 - 9, 比例 * 3 + 4)
// 描绘线条
context.stroke()
context.save() // 保存当前状态
context.translate(0, 0) // 移动到要镜像的区域
context.scale(-1, 1) // X轴镜像
context.drawImage(canvas, 0, 0, 比例 * 4, 比例 * 8, -(比例 * 8), 0, 比例 * 4, 比例 * 8) // 将左上角的区域复制并镜像到右上角
context.restore() // 恢复到上次保存的状态
context.save() // 保存当前状态
context.translate(0, 0) // 移动到要镜像的区域
context.scale(1, -1) // Y轴镜像
context.drawImage(canvas, 0, 0, 比例 * 8, 比例 * 4 + (线条宽度 / 2), 0, -(比例 * 9), 比例 * 8, 比例 * 4 + (线条宽度 / 2)) // 将上半部分镜像到下半部分
context.restore() // 恢复到上次保存的状态
// 河界
context.font = `${比例 / 2.4}px serif`
context.fillStyle = '#784518'
context.fillText('楚', 比例 * 1.5, 比例 * 4.65)
context.fillText('河', 比例 * 2.5, 比例 * 4.65)
context.fillText('漢', 比例 * 5.5, 比例 * 4.65)
context.fillText('界', 比例 * 6.5, 比例 * 4.65)
var img = new Image()
img.onload = () => {
context.clearRect(0, 0, canvas.width, canvas.height)
context.drawImage(img, 边距, 边距)
context.beginPath() // 重新描绘线条
// 棋盘描边
context.moveTo(边距, 边距)
context.lineTo(边距, 边距 + (比例 * 9))
context.lineTo(边距 + (比例 * 8), 边距 + (比例 * 9))
context.lineTo(边距 + (比例 * 8), 边距)
context.lineTo(边距, 边距)
context.stroke()
// 单独保存绘制好的棋盘
const chessboard = canvas.toDataURL()
// 红方棋子
this.drawChessman({context, name:'車', color:'#cc1414', position:{x:0, y:0}, 边距, 比例})
this.drawChessman({context, name:'馬', color:'#cc1414', position:{x:1, y:0}, 边距, 比例})
this.drawChessman({context, name:'相', color:'#cc1414', position:{x:2, y:0}, 边距, 比例})
this.drawChessman({context, name:'仕', color:'#cc1414', position:{x:3, y:0}, 边距, 比例})
this.drawChessman({context, name:'将', color:'#cc1414', position:{x:4, y:0}, 边距, 比例})
this.drawChessman({context, name:'仕', color:'#cc1414', position:{x:5, y:0}, 边距, 比例})
this.drawChessman({context, name:'相', color:'#cc1414', position:{x:6, y:0}, 边距, 比例})
this.drawChessman({context, name:'馬', color:'#cc1414', position:{x:7, y:0}, 边距, 比例})
this.drawChessman({context, name:'車', color:'#cc1414', position:{x:8, y:0}, 边距, 比例})
this.drawChessman({context, name:'砲', color:'#cc1414', position:{x:1, y:2}, 边距, 比例})
this.drawChessman({context, name:'砲', color:'#cc1414', position:{x:7, y:2}, 边距, 比例})
this.drawChessman({context, name:'兵', color:'#cc1414', position:{x:0, y:3}, 边距, 比例})
this.drawChessman({context, name:'兵', color:'#cc1414', position:{x:2, y:3}, 边距, 比例})
this.drawChessman({context, name:'兵', color:'#cc1414', position:{x:4, y:3}, 边距, 比例})
this.drawChessman({context, name:'兵', color:'#cc1414', position:{x:6, y:3}, 边距, 比例})
this.drawChessman({context, name:'兵', color:'#cc1414', position:{x:8, y:3}, 边距, 比例})
// 绿方棋子
this.drawChessman({context, name:'車', color:'#14cc14', position:{x:0, y:9}, 边距, 比例})
this.drawChessman({context, name:'馬', color:'#14cc14', position:{x:1, y:9}, 边距, 比例})
this.drawChessman({context, name:'象', color:'#14cc14', position:{x:2, y:9}, 边距, 比例})
this.drawChessman({context, name:'士', color:'#14cc14', position:{x:3, y:9}, 边距, 比例})
this.drawChessman({context, name:'帥', color:'#14cc14', position:{x:4, y:9}, 边距, 比例})
this.drawChessman({context, name:'士', color:'#14cc14', position:{x:5, y:9}, 边距, 比例})
this.drawChessman({context, name:'象', color:'#14cc14', position:{x:6, y:9}, 边距, 比例})
this.drawChessman({context, name:'馬', color:'#14cc14', position:{x:7, y:9}, 边距, 比例})
this.drawChessman({context, name:'車', color:'#14cc14', position:{x:8, y:9}, 边距, 比例})
this.drawChessman({context, name:'炮', color:'#14cc14', position:{x:1, y:7}, 边距, 比例})
this.drawChessman({context, name:'炮', color:'#14cc14', position:{x:7, y:7}, 边距, 比例})
this.drawChessman({context, name:'卒', color:'#14cc14', position:{x:0, y:6}, 边距, 比例})
this.drawChessman({context, name:'卒', color:'#14cc14', position:{x:2, y:6}, 边距, 比例})
this.drawChessman({context, name:'卒', color:'#14cc14', position:{x:4, y:6}, 边距, 比例})
this.drawChessman({context, name:'卒', color:'#14cc14', position:{x:6, y:6}, 边距, 比例})
this.drawChessman({context, name:'卒', color:'#14cc14', position:{x:8, y:6}, 边距, 比例})
}
img.src = canvas.toDataURL()
canvas.style.backgroundColor = '#ffeedd'
canvas.style.margin = '1rem'
document.body.appendChild(canvas)
}
// canvas绘制棋子
drawChessman({context, name, color, position = { x: 0, y: 0 }, 边距, 比例}) {
// 先画圆形象棋子
context.beginPath()
context.arc(边距+(比例*position.x), 边距+(比例*position.y), 比例/3, 0, 2*Math.PI)
context.fillStyle = '#ffeedd'
context.fill()
context.stroke()
// 再画文字
context.font = '20px serif'
context.fillStyle = color
context.fillText(name, 边距+(比例*position.x)-(比例/5), 20+边距-(比例/4) + (比例*position.y))
context.stroke()
}
}
export default { Chessboard, Chessman }

View File

@@ -41,6 +41,7 @@ export default class Chat {
boxShadow: '0 0 1rem #eee', boxShadow: '0 0 1rem #eee',
border: 'none', border: 'none',
outline: 'none', outline: 'none',
borderRadius: '2rem'
}, },
onkeydown: event => { onkeydown: event => {
event.stopPropagation() event.stopPropagation()
@@ -79,7 +80,6 @@ export default class Chat {
} }
}), }),
Button({ Button({
textContent: '发送(Enter)',
onclick: event => { onclick: event => {
const text = event.target.previousSibling.value.trim() const text = event.target.previousSibling.value.trim()
if (text) { if (text) {
@@ -88,10 +88,11 @@ export default class Chat {
} }
}, },
style: { style: {
padding: '.5rem 1rem', width: '1.2rem',
boxSizing: 'border-box', height: '1.2rem',
boxShadow: '0 0 1rem #eee', border: 'none',
borderRadius: '1rem', background: 'url("/send.svg") no-repeat center / cover',
margin: 'auto 0 auto -2.6rem'
} }
}), }),
] ]
@@ -128,6 +129,44 @@ export default class Chat {
}, 'style')) }, 'style'))
this.从本地载入消息() this.从本地载入消息()
this.挂载全局快捷键() this.挂载全局快捷键()
// 如果未询问用户是否允许通知,则询问用户是否允许通知
if (Notification.permission === 'default') {
Notification.requestPermission()
}
}
async 播放提示音() {
// 如果页面可见或且浏览器不在前台运行
//if (document.visibilityState === 'visible') return console.log('页面可见')
// 创建一个MIDI的清脆的"叮咚"
const context = new AudioContext()
const o = context.createOscillator()
const g = context.createGain()
o.connect(g)
o.type = 'sine'
o.frequency.value = 400
g.connect(context.destination)
o.start(0)
g.gain.exponentialRampToValueAtTime(0.00001, context.currentTime + 1)
}
async 通知栏消息(data) {
console.log('通知栏消息', data)
const { name, text, avatar } = data
// 如果页面可见或且浏览器不在前台运行, 则发送通知
if (document.visibilityState === 'visible') return console.log('页面可见')
// 如果用户不允许通知,则不发送通知
if (Notification.permission !== 'granted') return console.log('用户不允许通知')
// 如果未询问用户是否允许通知,则询问用户是否允许通知
if (Notification.permission === 'default') {
await Notification.requestPermission()
}
if (Notification.permission === 'granted') {
const notification = new Notification(name, { body: text, icon: avatar })
notification.onclick = () => {
window.focus()
notification.close()
}
}
} }
async 挂载全局快捷键() { async 挂载全局快捷键() {
document.addEventListener('keydown', event => { document.addEventListener('keydown', event => {
@@ -142,7 +181,7 @@ export default class Chat {
data.map(item => ({ timestamp: new Date(item.time).getTime(), ...item })) data.map(item => ({ timestamp: new Date(item.time).getTime(), ...item }))
.filter(item => !item.ban) .filter(item => !item.ban)
.sort((a, b) => a.timestamp - b.timestamp).forEach(item => { .sort((a, b) => a.timestamp - b.timestamp).forEach(item => {
this.添加元素(item) this.添加元素(item, true)
}) })
} }
async 筛选指定范围的消息({ start, end }) { async 筛选指定范围的消息({ start, end }) {
@@ -196,7 +235,7 @@ export default class Chat {
this.event.onsend(text) this.event.onsend(text)
} }
} }
添加元素(data) { 添加元素(data, local) {
// 人类可读的时间: 今天,昨天, 空字符串 // 人类可读的时间: 今天,昨天, 空字符串
function convertTimestampToReadableTime(timestamp) { function convertTimestampToReadableTime(timestamp) {
const date = new Date(timestamp); const date = new Date(timestamp);
@@ -231,37 +270,39 @@ export default class Chat {
] ]
})) }))
} }
//// 将时间转换为人类可读的格式: 如果是今天,则显示时间,如果是昨天,则显示昨天,如果是今年,则显示月日,如果是去年,则显示年月日
//const redate = (str) => {
// const date = new Date(str)
// const now = new Date()
// const year = date.getFullYear()
// const month = date.getMonth() + 1
// const day = date.getDate()
// const hour = date.getHours()
// const minute = date.getMinutes()
// const nowYear = now.getFullYear()
// const nowMonth = now.getMonth() + 1
// const nowDay = now.getDate()
// if (year === nowYear && month === nowMonth && day === nowDay) {
// return `${hour}:${minute}`
// }
// if (year === nowYear && month === nowMonth && day === nowDay - 1) {
// return `昨天 ${hour}:${minute}`
// }
// if (year === nowYear) {
// return `${month}月${day}日 ${hour}:${minute}`
// }
// return `${year}年${month}月${day}日 ${hour}:${minute}`
//}
const scroll = this.ul.scrollHeight === this.ul.clientHeight + this.ul.scrollTop
// 如果和上一条消息是同一人, 且时间间隔小于1小时, 则向上合并 // 如果和上一条消息是同一人, 且时间间隔小于1小时, 则向上合并
if (this.last && this.last.name === data.name && new Date(data.time).getTime() - new Date(this.last.time).getTime() < 1000 * 60 * 60) { if (this.last && this.last.name === data.name && new Date(data.time).getTime() - new Date(this.last.time).getTime() < 1000 * 60 * 60) {
this.last.item.querySelector('ul').appendChild(ListItem({ textContent: data.text })) this.last.item.querySelector('ul').appendChild(ListItem({ textContent: data.text }))
this.last = { ...data, item: this.last.item } this.last = { ...data, item: this.last.item }
return this.last.item if (scroll || local) {
this.ul.scrollTop = this.ul.scrollHeight
} }
// 将时间转换为人类可读的格式: 如果是今天,则显示时间,如果是昨天,则显示昨天,如果是今年,则显示月日,如果是去年,则显示年月日 return
const redate = (str) => {
const date = new Date(str)
const now = new Date()
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const hour = date.getHours()
const minute = date.getMinutes()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
const nowDay = now.getDate()
if (year === nowYear && month === nowMonth && day === nowDay) {
return `${hour}:${minute}`
} }
if (year === nowYear && month === nowMonth && day === nowDay - 1) {
return `昨天 ${hour}:${minute}`
}
if (year === nowYear) {
return `${month}${day}${hour}:${minute}`
}
return `${year}${month}${day}${hour}:${minute}`
}
const scroll = this.ul.scrollHeight === this.ul.clientHeight + this.ul.scrollTop
this.ul.appendChild(ListItem({ this.ul.appendChild(ListItem({
style: { style: {
margin: '1rem', margin: '1rem',
@@ -271,6 +312,7 @@ export default class Chat {
maxWidth: '24rem', maxWidth: '24rem',
borderRadius: '1rem', borderRadius: '1rem',
listStyle: 'none', listStyle: 'none',
backgroundColor: 'rgba(255,255,255,.9)',
}, },
children: [ children: [
createElement({ createElement({
@@ -287,8 +329,6 @@ export default class Chat {
color: '#888', color: '#888',
fontSize: '12px', fontSize: '12px',
}, },
//textContent: `${redate(data.time)}`
// 只取时间, 不要秒
textContent: `${data.time.split(' ')[1].split(':')[0]}:${data.time.split(' ')[1].split(':')[1]}` textContent: `${data.time.split(' ')[1].split(':')[0]}:${data.time.split(' ')[1].split(':')[1]}`
}), }),
Button({ Button({
@@ -343,8 +383,10 @@ export default class Chat {
this.send(data) this.send(data)
} }
收到消息(data) { 收到消息(data) {
console.log('收到消息', data) const { avatar, ...save } = data
this.添加元素(data) this.添加元素(data)
this.存储消息(data) this.存储消息(save)
this.通知栏消息(data)
this.播放提示音()
} }
} }

View File

@@ -1,92 +0,0 @@
export default class IndexedDB {
constructor(databaseName, databaseVersion) {
this.databaseName = databaseName
this.databaseVersion = databaseVersion
this.db = null
}
open(name) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.databaseName, this.databaseVersion)
request.onerror = (event) => {
reject(event.target.error)
}
request.onsuccess = (event) => {
this.db = event.target.result
resolve(this.db)
}
request.onupgradeneeded = (event) => {
const db = event.target.result
if (!db.objectStoreNames.contains(name)) {
db.createObjectStore(name, { keyPath: 'id' })
console.log('store created:', name)
}
}
})
}
async store(name) {
if (!this.db) await this.open(name)
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([name], 'readwrite')
const objectStore = transaction.objectStore(name)
resolve(objectStore)
})
}
getAll(name) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([name], 'readonly')
const objectStore = transaction.objectStore(name)
const request = objectStore.getAll()
request.onerror = (event) => {
reject(event.target.error)
}
request.onsuccess = (event) => {
resolve(event.target.result)
}
})
}
add(name, data) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([name], 'readwrite')
const objectStore = transaction.objectStore(name)
const request = objectStore.add(data)
request.onerror = (event) => {
reject(event.target.error)
}
request.onsuccess = (event) => {
resolve(event.target.result)
}
})
}
put(name, data) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([name], 'readwrite')
const objectStore = transaction.objectStore(name)
const request = objectStore.put(data)
request.onerror = (event) => {
reject(event.target.error)
}
request.onsuccess = (event) => {
resolve(event.target.result)
}
})
}
delete(name, id) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([name], 'readwrite')
const objectStore = transaction.objectStore(name)
const request = objectStore.delete(id)
request.onerror = (event) => {
reject(event.target.error)
}
request.onsuccess = (event) => {
resolve(event.target.result)
}
})
}
}

View File

@@ -1,6 +1,4 @@
import 'virtual:windi.css' import * as idb from 'idb-keyval'
import IndexedDB from './indexeddb.js'
import MusicList from './music.js' import MusicList from './music.js'
import ClientList from './client.js' import ClientList from './client.js'
import Chat from './chat.js' import Chat from './chat.js'
@@ -25,11 +23,11 @@ function appendBuffer(buffer1, buffer2) {
} }
// 读取本地音乐列表并标识为缓存状态(本地缓存) // 读取本地音乐列表并标识为缓存状态(本地缓存)
const database = new IndexedDB('musicDatabase', 1) const musicStore = idb.createStore('database', 'music')
await database.store('musicObjectStore') // 音乐(为什么会用这么丑的格式呢)
// 读取本地音乐列表并标识为缓存状态(本地缓存) // 读取本地音乐列表并标识为缓存状态(本地缓存)
const list = await Promise.all((await database.getAll('musicObjectStore')).map(async item => { const list = await idb.values(musicStore)
for (const item of list) {
if (!item.picture && item.picture !== false) { if (!item.picture && item.picture !== false) {
console.log('提取封面', item.name) console.log('提取封面', item.name)
const blob = new Blob([item.arrayBuffer], { type: item.type }) const blob = new Blob([item.arrayBuffer], { type: item.type })
@@ -42,10 +40,10 @@ const list = await Promise.all((await database.getAll('musicObjectStore')).map(a
} else { } else {
item.picture = false item.picture = false
} }
database.put('musicObjectStore', item) idb.set(item.id, item, musicStore)
}
item.save = true
} }
return { save: true, ...item }
}))
// 读取本地用户名(本地缓存) // 读取本地用户名(本地缓存)
const name = localStorage.getItem('username') ?? '匿' const name = localStorage.getItem('username') ?? '匿'
@@ -82,8 +80,7 @@ const musicList = new MusicList({
onlike: (item, list) => { onlike: (item, list) => {
console.log('喜欢音乐', item.name) console.log('喜欢音乐', item.name)
if (item.arrayBuffer) { if (item.arrayBuffer) {
//musicStore.add(item) idb.set(item.id, item, musicStore)
database.add('musicObjectStore', item)
clientList.send('base', JSON.stringify({ clientList.send('base', JSON.stringify({
type: 'set_music_list', type: 'set_music_list',
list: list.map(({ id, name, size, type }) => ({ id, name, size, type })) list: list.map(({ id, name, size, type }) => ({ id, name, size, type }))
@@ -93,14 +90,30 @@ const musicList = new MusicList({
onunlike: (item, list) => { onunlike: (item, list) => {
console.log('取消喜欢', item.name) console.log('取消喜欢', item.name)
if (item.arrayBuffer) { if (item.arrayBuffer) {
database.delete('musicObjectStore', item.id) idb.del(item.id, musicStore)
//musicStore.delete(item.id)
clientList.send('base', JSON.stringify({ clientList.send('base', JSON.stringify({
type: 'set_music_list', type: 'set_music_list',
list: list.map(({ id, name, size, type }) => ({ id, name, size, type })) list: list.map(({ id, name, size, type }) => ({ id, name, size, type }))
})) }))
} }
}, },
onsetlrc(item) {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.lrc'
input.onchange = function () {
const file = this.files[0]
const reader = new FileReader()
reader.onload = function (e) {
const lrc = e.target.result
console.log(lrc)
console.log(item)
idb.set(item.id, { lrc, ...item }, musicStore)
}
reader.readAsText(file)
}
input.click()
},
onban: item => { onban: item => {
//console.info('禁止音乐', item.name) //console.info('禁止音乐', item.name)
}, },
@@ -109,8 +122,7 @@ const musicList = new MusicList({
}, },
onremove: item => { onremove: item => {
//console.info('移除音乐', item.name) //console.info('移除音乐', item.name)
//musicStore.delete(item.id) idb.del(item.id, musicStore)
database.delete('musicObjectStore', item.id)
}, },
onadd: (item, list) => { onadd: (item, list) => {
//console.info('添加音乐', item.name) //console.info('添加音乐', item.name)
@@ -142,8 +154,9 @@ const musicList = new MusicList({
} }
}) })
const client = clientList.clientlist.find(client => { const client = clientList.clientlist.find(client => {
return client.musicList.find(x => x.id === item.id) return client.musicList?.find(x => x.id === item.id)
}) })
if (!client) return console.error('未找到拥有此音乐的用户')
console.info('向', client.name, '请求音乐数据') console.info('向', client.name, '请求音乐数据')
const c = Math.ceil(item.size / CHUNK_SIZE) const c = Math.ceil(item.size / CHUNK_SIZE)
@@ -166,29 +179,6 @@ const chat = new Chat({
} }
}) })
//// 与每个客户端保持心跳
//clientList.setChannel('ping', {
// onopen: async (event, client) => {
// console.log('打开信道', event.target.label)
// clientList.sendto(client.id, 'ping', JSON.stringify({ type: 'ping' }))
// },
// onmessage: async (event, client) => {
// const data = JSON.parse(event.data)
// if (data.type === 'ping') {
// console.log(client.name, '心跳:', data)
// clientList.sendto(client.id, 'ping', JSON.stringify({ type: 'pong' }))
// return
// }
// if (data.type === 'pong') {
// console.log(client.name, '心跳:', data)
// await new Promise((resolve) => setTimeout(resolve, 5000))
// clientList.sendto(client.id, 'ping', JSON.stringify({ type: 'ping' }))
// return
// }
// console.log('未知类型:', data.type)
// }
//})
// 与每个客户端都建立聊天信道 // 与每个客户端都建立聊天信道
clientList.setChannel('chat', { clientList.setChannel('chat', {
onopen: async (event, client) => { onopen: async (event, client) => {
@@ -200,7 +190,7 @@ clientList.setChannel('chat', {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.type === 'message') { if (data.type === 'message') {
console.log(client.name, '发来消息:', data) console.log(client.name, '发来消息:', data)
chat.收到消息({ name: client.name, ...data.text }) chat.收到消息({ name: client.name, ...data.text, avatar: client.avatar })
return return
} }
if (data.type === 'answer') { if (data.type === 'answer') {
@@ -387,3 +377,16 @@ if (localStorage.getItem('avatar')) {
if (localStorage.getItem('username')) { if (localStorage.getItem('username')) {
document.title = localStorage.getItem('username') document.title = localStorage.getItem('username')
} }
// 链接 GIT 仓库
const git = document.createElement('a')
git.href = 'https://git.satori.love/laniakeaSupercluster/webrtc'
git.textContent = 'Git'
git.target = '_blank'
git.style.position = 'absolute'
git.style.bottom = 0
git.style.right = 0
git.style.padding = '.5rem'
git.style.textDecoration = 'none'
git.style.color = '#555'
document.body.appendChild(git)

View File

@@ -1,8 +1,8 @@
import { Img, Span, Button, List, ListItem, UploadMusic, createElement } from './weigets.js' import { Img, Span, Button, List, ListItem, UploadMusic, createElement } from './weigets.js'
export default class MusicList { export default class MusicList {
constructor({ list = [], EventListeners = {}, onplay, onstop, onadd, onremove, onlike, onunlike, onban, onload }) { constructor({ list = [], EventListeners = {}, onsetlrc, onplay, onstop, onadd, onremove, onlike, onunlike, onban, onload }) {
this.event = { onplay, onstop, onadd, onremove, onlike, onunlike, onban, onload } this.event = { onsetlrc, onplay, onstop, onadd, onremove, onlike, onunlike, onban, onload }
// 添加音乐播放器 // 添加音乐播放器
this.audio = new Audio() this.audio = new Audio()
@@ -16,41 +16,54 @@ export default class MusicList {
this.audio.addEventListener('ended', () => { this.audio.addEventListener('ended', () => {
this.next() this.next()
}) })
//this.audio.addEventListener('timeupdate', () => {
// console.log(this.audio.currentTime)
//})
//this.audio.addEventListener('error', event => {
// console.error('音乐播放器错误:', event)
//})
this.ul = List({ this.ul = List({
classList: ['music-list'], classList: ['music-list'],
style: { style: {
flex: 1, flex: 1, // 防止在flex中被挤压变形
textOverflow: 'ellipsis', textOverflow: 'ellipsis', // 文本溢出时省略号
whiteSpace: 'nowrap', whiteSpace: 'nowrap', // 不换行
overflowX: 'hidden', // 溢出时隐藏 overflowX: 'hidden', // 溢出时隐藏
overflowY: 'auto', // 溢出时显示滚动条 overflowY: 'auto', // 溢出时显示滚动条
listStyle: 'disc', // 实心圆 listStyle: 'disc', // 实心圆
padding: '0 1.1rem', // 列表左右留白 padding: '0 1.1rem', // 列表左右留白
gap: '.1rem', // 列表项间隔
display: 'flex', // 列表垂直排列
flexDirection: 'column', // 列表垂直排列
} }
}) })
this.EventListeners = EventListeners this.EventListeners = EventListeners
this.list = [] this.list = []
list.forEach(item => this.add(item)) // 列表逐一添加 list.forEach(item => this.add(item)) // 列表逐一添加
this.封面 = createElement({
this.封面 = Img({
src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
style: { style: {
width: '6rem', width: '6rem',
height: '6rem', height: '6rem',
borderRadius: '1rem',
backgroundColor: '#eee',
border: 'none', border: 'none',
borderRadius: '1rem',
backgroundImage: "url('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7')",
backgroundSize: 'cover',
position: 'relative'
}, },
onerror: event => { children: [
// img 标签错误时, 替换为1x1透明gif图片 createElement({
event.target.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' 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({ this.标题 = Span({
style: { style: {
@@ -58,7 +71,24 @@ export default class MusicList {
fontWeight: 'bold', fontWeight: 'bold',
margin: '0 0 .5rem 0', margin: '0 0 .5rem 0',
}, },
textContent: '音乐名', textContent: '',
})
const upload = 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)
}
}
}) })
// 收起到右上角, 音乐播放器基于浮窗定位, 不再占用页面空间 // 收起到右上角, 音乐播放器基于浮窗定位, 不再占用页面空间
@@ -67,9 +97,7 @@ export default class MusicList {
position: 'fixed', top: '5rem', right: '1rem', position: 'fixed', top: '5rem', right: '1rem',
backgroundColor: '#fff', padding: '.5rem', backgroundColor: '#fff', padding: '.5rem',
borderRadius: '1rem', cursor: 'pointer', borderRadius: '1rem', cursor: 'pointer',
width: '20rem', Height: '70vh', width: '20rem', Height: '70vh', minWidth: '20rem', maxWidth: '80vw', maxHeight: '80vh',
minWidth: '20rem', minHeight: '13rem',
maxWidth: '80vw', maxHeight: '80vh',
overflow: 'hidden', boxShadow: '0 0 1rem #eee', overflow: 'hidden', boxShadow: '0 0 1rem #eee',
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
fontSize: '12px', userSelect: 'none', fontSize: '12px', userSelect: 'none',
@@ -112,6 +140,18 @@ export default class MusicList {
justifyContent: 'space-between', flexShrink: 0, justifyContent: 'space-between', flexShrink: 0,
marginBottom: '.5rem', marginBottom: '.5rem',
}, },
onclick: event => {
// 点击隐藏列表和播放器
if (this.ul.style.display === 'none') {
this.ul.style.display = 'block'
this.audio.style.display = 'block'
upload.style.display = 'block'
} else {
this.ul.style.display = 'none'
this.audio.style.display = 'none'
upload.style.display = 'none'
}
},
children: [ children: [
this.封面, this.封面,
createElement({ createElement({
@@ -163,22 +203,7 @@ export default class MusicList {
}), }),
this.audio, this.audio,
this.ul, this.ul,
UploadMusic({ upload,
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) document.body.appendChild(element)
@@ -201,10 +226,13 @@ export default class MusicList {
ul.music-list > li.play > span { ul.music-list > li.play > span {
color: #02be08; color: #02be08;
} }
ul.music-list > li.cache::marker { ul.music-list > li::before {
color: #02be08; content: '●';
color: #cccccc;
font-size: 1em; font-size: 1em;
contentx: '⚡'; }
ul.music-list > li.cache::before {
color: #02be08;
} }
ul.music-list > li.disable { ul.music-list > li.disable {
color: #999999; color: #999999;
@@ -243,17 +271,10 @@ export default class MusicList {
id: item.id, id: item.id,
classList: item.arrayBuffer ? ['cache'] : [], classList: item.arrayBuffer ? ['cache'] : [],
style: { style: {
display: 'flex', display: 'flex', gap: '.25rem', maxWidth: '100%',
alignItems: 'center', alignItems: 'center', justifyContent: 'space-between',
justifyContent: 'space-between',
gap: '.25rem',
maxWidth: '100%',
}, },
children: [ children: [
//...(item.picture ? [Img({
// src: item.picture,
// style: { width: '2em', height: '2em', borderRadius: '.25em' }
//})] : []),
Img({ Img({
src: item.picture || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', src: item.picture || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
style: { width: '2em', height: '2em', borderRadius: '.25em', backgroundColor: '#eee' }, style: { width: '2em', height: '2em', borderRadius: '.25em', backgroundColor: '#eee' },
@@ -297,10 +318,16 @@ export default class MusicList {
this.like(item) this.like(item)
} }
} }
}),
Button({
textContent:'lrc',
onclick: () => {
this.event.onsetlrc?.(item)
}
}) })
] ]
})) }))
this.event.onadd(item, this.list) this.event.onadd?.(item, this.list)
} }
async remove(item) { async remove(item) {
this.ul.querySelector(`#${item.id}`)?.remove() this.ul.querySelector(`#${item.id}`)?.remove()
@@ -364,8 +391,9 @@ export default class MusicList {
}) })
} }
} else { } else {
this.标题.textContent = item.name // 替换标题 const default_picture = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
this.封面.src = item.picture // 替换封面图像 this.标题.textContent = item.name
this.封面.style.backgroundImage = `url(${item.picture || default_picture})`
// 替换浏览器媒体信息(使系统通知栏显示歌曲信息) // 替换浏览器媒体信息(使系统通知栏显示歌曲信息)
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
@@ -373,7 +401,7 @@ export default class MusicList {
artist: '艺术家名', artist: '艺术家名',
album: '专辑名', album: '专辑名',
artwork: [ artwork: [
{ src: item.picture, sizes: '96x96', type: 'image/jpeg' }, { src: item.picture || default_picture, sizes: '96x96', type: 'image/jpeg' },
] ]
}) })
} }

View File

@@ -1,97 +0,0 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #f7df1eaa);
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}