Compare commits

..

136 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
5a39640f74 减少日志 2023-10-21 19:19:44 +08:00
b81cedc023 放置离开时产生报错 2023-10-21 19:17:53 +08:00
d668b7236d 避免重复ID 2023-10-21 19:16:16 +08:00
2808da61a0 DEBUG: 修正请求同步次数 2023-10-21 19:08:54 +08:00
74b6ae073d 修正为时间戳 2023-10-21 18:40:38 +08:00
11a8dbf4f3 DEBUG 2023-10-21 18:15:39 +08:00
17e16a4246 DEBUG: 消息筛选 2023-10-21 18:08:51 +08:00
2dd39dc530 使用时间戳范围同步 2023-10-21 18:00:08 +08:00
b27340365a 清屏 2023-10-21 16:53:09 +08:00
347de07a90 tips 2023-10-21 16:51:31 +08:00
212adad2af 消息同步 2023-10-21 16:17:02 +08:00
99e3c0ac63 合并 2023-10-21 15:54:15 +08:00
afa9edd3ad 合并 2023-10-21 15:50:14 +08:00
77fd29efb4 为消息添加id 2023-10-21 15:48:01 +08:00
e96b319a85 启动时build 2023-10-21 09:18:26 +08:00
2d4da34a41 去除设置昵称输入框 2023-10-21 09:10:09 +08:00
c786e05302 降低日志级别 2023-10-21 08:38:06 +08:00
7abc7a19cf 降低日志级别 2023-10-21 08:37:19 +08:00
9b0d418abc 移除错误消息 2023-10-21 08:34:26 +08:00
346adfe6b6 DEBUG 2023-10-21 08:07:11 +08:00
1c584097d5 更新用户信息 2023-10-21 08:06:29 +08:00
3aa2b65c26 移除icon 2023-10-21 07:57:07 +08:00
270b899f0f 接收消息合并 2023-10-21 07:54:30 +08:00
cb14d5b799 快捷移位 2023-10-21 07:37:26 +08:00
e844cbd910 游戏模式 2023-10-21 06:46:56 +08:00
6a244555d6 对话框 2023-10-21 05:50:17 +08:00
14dddbe8f5 删除记录 2023-10-21 05:09:32 +08:00
d3e2355fb3 用户离开 2023-10-21 04:54:41 +08:00
93e5754857 隐藏音乐列表日志 2023-10-21 04:48:47 +08:00
07b6370ff8 用户更新 2023-10-21 04:47:40 +08:00
e6af4f0fc7 唯一ID? 2023-10-21 04:21:27 +08:00
ea397c735b TextArea 2023-10-21 03:48:03 +08:00
f5ac981a15 合并style 2023-10-21 03:42:56 +08:00
df3500f6c5 发送按钮 2023-10-21 03:38:14 +08:00
cabab4890b 本地存储消息 2023-10-21 03:27:09 +08:00
5c00514610 使用 idb-keyval 2023-10-21 02:26:42 +08:00
6f267109a4 demo 2023-10-20 23:05:24 +08:00
c37086cfcd DEBUGL 2023-10-20 21:54:20 +08:00
e5c9623092 初始化存储 2023-10-20 20:34:13 +08:00
06479610f9 Merge branch 'main' of git.satori.love:LaniakeaSupercluster/webrtc 2023-10-20 15:13:45 +08:00
ef3f17e7a0 answer 2023-10-20 15:13:39 +08:00
XiaoZhuo
b1e3a7c8d7 播放器自由拖动位置 2023-10-20 03:23:01 +08:00
692ae68433 textarea 2023-10-19 10:44:01 +08:00
eea4e71414 DEBUG (离谱 2023-10-19 10:21:42 +08:00
1051fa5669 对象无法传递(离谱 2023-10-19 10:19:31 +08:00
807dd17ad6 DEBUG store 2023-10-19 10:02:32 +08:00
5e2205ecdc 防止在flex中被挤压变形 2023-10-19 09:02:07 +08:00
9af97703e6 停用 windi-devtools 2023-10-19 08:49:28 +08:00
da09d67121 修正indexdb连接 2023-10-19 08:47:39 +08:00
2c00cb8ef9 清理调试信息 2023-10-19 07:03:21 +08:00
edae22db79 profile 分离 2023-10-19 06:55:54 +08:00
23a0c60ad0 sctp默认参数 2023-10-19 02:26:29 +08:00
1211efae37 聊天窗口 2023-10-19 01:12:34 +08:00
f9d1d4b588 紧凑布局4x 2023-10-19 01:04:12 +08:00
9f8670ed7d 紧凑布局2x 2023-10-19 01:01:52 +08:00
5ebb20716d 紧凑布局 2023-10-19 00:54:43 +08:00
857f38a7ac 鼠标悬停时气泡展示音乐信息 2023-10-19 00:34:16 +08:00
247e3e532d 合并组件 2023-10-19 00:21:45 +08:00
1cdfc924d2 收起音乐播放器 2023-10-19 00:14:29 +08:00
273633bda4 聊天室显示自己的消息 2023-10-19 00:01:58 +08:00
b14aaa6ec3 添加聊天室 2023-10-18 23:54:44 +08:00
488d6d9c7c 停用本地图像存储(BUG 2023-10-17 17:00:10 +08:00
d142d3a613 退场过渡 2023-10-13 20:11:21 +08:00
ae7402b62c 增加一个按下Esc状态界面 2023-10-13 19:08:25 +08:00
624f21cc84 云朵位置修正 2023-10-13 08:27:25 +08:00
aa0875ae9e build debug 2023-10-13 05:51:45 +08:00
2aadae6b85 使用vite 2023-10-13 05:45:12 +08:00
0865ef39f6 补全 innerHTML 2023-10-13 03:16:57 +08:00
5a39a2eb3d 自动为 element 添加所有事件 2023-10-13 03:10:39 +08:00
52ec69488c 音乐符号 2023-10-13 02:01:37 +08:00
7694e79809 文件格式检查 2023-10-13 01:40:31 +08:00
1a7e373aa1 暂存 2023-10-13 01:28:33 +08:00
065df5cc9e 拖放事件 2023-10-13 01:07:36 +08:00
23 changed files with 1906 additions and 1308 deletions

134
.gitignore vendored
View File

@@ -4,129 +4,23 @@ logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.pnpm-debug.log*
package-lock.json
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
node_modules
dist
dist-ssr
*.local
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,14 +1,23 @@
# webRTC
webrtc 实现的 p2p 信道
rtc rtc rtc: 稳定, 多重连接
channel channel channel: 细流
part-server: 调谐, 从不同服务器请求资源分片
webrtc://用户@域名:端口/信道标识/资源ID
```bash
# 使用 git 克隆到本地或者直接下载zip压缩包
git clone git@git.satori.love:LaniakeaSupercluster/webrtc.git
cd webrtc
封包格式
资源ID 分片信息(位置) 分片数据
# 安装依赖
npm i
# 编译
npm run build
# 运行服务
npm run start
# 或者使用 pm2 作为守护进程
pm2 start npm --name webrtc -- run start
```
插件市场
1. 从浏览器创建插件(单文件)
@@ -30,28 +39,27 @@ export default class 插件名 {
聊天室
1. 每个设备保存全量聊天记录
2. 每个设备各自设定存储区间
3. 接入网络后向同频道设备同步区间内记录
4. 对方撤回的并不删除, 但不再分发
5. 阅后既焚开关, 全频道不保留也不分发记录
6. mark 标记的记录保留, 其它自动丢弃
- [x] 每个设备保存全量聊天记录
- [x] 每个设备各自设定存储区间
- [x] 接入网络后向同频道设备同步区间内记录
- [ ] 对方撤回的并不删除, 但不再分发
- [ ] 阅后既焚开关, 全频道不保留也不分发记录
- [ ] mark 标记的记录保留, 其它自动丢弃
- [ ] 非活跃状态下提示音
音乐频道
1. 每个设备存储自己的列表
2. 可以缓存对方的列表
3. 使用md5验证完整性
4. 可以上传lrc
5. 可以上传封面, 可以从数据中解析封面
6. ban表匹配时不播放且收起隐藏, 支持正则ban表
- [x] 每个设备存储自己的列表
- [x] 可以缓存对方的列表
- [ ] 使用md5验证完整性
- [ ] 可以上传lrc
- [ ] 可以上传封面, 可以从数据中解析封面
- [ ] ban表匹配时不播放且收起隐藏, 支持正则ban表
猫窝
7. 每个节点都公开持有的资源列表, 和连接的节点列表
8. 每当资源变动时告知所有连接的节点
9. 与节点创建多个RTC时, 不发送多份, 以ID为准, id随机生成给不同机器, 无法通过ID锁定其它机器
10. 通过WS交换信息时, ID是否固定? 向WS提供连接?
传递资源
1. 每个节点都公开持有的资源列表, 和连接的节点列表
2. 每当资源变动时告知所有连接的节点
3. 与节点创建多个RTC时, 不发送多份, 以ID为准, id随机生成给不同机器, 无法通过ID锁定其它机器
4. 通过WS交换信息时, ID是否固定? 向WS提供连接?
- [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
<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})

11
index.html Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webRTC</title>
</head>
<body>
<script type="module" src="./src/main.js"></script>
</body>
</html>

View File

@@ -5,7 +5,7 @@ import { exec } from 'child_process'
// 创建 TURN 服务器
const turnServer = new turn({
debugLevel: 'ALL'
debugLevel: 'WARN', // ALL, DEBUG, INFO, WARN, ERROR, NONE
})
// 启动 TURN 服务器
@@ -15,7 +15,7 @@ turnServer.start(() => {
const app = express()
const wsInstance = expressWs(app)
app.use(express.static('public'))
app.use(express.static('dist'))
app.use(express.json())
app.use((req, res, next) => {
if (req.method === 'CONNECT') {
@@ -121,7 +121,7 @@ app.ws('/entanglement', (ws, req) => {
// WEBHOOK 处理 GitHub 事件
app.post('/webhook', (req, res) => {
console.log('WEBHOOK:' + new Date().toLocaleString())
exec('git pull;npm i')
exec('git pull;npm i;npm run build;pm2 reload webrtc;')
return res.json({ success: true })
})

View File

@@ -5,17 +5,30 @@
"type": "module",
"main": "index.js",
"scripts": {
"dev": "nodemon index.js",
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"devx": "nodemon index.js",
"start": "node index.js"
},
"author": "",
"license": "GPL-3.0-or-later",
"devDependencies": {
"nodemon": "^3.0.1"
"nodemon": "^3.0.1",
"vite": "^4.4.11",
"vite-plugin-windicss": "^1.9.1",
"windicss": "^3.5.6"
},
"dependencies": {
"buffer": "^6.0.3",
"express": "^4.18.2",
"express-ws": "^5.0.2",
"node-turn": "^0.0.6"
"gun": "^0.2020.1239",
"iconv-lite": "^0.6.3",
"iconv-lite-umd": "^0.6.10",
"idb-keyval": "^6.2.1",
"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,108 +0,0 @@
// 使用示例:
// const db = new IndexedDB('myDatabase', 1, 'myStore')
// await db.open()
// await db.add({ id: 1, name: 'John' })
// const data = await db.get(1)
// console.log(data)
// await db.delete(1)
export default class IndexedDB {
constructor(databaseName, databaseVersion, storeName) {
this.databaseName = databaseName
this.databaseVersion = databaseVersion
this.storeName = storeName
this.db = null
}
open() {
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(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'id' })
}
}
})
}
add(data) {
console.log('add', data)
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite')
const objectStore = transaction.objectStore(this.storeName)
// 判断是否已经存在
const request = objectStore.get(data.id)
request.onerror = (event) => {
reject(event.target.error)
}
request.onsuccess = (event) => {
if (event.target.result) return resolve(event.target.result)
const request = objectStore.add(data)
request.onerror = (event) => {
reject(event.target.error)
}
request.onsuccess = (event) => {
resolve(event.target.result)
}
}
})
}
get(id) {
console.log('get', id)
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly')
const objectStore = transaction.objectStore(this.storeName)
const request = objectStore.get(id)
request.onerror = (event) => {
reject(event.target.error)
}
request.onsuccess = (event) => {
resolve(event.target.result)
}
})
}
getAll() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly')
const objectStore = transaction.objectStore(this.storeName)
const request = objectStore.getAll()
request.onerror = (event) => {
reject(event.target.error)
}
request.onsuccess = (event) => {
resolve(event.target.result)
}
})
}
delete(id) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite')
const objectStore = transaction.objectStore(this.storeName)
const request = objectStore.delete(id)
request.onerror = (event) => {
reject(event.target.error)
}
request.onsuccess = (event) => {
resolve(event.target.result)
}
})
}
}

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>

