Compare commits

...

63 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
2c748b029b DEBUG: 用户加入离开避免影响音乐播放 2023-10-24 08:21:15 +08:00
018246e6dc Remove console.log statements in Chat class 2023-10-24 06:59:14 +08:00
a641b07de1 时间间隔小于1小时允许合并 2023-10-24 06:57:17 +08:00
7cc8b37d5d 只取时间 2023-10-24 06:48:27 +08:00
938739e8c2 按日期归并 2023-10-24 06:46:40 +08:00
c9477581c8 合并消息 2023-10-24 06:20:41 +08:00
ae5072a490 按钮禁止换行 2023-10-24 05:45:32 +08:00
d75641c8b8 没有封面时也显示封面占位 2023-10-24 05:10:58 +08:00
1fec1bb2f6 Add maxWidth and ellipsis to music list item name 2023-10-24 05:08:09 +08:00
49c5360680 对齐 2023-10-24 05:06:37 +08:00
3c02e2dc82 播放时添加封面 2023-10-24 04:55:22 +08:00
bd3f3d57eb 移除jsmediatags 2023-10-24 03:51:33 +08:00
e269d013e2 新消息自动滚动到底部 2023-10-22 20:06:44 +08:00
f79a2b2f66 移除心跳测试 2023-10-22 18:30:25 +08:00
f85316b86c ICE 候选者的数量 2023-10-22 17:41:22 +08:00
c268bb1bf7 更多 stun 2023-10-22 17:35:56 +08:00
44a158a2e8 DEBUG STUN服务器列表 2023-10-22 17:30:29 +08:00
8056b0b8cb 禁用 turn 2023-10-22 17:22:39 +08:00
9dae52a858 心跳 2023-10-22 16:30:14 +08:00
caadd67b68 DEBUG: ICE 连接状态 2023-10-22 16:08:42 +08:00
8829a6cbb2 DEBUG: candidate 状态 2023-10-22 15:45:18 +08:00
688ba3a7f6 DEBUG: candidate 状态 2023-10-22 15:42:42 +08:00
f16e3f62cb 降低反复提取封面耗时 2023-10-22 14:21:37 +08:00
XiaoZhuo
277e2dd5b9 播放器可自由拖动大小 2023-10-22 12:30:31 +08:00
XiaoZhuo
f688f6e1b1 Revert "播放器可自由拖动扩展大小"
This reverts commit af80bb2d0b.
2023-10-22 12:24:55 +08:00
XiaoZhuo
af80bb2d0b 播放器可自由拖动扩展大小 2023-10-22 12:17:11 +08:00
XiaoZhuo
4b1b65c29e 修复层级导致的无法拖动 2023-10-22 10:19:07 +08:00
75888cc69d 从数据加载封面 2023-10-22 02:07:36 +08:00
2248a9d552 文档更新 2023-10-21 21:18:07 +08:00
3d75b3456d 更新文档 2023-10-21 21:05:25 +08:00
a37f469f65 隐藏滚动条 2023-10-21 20:55:47 +08:00
0756b66792 精简时间格式 2023-10-21 20:52:19 +08:00
367bc51c64 本地记录范围内屏蔽(ban 2023-10-21 20:25:08 +08:00
da2c1ef8c8 使用正确的用户名 2023-10-21 19:31:21 +08:00
65cb34f713 风格一致 2023-10-21 19:21:17 +08:00
20 changed files with 873 additions and 1063 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. 从浏览器创建插件(单文件)
@@ -30,28 +39,27 @@ export default class 插件名 {
聊天室 聊天室
1. 每个设备保存全量聊天记录 - [x] 每个设备保存全量聊天记录
2. 每个设备各自设定存储区间 - [x] 每个设备各自设定存储区间
3. 接入网络后向同频道设备同步区间内记录 - [x] 接入网络后向同频道设备同步区间内记录
4. 对方撤回的并不删除, 但不再分发 - [ ] 对方撤回的并不删除, 但不再分发
5. 阅后既焚开关, 全频道不保留也不分发记录 - [ ] 阅后既焚开关, 全频道不保留也不分发记录
6. mark 标记的记录保留, 其它自动丢弃 - [ ] mark 标记的记录保留, 其它自动丢弃
- [ ] 非活跃状态下提示音
音乐频道 音乐频道
1. 每个设备存储自己的列表 - [x] 每个设备存储自己的列表
2. 可以缓存对方的列表 - [x] 可以缓存对方的列表
3. 使用md5验证完整性 - [ ] 使用md5验证完整性
4. 可以上传lrc - [ ] 可以上传lrc
5. 可以上传封面, 可以从数据中解析封面 - [ ] 可以上传封面, 可以从数据中解析封面
6. ban表匹配时不播放且收起隐藏, 支持正则ban表 - [ ] ban表匹配时不播放且收起隐藏, 支持正则ban表
猫窝 传递资源
1. 每个节点都公开持有的资源列表, 和连接的节点列表
2. 每当资源变动时告知所有连接的节点
7. 每个节点都公开持有的资源列表, 和连接的节点列表 3. 与节点创建多个RTC时, 不发送多份, 以ID为准, id随机生成给不同机器, 无法通过ID锁定其它机器
8. 每当资源变动时告知所有连接的节点 4. 通过WS交换信息时, ID是否固定? 向WS提供连接?
9. 与节点创建多个RTC时, 不发送多份, 以ID为准, id随机生成给不同机器, 无法通过ID锁定其它机器
10. 通过WS交换信息时, ID是否固定? 向WS提供连接?
- [x] P2P通信 - [x] P2P通信
- [ ] 分离出主要功能, 作为库或桁架使用 - [ ] 分离出主要功能, 作为库或桁架使用
@@ -93,7 +101,32 @@ export default class 插件名 {
``` ```
依赖文档
- https://www.npmjs.com/package/idb-keyval
- https://www.npmjs.com/package/express
- https://www.npmjs.com/package/express-ws
- https://www.npmjs.com/package/node-turn
- https://www.npmjs.com/package/vite
备用代码片段 备用代码片段
```javascript
// jsmediatags 似乎支持的格式较少, 但不会产生错误警告
//import jsmediatags from 'jsmediatags/dist/jsmediatags.min.js'
//list.forEach(async item => {
// const blob = new Blob([item.arrayBuffer], { type: item.type })
// jsmediatags.read(blob, {
// onSuccess: function (tag) {
// console.log(tag)
// console.log(tag.tags.title)
// },
// onError: function (error) {
// console.log(error);
// }
// })
//})
```
```html ```html
<script type="module"> <script type="module">

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

@@ -20,9 +20,15 @@
"windicss": "^3.5.6" "windicss": "^3.5.6"
}, },
"dependencies": { "dependencies": {
"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-umd": "^0.6.10",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"node-turn": "^0.0.6" "music-metadata-browser": "^2.5.10",
"node-turn": "^0.0.6",
"process": "^0.11.10"
} }
} }

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]
}
}

