直播:转发音频、观众 recv audio、全屏与开声音按钮、后台采集麦克风

Made-with: Cursor
This commit is contained in:
whm
2026-03-26 09:55:27 +08:00
parent 2295410e1b
commit 6b3210f714
5 changed files with 86 additions and 16 deletions

View File

@@ -21,7 +21,7 @@ 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()
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') { if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
return '已拒绝摄像头权限:在浏览器地址栏左侧允许摄像头,并确认本页为 HTTPS。' return '已拒绝摄像头或麦克风权限:在浏览器地址栏左侧允许摄像头与麦克风,并确认本页为 HTTPS。'
} }
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
return '未检测到摄像头,请检查是否已接入设备或被系统禁用。' return '未检测到摄像头,请检查是否已接入设备或被系统禁用。'
@@ -75,7 +75,7 @@ export function startPublishing(opts = {}) {
onStatus('信令已连接,正在采集摄像头…') onStatus('信令已连接,正在采集摄像头…')
try { try {
// 后台多为 PC不要用 facingMode:'user',部分机器会直接导致 “Could not start video source” // 后台多为 PC不要用 facingMode:'user',部分机器会直接导致 “Could not start video source”
stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }) stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
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()

View File

@@ -146,8 +146,7 @@ func (h *Hub) removeViewer(id string) {
} }
func (h *Hub) onPublisherTrack(track *webrtc.TrackRemote) { func (h *Hub) onPublisherTrack(track *webrtc.TrackRemote) {
// 仅转发视频轨,降低协商复杂度 if track.Kind() != webrtc.RTPCodecTypeVideo && track.Kind() != webrtc.RTPCodecTypeAudio {
if track.Kind() != webrtc.RTPCodecTypeVideo {
return return
} }
tf := newTrackForwarder(track) tf := newTrackForwarder(track)

View File

@@ -58,6 +58,7 @@ const showSurface = ref(false)
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
session = startViewing(videoRef.value, { session = startViewing(videoRef.value, {
muted: true,
onStatus: (s) => { onStatus: (s) => {
hint.value = s hint.value = s
}, },

View File

@@ -35,13 +35,27 @@ function newPeer(onTrack) {
/** /**
* 观众端:轮询 live 后拉流;结束后自动恢复轮询以便下一场 * 观众端:轮询 live 后拉流;结束后自动恢复轮询以便下一场
* @param {HTMLVideoElement | null} videoEl * @param {HTMLVideoElement | null} videoEl
* @param {{ onStatus?: (s: string) => void, onLive?: () => void, onEnded?: () => void, pollMs?: number }} [opts] * @param {{ onStatus?: (s: string) => void, onLive?: () => void, onEnded?: () => void, pollMs?: number, muted?: boolean }} [opts]
*/ */
export function startViewing(videoEl, opts = {}) { export function startViewing(videoEl, opts = {}) {
const { onStatus = () => {}, onLive = () => {}, onEnded = () => {}, pollMs = 1200 } = opts const {
onStatus = () => {},
onLive = () => {},
onEnded = () => {},
pollMs = 1200,
muted = true
} = opts
function attachStreamToVideo(stream) {
if (!videoEl || !stream) return
videoEl.srcObject = stream
videoEl.muted = !!muted
videoEl.play().catch(() => {})
}
let pc = newPeer((e) => { let pc = newPeer((e) => {
if (videoEl && e.streams[0]) { if (videoEl && e.streams[0]) {
videoEl.srcObject = e.streams[0] attachStreamToVideo(e.streams[0])
} }
}) })
let ws = null let ws = null
@@ -98,7 +112,7 @@ export function startViewing(videoEl, opts = {}) {
} catch (_) {} } catch (_) {}
pc = newPeer((e) => { pc = newPeer((e) => {
if (videoEl && e.streams[0]) { if (videoEl && e.streams[0]) {
videoEl.srcObject = e.streams[0] attachStreamToVideo(e.streams[0])
} }
}) })
} }
@@ -129,6 +143,7 @@ export function startViewing(videoEl, opts = {}) {
else icePending.push(msg) else icePending.push(msg)
} }
pc.addTransceiver('video', { direction: 'recvonly' }) pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('audio', { direction: 'recvonly' })
const offer = await pc.createOffer() const offer = await pc.createOffer()
await pc.setLocalDescription(offer) await pc.setLocalDescription(offer)
ws = new WebSocket(liveWsURLView()) ws = new WebSocket(liveWsURLView())

View File

@@ -10,13 +10,18 @@
<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-watch-status">{{ watchStatus }}</p> <p class="live-watch-status">{{ watchStatus }}</p>
<div class="live-video-wrap">
<video <video
ref="watchVideoRef" ref="watchVideoRef"
class="live-room-video live-room-video--watch" class="live-room-video live-room-video--watch"
playsinline playsinline
autoplay autoplay
muted
></video> ></video>
<div class="live-video-toolbar" role="toolbar" aria-label="播放控制">
<button type="button" class="live-video-toolbtn" @click="unmuteAndPlay">开声音</button>
<button type="button" class="live-video-toolbtn" @click="toggleVideoFullscreen">全屏</button>
</div>
</div>
</section> </section>
<section class="live-block live-block--divider" aria-label="外链直播间"> <section class="live-block live-block--divider" aria-label="外链直播间">
@@ -80,10 +85,34 @@ function goLiveRoom() {
window.location.href = target window.location.href = target
} }
function unmuteAndPlay() {
const v = watchVideoRef.value
if (!v) return
v.muted = false
v.play().catch(() => {})
}
function toggleVideoFullscreen() {
const v = watchVideoRef.value
if (!v) return
const doc = document
if (doc.fullscreenElement || doc.webkitFullscreenElement) {
doc.exitFullscreen?.() || doc.webkitExitFullscreen?.()
return
}
if (typeof v.webkitEnterFullscreen === 'function') {
v.webkitEnterFullscreen()
return
}
const el = v
el.requestFullscreen?.() || el.webkitRequestFullscreen?.()
}
onMounted(async () => { onMounted(async () => {
loadHomepage() loadHomepage()
await nextTick() await nextTick()
viewSession = startViewing(watchVideoRef.value, { viewSession = startViewing(watchVideoRef.value, {
muted: false,
onStatus: (s) => { onStatus: (s) => {
watchStatus.value = s watchStatus.value = s
} }
@@ -153,6 +182,33 @@ onUnmounted(() => {
margin: 0 0 14px; margin: 0 0 14px;
min-height: 1.4em; min-height: 1.4em;
} }
.live-video-wrap {
position: relative;
max-width: 480px;
margin: 0 auto;
}
.live-video-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
margin-top: 12px;
}
.live-video-toolbtn {
padding: 8px 18px;
border-radius: 10px;
border: 1px solid rgba(0, 212, 255, 0.45);
background: rgba(0, 212, 255, 0.12);
color: #00d4ff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.live-video-toolbtn:hover {
background: rgba(0, 212, 255, 0.22);
border-color: rgba(0, 212, 255, 0.75);
}
.live-room-actions { .live-room-actions {
margin-top: 8px; margin-top: 8px;
} }
@@ -186,7 +242,6 @@ onUnmounted(() => {
.live-room-video { .live-room-video {
display: block; display: block;
width: 100%; width: 100%;
max-width: 480px;
margin: 0 auto; margin: 0 auto;
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(0, 212, 255, 0.25); border: 1px solid rgba(0, 212, 255, 0.25);