视频连线

This commit is contained in:
duoaohui
2026-05-27 14:38:55 +08:00
parent 3dc4fa6563
commit db7ea0b44a
6 changed files with 128 additions and 9 deletions

View File

@@ -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>

View File

@@ -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 })
// 修复刚进入会议时画面不显示、必须点切换才出图的问题:

View File

@@ -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; }

View File

@@ -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') 也会触发关闭,这里加一层保险

View File

@@ -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,

View File

@@ -55,7 +55,7 @@ export async function createWebRtcTransport(params: {
enableUdp: true,
enableTcp: true,
preferUdp: true,
initialAvailableOutgoingBitrate: 1_000_000,
initialAvailableOutgoingBitrate: 4_000_000,
appData: {
userId: params.userId,
direction: params.direction,
@@ -63,6 +63,17 @@ export async function createWebRtcTransport(params: {
},
});
if (params.direction === 'send') {
try {
await transport.setMaxIncomingBitrate(4_000_000);
} catch (err) {
log.warn(
{ transportId: transport.id, err: err instanceof Error ? err.message : String(err) },
'set max incoming bitrate failed',
);
}
}
transport.observer.once('close', () => {
transportMap.delete(transport.id);
log.info(