直播:LIVE_PUBLIC_IP 与 ICE 诊断;摄像头错误中文提示与约束放宽
Made-with: Cursor
This commit is contained in:
@@ -16,6 +16,33 @@ function liveWsURLPublish(token) {
|
|||||||
|
|
||||||
const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }]
|
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 {object} opts
|
||||||
* @param {string} opts.token 管理员 JWT
|
* @param {string} opts.token 管理员 JWT
|
||||||
@@ -47,10 +74,8 @@ export function startPublishing(opts = {}) {
|
|||||||
ws.onopen = async () => {
|
ws.onopen = async () => {
|
||||||
onStatus('信令已连接,正在采集摄像头…')
|
onStatus('信令已连接,正在采集摄像头…')
|
||||||
try {
|
try {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
// 后台多为 PC:不要用 facingMode:'user',部分机器会直接导致 “Could not start video source”
|
||||||
video: { facingMode: 'user' },
|
stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false })
|
||||||
audio: false
|
|
||||||
})
|
|
||||||
onLocalStream(stream)
|
onLocalStream(stream)
|
||||||
stream.getTracks().forEach((t) => pc.addTrack(t, stream))
|
stream.getTracks().forEach((t) => pc.addTrack(t, stream))
|
||||||
const offer = await pc.createOffer()
|
const offer = await pc.createOffer()
|
||||||
@@ -58,14 +83,7 @@ export function startPublishing(opts = {}) {
|
|||||||
send({ type: 'offer', sdp: offer.sdp })
|
send({ type: 'offer', sdp: offer.sdp })
|
||||||
onStatus('已发起推流协商,等待服务端应答…')
|
onStatus('已发起推流协商,等待服务端应答…')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const name = err && err.name
|
onStatus(humanizeGetUserMediaError(err))
|
||||||
let tip = err.message || '无法打开摄像头'
|
|
||||||
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
|
|
||||||
tip = '已拒绝摄像头权限:请在浏览器地址栏允许摄像头,并确认本页为 HTTPS。'
|
|
||||||
} else if (name === 'NotFoundError') {
|
|
||||||
tip = '未检测到摄像头设备。'
|
|
||||||
}
|
|
||||||
onStatus(tip)
|
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
</template>
|
</template>
|
||||||
<p class="tip">
|
<p class="tip">
|
||||||
仅后台可开播:推流后,官网首页左上角以<strong>画中画</strong>自动展示,用户也可打开官网「直播」全屏页观看。需站点使用
|
仅后台可开播:推流后,官网首页左上角以<strong>画中画</strong>自动展示,用户也可打开官网「直播」全屏页观看。需站点使用
|
||||||
<strong>HTTPS</strong>;公网复杂网络请在服务端配置 <code>LIVE_ICE_SERVERS</code>(含 TURN)。
|
<strong>HTTPS</strong>。公网若观众端<strong>黑屏但信令正常</strong>,请在 API 服务环境变量中设置
|
||||||
|
<code>LIVE_PUBLIC_IP</code>(服务器公网 IPv4,与域名一致),并配置 <code>LIVE_ICE_SERVERS</code>(含 TURN)。
|
||||||
</p>
|
</p>
|
||||||
<p class="status">{{ status }}</p>
|
<p class="status">{{ status }}</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
|||||||
@@ -9,12 +9,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// MediaEngine 与 API 构建(全局复用,避免重复注册编解码器)
|
// MediaEngine 与 API 构建(全局复用,避免重复注册编解码器)
|
||||||
|
// 部署在 Docker/NAT 后若观众端黑屏、信令正常,请设置 LIVE_PUBLIC_IP 为本机公网 IPv4(与域名解析一致,可逗号分隔多个)。
|
||||||
func buildAPI() (*webrtc.API, error) {
|
func buildAPI() (*webrtc.API, error) {
|
||||||
m := &webrtc.MediaEngine{}
|
m := &webrtc.MediaEngine{}
|
||||||
if err := m.RegisterDefaultCodecs(); err != nil {
|
if err := m.RegisterDefaultCodecs(); err != nil {
|
||||||
return nil, err
|
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
|
return api, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,40 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
let ws = null
|
let ws = null
|
||||||
let pollTimer = null
|
let pollTimer = null
|
||||||
let stopped = false
|
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() {
|
async function negotiate() {
|
||||||
if (stopped) return
|
if (stopped) return
|
||||||
@@ -65,6 +99,7 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
while (icePending.length) {
|
while (icePending.length) {
|
||||||
ws.send(icePending.shift())
|
ws.send(icePending.shift())
|
||||||
}
|
}
|
||||||
|
attachIceDiagnostics(pc)
|
||||||
ws.onmessage = async (ev) => {
|
ws.onmessage = async (ev) => {
|
||||||
let msg
|
let msg
|
||||||
try {
|
try {
|
||||||
@@ -75,6 +110,7 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
if (msg.type === 'answer' && msg.sdp) {
|
if (msg.type === 'answer' && msg.sdp) {
|
||||||
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp })
|
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp })
|
||||||
onStatus('正在播放…')
|
onStatus('正在播放…')
|
||||||
|
scheduleBlackFrameHint(pc)
|
||||||
}
|
}
|
||||||
if (msg.type === 'ice' && msg.candidate) {
|
if (msg.type === 'ice' && msg.candidate) {
|
||||||
try {
|
try {
|
||||||
@@ -82,6 +118,7 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
if (msg.type === 'ended') {
|
if (msg.type === 'ended') {
|
||||||
|
clearBlackFrameTimer()
|
||||||
onStatus(msg.message || '直播已结束')
|
onStatus(msg.message || '直播已结束')
|
||||||
if (videoEl) videoEl.srcObject = null
|
if (videoEl) videoEl.srcObject = null
|
||||||
onEnded()
|
onEnded()
|
||||||
@@ -136,6 +173,7 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
stopped = true
|
stopped = true
|
||||||
|
clearBlackFrameTimer()
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
try {
|
try {
|
||||||
ws?.close()
|
ws?.close()
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
<section class="live-block" aria-label="本站直播">
|
<section class="live-block" aria-label="本站直播">
|
||||||
<h2 class="live-block-title">本站直播(WebRTC)</h2>
|
<h2 class="live-block-title">本站直播(WebRTC)</h2>
|
||||||
<p class="live-block-desc">
|
<p class="live-block-desc">
|
||||||
仅<strong>管理后台 → 视频直播开播</strong>(需首页编辑权限)可推流;开播后全站左上角画中画自动播放,本页为大屏观看。单房间、进程内转发;公网建议配置服务端
|
仅<strong>管理后台 → 视频直播开播</strong>可推流;开播后全站画中画与本页播放。若<strong>一直黑屏</strong>但状态显示「正在播放」,多为 ICE 未通:服务端需配置
|
||||||
|
<code style="color: #7ee0ff">LIVE_PUBLIC_IP</code>(公网 IP)与
|
||||||
<code style="color: #7ee0ff">LIVE_ICE_SERVERS</code>(含 TURN)。
|
<code style="color: #7ee0ff">LIVE_ICE_SERVERS</code>(含 TURN)。
|
||||||
</p>
|
</p>
|
||||||
<p class="live-watch-status">{{ watchStatus }}</p>
|
<p class="live-watch-status">{{ watchStatus }}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user