diff --git a/admin/src/utils/liveWebRTC.js b/admin/src/utils/liveWebRTC.js index dacd96d..3686f6a 100644 --- a/admin/src/utils/liveWebRTC.js +++ b/admin/src/utils/liveWebRTC.js @@ -16,6 +16,33 @@ function liveWsURLPublish(token) { const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }] +/** 将 getUserMedia 异常转为中文说明(含 Edge 英文 “Could not start video source”) */ +function humanizeGetUserMediaError(err) { + const name = err && err.name + const raw = ((err && err.message) || '').toLowerCase() + if (name === 'NotAllowedError' || name === 'PermissionDeniedError') { + return '已拒绝摄像头权限:在浏览器地址栏左侧允许摄像头,并确认本页为 HTTPS。' + } + if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { + return '未检测到摄像头,请检查是否已接入设备或被系统禁用。' + } + if ( + name === 'NotReadableError' || + raw.includes('could not start video source') || + raw.includes('failed to start video source') || + raw.includes('video source') + ) { + return '无法启动摄像头:可能被 Zoom、Teams、腾讯会议、OBS 等占用;或在 Windows「设置 → 隐私和安全性 → 相机」中未允许浏览器/桌面应用访问。请关闭占用软件后刷新页面重试。' + } + if (name === 'OverconstrainedError') { + return '摄像头不满足当前参数约束,请换用其他摄像头或更新驱动。' + } + if (name === 'AbortError') { + return '打开摄像头被系统中断,请重试。' + } + return (err && err.message) || '无法打开摄像头' +} + /** * @param {object} opts * @param {string} opts.token 管理员 JWT @@ -47,10 +74,8 @@ export function startPublishing(opts = {}) { ws.onopen = async () => { onStatus('信令已连接,正在采集摄像头…') try { - stream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: 'user' }, - audio: false - }) + // 后台多为 PC:不要用 facingMode:'user',部分机器会直接导致 “Could not start video source” + stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }) onLocalStream(stream) stream.getTracks().forEach((t) => pc.addTrack(t, stream)) const offer = await pc.createOffer() @@ -58,14 +83,7 @@ export function startPublishing(opts = {}) { send({ type: 'offer', sdp: offer.sdp }) onStatus('已发起推流协商,等待服务端应答…') } catch (err) { - const name = err && err.name - let tip = err.message || '无法打开摄像头' - if (name === 'NotAllowedError' || name === 'PermissionDeniedError') { - tip = '已拒绝摄像头权限:请在浏览器地址栏允许摄像头,并确认本页为 HTTPS。' - } else if (name === 'NotFoundError') { - tip = '未检测到摄像头设备。' - } - onStatus(tip) + onStatus(humanizeGetUserMediaError(err)) stop() } } diff --git a/admin/src/views/sites/LiveBroadcast.vue b/admin/src/views/sites/LiveBroadcast.vue index ff2b61f..c4c5fd2 100644 --- a/admin/src/views/sites/LiveBroadcast.vue +++ b/admin/src/views/sites/LiveBroadcast.vue @@ -6,7 +6,8 @@

仅后台可开播:推流后,官网首页左上角以画中画自动展示,用户也可打开官网「直播」全屏页观看。需站点使用 - HTTPS;公网复杂网络请在服务端配置 LIVE_ICE_SERVERS(含 TURN)。 + HTTPS。公网若观众端黑屏但信令正常,请在 API 服务环境变量中设置 + LIVE_PUBLIC_IP(服务器公网 IPv4,与域名一致),并配置 LIVE_ICE_SERVERS(含 TURN)。

{{ status }}

diff --git a/server/pkg/weblive/config.go b/server/pkg/weblive/config.go index 29dc3f7..00d93f9 100644 --- a/server/pkg/weblive/config.go +++ b/server/pkg/weblive/config.go @@ -9,12 +9,30 @@ import ( ) // MediaEngine 与 API 构建(全局复用,避免重复注册编解码器) +// 部署在 Docker/NAT 后若观众端黑屏、信令正常,请设置 LIVE_PUBLIC_IP 为本机公网 IPv4(与域名解析一致,可逗号分隔多个)。 func buildAPI() (*webrtc.API, error) { m := &webrtc.MediaEngine{} if err := m.RegisterDefaultCodecs(); err != nil { return nil, err } - api := webrtc.NewAPI(webrtc.WithMediaEngine(m)) + se := webrtc.SettingEngine{} + if raw := strings.TrimSpace(os.Getenv("LIVE_PUBLIC_IP")); raw != "" { + parts := strings.Split(raw, ",") + var ips []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + ips = append(ips, p) + } + } + if len(ips) > 0 { + se.SetNAT1To1IPs(ips, webrtc.ICECandidateTypeHost) + } + } + api := webrtc.NewAPI( + webrtc.WithMediaEngine(m), + webrtc.WithSettingEngine(se), + ) return api, nil } diff --git a/web/src/utils/liveWebRTC.js b/web/src/utils/liveWebRTC.js index eb9caa6..2af045c 100644 --- a/web/src/utils/liveWebRTC.js +++ b/web/src/utils/liveWebRTC.js @@ -43,6 +43,40 @@ export function startViewing(videoEl, opts = {}) { let ws = null let pollTimer = null let stopped = false + let blackFrameTimer = null + + function clearBlackFrameTimer() { + if (blackFrameTimer) { + clearTimeout(blackFrameTimer) + blackFrameTimer = null + } + } + + function attachIceDiagnostics(peer) { + peer.onconnectionstatechange = () => { + if (stopped) return + if (peer.connectionState === 'failed') { + onStatus( + '媒体连接失败(ICE):服务器若在 Docker/内网,请在环境变量中设置 LIVE_PUBLIC_IP=公网IPv4,并在 LIVE_ICE_SERVERS 配置 TURN。' + ) + } + } + } + + function scheduleBlackFrameHint(peer) { + clearBlackFrameTimer() + blackFrameTimer = setTimeout(() => { + blackFrameTimer = null + if (stopped || !videoEl) return + const hasStream = Boolean(videoEl.srcObject) + const noFrame = videoEl.videoWidth === 0 + if (hasStream && noFrame && peer.connectionState === 'connected') { + onStatus( + '已连接仍无画面:请确认后台正在推流;公网部署请配置服务端 LIVE_PUBLIC_IP 或 TURN(LIVE_ICE_SERVERS)。' + ) + } + }, 6000) + } async function negotiate() { if (stopped) return @@ -65,6 +99,7 @@ export function startViewing(videoEl, opts = {}) { while (icePending.length) { ws.send(icePending.shift()) } + attachIceDiagnostics(pc) ws.onmessage = async (ev) => { let msg try { @@ -75,6 +110,7 @@ export function startViewing(videoEl, opts = {}) { if (msg.type === 'answer' && msg.sdp) { await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp }) onStatus('正在播放…') + scheduleBlackFrameHint(pc) } if (msg.type === 'ice' && msg.candidate) { try { @@ -82,6 +118,7 @@ export function startViewing(videoEl, opts = {}) { } catch (_) {} } if (msg.type === 'ended') { + clearBlackFrameTimer() onStatus(msg.message || '直播已结束') if (videoEl) videoEl.srcObject = null onEnded() @@ -136,6 +173,7 @@ export function startViewing(videoEl, opts = {}) { function stop() { stopped = true + clearBlackFrameTimer() if (pollTimer) clearInterval(pollTimer) try { ws?.close() diff --git a/web/src/views/LiveRoom.vue b/web/src/views/LiveRoom.vue index d644cb2..18983bd 100644 --- a/web/src/views/LiveRoom.vue +++ b/web/src/views/LiveRoom.vue @@ -10,7 +10,8 @@

本站直播(WebRTC)

- 仅管理后台 → 视频直播开播(需首页编辑权限)可推流;开播后全站左上角画中画自动播放,本页为大屏观看。单房间、进程内转发;公网建议配置服务端 + 仅管理后台 → 视频直播开播可推流;开播后全站画中画与本页播放。若一直黑屏但状态显示「正在播放」,多为 ICE 未通:服务端需配置 + LIVE_PUBLIC_IP(公网 IP)与 LIVE_ICE_SERVERS(含 TURN)。

{{ watchStatus }}