View File

@@ -1,266 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>webRTC</title>
</head>
<body>
<div>
<h1>webRTC</h1>
<p>选择音乐使频道内所有设备同步播放 chrome://webrtc-internals/</p>
</div>
<script type="module">
import IndexedDB from './database.js'
import MusicList from './music.js'
import ClientList from './client.js'
// 缓冲分片发送
const CHUNK_SIZE = 1024 * 64 // 默认每个块的大小为128KB
const THRESHOLD = 1024 * 1024 // 默认缓冲区的阈值为1MB
const DELAY = 50 // 默认延迟500ms
// 将两个ArrayBuffer合并成一个
function appendBuffer(buffer1, buffer2) {
const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength)
tmp.set(new Uint8Array(buffer1), 0)
tmp.set(new Uint8Array(buffer2), buffer1.byteLength)
return tmp.buffer
}
// 读取本地图像
const imageStore = new IndexedDB('musicDatabase', 1, 'imageObjectStore')
await imageStore.open()
// 读取本地音乐列表并标识为缓存状态(本地缓存)
const musicStore = new IndexedDB('musicDatabase', 1, 'musicObjectStore')
await musicStore.open()
const list = (await musicStore.getAll()).map(item => {
return { save: true, ...item }
})
// 读取本地用户名(本地缓存)
const name = localStorage.getItem('username') ?? '游客'
const avatar = localStorage.getItem('avatar') ?? '/favicon.ico'
// 初始化客户端列表
const clientList = new ClientList({
name,
onexit: async client => {
console.log(client.name, '离开频道', client)
// 从列表中移除未缓存的此用户的音乐, 但可能多人都有此音乐且未缓存
// 因此每条音乐都要检查是否有其他用户也有此音乐, 如果有则不移除
const 此用户音乐 = client.musicList?.map(item => item.id) || []
const 无数据音乐 = musicList.list.filter(item => !item.arrayBuffer).filter(item => {
return 此用户音乐.includes(item.id)
})
无数据音乐.forEach(item => {
const client = clientList.clientlist.find(client => {
return client.musicList.find(x => x.id === item.id)
})
if (!client) musicList.remove(item)
})
}
})
// 初始化音乐列表(加入本地缓存)
const musicList = new MusicList({
list,
onplay: item => {
console.log('播放音乐', item.name)
},
onstop: item => {
console.log('停止音乐', item?.name)
},
onlike: (item, list) => {
console.log('喜欢音乐', item.name)
if (item.arrayBuffer) {
musicStore.add(item)
clientList.send('base', JSON.stringify({
type: 'set_music_list',
list: list.map(({ id, name, size, type }) => ({ id, name, size, type }))
}))
}
},
onunlike: (item, list) => {
console.log('取消喜欢', item.name)
if (item.arrayBuffer) {
musicStore.delete(item.id)
clientList.send('base', JSON.stringify({
type: 'set_music_list',
list: list.map(({ id, name, size, type }) => ({ id, name, size, type }))
}))
}
},
onban: item => {
console.info('禁止音乐', item.name)
},
onunban: item => {
console.info('解禁音乐', item.name)
},
onremove: item => {
console.info('移除音乐', item.name)
musicStore.delete(item.id)
},
onadd: (item, list) => {
console.info('添加音乐', item.name)
},
onupdate: item => {
console.info('更新音乐', item.name)
musicStore.put(item)
},
onerror: error => {
console.error('音乐列表错误', error)
},
onload: async item => {
console.info('加载音乐', item)
return await new Promise((resolve) => {
var buffer = new ArrayBuffer(0) // 接收音乐数据
var count = 0 // 接收分片计数
const chunkNumber = Math.ceil(item.size / 1024 / 64) // 64KB每片
clientList.setChannel(`music-data-${item.id}`, {
onmessage: async (event, client) => {
console.log('收到音乐数据 chunk', `${count + 1}/${chunkNumber}`, buffer.byteLength)
buffer = appendBuffer(buffer, event.data) // 合并分片准备存储
item.arrayBufferChunks?.push(event.data) // 保存分片给边下边播
count++
if (buffer.byteLength >= item.size) {
console.log('音乐数据接收完毕')
item.arrayBuffer = buffer
event.target.close() // 关闭信道
resolve(item)
}
}
})
const client = clientList.clientlist.find(client => {
return client.musicList.find(x => x.id === item.id)
})
console.info('向', client.name, '请求音乐数据')
const c = Math.ceil(item.size / CHUNK_SIZE)
console.log('需要接收', c, '个分片')
clientList.sendto(client.id, 'base', JSON.stringify({
type: 'get_music_data', id: item.id, channel: `music-data-${item.id}`
}))
})
}
})
const ImageList = []
// 只有一个基本信道, 用于交换和调度信息
clientList.setChannel('base', {
onopen: async event => {
//console.debug('打开信道', event.target.label, '广播请求音乐列表和身份信息')
clientList.send('base', JSON.stringify({ type: 'get_music_list' })) // 要求对方发送音乐列表
clientList.send('base', JSON.stringify({ type: 'get_user_profile' })) // 要求对方发送身份信息
},
onmessage: async (event, client) => {
const data = JSON.parse(event.data)
if (data.type === 'get_user_profile') {
console.log(client.name, '请求身份信息:', data)
// 包过大会导致发送失败, 因此需要分开发送
clientList.sendto(client.id, 'base', JSON.stringify({
type: 'set_user_profile',
name: name,
avatar: avatar,
}))
return
}
if (data.type === 'set_user_profile') {
console.log(client.name, '发来身份信息:', data)
console.log('将身份信息保存到本机记录:', client)
client.name = data.name
client.avatar = data.avatar
// 还需要更新组件的用户信息
console.log('更新组件的用户信息:', data, client)
clientList.setAvatar({ id:client.id, ...data })
return
}
if (data.type === 'get_image_list') {
// webrtc://用户@域名:端口/信道标识/资源ID
}
if (data.type === 'get_music_list') {
const ms = musicList.list.filter(item => item.arrayBuffer)
console.log(client.name, '请求音乐列表:', ms)
clientList.sendto(client.id, 'base', JSON.stringify({
type: 'set_music_list',
list: ms.map(({ id, name, size, type }) => ({ id, name, size, type }))
}))
return
}
if (data.type === 'set_music_list') {
console.log(client.name, '发来音乐列表:', `x${JSON.parse(event.data).list.length}`)
client.musicList = data.list
client.musicList.forEach(music => musicList.add(music))
return
}
if (data.type === 'get_music_data') {
// 建立一个信道, 用于传输音乐数据(接收方已经准备好摘要信息)
console.log(client.name, '建立一个信道, 用于传输音乐数据', musicList.list)
musicList.list.filter(item => item.id === data.id).forEach(item => {
const ch = client.webrtc.createDataChannel(data.channel, { reliable: true })
ch.onopen = async event => {
console.log(client.name, `打开 ${data.channel} 信道传输音乐数据`, item.name)
// 将音乐数据分成多个小块,并逐个发送
async function sendChunk(dataChannel, data, index = 0, buffer = new ArrayBuffer(0)) {
while (index < data.byteLength) {
if (dataChannel.bufferedAmount <= THRESHOLD) {
const chunk = data.slice(index, index + CHUNK_SIZE)
dataChannel.send(chunk)
index += CHUNK_SIZE
buffer = appendBuffer(buffer, chunk)
}
await new Promise((resolve) => setTimeout(resolve, DELAY))
}
return buffer
}
await sendChunk(ch, item.arrayBuffer)
console.log(client.name, `获取 ${data.channel} 信道数据结束`, item.name)
ch.close() // 关闭信道
}
})
return
}
console.log('未知类型:', data.type)
},
onclose: event => {
console.log('关闭信道', event.target.label)
},
onerror: event => {
console.error('信道错误', event.target.label, event.error)
}
})
// 延迟1500ms
//await new Promise((resolve) => setTimeout(resolve, 100))
// 设置自己的主机名
const nameInput = document.createElement('input')
nameInput.type = 'text'
nameInput.placeholder = '请设置你的昵称'
nameInput.value = name
nameInput.onchange = event => {
localStorage.setItem('username', event.target.value)
window.location.reload() // 简单刷新页面
}
document.body.appendChild(nameInput)
// 设置标签为自己的头像
if (localStorage.getItem('avatar')) {
const favicon = document.createElement('link')
favicon.rel = 'icon'
favicon.href = localStorage.getItem('avatar')
document.head.appendChild(favicon)
}
// 设置标题为自己的昵称
if (localStorage.getItem('username')) {
document.title = localStorage.getItem('username')
}
</script>
</body>
</html>

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'
} }
}), }),
] ]
@@ -101,6 +102,12 @@ export default class Chat {
document.body.appendChild(this.element) document.body.appendChild(this.element)
document.head.appendChild(createElement({ document.head.appendChild(createElement({
innerText: ` innerText: `
ul.chat-list::-webkit-scrollbar {
display: none;
}
ul.chat-list {
scrollbar-width: none;
}
ul.chat-list { ul.chat-list {
max-height: 70vh; max-height: 70vh;
overflow-y: auto; overflow-y: auto;
@@ -122,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 => {
@@ -133,15 +178,17 @@ export default class Chat {
} }
async 从本地载入消息() { async 从本地载入消息() {
const data = await values(this.store) const data = await values(this.store)
data.map(item => ({ timestamp: new Date(item.time).getTime(), ...item })).sort((a, b) => a.timestamp - b.timestamp).forEach(item => { data.map(item => ({ timestamp: new Date(item.time).getTime(), ...item }))
this.添加元素(item) .filter(item => !item.ban)
}) .sort((a, b) => a.timestamp - b.timestamp).forEach(item => {
this.添加元素(item, true)
})
} }
async 筛选指定范围的消息({ start, end }) { async 筛选指定范围的消息({ start, end }) {
const data = await values(this.store) const data = await values(this.store)
return data.map(item => ({ timestamp: new Date(item.time).getTime(), ...item })).filter(item => { return data.map(item => ({ timestamp: new Date(item.time).getTime(), ...item })).filter(item => {
const timestamp = new Date(item.time).getTime() const timestamp = new Date(item.time).getTime()
return timestamp >= start && timestamp <= end return timestamp >= start && timestamp <= end && !item.ban
}) })
} }
// 检查本地不存在的id,存储消息 // 检查本地不存在的id,存储消息
@@ -161,6 +208,15 @@ export default class Chat {
} }
// 添加一条消息 // 添加一条消息
add({ name, text, time, type }) { add({ name, text, time, type }) {
// 如果和上一条消息是同一人, 且时间间隔小于1小时, 则向上合并
// && new Date(time).getTime() - new Date(this.last.time).getTime() < 1000 * 60 * 60
console.log('添加一条消息', this.last, name, text, time, type)
if (this.last && this.last.name === name) {
this.last.item.appendChild(Span({ textContent: text }))
this.last = { name, text, time, type, item: this.last.item }
return this.last.item
}
const item = ListItem({ const item = ListItem({
classList: [type], classList: [type],
children: [ children: [
@@ -169,6 +225,9 @@ export default class Chat {
}) })
this.ul.appendChild(item) this.ul.appendChild(item)
this.ul.scrollTop = this.ul.scrollHeight this.ul.scrollTop = this.ul.scrollHeight
// 记录到上一条消息
this.last = { name, text, time, type, item }
return item return item
} }
send(text) { send(text) {
@@ -176,37 +235,136 @@ export default class Chat {
this.event.onsend(text) this.event.onsend(text)
} }
} }
添加元素(data) { 添加元素(data, local) {
// 人类可读的时间: 今天,昨天, 空字符串
function convertTimestampToReadableTime(timestamp) {
const date = new Date(timestamp);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return '今天';
} else if (date.toDateString() === yesterday.toDateString()) {
return '昨天';
} else {
return '';
}
}
// 如果上一条日期不等于这一条日期,则添加日期(只计算日期,不计算时间)
if (!this.last || this.last.time.split(' ')[0] !== data.time.split(' ')[0]) {
this.ul.appendChild(ListItem({
style: { listStyle: 'none', maxWidth: '24rem', textAlign: 'center' },
children: [
Span({
style: {
display: 'inline-block',
padding: '.1rem .5rem',
marginTop: '2rem',
boxSizing: 'border-box',
boxShadow: '0 0 1rem #eee',
borderRadius: '1rem',
fontSize: '12px',
},
textContent: `${data.time.split(' ')[0]} ${convertTimestampToReadableTime(new Date(data.time).getTime())}`
})
]
}))
}
//// 将时间转换为人类可读的格式: 如果是今天,则显示时间,如果是昨天,则显示昨天,如果是今年,则显示月日,如果是去年,则显示年月日
//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小时, 则向上合并
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 = { ...data, item: this.last.item }
if (scroll || local) {
this.ul.scrollTop = this.ul.scrollHeight
}
return
}
this.ul.appendChild(ListItem({ this.ul.appendChild(ListItem({
style: { style: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
margin: '1rem', margin: '1rem',
padding: '.5rem 1rem', padding: '.5rem 1rem',
boxSizing: 'border-box', boxSizing: 'border-box',
boxShadow: '0 0 1rem #eee', boxShadow: '0 0 1rem #eee',
maxWidth: '24rem', maxWidth: '24rem',
borderRadius: '1rem', borderRadius: '1rem',
listStyle: 'none',
backgroundColor: 'rgba(255,255,255,.9)',
}, },
children: [ children: [
Span({ textContent: `${data.name} ${data.time} ${data.text}` }), createElement({
Button({
style: { style: {
boxSizing: 'border-box', display: 'flex',
boxShadow: '0 0 1rem #eee', alignItems: 'end',
borderRadius: '1rem', justifyContent: 'space-between',
fontSize: '12px', gap: '.25rem',
color: '#555'
}, },
textContent: '删除', children: [
onclick: event => { Span({ textContent: `${data.name}` }),
event.target.parentNode.remove() Span({
del(data.id, this.store) style: {
} color: '#888',
}) fontSize: '12px',
},
textContent: `${data.time.split(' ')[1].split(':')[0]}:${data.time.split(' ')[1].split(':')[1]}`
}),
Button({
style: {
boxSizing: 'border-box',
boxShadow: '0 0 1rem #eee',
borderRadius: '1rem',
fontSize: '12px',
color: '#555',
marginLeft: 'auto',
whiteSpace: 'nowrap',
},
textContent: '移除',
title: '加入屏蔽列表不再被渲染',
onclick: event => {
event.target.parentNode.remove()
update(data.id, item => ({ ban: true, ...item }), this.store)
}
})
]
}),
List({
style: {
listStyle: 'disc',
},
children: [
ListItem({ textContent: `${data.text}` }),
]
}),
] ]
})) }))
if (scroll) {
this.ul.scrollTop = this.ul.scrollHeight
}
// 记录到上一条消息
this.last = { ...data, item: this.ul.lastChild }
} }
async 存储消息(data) { async 存储消息(data) {
// 检查id是否已经存在 // 检查id是否已经存在
@@ -215,7 +373,7 @@ export default class Chat {
await set(data.id, data, this.store) await set(data.id, data, this.store)
} }
发送消息(text) { 发送消息(text) {
const name = '我' const name = localStorage.getItem('username')
const id = window.crypto.randomUUID() const id = window.crypto.randomUUID()
const time = new Date().toLocaleString() const time = new Date().toLocaleString()
const type = 'text' const type = 'text'
@@ -225,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

@@ -32,40 +32,46 @@ export default class ClientList {
const webrtc_init = async () => { const webrtc_init = async () => {
const webrtc = new RTCPeerConnection({ const webrtc = new RTCPeerConnection({
iceServers: [ iceServers: [
{ ...[
urls: 'turn:satori.love:3478?transport=udp', 'stun:stun.voipbuster.com', // 德国 黑森州 美因河畔法兰克福
username: 'x-username', 'stun:stun.voipstunt.com', // 德国 黑森州 美因河畔法兰克福
credential: 'x-password' 'stun:stun.internetcalls.com', // 德国 黑森州 美因河畔法兰克福
}, 'stun:stun.voip.aebc.com', // 加拿大 不列颠哥伦比亚省 温哥华
{ 'stun:stun.1und1.de',
urls: [ 'stun:stun.callwithus.com',
'stun:stun.1und1.de', 'stun:stun.ekiga.net',
'stun:stun.callwithus.com', 'stun:stun.fwdnet.net',
'stun:stun.ekiga.net', 'stun:stun.fwdnet.net:3478',
'stun:stun.fwdnet.net', 'stun:stun.gmx.net',
'stun:stun.fwdnet.net:3478', 'stun:stun.iptel.org',
'stun:stun.gmx.net', 'stun:stun.minisipserver.com',
'stun:stun.iptel.org', 'stun:stun.schlund.de',
'stun:stun.internetcalls.com', 'stun:stun.sipgate.net',
'stun:stun.minisipserver.com', 'stun:stun.sipgate.net:10000',
'stun:stun.schlund.de', 'stun:stun.softjoys.com',
'stun:stun.sipgate.net', 'stun:stun.softjoys.com:3478',
'stun:stun.sipgate.net:10000', 'stun:stun.voxgratia.org',
'stun:stun.softjoys.com', 'stun:stun.wirlab.net',
'stun:stun.softjoys.com:3478', 'stun:stun.xten.com',
'stun:stun.voip.aebc.com', 'stun:stunserver.org',
'stun:stun.voipbuster.com', 'stun:stun01.sipphone.com',
'stun:stun.voipstunt.com', 'stun:stun.zoiper.com',
'stun:stun.voxgratia.org', 'stun:stun1.l.google.com:19302',
'stun:stun.wirlab.net', 'stun:stun2.l.google.com:19302',
'stun:stun.xten.com', 'stun:stun3.l.google.com:19302',
'stun:stunserver.org', 'stun:stun4.l.google.com:19302',
'stun:stun01.sipphone.com', 'stun:stun.ideasip.com',
'stun:stun.zoiper.com' 'stun:stun.stunprotocol.org:3478',
] 'stun:stun.voiparound.com',
} 'stun:stun.services.mozilla.com',
].map(url => ({ urls: url }))
//{
// urls: 'turn:satori.love:3478?transport=udp',
// username: 'x-username',
// credential: 'x-password'
//},
], ],
iceCandidatePoolSize: 10, // 限制 ICE 候选者的数量 iceCandidatePoolSize: 64, // 限制 ICE 候选者的数量
iceTransportPolicy: 'all', // 使用所有可用的候选者 iceTransportPolicy: 'all', // 使用所有可用的候选者
bundlePolicy: 'max-bundle',// 将所有媒体流捆绑在一起,以最大程度地提高性能和减少延迟 bundlePolicy: 'max-bundle',// 将所有媒体流捆绑在一起,以最大程度地提高性能和减少延迟
sctp: { sctp: {
@@ -95,18 +101,18 @@ export default class ClientList {
option.onmessage(event, client) option.onmessage(event, client)
} }
} }
//channel.onclose = event => { channel.onclose = event => {
// console.debug('对方关闭', channel.label, '数据通道') console.debug('对方关闭', channel.label, '数据通道')
// if (option && option.onclose) { if (option && option.onclose) {
// option.onclose(event, client) option.onclose(event, client)
// } }
//} }
//channel.onerror = event => { channel.onerror = event => {
// console.error(data.name, '通道', channel.label, '发生错误') console.error(data.name, '通道', channel.label, '发生错误')
// if (option && option.onerror) { if (option && option.onerror) {
// option.onerror(event, client) option.onerror(event, client)
// } }
//} }
} }
webrtc.onicecandidate = event => { webrtc.onicecandidate = event => {
if (event.candidate) { if (event.candidate) {
@@ -118,8 +124,10 @@ export default class ClientList {
} }
} }
webrtc.oniceconnectionstatechange = async event => { webrtc.oniceconnectionstatechange = async event => {
console.log(data.name, 'ICE 连接状态:', webrtc.iceConnectionState, webrtc.iceGatheringState)
if (webrtc.iceConnectionState === 'disconnected' || webrtc.iceConnectionState === 'failed') { if (webrtc.iceConnectionState === 'disconnected' || webrtc.iceConnectionState === 'failed') {
console.error(data.name, '需要添加新的 candidate') const client = this.clientlist.find(x => x.id === data.id) ?? {}
console.error(data.name, '需要添加新的 candidate', webrtc.iceConnectionState, client.online)
// 添加新的 candidate // 添加新的 candidate
} else if (webrtc.iceConnectionState === 'connected' || webrtc.iceConnectionState === 'completed') { } else if (webrtc.iceConnectionState === 'connected' || webrtc.iceConnectionState === 'completed') {
//console.debug(data.name, 'WebRTC 连接已经建立成功') //console.debug(data.name, 'WebRTC 连接已经建立成功')
@@ -147,7 +155,7 @@ export default class ClientList {
const { webrtc, channels } = await webrtc_init() const { webrtc, channels } = await webrtc_init()
const offer = await webrtc.createOffer() const offer = await webrtc.createOffer()
await webrtc.setLocalDescription(offer) await webrtc.setLocalDescription(offer)
this.clientlist.push({ id: data.id, name: data.name, webrtc, channels }) this.clientlist.push({ id: data.id, name: data.name, webrtc, channels, online: true })
websocket.send(JSON.stringify({ type: 'offer', id: data.id, offer })) websocket.send(JSON.stringify({ type: 'offer', id: data.id, offer }))
this.push(this.clientlist.find(client => client.id === data.id)) this.push(this.clientlist.find(client => client.id === data.id))
return return
@@ -158,11 +166,14 @@ export default class ClientList {
} }
if (data.type === 'pull') { if (data.type === 'pull') {
//console.debug('移除客户端:', data) //console.debug('移除客户端:', data)
const client = this.clientlist.find(client => client.id === data.id)
if (!client) return console.error('目标用户本不存在')
client.online = false // 离开时改变状态
return this.exit(data) return this.exit(data)
} }
if (data.type === 'offer') { if (data.type === 'offer') {
const { webrtc, channels } = await webrtc_init() const { webrtc, channels } = await webrtc_init()
this.clientlist.push({ id: data.id, name: data.name, webrtc, channels }) this.clientlist.push({ id: data.id, name: data.name, webrtc, channels, online: true })
this.push(this.clientlist.find(client => client.id === data.id)) this.push(this.clientlist.find(client => client.id === data.id))
await webrtc.setRemoteDescription(data.offer) await webrtc.setRemoteDescription(data.offer)
const answer = await webrtc.createAnswer() const answer = await webrtc.createAnswer()
@@ -239,7 +250,7 @@ export default class ClientList {
const avatar = localStorage.getItem('avatar') const avatar = localStorage.getItem('avatar')
this.push({ id, name: username, avatar }, true) this.push({ id, name: username, avatar }, true)
} }
async 用户列表() {} async 用户列表() { }
async 用户加入(data) { async 用户加入(data) {
await set(data.id, data, this.store) await set(data.id, data, this.store)
this.push(data) this.push(data)
@@ -250,7 +261,7 @@ export default class ClientList {
} }
async 用户更新({ id, name, avatar }) { async 用户更新({ id, name, avatar }) {
const client = this.clientlist.find(client => client.id === id) const client = this.clientlist.find(client => client.id === id)
console.log('更新用户信息:', name) console.log(name, '更新了身份信息')
document.getElementById(id).querySelector('span').textContent = name document.getElementById(id).querySelector('span').textContent = name
document.getElementById(id).querySelector('img').src = avatar document.getElementById(id).querySelector('img').src = avatar
} }

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,10 +1,14 @@
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'
import { Buffer } from "buffer"
import process from "process"
window.Buffer = Buffer
window.process = process
import { parseBlob } from 'music-metadata-browser'
// 缓冲分片发送 // 缓冲分片发送
const CHUNK_SIZE = 1024 * 64 // 默认每个块的大小为128KB const CHUNK_SIZE = 1024 * 64 // 默认每个块的大小为128KB
const THRESHOLD = 1024 * 1024 // 默认缓冲区的阈值为1MB const THRESHOLD = 1024 * 1024 // 默认缓冲区的阈值为1MB
@@ -19,13 +23,27 @@ function appendBuffer(buffer1, buffer2) {
} }
// 读取本地音乐列表并标识为缓存状态(本地缓存) // 读取本地音乐列表并标识为缓存状态(本地缓存)
const database = new IndexedDB('musicDatabase', 1) const musicStore = idb.createStore('database', 'music')
await database.store('musicObjectStore') // 音乐(为什么会用这么丑的格式呢)
// 读取本地音乐列表并标识为缓存状态(本地缓存) // 读取本地音乐列表并标识为缓存状态(本地缓存)
const list = (await database.getAll('musicObjectStore')).map(item => { const list = await idb.values(musicStore)
return { save: true, ...item } for (const item of list) {
}) if (!item.picture && item.picture !== false) {
console.log('提取封面', item.name)
const blob = new Blob([item.arrayBuffer], { type: item.type })
const metadata = await parseBlob(blob)
const picture = metadata.common.picture?.[0]
if (picture) {
const format = picture.format
const data = picture.data
item.picture = `data:${format};base64,${Buffer.from(data).toString('base64')}`
} else {
item.picture = false
}
idb.set(item.id, item, musicStore)
}
item.save = true
}
// 读取本地用户名(本地缓存) // 读取本地用户名(本地缓存)
const name = localStorage.getItem('username') ?? '匿' const name = localStorage.getItem('username') ?? '匿'
@@ -62,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 }))
@@ -73,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)
}, },
@@ -89,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)
@@ -122,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)
@@ -157,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') {
@@ -174,7 +207,7 @@ clientList.setChannel('chat', {
return return
} }
if (data.type === 'list') { if (data.type === 'list') {
console.log(client.name, `同步来 ${data.list.length} 条消息`) console.log(client.name, `同步来 ${data.list.length} 条消息`, data.list)
await chat.合并消息列表(data.list) await chat.合并消息列表(data.list)
return return
} }
@@ -344,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,13 +1,14 @@
import { 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()
this.audio.autoplay = true this.audio.autoplay = true
this.audio.controls = true this.audio.controls = true
this.audio.style.margin = '0 auto'
this.audio.style.flexShrink = 0 // 防止在flex中被挤压变形 this.audio.style.flexShrink = 0 // 防止在flex中被挤压变形
this.audio.addEventListener('play', () => { this.audio.addEventListener('play', () => {
this.event.onplay(this.playing) this.event.onplay(this.playing)
@@ -15,28 +16,80 @@ 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({
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 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)
}
}
})
// 收起到右上角, 音乐播放器基于浮窗定位, 不再占用页面空间 // 收起到右上角, 音乐播放器基于浮窗定位, 不再占用页面空间
const element = createElement({ const element = createElement({
@@ -44,18 +97,29 @@ 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',
maxWidth: '20rem', maxHeight: '70vh', width: '20rem', Height: '70vh', minWidth: '20rem', 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',
zIndex: '10', resize: 'auto',
}, },
onclick: event => { onclick: event => {
this.ul.classList.toggle('disable') this.ul.classList.toggle('disable')
}, },
onmousedown({ srcElement: dom, clientX, clientY }) { onmousedown({ srcElement: dom, clientX, clientY, offsetX, offsetY }) {
if (dom !== element) return if (dom !== element) return
const [w, h] = [innerWidth - dom.offsetWidth, innerWidth - dom.offsetHeight] 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 = () => { document.onmouseup = () => {
const [w, h] = [innerWidth - dom.clientWidth, innerHeight - dom.clientHeight]
if (dom.offsetLeft < 0) dom.style.left = '0px' if (dom.offsetLeft < 0) dom.style.left = '0px'
if (dom.offsetTop < 0) dom.style.top = '0px' if (dom.offsetTop < 0) dom.style.top = '0px'
if (dom.offsetLeft > w) dom.style.left = w + 'px' if (dom.offsetLeft > w) dom.style.left = w + 'px'
@@ -70,24 +134,76 @@ export default class MusicList {
} }
}, },
children: [ children: [
createElement({
style: {
display: 'flex', alignItems: 'center',
justifyContent: 'space-between', flexShrink: 0,
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: [
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.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)
@@ -110,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;
@@ -151,8 +270,22 @@ export default class MusicList {
this.ul.appendChild(ListItem({ this.ul.appendChild(ListItem({
id: item.id, id: item.id,
classList: item.arrayBuffer ? ['cache'] : [], classList: item.arrayBuffer ? ['cache'] : [],
style: {
display: 'flex', gap: '.25rem', maxWidth: '100%',
alignItems: 'center', justifyContent: 'space-between',
},
children: [ children: [
Img({
src: item.picture || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
style: { width: '2em', height: '2em', borderRadius: '.25em', backgroundColor: '#eee' },
}),
Span({ Span({
style: {
flex: 1,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflowX: 'hidden',
},
title: `${item.name} - ${bytesToSize(item.size)} - ${item.type}`, title: `${item.name} - ${bytesToSize(item.size)} - ${item.type}`,
textContent: `${item.name} - ${bytesToSize(item.size)}`, textContent: `${item.name} - ${bytesToSize(item.size)}`,
onclick: event => { onclick: event => {
@@ -185,14 +318,20 @@ 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()
if (!this.audio.paused) this.stop() // 停止播放 if (!this.audio.paused && item.id === this.playing?.id) this.stop() // 停止播放
this.list = this.list.filter(i => i.id !== item.id) this.list = this.list.filter(i => i.id !== item.id)
this.event.onremove(item) this.event.onremove(item)
} }
@@ -252,6 +391,20 @@ export default class MusicList {
}) })
} }
} else { } else {
const default_picture = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
this.标题.textContent = item.name
this.封面.style.backgroundImage = `url(${item.picture || default_picture})`
// 替换浏览器媒体信息(使系统通知栏显示歌曲信息)
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: item.name,
artist: '艺术家名',
album: '专辑名',
artwork: [
{ src: item.picture || default_picture, sizes: '96x96', type: 'image/jpeg' },
]
})
}
// 本地缓存直接播放 // 本地缓存直接播放
this.audio.src = URL.createObjectURL(new Blob([item.arrayBuffer], { type: item.type })) this.audio.src = URL.createObjectURL(new Blob([item.arrayBuffer], { type: item.type }))
this.audio.play() this.audio.play()

View File

@@ -37,6 +37,10 @@ export function Button(options) {
}, 'button') }, 'button')
} }
export function Img(options) {
return createElement(options, 'img')
}
export function Input(options) { export function Input(options) {
return createElement(options, 'input') return createElement(options, 'input')
} }

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;
}
}