H5页面
html
复制代码
<template>
<div class="h5-container">
<div class="container">
<h1>📱 摄像头同步</h1>
<div class="sub">H5 采集端 → 实时推送至 PC</div>
<div class="status-bar">
<div class="status-badge">
<span class="led" :class="{ active: wsConnected }"></span>
<span>{{ wsStatusText }}</span>
</div>
<div class="status-badge">
<span class="led" :class="{ active: cameraActive, warning: cameraError }"></span>
<span>{{ cameraStatusText }}</span>
</div>
<div class="status-badge" v-if="isStreaming">
<span>📊 {{ currentFPS }} fps</span>
</div>
</div>
<div class="video-wrapper">
<!-- 主视频:只管显示,绝对流畅 -->
<video ref="localVideo" autoplay playsinline muted></video>
<!-- 隐藏的辅助视频:用于截图,避免影响主视频 -->
<video ref="captureVideo" autoplay playsinline muted style="display: none;"></video>
<div v-if="showPlaceholder" class="placeholder">
⚠️ 等待摄像头授权
<span style="font-size:12px">请允许使用相机</span>
</div>
</div>
<div class="btn-group">
<button @click="startSync" :disabled="isStreaming" class="primary">📷 开启摄像头</button>
<button @click="switchCamera" :disabled="!isStreaming" class="switch-camera">🔄 切换摄像头</button>
<button @click="stopSync" :disabled="!isStreaming" class="danger">⏹️ 停止同步</button>
</div>
<div class="info-text">
💡 H5 端显示完全流畅,后台静默采集发送<br>
建议使用 https 或 localhost 环境
</div>
</div>
</div>
</template>
<script>
export default {
name: 'H5View',
data() {
return {
wsUrl: 'ws://192.168.20.156:3000/ws',
// 状态
wsConnected: false,
cameraActive: false,
cameraError: false,
isStreaming: false,
showPlaceholder: true,
// 摄像头方向
currentFacingMode: 'environment', // 'environment' 后置, 'user' 前置
hasMultipleCameras: false, // 是否有多个摄像头
// 内部对象
mediaStream: null,
websocket: null,
// 采集相关(使用独立定时器,不干扰主视频)
captureInterval: null,
canvas: null,
ctx: null,
// 帧率统计
currentFPS: 0,
frameCount: 0,
lastFpsUpdate: 0
}
},
computed: {
wsStatusText() {
return this.wsConnected ? 'WebSocket 已连接 ✅' : 'WebSocket 未连接 🔌'
},
cameraStatusText() {
if (this.cameraError) return '摄像头 失败 ❌'
return this.cameraActive ? `摄像头 工作中 🟢 (${this.currentFacingMode === 'environment' ? '后置' : '前置'})` : '摄像头 未启动 ⚪'
}
},
mounted() {
this.init()
},
beforeDestroy() {
this.cleanup()
},
methods: {
init() {
// 创建画布(用于编码,不影响主线程显示)
this.canvas = document.createElement('canvas')
this.ctx = this.canvas.getContext('2d')
this.connectWebSocket()
document.addEventListener('visibilitychange', this.handlePageVisibilityChange)
// 检查设备摄像头数量
this.checkCameraCount()
},
// 检查是否有多个摄像头
async checkCameraCount() {
try {
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter(device => device.kind === 'videoinput')
this.hasMultipleCameras = videoDevices.length >= 2
console.log(`检测到 ${videoDevices.length} 个摄像头设备`)
} catch (err) {
console.warn('无法枚举设备:', err)
this.hasMultipleCameras = false
}
},
connectWebSocket() {
if (this.websocket && (this.websocket.readyState === WebSocket.OPEN || this.websocket.readyState === WebSocket.CONNECTING)) {
return
}
try {
this.websocket = new WebSocket(this.wsUrl)
this.websocket.onopen = () => {
console.log('[WS] 连接成功')
this.wsConnected = true
this.websocket.send(JSON.stringify({ type: 'identify', role: 'publisher' }))
}
this.websocket.onclose = () => {
console.log('[WS] 连接关闭')
this.wsConnected = false
if (this.isStreaming) {
setTimeout(() => this.connectWebSocket(), 2000)
}
}
this.websocket.onerror = (err) => {
console.error('[WS] 错误', err)
this.wsConnected = false
}
} catch (e) {
console.error('[WS] 连接异常', e)
}
},
async startCamera() {
if (this.mediaStream && this.mediaStream.active) {
await this.stopCamera()
}
// 根据当前方向设置 constraints
const constraints = {
video: {
facingMode: { exact: this.currentFacingMode },
width: { ideal: 640 },
height: { ideal: 480 },
frameRate: { ideal: 30 }
},
audio: false
}
try {
let stream = await navigator.mediaDevices.getUserMedia(constraints)
this.mediaStream = stream
console.log(`成功获取摄像头: ${this.currentFacingMode}`)
} catch (firstErr) {
console.warn(`无法获取 ${this.currentFacingMode} 摄像头,尝试默认摄像头`, firstErr)
// 如果精确模式失败,尝试不指定 facingMode
try {
let fallbackStream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 30 } },
audio: false
})
this.mediaStream = fallbackStream
console.log('使用默认摄像头成功')
} catch (secondErr) {
console.error("摄像头获取失败", secondErr)
throw new Error("无法获取摄像头权限,请检查授权")
}
}
// 主视频:用于显示
const videoEl = this.$refs.localVideo
videoEl.srcObject = this.mediaStream
await videoEl.play()
this.cameraActive = true
this.cameraError = false
this.showPlaceholder = false
console.log('摄像头已启动,分辨率:', videoEl.videoWidth, 'x', videoEl.videoHeight)
},
async stopCamera() {
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop())
this.mediaStream = null
}
const videoEl = this.$refs.localVideo
if (videoEl) videoEl.srcObject = null
this.cameraActive = false
this.showPlaceholder = true
},
// 切换摄像头
async switchCamera() {
if (!this.isStreaming) {
console.log('未开启流,无法切换')
return
}
if (!this.hasMultipleCameras) {
alert('当前设备只有一个摄像头,无法切换')
return
}
// 保存当前的 streaming 状态和 WebSocket 连接状态
const wasStreaming = this.isStreaming
const wasWsConnected = this.wsConnected
// 临时停止采集(但保持 isStreaming 为 true,让 stopSync 不被调用)
this.stopCapture()
// 切换方向
const newFacingMode = this.currentFacingMode === 'environment' ? 'user' : 'environment'
this.currentFacingMode = newFacingMode
try {
// 重新启动摄像头(会自动停止旧的)
await this.startCamera()
// 恢复采集
if (wasStreaming && this.mediaStream && this.mediaStream.active) {
this.startCapture()
}
console.log(`摄像头已切换到: ${this.currentFacingMode === 'environment' ? '后置' : '前置'}`)
} catch (err) {
console.error('切换摄像头失败', err)
this.cameraError = true
alert('切换摄像头失败: ' + (err.message || '未知错误'))
// 切换失败,尝试恢复原摄像头
this.currentFacingMode = this.currentFacingMode === 'environment' ? 'user' : 'environment'
try {
await this.startCamera()
if (wasStreaming && this.mediaStream && this.mediaStream.active) {
this.startCapture()
}
} catch (recoverErr) {
console.error('恢复摄像头也失败', recoverErr)
// 完全失败,停止同步
this.stopSync()
}
}
},
/**
* 核心改进:使用 setInterval 而不是 requestAnimationFrame
* setInterval 不会阻塞主视频的渲染
*/
startCapture() {
if (this.captureInterval) {
clearInterval(this.captureInterval)
}
// 重置统计
this.frameCount = 0
this.lastFpsUpdate = Date.now()
// 每 16ms 采集一帧(约60fps),完全不阻塞主视频
this.captureInterval = setInterval(() => {
this.captureAndSend()
}, 16)
// FPS 统计
setInterval(() => {
if (this.isStreaming) {
this.currentFPS = this.frameCount
this.frameCount = 0
}
}, 1000)
},
captureAndSend() {
if (!this.isStreaming) return
if (!this.mediaStream || !this.mediaStream.active) {
this.stopSync()
return
}
const videoEl = this.$refs.localVideo
const vw = videoEl.videoWidth
const vh = videoEl.videoHeight
if (vw === 0 || vh === 0) return
// 调整画布大小
if (this.canvas.width !== vw || this.canvas.height !== vh) {
this.canvas.width = vw
this.canvas.height = vh
}
// 绘制当前帧
this.ctx.drawImage(videoEl, 0, 0, this.canvas.width, this.canvas.height)
// 统计帧数
this.frameCount++
// 检查 WebSocket 状态
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
return
}
// 可选:如果缓冲区太大,跳过这一帧
if (this.websocket.bufferedAmount > 512 * 1024) { // 512KB
return
}
// 异步发送,不等待
this.canvas.toBlob((blob) => {
if (blob && this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(blob)
}
}, 'image/jpeg', 0.6)
},
stopCapture() {
if (this.captureInterval) {
clearInterval(this.captureInterval)
this.captureInterval = null
}
},
async startSync() {
if (this.isStreaming) return
// 确保 WebSocket 连接
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
this.connectWebSocket()
await new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
clearInterval(checkInterval)
resolve()
}
}, 100)
setTimeout(() => {
clearInterval(checkInterval)
resolve()
}, 2000)
})
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
alert("WebSocket 连接失败,请检查服务端地址: " + this.wsUrl)
return
}
}
try {
await this.startCamera()
} catch (err) {
alert("无法获取摄像头: " + (err.message || "未知错误"))
this.cameraError = true
return
}
this.isStreaming = true
this.startCapture()
},
stopSync() {
if (!this.isStreaming) return
this.isStreaming = false
this.stopCapture()
this.stopCamera()
},
handlePageVisibilityChange() {
if (document.hidden && this.isStreaming) {
this.stopCapture()
} else if (!document.hidden && this.isStreaming && this.mediaStream && this.mediaStream.active) {
this.startCapture()
}
},
cleanup() {
this.stopCapture()
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(t => t.stop())
}
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.close()
}
document.removeEventListener('visibilitychange', this.handlePageVisibilityChange)
}
}
}
</script>
<style scoped>
/* 样式保持不变 */
.h5-container {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #0a0f1e;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
}
.container {
max-width: 600px;
width: 100%;
background: rgba(20, 28, 40, 0.8);
backdrop-filter: blur(8px);
border-radius: 32px;
box-shadow: 0 20px 35px -12px rgba(0, 0, 0, 0.5);
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
h1 {
font-size: 1.6rem;
text-align: center;
color: #fff;
margin-bottom: 8px;
letter-spacing: -0.3px;
}
.sub {
text-align: center;
color: #9ca3af;
font-size: 0.8rem;
margin-bottom: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 12px;
}
.video-wrapper {
background: #000;
border-radius: 24px;
overflow: hidden;
aspect-ratio: 4 / 3;
position: relative;
margin-bottom: 20px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.3);
}
video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: #111827;
color: #e5e7eb;
font-size: 1rem;
gap: 12px;
}
.status-bar {
background: #1f2937;
border-radius: 60px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
font-size: 0.85rem;
flex-wrap: wrap;
gap: 8px;
}
.status-badge {
display: flex;
align-items: center;
gap: 8px;
color: #e5e7eb;
}
.led {
width: 10px;
height: 10px;
border-radius: 50%;
background: #6b7280;
transition: all 0.2s;
}
.led.active {
background: #10b981;
box-shadow: 0 0 6px #10b981;
}
.led.warning {
background: #f59e0b;
}
.btn-group {
display: flex;
gap: 12px;
justify-content: center;
}
button {
border: none;
color: white;
font-weight: 600;
padding: 12px 24px;
border-radius: 40px;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
flex: 1;
max-width: 160px;
}
button.switch-camera {
background: #8b5cf6;
}
button:active {
transform: scale(0.96);
}
button.primary {
background: #3b82f6;
}
button.danger {
background: #dc2626;
}
button:disabled {
opacity: 0.5;
transform: none;
cursor: not-allowed;
}
.info-text {
text-align: center;
font-size: 0.7rem;
color: #9ca3af;
margin-top: 20px;
word-break: break-all;
}
@media (max-width: 500px) {
.container {
padding: 16px;
}
button {
padding: 10px 16px;
font-size: 0.9rem;
}
.btn-group {
flex-wrap: wrap;
}
button {
max-width: none;
flex: 1 1 auto;
}
}
</style>
PC页面
html
复制代码
<template>
<div class="pc-container">
<div class="receiver-container">
<div class="header">
<h1>手机摄像头实时画面</h1>
<div class="sub">
<span>📡 WebSocket 接收端 | 低延迟同步</span>
<span>📱 等待 H5 端推送视频流</span>
</div>
</div>
<div class="status-panel">
<div class="status-card">
<span class="led" :class="{ active: wsConnected, error: wsError }"></span>
<span class="stat-text">{{ wsStatusText }}</span>
</div>
<div class="status-card">
<span class="led" :class="{ active: receivingStream }"></span>
<span class="stat-text">{{ streamStatusText }}</span>
</div>
<div class="connection-info" @click="changeWsUrl">
🔌 服务地址: {{ wsUrl }}
</div>
<button @click="manualReconnect">🔄 重连服务</button>
</div>
<div class="video-area">
<div class="video-wall" :class="{ receiving: receivingStream }">
<img ref="remoteVideo" alt="手机摄像头实时画面">
</div>
<div class="placeholder-overlay" :class="{ hidden: receivingStream }">
<div class="spinner"></div>
<div style="font-size:1.2rem; font-weight:500;">等待手机端连接...</div>
<div style="font-size:0.85rem; opacity:0.7;">请确保 H5 页面已启动摄像头并推流</div>
</div>
</div>
<div class="info-footer">
<div>✨ 说明: 接收手机通过 WebSocket 发送的 JPEG 帧,实时渲染画面</div>
<div>⚡ 帧率: {{ currentFps }} fps</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'PCView',
data() {
return {
wsUrl: 'ws://192.168.20.156:3000/ws',
websocket: null,
wsConnected: false,
wsError: false,
receivingStream: false,
receivedFramesCount: 0,
lastFpsUpdate: 0,
currentFps: 0,
pendingObjectUrl: null,
fpsInterval: null
}
},
computed: {
wsStatusText() {
if (this.wsError) return '连接错误,请检查服务端'
return this.wsConnected ? 'WebSocket 已连接 ✅' : 'WebSocket 未连接 🔌'
},
streamStatusText() {
return this.receivingStream ? '接收视频流中 🟢' : '未收到视频流 ⚪'
}
},
mounted() {
this.init()
},
beforeDestroy() {
this.cleanup()
},
methods: {
init() {
this.lastFpsUpdate = performance.now()
this.startFpsMonitor()
this.connectWebSocket()
},
startFpsMonitor() {
const updateFps = () => {
const now = performance.now()
const elapsed = (now - this.lastFpsUpdate) / 1000
if (elapsed >= 1.0) {
this.currentFps = Math.round(this.receivedFramesCount / elapsed)
this.receivedFramesCount = 0
this.lastFpsUpdate = now
}
this.fpsInterval = requestAnimationFrame(updateFps)
}
this.fpsInterval = requestAnimationFrame(updateFps)
},
displayFrame(blob) {
if (!blob || blob.size === 0) return
if (this.pendingObjectUrl) {
URL.revokeObjectURL(this.pendingObjectUrl)
}
const url = URL.createObjectURL(blob)
this.pendingObjectUrl = url
this.$refs.remoteVideo.src = url
this.receivingStream = true
this.receivedFramesCount++
// 2秒后如果没有新帧,重置状态
clearTimeout(this.streamTimeout)
this.streamTimeout = setTimeout(() => {
this.receivingStream = false
}, 2000)
},
connectWebSocket() {
if (this.websocket && (this.websocket.readyState === WebSocket.OPEN || this.websocket.readyState === WebSocket.CONNECTING)) {
return
}
try {
this.websocket = new WebSocket(this.wsUrl)
this.websocket.onopen = () => {
console.log('[PC] WebSocket 连接成功', this.wsUrl)
this.wsConnected = true
this.wsError = false
this.receivedFramesCount = 0
// 发送身份标识
this.websocket.send(JSON.stringify({ type: 'identify', role: 'viewer' }))
}
this.websocket.onmessage = (event) => {
if (event.data instanceof Blob) {
this.displayFrame(event.data)
} else if (event.data instanceof ArrayBuffer) {
const blob = new Blob([event.data], { type: 'image/jpeg' })
this.displayFrame(blob)
} else if (typeof event.data === 'string') {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'pong') {
// 心跳响应
}
} catch (e) { }
}
}
this.websocket.onclose = () => {
console.warn('[PC] WebSocket 断开')
this.wsConnected = false
this.wsError = true
this.receivingStream = false
setTimeout(() => {
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
this.connectWebSocket()
}
}, 3000)
}
this.websocket.onerror = () => {
console.error('[PC] WebSocket 错误')
this.wsConnected = false
this.wsError = true
}
} catch (e) {
console.error('[PC] 连接异常', e)
this.wsError = true
}
},
manualReconnect() {
if (this.websocket) {
this.websocket.close()
}
this.connectWebSocket()
},
changeWsUrl() {
let newUrl = prompt('请输入 WebSocket 服务地址', this.wsUrl)
if (newUrl && newUrl.trim()) {
this.wsUrl = newUrl.trim()
this.manualReconnect()
}
},
cleanup() {
if (this.fpsInterval) {
cancelAnimationFrame(this.fpsInterval)
}
if (this.websocket) {
this.websocket.close()
}
if (this.pendingObjectUrl) {
URL.revokeObjectURL(this.pendingObjectUrl)
}
if (this.streamTimeout) {
clearTimeout(this.streamTimeout)
}
}
}
}
</script>
<style scoped>
.pc-container {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1e2c 0%, #0f1119 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
}
.receiver-container {
max-width: 1200px;
width: 100%;
background: rgba(18, 22, 35, 0.85);
backdrop-filter: blur(12px);
border-radius: 48px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.15);
overflow: hidden;
}
.header {
padding: 24px 32px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.3);
}
h1 {
font-size: 1.8rem;
font-weight: 600;
background: linear-gradient(135deg, #E0E7FF, #A5B4FC);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
display: flex;
align-items: center;
gap: 12px;
}
h1::before {
content: "🖥️";
font-size: 1.8rem;
background: none;
-webkit-background-clip: unset;
color: #8b5cf6;
}
.sub {
color: #9ca3af;
margin-top: 8px;
font-size: 0.9rem;
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.status-panel {
display: flex;
gap: 16px;
padding: 16px 32px;
background: rgba(0, 0, 0, 0.4);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
}
.status-card {
display: flex;
align-items: center;
gap: 12px;
background: rgba(31, 41, 55, 0.6);
padding: 8px 20px;
border-radius: 60px;
}
.led {
width: 12px;
height: 12px;
border-radius: 50%;
background: #6b7280;
transition: all 0.2s;
}
.led.active {
background: #10b981;
box-shadow: 0 0 8px #10b981;
}
.led.error {
background: #ef4444;
box-shadow: 0 0 6px #ef4444;
}
.stat-text {
font-weight: 500;
color: #e5e7eb;
font-size: 0.9rem;
}
.connection-info {
font-family: monospace;
font-size: 0.8rem;
background: #1e1f2c;
padding: 6px 14px;
border-radius: 20px;
color: #a5b4fc;
cursor: pointer;
}
button {
background: #3b82f6;
border: none;
color: white;
padding: 8px 20px;
border-radius: 40px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-size: 0.85rem;
}
button:hover {
background: #2563eb;
transform: scale(1.02);
}
.video-area {
padding: 32px;
position: relative;
}
.video-wall {
background: #000000;
border-radius: 32px;
overflow: hidden;
box-shadow: 0 20px 35px -12px black;
border: 2px solid rgba(139, 92, 246, 0.4);
transition: border-color 0.2s;
}
.video-wall.receiving {
border-color: #10b981;
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
}
.video-wall img {
width: 100%;
display: block;
background: #0a0a0a;
aspect-ratio: 16 / 9;
object-fit: contain;
}
.placeholder-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
color: #e2e8f0;
border-radius: 32px;
margin: 32px;
transition: opacity 0.2s;
}
.placeholder-overlay.hidden {
opacity: 0;
visibility: hidden;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(139, 92, 246, 0.3);
border-top: 4px solid #8b5cf6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.info-footer {
padding: 20px 32px;
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(255, 255, 255, 0.08);
font-size: 0.75rem;
color: #6c7283;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
@media (max-width: 768px) {
.receiver-container {
border-radius: 32px;
}
.header {
padding: 20px;
}
h1 {
font-size: 1.4rem;
}
.video-area {
padding: 20px;
}
.status-panel {
padding: 12px 20px;
}
}
</style>
WebSocket服务
javascript
复制代码
const WebSocket = require('ws');
const http = require('http');
// 配置 - 改为 3000 端口,避免与 Vue 开发服务器冲突
const PORT = 3000;
const WS_PATH = '/ws';
// 创建 HTTP 服务器
const server = http.createServer((req, res) => {
// 简单的健康检查端点
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', timestamp: new Date().toISOString() }));
return;
}
// API 端点获取统计信息
if (req.url === '/api/stats') {
const stats = getStats();
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(JSON.stringify(stats));
return;
}
// 提供简单的状态页面
if (req.url === '/' || req.url === '/status') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>WebSocket 视频中继服务</title>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; margin: 40px; background: #0a0f1e; color: #e5e7eb; }
.container { max-width: 800px; margin: 0 auto; background: #1f2937; padding: 30px; border-radius: 16px; }
h1 { color: #8b5cf6; }
.status { padding: 15px; background: #111827; border-radius: 8px; margin: 20px 0; }
.online { color: #10b981; font-weight: bold; }
.info { color: #9ca3af; }
.client-list { background: #111827; padding: 15px; border-radius: 8px; font-family: monospace; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<h1>📡 WebSocket 视频中继服务器</h1>
<div class="status">
<div>🟢 服务状态: <span class="online">运行中</span></div>
<div>🔌 端口: ${PORT}</div>
<div>📡 WebSocket 地址: ws://192.168.20.156:${PORT}${WS_PATH}</div>
<div>📱 H5 推流端数量: <span id="publisherCount">0</span></div>
<div>🖥️ PC 接收端数量: <span id="viewerCount">0</span></div>
<div>👥 总连接数: <span id="totalCount">0</span></div>
</div>
<div class="client-list" id="clientList">
等待客户端连接...
</div>
<div class="info">
<p>✨ 说明: H5 手机端推送视频帧,服务器自动转发给所有 PC 接收端</p>
<p>💡 提示: 确保手机和 PC 在同一局域网</p>
</div>
</div>
<script>
async function updateStatus() {
try {
const res = await fetch('/api/stats');
const stats = await res.json();
document.getElementById('publisherCount').innerText = stats.publishers;
document.getElementById('viewerCount').innerText = stats.viewers;
document.getElementById('totalCount').innerText = stats.total;
const clientListDiv = document.getElementById('clientList');
if (stats.clients && stats.clients.length > 0) {
clientListDiv.innerHTML = stats.clients.map(c =>
'<div>🔹 ' + c.type + ' - ' + c.id + '</div>'
).join('');
} else {
clientListDiv.innerHTML = '暂无客户端连接';
}
} catch(e) {}
}
updateStatus();
setInterval(updateStatus, 2000);
</script>
</body>
</html>
`);
return;
}
res.writeHead(404);
res.end('Not Found');
});
// 创建 WebSocket 服务器
const wss = new WebSocket.Server({ server, path: WS_PATH });
// 存储所有客户端连接
const clients = new Map();
let clientIdCounter = 0;
function getStats() {
let publishers = 0;
let viewers = 0;
const clientInfo = [];
for (const [, info] of clients.entries()) {
if (info.type === 'publisher') publishers++;
if (info.type === 'viewer') viewers++;
clientInfo.push({
id: info.id,
type: info.type === 'publisher' ? '📱 推流端(H5)' : info.type === 'viewer' ? '🖥️ 接收端(PC)' : '❓ 未知'
});
}
return { publishers, viewers, total: clients.size, clients: clientInfo };
}
wss.on('connection', (ws, req) => {
const clientId = `client_${++clientIdCounter}`;
const remoteAddress = req.socket.remoteAddress;
let clientType = 'unknown';
console.log(`\n[新连接] ID: ${clientId}, 地址: ${remoteAddress}`);
console.log(`当前连接数: ${clients.size + 1}`);
clients.set(ws, { id: clientId, type: clientType, remoteAddress });
// 发送欢迎消息
ws.send(JSON.stringify({
type: 'welcome',
clientId: clientId,
message: '已连接到视频中继服务器'
}));
// ws.on('message', (data) => {
// if (typeof data === 'string') {
// try {
// const msg = JSON.parse(data);
// console.log(`[消息] ${clientId}: ${msg.type} - role: ${msg.role || '无'}`);
// if (msg.type === 'identify') {
// if (msg.role === 'publisher') {
// clientType = 'publisher';
// console.log(`✅ [识别] ${clientId} 设置为推流端(H5)`);
// } else if (msg.role === 'viewer') {
// clientType = 'viewer';
// console.log(`✅ [识别] ${clientId} 设置为接收端(PC)`);
// }
// clients.set(ws, { id: clientId, type: clientType, remoteAddress });
// ws.send(JSON.stringify({
// type: 'identified',
// role: clientType,
// success: true
// }));
// }
// } catch (e) {
// console.log(`[解析错误] ${e.message}`);
// }
// } else {
// // 二进制数据 - 视频帧
// const clientInfo = clients.get(ws);
// if (clientInfo && clientInfo.type === 'publisher') {
// // 转发给所有接收端
// let sentCount = 0;
// for (const [clientWs, info] of clients.entries()) {
// if (info.type === 'viewer' && clientWs.readyState === WebSocket.OPEN) {
// clientWs.send(data);
// sentCount++;
// }
// }
// if (sentCount > 0 && Math.random() < 0.05) {
// console.log(`[转发] 推流端 → ${sentCount} 个接收端`);
// }
// } else if (clientInfo && clientInfo.type === 'unknown') {
// // 自动将发送视频帧的 unknown 客户端设置为推流端
// console.log(`🔄 [自动识别] ${clientId} 发送视频帧,自动设置为推流端`);
// clientInfo.type = 'publisher';
// clients.set(ws, clientInfo);
// // 转发给所有接收端
// let sentCount = 0;
// for (const [clientWs, info] of clients.entries()) {
// if (info.type === 'viewer' && clientWs.readyState === WebSocket.OPEN) {
// clientWs.send(data);
// sentCount++;
// }
// }
// if (sentCount > 0) {
// console.log(`[转发] 新推流端 → ${sentCount} 个接收端`);
// }
// } else if (clientInfo && clientInfo.type !== 'publisher') {
// console.log(`[警告] ${clientId} (${clientInfo.type}) 尝试发送视频帧,已忽略`);
// }
// }
// });
ws.on('message', (data, isBinary) => {
// isBinary 参数表示是否是二进制数据
if (!isBinary) {
// 文本消息
const msgStr = data.toString();
try {
const msg = JSON.parse(msgStr);
console.log(`[消息] ${clientId}: ${msg.type} - role: ${msg.role || '无'}`);
if (msg.type === 'identify') {
if (msg.role === 'publisher') {
clientType = 'publisher';
console.log(`✅ [识别] ${clientId} 设置为推流端(H5)`);
} else if (msg.role === 'viewer') {
clientType = 'viewer';
console.log(`✅ [识别] ${clientId} 设置为接收端(PC)`);
}
// 更新 clients Map 中的客户端信息
clients.set(ws, { id: clientId, type: clientType, remoteAddress });
ws.send(JSON.stringify({
type: 'identified',
role: clientType,
success: true
}));
}
} catch (e) {
console.log(`[解析错误] ${e.message}`);
}
} else {
// 二进制数据 - 视频帧
const clientInfo = clients.get(ws);
if (clientInfo && clientInfo.type === 'publisher') {
// 转发给所有接收端
let sentCount = 0;
for (const [clientWs, info] of clients.entries()) {
if (info.type === 'viewer' && clientWs.readyState === WebSocket.OPEN) {
clientWs.send(data);
sentCount++;
}
}
if (sentCount > 0 && Math.random() < 0.05) {
console.log(`[转发] 推流端 → ${sentCount} 个接收端`);
}
} else if (clientInfo && clientInfo.type === 'unknown') {
// 自动将发送视频帧的 unknown 客户端设置为推流端
console.log(`🔄 [自动识别] ${clientId} 发送视频帧,自动设置为推流端`);
clientInfo.type = 'publisher';
clients.set(ws, clientInfo);
// 转发给所有接收端
let sentCount = 0;
for (const [clientWs, info] of clients.entries()) {
if (info.type === 'viewer' && clientWs.readyState === WebSocket.OPEN) {
clientWs.send(data);
sentCount++;
}
}
if (sentCount > 0) {
console.log(`[转发] 新推流端 → ${sentCount} 个接收端`);
}
} else if (clientInfo && clientInfo.type !== 'publisher') {
console.log(`[警告] ${clientId} (${clientInfo?.type}) 尝试发送视频帧,已忽略`);
}
}
});
ws.on('close', () => {
const info = clients.get(ws);
console.log(`[断开] ${info?.id || 'unknown'}, 类型: ${info?.type || 'unknown'}`);
clients.delete(ws);
console.log(`当前连接数: ${clients.size}`);
});
ws.on('error', (error) => {
console.error(`[错误] ${clientId}:`, error.message);
clients.delete(ws);
});
});
// 定期打印统计信息
setInterval(() => {
const stats = getStats();
console.log(`\n📊 统计: 推流端=${stats.publishers}, 接收端=${stats.viewers}, 总计=${stats.total}\n`);
}, 30000);
// 启动服务器
server.listen(PORT, '0.0.0.0', () => {
console.log('\n========================================');
console.log('🚀 WebSocket 视频中继服务器已启动');
console.log('========================================');
console.log(`📡 状态页面: http://192.168.20.156:${PORT}`);
console.log(`🔌 WebSocket 地址: ws://192.168.20.156:${PORT}${WS_PATH}`);
console.log('========================================');
console.log('按 Ctrl+C 停止服务\n');
});
process.on('SIGINT', () => {
console.log('\n正在关闭服务器...');
for (const [ws] of clients.entries()) {
ws.close();
}
server.close(() => {
console.log('服务器已关闭');
process.exit(0);
});
});