├── .gitignore ├── .prettierrc.json ├── Dockerfile ├── LICENSE ├── README.md ├── app.config.ts ├── app.vue ├── assets └── main.css ├── components ├── LogBar.vue └── NavBar.vue ├── composables └── useNavHeight.ts ├── docker-compose.yaml ├── i18n.config.ts ├── nuxt.config.ts ├── ogImg.png ├── package.json ├── pages ├── camera.vue ├── index.vue └── monitor.vue ├── public ├── favicon.webp └── ogImg.webp ├── server ├── api │ ├── camera.post.ts │ ├── monitor.post.ts │ └── sendEvent.post.ts ├── tsconfig.json └── utils │ └── sseMap.ts ├── tailwind.config.ts ├── tsconfig.json ├── utils ├── DateUtils.js ├── RTCNode.ts ├── index.ts └── publicStunList.ts └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "trailingComma": "none" 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 构建阶段 2 | FROM node:lts-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | COPY package.json yarn.lock* /app/ 7 | 8 | RUN yarn install --frozen-lockfile 9 | 10 | COPY . /app 11 | 12 | RUN yarn run build 13 | 14 | # 运行阶段 15 | FROM node:lts-alpine 16 | 17 | WORKDIR /app 18 | 19 | # 从构建阶段复制输出文件到运行容器 20 | COPY --from=builder /app/.output /app 21 | 22 | # 暴露容器运行时的端口 23 | EXPOSE 3000 24 | 25 | CMD ["node", "server/index.mjs"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2024 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebCamera 2 | 3 | ![License](https://img.shields.io/badge/license-MIT-blue.svg) 4 | ![Nuxt](https://img.shields.io/badge/nuxt.js-v3.11.2-green.svg) 5 | ![Yarn](https://img.shields.io/badge/yarn-v1.22.22-blue.svg) 6 | 7 | ![WebCamera](./public/ogImg.webp) 8 | 9 | WebCamera 是一个基于 WebRTC 技术的网络摄像头监控工具,使用 Nuxt.js 框架开发。 10 | 11 | ## 目录 12 | 13 | - [特性](#特性) 14 | - [安装](#安装) 15 | - [使用](#使用) 16 | - [构建](#构建) 17 | - [Docker运行](#Docker运行) 18 | - [贡献](#贡献) 19 | - [许可证](#许可证) 20 | 21 | ## 特性 22 | 23 | - **实时视频流**: 使用 WebRTC 技术实现高效的实时视频流。 24 | - **跨平台支持**: 兼容多种浏览器和设备。 25 | - **易于开发**: 基于 Nuxt.js 框架,方便扩展和维护。 26 | - **模块化设计**: 便于功能的扩展和集成。 27 | - **隐私安全**: 使用点对点加密连接,保护隐私安全。 28 | 29 | ## 安装 30 | 31 | 在开始之前,请确保您的系统已经安装了 [Node.js](https://nodejs.org/) 和 [Yarn](https://yarnpkg.com/)。 32 | 33 | 1. 克隆仓库 34 | 35 | ```bash 36 | git clone https://github.com/ShouChenICU/WebCamera.git 37 | 38 | cd WebCamera 39 | ``` 40 | 41 | 2. 安装依赖 42 | 43 | ```bash 44 | yarn install 45 | ``` 46 | 47 | ## 使用 48 | 49 | 1. 启动开发服务器 50 | 51 | ```bash 52 | yarn run dev 53 | ``` 54 | 55 | 2. 打开浏览器访问 `http://localhost:3000` 56 | 57 | 3. 摄像头先连接,然后监控页面填入和摄像头相同的连接ID,点连接,即可连接到摄像头。 58 | 59 | ## 构建 60 | 61 | 1. 进入项目根目录执行 62 | 63 | ```bash 64 | yarn run build 65 | ``` 66 | 67 | 2. 构建输出在 `.output` 目录中 68 | 3. 进入 `.output` 执行如下命令即可启动服务 69 | 70 | ```bash 71 | node server/index.mjs 72 | ``` 73 | 74 | **自部署请注意**: 浏览器媒体权限(摄像头和麦克风等)需要地址为`localhost`或使用`HTTPS`才能正常申请和启用,请自行配置`HTTPS`部署。 75 | 76 | ## Docker运行 77 | 78 | ```bash 79 | docker build -t webcamera . 80 | docker run -d -p 3000:3000 webcamera 81 | ``` 82 | 83 | ## 贡献 84 | 85 | 我们欢迎任何形式的贡献!如果你有任何建议或发现了 bug,请提交一个 issue 或者发送一个 pull request。 86 | 87 | 1. Fork 本仓库 88 | 2. 创建一个新的分支 (`git checkout -b feature-branch`) 89 | 3. 提交你的更改 (`git commit -am 'Add some feature'`) 90 | 4. 推送到分支 (`git push origin feature-branch`) 91 | 5. 创建一个新的 Pull Request 92 | 93 | ## 许可证 94 | 95 | 该项目基于 MIT 许可证,详细信息请参阅 [LICENSE](./LICENSE) 文件。 96 | 97 | --- 98 | 99 | 100 | 101 | 102 | 103 | Star History Chart 104 | 105 | 106 | -------------------------------------------------------------------------------- /app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | ui: { 3 | primary: 'blue', 4 | gray: 'slate', 5 | 6 | notifications: { 7 | // Show toasts at the top right of the screen 8 | position: 'top-auto bottom-4' 9 | } 10 | }, 11 | 12 | navLinks: [ 13 | { icon: 'i-solar-home-2-linear', key: 'home', link: '/' }, 14 | { icon: 'solar:cup-line-duotone', key: 'about', link: '/about' } 15 | ] 16 | }) 17 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /assets/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | @apply bg-white text-black; 3 | @apply dark:bg-[#121212] dark:text-white; 4 | margin: 0; 5 | width: 100%; 6 | min-height: 100vh; 7 | font-family: 8 | ui-sans-serif, 9 | Inter, 10 | -apple-system, 11 | BlinkMacSystemFont, 12 | 'Segoe UI', 13 | Roboto, 14 | Oxygen, 15 | Ubuntu, 16 | Cantarell, 17 | 'Fira Sans', 18 | 'Droid Sans', 19 | 'Helvetica Neue', 20 | sans-serif; 21 | overflow-y: auto; 22 | overflow-x: hidden; 23 | } 24 | 25 | body, 26 | body > * { 27 | transition: 28 | color 0.2s cubic-bezier(0.215, 0.61, 0.355, 1), 29 | background-color 0.2s cubic-bezier(0.215, 0.61, 0.355, 1); 30 | } 31 | 32 | .border-color { 33 | @apply border-neutral-300 dark:border-neutral-700; 34 | } 35 | 36 | .inout-leave-active, 37 | .inout-enter-active { 38 | transition: 39 | translate 0.24s cubic-bezier(0.3, 1, 0.5, 1), 40 | opacity 0.24s cubic-bezier(0, 0.8, 0.55, 1); 41 | } 42 | 43 | .inout-enter-active { 44 | transition-delay: 0.24s; 45 | } 46 | 47 | .inout-leave-active { 48 | /* background:pink; */ 49 | } 50 | 51 | .inout-enter-from, 52 | .inout-leave-to { 53 | opacity: 0; 54 | translate: 0 2rem; 55 | } 56 | 57 | .inout-enter-to, 58 | .inout-leave-from { 59 | opacity: 1; 60 | translate: 0; 61 | } 62 | -------------------------------------------------------------------------------- /components/LogBar.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 80 | -------------------------------------------------------------------------------- /components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 77 | -------------------------------------------------------------------------------- /composables/useNavHeight.ts: -------------------------------------------------------------------------------- 1 | export const useNavHeight = () => useState('navHeight', () => 32) 2 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | app: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | ports: 8 | - "3000:3000" 9 | environment: 10 | - NODE_ENV=production # 设置环境变量,如果需要的话 11 | command: node server/index.mjs # 用你的启动命令替换如果有必要 12 | -------------------------------------------------------------------------------- /i18n.config.ts: -------------------------------------------------------------------------------- 1 | export default defineI18nConfig(() => ({ 2 | legacy: false, 3 | locale: 'zh', 4 | messages: { 5 | en: { 6 | welcome: 7 | 'WebRTC-powered Peer-to-Peer webcam surveillance application for real-time monitoring platform.', 8 | home: 'Home', 9 | about: 'About', 10 | login: 'Login', 11 | label: { 12 | title: 'Title', 13 | lang: 'Lang', 14 | length: 'Length', 15 | totalViews: 'Total views', 16 | totalLikes: 'Total likes', 17 | totalCollections: 'Total collections', 18 | description: 'Description', 19 | secondsAgo: '{n} seconds ago', 20 | minutesAgo: '{n} minutes ago', 21 | hoursAgo: '{n} hours ago', 22 | daysAgo: '{n} days ago', 23 | search: 'Search', 24 | model: 'Model', 25 | prompt: 'Prompt', 26 | genResult: 'Generate result', 27 | quickStart: 'Quick start', 28 | selectImage: 'Select image', 29 | analysisResult: 'Analysis result', 30 | recResult: 'Recognition result', 31 | summaryResult: 'Summary results', 32 | wordCount: 'Word-count', 33 | audioFile: 'Audio file', 34 | webRec: 'Web recording', 35 | connectionID: 'Connection ID', 36 | audioDev: 'Audio device', 37 | videoDev: 'Video device', 38 | resolution: 'Resolution', 39 | '4K_priority': '4K Priority', 40 | '2K_priority': '2K Priority', 41 | '1080P_priority': '1080P Priority', 42 | '720P_priority': '720P Priority', 43 | record: 'Record', 44 | recordSettings: 'Record settings', 45 | format: 'Format', 46 | bps: 'Bitrate' 47 | }, 48 | btn: { 49 | myFav: 'My Favorites', 50 | submit: 'Submit', 51 | update: 'Update', 52 | saveDraft: 'Save draft', 53 | ok: 'OK', 54 | cancel: 'Cancel', 55 | send: 'Send', 56 | clear: 'Clear', 57 | generate: 'Generate', 58 | download: 'Download', 59 | translate: 'Translate', 60 | copy: 'Copy', 61 | copied: 'Copied', 62 | startAnalysis: 'Start analysis', 63 | startRecognizing: 'Start recognizing', 64 | startSummarizing: 'Start summarizing', 65 | startRec: 'Start recording', 66 | stopRec: 'Stop recording', 67 | downloadJSONFile: 'Download JSON file', 68 | camera: 'Camera', 69 | monitor: 'Monitor', 70 | connect: 'Connect', 71 | disconnect: 'Disconnect', 72 | autoConnect: 'Auto connect', 73 | openAudio: 'Open audio' 74 | }, 75 | des: { 76 | t1: 'Native Support', 77 | d1: 'Most modern browsers natively support WebRTC', 78 | t2: 'Efficient Realtime', 79 | d2: 'WebRTC utilizes peer-to-peer (P2P) communication to avoid server relays, improving communication efficiency', 80 | t3: 'Privacy & Security', 81 | d3: 'WebRTC incorporates built-in encryption to secure the communication content' 82 | }, 83 | usage: { 84 | usage: 'Usage', 85 | step1: 86 | 'First, connect the camera. Go to the `Camera` page on the device to be used as a camera, enter the connection ID, and click connect.', 87 | step2: 88 | 'On the monitoring side, go to the `Monitor` page, enter the same connection ID as the camera, and click connect to connect to the corresponding camera.' 89 | }, 90 | hint: { 91 | recHint: 92 | 'The current browser does not support the file access api, limiting the recording time to 10 minutes' 93 | } 94 | }, 95 | zh: { 96 | welcome: '基于WebRTC的点对点网络摄像头实时监控工具', 97 | home: '主页', 98 | about: '关于', 99 | login: '登陆', 100 | label: { 101 | title: '标题', 102 | lang: '语言', 103 | length: '长度', 104 | totalViews: '总浏览量', 105 | totalLikes: '总点赞数', 106 | totalCollections: '总收藏数', 107 | description: '描述', 108 | secondsAgo: '{n} 秒前', 109 | minutesAgo: '{n} 分前', 110 | hoursAgo: '{n} 小时前', 111 | daysAgo: '{n} 天前', 112 | search: '搜索', 113 | model: '模型', 114 | prompt: '提示词', 115 | genResult: '生成结果', 116 | quickStart: '快速开始', 117 | selectImage: '选择图片', 118 | analysisResult: '分析结果', 119 | recResult: '识别结果', 120 | summaryResult: '总结结果', 121 | wordCount: '字数', 122 | audioFile: '音频文件', 123 | webRec: '网页录音', 124 | connectionID: '连接ID', 125 | audioDev: '音频设备', 126 | videoDev: '视频设备', 127 | resolution: '分辨率', 128 | '4K_priority': '4K优先', 129 | '2K_priority': '2K优先', 130 | '1080P_priority': '1080P优先', 131 | '720P_priority': '720P优先', 132 | record: '录制', 133 | recordSettings: '录制设置', 134 | format: '格式', 135 | bps: '比特率' 136 | }, 137 | btn: { 138 | myFav: '我的收藏', 139 | submit: '提交', 140 | update: '更新', 141 | saveDraft: '保存草稿', 142 | ok: '确定', 143 | cancel: '取消', 144 | send: '发送', 145 | clear: '清空', 146 | generate: '生成', 147 | download: '下载', 148 | translate: '翻译', 149 | copy: '复制', 150 | copied: '已复制', 151 | startAnalysis: '开始分析', 152 | startRecognizing: '开始识别', 153 | startSummarizing: '开始总结', 154 | startRec: '开始录制', 155 | stopRec: '停止录制', 156 | downloadJSONFile: '下载JSON文件', 157 | camera: '摄像头', 158 | monitor: '监控', 159 | connect: '连接', 160 | disconnect: '断开连接', 161 | autoConnect: '自动连接', 162 | openAudio: '开启音频' 163 | }, 164 | des: { 165 | t1: '原生支持', 166 | d1: '大部分现代浏览器原生支持WebRTC', 167 | t2: '高效实时', 168 | d2: 'WebRTC通过P2P通信避免服务器中继,提高通信效率', 169 | t3: '隐私安全', 170 | d3: 'WebRTC内置了加密技术,保护了通信内容的安全' 171 | }, 172 | usage: { 173 | usage: '使用方法', 174 | step1: '先连接摄像头,将作为摄像头的设备进入`摄像头`页面,输入连接ID,点连接。', 175 | step2: '监控端进入`监控`页面,填入与摄像头相同的连接ID,点连接,即可连到对应的摄像头' 176 | }, 177 | hint: { 178 | recHint: '当前浏览器不支持文件访问API,限制录制时长10分钟' 179 | } 180 | } 181 | } 182 | })) 183 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | devtools: { enabled: true }, 4 | css: ['~/assets/main.css'], 5 | modules: ['@nuxtjs/seo', '@nuxtjs/i18n', '@nuxt/ui', '@vite-pwa/nuxt'], 6 | 7 | app: { 8 | head: { 9 | charset: 'utf-8', 10 | viewport: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0', 11 | link: [{ rel: 'icon', href: '/favicon.webp' }] 12 | } 13 | }, 14 | 15 | site: { 16 | // url: 'http://localhost:3000', 17 | url: 'https://webcamera.cc', 18 | name: 'WebCamera', 19 | description: 'WebCamera' 20 | // defaultLocale: 'zh' 21 | }, 22 | 23 | i18n: { 24 | vueI18n: './i18n.config.ts', 25 | baseUrl: 'https://webcamera.cc', 26 | locales: [ 27 | { code: 'en', iso: 'en-US' }, 28 | { code: 'zh', iso: 'zh-CN' } 29 | ], 30 | defaultLocale: 'en', 31 | detectBrowserLanguage: { 32 | useCookie: true, 33 | cookieKey: 'i18n_redirected', 34 | redirectOn: 'root' 35 | } 36 | }, 37 | 38 | ogImage: { 39 | enabled: false 40 | }, 41 | 42 | colorMode: { 43 | preference: 'system', // default value of $colorMode.preference 44 | fallback: 'light', // fallback value if not system preference found 45 | hid: 'nuxt-color-mode-script', 46 | globalName: '__NUXT_COLOR_MODE__', 47 | componentName: 'ColorScheme', 48 | classPrefix: '', 49 | classSuffix: '', 50 | storageKey: 'nuxt-color-mode' 51 | }, 52 | 53 | pwa: { 54 | strategies: 'generateSW', 55 | registerType: 'autoUpdate', 56 | 57 | workbox: { 58 | runtimeCaching: [ 59 | { 60 | urlPattern: () => true, 61 | handler: 'NetworkOnly' 62 | } 63 | ] 64 | }, 65 | 66 | manifest: { 67 | name: 'Web Camera', 68 | short_name: 'WebCamera', 69 | theme_color: '#ffffff', 70 | 71 | icons: [ 72 | { 73 | src: '/favicon.webp', 74 | sizes: '512x512', 75 | type: 'image/webp', 76 | purpose: 'any' 77 | } 78 | ] 79 | } 80 | } 81 | }) 82 | -------------------------------------------------------------------------------- /ogImg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShouChenICU/WebCamera/4007149dc40c27a9b02a047c8c8293e4fac0aafb/ogImg.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev --host", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare" 11 | }, 12 | "dependencies": { 13 | "@isaacs/ttlcache": "^1.4.1", 14 | "@nuxt/ui": "^2.16.0", 15 | "@nuxtjs/i18n": "^8.3.1", 16 | "@nuxtjs/seo": "^2.0.0-rc.10", 17 | "@vite-pwa/nuxt": "^0.7.0", 18 | "nuxt": "^3.11.2", 19 | "sse.js": "^2.4.1", 20 | "vue": "^3.4.27", 21 | "vue-router": "^4.3.2", 22 | "webrtc-adapter": "^9.0.1" 23 | }, 24 | "devDependencies": { 25 | "prettier": "^3.2.5" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pages/camera.vue: -------------------------------------------------------------------------------- 1 | 433 | 434 | 533 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 75 | -------------------------------------------------------------------------------- /pages/monitor.vue: -------------------------------------------------------------------------------- 1 | 354 | 355 | 472 | -------------------------------------------------------------------------------- /public/favicon.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShouChenICU/WebCamera/4007149dc40c27a9b02a047c8c8293e4fac0aafb/public/favicon.webp -------------------------------------------------------------------------------- /public/ogImg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShouChenICU/WebCamera/4007149dc40c27a9b02a047c8c8293e4fac0aafb/public/ogImg.webp -------------------------------------------------------------------------------- /server/api/camera.post.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const body = await readBody(event) 3 | 4 | // console.log(body) 5 | 6 | const cameraId = body?.cameraId 7 | 8 | if (!cameraId) { 9 | throw createError({ statusCode: 400, statusText: 'Bad Request' }) 10 | } 11 | 12 | const tmpEs = sseMap.get(cameraId) 13 | if (tmpEs) { 14 | await tmpEs.close() 15 | // throw createError({ statusCode: 400, statusText: 'Connect id already exists' }) 16 | } 17 | 18 | const es = createEventStream(event) 19 | sseMap.set(cameraId, es) 20 | 21 | es.onClosed(async () => { 22 | sseMap.delete(cameraId) 23 | await es.close() 24 | }) 25 | 26 | es.push(JSON.stringify({ type: 'heartbeat' })) 27 | 28 | return es.send() 29 | }) 30 | -------------------------------------------------------------------------------- /server/api/monitor.post.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const body = await readBody(event) 3 | 4 | // console.log(body) 5 | 6 | const monitorId = body?.monitorId 7 | // const sdp = body?.sdp 8 | 9 | if (!monitorId) { 10 | throw createError({ statusCode: 400, statusText: 'Bad Request' }) 11 | } 12 | 13 | const es = createEventStream(event) 14 | sseMap.set(monitorId, es) 15 | 16 | es.onClosed(async () => { 17 | sseMap.delete(monitorId) 18 | await es.close() 19 | }) 20 | 21 | es.push(JSON.stringify({ type: 'heartbeat' })) 22 | 23 | return es.send() 24 | }) 25 | -------------------------------------------------------------------------------- /server/api/sendEvent.post.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const body = await readBody(event) 3 | 4 | // console.log('event', body) 5 | 6 | const id = body.id 7 | const type = body.type 8 | const content = body.content 9 | if (!id || !type || !content) { 10 | throw createError({ statusCode: 400, statusText: 'Bad Request' }) 11 | } 12 | 13 | const es = sseMap.get(id) 14 | if (!es) { 15 | throw createError({ statusCode: 404, statusText: 'Not found' }) 16 | } 17 | 18 | await es.push(JSON.stringify({ type: type, content: content })) 19 | 20 | return 'success' 21 | }) 22 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /server/utils/sseMap.ts: -------------------------------------------------------------------------------- 1 | import TTLCache from '@isaacs/ttlcache' 2 | import { toISOStringWithTimezone } from '~/utils/DateUtils' 3 | 4 | export const sseMap = new TTLCache({ 5 | max: 2048, 6 | ttl: 600e3, 7 | dispose: (val, key) => { 8 | try { 9 | console.log(toISOStringWithTimezone(new Date()) + ' dispose: ' + key.substring(0, 6) + '...') 10 | val.close() 11 | } catch (e) { 12 | console.warn(e) 13 | } 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | // import typography from '@tailwindcss/typography' 3 | 4 | export default >{ 5 | darkMode: 'selector', 6 | theme: { 7 | extend: { 8 | aspectRatio: { 9 | auto: 'auto', 10 | square: '1 / 1', 11 | video: '16 / 9' 12 | } 13 | } 14 | } 15 | // plugins: [typography] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /utils/DateUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 格式化日期 3 | * @param {Date} date 日期 4 | * @param {string} format 格式化字符串 5 | * @returns 格式化的日期 6 | */ 7 | export function formatDateTime(date, format) { 8 | const re = /(y+)/ 9 | if (re.test(format)) { 10 | const t = re.exec(format)[1] 11 | format = format.replace(t, (date.getFullYear() + '').substring(4 - t.length)) 12 | } 13 | 14 | const o = { 15 | 'M+': date.getMonth() + 1, // 月份 16 | 'd+': date.getDate(), // 日 17 | 'H+': date.getHours(), // 小时 18 | 'm+': date.getMinutes(), // 分 19 | 's+': date.getSeconds(), // 秒 20 | 'q+': Math.floor((date.getMonth() + 3) / 3), // 季度 21 | S: date.getMilliseconds() // 毫秒 22 | } 23 | for (let k in o) { 24 | const regx = new RegExp('(' + k + ')') 25 | if (regx.test(format)) { 26 | const t = regx.exec(format)[1] 27 | format = format.replace(t, t.length == 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length)) 28 | } 29 | } 30 | return format 31 | } 32 | 33 | /** 34 | * 将毫秒数格式化为 HH:mm:ss 35 | * @param {number} milliseconds 36 | * @returns 37 | */ 38 | export function formatTime(milliseconds) { 39 | // 计算小时数 40 | const hours = Math.floor(milliseconds / (1000 * 60 * 60)) 41 | // 计算剩余的分钟数 42 | const minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60)) 43 | // 计算剩余的秒数 44 | const seconds = Math.floor((milliseconds % (1000 * 60)) / 1000) 45 | 46 | // 格式化分钟和秒数为两位数 47 | const formattedMinutes = minutes.toString().padStart(2, '0') 48 | const formattedSeconds = seconds.toString().padStart(2, '0') 49 | 50 | return `${hours}:${formattedMinutes}:${formattedSeconds}` 51 | } 52 | 53 | export function currentTime(format) { 54 | if (format === undefined) { 55 | format = 'yyyy/MM/dd HH:mm:ss' 56 | } 57 | return formatDateTime(new Date(), format) 58 | } 59 | 60 | /** 61 | * 给定日期返回距今的时间偏移,超过7天返回正常日期格式化字符串 62 | * @param {Date} date 日期 63 | * @param {function} t i18n函数 64 | * @returns 格式化字符串 65 | */ 66 | export function timeAgo(date, t) { 67 | if (!date) { 68 | return '' 69 | } 70 | if (typeof date === 'string') { 71 | date = new Date(date) 72 | } 73 | const now = new Date() 74 | const diffMs = Math.abs(now - date) 75 | const diffSec = Math.floor(diffMs / 1000) 76 | const diffMin = Math.floor(diffSec / 60) 77 | const diffHour = Math.floor(diffMin / 60) 78 | const diffDay = Math.floor(diffHour / 24) 79 | 80 | if (diffDay <= 7) { 81 | if (diffDay === 0) { 82 | if (diffHour === 0) { 83 | if (diffMin === 0) { 84 | return t('label.secondsAgo', { n: diffSec }) 85 | } else { 86 | return t('label.minutesAgo', { n: diffMin }) 87 | } 88 | } else { 89 | return t('label.hoursAgo', { n: diffHour }) 90 | } 91 | } else { 92 | return t('label.daysAgo', { n: diffDay }) 93 | } 94 | } else { 95 | return formatDateTime(date, 'yyyy/MM/dd') 96 | } 97 | } 98 | 99 | export function timeRecent(date) { 100 | if (!date) { 101 | return '' 102 | } 103 | if (typeof date === 'string') { 104 | date = new Date(date) 105 | } 106 | const now = new Date() 107 | const diffMs = Math.abs(now - date) 108 | const diffSec = Math.floor(diffMs / 1000) 109 | const diffMin = Math.floor(diffSec / 60) 110 | const diffHour = Math.floor(diffMin / 60) 111 | const diffDay = Math.floor(diffHour / 24) 112 | 113 | if (diffDay <= 1) { 114 | return formatDateTime(date, 'HH:mm:ss') 115 | } else { 116 | return formatDateTime(date, 'yyyy/MM/dd HH:mm:ss') 117 | } 118 | } 119 | 120 | export function toISOStringWithTimezone(date) { 121 | function pad(number) { 122 | if (number < 10) { 123 | return '0' + number 124 | } 125 | return number 126 | } 127 | 128 | var offset = date.getTimezoneOffset() 129 | var offsetHours = Math.abs(offset / 60) 130 | var offsetMinutes = Math.abs(offset % 60) 131 | var timezoneOffset = (offset >= 0 ? '-' : '+') + pad(offsetHours) + ':' + pad(offsetMinutes) 132 | 133 | return ( 134 | date.getFullYear() + 135 | '-' + 136 | pad(date.getMonth() + 1) + 137 | '-' + 138 | pad(date.getDate()) + 139 | 'T' + 140 | pad(date.getHours()) + 141 | ':' + 142 | pad(date.getMinutes()) + 143 | ':' + 144 | pad(date.getSeconds()) + 145 | '.' + 146 | date.getMilliseconds() + 147 | timezoneOffset 148 | ) 149 | } 150 | -------------------------------------------------------------------------------- /utils/RTCNode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * RTC节点封装工具类 3 | */ 4 | export class RTCNode { 5 | private pc: RTCPeerConnection 6 | private mediaStream: MediaStream 7 | private dataChunnel: RTCDataChannel | undefined 8 | public onSDP: (sdp: RTCSessionDescriptionInit) => void = () => {} 9 | public onICECandidate: (candidate: RTCIceCandidate) => void = () => {} 10 | public onError: (e: any) => void = () => {} 11 | public onConnected: () => void = () => {} 12 | public onDispose: () => void = () => {} 13 | 14 | constructor(iceServers: RTCIceServer[] | undefined = undefined) { 15 | this.mediaStream = new MediaStream() 16 | // 初始化 17 | this.pc = new RTCPeerConnection({ iceServers: iceServers }) 18 | this.pc.onnegotiationneeded = () => this.reNegotition() 19 | this.pc.onicecandidate = (e) => (e.candidate ? this.onICECandidate(e.candidate) : undefined) 20 | this.pc.onicecandidateerror = this.onError 21 | // 收到媒体流 22 | this.pc.ontrack = (e) => { 23 | const track = e?.track 24 | if (track) { 25 | track.onmute = () => { 26 | this.mediaStream.removeTrack(track) 27 | track.stop() 28 | } 29 | this.mediaStream.addTrack(track) 30 | } 31 | } 32 | this.pc.onconnectionstatechange = () => { 33 | if (['closed', 'disconnected', 'failed'].includes(this.pc.connectionState)) { 34 | this.dispose() 35 | this.onDispose() 36 | } else if (this.pc.connectionState === 'connected') { 37 | this.onConnected() 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * 重协商SDP 44 | */ 45 | private async reNegotition() { 46 | return this.pc 47 | .createOffer() 48 | .then((offer) => this.pc.setLocalDescription(offer)) 49 | .then(() => (this.pc.localDescription ? this.onSDP(this.pc.localDescription) : undefined)) 50 | .catch((e) => { 51 | console.error(e) 52 | this.onError(e) 53 | this.dispose() 54 | }) 55 | } 56 | 57 | /** 58 | * 更新要发送的媒体流 59 | * @param stream 媒体流 60 | */ 61 | public updateStream(stream: MediaStream) { 62 | this.pc.getSenders().forEach((sender) => { 63 | this.pc.removeTrack(sender) 64 | sender.track?.stop() 65 | }) 66 | stream.getTracks().forEach((track) => this.pc.addTrack(track)) 67 | } 68 | 69 | /** 70 | * 设置远端SDP 71 | * @param sdp 会话描述 72 | */ 73 | public async setRemoteSDP(sdp: RTCSessionDescriptionInit) { 74 | return this.pc 75 | .setRemoteDescription(sdp) 76 | .then(() => (sdp?.type === 'offer' ? this.pc.createAnswer() : undefined)) 77 | .then(async (anwser) => { 78 | if (anwser) { 79 | await this.pc.setLocalDescription(anwser) 80 | this.onSDP(anwser) 81 | } 82 | }) 83 | .catch((e) => { 84 | console.error(e) 85 | this.onError(e) 86 | this.dispose() 87 | }) 88 | } 89 | 90 | /** 91 | * 添加ICE服务候选 92 | * @param candidate ICE服务候选 93 | */ 94 | public async addICECandidate(candidate: RTCIceCandidateInit) { 95 | return this.pc.addIceCandidate(candidate) 96 | } 97 | 98 | /** 99 | * 获取接收到的媒体流 100 | * @returns 媒体流 101 | */ 102 | public getMediaStream() { 103 | return this.mediaStream 104 | } 105 | 106 | /** 107 | * 获取数据通道,没有则创建 108 | * @returns 数据通道 109 | */ 110 | public getDataChunnel() { 111 | if (!this.dataChunnel) { 112 | this.dataChunnel = this.pc.createDataChannel('dataChunnel') 113 | } 114 | return this.dataChunnel 115 | } 116 | 117 | /** 118 | * 获取统计信息 119 | * @returns 统计信息 120 | */ 121 | public async getInfo() { 122 | return this.pc 123 | .getStats() 124 | .then((states: RTCStatsReport) => { 125 | const info = { 126 | bytesSent: 0, 127 | bytesReceived: 0, 128 | local: { 129 | address: '', 130 | port: 0, 131 | protocol: '', 132 | candidateType: '' 133 | }, 134 | remote: { 135 | address: '', 136 | port: 0, 137 | protocol: '', 138 | candidateType: '' 139 | } 140 | } 141 | for (const state of states.values()) { 142 | if (state.type === 'transport') { 143 | info.bytesSent = state?.bytesSent 144 | info.bytesReceived = state?.bytesReceived 145 | const selectedCandidatePair = states.get(state?.selectedCandidatePairId) 146 | const localCandidate = states.get(selectedCandidatePair?.localCandidateId) 147 | info.local.address = localCandidate?.address 148 | info.local.port = localCandidate?.port 149 | info.local.protocol = localCandidate?.protocol 150 | info.local.candidateType = localCandidate?.candidateType 151 | const remoteCandidate = states.get(selectedCandidatePair?.remoteCandidateId) 152 | info.remote.address = remoteCandidate?.address 153 | info.remote.port = remoteCandidate?.port 154 | info.remote.protocol = remoteCandidate?.protocol 155 | info.remote.candidateType = remoteCandidate?.candidateType 156 | break 157 | } 158 | } 159 | return info 160 | }) 161 | .catch(this.onError) 162 | } 163 | 164 | /** 165 | * 判断是否已连接 166 | * @returns 为 true 则已连接,否则未连接 167 | */ 168 | public isConnected() { 169 | return this.pc.connectionState === 'connected' 170 | } 171 | 172 | /** 173 | * 销毁节点并清理资源 174 | */ 175 | public dispose() { 176 | // 停止发送的媒体轨道 177 | this.pc.getSenders().forEach((sender) => sender?.track?.stop()) 178 | 179 | // 停止接收的媒体轨道 180 | this.pc.getReceivers().forEach((receiver) => receiver?.track?.stop()) 181 | 182 | // 关闭数据通道 183 | this.dataChunnel?.close() 184 | 185 | // 移除所有的事件监听器 186 | this.pc.onicecandidate = null 187 | this.pc.ontrack = null 188 | this.pc.ondatachannel = null 189 | this.pc.oniceconnectionstatechange = null 190 | this.pc.onsignalingstatechange = null 191 | this.pc.onicegatheringstatechange = null 192 | this.pc.onnegotiationneeded = null 193 | this.pc.close() 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | export function genRandomString(length: number): string { 2 | const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' 3 | let randomString = '' 4 | for (let i = 0; i < length; i++) { 5 | const randomIndex = Math.floor(Math.random() * charset.length) 6 | randomString += charset.charAt(randomIndex) 7 | } 8 | return randomString 9 | } 10 | 11 | export function postEventSource(url: string, data: any, eventHandler: Function) { 12 | return fetch(url, { 13 | method: 'POST', 14 | headers: { 15 | 'Content-Type': 'application/json' 16 | }, 17 | body: JSON.stringify(data) 18 | }) 19 | .then((response) => { 20 | if (!response.ok) { 21 | if (response.statusText) { 22 | throw Error(response.statusText) 23 | } 24 | throw new Error('Network response was not ok') 25 | } 26 | const contentType = response.headers.get('Content-Type') 27 | if (!contentType || !contentType.includes('text/event-stream')) { 28 | throw new Error('Expected text/event-stream content type') 29 | } 30 | if (response.body !== null) { 31 | return response.body.getReader() 32 | } else { 33 | throw new Error('Response body is null') 34 | } 35 | }) 36 | .then((reader) => { 37 | const decoder = new TextDecoder() 38 | let buffer = '' 39 | 40 | async function processText(text: string) { 41 | buffer += text 42 | let newlineIndex 43 | while ((newlineIndex = buffer.indexOf('\n')) >= 0) { 44 | const line = buffer.slice(0, newlineIndex).trim() 45 | buffer = buffer.slice(newlineIndex + 1) 46 | 47 | if (line.startsWith('data:')) { 48 | const eventData = line.slice(5).trim() 49 | // const parsedData = JSON.parse(eventData) 50 | await eventHandler(eventData) 51 | } 52 | } 53 | } 54 | async function read() { 55 | const { done, value } = await reader.read() 56 | if (done) { 57 | // console.log('Stream complete') 58 | return 59 | } 60 | const chunk = decoder.decode(value, { stream: true }) 61 | await processText(chunk) 62 | return read() 63 | } 64 | return read() 65 | }) 66 | } 67 | 68 | export function closeRTCPeerConnection(peerConnection: RTCPeerConnection): void { 69 | if (!peerConnection) { 70 | console.error('Invalid RTCPeerConnection object') 71 | return 72 | } 73 | 74 | // 停止发送的媒体轨道 75 | peerConnection.getSenders().forEach((sender) => { 76 | if (sender.track) { 77 | sender.track.stop() 78 | } 79 | }) 80 | 81 | // 停止接收的媒体轨道 82 | peerConnection.getReceivers().forEach((receiver) => { 83 | if (receiver.track) { 84 | receiver.track.stop() 85 | } 86 | }) 87 | 88 | // 关闭所有的数据通道 89 | if ((peerConnection as any).dataChannels) { 90 | ;(peerConnection as any).dataChannels.forEach((channel: RTCDataChannel) => { 91 | channel.close() 92 | }) 93 | } 94 | 95 | // 移除所有的事件监听器 96 | peerConnection.onicecandidate = null 97 | peerConnection.ontrack = null 98 | peerConnection.ondatachannel = null 99 | peerConnection.oniceconnectionstatechange = null 100 | peerConnection.onsignalingstatechange = null 101 | peerConnection.onicegatheringstatechange = null 102 | peerConnection.onnegotiationneeded = null 103 | 104 | // 关闭 RTCPeerConnection 105 | peerConnection.close() 106 | // console.log('RTCPeerConnection closed') 107 | } 108 | 109 | /** 110 | * 将字节格式化为人类友好的文本 111 | * 112 | * @param bytes 字节数量 113 | * @param decimals 显示的小数位数 114 | * 115 | * @return 格式化后的字符串 116 | */ 117 | export function humanFileSize(bytes: number, decimals = 2) { 118 | if (!bytes) return '0B' 119 | var k = 1024 120 | var dm = decimals || 2 121 | var sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 122 | var i = Math.floor(Math.log(bytes) / Math.log(k)) 123 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + sizes[i] 124 | } 125 | 126 | /** 127 | * 判断浏览器是否支持现代文件操作 128 | */ 129 | export function isModernFileAPIAvailable() { 130 | const fileHandleSupported = 'FileSystemFileHandle' in window 131 | const openFilePickerSupported = 'showOpenFilePicker' in window 132 | const saveFilePickerSupported = 'showSaveFilePicker' in window 133 | const directoryPickerSupported = 'showDirectoryPicker' in window 134 | 135 | return ( 136 | fileHandleSupported && 137 | openFilePickerSupported && 138 | saveFilePickerSupported && 139 | directoryPickerSupported 140 | ) 141 | } 142 | 143 | export function doDownloadFromHref(href: string, filename: string) { 144 | const a = document.createElement('a') 145 | a.style.display = 'none' 146 | a.href = href 147 | a.download = filename 148 | document.body.appendChild(a) 149 | a.click() 150 | a.remove() 151 | } 152 | 153 | /** 154 | * 从Blob下载 155 | * 156 | * @param {Blob} blob 157 | * @param {string} filename 158 | */ 159 | export function doDownloadFromBlob(blob: Blob | File, filename: string) { 160 | const objUrl = URL.createObjectURL(blob) 161 | doDownloadFromHref(objUrl, filename) 162 | URL.revokeObjectURL(objUrl) 163 | } 164 | 165 | export function getRecordMimeTypes() { 166 | const mimeTypes = [ 167 | 'video/mp4', 168 | 'video/webm', 169 | 'video/ogg', 170 | 'video/mpeg', 171 | 'video/quicktime', 172 | 'video/x-msvideo' 173 | ] 174 | return mimeTypes.filter((t) => MediaRecorder.isTypeSupported(t)) 175 | } 176 | -------------------------------------------------------------------------------- /utils/publicStunList.ts: -------------------------------------------------------------------------------- 1 | export const pubStunList = [ 2 | 'iphone-stun.strato-iphone.de:3478', 3 | 'numb.viagenie.ca:3478', 4 | 'relay.webwormhole.io', 5 | 's1.taraba.net:3478', 6 | 's2.taraba.net:3478', 7 | 'stun.12connect.com:3478', 8 | 'stun.12voip.com:3478', 9 | 'stun1.faktortel.com.au:3478', 10 | 'stun1.l.google.com:19302', 11 | 'stun.1und1.de:3478', 12 | 'stun1.voiceeclipse.net:3478', 13 | 'stun2.l.google.com:19302', 14 | 'stun.2talk.com:3478', 15 | 'stun.2talk.co.nz:3478', 16 | 'stun.3clogic.com:3478', 17 | 'stun.3cx.com:3478', 18 | 'stun3.l.google.com:19302', 19 | 'stun4.l.google.com:19302', 20 | 'stun.aa.net.uk:3478', 21 | 'stun.acrobits.cz:3478', 22 | 'stun.actionvoip.com:3478', 23 | 'stun.advfn.com:3478', 24 | 'stun.aeta-audio.com:3478', 25 | 'stun.aeta.com:3478', 26 | 'stun.alltel.com.au:3478', 27 | 'stun.altar.com.pl:3478', 28 | 'stun.a-mm.tv:3478', 29 | 'stun.annatel.net:3478', 30 | 'stun.antisip.com:3478', 31 | 'stun.arbuz.ru:3478', 32 | 'stun.avigora.com:3478', 33 | 'stun.avigora.fr:3478', 34 | 'stun.awa-shima.com:3478', 35 | 'stun.awt.be:3478', 36 | 'stun.b2b2c.ca:3478', 37 | 'stun.bahnhof.net:3478', 38 | 'stun.barracuda.com:3478', 39 | 'stun.bluesip.net:3478', 40 | 'stun.bmwgs.cz:3478', 41 | 'stun.botonakis.com:3478', 42 | 'stun.budgetphone.nl:3478', 43 | 'stun.budgetsip.com:3478', 44 | 'stun.cablenet-as.net:3478', 45 | 'stun.callromania.ro:3478', 46 | 'stun.callwithus.com:3478', 47 | 'stun.cbsys.net:3478', 48 | 'stun.chathelp.ru:3478', 49 | 'stun.cheapvoip.com:3478', 50 | 'stun.ciktel.com:3478', 51 | 'stun.cloopen.com:3478', 52 | 'stun.cloudflare.com:3478', 53 | 'stun.colouredlines.com.au:3478', 54 | 'stun.comfi.com:3478', 55 | 'stun.commpeak.com:3478', 56 | 'stun.comtube.com:3478', 57 | 'stun.comtube.ru:3478', 58 | 'stun.cope.es:3478', 59 | 'stun.counterpath.com:3478', 60 | 'stun.counterpath.net:3478', 61 | 'stun.cryptonit.net:3478', 62 | 'stun.darioflaccovio.it:3478', 63 | 'stun.datamanagement.it:3478', 64 | 'stun.dcalling.de:3478', 65 | 'stun.decanet.fr:3478', 66 | 'stun.demos.ru:3478', 67 | 'stun.develz.org:3478', 68 | 'stun.dingaling.ca:3478', 69 | 'stun.doublerobotics.com:3478', 70 | 'stun.drogon.net:3478', 71 | 'stun.duocom.es:3478', 72 | 'stun.dus.net:3478', 73 | 'stun.easybell.de:3478', 74 | 'stun.easycall.pl:3478', 75 | 'stun.easyvoip.com:3478', 76 | 'stun.efficace-factory.com:3478', 77 | 'stun.e-fon.ch:3478', 78 | 'stun.einsundeins.com:3478', 79 | 'stun.einsundeins.de:3478', 80 | 'stun.ekiga.net:3478', 81 | 'stun.epygi.com:3478', 82 | 'stun.etoilediese.fr:3478', 83 | 'stun.eyeball.com:3478', 84 | 'stun.faktortel.com.au:3478', 85 | 'stun.flashdance.cx:3478', 86 | 'stun.freecall.com:3478', 87 | 'stun.freeswitch.org:3478', 88 | 'stun.freevoipdeal.com:3478', 89 | 'stun.fuzemeeting.com:3478', 90 | 'stun.gmx.de:3478', 91 | 'stun.gmx.net:3478', 92 | 'stun.gradwell.com:3478', 93 | 'stun.halonet.pl:3478', 94 | 'stun.hellonanu.com:3478', 95 | 'stun.hoiio.com:3478', 96 | 'stun.hosteurope.de:3478', 97 | 'stun.ideasip.com:3478', 98 | 'stun.imesh.com:3478', 99 | 'stun.infra.net:3478', 100 | 'stun.internetcalls.com:3478', 101 | 'stun.intervoip.com:3478', 102 | 'stun.ipcomms.net:3478', 103 | 'stun.ipfire.org:3478', 104 | 'stun.ippi.fr:3478', 105 | 'stun.ipshka.com:3478', 106 | 'stun.iptel.org:3478', 107 | 'stun.irian.at:3478', 108 | 'stun.it1.hr:3478', 109 | 'stun.ivao.aero:3478', 110 | 'stun.jappix.com:3478', 111 | 'stun.jumblo.com:3478', 112 | 'stun.justvoip.com:3478', 113 | 'stun.kanet.ru:3478', 114 | 'stun.kiwilink.co.nz:3478', 115 | 'stun.kundenserver.de:3478', 116 | 'stun.l.google.com:19302', 117 | 'stun.linea7.net:3478', 118 | 'stun.linphone.org:3478', 119 | 'stun.liveo.fr:3478', 120 | 'stun.lowratevoip.com:3478', 121 | 'stun.lugosoft.com:3478', 122 | 'stun.lundimatin.fr:3478', 123 | 'stun.magnet.ie:3478', 124 | 'stun.manle.com:3478', 125 | 'stun.mgn.ru:3478', 126 | 'stun.mitake.com.tw:3478', 127 | 'stun.mit.de:3478', 128 | 'stun.miwifi.com:3478', 129 | 'stun.modulus.gr:3478', 130 | 'stun.mozcom.com:3478', 131 | 'stun.myvoiptraffic.com:3478', 132 | 'stun.mywatson.it:3478', 133 | 'stun.nas.net:3478', 134 | 'stun.neotel.co.za:3478', 135 | 'stun.netappel.com:3478', 136 | 'stun.netappel.fr:3478', 137 | 'stun.netgsm.com.tr:3478', 138 | 'stun.nextcloud.com:443', 139 | 'stun.nfon.net:3478', 140 | 'stun.noblogs.org:3478', 141 | 'stun.noc.ams-ix.net:3478', 142 | 'stun.node4.co.uk:3478', 143 | 'stun.nonoh.net:3478', 144 | 'stun.nottingham.ac.uk:3478', 145 | 'stun.nova.is:3478', 146 | 'stun.nventure.com:3478', 147 | 'stun.on.net.mk:3478', 148 | 'stun.ooma.com:3478', 149 | 'stun.ooonet.ru:3478', 150 | 'stun.oriontelekom.rs:3478', 151 | 'stun.outland-net.de:3478', 152 | 'stun.ozekiphone.com:3478', 153 | 'stun.patlive.com:3478', 154 | 'stun.personal-voip.de:3478', 155 | 'stun.petcube.com:3478', 156 | 'stun.phone.com:3478', 157 | 'stun.phoneserve.com:3478', 158 | 'stun.pjsip.org:3478', 159 | 'stun.poivy.com:3478', 160 | 'stun.powerpbx.org:3478', 161 | 'stun.powervoip.com:3478', 162 | 'stun.ppdi.com:3478', 163 | 'stun.prizee.com:3478', 164 | 'stun.qq.com:3478', 165 | 'stun.qvod.com:3478', 166 | 'stun.rackco.com:3478', 167 | 'stun.rapidnet.de:3478', 168 | 'stun.rb-net.com:3478', 169 | 'stun.refint.net:3478', 170 | 'stun.remote-learner.net:3478', 171 | 'stun.rixtelecom.se:3478', 172 | 'stun.rockenstein.de:3478', 173 | 'stun.rolmail.net:3478', 174 | 'stun.rounds.com:3478', 175 | 'stun.rynga.com:3478', 176 | 'stun.samsungsmartcam.com:3478', 177 | 'stun.schlund.de:3478', 178 | 'stunserver.org:3478', 179 | 'stun.services.mozilla.com:3478', 180 | 'stun.sigmavoip.com:3478', 181 | 'stun.sipdiscount.com:3478', 182 | 'stun.sipgate.net:10000', 183 | 'stun.sipgate.net:3478', 184 | 'stun.siplogin.de:3478', 185 | 'stun.sipnet.net:3478', 186 | 'stun.sipnet.ru:3478', 187 | 'stun.siportal.it:3478', 188 | 'stun.sippeer.dk:3478', 189 | 'stun.siptraffic.com:3478', 190 | 'stun.sip.us:3478', 191 | 'stun.skylink.ru:3478', 192 | 'stun.sma.de:3478', 193 | 'stun.smartvoip.com:3478', 194 | 'stun.smsdiscount.com:3478', 195 | 'stun.snafu.de:3478', 196 | 'stun.softjoys.com:3478', 197 | 'stun.solcon.nl:3478', 198 | 'stun.solnet.ch:3478', 199 | 'stun.sonetel.com:3478', 200 | 'stun.sonetel.net:3478', 201 | 'stun.sovtest.ru:3478', 202 | 'stun.speedy.com.ar:3478', 203 | 'stun.spokn.com:3478', 204 | 'stun.srce.hr:3478', 205 | 'stun.ssl7.net:3478', 206 | 'stun.stunprotocol.org:3478', 207 | 'stun.symform.com:3478', 208 | 'stun.symplicity.com:3478', 209 | 'stun.sysadminman.net:3478', 210 | 'stun.tagan.ru:3478', 211 | 'stun.tatneft.ru:3478', 212 | 'stun.teachercreated.com:3478', 213 | 'stun.telbo.com:3478', 214 | 'stun.telefacil.com:3478', 215 | 'stun.tel.lu:3478', 216 | 'stun.tis-dialog.ru:3478', 217 | 'stun.tng.de:3478', 218 | 'stun.t-online.de:3478', 219 | 'stun.twt.it:3478', 220 | 'stun.u-blox.com:3478', 221 | 'stun.ucallweconn.net:3478', 222 | 'stun.ucsb.edu:3478', 223 | 'stun.ucw.cz:3478', 224 | 'stun.uls.co.za:3478', 225 | 'stun.unseen.is:3478', 226 | 'stun.usfamily.net:3478', 227 | 'stun.veoh.com:3478', 228 | 'stun.vidyo.com:3478', 229 | 'stun.vipgroup.net:3478', 230 | 'stun.virtual-call.com:3478', 231 | 'stun.viva.gr:3478', 232 | 'stun.vivox.com:3478', 233 | 'stun.vline.com:3478', 234 | 'stun.vodafone.ro:3478', 235 | 'stun.voicetrading.com:3478', 236 | 'stun.voip.aebc.com:3478', 237 | 'stun.voiparound.com:3478', 238 | 'stun.voip.blackberry.com:3478', 239 | 'stun.voipblast.com:3478', 240 | 'stun.voipbuster.com:3478', 241 | 'stun.voipbusterpro.com:3478', 242 | 'stun.voipcheap.com:3478', 243 | 'stun.voipcheap.co.uk:3478', 244 | 'stun.voip.eutelia.it:3478', 245 | 'stun.voipfibre.com:3478', 246 | 'stun.voipgain.com:3478', 247 | 'stun.voipgate.com:3478', 248 | 'stun.voipinfocenter.com:3478', 249 | 'stun.voipplanet.nl:3478', 250 | 'stun.voippro.com:3478', 251 | 'stun.voipraider.com:3478', 252 | 'stun.voipstunt.com:3478', 253 | 'stun.voipwise.com:3478', 254 | 'stun.voipzoom.com:3478', 255 | 'stun.vo.lu:3478', 256 | 'stun.vopium.com:3478', 257 | 'stun.voxgratia.org:3478', 258 | 'stun.voxox.com:3478', 259 | 'stun.voys.nl:3478', 260 | 'stun.voztele.com:3478', 261 | 'stun.vyke.com:3478', 262 | 'stun.webcalldirect.com:3478', 263 | 'stun.whoi.edu:3478', 264 | 'stun.wifirst.net:3478', 265 | 'stun.wwdl.net:3478', 266 | 'stun.xs4all.nl:3478', 267 | 'stun.xtratelecom.es:3478', 268 | 'stun.yesss.at:3478', 269 | 'stun.zadarma.com:3478', 270 | 'stun.zadv.com:3478', 271 | 'stun.zoiper.com:3478' 272 | ] 273 | 274 | export const pubIceServers = pubStunList.map((i) => ({ 275 | urls: 'stun:' + i 276 | })) 277 | --------------------------------------------------------------------------------