View File

@@ -1,225 +0,0 @@
import { Span, Button, List, ListItem, UploadMusic } from './weigets.js'
export default class MusicList {
constructor({ list = [], EventListeners = {}, onplay, onstop, onadd, onremove, onlike, onunlike, onban, onload }) {
this.event = { onplay, onstop, onadd, onremove, onlike, onunlike, onban, onload }
this.ul = List({ classList: ['music-list'] })
this.EventListeners = EventListeners
this.list = []
list.forEach(item => this.add(item)) // 列表逐一添加
document.body.appendChild(this.ul) // 元素加入页面
// 添加音乐播放器
this.audio = new Audio()
this.audio.addEventListener('ended', () => {
this.next()
})
//this.audio.addEventListener('timeupdate', () => {
// console.log(this.audio.currentTime)
//})
//this.audio.addEventListener('error', event => {
// console.error('音乐播放器错误:', event)
//})
// 本地添加音乐按钮
document.body.appendChild(UploadMusic({
style: { width: '20rem', height: '5rem', margin: '1rem 2rem' },
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)
}
}
}))
// 写入 css 样式到 head
const style = document.createElement('style')
style.innerText = `
ul.music-list {
max-height: 70vh;
overflow-y: auto;
}
ul.music-list > li > span {
cursor: pointer;
}
ul.music-list > li.play > span {
color: #02be08;
}
ul.music-list > li.cache::marker {
color: #02be08;
font-size: 1em;
contentx: '⚡';
}
ul.music-list > li.disable {
color: #999999;
}
ul.music-list > li > button {
margin-left: 10px;
border: none;
border-radius: 1em;
cursor: pointer;
user-select: none;
font-size: .5rem;
padding: 0 .5rem;
color: #555555;
}
ul.music-list > li > button:hover {
background-color: #ccc;
}
`
document.head.appendChild(style)
}
add(item) {
// 如果ID已存在则不添加
if (this.list.find(i => i.id === item.id)) {
return
}
// 将字节转换为可读的单位
const bytesToSize = bytes => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]
}
this.list.push(item)
this.ul.appendChild(ListItem({
id: item.id,
classList: item.arrayBuffer ? ['cache'] : [],
children: [
Span({
textContent: `${item.name} - ${bytesToSize(item.size)}`,
onclick: event => {
event.stopPropagation() // !如果使用async则此处不能阻止冒泡传递
const li = event.target.parentElement // ListItem
const ul = li.parentElement // List
const list = Array.from(ul.children) // ListItems
list.forEach(li => li.classList.remove('play'))
if (!this.audio.paused && this.playing === item) {
li.classList.remove('play')
this.stop(item)
} else {
li.classList.add('play')
this.play(item)
}
}
}),
Button({
textContent: item.save ? '移除' : '缓存',
onclick: event => {
event.stopPropagation() // !如果使用async则此处不能阻止冒泡传递
if (item.save) {
event.target.textContent = '缓存'
this.ul.querySelector(`#${item.id}`).classList.remove('cache')
this.unlike(item)
} else {
item.save = true
event.target.textContent = '移除'
this.ul.querySelector(`#${item.id}`).classList.add('cache')
this.like(item)
}
}
})
]
}))
this.event.onadd(item, this.list)
}
async remove(item) {
this.ul.querySelector(`#${item.id}`)?.remove()
if (!this.audio.paused) this.stop() // 停止播放
this.list = this.list.filter(i => i.id !== item.id)
this.event.onremove(item)
}
async load(item) {
await this.event.onload(item)
}
async play(item) {
if (!item.arrayBuffer) {
console.log('加载音乐类型:', item.type)
// 不支持流式加载wav和flac和m4a, 需要全部加载完毕才能播放
if (item.type === 'audio/wav' || item.type === 'audio/flac' || item.type === 'audio/x-m4a') {
await this.load(item)
this.audio.src = URL.createObjectURL(new Blob([item.arrayBuffer], { type: item.type }))
this.audio.play()
} else {
// 边加载边播放
const mediaSource = new MediaSource()
this.audio.src = URL.createObjectURL(mediaSource)
if (!item.arrayBufferChunks) item.arrayBufferChunks = []
mediaSource.addEventListener('sourceopen', async () => {
const sourceBuffer = mediaSource.addSourceBuffer(item.type)
const arrayBufferLoader = async (index = 0) => {
console.log('开始加载====================================')
// 按照数据长度计算出分片应有数量, 如果数量不到且没有停止加载则一直读取
const chunkNumber = Math.ceil(item.size / 1024 / 64) // 64KB每片
console.log({ index, chunkNumber, paused: this.audio.paused })
while (index < chunkNumber && !this.audio.paused) {
const 播放状态 = !this.audio.paused && this.playing === item
const 加载状态 = item.arrayBufferChunks.length < chunkNumber
const 结束时间 = sourceBuffer.buffered.length && sourceBuffer.buffered.end(0)
const 缓冲时间 = 结束时间 - this.audio.currentTime
if (!播放状态 && !加载状态) break // 播放停止且加载完毕则退出
if (this.audio.paused || this.playing !== item) break // 播放停止或已经切歌则退出
if (缓冲时间 > 60) { // 缓冲超过60秒则等待30秒
await new Promise(resolve => setTimeout(resolve, 30000))
continue
}
if (sourceBuffer.updating) { // sourceBuffer正在更新则等待更新结束
await new Promise(resolve => sourceBuffer.addEventListener('updateend', resolve))
continue
}
if (item.arrayBufferChunks.length <= index) { // 分片数量不足则等待
await new Promise(resolve => setTimeout(resolve, 200))
continue
}
console.log('播放器加载分片:', item.name, `${index + 1}/${chunkNumber}`)
const chunk = item.arrayBufferChunks[index] // 顺序取出一个arrayBuffer分片
sourceBuffer.appendBuffer(chunk) // 添加到sourceBuffer
index++
}
console.log('加载完毕====================================')
item.arrayBufferChunks = null // 加载完毕释放分片内存
}
this.event.onload(item)
this.audio.play()
arrayBufferLoader()
})
}
} else {
// 本地缓存直接播放
this.audio.src = URL.createObjectURL(new Blob([item.arrayBuffer], { type: item.type }))
this.audio.play()
}
this.playing = item
this.event.onplay(item)
}
async stop() {
if (this.audio.paused) {
return console.error('暂停播放:音乐播放器不是播放状态!')
}
this.audio.pause()
this.event.onstop(this.playing)
this.playing = null
}
async like(item) {
if (!item.arrayBuffer) {
await this.load(item)
}
this.event.onlike(item, this.list)
}
async unlike(item) {
this.event.onunlike(item, this.list)
}
async ban(item) {
this.event.onban(item)
}
next() { }
prev() { }
}

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 }

