新增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 展示
|
- 本地发送到远程的 ui 展示
|
||||||
- 屏幕预览
|
- 屏幕预览 语音沟通 引用文件文字交流
|
||||||
- docker 化,使用自己的 stun 与 turn 自定义部署
|
- 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",
|
"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",
|
||||||
|
@ -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");
|
||||||
});
|
});
|
||||||
|
@ -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')
|
||||||
|
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/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"
|
||||||
|
Loading…
Reference in New Issue
Block a user