前端开发攻略---H5页面手机获取摄像头权限回显出画面并且同步到PC页面

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);
    });
});
相关推荐
早起傻一天~G2 小时前
vue2+element-UI表格封装
javascript·vue.js·ui
这儿有一堆花2 小时前
深入解析 Video.js:现代 Web 视频播放的工程实践
前端·javascript·音视频
烤麻辣烫2 小时前
JS基础
开发语言·前端·javascript·学习
猫猫不是喵喵.4 小时前
layui表单项次大数据量导入并提交
前端·javascript·layui
Hello--_--World5 小时前
ES13:类私有属性和方法、顶层 await、at() 方法、Object.hasOwnProperty()、类静态块 相关知识点
开发语言·javascript·es13
comerzhang6555 小时前
Web 性能的架构边界:跨线程信令通道的确定性分析
javascript·webassembly
zhensherlock6 小时前
Protocol Launcher 系列:Overcast 一键订阅播客
前端·javascript·typescript·node.js·自动化·github·js
px不是xp7 小时前
DeepSeek API集成:让小程序拥有AI大脑
javascript·人工智能·小程序
小汪说干货8 小时前
2026年4月最新|公众号文章插入文档附件3种技术方案
javascript·小程序