直播:三模式采集、Canvas 单轨与可拖动小窗;观众端单路视频
Made-with: Cursor
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 管理后台 WebRTC 开播(/api/web/live/ws?role=publish&token=)
|
* 管理后台 WebRTC 开播(观众端始终单路视频)
|
||||||
* 画质:官网 /live 写入 localStorage(yh_live_capture_quality)
|
* - camera:仅摄像头
|
||||||
* 支持:仅摄像头 / 屏幕共享 + 摄像头小窗;信令断线自动重连(复用 MediaStream)
|
* - screen_only:仅共享屏幕 + 麦克风
|
||||||
|
* - screen_pip:屏幕与摄像头 Canvas 合成;小窗位置由 getPipRect() 归一化矩形 (nx,ny,nw,nh) 决定,与预览拖动一致
|
||||||
|
* - 直播中可 switchMode 切换,无需结束直播
|
||||||
*/
|
*/
|
||||||
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
|
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
|
||||||
|
|
||||||
@@ -59,6 +61,9 @@ const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }]
|
|||||||
|
|
||||||
const MAX_SIGNAL_RECONNECT = 15
|
const MAX_SIGNAL_RECONNECT = 15
|
||||||
|
|
||||||
|
const CANVAS_W = 1280
|
||||||
|
const CANVAS_H = 720
|
||||||
|
|
||||||
function healthCheckUrl() {
|
function healthCheckUrl() {
|
||||||
if (apiBase) return `${apiBase}/api/health`
|
if (apiBase) return `${apiBase}/api/health`
|
||||||
if (typeof window !== 'undefined') return `${window.location.origin}/api/health`
|
if (typeof window !== 'undefined') return `${window.location.origin}/api/health`
|
||||||
@@ -80,6 +85,22 @@ function buildCameraConstraints(publishKey, videoDeviceId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp(v, lo, hi) {
|
||||||
|
return Math.min(hi, Math.max(lo, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {() => { nx?: number, ny?: number, nw?: number, nh?: number } | null | undefined} getPipRect */
|
||||||
|
function readPipRect(getPipRect) {
|
||||||
|
const d = typeof getPipRect === 'function' ? getPipRect() : null
|
||||||
|
const nw = clamp(Number(d?.nw) || 0.24, 0.08, 0.55)
|
||||||
|
const nh = clamp(Number(d?.nh) || 0.24, 0.08, 0.55)
|
||||||
|
const defNx = 1 - nw - 10 / CANVAS_W
|
||||||
|
const defNy = 1 - nh - 10 / CANVAS_H
|
||||||
|
const nx = clamp(Number(d?.nx) || defNx, 0, 1 - nw)
|
||||||
|
const ny = clamp(Number(d?.ny) || defNy, 0, 1 - nh)
|
||||||
|
return { nx, ny, nw, nh }
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -106,31 +127,28 @@ function humanizeGetUserMediaError(err) {
|
|||||||
return (err && err.message) || '无法打开摄像头'
|
return (err && err.message) || '无法打开摄像头'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {object} opts
|
|
||||||
* @param {string} opts.token
|
|
||||||
* @param {'camera'|'screen_pip'} [opts.captureMode]
|
|
||||||
* @param {string} [opts.videoDeviceId] videoinput deviceId,空则默认设备
|
|
||||||
* @param {(s: string) => void} [opts.onStatus]
|
|
||||||
* @param {(p: { main: MediaStream, pip: MediaStream|null }) => void} [opts.onLocalStream]
|
|
||||||
*/
|
|
||||||
export function startPublishing(opts = {}) {
|
export function startPublishing(opts = {}) {
|
||||||
const {
|
const {
|
||||||
token = '',
|
token = '',
|
||||||
captureMode = 'camera',
|
captureMode: initialMode = 'camera',
|
||||||
videoDeviceId = '',
|
videoDeviceId: initialDeviceId = '',
|
||||||
onStatus = () => {},
|
onStatus = () => {},
|
||||||
onLocalStream = () => {}
|
onLocalStream = () => {},
|
||||||
|
onActiveModeChange = () => {},
|
||||||
|
getPipRect = null
|
||||||
} = opts
|
} = opts
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
onStatus('未登录,无法开播')
|
onStatus('未登录,无法开播')
|
||||||
return { stop: () => {} }
|
return { stop: () => {}, switchMode: async () => {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishKey = effectivePublishQualityKey()
|
const publishKey = effectivePublishQualityKey()
|
||||||
const wsUrl = liveWsURLPublish(token)
|
const wsUrl = liveWsURLPublish(token)
|
||||||
|
|
||||||
|
let activeMode = initialMode
|
||||||
|
let deviceIdState = initialDeviceId
|
||||||
|
|
||||||
let closedByLocal = false
|
let closedByLocal = false
|
||||||
let stream = null
|
let stream = null
|
||||||
let ws = null
|
let ws = null
|
||||||
@@ -139,6 +157,42 @@ export function startPublishing(opts = {}) {
|
|||||||
let reconnectAttempt = 0
|
let reconnectAttempt = 0
|
||||||
let wsGen = 0
|
let wsGen = 0
|
||||||
let reconnectStopped = false
|
let reconnectStopped = false
|
||||||
|
let switchBusy = false
|
||||||
|
|
||||||
|
let rafId = null
|
||||||
|
let vScreen = null
|
||||||
|
let vCam = null
|
||||||
|
let canvasEl = null
|
||||||
|
let screenShareTrack = null
|
||||||
|
|
||||||
|
function teardownComposite() {
|
||||||
|
if (rafId != null) {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
rafId = null
|
||||||
|
}
|
||||||
|
if (vScreen) {
|
||||||
|
try {
|
||||||
|
const so = vScreen.srcObject
|
||||||
|
if (so) so.getTracks().forEach((t) => t.stop())
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
vScreen.srcObject = null
|
||||||
|
} catch (_) {}
|
||||||
|
vScreen = null
|
||||||
|
}
|
||||||
|
if (vCam) {
|
||||||
|
try {
|
||||||
|
const so = vCam.srcObject
|
||||||
|
if (so) so.getTracks().forEach((t) => t.stop())
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
vCam.srcObject = null
|
||||||
|
} catch (_) {}
|
||||||
|
vCam = null
|
||||||
|
}
|
||||||
|
canvasEl = null
|
||||||
|
screenShareTrack = null
|
||||||
|
}
|
||||||
|
|
||||||
function clearReconnectTimer() {
|
function clearReconnectTimer() {
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
@@ -151,44 +205,121 @@ export function startPublishing(opts = {}) {
|
|||||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o))
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acquireMedia() {
|
async function acquireDisplayStream() {
|
||||||
if (captureMode === 'screen_pip') {
|
|
||||||
let display
|
let display
|
||||||
try {
|
try {
|
||||||
display = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
|
display = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: true,
|
||||||
|
audio: false
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const name = e && e.name
|
const name = e && e.name
|
||||||
throw new Error(name === 'NotAllowedError' ? '已取消或未允许屏幕共享' : (e && e.message) || '屏幕共享失败')
|
throw new Error(name === 'NotAllowedError' ? '已取消或未允许屏幕共享' : (e && e.message) || '屏幕共享失败')
|
||||||
}
|
}
|
||||||
const screenVideo = display.getVideoTracks()[0]
|
const sTr = display.getVideoTracks()[0]
|
||||||
if (!screenVideo) {
|
if (!sTr) {
|
||||||
display.getTracks().forEach((t) => t.stop())
|
display.getTracks().forEach((t) => t.stop())
|
||||||
throw new Error('未获得屏幕画面')
|
throw new Error('未获得屏幕画面')
|
||||||
}
|
}
|
||||||
screenVideo.addEventListener('ended', () => {
|
screenShareTrack = sTr
|
||||||
|
sTr.addEventListener('ended', () => {
|
||||||
if (!closedByLocal) {
|
if (!closedByLocal) {
|
||||||
onStatus('屏幕共享已结束')
|
onStatus('屏幕共享已结束')
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
let cam
|
return display
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPublishStream() {
|
||||||
|
teardownComposite()
|
||||||
|
if (activeMode === 'screen_only') {
|
||||||
|
const display = await acquireDisplayStream()
|
||||||
|
const sTr = display.getVideoTracks()[0]
|
||||||
|
let micStream
|
||||||
try {
|
try {
|
||||||
cam = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, videoDeviceId))
|
micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
screenVideo.stop()
|
sTr.stop()
|
||||||
display.getTracks().forEach((t) => t.stop())
|
display.getTracks().forEach((t) => t.stop())
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
const camVideo = cam.getVideoTracks()[0]
|
const previewVid = new MediaStream([sTr])
|
||||||
|
onLocalStream({ layout: 'screen_only', main: previewVid })
|
||||||
|
return new MediaStream([sTr, ...micStream.getAudioTracks()])
|
||||||
|
}
|
||||||
|
if (activeMode === 'screen_pip') {
|
||||||
|
const display = await acquireDisplayStream()
|
||||||
|
const sTr = display.getVideoTracks()[0]
|
||||||
|
let cam
|
||||||
|
try {
|
||||||
|
cam = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState))
|
||||||
|
} catch (e) {
|
||||||
|
sTr.stop()
|
||||||
|
display.getTracks().forEach((t) => t.stop())
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
vScreen = document.createElement('video')
|
||||||
|
vCam = document.createElement('video')
|
||||||
|
vScreen.muted = true
|
||||||
|
vCam.muted = true
|
||||||
|
vScreen.playsInline = true
|
||||||
|
vCam.playsInline = true
|
||||||
|
vScreen.srcObject = display
|
||||||
|
vCam.srcObject = cam
|
||||||
|
await vScreen.play().catch(() => {})
|
||||||
|
await vCam.play().catch(() => {})
|
||||||
|
|
||||||
|
canvasEl = document.createElement('canvas')
|
||||||
|
canvasEl.width = CANVAS_W
|
||||||
|
canvasEl.height = CANVAS_H
|
||||||
|
const ctx = canvasEl.getContext('2d')
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (closedByLocal || !canvasEl) return
|
||||||
|
ctx.fillStyle = '#000'
|
||||||
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
|
||||||
|
if (vScreen && vScreen.readyState >= 2) {
|
||||||
|
try {
|
||||||
|
ctx.drawImage(vScreen, 0, 0, CANVAS_W, CANVAS_H)
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (vCam && vCam.readyState >= 2) {
|
||||||
|
const { nx, ny, nw, nh } = readPipRect(getPipRect)
|
||||||
|
const pw = Math.round(CANVAS_W * nw)
|
||||||
|
const ph = Math.round(CANVAS_H * nh)
|
||||||
|
const px = Math.round(CANVAS_W * nx)
|
||||||
|
const py = Math.round(CANVAS_H * ny)
|
||||||
|
ctx.strokeStyle = 'rgba(64,158,255,0.9)'
|
||||||
|
ctx.lineWidth = 3
|
||||||
|
ctx.strokeRect(px, py, pw, ph)
|
||||||
|
try {
|
||||||
|
ctx.drawImage(vCam, px, py, pw, ph)
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
rafId = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
|
||||||
|
const cap = canvasEl.captureStream(30)
|
||||||
|
const outV = cap.getVideoTracks()[0]
|
||||||
|
if (!outV) {
|
||||||
|
teardownComposite()
|
||||||
|
sTr.stop()
|
||||||
|
cam.getTracks().forEach((t) => t.stop())
|
||||||
|
throw new Error('画布采集失败')
|
||||||
|
}
|
||||||
const mic = cam.getAudioTracks()
|
const mic = cam.getAudioTracks()
|
||||||
const publish = new MediaStream([screenVideo, camVideo, ...mic])
|
const publish = new MediaStream([outV, ...mic])
|
||||||
const mainPrev = new MediaStream([screenVideo])
|
onLocalStream({
|
||||||
const pipPrev = new MediaStream([camVideo])
|
layout: 'screen_pip',
|
||||||
onLocalStream({ main: mainPrev, pip: pipPrev })
|
screen: display,
|
||||||
|
cam: new MediaStream([cam.getVideoTracks()[0]])
|
||||||
|
})
|
||||||
return publish
|
return publish
|
||||||
}
|
}
|
||||||
const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, videoDeviceId))
|
const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState))
|
||||||
onLocalStream({ main: s, pip: null })
|
onLocalStream({ layout: 'camera', main: s })
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,8 +334,9 @@ export function startPublishing(opts = {}) {
|
|||||||
t.stop()
|
t.stop()
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
})
|
})
|
||||||
|
teardownComposite()
|
||||||
try {
|
try {
|
||||||
stream = await acquireMedia()
|
stream = await buildPublishStream()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e && typeof e.message === 'string' && e.message
|
e && typeof e.message === 'string' && e.message
|
||||||
@@ -233,6 +365,62 @@ export function startPublishing(opts = {}) {
|
|||||||
onStatus('协商中…')
|
onStatus('协商中…')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function switchMode(mode, camDeviceId) {
|
||||||
|
if (closedByLocal || switchBusy || !pc || pc.signalingState === 'closed') return
|
||||||
|
switchBusy = true
|
||||||
|
if (typeof camDeviceId === 'string') deviceIdState = camDeviceId
|
||||||
|
const switchingTo = mode
|
||||||
|
activeMode = mode
|
||||||
|
onStatus('切换画面中…')
|
||||||
|
try {
|
||||||
|
stream?.getTracks().forEach((t) => {
|
||||||
|
try {
|
||||||
|
t.stop()
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
stream = null
|
||||||
|
teardownComposite()
|
||||||
|
try {
|
||||||
|
stream = await buildPublishStream()
|
||||||
|
} catch (e1) {
|
||||||
|
if (switchingTo === 'screen_pip' || switchingTo === 'screen_only') {
|
||||||
|
activeMode = 'camera'
|
||||||
|
try {
|
||||||
|
onActiveModeChange('camera')
|
||||||
|
} catch (_) {}
|
||||||
|
onStatus(
|
||||||
|
e1?.message ? `${e1.message},已切回仅摄像头` : '屏幕共享未就绪,已切回仅摄像头'
|
||||||
|
)
|
||||||
|
stream = await buildPublishStream()
|
||||||
|
} else {
|
||||||
|
throw e1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const vT = stream.getVideoTracks()[0]
|
||||||
|
const aT = stream.getAudioTracks()[0]
|
||||||
|
const vSender = pc.getSenders().find((s) => s.track?.kind === 'video')
|
||||||
|
const aSender = pc.getSenders().find((s) => s.track?.kind === 'audio')
|
||||||
|
if (vSender && vT) {
|
||||||
|
await vSender.replaceTrack(vT)
|
||||||
|
} else if (vT) {
|
||||||
|
pc.addTrack(vT, stream)
|
||||||
|
}
|
||||||
|
if (aSender && aT) {
|
||||||
|
await aSender.replaceTrack(aT)
|
||||||
|
} else if (aT) {
|
||||||
|
pc.addTrack(aT, stream)
|
||||||
|
}
|
||||||
|
const offer = await pc.createOffer({ iceRestart: false })
|
||||||
|
await pc.setLocalDescription(offer)
|
||||||
|
send({ type: 'offer', sdp: offer.sdp })
|
||||||
|
onStatus('已切换,协商中…')
|
||||||
|
} catch (e) {
|
||||||
|
onStatus(e?.message || humanizeGetUserMediaError(e))
|
||||||
|
} finally {
|
||||||
|
switchBusy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onSocketMessage(ev) {
|
function onSocketMessage(ev) {
|
||||||
let msg
|
let msg
|
||||||
try {
|
try {
|
||||||
@@ -269,7 +457,7 @@ export function startPublishing(opts = {}) {
|
|||||||
if (reconnectAttempt > MAX_SIGNAL_RECONNECT) {
|
if (reconnectAttempt > MAX_SIGNAL_RECONNECT) {
|
||||||
reconnectStopped = true
|
reconnectStopped = true
|
||||||
onStatus(
|
onStatus(
|
||||||
`信令已重试 ${MAX_SIGNAL_RECONNECT} 次仍失败。多为 API 返回 502(进程未启动/崩溃)或 Nginx 未正确反代到 Go。请检查服务与 \`/api/web/live/ws\` 的 WebSocket 配置,修复后刷新本页再点「开始直播」。`
|
`信令已重试 ${MAX_SIGNAL_RECONNECT} 次仍失败。多为 API 502 或未启动。修复后刷新本页再开播。`
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -281,10 +469,10 @@ export function startPublishing(opts = {}) {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(healthCheckUrl(), { method: 'GET', cache: 'no-store' })
|
const r = await fetch(healthCheckUrl(), { method: 'GET', cache: 'no-store' })
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
onStatus(`API 不可用(HTTP ${r.status}),多为网关 502,推迟重试…`)
|
onStatus(`API 不可用(HTTP ${r.status})`)
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
onStatus('无法访问健康检查接口,请确认网络与域名。')
|
onStatus('无法访问健康检查接口')
|
||||||
}
|
}
|
||||||
if (closedByLocal || reconnectStopped) return
|
if (closedByLocal || reconnectStopped) return
|
||||||
openSignalingSocket()
|
openSignalingSocket()
|
||||||
@@ -349,6 +537,7 @@ export function startPublishing(opts = {}) {
|
|||||||
reconnectStopped = true
|
reconnectStopped = true
|
||||||
wsGen += 1
|
wsGen += 1
|
||||||
clearReconnectTimer()
|
clearReconnectTimer()
|
||||||
|
teardownComposite()
|
||||||
if (ws) {
|
if (ws) {
|
||||||
try {
|
try {
|
||||||
ws.close()
|
ws.close()
|
||||||
@@ -371,5 +560,8 @@ export function startPublishing(opts = {}) {
|
|||||||
|
|
||||||
openSignalingSocket()
|
openSignalingSocket()
|
||||||
|
|
||||||
return { stop }
|
return {
|
||||||
|
stop,
|
||||||
|
switchMode: (mode, camDeviceId) => switchMode(mode, camDeviceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
<span>官网视频直播(WebRTC)</span>
|
<span>官网视频直播(WebRTC)</span>
|
||||||
</template>
|
</template>
|
||||||
<p class="status">{{ status }}</p>
|
<p class="status">{{ status }}</p>
|
||||||
<div v-if="!session" class="form-block">
|
<div class="form-block">
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<span class="field-label">画面来源</span>
|
<span class="field-label">画面来源</span>
|
||||||
<el-radio-group v-model="captureMode" :disabled="!token">
|
<el-radio-group v-model="captureMode" :disabled="!token || switchingCapture">
|
||||||
<el-radio-button value="camera">仅摄像头</el-radio-button>
|
<el-radio-button value="camera">仅摄像头</el-radio-button>
|
||||||
<el-radio-button value="screen_pip">屏幕 + 摄像头小窗</el-radio-button>
|
<el-radio-button value="screen_only">仅共享屏幕</el-radio-button>
|
||||||
|
<el-radio-button value="screen_pip">共享屏幕 + 摄像头</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
style="width: 100%; max-width: 360px"
|
style="width: 100%; max-width: 360px"
|
||||||
:disabled="!token"
|
:disabled="!token || switchingCapture"
|
||||||
>
|
>
|
||||||
<el-option label="系统默认" value="" />
|
<el-option label="系统默认" value="" />
|
||||||
<el-option
|
<el-option
|
||||||
@@ -33,21 +34,54 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
<el-button :disabled="!token" @click="refreshVideoDevices">刷新列表</el-button>
|
<el-button :disabled="!token" @click="refreshVideoDevices">刷新列表</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="session" class="hint-live">
|
||||||
|
直播中可切换三种模式;「屏幕+摄像头」下可拖动小窗,观众画面与预览一致(画布 16:9
|
||||||
|
铺满)。共享整屏时尽量勿把本管理页选进画面,以免套娃。
|
||||||
|
</p>
|
||||||
|
<div v-if="session" class="field-row">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:disabled="!token || switchingCapture"
|
||||||
|
:loading="switchingCapture"
|
||||||
|
@click="applyCaptureSwitch"
|
||||||
|
>
|
||||||
|
应用切换(不切断直播)
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-wrap">
|
<div ref="previewWrapRef" class="preview-wrap">
|
||||||
<video ref="previewMainRef" class="preview-main" playsinline muted autoplay></video>
|
|
||||||
<video
|
<video
|
||||||
v-show="captureMode === 'screen_pip'"
|
v-show="previewLayout !== 'screen_pip'"
|
||||||
ref="previewPipRef"
|
ref="previewMainRef"
|
||||||
class="preview-pip"
|
class="preview-main"
|
||||||
playsinline
|
playsinline
|
||||||
muted
|
muted
|
||||||
autoplay
|
autoplay
|
||||||
></video>
|
></video>
|
||||||
|
<video
|
||||||
|
v-show="previewLayout === 'screen_pip'"
|
||||||
|
ref="previewScreenRef"
|
||||||
|
class="preview-main preview-main--fill"
|
||||||
|
playsinline
|
||||||
|
muted
|
||||||
|
autoplay
|
||||||
|
></video>
|
||||||
|
<video
|
||||||
|
v-show="previewLayout === 'screen_pip'"
|
||||||
|
ref="previewCamRef"
|
||||||
|
class="preview-pip-drag"
|
||||||
|
:style="pipStyle"
|
||||||
|
playsinline
|
||||||
|
muted
|
||||||
|
autoplay
|
||||||
|
title="拖动调整小窗位置(观众端同步)"
|
||||||
|
@pointerdown.prevent="onPipPointerDown"
|
||||||
|
></video>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,13 +95,80 @@ import { startPublishing } from '../../utils/liveWebRTC'
|
|||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const token = computed(() => authStore.getToken() || '')
|
const token = computed(() => authStore.getToken() || '')
|
||||||
|
const previewWrapRef = ref(null)
|
||||||
const previewMainRef = ref(null)
|
const previewMainRef = ref(null)
|
||||||
const previewPipRef = ref(null)
|
const previewScreenRef = ref(null)
|
||||||
|
const previewCamRef = ref(null)
|
||||||
const status = ref('就绪')
|
const status = ref('就绪')
|
||||||
const session = ref(null)
|
const session = ref(null)
|
||||||
const captureMode = ref('camera')
|
const captureMode = ref('camera')
|
||||||
const selectedCameraId = ref('')
|
const selectedCameraId = ref('')
|
||||||
const videoInputs = ref([])
|
const videoInputs = ref([])
|
||||||
|
const switchingCapture = ref(false)
|
||||||
|
/** @type {import('vue').Ref<'camera'|'screen_only'|'screen_pip'>} */
|
||||||
|
const previewLayout = ref('camera')
|
||||||
|
|
||||||
|
/** 与推流画布 1280×720 一致的归一化小窗矩形(左上 + 宽高,0~1) */
|
||||||
|
const pipNorm = ref(defaultPipNorm())
|
||||||
|
|
||||||
|
function defaultPipNorm() {
|
||||||
|
const nw = 0.24
|
||||||
|
const nh = 0.24
|
||||||
|
return {
|
||||||
|
nx: 1 - nw - 10 / 1280,
|
||||||
|
ny: 1 - nh - 10 / 720,
|
||||||
|
nw,
|
||||||
|
nh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipStyle = computed(() => ({
|
||||||
|
left: `${pipNorm.value.nx * 100}%`,
|
||||||
|
top: `${pipNorm.value.ny * 100}%`,
|
||||||
|
width: `${pipNorm.value.nw * 100}%`,
|
||||||
|
height: `${pipNorm.value.nh * 100}%`
|
||||||
|
}))
|
||||||
|
|
||||||
|
let pipDragging = false
|
||||||
|
let pipDragStart = { cx: 0, cy: 0, nx: 0, ny: 0 }
|
||||||
|
|
||||||
|
function clamp01(v, lo, hi) {
|
||||||
|
return Math.min(hi, Math.max(lo, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPipPointerDown(e) {
|
||||||
|
if (previewLayout.value !== 'screen_pip') return
|
||||||
|
pipDragging = true
|
||||||
|
pipDragStart.cx = e.clientX
|
||||||
|
pipDragStart.cy = e.clientY
|
||||||
|
pipDragStart.nx = pipNorm.value.nx
|
||||||
|
pipDragStart.ny = pipNorm.value.ny
|
||||||
|
try {
|
||||||
|
e.target.setPointerCapture(e.pointerId)
|
||||||
|
} catch (_) {}
|
||||||
|
window.addEventListener('pointermove', onPipPointerMove)
|
||||||
|
window.addEventListener('pointerup', onPipPointerUp, { once: true })
|
||||||
|
window.addEventListener('pointercancel', onPipPointerUp, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPipPointerMove(e) {
|
||||||
|
if (!pipDragging || !previewWrapRef.value) return
|
||||||
|
const rect = previewWrapRef.value.getBoundingClientRect()
|
||||||
|
if (rect.width < 1 || rect.height < 1) return
|
||||||
|
const dx = (e.clientX - pipDragStart.cx) / rect.width
|
||||||
|
const dy = (e.clientY - pipDragStart.cy) / rect.height
|
||||||
|
const { nw, nh } = pipNorm.value
|
||||||
|
pipNorm.value = {
|
||||||
|
...pipNorm.value,
|
||||||
|
nx: clamp01(pipDragStart.nx + dx, 0, 1 - nw),
|
||||||
|
ny: clamp01(pipDragStart.ny + dy, 0, 1 - nh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPipPointerUp() {
|
||||||
|
pipDragging = false
|
||||||
|
window.removeEventListener('pointermove', onPipPointerMove)
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshVideoDevices() {
|
async function refreshVideoDevices() {
|
||||||
try {
|
try {
|
||||||
@@ -77,14 +178,36 @@ async function refreshVideoDevices() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPreview({ main, pip }) {
|
function applyPreview(payload) {
|
||||||
|
const { layout, main, screen, cam } = payload || {}
|
||||||
|
previewLayout.value = layout || 'camera'
|
||||||
|
if (previewLayout.value === 'screen_pip') {
|
||||||
|
if (previewMainRef.value) previewMainRef.value.srcObject = null
|
||||||
|
if (previewScreenRef.value) previewScreenRef.value.srcObject = screen || null
|
||||||
|
if (previewCamRef.value) previewCamRef.value.srcObject = cam || null
|
||||||
|
} else {
|
||||||
|
if (previewScreenRef.value) previewScreenRef.value.srcObject = null
|
||||||
|
if (previewCamRef.value) previewCamRef.value.srcObject = null
|
||||||
if (previewMainRef.value) previewMainRef.value.srcObject = main || null
|
if (previewMainRef.value) previewMainRef.value.srcObject = main || null
|
||||||
if (previewPipRef.value) previewPipRef.value.srcObject = pip || null
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPreview() {
|
function clearPreview() {
|
||||||
|
previewLayout.value = 'camera'
|
||||||
|
pipNorm.value = defaultPipNorm()
|
||||||
if (previewMainRef.value) previewMainRef.value.srcObject = null
|
if (previewMainRef.value) previewMainRef.value.srcObject = null
|
||||||
if (previewPipRef.value) previewPipRef.value.srcObject = null
|
if (previewScreenRef.value) previewScreenRef.value.srcObject = null
|
||||||
|
if (previewCamRef.value) previewCamRef.value.srcObject = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCaptureSwitch() {
|
||||||
|
if (!session.value?.switchMode) return
|
||||||
|
switchingCapture.value = true
|
||||||
|
try {
|
||||||
|
await session.value.switchMode(captureMode.value, selectedCameraId.value || '')
|
||||||
|
} finally {
|
||||||
|
switchingCapture.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
@@ -93,16 +216,20 @@ function start() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
status.value = '正在连接…'
|
status.value = '正在连接…'
|
||||||
const { stop } = startPublishing({
|
const { stop, switchMode } = startPublishing({
|
||||||
token: token.value,
|
token: token.value,
|
||||||
captureMode: captureMode.value,
|
captureMode: captureMode.value,
|
||||||
videoDeviceId: selectedCameraId.value || '',
|
videoDeviceId: selectedCameraId.value || '',
|
||||||
onStatus: (s) => {
|
onStatus: (s) => {
|
||||||
status.value = s
|
status.value = s
|
||||||
},
|
},
|
||||||
onLocalStream: applyPreview
|
onLocalStream: applyPreview,
|
||||||
|
onActiveModeChange: (m) => {
|
||||||
|
captureMode.value = m
|
||||||
|
},
|
||||||
|
getPipRect: () => ({ ...pipNorm.value })
|
||||||
})
|
})
|
||||||
session.value = { stop }
|
session.value = { stop, switchMode }
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
@@ -124,6 +251,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||||
|
window.removeEventListener('pointermove', onPipPointerMove)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeRouteLeave(() => {
|
onBeforeRouteLeave(() => {
|
||||||
@@ -155,6 +283,13 @@ onBeforeRouteLeave(() => {
|
|||||||
color: #606266;
|
color: #606266;
|
||||||
min-width: 72px;
|
min-width: 72px;
|
||||||
}
|
}
|
||||||
|
.hint-live {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #909399;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
.actions {
|
.actions {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
@@ -171,17 +306,24 @@ onBeforeRouteLeave(() => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #000;
|
background: #000;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
}
|
}
|
||||||
.preview-pip {
|
.preview-main--fill {
|
||||||
|
object-fit: fill;
|
||||||
|
}
|
||||||
|
.preview-pip-drag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 14px;
|
box-sizing: border-box;
|
||||||
bottom: 14px;
|
|
||||||
width: min(32%, 280px);
|
|
||||||
aspect-ratio: 4 / 3;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 2px solid #409eff;
|
border: 3px solid #409eff;
|
||||||
background: #000;
|
background: #000;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.preview-pip-drag:active {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -42,23 +42,15 @@ export async function fetchLiveStatus() {
|
|||||||
const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }]
|
const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 观众端:轮询 live 后拉流;支持双路视频(第一路主画面、第二路摄像头小窗)
|
* 观众端:轮询 live 后拉流;单路视频 + 音频(主播端 Canvas 合成)
|
||||||
* @param {HTMLVideoElement | null} videoEl 主画面
|
* @param {HTMLVideoElement | null} videoEl
|
||||||
* @param {{ pipVideoEl?: HTMLVideoElement | null, onStatus?: function, onLive?: function, onEnded?: function, pollMs?: number, muted?: boolean }} [opts]
|
* @param {{ onStatus?: function, onLive?: function, onEnded?: function, pollMs?: number, muted?: boolean }} [opts]
|
||||||
*/
|
*/
|
||||||
export function startViewing(videoEl, opts = {}) {
|
export function startViewing(videoEl, opts = {}) {
|
||||||
const {
|
const { onStatus = () => {}, onLive = () => {}, onEnded = () => {}, pollMs = 1200, muted = true } =
|
||||||
pipVideoEl = null,
|
opts
|
||||||
onPipActive = () => {},
|
|
||||||
onStatus = () => {},
|
|
||||||
onLive = () => {},
|
|
||||||
onEnded = () => {},
|
|
||||||
pollMs = 1200,
|
|
||||||
muted = true
|
|
||||||
} = opts
|
|
||||||
|
|
||||||
let mainRecv = new MediaStream()
|
let mainRecv = new MediaStream()
|
||||||
let videoIdx = 0
|
|
||||||
|
|
||||||
function syncMain() {
|
function syncMain() {
|
||||||
if (!videoEl) return
|
if (!videoEl) return
|
||||||
@@ -67,15 +59,6 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
videoEl.play().catch(() => {})
|
videoEl.play().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPip() {
|
|
||||||
if (pipVideoEl) {
|
|
||||||
pipVideoEl.srcObject = null
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
onPipActive(false)
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTrack(e) {
|
function handleTrack(e) {
|
||||||
if (e.track.kind === 'audio') {
|
if (e.track.kind === 'audio') {
|
||||||
mainRecv.addTrack(e.track)
|
mainRecv.addTrack(e.track)
|
||||||
@@ -83,19 +66,13 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.track.kind === 'video') {
|
if (e.track.kind === 'video') {
|
||||||
const i = videoIdx++
|
mainRecv.getVideoTracks().forEach((t) => {
|
||||||
if (i === 0) {
|
try {
|
||||||
|
mainRecv.removeTrack(t)
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
mainRecv.addTrack(e.track)
|
mainRecv.addTrack(e.track)
|
||||||
syncMain()
|
syncMain()
|
||||||
} else if (pipVideoEl) {
|
|
||||||
const pipMs = new MediaStream([e.track])
|
|
||||||
pipVideoEl.srcObject = pipMs
|
|
||||||
pipVideoEl.muted = true
|
|
||||||
pipVideoEl.play().catch(() => {})
|
|
||||||
try {
|
|
||||||
onPipActive(true)
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,8 +132,6 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
pc.close()
|
pc.close()
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
mainRecv = new MediaStream()
|
mainRecv = new MediaStream()
|
||||||
videoIdx = 0
|
|
||||||
clearPip()
|
|
||||||
pc = newPeer()
|
pc = newPeer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +139,6 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
if (stopped) return
|
if (stopped) return
|
||||||
clearBlackFrameTimer()
|
clearBlackFrameTimer()
|
||||||
if (videoEl) videoEl.srcObject = null
|
if (videoEl) videoEl.srcObject = null
|
||||||
clearPip()
|
|
||||||
if (tip) onStatus(tip)
|
if (tip) onStatus(tip)
|
||||||
if (pollTimer) return
|
if (pollTimer) return
|
||||||
rebuildPeer()
|
rebuildPeer()
|
||||||
@@ -186,7 +160,6 @@ 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('video', { direction: 'recvonly' })
|
|
||||||
pc.addTransceiver('audio', { 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)
|
||||||
@@ -297,7 +270,6 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
ws = null
|
ws = null
|
||||||
pc.close()
|
pc.close()
|
||||||
if (videoEl) videoEl.srcObject = null
|
if (videoEl) videoEl.srcObject = null
|
||||||
clearPip()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { stop }
|
return { stop }
|
||||||
|
|||||||
@@ -21,19 +21,10 @@
|
|||||||
<div class="live-video-wrap">
|
<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 live-room-video--contain"
|
||||||
:class="{ 'live-room-video--contain': pipVisible }"
|
|
||||||
playsinline
|
playsinline
|
||||||
autoplay
|
autoplay
|
||||||
></video>
|
></video>
|
||||||
<video
|
|
||||||
v-show="pipVisible"
|
|
||||||
ref="pipVideoRef"
|
|
||||||
class="live-room-pip-video"
|
|
||||||
playsinline
|
|
||||||
autoplay
|
|
||||||
muted
|
|
||||||
></video>
|
|
||||||
<div class="live-dm-layer" aria-hidden="true">
|
<div class="live-dm-layer" aria-hidden="true">
|
||||||
<div
|
<div
|
||||||
v-for="d in dmItems"
|
v-for="d in dmItems"
|
||||||
@@ -88,8 +79,6 @@ import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils
|
|||||||
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
|
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
|
||||||
|
|
||||||
const watchVideoRef = ref(null)
|
const watchVideoRef = ref(null)
|
||||||
const pipVideoRef = ref(null)
|
|
||||||
const pipVisible = ref(false)
|
|
||||||
const rawLiveUrl = ref('')
|
const rawLiveUrl = ref('')
|
||||||
const pageTitle = ref('视频直播')
|
const pageTitle = ref('视频直播')
|
||||||
const watchStatus = ref('正在检测本站直播…')
|
const watchStatus = ref('正在检测本站直播…')
|
||||||
@@ -163,11 +152,6 @@ function unmuteAndPlay() {
|
|||||||
if (!v) return
|
if (!v) return
|
||||||
v.muted = false
|
v.muted = false
|
||||||
v.play().catch(() => {})
|
v.play().catch(() => {})
|
||||||
const p = pipVideoRef.value
|
|
||||||
if (p) {
|
|
||||||
p.muted = true
|
|
||||||
p.play().catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleVideoFullscreen() {
|
function toggleVideoFullscreen() {
|
||||||
@@ -308,10 +292,6 @@ onMounted(async () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
connectDanmaku()
|
connectDanmaku()
|
||||||
viewSession = startViewing(watchVideoRef.value, {
|
viewSession = startViewing(watchVideoRef.value, {
|
||||||
pipVideoEl: pipVideoRef.value,
|
|
||||||
onPipActive: (on) => {
|
|
||||||
pipVisible.value = on
|
|
||||||
},
|
|
||||||
muted: false,
|
muted: false,
|
||||||
onStatus: (s) => {
|
onStatus: (s) => {
|
||||||
watchStatus.value = s
|
watchStatus.value = s
|
||||||
@@ -327,7 +307,6 @@ onUnmounted(() => {
|
|||||||
dmReconnectTimer = null
|
dmReconnectTimer = null
|
||||||
}
|
}
|
||||||
viewSession?.stop()
|
viewSession?.stop()
|
||||||
pipVisible.value = false
|
|
||||||
try {
|
try {
|
||||||
dmWs?.close()
|
dmWs?.close()
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -562,18 +541,4 @@ onUnmounted(() => {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
}
|
}
|
||||||
.live-room-pip-video {
|
|
||||||
position: absolute;
|
|
||||||
right: 10px;
|
|
||||||
bottom: 10px;
|
|
||||||
width: 30%;
|
|
||||||
max-width: 200px;
|
|
||||||
aspect-ratio: 4 / 3;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 2px solid rgba(0, 212, 255, 0.65);
|
|
||||||
background: #000;
|
|
||||||
object-fit: cover;
|
|
||||||
z-index: 4;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user