视频连线
This commit is contained in:
@@ -284,9 +284,26 @@ const emitIf = (name) => {
|
|||||||
}
|
}
|
||||||
.badge.badge-red { background: #EF4444; }
|
.badge.badge-red { background: #EF4444; }
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 750px) {
|
||||||
.btn { min-width: 88rpx; }
|
.toolbar {
|
||||||
.icon { width: 64rpx; height: 64rpx; }
|
position: fixed;
|
||||||
.label { font-size: 20rpx; }
|
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>
|
</style>
|
||||||
|
|||||||
@@ -49,12 +49,14 @@ const props = defineProps({
|
|||||||
audioTrack: { type: Object, default: null },
|
audioTrack: { type: Object, default: null },
|
||||||
videoTrack: { type: Object, default: null },
|
videoTrack: { type: Object, default: null },
|
||||||
isSpeaking: { type: Boolean, default: false },
|
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 mediaBox = ref(null)
|
||||||
|
|
||||||
const hasVideo = computed(() => !!props.videoTrack && props.videoEnabled)
|
const hasVideo = computed(() => !!props.videoTrack && props.videoEnabled)
|
||||||
|
const normalizedVideoFit = computed(() => props.videoFit === 'contain' ? 'contain' : 'cover')
|
||||||
|
|
||||||
const avatarInitial = computed(() => {
|
const avatarInitial = computed(() => {
|
||||||
const s = (props.name || '').trim()
|
const s = (props.name || '').trim()
|
||||||
@@ -82,7 +84,7 @@ const ensureMediaEl = (tagName) => {
|
|||||||
if (tagName === 'video') {
|
if (tagName === 'video') {
|
||||||
el.style.width = '100%'
|
el.style.width = '100%'
|
||||||
el.style.height = '100%'
|
el.style.height = '100%'
|
||||||
el.style.objectFit = 'cover'
|
el.style.objectFit = normalizedVideoFit.value
|
||||||
el.style.background = '#0B1220'
|
el.style.background = '#0B1220'
|
||||||
el.style.display = 'block'
|
el.style.display = 'block'
|
||||||
}
|
}
|
||||||
@@ -134,6 +136,7 @@ const applyVideo = (retry = 0) => {
|
|||||||
}
|
}
|
||||||
const el = ensureMediaEl('video')
|
const el = ensureMediaEl('video')
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
el.style.objectFit = normalizedVideoFit.value
|
||||||
// 关键:<video> 必须 muted 才能在 Chrome / 微信内核里自动播放。
|
// 关键:<video> 必须 muted 才能在 Chrome / 微信内核里自动播放。
|
||||||
// 音频在独立的 <audio> 元素上播放,所以此处 muted 不会让对方静音,
|
// 音频在独立的 <audio> 元素上播放,所以此处 muted 不会让对方静音,
|
||||||
// 同时彻底解决"进会黑屏,必须点切换按钮才出图"的问题。
|
// 同时彻底解决"进会黑屏,必须点切换按钮才出图"的问题。
|
||||||
@@ -183,7 +186,7 @@ const applyAudio = (retry = 0) => {
|
|||||||
// #endif
|
// #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 })
|
watch(() => [props.audioTrack, props.audioEnabled, props.isLocal], () => nextTick(() => applyAudio()), { immediate: true })
|
||||||
|
|
||||||
// 修复刚进入会议时画面不显示、必须点切换才出图的问题:
|
// 修复刚进入会议时画面不显示、必须点切换才出图的问题:
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
:video-enabled="true"
|
:video-enabled="true"
|
||||||
:audio-track="null"
|
:audio-track="null"
|
||||||
:video-track="screenTile.videoTrack"
|
:video-track="screenTile.videoTrack"
|
||||||
|
video-fit="contain"
|
||||||
:is-speaking="false"
|
:is-speaking="false"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
@@ -241,6 +242,7 @@ const networkLevel = ref(4) // 持续接入 getStats 后动态更新(Task 16
|
|||||||
const startedAt = ref(0)
|
const startedAt = ref(0)
|
||||||
const nowTs = ref(Date.now())
|
const nowTs = ref(Date.now())
|
||||||
let timerHandle = null
|
let timerHandle = null
|
||||||
|
let viewportCleanup = null
|
||||||
|
|
||||||
/** 视频区容器引用:传给 SelfVideoFloat 计算吸附边界 */
|
/** 视频区容器引用:传给 SelfVideoFloat 计算吸附边界 */
|
||||||
const videoCol = ref(null)
|
const videoCol = ref(null)
|
||||||
@@ -765,6 +767,15 @@ const redirectHome = () => {
|
|||||||
uni.reLaunch({ url: '/pages/index/index' })
|
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
|
let stateStopWatch = null
|
||||||
@@ -787,6 +798,22 @@ onMounted(() => {
|
|||||||
// P2-3 修复:onLoad 已 redirectTo 跳转时,避免本页继续初始化定时器与 watcher
|
// P2-3 修复:onLoad 已 redirectTo 跳转时,避免本页继续初始化定时器与 watcher
|
||||||
if (redirectingToJoin) return
|
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
|
const startIso = meetingStore.currentRoom?.started_at || meetingStore.currentRoom?.created_at
|
||||||
startedAt.value = startIso ? Date.parse(startIso) : Date.now()
|
startedAt.value = startIso ? Date.parse(startIso) : Date.now()
|
||||||
timerHandle = setInterval(() => { nowTs.value = Date.now() }, 1000)
|
timerHandle = setInterval(() => { nowTs.value = Date.now() }, 1000)
|
||||||
@@ -807,6 +834,7 @@ onBeforeUnmount(() => {
|
|||||||
clearInterval(timerHandle)
|
clearInterval(timerHandle)
|
||||||
timerHandle = null
|
timerHandle = null
|
||||||
}
|
}
|
||||||
|
if (viewportCleanup) { viewportCleanup(); viewportCleanup = null }
|
||||||
if (stateStopWatch) { stateStopWatch(); stateStopWatch = null }
|
if (stateStopWatch) { stateStopWatch(); stateStopWatch = null }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -832,8 +860,18 @@ onUnload(() => {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100svh;
|
||||||
|
height: 100dvh;
|
||||||
|
height: var(--meeting-vh, 100dvh);
|
||||||
min-width: 100vw;
|
min-width: 100vw;
|
||||||
min-height: 100vh;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #0F172A;
|
background: #0F172A;
|
||||||
@@ -1038,6 +1076,10 @@ onUnload(() => {
|
|||||||
.chat-col.open { width: 640rpx; }
|
.chat-col.open { width: 640rpx; }
|
||||||
|
|
||||||
@media (max-width: 750px) {
|
@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 { position: fixed; top: 0; right: 0; bottom: 0; z-index: 205; }
|
||||||
.chat-col.open { width: 100vw; }
|
.chat-col.open { width: 100vw; }
|
||||||
|
|||||||
@@ -1294,7 +1294,14 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
|
|
||||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
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
|
audio: false
|
||||||
})
|
})
|
||||||
const track = stream.getVideoTracks()[0]
|
const track = stream.getVideoTracks()[0]
|
||||||
@@ -1313,6 +1320,17 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
producer = await _engine.produce({
|
producer = await _engine.produce({
|
||||||
kind: 'video',
|
kind: 'video',
|
||||||
track,
|
track,
|
||||||
|
encodings: [
|
||||||
|
{
|
||||||
|
maxBitrate: 2500000,
|
||||||
|
maxFramerate: 15
|
||||||
|
}
|
||||||
|
],
|
||||||
|
codecOptions: {
|
||||||
|
videoGoogleStartBitrate: 1500,
|
||||||
|
videoGoogleMinBitrate: 800,
|
||||||
|
videoGoogleMaxBitrate: 2500
|
||||||
|
},
|
||||||
appData: { screen: true }
|
appData: { screen: true }
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1321,6 +1339,12 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
localProducers.screen = producer.id
|
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,
|
// 浏览器原生"停止共享"按钮:用户从系统 UI 取消时 track 进入 ended,
|
||||||
// 此时本地 producer.on('trackended') 也会触发关闭,这里加一层保险
|
// 此时本地 producer.on('trackended') 也会触发关闭,这里加一层保险
|
||||||
|
|||||||
@@ -314,6 +314,27 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa
|
|||||||
return producer
|
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
|
* 订阅远端 Producer:请求后端创建 Consumer → 本地 consume → 等 track 挂好后 resume
|
||||||
*
|
*
|
||||||
@@ -480,6 +501,7 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa
|
|||||||
closeProducer,
|
closeProducer,
|
||||||
pauseProducer,
|
pauseProducer,
|
||||||
resumeProducer,
|
resumeProducer,
|
||||||
|
tuneProducerEncoding,
|
||||||
closeConsumer,
|
closeConsumer,
|
||||||
close,
|
close,
|
||||||
getDevice: () => device,
|
getDevice: () => device,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export async function createWebRtcTransport(params: {
|
|||||||
enableUdp: true,
|
enableUdp: true,
|
||||||
enableTcp: true,
|
enableTcp: true,
|
||||||
preferUdp: true,
|
preferUdp: true,
|
||||||
initialAvailableOutgoingBitrate: 1_000_000,
|
initialAvailableOutgoingBitrate: 4_000_000,
|
||||||
appData: {
|
appData: {
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
direction: params.direction,
|
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', () => {
|
transport.observer.once('close', () => {
|
||||||
transportMap.delete(transport.id);
|
transportMap.delete(transport.id);
|
||||||
log.info(
|
log.info(
|
||||||
|
|||||||
Reference in New Issue
Block a user