Compare commits
No commits in common. "c96d5500013036cda67379d60308e6e20abedf44" and "c942b7673a9c17075ccb965291b7b3a68a4797b6" have entirely different histories.
c96d550001
...
c942b7673a
11
README.md
11
README.md
@ -13,10 +13,6 @@ p2p-explorer-web 是一个基于浏览器的 webtrc P2P 远程文件传输工具
|
|||||||
- 🌐 无需安装,基于浏览器即可使用
|
- 🌐 无需安装,基于浏览器即可使用
|
||||||
- 💨 快速分享链接功能
|
- 💨 快速分享链接功能
|
||||||
|
|
||||||
## 需要注意使用的服务
|
|
||||||
|
|
||||||
文件交互全部走 webrtc,使用 peerjs 的信令服务器(wss://0.peerjs.com/peerjs)交换 ice 候选(可信,没有本地数据上传),使用 peerjs 的打洞 0.peerjs.com:(可信,没有数据上传),当 udp 直连失败,会使用 peerjs 的中转服务(不可信,所有数据上传),后续会 docker 化,使用自己的 stun 与 turn 自定义部署。
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
### 安装
|
### 安装
|
||||||
@ -60,13 +56,8 @@ 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,8 +20,7 @@
|
|||||||
"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,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<Index />
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted } from "vue";
|
import { onMounted, onUnmounted } from "vue";
|
||||||
import { RouterView } from "vue-router";
|
import Index from "./pages/file/index.vue";
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
console.log("App Launch");
|
console.log("App Launch");
|
||||||
});
|
});
|
||||||
|
@ -2,8 +2,6 @@ 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')
|
||||||
|
@ -188,7 +188,6 @@ const handleReceive = async () => {
|
|||||||
};
|
};
|
||||||
// 事件监听
|
// 事件监听
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
peer.init();
|
|
||||||
peer.on("open", ((event: CustomEvent) => {
|
peer.on("open", ((event: CustomEvent) => {
|
||||||
myId.value = event.detail;
|
myId.value = event.detail;
|
||||||
const sign = getUrlParam("sign");
|
const sign = getUrlParam("sign");
|
||||||
|
@ -21,9 +21,7 @@ class Peer extends EventTarget {
|
|||||||
constructor(sign: string) {
|
constructor(sign: string) {
|
||||||
super()
|
super()
|
||||||
this.sign = sign
|
this.sign = sign
|
||||||
}
|
this.peer = new PeerJs(sign2peerid(sign))
|
||||||
public init() {
|
|
||||||
this.peer = new PeerJs(sign2peerid(this.sign))
|
|
||||||
this.peer.on('open', (id) => {
|
this.peer.on('open', (id) => {
|
||||||
console.log('peer open', id)
|
console.log('peer open', id)
|
||||||
this.id = id
|
this.id = id
|
||||||
|
@ -1,284 +0,0 @@
|
|||||||
<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>
|
|
@ -1,12 +0,0 @@
|
|||||||
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.6.4":
|
"@vue/devtools-api@^6.5.0":
|
||||||
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,13 +2102,6 @@ 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