Compare commits
134 Commits
1a7e373aa1
...
main
Author | SHA1 | Date | |
---|---|---|---|
b72be4e004 | |||
c3c0a37a41 | |||
03e19fbf58 | |||
099e36e1b5 | |||
db8b33299c | |||
231e7f4376 | |||
3dd7e0fa0f | |||
73d895dc2b | |||
da3b4e19e9 | |||
5e0552030a | |||
8e18d19b7d | |||
5915c717a0 | |||
2629f262ef | |||
de06553a8d | |||
9dfbb9cd29 | |||
ca924af85b | |||
6c30ebfc5d | |||
662db2a75b | |||
a232ece058 | |||
253a4b7134 | |||
4ea9a51740 | |||
ae224bd983 | |||
175db8c90c | |||
65554afb2f | |||
d43adff7b4 | |||
3f1fc3b709 | |||
8b2fd539b4 | |||
4519a6701e | |||
2c748b029b | |||
018246e6dc | |||
a641b07de1 | |||
7cc8b37d5d | |||
938739e8c2 | |||
c9477581c8 | |||
ae5072a490 | |||
d75641c8b8 | |||
1fec1bb2f6 | |||
49c5360680 | |||
3c02e2dc82 | |||
bd3f3d57eb | |||
e269d013e2 | |||
f79a2b2f66 | |||
f85316b86c | |||
c268bb1bf7 | |||
44a158a2e8 | |||
8056b0b8cb | |||
9dae52a858 | |||
caadd67b68 | |||
8829a6cbb2 | |||
688ba3a7f6 | |||
f16e3f62cb | |||
|
277e2dd5b9 | ||
|
f688f6e1b1 | ||
|
af80bb2d0b | ||
|
4b1b65c29e | ||
75888cc69d | |||
2248a9d552 | |||
3d75b3456d | |||
a37f469f65 | |||
0756b66792 | |||
367bc51c64 | |||
da2c1ef8c8 | |||
65cb34f713 | |||
5a39640f74 | |||
b81cedc023 | |||
d668b7236d | |||
2808da61a0 | |||
74b6ae073d | |||
11a8dbf4f3 | |||
17e16a4246 | |||
2dd39dc530 | |||
b27340365a | |||
347de07a90 | |||
212adad2af | |||
99e3c0ac63 | |||
afa9edd3ad | |||
77fd29efb4 | |||
e96b319a85 | |||
2d4da34a41 | |||
c786e05302 | |||
7abc7a19cf | |||
9b0d418abc | |||
346adfe6b6 | |||
1c584097d5 | |||
3aa2b65c26 | |||
270b899f0f | |||
cb14d5b799 | |||
e844cbd910 | |||
6a244555d6 | |||
14dddbe8f5 | |||
d3e2355fb3 | |||
93e5754857 | |||
07b6370ff8 | |||
e6af4f0fc7 | |||
ea397c735b | |||
f5ac981a15 | |||
df3500f6c5 | |||
cabab4890b | |||
5c00514610 | |||
6f267109a4 | |||
c37086cfcd | |||
e5c9623092 | |||
06479610f9 | |||
ef3f17e7a0 | |||
|
b1e3a7c8d7 | ||
692ae68433 | |||
eea4e71414 | |||
1051fa5669 | |||
807dd17ad6 | |||
5e2205ecdc | |||
9af97703e6 | |||
da09d67121 | |||
2c00cb8ef9 | |||
edae22db79 | |||
23a0c60ad0 | |||
1211efae37 | |||
f9d1d4b588 | |||
9f8670ed7d | |||
5ebb20716d | |||
857f38a7ac | |||
247e3e532d | |||
1cdfc924d2 | |||
273633bda4 | |||
b14aaa6ec3 | |||
488d6d9c7c | |||
d142d3a613 | |||
ae7402b62c | |||
624f21cc84 | |||
aa0875ae9e | |||
2aadae6b85 | |||
0865ef39f6 | |||
5a39a2eb3d | |||
52ec69488c | |||
7694e79809 |
134
.gitignore
vendored
134
.gitignore
vendored
@@ -4,129 +4,23 @@ logs
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
node_modules
|
||||||
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
|
|
||||||
dist
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
# Gatsby files
|
# Editor directories and files
|
||||||
.cache/
|
.vscode/*
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
!.vscode/extensions.json
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
.idea
|
||||||
# public
|
.DS_Store
|
||||||
|
*.suo
|
||||||
# vuepress build output
|
*.ntvs*
|
||||||
.vuepress/dist
|
*.njsproj
|
||||||
|
*.sln
|
||||||
# vuepress v2.x temp and cache directory
|
*.sw?
|
||||||
.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.*
|
|
||||||
|
83
README.md
83
README.md
@@ -1,14 +1,23 @@
|
|||||||
# webRTC
|
# webRTC
|
||||||
webrtc 实现的 p2p 信道
|
webrtc 实现的 p2p 信道
|
||||||
|
|
||||||
rtc rtc rtc: 稳定, 多重连接
|
```bash
|
||||||
channel channel channel: 细流
|
# 使用 git 克隆到本地或者直接下载zip压缩包
|
||||||
part-server: 调谐, 从不同服务器请求资源分片
|
git clone git@git.satori.love:LaniakeaSupercluster/webrtc.git
|
||||||
webrtc://用户@域名:端口/信道标识/资源ID
|
cd webrtc
|
||||||
|
|
||||||
封包格式
|
# 安装依赖
|
||||||
资源ID 分片信息(位置) 分片数据
|
npm i
|
||||||
|
|
||||||
|
# 编译
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 运行服务
|
||||||
|
npm run start
|
||||||
|
|
||||||
|
# 或者使用 pm2 作为守护进程
|
||||||
|
pm2 start npm --name webrtc -- run start
|
||||||
|
```
|
||||||
|
|
||||||
插件市场
|
插件市场
|
||||||
1. 从浏览器创建插件(单文件)
|
1. 从浏览器创建插件(单文件)
|
||||||
@@ -30,28 +39,27 @@ export default class 插件名 {
|
|||||||
|
|
||||||
|
|
||||||
聊天室
|
聊天室
|
||||||
1. 每个设备保存全量聊天记录
|
- [x] 每个设备保存全量聊天记录
|
||||||
2. 每个设备各自设定存储区间
|
- [x] 每个设备各自设定存储区间
|
||||||
3. 接入网络后向同频道设备同步区间内记录
|
- [x] 接入网络后向同频道设备同步区间内记录
|
||||||
4. 对方撤回的并不删除, 但不再分发
|
- [ ] 对方撤回的并不删除, 但不再分发
|
||||||
5. 阅后既焚开关, 全频道不保留也不分发记录
|
- [ ] 阅后既焚开关, 全频道不保留也不分发记录
|
||||||
6. mark 标记的记录保留, 其它自动丢弃
|
- [ ] mark 标记的记录保留, 其它自动丢弃
|
||||||
|
- [ ] 非活跃状态下提示音
|
||||||
|
|
||||||
音乐频道
|
音乐频道
|
||||||
1. 每个设备存储自己的列表
|
- [x] 每个设备存储自己的列表
|
||||||
2. 可以缓存对方的列表
|
- [x] 可以缓存对方的列表
|
||||||
3. 使用md5验证完整性
|
- [ ] 使用md5验证完整性
|
||||||
4. 可以上传lrc
|
- [ ] 可以上传lrc
|
||||||
5. 可以上传封面, 可以从数据中解析封面
|
- [ ] 可以上传封面, 可以从数据中解析封面
|
||||||
6. ban表匹配时不播放且收起隐藏, 支持正则ban表
|
- [ ] ban表匹配时不播放且收起隐藏, 支持正则ban表
|
||||||
|
|
||||||
猫窝
|
传递资源
|
||||||
|
1. 每个节点都公开持有的资源列表, 和连接的节点列表
|
||||||
|
2. 每当资源变动时告知所有连接的节点
|
||||||
7. 每个节点都公开持有的资源列表, 和连接的节点列表
|
3. 与节点创建多个RTC时, 不发送多份, 以ID为准, id随机生成给不同机器, 无法通过ID锁定其它机器
|
||||||
8. 每当资源变动时告知所有连接的节点
|
4. 通过WS交换信息时, ID是否固定? 向WS提供连接?
|
||||||
9. 与节点创建多个RTC时, 不发送多份, 以ID为准, id随机生成给不同机器, 无法通过ID锁定其它机器
|
|
||||||
10. 通过WS交换信息时, ID是否固定? 向WS提供连接?
|
|
||||||
|
|
||||||
- [x] P2P通信
|
- [x] P2P通信
|
||||||
- [ ] 分离出主要功能, 作为库或桁架使用
|
- [ ] 分离出主要功能, 作为库或桁架使用
|
||||||
@@ -93,7 +101,32 @@ export default class 插件名 {
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
依赖文档
|
||||||
|
- https://www.npmjs.com/package/idb-keyval
|
||||||
|
- https://www.npmjs.com/package/express
|
||||||
|
- https://www.npmjs.com/package/express-ws
|
||||||
|
- https://www.npmjs.com/package/node-turn
|
||||||
|
- https://www.npmjs.com/package/vite
|
||||||
|
|
||||||
备用代码片段
|
备用代码片段
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// jsmediatags 似乎支持的格式较少, 但不会产生错误警告
|
||||||
|
//import jsmediatags from 'jsmediatags/dist/jsmediatags.min.js'
|
||||||
|
//list.forEach(async item => {
|
||||||
|
// const blob = new Blob([item.arrayBuffer], { type: item.type })
|
||||||
|
// jsmediatags.read(blob, {
|
||||||
|
// onSuccess: function (tag) {
|
||||||
|
// console.log(tag)
|
||||||
|
// console.log(tag.tags.title)
|
||||||
|
// },
|
||||||
|
// onError: function (error) {
|
||||||
|
// console.log(error);
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
//})
|
||||||
|
```
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
|
||||||
|
38
demo.html
Normal file
38
demo.html
Normal 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
12
demo.js
Normal 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
11
index.html
Normal 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>
|
6
index.js
6
index.js
@@ -5,7 +5,7 @@ import { exec } from 'child_process'
|
|||||||
|
|
||||||
// 创建 TURN 服务器
|
// 创建 TURN 服务器
|
||||||
const turnServer = new turn({
|
const turnServer = new turn({
|
||||||
debugLevel: 'ALL'
|
debugLevel: 'WARN', // ALL, DEBUG, INFO, WARN, ERROR, NONE
|
||||||
})
|
})
|
||||||
|
|
||||||
// 启动 TURN 服务器
|
// 启动 TURN 服务器
|
||||||
@@ -15,7 +15,7 @@ turnServer.start(() => {
|
|||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const wsInstance = expressWs(app)
|
const wsInstance = expressWs(app)
|
||||||
app.use(express.static('public'))
|
app.use(express.static('dist'))
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (req.method === 'CONNECT') {
|
if (req.method === 'CONNECT') {
|
||||||
@@ -121,7 +121,7 @@ app.ws('/entanglement', (ws, req) => {
|
|||||||
// WEBHOOK 处理 GitHub 事件
|
// WEBHOOK 处理 GitHub 事件
|
||||||
app.post('/webhook', (req, res) => {
|
app.post('/webhook', (req, res) => {
|
||||||
console.log('WEBHOOK:' + new Date().toLocaleString())
|
console.log('WEBHOOK:' + new Date().toLocaleString())
|
||||||
exec('git pull;npm i')
|
exec('git pull;npm i;npm run build;pm2 reload webrtc;')
|
||||||
return res.json({ success: true })
|
return res.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
19
package.json
19
package.json
@@ -5,17 +5,30 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon index.js",
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"devx": "nodemon index.js",
|
||||||
"start": "node index.js"
|
"start": "node index.js"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1",
|
||||||
|
"vite": "^4.4.11",
|
||||||
|
"vite-plugin-windicss": "^1.9.1",
|
||||||
|
"windicss": "^3.5.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-ws": "^5.0.2",
|
"express-ws": "^5.0.2",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
|
36
public/cs.js
36
public/cs.js
@@ -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验证失败时才验证分片
|
|
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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]
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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>
|
|
225
public/music.js
225
public/music.js
@@ -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
5
public/remove.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Chessboard } from './ChineseChess.js'
|
||||||
|
|
||||||
|
// 中国象棋
|
||||||
|
const chessboard = new Chessboard()
|
||||||
|
chessboard.绘制棋盘({比例: 48, 边距: 20})
|
13
public/send.svg
Normal file
13
public/send.svg
Normal 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 |
@@ -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
228
src/ChineseChess.js
Normal 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
392
src/chat.js
Normal 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.播放提示音()
|
||||||
|
}
|
||||||
|
}
|
@@ -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'
|
import { List, ListItem, Avatar, Span, Dialog, Button, Input } from './weigets.js'
|
||||||
|
|
||||||
export default class ClientList {
|
export default class ClientList {
|
||||||
constructor({ channels = {}, EventListeners = {}, name: username, onexit }) {
|
constructor({ channels = {}, EventListeners = {}, name: username, onexit }) {
|
||||||
this.event = { onexit }
|
this.event = { onexit }
|
||||||
|
this.store = createStore(`db-user`, `store-user`)
|
||||||
this.channels = channels
|
this.channels = channels
|
||||||
this.EventListeners = EventListeners
|
this.EventListeners = EventListeners
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
@@ -30,13 +32,11 @@ export default class ClientList {
|
|||||||
const webrtc_init = async () => {
|
const webrtc_init = async () => {
|
||||||
const webrtc = new RTCPeerConnection({
|
const webrtc = new RTCPeerConnection({
|
||||||
iceServers: [
|
iceServers: [
|
||||||
{
|
...[
|
||||||
urls: 'turn:satori.love:3478?transport=udp',
|
'stun:stun.voipbuster.com', // 德国 黑森州 美因河畔法兰克福
|
||||||
username: 'x-username',
|
'stun:stun.voipstunt.com', // 德国 黑森州 美因河畔法兰克福
|
||||||
credential: 'x-password'
|
'stun:stun.internetcalls.com', // 德国 黑森州 美因河畔法兰克福
|
||||||
},
|
'stun:stun.voip.aebc.com', // 加拿大 不列颠哥伦比亚省 温哥华
|
||||||
{
|
|
||||||
urls: [
|
|
||||||
'stun:stun.1und1.de',
|
'stun:stun.1und1.de',
|
||||||
'stun:stun.callwithus.com',
|
'stun:stun.callwithus.com',
|
||||||
'stun:stun.ekiga.net',
|
'stun:stun.ekiga.net',
|
||||||
@@ -44,35 +44,53 @@ export default class ClientList {
|
|||||||
'stun:stun.fwdnet.net:3478',
|
'stun:stun.fwdnet.net:3478',
|
||||||
'stun:stun.gmx.net',
|
'stun:stun.gmx.net',
|
||||||
'stun:stun.iptel.org',
|
'stun:stun.iptel.org',
|
||||||
'stun:stun.internetcalls.com',
|
|
||||||
'stun:stun.minisipserver.com',
|
'stun:stun.minisipserver.com',
|
||||||
'stun:stun.schlund.de',
|
'stun:stun.schlund.de',
|
||||||
'stun:stun.sipgate.net',
|
'stun:stun.sipgate.net',
|
||||||
'stun:stun.sipgate.net:10000',
|
'stun:stun.sipgate.net:10000',
|
||||||
'stun:stun.softjoys.com',
|
'stun:stun.softjoys.com',
|
||||||
'stun:stun.softjoys.com:3478',
|
'stun:stun.softjoys.com:3478',
|
||||||
'stun:stun.voip.aebc.com',
|
|
||||||
'stun:stun.voipbuster.com',
|
|
||||||
'stun:stun.voipstunt.com',
|
|
||||||
'stun:stun.voxgratia.org',
|
'stun:stun.voxgratia.org',
|
||||||
'stun:stun.wirlab.net',
|
'stun:stun.wirlab.net',
|
||||||
'stun:stun.xten.com',
|
'stun:stun.xten.com',
|
||||||
'stun:stunserver.org',
|
'stun:stunserver.org',
|
||||||
'stun:stun01.sipphone.com',
|
'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', // 使用所有可用的候选者
|
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 }) => {
|
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 client = this.clientlist.find(x => x.id === data.id)
|
||||||
const option = this.channels[channel.label]
|
const option = this.channels[channel.label]
|
||||||
channel.onopen = event => {
|
channel.onopen = event => {
|
||||||
console.debug('对方打开', channel.label, '数据通道')
|
//console.debug('对方打开', channel.label, '数据通道')
|
||||||
if (option && option.onopen) {
|
if (option && option.onopen) {
|
||||||
option.onopen(event, client)
|
option.onopen(event, client)
|
||||||
}
|
}
|
||||||
@@ -106,46 +124,57 @@ export default class ClientList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
webrtc.oniceconnectionstatechange = async event => {
|
webrtc.oniceconnectionstatechange = async event => {
|
||||||
|
console.log(data.name, 'ICE 连接状态:', webrtc.iceConnectionState, webrtc.iceGatheringState)
|
||||||
if (webrtc.iceConnectionState === 'disconnected' || webrtc.iceConnectionState === 'failed') {
|
if (webrtc.iceConnectionState === 'disconnected' || webrtc.iceConnectionState === 'failed') {
|
||||||
console.error(data.name, '需要添加新的 candidate')
|
const client = this.clientlist.find(x => x.id === data.id) ?? {}
|
||||||
|
console.error(data.name, '需要添加新的 candidate', webrtc.iceConnectionState, client.online)
|
||||||
// 添加新的 candidate
|
// 添加新的 candidate
|
||||||
} else if (webrtc.iceConnectionState === 'connected' || webrtc.iceConnectionState === 'completed') {
|
} else if (webrtc.iceConnectionState === 'connected' || webrtc.iceConnectionState === 'completed') {
|
||||||
console.debug(data.name, 'WebRTC 连接已经建立成功')
|
//console.debug(data.name, 'WebRTC 连接已经建立成功')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const channels = Object.entries(this.channels).map(([name, callback]) => {
|
const channels = Object.entries(this.channels).map(([name, callback]) => {
|
||||||
const channel = webrtc.createDataChannel(name, { reliable: true })
|
const dc = webrtc.createDataChannel(name, {
|
||||||
return channel
|
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 }
|
return { webrtc, channels }
|
||||||
}
|
}
|
||||||
if (data.type === 'list') {
|
if (data.type === 'list') {
|
||||||
console.debug('取得在线对端列表:', data)
|
|
||||||
const { webrtc, channels } = await webrtc_init()
|
const { webrtc, channels } = await webrtc_init()
|
||||||
console.debug('发送给对方 offer')
|
|
||||||
const offer = await webrtc.createOffer()
|
const offer = await webrtc.createOffer()
|
||||||
await webrtc.setLocalDescription(offer)
|
await webrtc.setLocalDescription(offer)
|
||||||
this.clientlist.push({ id: data.id, name: data.name, webrtc, channels })
|
this.clientlist.push({ id: data.id, name: data.name, webrtc, channels, online: true })
|
||||||
websocket.send(JSON.stringify({ type: 'offer', id: data.id, offer }))
|
websocket.send(JSON.stringify({ type: 'offer', id: data.id, offer }))
|
||||||
// 传递正确的指针给元素, 以便其能够调取正确的头像
|
|
||||||
this.push(this.clientlist.find(client => client.id === data.id))
|
this.push(this.clientlist.find(client => client.id === data.id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (data.type === 'push') {
|
if (data.type === 'push') {
|
||||||
console.debug('新上线客户端:', data)
|
//console.debug('新上线客户端:', data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (data.type === 'pull') {
|
if (data.type === 'pull') {
|
||||||
console.debug('移除客户端:', data)
|
//console.debug('移除客户端:', data)
|
||||||
|
const client = this.clientlist.find(client => client.id === data.id)
|
||||||
|
if (!client) return console.error('目标用户本不存在')
|
||||||
|
client.online = false // 离开时改变状态
|
||||||
return this.exit(data)
|
return this.exit(data)
|
||||||
}
|
}
|
||||||
if (data.type === 'offer') {
|
if (data.type === 'offer') {
|
||||||
console.debug('收到对方 offer', data)
|
|
||||||
const { webrtc, channels } = await webrtc_init()
|
const { webrtc, channels } = await webrtc_init()
|
||||||
this.clientlist.push({ id: data.id, name: data.name, webrtc, channels })
|
this.clientlist.push({ id: data.id, name: data.name, webrtc, channels, online: true })
|
||||||
// 传递正确的指针给元素, 以便其能够调取正确的头像
|
|
||||||
this.push(this.clientlist.find(client => client.id === data.id))
|
this.push(this.clientlist.find(client => client.id === data.id))
|
||||||
console.debug('发送给对方 answer')
|
|
||||||
await webrtc.setRemoteDescription(data.offer)
|
await webrtc.setRemoteDescription(data.offer)
|
||||||
const answer = await webrtc.createAnswer()
|
const answer = await webrtc.createAnswer()
|
||||||
await webrtc.setLocalDescription(answer)
|
await webrtc.setLocalDescription(answer)
|
||||||
@@ -153,13 +182,11 @@ export default class ClientList {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (data.type === 'answer') {
|
if (data.type === 'answer') {
|
||||||
console.debug('收到对方 answer', data)
|
|
||||||
const webrtc = this.clientlist.find(client => client.id === data.id).webrtc
|
const webrtc = this.clientlist.find(client => client.id === data.id).webrtc
|
||||||
await webrtc.setRemoteDescription(data.answer)
|
await webrtc.setRemoteDescription(data.answer)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (data.type === 'candidate') {
|
if (data.type === 'candidate') {
|
||||||
console.debug(data.name, '发来 candidate 候选通道')
|
|
||||||
const webrtc = this.clientlist.find(client => client.id === data.id).webrtc
|
const webrtc = this.clientlist.find(client => client.id === data.id).webrtc
|
||||||
await webrtc.addIceCandidate(data.candidate)
|
await webrtc.addIceCandidate(data.candidate)
|
||||||
return
|
return
|
||||||
@@ -177,18 +204,66 @@ export default class ClientList {
|
|||||||
}
|
}
|
||||||
this.websocket = linkStart()
|
this.websocket = linkStart()
|
||||||
|
|
||||||
// 也插入自己的信息
|
this.我的帐户()
|
||||||
const avatar = localStorage.getItem('avatar')
|
this.DEBUG()
|
||||||
this.push({ id: 'self', name: username, avatar }, true)
|
|
||||||
}
|
}
|
||||||
getAvatar(id) { }
|
async DEBUG() {
|
||||||
setAvatar(user) {
|
// 监听键盘Esc按下, 如果全局没有焦点则显示调试信息, 如果在调试信息显示期间弹起Esc则隐藏调试信息
|
||||||
console.info('更新avatar', user)
|
let debug = false
|
||||||
document.getElementById(user.id).querySelector('img').src = user.avatar
|
let debugElement = Dialog({
|
||||||
const u = this.clientlist.find(client => client.id === user.id)
|
children: [
|
||||||
u.avatar = user.avatar
|
Button({
|
||||||
console.log(u, user)
|
textContent: '关闭',
|
||||||
//.avatar = user.avatar
|
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) {
|
exit(item) {
|
||||||
const client = this.clientlist.find(client => client.id === item.id)
|
const client = this.clientlist.find(client => client.id === item.id)
|
||||||
@@ -312,6 +387,48 @@ export default class ClientList {
|
|||||||
ch.send(data)
|
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) {
|
send(name, data) {
|
||||||
this.clientlist.forEach(client => {
|
this.clientlist.forEach(client => {
|
392
src/main.js
Normal file
392
src/main.js
Normal 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
437
src/music.js
Normal 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() { }
|
||||||
|
}
|
@@ -1,21 +1,17 @@
|
|||||||
export function createElement({ innerText, textContent, onclick, onchange, ondrop, ondragover, ondragleave, 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)
|
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 (style) Object.assign(element.style, style)
|
||||||
if (classList.length) element.classList.add(...classList)
|
if (classList.length) element.classList.add(...classList)
|
||||||
if (textContent) element.textContent = textContent
|
if (textContent) element.textContent = textContent
|
||||||
if (innerText) element.innerText = innerText
|
if (innerText) element.innerText = innerText
|
||||||
|
if (innerHTML) element.innerHTML = innerHTML
|
||||||
if (readOnly) element.readOnly = readOnly
|
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 (children) children.forEach(child => element.appendChild(child))
|
||||||
if (onmouseenter) element.onmouseenter = onmouseenter
|
|
||||||
if (onmouseleave) element.onmouseleave = onmouseleave
|
|
||||||
if (ondrop) element.ondrop = ondrop
|
|
||||||
if (ondragover) element.ondragover = ondragover
|
|
||||||
if (ondragleave) element.ondragleave = ondragleave
|
|
||||||
return element
|
return element
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,13 +28,27 @@ export function Span(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Button(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) {
|
export function Input(options) {
|
||||||
return createElement(options, 'input')
|
return createElement(options, 'input')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TextArea(options) {
|
||||||
|
return createElement(options, 'textarea')
|
||||||
|
}
|
||||||
|
|
||||||
export function Avatar(options) {
|
export function Avatar(options) {
|
||||||
const element = createElement(options, 'img')
|
const element = createElement(options, 'img')
|
||||||
element.onerror = () => element.src = '/favicon.ico'
|
element.onerror = () => element.src = '/favicon.ico'
|
||||||
@@ -119,12 +129,19 @@ export function UploadMusic(options) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const files = Array.from(event.dataTransfer.files)
|
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('没有文件')
|
if (files.length === 0) return console.log('没有文件')
|
||||||
console.log('files', files)
|
console.log('files', files)
|
||||||
options.onchange(files)
|
options.onchange(files)
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
// 绘制一个云朵上传图标(别放弃...还有我呢!)
|
// 绘制一个云朵上传图标
|
||||||
createElement({
|
createElement({
|
||||||
style: {
|
style: {
|
||||||
width: '82px',
|
width: '82px',
|
||||||
@@ -169,54 +186,32 @@ export function UploadMusic(options) {
|
|||||||
zIndex: '-1',
|
zIndex: '-1',
|
||||||
boxShadow: '0 0 6px 2px rgba(0,0,0,0.1)',
|
boxShadow: '0 0 6px 2px rgba(0,0,0,0.1)',
|
||||||
borderRadius: '50%'
|
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'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
drop
|
drop
|
||||||
//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;',
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// ],
|
|
||||||
//})
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
//Input({
|
|
||||||
// ...options,
|
|
||||||
// type: 'file',
|
|
||||||
// multiple: true,
|
|
||||||
// accept: 'audio/*',
|
|
||||||
// style: {
|
|
||||||
// display: 'none',
|
|
||||||
// ...options.style
|
|
||||||
// }
|
|
||||||
//})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 聊天窗口
|
||||||
|
export function Chat(options) {}
|
||||||
|
|
||||||
// 弹出窗口, 高斯模糊背景, 进入离开动画过渡
|
// 弹出窗口, 高斯模糊背景, 进入离开动画过渡
|
||||||
export function Dialog(options) {
|
export function Dialog(options) {
|
||||||
const element = createElement({
|
const element = createElement({
|
27
vite.config.js
Normal file
27
vite.config.js
Normal 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"
|
||||||
|
}
|
||||||
|
})
|
Reference in New Issue
Block a user