视频连线
This commit is contained in:
@@ -284,9 +284,26 @@ const emitIf = (name) => {
|
||||
}
|
||||
.badge.badge-red { background: #EF4444; }
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.btn { min-width: 88rpx; }
|
||||
.icon { width: 64rpx; height: 64rpx; }
|
||||
.label { font-size: 20rpx; }
|
||||
@media (max-width: 750px) {
|
||||
.toolbar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
justify-content: flex-start;
|
||||
gap: 8rpx;
|
||||
padding: 10rpx 12rpx calc(10rpx + env(safe-area-inset-bottom)) 12rpx;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.btn {
|
||||
min-width: 76rpx;
|
||||
flex: 0 0 auto;
|
||||
gap: 4rpx;
|
||||
padding: 4rpx 4rpx;
|
||||
}
|
||||
.icon { width: 54rpx; height: 54rpx; }
|
||||
.label { font-size: 18rpx; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,12 +49,14 @@ const props = defineProps({
|
||||
audioTrack: { type: Object, default: null },
|
||||
videoTrack: { type: Object, default: null },
|
||||
isSpeaking: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false }
|
||||
compact: { type: Boolean, default: false },
|
||||
videoFit: { type: String, default: 'cover' }
|
||||
})
|
||||
|
||||
const mediaBox = ref(null)
|
||||
|
||||
const hasVideo = computed(() => !!props.videoTrack && props.videoEnabled)
|
||||
const normalizedVideoFit = computed(() => props.videoFit === 'contain' ? 'contain' : 'cover')
|
||||
|
||||
const avatarInitial = computed(() => {
|
||||
const s = (props.name || '').trim()
|
||||
@@ -82,7 +84,7 @@ const ensureMediaEl = (tagName) => {
|
||||
if (tagName === 'video') {
|
||||
el.style.width = '100%'
|
||||
el.style.height = '100%'
|
||||
el.style.objectFit = 'cover'
|
||||
el.style.objectFit = normalizedVideoFit.value
|
||||
el.style.background = '#0B1220'
|
||||
el.style.display = 'block'
|
||||
}
|
||||
@@ -134,6 +136,7 @@ const applyVideo = (retry = 0) => {
|
||||
}
|
||||
const el = ensureMediaEl('video')
|
||||
if (!el) return
|
||||
el.style.objectFit = normalizedVideoFit.value
|
||||
// 关键:<video> 必须 muted 才能在 Chrome / 微信内核里自动播放。
|
||||
// 音频在独立的 <audio> 元素上播放,所以此处 muted 不会让对方静音,
|
||||
// 同时彻底解决"进会黑屏,必须点切换按钮才出图"的问题。
|
||||
@@ -183,7 +186,7 @@ const applyAudio = (retry = 0) => {
|
||||
// #endif
|
||||
}
|
||||
|
||||
watch(() => [props.videoTrack, props.videoEnabled], () => nextTick(() => applyVideo()), { immediate: true })
|
||||
watch(() => [props.videoTrack, props.videoEnabled, props.videoFit], () => nextTick(() => applyVideo()), { immediate: true })
|
||||
watch(() => [props.audioTrack, props.audioEnabled, props.isLocal], () => nextTick(() => applyAudio()), { immediate: true })
|
||||
|
||||
// 修复刚进入会议时画面不显示、必须点切换才出图的问题:
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
:video-enabled="true"
|
||||
:audio-track="null"
|
||||
:video-track="screenTile.videoTrack"
|
||||
video-fit="contain"
|
||||
:is-speaking="false"
|
||||
/>
|
||||
</view>
|
||||
@@ -241,6 +242,7 @@ const networkLevel = ref(4) // 持续接入 getStats 后动态更新(Task 16
|
||||
const startedAt = ref(0)
|
||||
const nowTs = ref(Date.now())
|
||||
let timerHandle = null
|
||||
let viewportCleanup = null
|
||||
|
||||
/** 视频区容器引用:传给 SelfVideoFloat 计算吸附边界 */
|
||||
const videoCol = ref(null)
|
||||
@@ -765,6 +767,15 @@ const redirectHome = () => {
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
|
||||
const syncViewportHeight = () => {
|
||||
// #ifdef H5
|
||||
const height = window.visualViewport?.height || window.innerHeight
|
||||
if (height > 0) {
|
||||
document.documentElement.style.setProperty('--meeting-vh', `${height}px`)
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
// ============ 生命周期 ============
|
||||
|
||||
let stateStopWatch = null
|
||||
@@ -787,6 +798,22 @@ onMounted(() => {
|
||||
// P2-3 修复:onLoad 已 redirectTo 跳转时,避免本页继续初始化定时器与 watcher
|
||||
if (redirectingToJoin) return
|
||||
|
||||
// #ifdef H5
|
||||
syncViewportHeight()
|
||||
const viewport = window.visualViewport
|
||||
const onViewportChange = () => syncViewportHeight()
|
||||
window.addEventListener('resize', onViewportChange)
|
||||
window.addEventListener('orientationchange', onViewportChange)
|
||||
viewport?.addEventListener?.('resize', onViewportChange)
|
||||
viewport?.addEventListener?.('scroll', onViewportChange)
|
||||
viewportCleanup = () => {
|
||||
window.removeEventListener('resize', onViewportChange)
|
||||
window.removeEventListener('orientationchange', onViewportChange)
|
||||
viewport?.removeEventListener?.('resize', onViewportChange)
|
||||
viewport?.removeEventListener?.('scroll', onViewportChange)
|
||||
}
|
||||
// #endif
|
||||
|
||||
const startIso = meetingStore.currentRoom?.started_at || meetingStore.currentRoom?.created_at
|
||||
startedAt.value = startIso ? Date.parse(startIso) : Date.now()
|
||||
timerHandle = setInterval(() => { nowTs.value = Date.now() }, 1000)
|
||||
@@ -807,6 +834,7 @@ onBeforeUnmount(() => {
|
||||
clearInterval(timerHandle)
|
||||
timerHandle = null
|
||||
}
|
||||
if (viewportCleanup) { viewportCleanup(); viewportCleanup = null }
|
||||
if (stateStopWatch) { stateStopWatch(); stateStopWatch = null }
|
||||
})
|
||||
|
||||
@@ -832,8 +860,18 @@ onUnload(() => {
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100svh;
|
||||
height: 100dvh;
|
||||
height: var(--meeting-vh, 100dvh);
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
min-height: 100svh;
|
||||
min-height: 100dvh;
|
||||
min-height: var(--meeting-vh, 100dvh);
|
||||
max-height: 100vh;
|
||||
max-height: 100svh;
|
||||
max-height: 100dvh;
|
||||
max-height: var(--meeting-vh, 100dvh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0F172A;
|
||||
@@ -1038,6 +1076,10 @@ onUnload(() => {
|
||||
.chat-col.open { width: 640rpx; }
|
||||
|
||||
@media (max-width: 750px) {
|
||||
.body {
|
||||
padding-bottom: calc(96rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* 小屏幕:聊天面板改为全屏浮层,避免挤压视频 */
|
||||
.chat-col { position: fixed; top: 0; right: 0; bottom: 0; z-index: 205; }
|
||||
.chat-col.open { width: 100vw; }
|
||||
|
||||
@@ -1294,7 +1294,14 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
// 屏幕共享对帧率要求低、清晰度要求高,控制带宽优先保证清晰度
|
||||
video: { frameRate: { ideal: 15, max: 30 } },
|
||||
video: {
|
||||
width: { ideal: 1920, max: 1920 },
|
||||
height: { ideal: 1080, max: 1080 },
|
||||
frameRate: { ideal: 15, max: 15 },
|
||||
cursor: 'always',
|
||||
displaySurface: 'monitor',
|
||||
logicalSurface: true
|
||||
},
|
||||
audio: false
|
||||
})
|
||||
const track = stream.getVideoTracks()[0]
|
||||
@@ -1313,6 +1320,17 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
producer = await _engine.produce({
|
||||
kind: 'video',
|
||||
track,
|
||||
encodings: [
|
||||
{
|
||||
maxBitrate: 2500000,
|
||||
maxFramerate: 15
|
||||
}
|
||||
],
|
||||
codecOptions: {
|
||||
videoGoogleStartBitrate: 1500,
|
||||
videoGoogleMinBitrate: 800,
|
||||
videoGoogleMaxBitrate: 2500
|
||||
},
|
||||
appData: { screen: true }
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -1321,6 +1339,12 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
throw err
|
||||
}
|
||||
localProducers.screen = producer.id
|
||||
if (typeof _engine.tuneProducerEncoding === 'function') {
|
||||
_engine.tuneProducerEncoding(producer.id, {
|
||||
maxBitrate: 2500000,
|
||||
maxFramerate: 15
|
||||
}).catch((err) => _log('warn', '[Meeting] 屏幕共享编码参数设置失败', err))
|
||||
}
|
||||
|
||||
// 浏览器原生"停止共享"按钮:用户从系统 UI 取消时 track 进入 ended,
|
||||
// 此时本地 producer.on('trackended') 也会触发关闭,这里加一层保险
|
||||
|
||||
@@ -314,6 +314,27 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa
|
||||
return producer
|
||||
}
|
||||
|
||||
const tuneProducerEncoding = async (producerId, encodingPatch = {}) => {
|
||||
const producer = producers.get(producerId)
|
||||
const sender = producer && producer.rtpSender
|
||||
if (!sender || typeof sender.getParameters !== 'function' || typeof sender.setParameters !== 'function') {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const params = sender.getParameters() || {}
|
||||
params.encodings = Array.isArray(params.encodings) && params.encodings.length
|
||||
? params.encodings
|
||||
: [{}]
|
||||
params.encodings = params.encodings.map((encoding) => ({ ...encoding, ...encodingPatch }))
|
||||
await sender.setParameters(params)
|
||||
logger('info', '[MediaEngine] producer 编码参数已更新', { producerId, encodingPatch })
|
||||
return true
|
||||
} catch (e) {
|
||||
logger('warn', '[MediaEngine] producer 编码参数更新失败', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅远端 Producer:请求后端创建 Consumer → 本地 consume → 等 track 挂好后 resume
|
||||
*
|
||||
@@ -480,6 +501,7 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa
|
||||
closeProducer,
|
||||
pauseProducer,
|
||||
resumeProducer,
|
||||
tuneProducerEncoding,
|
||||
closeConsumer,
|
||||
close,
|
||||
getDevice: () => device,
|
||||
|
||||
Reference in New Issue
Block a user