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 }}