修复开播卡正在连接:移除未定义 quality;画质改官网选择+localStorage

Made-with: Cursor
This commit is contained in:
whm
2026-03-26 10:22:00 +08:00
parent 106e6e1f16
commit 10a842b4ef
4 changed files with 111 additions and 45 deletions

View File

@@ -1,14 +1,10 @@
/** /**
* 管理后台 WebRTC 开播(需登录 token与 /api/web/live/ws?role=publish&token= 一致) * 管理后台 WebRTC 开播(需登录 token与 /api/web/live/ws?role=publish&token= 一致)
* 画质由官网 /live 写入 localStorageyh_live_capture_quality同浏览器开播时生效默认原画约束最少。
*/ */
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '') const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
export const LIVE_QUALITY_OPTIONS = [ const LIVE_CAPTURE_QUALITY_STORAGE_KEY = 'yh_live_capture_quality'
{ value: 'source', label: '原画(设备默认)' },
{ value: 'high', label: '高清 720p' },
{ value: 'mid', label: '标清 480p' },
{ value: 'low', label: '流畅 360p' }
]
const QUALITY_MEDIA = { const QUALITY_MEDIA = {
source: { video: true, audio: true }, source: { video: true, audio: true },
@@ -38,8 +34,16 @@ const QUALITY_MEDIA = {
} }
} }
function liveWsURLPublish(token, quality) { function effectivePublishQualityKey() {
const q = QUALITY_MEDIA[quality] ? quality : 'high' try {
const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY)
if (v && QUALITY_MEDIA[v]) return v
} catch (_) {}
return 'source'
}
function liveWsURLPublish(token) {
const q = effectivePublishQualityKey()
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}&quality=${encodeURIComponent(q)}` const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}&quality=${encodeURIComponent(q)}`
if (apiBase) { if (apiBase) {
const base = apiBase.replace(/\/$/, '') const base = apiBase.replace(/\/$/, '')
@@ -52,7 +56,6 @@ function liveWsURLPublish(token, quality) {
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) { function humanizeGetUserMediaError(err) {
const name = err && err.name const name = err && err.name
const raw = ((err && err.message) || '').toLowerCase() const raw = ((err && err.message) || '').toLowerCase()
@@ -71,7 +74,7 @@ function humanizeGetUserMediaError(err) {
return '无法启动摄像头:可能被 Zoom、Teams、腾讯会议、OBS 等占用;或在 Windows「设置 → 隐私和安全性 → 相机」中未允许浏览器/桌面应用访问。请关闭占用软件后刷新页面重试。' return '无法启动摄像头:可能被 Zoom、Teams、腾讯会议、OBS 等占用;或在 Windows「设置 → 隐私和安全性 → 相机」中未允许浏览器/桌面应用访问。请关闭占用软件后刷新页面重试。'
} }
if (name === 'OverconstrainedError') { if (name === 'OverconstrainedError') {
return '摄像头不满足当前参数约束,请换用其他摄像头或更新驱动。' return '摄像头不满足当前参数约束:请到官网「直播」页换一档画质(或选原画)后再开播。'
} }
if (name === 'AbortError') { if (name === 'AbortError') {
return '打开摄像头被系统中断,请重试。' return '打开摄像头被系统中断,请重试。'
@@ -84,25 +87,19 @@ function humanizeGetUserMediaError(err) {
* @param {string} opts.token 管理员 JWT * @param {string} opts.token 管理员 JWT
* @param {(s: string) => void} [opts.onStatus] * @param {(s: string) => void} [opts.onStatus]
* @param {(stream: MediaStream) => void} [opts.onLocalStream] * @param {(stream: MediaStream) => void} [opts.onLocalStream]
* @param {'source'|'high'|'mid'|'low'} [opts.quality] 推流画质(约束摄像头采集分辨率)
*/ */
export function startPublishing(opts = {}) { export function startPublishing(opts = {}) {
const { const { token = '', onStatus = () => {}, onLocalStream = () => {} } = opts
token = '',
quality = 'high',
onStatus = () => {},
onLocalStream = () => {}
} = opts
if (!token) { if (!token) {
onStatus('未登录,无法开播') onStatus('未登录,无法开播')
return { stop: () => {} } return { stop: () => {} }
} }
const wsUrl = liveWsURLPublish(token, quality) const publishKey = effectivePublishQualityKey()
const wsUrl = liveWsURLPublish(token)
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
const pc = new RTCPeerConnection({ iceServers: defaultIce }) const pc = new RTCPeerConnection({ iceServers: defaultIce })
let stream = null let stream = null
/** 本地主动 stop / 摄像头失败关闭,避免 onclose 覆盖真实错误提示 */
let closedByLocal = false let closedByLocal = false
const send = (o) => { const send = (o) => {
@@ -116,8 +113,7 @@ export function startPublishing(opts = {}) {
ws.onopen = async () => { ws.onopen = async () => {
onStatus('信令已连接,正在采集摄像头…') onStatus('信令已连接,正在采集摄像头…')
try { try {
// 后台多为 PC不要用 facingMode:'user',部分机器会直接导致 “Could not start video source const cons = QUALITY_MEDIA[publishKey] || QUALITY_MEDIA.source
const cons = QUALITY_MEDIA[quality] || QUALITY_MEDIA.high
stream = await navigator.mediaDevices.getUserMedia(cons) stream = await navigator.mediaDevices.getUserMedia(cons)
onLocalStream(stream) onLocalStream(stream)
stream.getTracks().forEach((t) => pc.addTrack(t, stream)) stream.getTracks().forEach((t) => pc.addTrack(t, stream))

View File

@@ -10,17 +10,9 @@
<code>LIVE_PUBLIC_IP</code>服务器公网 IPv4与域名一致并配置 <code>LIVE_ICE_SERVERS</code> TURN <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 v-if="!session" class="quality-row"> <p class="quality-hint">
<span class="quality-label">推流画质</span> 画质请在官网直播页选择写入本机与后台开播使用<strong>同一浏览器</strong>开始直播将按该档位采集
<el-select v-model="quality" style="width: 220px" :disabled="!token"> </p>
<el-option
v-for="o in qualityOptions"
:key="o.value"
:label="o.label"
:value="o.value"
/>
</el-select>
</div>
<div class="actions"> <div class="actions">
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button> <el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
<el-button v-else type="danger" @click="stop">结束直播</el-button> <el-button v-else type="danger" @click="stop">结束直播</el-button>
@@ -34,7 +26,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router' import { onBeforeRouteLeave } from 'vue-router'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { startPublishing, LIVE_QUALITY_OPTIONS } from '../../utils/liveWebRTC' import { startPublishing } from '../../utils/liveWebRTC'
const authStore = useAuthStore() const authStore = useAuthStore()
const token = computed(() => authStore.getToken() || '') const token = computed(() => authStore.getToken() || '')
@@ -50,7 +42,6 @@ function start() {
status.value = '正在连接…' status.value = '正在连接…'
const { stop } = startPublishing({ const { stop } = startPublishing({
token: token.value, token: token.value,
quality: quality.value,
onStatus: (s) => { onStatus: (s) => {
status.value = s status.value = s
}, },
@@ -108,15 +99,11 @@ onBeforeRouteLeave(() => {
margin-bottom: 12px; margin-bottom: 12px;
min-height: 1.5em; min-height: 1.5em;
} }
.quality-row { .quality-hint {
display: flex; font-size: 13px;
align-items: center; line-height: 1.6;
gap: 12px; color: #909399;
margin-bottom: 12px; margin: 0 0 14px;
}
.quality-label {
font-size: 14px;
color: #606266;
} }
.actions { .actions {
margin-bottom: 16px; margin-bottom: 16px;

View File

@@ -0,0 +1,9 @@
/** 与 admin 开播页约定同一 key用于「官网选画质 → 同机后台开播按此采集」 */
export const LIVE_CAPTURE_QUALITY_STORAGE_KEY = 'yh_live_capture_quality'
export const LIVE_QUALITY_OPTIONS = [
{ value: 'source', label: '原画(设备默认,推荐)' },
{ value: 'high', label: '高清 720p' },
{ value: 'mid', label: '标清 480p' },
{ value: 'low', label: '流畅 360p' }
]

View File

@@ -9,6 +9,15 @@
<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>
<div class="live-quality-row">
<label class="live-quality-label" for="live-cap-q">采集画质</label>
<select id="live-cap-q" v-model="captureQualityPref" class="live-quality-select">
<option v-for="o in qualityOptions" :key="o.value" :value="o.value">
{{ o.label }}
</option>
</select>
</div>
<p v-if="liveInfoLine" class="live-info-quality">{{ liveInfoLine }}</p>
<p class="live-watch-status">{{ watchStatus }}</p> <p class="live-watch-status">{{ watchStatus }}</p>
<div class="live-video-wrap"> <div class="live-video-wrap">
<video <video
@@ -64,14 +73,18 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { apiBase } from '../config' import { apiBase } from '../config'
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC' import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils/liveQuality'
import { startViewing, liveDanmakuWsURL, liveInfoURL } from '../utils/liveWebRTC'
const watchVideoRef = ref(null) const watchVideoRef = ref(null)
const rawLiveUrl = ref('') const rawLiveUrl = ref('')
const pageTitle = ref('视频直播') const pageTitle = ref('视频直播')
const watchStatus = ref('正在检测本站直播…') const watchStatus = ref('正在检测本站直播…')
const qualityOptions = LIVE_QUALITY_OPTIONS
const captureQualityPref = ref('source')
const liveInfoLine = ref('')
const dmDraft = ref('') const dmDraft = ref('')
const dmItems = ref([]) const dmItems = ref([])
let dmIdSeq = 0 let dmIdSeq = 0
@@ -80,6 +93,35 @@ let dmWs = null
const enterUrl = computed(() => (rawLiveUrl.value || '').trim()) const enterUrl = computed(() => (rawLiveUrl.value || '').trim())
let viewSession = null let viewSession = null
let liveInfoTimer = null
function loadCaptureQualityPref() {
try {
const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY)
if (v && qualityOptions.some((o) => o.value === v)) {
captureQualityPref.value = v
return
}
} catch (_) {}
captureQualityPref.value = 'source'
}
watch(captureQualityPref, (v) => {
try {
localStorage.setItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY, v)
} catch (_) {}
})
async function refreshLiveInfoLine() {
try {
const r = await fetch(liveInfoURL())
if (!r.ok) return
const j = await r.json()
const id = typeof j.current_quality === 'string' && j.current_quality ? j.current_quality : 'source'
const label = qualityOptions.find((o) => o.value === id)?.label || id
liveInfoLine.value = j.live ? `当前在播 · 采集档位:${label}` : ''
} catch (_) {}
}
function normalizeOutboundUrl(u) { function normalizeOutboundUrl(u) {
const s = (u || '').trim() const s = (u || '').trim()
@@ -173,9 +215,12 @@ function sendDm() {
} }
onMounted(async () => { onMounted(async () => {
loadCaptureQualityPref()
loadHomepage() loadHomepage()
await nextTick() await nextTick()
connectDanmaku() connectDanmaku()
refreshLiveInfoLine()
liveInfoTimer = window.setInterval(refreshLiveInfoLine, 8000)
viewSession = startViewing(watchVideoRef.value, { viewSession = startViewing(watchVideoRef.value, {
muted: false, muted: false,
onStatus: (s) => { onStatus: (s) => {
@@ -185,6 +230,10 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
if (liveInfoTimer) {
clearInterval(liveInfoTimer)
liveInfoTimer = null
}
viewSession?.stop() viewSession?.stop()
try { try {
dmWs?.close() dmWs?.close()
@@ -245,6 +294,31 @@ onUnmounted(() => {
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
margin: 0 0 10px; margin: 0 0 10px;
} }
.live-quality-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.live-quality-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.65);
}
.live-quality-select {
min-width: 200px;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(0, 212, 255, 0.35);
background: rgba(0, 0, 0, 0.35);
color: #fff;
font-size: 14px;
}
.live-info-quality {
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
margin: 0 0 8px;
}
.live-watch-status { .live-watch-status {
font-size: 13px; font-size: 13px;
color: #00d4ff; color: #00d4ff;