diff --git a/.gitignore b/.gitignore
index 13fa826..7334bee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,129 +4,23 @@ logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+pnpm-debug.log*
lerna-debug.log*
-.pnpm-debug.log*
package-lock.json
-# Diagnostic reports (https://nodejs.org/api/report.html)
-report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
-
-# Runtime data
-pids
-*.pid
-*.seed
-*.pid.lock
-
-# Directory for instrumented libs generated by jscoverage/JSCover
-lib-cov
-
-# Coverage directory used by tools like istanbul
-coverage
-*.lcov
-
-# nyc test coverage
-.nyc_output
-
-# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
-.grunt
-
-# Bower dependency directory (https://bower.io/)
-bower_components
-
-# node-waf configuration
-.lock-wscript
-
-# Compiled binary addons (https://nodejs.org/api/addons.html)
-build/Release
-
-# Dependency directories
-node_modules/
-jspm_packages/
-
-# Snowpack dependency directory (https://snowpack.dev/)
-web_modules/
-
-# TypeScript cache
-*.tsbuildinfo
-
-# Optional npm cache directory
-.npm
-
-# Optional eslint cache
-.eslintcache
-
-# Optional stylelint cache
-.stylelintcache
-
-# Microbundle cache
-.rpt2_cache/
-.rts2_cache_cjs/
-.rts2_cache_es/
-.rts2_cache_umd/
-
-# Optional REPL history
-.node_repl_history
-
-# Output of 'npm pack'
-*.tgz
-
-# Yarn Integrity file
-.yarn-integrity
-
-# dotenv environment variable files
-.env
-.env.development.local
-.env.test.local
-.env.production.local
-.env.local
-
-# parcel-bundler cache (https://parceljs.org/)
-.cache
-.parcel-cache
-
-# Next.js build output
-.next
-out
-
-# Nuxt.js build / generate output
-.nuxt
+node_modules
dist
+dist-ssr
+*.local
-# Gatsby files
-.cache/
-# Comment in the public line in if your project uses Gatsby and not Next.js
-# https://nextjs.org/blog/next-9-1#public-directory-support
-# public
-
-# vuepress build output
-.vuepress/dist
-
-# vuepress v2.x temp and cache directory
-.temp
-.cache
-
-# Docusaurus cache and generated files
-.docusaurus
-
-# Serverless directories
-.serverless/
-
-# FuseBox cache
-.fusebox/
-
-# DynamoDB Local files
-.dynamodb/
-
-# TernJS port file
-.tern-port
-
-# Stores VSCode versions used for testing VSCode extensions
-.vscode-test
-
-# yarn v2
-.yarn/cache
-.yarn/unplugged
-.yarn/build-state.yml
-.yarn/install-state.gz
-.pnp.*
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..0dad305
--- /dev/null
+++ b/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ webRTC
+
+
+
+
+
diff --git a/package.json b/package.json
index 8226b73..87c8bf1 100644
--- a/package.json
+++ b/package.json
@@ -5,13 +5,19 @@
"type": "module",
"main": "index.js",
"scripts": {
- "dev": "nodemon index.js",
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "devx": "nodemon index.js",
"start": "node index.js"
},
"author": "",
"license": "GPL-3.0-or-later",
"devDependencies": {
- "nodemon": "^3.0.1"
+ "nodemon": "^3.0.1",
+ "vite": "^4.4.11",
+ "vite-plugin-windicss": "^1.9.1",
+ "windicss": "^3.5.6"
},
"dependencies": {
"express": "^4.18.2",
diff --git a/public/vite.svg b/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/client.js b/src/client.js
similarity index 100%
rename from public/client.js
rename to src/client.js
diff --git a/public/database.js b/src/database.js
similarity index 100%
rename from public/database.js
rename to src/database.js
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..e9a579a
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,253 @@
+import 'virtual:windi.css'
+import 'virtual:windi-devtools'
+
+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')
+}
\ No newline at end of file
diff --git a/public/music.js b/src/music.js
similarity index 100%
rename from public/music.js
rename to src/music.js
diff --git a/public/weigets.js b/src/weigets.js
similarity index 100%
rename from public/weigets.js
rename to src/weigets.js
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..abf9d15
--- /dev/null
+++ b/style.css
@@ -0,0 +1,97 @@
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+#app {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.vanilla:hover {
+ filter: drop-shadow(0 0 2em #f7df1eaa);
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..cc550ee
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,24 @@
+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(),
+ ]
+})