直播:转发音频、观众 recv audio、全屏与开声音按钮、后台采集麦克风
Made-with: Cursor
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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>
|
||||||
<video
|
<div class="live-video-wrap">
|
||||||
ref="watchVideoRef"
|
<video
|
||||||
class="live-room-video live-room-video--watch"
|
ref="watchVideoRef"
|
||||||
playsinline
|
class="live-room-video live-room-video--watch"
|
||||||
autoplay
|
playsinline
|
||||||
muted
|
autoplay
|
||||||
></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);
|
||||||
|
|||||||
Reference in New Issue
Block a user