392
src/chat.js Normal file
View File

@@ -0,0 +1,392 @@
import { get, set, del, update, createStore, values } from 'idb-keyval'
import { Span, Button, List, ListItem, TextArea, createElement, Input } from './weigets.js'
export default class Chat {
constructor({ name, EventListeners = {}, onsend, onexit }) {
this.event = { onsend, onexit }
this.store = createStore(`db-chat-${name}`, `store-chat-${name}`)
this.ul = List({ classList: ['chat-list'] })
this.EventListeners = EventListeners
this.element = createElement({
style: {
position: 'fixed', bottom: 0, left: '50%',
maxWidth: '64rem', width: '100%',
transform: 'translate(-50%, 0)',
display: 'block',
transition: 'all .15s',
},
children: [
this.ul,
createElement({
style: {
display: 'flex',
margin: '1rem 2rem',
},
children: [
Input({
type: 'text',
// 随机显示一句话
placeholder: [
'输入聊天内容',
'ctrl+左键: 左边显示',
'ctrl+右键: 右边显示',
'ctrl+下键: 居中显示',
'ctrl+L: 清屏',
].sort(() => Math.random() - 0.5)[0],
style: {
flex: 1,
height: '3rem',
padding: '0 1rem',
boxSizing: 'border-box',
boxShadow: '0 0 1rem #eee',
border: 'none',
outline: 'none',
borderRadius: '2rem'
},
onkeydown: event => {
event.stopPropagation()
const text = event.target.value.trim()
if (text && event.key === 'Enter') {
this.发送消息(text)
event.target.value = ''
}
if (!text && event.key === 'Enter') {
event.target.parentNode.parentNode.style.display = 'none'
}
if (event.ctrlKey && event.key === 'ArrowLeft') {
event.target.parentNode.parentNode.style.left = '0'
event.target.parentNode.parentNode.style.right = '50%'
}
if (event.ctrlKey && event.key === 'ArrowRight') {
event.target.parentNode.parentNode.style.left = '50%'
event.target.parentNode.parentNode.style.right = '0'
}
if (event.ctrlKey && event.key === 'ArrowDown') {
event.target.parentNode.parentNode.style.left = '50%'
event.target.parentNode.parentNode.style.right = '50%'
}
if (event.ctrlKey && event.key === 'l') {
this.ul.innerHTML = '' // 清理屏幕
// 获取当前时间戳
const timestamp = new Date().getTime()
// 清理本地存储
values(this.store).then(list => list.forEach(item => {
const t = new Date(item.time).getTime()
if (t < timestamp) del(item.id, this.store)
}))
console.log('清屏', `store-chat-${name}`, timestamp)
localStorage.setItem(`store-chat-${name}`, timestamp)
}
}
}),
Button({
onclick: event => {
const text = event.target.previousSibling.value.trim()
if (text) {
this.发送消息(text)
event.target.previousSibling.value = ''
}
},
style: {
width: '1.2rem',
height: '1.2rem',
border: 'none',
background: 'url("/send.svg") no-repeat center / cover',
margin: 'auto 0 auto -2.6rem'
}
}),
]
})
]
})
document.body.appendChild(this.element)
document.head.appendChild(createElement({
innerText: `
ul.chat-list::-webkit-scrollbar {
display: none;
}
ul.chat-list {
scrollbar-width: none;
}
ul.chat-list {
max-height: 70vh;
overflow-y: auto;
}
ul.chat-list > li > span {
cursor: pointer;
}
ul.chat-list > li.play > span {
color: #02be08;
}
ul.chat-list > li.cache::marker {
color: #02be08;
font-size: 1em;
contentx: '⚡';
}
ul.chat-list > li.disable {
color: #888;
}`
}, 'style'))
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 挂载全局快捷键() {
document.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === '/') {
this.element.style.display = 'block'
this.element.querySelector('input').focus()
}
})
}
async 从本地载入消息() {
const data = await values(this.store)
data.map(item => ({ timestamp: new Date(item.time).getTime(), ...item }))
.filter(item => !item.ban)
.sort((a, b) => a.timestamp - b.timestamp).forEach(item => {
this.添加元素(item, true)
})
}
async 筛选指定范围的消息({ start, end }) {
const data = await values(this.store)
return data.map(item => ({ timestamp: new Date(item.time).getTime(), ...item })).filter(item => {
const timestamp = new Date(item.time).getTime()
return timestamp >= start && timestamp <= end && !item.ban
})
}
// 检查本地不存在的id,存储消息
async 合并消息列表(list) {
const data = await values(this.store)
const ids = data.map(item => item.id)
list.filter(item => !ids.includes(item.id)).forEach(item => {
this.添加元素(item)
this.存储消息(item)
})
}
// 收到应答(对方确认消息已被接收)
answer(data) {
const { name, text, time, type } = data
const item = this.add({ name, text, time, type })
item.classList.add('disable')
}
// 添加一条消息
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({
classList: [type],
children: [
Span({ textContent: `${name} ${time} ${text}` })
]
})
this.ul.appendChild(item)
this.ul.scrollTop = this.ul.scrollHeight
// 记录到上一条消息
this.last = { name, text, time, type, item }
return item
}
send(text) {
if (this.event.onsend) {
this.event.onsend(text)
}
}
添加元素(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({
style: {
margin: '1rem',
padding: '.5rem 1rem',
boxSizing: 'border-box',
boxShadow: '0 0 1rem #eee',
maxWidth: '24rem',
borderRadius: '1rem',
listStyle: 'none',
backgroundColor: 'rgba(255,255,255,.9)',
},
children: [
createElement({
style: {
display: 'flex',
alignItems: 'end',
justifyContent: 'space-between',
gap: '.25rem',
},
children: [
Span({ textContent: `${data.name}` }),
Span({
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) {
// 检查id是否已经存在
const item = await get(data.id, this.store)
if (item) return;
await set(data.id, data, this.store)
}
发送消息(text) {
const name = localStorage.getItem('username')
const id = window.crypto.randomUUID()
const time = new Date().toLocaleString()
const type = 'text'
const data = { id, name, text, time, type }
this.添加元素(data)
this.存储消息(data)
this.send(data)
}
收到消息(data) {
const { avatar, ...save } = data
this.添加元素(data)
this.存储消息(save)
this.通知栏消息(data)
this.播放提示音()
}
}

View File

@@ -1,8 +1,10 @@
import { get, set, del, update, createStore, values } from 'idb-keyval'
import { List, ListItem, Avatar, Span, Dialog, Button, Input } from './weigets.js'
export default class ClientList {
constructor({ channels = {}, EventListeners = {}, name: username, onexit }) {
this.event = { onexit }
this.store = createStore(`db-user`, `store-user`)
this.channels = channels
this.EventListeners = EventListeners
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
@@ -30,13 +32,11 @@ export default class ClientList {
const webrtc_init = async () => {
const webrtc = new RTCPeerConnection({
iceServers: [
{
urls: 'turn:satori.love:3478?transport=udp',
username: 'x-username',
credential: 'x-password'
},
{
urls: [
...[
'stun:stun.voipbuster.com', // 德国 黑森州 美因河畔法兰克福
'stun:stun.voipstunt.com', // 德国 黑森州 美因河畔法兰克福
'stun:stun.internetcalls.com', // 德国 黑森州 美因河畔法兰克福
'stun:stun.voip.aebc.com', // 加拿大 不列颠哥伦比亚省 温哥华
'stun:stun.1und1.de',
'stun:stun.callwithus.com',
'stun:stun.ekiga.net',
@@ -44,35 +44,53 @@ export default class ClientList {
'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'
]
}
'stun:stun.zoiper.com',
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302',
'stun:stun3.l.google.com:19302',
'stun:stun4.l.google.com:19302',
'stun:stun.ideasip.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', // 使用所有可用的候选者
bundlePolicy: 'balanced', // 每種類型的內容建立一個單獨的傳輸
bundlePolicy: 'max-bundle',// 将所有媒体流捆绑在一起,以最大程度地提高性能和减少延迟
sctp: {
//maxMessageSize: 1024 * 64, // 64KB
//maxRetransmits: 10, // 最大重传次数
//maxPacketLifeTime: 3000, // 最大生存时间
//renegotiationTimeout: 3000, // 重协商超时时间
//redeliveryTimeout: 1000, // 重传超时时间
//redeliveryTime: 1000, // 重传时间
//reliability: 'reliable', // 可靠传输
//ordered: true, // 有序传输
}
})
webrtc.ondatachannel = ({ channel }) => {
console.debug(data.name, '建立', channel.label, '数据通道')
//console.debug(data.name, '建立', channel.label, '数据通道')
const client = this.clientlist.find(x => x.id === data.id)
const option = this.channels[channel.label]
channel.onopen = event => {
console.debug('对方打开', channel.label, '数据通道')
//console.debug('对方打开', channel.label, '数据通道')
if (option && option.onopen) {
option.onopen(event, client)
}
@@ -106,46 +124,57 @@ export default class ClientList {
}
}
webrtc.oniceconnectionstatechange = async event => {
console.log(data.name, 'ICE 连接状态:', webrtc.iceConnectionState, webrtc.iceGatheringState)
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
} else if (webrtc.iceConnectionState === 'connected' || webrtc.iceConnectionState === 'completed') {
console.debug(data.name, 'WebRTC 连接已经建立成功')
//console.debug(data.name, 'WebRTC 连接已经建立成功')
}
}
const channels = Object.entries(this.channels).map(([name, callback]) => {
const channel = webrtc.createDataChannel(name, { reliable: true })
return channel
const dc = webrtc.createDataChannel(name, {
reliability: 'reliable', // 可靠传输
reliable: true, // 可靠传输
ordered: true, // 有序传输
//maxMessageSize: 64, // 64KB
//maxRetransmits: 10, // 最大重传次数
//maxPacketLifeTime: 3000, // 最大生存时间
})
return dc
})
//// 页面刷新前主动关闭所有数据通道并关闭 WebRTC 连接
//window.addEventListener('beforeunload', event => {
// channels.forEach(ch => ch.close())
// webrtc.close()
//})
return { webrtc, channels }
}
if (data.type === 'list') {
console.debug('取得在线对端列表:', data)
const { webrtc, channels } = await webrtc_init()
console.debug('发送给对方 offer')
const offer = await webrtc.createOffer()
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 }))
// 传递正确的指针给元素, 以便其能够调取正确的头像
this.push(this.clientlist.find(client => client.id === data.id))
return
}
if (data.type === 'push') {
console.debug('新上线客户端:', data)
//console.debug('新上线客户端:', data)
return
}
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)
}
if (data.type === 'offer') {
console.debug('收到对方 offer', data)
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))
console.debug('发送给对方 answer')
await webrtc.setRemoteDescription(data.offer)
const answer = await webrtc.createAnswer()
await webrtc.setLocalDescription(answer)
@@ -153,13 +182,11 @@ export default class ClientList {
return
}
if (data.type === 'answer') {
console.debug('收到对方 answer', data)
const webrtc = this.clientlist.find(client => client.id === data.id).webrtc
await webrtc.setRemoteDescription(data.answer)
return
}
if (data.type === 'candidate') {
console.debug(data.name, '发来 candidate 候选通道')
const webrtc = this.clientlist.find(client => client.id === data.id).webrtc
await webrtc.addIceCandidate(data.candidate)
return
@@ -177,18 +204,66 @@ export default class ClientList {
}
this.websocket = linkStart()
// 也插入自己的信息
const avatar = localStorage.getItem('avatar')
this.push({ id: 'self', name: username, avatar }, true)
this.我的帐户()
this.DEBUG()
}
getAvatar(id) { }
setAvatar(user) {
console.info('更新avatar', user)
document.getElementById(user.id).querySelector('img').src = user.avatar
const u = this.clientlist.find(client => client.id === user.id)
u.avatar = user.avatar
console.log(u, user)
//.avatar = user.avatar
async DEBUG() {
// 监听键盘Esc按下, 如果全局没有焦点则显示调试信息, 如果在调试信息显示期间弹起Esc则隐藏调试信息
let debug = false
let debugElement = Dialog({
children: [
Button({
textContent: '关闭',
onclick: event => document.body.removeChild(debugElement)
}),
Span({
textContent: JSON.stringify(this.clientlist, null, 4)
})
]
})
document.addEventListener('keydown', event => {
if (document.activeElement === document.body && event.key === 'Escape' && !debug) {
document.body.appendChild(debugElement)
debug = true
}
})
document.addEventListener('keyup', async event => {
if (document.activeElement === document.body && event.key === 'Escape' && debug) {
await debugElement.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 100 }).finished
document.body.removeChild(debugElement)
debug = false
}
})
}
async 我的帐户() {
if (!localStorage.getItem('id')) {
localStorage.setItem('id', window.crypto.randomUUID())
}
if (!localStorage.getItem('username')) {
localStorage.setItem('username', '匿')
}
if (!localStorage.getItem('avatar')) {
localStorage.setItem('avatar', '/favicon.ico')
}
const id = localStorage.getItem('id')
const username = localStorage.getItem('username')
const avatar = localStorage.getItem('avatar')
this.push({ id, name: username, avatar }, true)
}
async 用户列表() { }
async 用户加入(data) {
await set(data.id, data, this.store)
this.push(data)
}
async 用户离开({ id }) {
await del(id, this.store)
this.exit({ id })
}
async 用户更新({ id, name, avatar }) {
const client = this.clientlist.find(client => client.id === id)
console.log(name, '更新了身份信息')
document.getElementById(id).querySelector('span').textContent = name
document.getElementById(id).querySelector('img').src = avatar
}
exit(item) {
const client = this.clientlist.find(client => client.id === item.id)
@@ -312,6 +387,48 @@ export default class ClientList {
ch.send(data)
})
}
// 通过指定通道发送数据(单播, 自动分片)
sendto2(id, name, data) {
console.log('发送数据:', name)
const client = this.clientlist.find(client => client.id === id)
if (!client) {
console.error('客户端不存在:', id)
return
}
if (!client.channels.find(ch => ch.label === name)) {
console.error('通道不存在:', name)
return
}
client.channels.filter(ch => ch.label === name).forEach(async ch => {
console.log('发送数据:', name, ch.label)
// 等待 datachannel 打开(临时解决方案)
while (ch.readyState !== 'open') {
await new Promise(resolve => setTimeout(resolve, 100))
}
console.log('发送数据:', name, ch.label, ch.readyState)
// 将数据转换为arraybuffer
const buffer = new ArrayBuffer(data.length)
const view = new Uint8Array(buffer)
for (let i = 0; i < data.length; i++) {
view[i] = data.charCodeAt(i) & 0xff
}
// 将数据分片发送
const CHUNK_SIZE = 16 * 1024; // 16KB
for (let i = 0; i < buffer.byteLength; i += CHUNK_SIZE) {
const chunk = view.subarray(i, i + CHUNK_SIZE)
ch.send(chunk)
console.log('缓冲区:', ch.bufferedAmount)
console.log('缓冲区剩余:', ch.bufferedAmountLowThreshold)
}
})
}
// 通过指定通道发送数据(广播)
send(name, data) {
this.clientlist.forEach(client => {

392
src/main.js Normal file
View File

@@ -0,0 +1,392 @@
import * as idb from 'idb-keyval'
import MusicList from './music.js'
import ClientList from './client.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 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 musicStore = idb.createStore('database', 'music')
// 读取本地音乐列表并标识为缓存状态(本地缓存)
const list = await idb.values(musicStore)
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 avatar = localStorage.getItem('avatar') ?? '/favicon.ico'
// 初始化客户端列表
const clientList = new ClientList({
name,
onexit: async client => {
console.log(client.name, '离开频道')
// 从列表中移除未缓存的此用户的音乐, 但可能多人都有此音乐且未缓存
// 因此每条音乐都要检查是否有其他用户也有此音乐, 如果有则不移除
const 此用户音乐 = client.musicList?.map(item => item.id) || []
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) {
idb.set(item.id, item, musicStore)
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) {
idb.del(item.id, musicStore)
clientList.send('base', JSON.stringify({
type: 'set_music_list',
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 => {
//console.info('禁止音乐', item.name)
},
onunban: item => {
//console.info('解禁音乐', item.name)
},
onremove: item => {
//console.info('移除音乐', item.name)
idb.del(item.id, musicStore)
},
onadd: (item, list) => {
//console.info('添加音乐', item.name)
},
onupdate: item => {
//console.info('更新音乐', item.name)
},
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)
})
if (!client) return console.error('未找到拥有此音乐的用户')
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 chat = new Chat({
name: 'default',
onsend: async (text, list) => {
clientList.send('chat', JSON.stringify({ type: 'message', text }))
},
onexit: async () => {
console.log('退出聊天室')
}
})
// 与每个客户端都建立聊天信道
clientList.setChannel('chat', {
onopen: async (event, client) => {
const start = localStorage.getItem('store-chat-default') || 0
const end = new Date().getTime()
clientList.sendto(client.id, 'chat', JSON.stringify({ type: 'pull', start, end }))
},
onmessage: async (event, client) => {
const data = JSON.parse(event.data)
if (data.type === 'message') {
console.log(client.name, '发来消息:', data)
chat.收到消息({ name: client.name, ...data.text, avatar: client.avatar })
return
}
if (data.type === 'answer') {
console.log(client.name, '发来应答:', data)
chat.answer(data)
return
}
if (data.type === 'pull') {
const list = (await chat.筛选指定范围的消息({ start: data.start, end: data.end })).map(item => {
return { timestamp: new Date(item.time).getTime(), ...item }
})
clientList.sendto(client.id, 'chat', JSON.stringify({ type: 'list', list }))
console.log(client.name, `拉取了 ${list.length} 条消息`)
return
}
if (data.type === 'list') {
console.log(client.name, `同步来 ${data.list.length} 条消息`, data.list)
await chat.合并消息列表(data.list)
return
}
console.log('未知类型:', data.type)
},
onclose: event => {
console.log('关闭信道', event.target.label)
},
onerror: event => {
console.error('信道错误', event.target.label, event.error)
}
})
const file_cache = []
// 与每个客户端都建立文件传输信道
clientList.setChannel('file', {
onopen: async (event, client) => {
//console.debug('打开信道', event.target.label)
//clientList.sendto(client.id, 'file', JSON.stringify({ id: '', start: 0, end: 16384 }))
//clientList.sendto(client.id, 'file', new ArrayBuffer(1024))
},
onmessage: async (event, client) => {
if (typeof event.data === 'string') {
console.log(client.name, '文件请求:', event.data)
const { id, start, end } = JSON.parse(event.data)
const file = file_cache.find(file => file.id === id)
const data = file.chunk.slice(start, end)
// 分片发送
const c = Math.ceil(file.chunk.length / (CHUNK_SIZE - (12 * 8)))
console.log('需要发送', c, '个分片')
const chunks = []
for (let i = 0; i < c; i++) {
const chunk = new ArrayBuffer(CHUNK_SIZE)
const view = new Uint32Array(chunk)
view[0] = file.id
view[1] = i * (CHUNK_SIZE - (12 * 8))
view[2] = (i + 1) * (CHUNK_SIZE - (12 * 8))
const data = new Uint8Array(chunk, 12)
data.set(new Uint8Array(file.chunk[i]))
chunks.push(chunk)
}
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]
console.log('发送分片', `${i + 1}/${chunks.length}`, chunk.byteLength)
clientList.sendto(client.id, 'file', chunk)
await new Promise((resolve) => setTimeout(resolve, DELAY))
}
console.log('文件发送完毕')
return
}
if (event.data instanceof ArrayBuffer) {
console.log(client.name, '发来文件:', event.data)
// 判断分片是哪个文件的
const file = file_cache.find(file => file.hash === event.target.label)
if (!file) {
console.log('未知文件:', event.target.label)
return
}
// 将分片保存到文件的分片列表中
file.chunk.push(event.data)
// 判断文件是否接收完毕
if (file.chunk.length === file.chunkNumber) {
console.log('文件接收完毕:', file)
// 将分片列表合并成一个文件
file.arrayBuffer = file.chunk.reduce((prev, curr) => appendBuffer(prev, curr))
// 将文件保存到本地
//fileStore.add(file)
// 将文件添加到音乐列表
//musicList.add(file)
// 将文件从缓存列表中移除
file_cache.splice(file_cache.indexOf(file), 1)
}
return
}
console.log('未知类型:', event)
}
})
// 与每个客户端都建立交换身份信道
clientList.setChannel('profile', {
onopen: async (event, client) => {
clientList.sendto(client.id, 'profile', JSON.stringify({
name: name,
avatar: avatar,
}))
},
onmessage: async (event, client) => {
//console.log(client.name, '发来身份信息:', event.data)
const data = JSON.parse(event.data)
client.name = data.name
client.avatar = data.avatar
clientList.用户更新({ id: client.id, ...data })
},
onclose: event => {
console.log('关闭信道', event.target.label)
},
onerror: event => {
console.error('信道错误', event.target.label, event.error)
}
})
// 与每个客户端都建立基本信道, 用于交换和调度信息
clientList.setChannel('base', {
onopen: async event => {
clientList.send('base', JSON.stringify({ type: 'get_music_list' })) // 要求对方发送音乐列表
},
onmessage: async (event, client) => {
const data = JSON.parse(event.data)
if (data.type === 'get_music_list') {
const ms = musicList.list.filter(item => item.arrayBuffer)
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') {
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)
//}
})
// 设置标签为自己的头像
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')
}
// 链接 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)

437
src/music.js Normal file
View File

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

View File

@@ -1,18 +1,17 @@
export function createElement({ innerText, textContent, onclick, onchange, onkeydown, onmouseenter, onmouseleave, readOnly, children = [], dataset, style, classList = [], ...attributes }, tagName = 'div') {
export function createElement({ innerText, innerHTML, textContent, readOnly, children = [], dataset, style, classList = [], ...attributes }, tagName = 'div') {
const element = document.createElement(tagName)
for (const key in attributes) element.setAttribute(key, attributes[key])
for (const key in attributes) {
if (key.startsWith('on')) element[key] = attributes[key] // 如果是事件则直接赋值
else element.setAttribute(key, attributes[key]) // 否则是属性则使用setAttribute
}
if (dataset) Object.assign(element.dataset, dataset)
if (style) Object.assign(element.style, style)
if (classList.length) element.classList.add(...classList)
if (textContent) element.textContent = textContent
if (innerText) element.innerText = innerText
if (innerHTML) element.innerHTML = innerHTML
if (readOnly) element.readOnly = readOnly
if (onkeydown) element.onkeydown = onkeydown
if (onchange) element.onchange = onchange
if (onclick) element.onclick = onclick
if (dataset) Object.assign(element.dataset, dataset)
if (children) children.forEach(child => element.appendChild(child))
if (onmouseenter) element.onmouseenter = onmouseenter
if (onmouseleave) element.onmouseleave = onmouseleave
return element
}
@@ -29,13 +28,27 @@ export function Span(options) {
}
export function Button(options) {
return createElement(options, 'button')
return createElement({
...options,
style: {
cursor: 'pointer',
...options.style
}
}, 'button')
}
export function Img(options) {
return createElement(options, 'img')
}
export function Input(options) {
return createElement(options, 'input')
}
export function TextArea(options) {
return createElement(options, 'textarea')
}
export function Avatar(options) {
const element = createElement(options, 'img')
element.onerror = () => element.src = '/favicon.ico'
@@ -43,7 +56,8 @@ export function Avatar(options) {
}
export function UploadMusic(options) {
const text = createElement({
let dragStats = null
const drop = createElement({
textContent: '点击或拖拽音乐到此处共享您的音乐',
style: {
width: '100%',
@@ -87,13 +101,47 @@ export function UploadMusic(options) {
input.click()
},
onmouseenter: event => {
text.style.display = 'block'
drop.style.display = 'block'
},
onmouseleave: event => {
text.style.display = 'none'
drop.style.display = 'none'
},
ondragover: event => {
console.log('dragover')
event.preventDefault()
event.stopPropagation()
if (dragStats) return
event.dataTransfer.dropEffect = 'copy'
drop.style.display = 'block'
},
ondragleave: event => {
event.preventDefault()
event.stopPropagation()
if (dragStats) return
clearTimeout(dragStats)
dragStats = setTimeout(() => {
drop.style.display = 'none'
dragStats = null
}, 1000)
},
ondrop: event => {
console.log('drop')
event.preventDefault()
event.stopPropagation()
const files = Array.from(event.dataTransfer.files)
// 检查必须是音乐文件, 移除其它文件
for (let i = 0; i < files.length; i++) {
if (!files[i].type.startsWith('audio/')) {
files.splice(i, 1)
i--
}
}
if (files.length === 0) return console.log('没有文件')
console.log('files', files)
options.onchange(files)
},
children: [
// 绘制一个云朵上传图标(别放弃...还有我呢!)
// 绘制一个云朵上传图标
createElement({
style: {
width: '82px',
@@ -138,54 +186,32 @@ export function UploadMusic(options) {
zIndex: '-1',
boxShadow: '0 0 6px 2px rgba(0,0,0,0.1)',
borderRadius: '50%'
}),
createElement({
textContent: '♫',
style: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '3rem',
fontWeight: 'bold',
position: 'relative',
top: '-2.25rem',
color: '#e7f8fd',
textShadow: '0 1px 1px #fff',
//textShadow: '0 0 5px #000, 0 0 10px #000, 0 0 15px #000, 0 0 20px #000, 0 0 25px #000, 0 0 30px #000, 0 0 35px #000, 0 0 40px #000, 0 0 45px #000, 0 0 50px #000, 0 0 55px #000, 0 0 60px #000, 0 0 65px #000, 0 0 70px #000, 0 0 75px #000'
}
})
]
}),
text
//createElement({
// style: {
// position: 'absolute',
// top: '50%',
// left: '50%',
// transform: 'translate(-50%, -50%)',
// width: '4rem',
// height: '4rem',
// backgroundColor: '#ff1414',
// borderRadius: '50%',
// opacity: 0.2,
// boxShadow: '-35px 10px 0 -10px, 33px 15px 0 -15px, 0 0 0 6px #fff, -35px 10px 0 -5px #fff, 33px 15px 0 -10px #fff;',
// background: 'currentColor',
// },
// children: [
// createElement({
// style: {
// position: 'absolute',
// top: '50%',
// left: '50%',
// transform: 'translate(-50%, -50%)',
// width: '2rem',
// height: '2rem',
// backgroundColor: '#fff',
// borderRadius: '50%',
// boxShadow: '0 0 0 6px #fff, -35px 10px 0 -5px #fff, 33px 15px 0 -10px #fff;',
// }
// })
// ],
//})
drop
]
})
//Input({
// ...options,
// type: 'file',
// multiple: true,
// accept: 'audio/*',
// style: {
// display: 'none',
// ...options.style
// }
//})
}
// 聊天窗口
export function Chat(options) {}
// 弹出窗口, 高斯模糊背景, 进入离开动画过渡
export function Dialog(options) {
const element = createElement({

27
vite.config.js Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import WindiCSS from 'vite-plugin-windicss'
export default defineConfig({
server: {
port: 4096,
proxy: {
'/webrtc': {
target: 'wss://webrtc.satori.love',
changeOrigin: true,
ws: true,
},
'/entanglement': {
target: 'wss://webrtc.satori.love',
changeOrigin: true,
ws: true,
},
'/webhook': 'https://webrtc.satori.love',
}
},
plugins: [
WindiCSS(),
],
build: {
target: "esnext"
}
})