新增voice页面
This commit is contained in:
parent
09dc671216
commit
c96d550001
11
README.md
11
README.md
@ -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 展示
|
||||
- 屏幕预览
|
||||
- 屏幕预览 语音沟通 引用文件文字交流
|
||||
- docker 化,使用自己的 stun 与 turn 自定义部署
|
||||
- 各种异常处理需要闭环
|
||||
- 英文版本
|
||||
- 打包下载
|
||||
|
||||
## 感谢
|
||||
|
||||
[PeerJS](https://peerjs.com/) 提供的 WebRTC 支持
|
||||
[Ant Design Vue](https://2x.antdv.com/docs/vue/introduce-cn/) 提供的 UI 支持
|
||||
|
@ -20,7 +20,8 @@
|
||||
"highlight.js": "^11.11.1",
|
||||
"peerjs": "^1.5.4",
|
||||
"vue": "^3.3.0",
|
||||
"vue-i18n": "^9.1.9"
|
||||
"vue-i18n": "^9.1.9",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.6.1",
|
||||
|
@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<Index />
|
||||
<RouterView />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
import Index from "./pages/file/index.vue";
|
||||
import { RouterView } from "vue-router";
|
||||
|
||||
onMounted(() => {
|
||||
console.log("App Launch");
|
||||
});
|
||||
|
@ -2,6 +2,8 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
import Antd from 'ant-design-vue';
|
||||
import router from './router/router';
|
||||
const app = createApp(App);
|
||||
app.use(Antd);
|
||||
app.use(router);
|
||||
app.mount('#app')
|
||||
|
284
src/pages/voice/webrtcVoice.vue
Normal file
284
src/pages/voice/webrtcVoice.vue
Normal 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
12
src/router/router.ts
Normal 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;
|
@ -581,7 +581,7 @@
|
||||
"@vue/compiler-dom" "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"
|
||||
resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
|
||||
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
|
||||
@ -2102,6 +2102,13 @@ vue-i18n@^9.1.9:
|
||||
"@intlify/shared" "9.14.2"
|
||||
"@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:
|
||||
version "2.7.16"
|
||||
resolved "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz#c81b2d47753264c77ac03b9966a46637482bb03b"
|
||||
|
Loading…
Reference in New Issue
Block a user