新增voice页面

This commit is contained in:
kura 2025-01-03 10:53:26 +08:00
parent 09dc671216
commit c96d550001
7 changed files with 321 additions and 5 deletions

View File

@ -13,6 +13,10 @@ p2p-explorer-web 是一个基于浏览器的 webtrc P2P 远程文件传输工具
- 🌐 无需安装,基于浏览器即可使用 - 🌐 无需安装,基于浏览器即可使用
- 💨 快速分享链接功能 - 💨 快速分享链接功能
## 需要注意使用的服务
文件交互全部走 webrtc使用 peerjs 的信令服务器wss://0.peerjs.com/peerjs交换 ice 候选(可信,没有本地数据上传),使用 peerjs 的打洞 0.peerjs.com:(可信,没有数据上传),当 udp 直连失败,会使用 peerjs 的中转服务(不可信,所有数据上传),后续会 docker 化,使用自己的 stun 与 turn 自定义部署。
## 🚀 快速开始 ## 🚀 快速开始
### 安装 ### 安装
@ -56,8 +60,13 @@ cp -r static dist/
## 待完成 ## 待完成
- 本地发送到远程的 ui 展示 - 本地发送到远程的 ui 展示
- 屏幕预览 - 屏幕预览 语音沟通 引用文件文字交流
- docker 化,使用自己的 stun 与 turn 自定义部署 - docker 化,使用自己的 stun 与 turn 自定义部署
- 各种异常处理需要闭环 - 各种异常处理需要闭环
- 英文版本 - 英文版本
- 打包下载 - 打包下载
## 感谢
[PeerJS](https://peerjs.com/) 提供的 WebRTC 支持
[Ant Design Vue](https://2x.antdv.com/docs/vue/introduce-cn/) 提供的 UI 支持

View File

@ -20,7 +20,8 @@
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"peerjs": "^1.5.4", "peerjs": "^1.5.4",
"vue": "^3.3.0", "vue": "^3.3.0",
"vue-i18n": "^9.1.9" "vue-i18n": "^9.1.9",
"vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.6.1", "@rushstack/eslint-patch": "^1.6.1",

View File

@ -1,9 +1,10 @@
<template> <template>
<Index /> <RouterView />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted } from "vue"; import { onMounted, onUnmounted } from "vue";
import Index from "./pages/file/index.vue"; import { RouterView } from "vue-router";
onMounted(() => { onMounted(() => {
console.log("App Launch"); console.log("App Launch");
}); });

View File

@ -2,6 +2,8 @@ import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import 'ant-design-vue/dist/reset.css'; import 'ant-design-vue/dist/reset.css';
import Antd from 'ant-design-vue'; import Antd from 'ant-design-vue';
import router from './router/router';
const app = createApp(App); const app = createApp(App);
app.use(Antd); app.use(Antd);
app.use(router);
app.mount('#app') app.mount('#app')

View File

@ -0,0 +1,284 @@
<template>
<div class="webrtc-voice-container">
<div class="call-status">
<h2>{{ callStatus }}</h2>
<div v-if="myPeerId" class="peer-id">
我的ID: {{ myPeerId }}
</div>
<button @click="copyPeerId">复制</button>
</div>
<div class="delay-time">
<h2>延迟时间: {{ delayTime + 'ms' }}</h2>
</div>
<div class="connection-controls" v-if="!isInCall">
<input type="text" v-model="remotePeerId" placeholder="输入对方的ID" :disabled="isInCall" />
<button type="reset" class="call-btn start-call" @click="startCall" :disabled="!remotePeerId || isInCall">
呼叫
</button>
</div>
<div class="call-controls" v-if="isInCall">
<button class="call-btn end-call" @click="endCall">
结束通话
</button>
<button class="mute-btn" :class="{ muted: isMuted }" @click="toggleMute">
{{ isMuted ? '取消静音' : '静音' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import Peer, { type MediaConnection } from 'peerjs'
const myPeerId = ref('')
const remotePeerId = ref('')
const isInCall = ref(false)
const isMuted = ref(false)
const callStatus = ref('准备连接...')
const localStream = ref<MediaStream | null>(null)
const peer = ref<Peer | null>(null)
const currentCall = ref<MediaConnection | null>(null)
const delayTime = ref(0)
let statsInterval: number | null = null
const initPeer = () => {
peer.value = new Peer(
{
config: {
iceServers: [
{ urls: 'stun:8.134.35.244:3478' }
]
}
}
)
peer.value.on('open', (id) => {
myPeerId.value = id
callStatus.value = '准备就绪'
})
peer.value.on('call', async (call) => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
})
localStream.value = stream
call.answer(stream)
handleCall(call)
isInCall.value = true
callStatus.value = '通话中'
} catch (error) {
console.error('获取音频设备失败:', error)
callStatus.value = '无法访问麦克风'
}
})
peer.value.on('error', (error) => {
console.error('PeerJS错误:', error)
callStatus.value = '连接错误'
})
}
const copyPeerId = () => {
navigator.clipboard.writeText(myPeerId.value)
}
const updateDelayTime = async () => {
delayTime.value = 0
if (currentCall.value && currentCall.value.peerConnection) {
const stats = await currentCall.value.peerConnection.getStats()
stats.forEach(report => {
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
delayTime.value += report.currentRoundTripTime * 1000 //currentRoundTripTime
}
if (report.type === 'inbound-rtp' && report.kind === 'audio') {
delayTime.value += Math.round(
report.jitter * 1000 || 0 + //
report.playoutDelay || 0 //
)
}
})
}
}
const handleCall = (call: MediaConnection) => {
currentCall.value = call
statsInterval = window.setInterval(updateDelayTime, 1000)
call.on('stream', (remoteStream) => {
const audio = new Audio()
audio.srcObject = remoteStream
audio.play()
})
call.on('close', () => {
endCall()
})
}
const startCall = async () => {
if (!peer.value || !remotePeerId.value) return
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
})
localStream.value = stream
const call = peer.value.call(remotePeerId.value, stream)
handleCall(call)
isInCall.value = true
callStatus.value = '通话中'
} catch (error) {
console.error('获取音频设备失败:', error)
callStatus.value = '无法访问麦克风'
}
}
const toggleMute = () => {
if (localStream.value) {
const audioTrack = localStream.value.getAudioTracks()[0]
isMuted.value = !isMuted.value
audioTrack.enabled = !isMuted.value
}
}
const endCall = () => {
if (statsInterval) {
clearInterval(statsInterval)
statsInterval = null
}
if (currentCall.value) {
currentCall.value.close()
currentCall.value = null
}
if (localStream.value) {
localStream.value.getTracks().forEach(track => track.stop())
localStream.value = null
}
isInCall.value = false
isMuted.value = false
callStatus.value = '准备就绪'
delayTime.value = 0 //
}
onMounted(() => {
initPeer()
})
onUnmounted(() => {
endCall()
if (peer.value) {
peer.value.destroy()
}
})
</script>
<style scoped>
.webrtc-voice-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: #f5f5f5;
padding: 20px;
}
.call-status {
margin-bottom: 30px;
text-align: center;
}
.call-status h2 {
font-size: 24px;
color: #333;
margin-bottom: 10px;
}
.peer-id {
font-size: 16px;
color: #666;
margin-top: 10px;
}
.connection-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
justify-content: center;
align-items: center;
}
.connection-controls input {
padding: 12px;
border: 1px solid #ddd;
border-radius: 25px;
font-size: 16px;
width: 200px;
outline: none;
}
.connection-controls input:focus {
border-color: #2196F3;
}
.call-controls {
display: flex;
gap: 20px;
justify-content: center;
align-items: center;
}
button {
padding: 12px 24px;
border: none;
border-radius: 25px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.call-btn {
min-width: 120px;
}
.start-call {
background-color: #4CAF50;
color: white;
}
.end-call {
background-color: #f44336;
color: white;
}
.mute-btn {
background-color: #2196F3;
color: white;
}
.mute-btn.muted {
background-color: #9E9E9E;
}
button:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
</style>

12
src/router/router.ts Normal file
View File

@ -0,0 +1,12 @@
import { createRouter, createWebHashHistory } from "vue-router";
import Index from "../pages/file/index.vue";
import Voice from "../pages/voice/webrtcVoice.vue";
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: "/", component: Index },
{ path: "/voice", component: Voice },
],
});
export default router;

View File

@ -581,7 +581,7 @@
"@vue/compiler-dom" "3.5.13" "@vue/compiler-dom" "3.5.13"
"@vue/shared" "3.5.13" "@vue/shared" "3.5.13"
"@vue/devtools-api@^6.5.0": "@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.4":
version "6.6.4" version "6.6.4"
resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343" resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g== integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
@ -2102,6 +2102,13 @@ vue-i18n@^9.1.9:
"@intlify/shared" "9.14.2" "@intlify/shared" "9.14.2"
"@vue/devtools-api" "^6.5.0" "@vue/devtools-api" "^6.5.0"
vue-router@^4.5.0:
version "4.5.0"
resolved "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.0.tgz#58fc5fe374e10b6018f910328f756c3dae081f14"
integrity sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==
dependencies:
"@vue/devtools-api" "^6.6.4"
vue-template-compiler@^2.7.14: vue-template-compiler@^2.7.14:
version "2.7.16" version "2.7.16"
resolved "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz#c81b2d47753264c77ac03b9966a46637482bb03b" resolved "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz#c81b2d47753264c77ac03b9966a46637482bb03b"