修复开播卡正在连接:移除未定义 quality;画质改官网选择+localStorage
Made-with: Cursor
This commit is contained in:
@@ -1,14 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 管理后台 WebRTC 开播(需登录 token,与 /api/web/live/ws?role=publish&token= 一致)
|
* 管理后台 WebRTC 开播(需登录 token,与 /api/web/live/ws?role=publish&token= 一致)
|
||||||
|
* 画质由官网 /live 写入 localStorage(yh_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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
9
web/src/utils/liveQuality.js
Normal file
9
web/src/utils/liveQuality.js
Normal 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' }
|
||||||
|
]
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user