Compare commits

..

10 Commits

Author SHA1 Message Date
duoaohui
96fca438f4 视频连线 2026-05-28 10:15:07 +08:00
duoaohui
a3f19c6245 视频连线 2026-05-27 15:04:50 +08:00
duoaohui
db7ea0b44a 视频连线 2026-05-27 14:38:55 +08:00
duoaohui
3dc4fa6563 视频连线 2026-05-27 12:55:42 +08:00
duoaohui
533f0b1e36 视频连线 2026-05-27 12:52:11 +08:00
duoaohui
ad1cfa2f0b 视频连线 2026-05-27 12:51:18 +08:00
4969677be1 上传文件至 frontend/src/static 2026-05-27 11:51:03 +08:00
duoaohui
a70f6d1b93 视频连线 2026-05-27 11:49:57 +08:00
duoaohui
f13a2e51ec 视频连线 2026-05-26 22:54:55 +08:00
duoaohui
601366d2a8 视频连线 2026-05-26 22:50:11 +08:00
20 changed files with 265 additions and 57 deletions

View File

@@ -81,7 +81,7 @@ var MeetingLeftReasonMap = map[string]string{
// 会议默认配置(应用层强制约束)
const (
MeetingMVPMaxMembers = 8 // MVP 阶段单会议硬上限
MeetingMVPMaxMembers = 1000000 // MVP 阶段单会议硬上限
MeetingRoomCodeLength = 9 // 会议号去连字符后的长度XXX-XXX-XXX
MeetingRoomCodeRetryMax = 3 // 生成会议号冲突重试上限
MeetingPasswordMaxAttempts = 5 // 密码连续错误锁定阈值

View File

@@ -64,7 +64,7 @@ type MeetingChatDTO struct {
type CreateMeetingRoomRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"` // 会议标题
Password string `json:"password" binding:"omitempty,min=4,max=20"` // 可选入会密码(纯文本入参,服务端 bcrypt 哈希存储)
MaxMembers int `json:"max_members" binding:"omitempty,min=2,max=8"` // 可选上限MVP 硬上限 8留参数为 Phase 2e-3 扩展)
MaxMembers int `json:"max_members" binding:"omitempty,min=2"` // 可选上限
}
// CreateMeetingRoomResponse 创建会议响应

View File

@@ -250,7 +250,6 @@ func (ctl *MeetingController) CloudLawCreateMeeting(c *gin.Context) {
}
room, _, _, err := ctl.meetingService.CreateRoomForInternal(c.Request.Context(), hostUser.ID, &dto.CreateMeetingRoomRequest{
Title: title,
MaxMembers: 2,
})
if err != nil {
ctl.handleError(c, err, "创建云律会议失败")

View File

@@ -13,7 +13,7 @@ type MeetingRoom struct {
HostID int64 `json:"host_id" gorm:"not null;index:idx_meeting_rooms_host_status,priority:1"` // 主持人用户 ID
Type int `json:"type" gorm:"not null;default:1"` // 会议类型1=即时2=预约
PasswordHash *string `json:"-" gorm:"size:255"` // 入会密码 bcrypt 哈希NULL 表示无密码JSON 始终剥离
MaxMembers int `json:"max_members" gorm:"not null;default:50"` // 房间最大成员数MVP 应用层强制 8
MaxMembers int `json:"max_members" gorm:"not null;default:50"` // 房间最大成员数
Status int `json:"status" gorm:"not null;default:0;index:idx_meeting_rooms_host_status,priority:2"` // 状态0=未开始1=进行中2=已结束
ScheduledAt *time.Time `json:"scheduled_at" gorm:"type:timestamp(0)"` // 预约开始时间,即时会议为 NULL
StartedAt *time.Time `json:"started_at" gorm:"type:timestamp(0)"` // 实际开始时间

View File

@@ -398,13 +398,6 @@ func (s *MeetingService) JoinRoomForInternal(ctx context.Context, userID int64,
routerID, _ := s.mediaOrchestrator.ResolveRouterID(code)
return room, existing, routerID, nil
}
activeCount, err := s.participantDAO.CountActiveByRoom(ctx, room.ID)
if err != nil {
return nil, nil, "", err
}
if int(activeCount) >= room.MaxMembers {
return nil, nil, "", ErrMeetingFull
}
participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant)
if err != nil {
if errors.Is(err, dao.ErrAlreadyInMeeting) {
@@ -635,14 +628,6 @@ func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, passw
s.redis.Del(ctx, redisPasswordAttemptPrefix+code+":"+strconv.FormatInt(userID, 10))
}
activeCount, err := s.participantDAO.CountActiveByRoom(ctx, room.ID)
if err != nil {
return nil, nil, "", err
}
if int(activeCount) >= room.MaxMembers {
return nil, nil, "", ErrMeetingFull
}
participant, err := s.participantDAO.JoinRoom(ctx, room.ID, userID, constants.MeetingRoleParticipant)
if err != nil {
return nil, nil, "", err

View File

@@ -440,7 +440,7 @@ COMMENT ON COLUMN meeting_rooms.title IS '会议标题';
COMMENT ON COLUMN meeting_rooms.host_id IS '主持人用户 ID引用 auth_users.id';
COMMENT ON COLUMN meeting_rooms.type IS '会议类型1=即时MVP 仅此2=预约Phase 2e-3';
COMMENT ON COLUMN meeting_rooms.password_hash IS '入会密码 bcrypt 哈希NULL 表示无密码';
COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数MVP 应用层强制 8schema 默认 50 供后续扩展';
COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数schema 默认 50';
COMMENT ON COLUMN meeting_rooms.status IS '0=未开始仅预约1=进行中2=已结束';
COMMENT ON COLUMN meeting_rooms.scheduled_at IS '预约开始时间,即时会议为 NULL';
COMMENT ON COLUMN meeting_rooms.started_at IS '实际开始时间host 首次加入时回填';

View File

@@ -29,7 +29,7 @@ COMMENT ON COLUMN meeting_rooms.title IS '会议标题';
COMMENT ON COLUMN meeting_rooms.host_id IS '主持人用户 ID引用 auth_users.id';
COMMENT ON COLUMN meeting_rooms.type IS '会议类型1=即时MVP 仅此2=预约Phase 2e-3';
COMMENT ON COLUMN meeting_rooms.password_hash IS '入会密码 bcrypt 哈希NULL 表示无密码';
COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数MVP 应用层强制 8schema 默认 50 供后续扩展';
COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数schema 默认 50';
COMMENT ON COLUMN meeting_rooms.status IS '0=未开始仅预约1=进行中2=已结束';
COMMENT ON COLUMN meeting_rooms.scheduled_at IS '预约开始时间,即时会议为 NULL';
COMMENT ON COLUMN meeting_rooms.started_at IS '实际开始时间host 首次加入时回填';

View File

@@ -28,7 +28,7 @@
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
</view>
<text class="label">{{ audioEnabled ? '静音' : '解除' }}</text>
<text class="label">{{ audioEnabled ? '静音' : '开麦' }}</text>
</button>
<button class="btn" :class="{ active: videoEnabled, loading: videoLoading }" :disabled="videoLoading" @click="emitIf('cam-toggle')">
@@ -42,11 +42,14 @@
<path d="M16 16v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v4m0 0l6-4v10"></path>
</svg>
</view>
<text class="label">{{ videoEnabled ? '停止视频' : '开启视频' }}</text>
<text class="label">
<text class="label-desktop">{{ videoEnabled ? '停止视频' : '开启视频' }}</text>
<text class="label-mobile">视频</text>
</text>
</button>
<button
class="btn"
class="btn btn-screen"
:class="{ active: screenSharing, loading: screenLoading }"
:disabled="screenLoading || screenDisabled"
:title="screenDisabledReason || ''"
@@ -70,7 +73,10 @@
<line x1="12" y1="7" x2="12" y2="14"></line>
</svg>
</view>
<text class="label">{{ screenSharing ? '停止共享' : '共享屏幕' }}</text>
<text class="label">
<text class="label-desktop">{{ screenSharing ? '停止共享' : '共享屏幕' }}</text>
<text class="label-mobile">共享</text>
</text>
</button>
<!-- 录制按钮Phase B仅主持人可见recording=true 时红色脉动上传中禁用 -->
@@ -90,7 +96,10 @@
<circle cx="12" cy="12" r="3"></circle>
</svg>
</view>
<text class="label">{{ recording ? '停止录制' : '开始录制' }}</text>
<text class="label">
<text class="label-desktop">{{ recording ? '停止录制' : '开始录制' }}</text>
<text class="label-mobile">录制</text>
</text>
</button>
<button class="btn" @click="emitIf('invite')">
@@ -251,6 +260,7 @@ const emitIf = (name) => {
letter-spacing: 0.5rpx;
color: inherit;
}
.label-mobile { display: none; }
.btn-leave .icon { background: #EF4444; }
.btn-leave:hover .icon { background: #DC2626; }
@@ -284,9 +294,91 @@ 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: 14rpx;
right: 14rpx;
bottom: calc(12rpx + env(safe-area-inset-bottom));
justify-content: space-between;
gap: 6rpx;
box-sizing: border-box;
padding: 14rpx 14rpx;
background: rgba(7, 15, 30, 0.9);
backdrop-filter: blur(18px);
border: 1rpx solid rgba(148, 163, 184, 0.18);
border-radius: 32rpx;
box-shadow: 0 12rpx 34rpx rgba(0, 0, 0, 0.42);
overflow-x: hidden;
overflow-y: hidden;
}
.btn {
min-width: 0;
flex: 1 1 0;
gap: 6rpx;
padding: 4rpx 0;
border-radius: 18rpx;
}
.btn:hover { background: transparent; }
.btn:active { background: rgba(255, 255, 255, 0.08); }
.btn-screen {
display: none;
}
.icon {
width: 56rpx;
height: 56rpx;
background: rgba(51, 65, 85, 0.92);
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.08);
}
.icon svg {
width: 40rpx;
height: 40rpx;
}
.btn.active .icon { background: #2563EB; }
.btn[disabled] .icon {
background: rgba(30, 41, 59, 0.72);
color: rgba(255, 255, 255, 0.38);
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.05);
}
.btn[disabled] .label { color: rgba(255, 255, 255, 0.36); }
.btn-leave .icon { background: #EF4444; }
.btn-leave .label { color: #FECACA; }
.label {
max-width: 100%;
font-size: 19rpx;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.label-desktop { display: none; }
.label-mobile { display: inline; }
.badge {
top: -8rpx;
right: -8rpx;
min-width: 28rpx;
height: 28rpx;
font-size: 16rpx;
}
}
@media (max-width: 380px) {
.toolbar {
left: 10rpx;
right: 10rpx;
padding-left: 8rpx;
padding-right: 8rpx;
}
.icon {
width: 50rpx;
height: 50rpx;
}
.icon svg {
width: 36rpx;
height: 36rpx;
}
.label {
font-size: 17rpx;
}
}
</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 })
// 修复刚进入会议时画面不显示、必须点切换才出图的问题:
@@ -350,4 +353,25 @@ onBeforeUnmount(() => {
.tile.compact .footer { padding: 8rpx 12rpx; }
.tile.compact .name { font-size: 20rpx; }
.tile.compact .tag { font-size: 18rpx; padding: 2rpx 8rpx; }
@media (max-width: 750px) {
.footer {
left: 12rpx;
right: 12rpx;
bottom: 12rpx;
padding: 8rpx 12rpx;
border-radius: 14rpx;
background: rgba(2, 6, 23, 0.62);
backdrop-filter: blur(10px);
}
.name {
font-size: 20rpx;
}
.tile.compact .footer {
left: 8rpx;
right: 8rpx;
bottom: 8rpx;
padding: 6rpx 10rpx;
}
}
</style>

View File

@@ -90,7 +90,7 @@ export const MEETING_HOST_AUTO_REASON_LABEL = {
// ==================== 默认配置常量 ====================
export const MEETING_MVP_MAX_MEMBERS = 8 // MVP 单会议硬上限
export const MEETING_MVP_MAX_MEMBERS = 1000000 // MVP 单会议硬上限
export const MEETING_HOST_GRACE_SECONDS = 120 // 主持人掉线宽限期Task 8
export const MEETING_EMPTY_ROOM_TTL_SECONDS = 300 // 空房销毁 TTLTask 8
export const MEETING_WS_ACK_TIMEOUT_MS = 10000 // WS 事件 ACK 等待超时Task 9

View File

@@ -9,7 +9,7 @@
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "EchoChat",
"navigationBarTitleText": "赏讼视频会议系统",
"navigationStyle": "custom"
}
},
@@ -227,7 +227,7 @@
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "EchoChat",
"navigationBarTitleText": "赏讼视频会议系统",
"navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F8FAFC"
}

View File

@@ -20,9 +20,9 @@
<!-- 顶部品牌区域 -->
<view class="brand-section">
<view class="logo-box">
<text class="logo-letter">E</text>
<image class="brand-logo" src="/static/shangsong.png" mode="aspectFit" />
</view>
<text class="brand-name">EchoChat</text>
<text class="brand-name">赏讼视频会议系统</text>
<text class="brand-slogan">连接无限沟通无界</text>
</view>
@@ -80,8 +80,14 @@
<view class="oauth-line"></view>
</view>
<view class="oauth-buttons">
<button class="oauth-btn oauth-wechat" :disabled="oauthLoading" @tap="oauthLogin('wechat')">微信登录</button>
<button class="oauth-btn oauth-qq" :disabled="oauthLoading" @tap="oauthLogin('qq')">QQ登录</button>
<button class="oauth-btn oauth-wechat" :disabled="oauthLoading" @tap="oauthLogin('wechat')">
<image class="oauth-icon" src="/static/weixin.png" mode="aspectFit" />
<text>微信登录</text>
</button>
<button class="oauth-btn oauth-qq" :disabled="oauthLoading" @tap="oauthLogin('qq')">
<image class="oauth-icon" src="/static/qq.png" mode="aspectFit" />
<text>QQ登录</text>
</button>
</view>
</view>
@@ -272,17 +278,14 @@ export default {
.logo-box {
width: 112rpx;
height: 112rpx;
border-radius: 24rpx;
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
}
.logo-letter {
font-size: 52rpx;
font-weight: 700;
color: #FFFFFF;
.brand-logo {
width: 112rpx;
height: 112rpx;
}
.brand-name {
@@ -433,6 +436,10 @@ export default {
font-size: 28rpx;
color: #FFFFFF;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
}
.oauth-btn::after {
@@ -447,6 +454,11 @@ export default {
background-color: #12B7F5;
}
.oauth-icon {
width: 52rpx;
height: 52rpx;
}
/* ---- 底部链接 ---- */
.link-row {
display: flex;

View File

@@ -20,10 +20,10 @@
<!-- 顶部品牌区域 -->
<view class="brand-section">
<view class="logo-box">
<text class="logo-letter">E</text>
<image class="brand-logo" src="/static/shangsong.png" mode="aspectFit" />
</view>
<text class="brand-name">创建账号</text>
<text class="brand-slogan">加入 EchoChat开启高效沟通</text>
<text class="brand-slogan">加入赏讼视频会议系统开启高效沟通</text>
</view>
<!-- 表单卡片 -->
@@ -271,17 +271,14 @@ export default {
.logo-box {
width: 96rpx;
height: 96rpx;
border-radius: 20rpx;
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
}
.logo-letter {
font-size: 44rpx;
font-weight: 700;
color: #FFFFFF;
.brand-logo {
width: 96rpx;
height: 96rpx;
}
.brand-name {

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(142rpx + 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; }

BIN
frontend/src/static/qq.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

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(