feat(meeting): 支持房间结束时间管理及投屏双流模式
- 新增房间结束时间字段,支持老师端设置和续期 - 实现投屏双流模式,老师可同时展示摄像头和屏幕共享 - 学生端支持切换主画面显示摄像头或投屏内容 - 优化屏幕共享实现,使用独立声网客户端避免轨道冲突 - 增加设备冲突检测逻辑,区分教室端、监控端和学生端入口
This commit is contained in:
@ -6,7 +6,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '../../plugins/redis/redis.service';
|
||||
import type { Redis } from 'ioredis';
|
||||
import type { BlacklistUser, UserPermissionState } from './types';
|
||||
import type { BlacklistUser, JoinMode, UserPermissionState } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class MeetingRedisService {
|
||||
@ -63,6 +63,7 @@ export class MeetingRedisService {
|
||||
* - classStatus: 课堂状态(not_started | in_class | finished)
|
||||
* - speakerUid: 当前主讲人短 UID(可选)
|
||||
* - teacherUid: 老师短 UID(可选)
|
||||
* - endTimestamp: 房间结束时间(毫秒时间戳,可选)
|
||||
* 适用场景:同步课堂状态、切换主讲人、判断课程是否在进行中
|
||||
* 过期时间:24 小时
|
||||
*/
|
||||
@ -115,18 +116,32 @@ export class MeetingRedisService {
|
||||
async getUserState(roomId: string, shortUid: number): Promise<UserPermissionState> {
|
||||
const key = this.getUserStateKey(roomId, shortUid);
|
||||
const map = await this.getClient().hgetall(key);
|
||||
let socketEntries: Array<{ socketId: string; joinMode: JoinMode }> = [];
|
||||
if (map?.socketEntries) {
|
||||
try {
|
||||
socketEntries = JSON.parse(map.socketEntries);
|
||||
} catch {
|
||||
socketEntries = [];
|
||||
}
|
||||
}
|
||||
|
||||
let socketIds: string[] = [];
|
||||
if (map?.socketIds) {
|
||||
if (socketEntries.length > 0) {
|
||||
socketIds = [...new Set(socketEntries.map((e) => e.socketId).filter(Boolean))];
|
||||
} else if (map?.socketIds) {
|
||||
try {
|
||||
socketIds = JSON.parse(map.socketIds);
|
||||
socketEntries = socketIds.map((socketId) => ({ socketId, joinMode: 'classroom' }));
|
||||
} catch {
|
||||
socketIds = [];
|
||||
socketEntries = [];
|
||||
}
|
||||
}
|
||||
return {
|
||||
isAudioMuted: map?.isAudioMuted === '1',
|
||||
isVideoMuted: map?.isVideoMuted === '1',
|
||||
socketIds,
|
||||
socketEntries,
|
||||
};
|
||||
}
|
||||
|
||||
@ -223,12 +238,17 @@ export class MeetingRedisService {
|
||||
* @param roomId - 房间 ID
|
||||
* @param screenShareUid - 投屏人短 UID,null 表示停止投屏
|
||||
*/
|
||||
async setScreenSharing(roomId: string, screenShareUid: number | null): Promise<void> {
|
||||
async setScreenSharing(roomId: string, screenShareUid: number | null, screenShareOwnerUid?: number | null): Promise<void> {
|
||||
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
||||
if (screenShareUid === null) {
|
||||
await this.getClient().hdel(key, 'screenShareUid');
|
||||
await this.getClient().hdel(key, 'screenShareOwnerUid');
|
||||
} else {
|
||||
await this.getClient().hset(key, { screenShareUid: String(screenShareUid) });
|
||||
const hm: Record<string, string> = { screenShareUid: String(screenShareUid) };
|
||||
if (typeof screenShareOwnerUid === 'number' && Number.isFinite(screenShareOwnerUid) && screenShareOwnerUid > 0) {
|
||||
hm.screenShareOwnerUid = String(screenShareOwnerUid);
|
||||
}
|
||||
await this.getClient().hset(key, hm);
|
||||
await this.getClient().expire(key, 24 * 60 * 60);
|
||||
}
|
||||
}
|
||||
@ -267,22 +287,67 @@ export class MeetingRedisService {
|
||||
classStatus: 'finished' | 'in_class' | 'not_started';
|
||||
speakerUid?: number;
|
||||
screenShareUid?: number;
|
||||
screenShareOwnerUid?: number;
|
||||
teacherUid?: number;
|
||||
endTimestamp?: number;
|
||||
}> {
|
||||
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
||||
const map = await this.getClient().hgetall(key);
|
||||
const status = (map?.classStatus as any) || 'not_started';
|
||||
const speakerUid = map?.speakerUid ? Number(map.speakerUid) : undefined;
|
||||
const screenShareUid = map?.screenShareUid ? Number(map.screenShareUid) : undefined;
|
||||
const screenShareOwnerUid = map?.screenShareOwnerUid ? Number(map.screenShareOwnerUid) : undefined;
|
||||
const teacherUid = map?.teacherUid ? Number(map.teacherUid) : undefined;
|
||||
const endTimestamp = map?.endTimestamp ? Number(map.endTimestamp) : undefined;
|
||||
return {
|
||||
classStatus: status,
|
||||
...(Number.isFinite(speakerUid) ? { speakerUid } : {}),
|
||||
...(Number.isFinite(screenShareUid) ? { screenShareUid } : {}),
|
||||
...(Number.isFinite(screenShareOwnerUid) ? { screenShareOwnerUid } : {}),
|
||||
...(Number.isFinite(teacherUid) ? { teacherUid } : {}),
|
||||
...(Number.isFinite(endTimestamp) ? { endTimestamp } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置房间结束时间
|
||||
* @param roomId - 房间 ID
|
||||
* @param endTimestamp - 房间结束时间(毫秒时间戳)
|
||||
* @param mode - 写入模式:max 表示只允许延长,不允许缩短
|
||||
*/
|
||||
async setRoomEndTimestamp(roomId: string, endTimestamp: number, mode: 'max' | 'overwrite' = 'max'): Promise<void> {
|
||||
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
||||
const next = Number(endTimestamp);
|
||||
if (!Number.isFinite(next) || next <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'max') {
|
||||
const current = await this.getClient().hget(key, 'endTimestamp');
|
||||
const currentNum = current ? Number(current) : NaN;
|
||||
if (Number.isFinite(currentNum) && currentNum > 0 && currentNum >= next) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.getClient().hset(key, { endTimestamp: String(next) });
|
||||
await this.getClient().expire(key, 24 * 60 * 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取房间结束时间
|
||||
* @param roomId - 房间 ID
|
||||
*/
|
||||
async getRoomEndTimestamp(roomId: string): Promise<number | null> {
|
||||
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
||||
const v = await this.getClient().hget(key, 'endTimestamp');
|
||||
const t = v ? Number(v) : NaN;
|
||||
if (!Number.isFinite(t) || t <= 0) {
|
||||
return null;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// ==================== Socket 连接管理 ====================
|
||||
|
||||
/**
|
||||
@ -290,18 +355,18 @@ export class MeetingRedisService {
|
||||
* @param roomId - 房间 ID
|
||||
* @param shortUid - 短 UID
|
||||
* @param socketId - Socket.IO 连接 ID
|
||||
* @param joinMode - 加入方式(课程/监控/学生)
|
||||
*/
|
||||
async addSocket(roomId: string, shortUid: number, socketId: string): Promise<void> {
|
||||
async addSocket(roomId: string, shortUid: number, socketId: string, joinMode: JoinMode): Promise<void> {
|
||||
const key = this.getUserStateKey(roomId, shortUid);
|
||||
// 获取当前 socketIds
|
||||
const currentState = await this.getUserState(roomId, shortUid);
|
||||
const socketIds = currentState.socketIds || [];
|
||||
// 添加新 socketId(如果不存在)
|
||||
if (!socketIds.includes(socketId)) {
|
||||
socketIds.push(socketId);
|
||||
const socketEntries = Array.isArray(currentState.socketEntries) ? currentState.socketEntries : [];
|
||||
const existed = socketEntries.some((e) => e.socketId === socketId);
|
||||
if (!existed) {
|
||||
socketEntries.push({ socketId, joinMode });
|
||||
}
|
||||
// 存储到 Redis
|
||||
await this.getClient().hset(key, { socketIds: JSON.stringify(socketIds) });
|
||||
const socketIds = [...new Set(socketEntries.map((e) => e.socketId).filter(Boolean))];
|
||||
await this.getClient().hset(key, { socketIds: JSON.stringify(socketIds), socketEntries: JSON.stringify(socketEntries) });
|
||||
await this.getClient().expire(key, 24 * 60 * 60);
|
||||
}
|
||||
|
||||
@ -313,15 +378,19 @@ export class MeetingRedisService {
|
||||
*/
|
||||
async removeSocket(roomId: string, shortUid: number, socketId: string): Promise<void> {
|
||||
const key = this.getUserStateKey(roomId, shortUid);
|
||||
// 获取当前 socketIds
|
||||
const currentState = await this.getUserState(roomId, shortUid);
|
||||
const socketIds = currentState.socketIds || [];
|
||||
// 移除指定的 socketId
|
||||
const newSocketIds = socketIds.filter((id) => id !== socketId);
|
||||
if (newSocketIds.length > 0) {
|
||||
await this.getClient().hset(key, { socketIds: JSON.stringify(newSocketIds) });
|
||||
const socketEntries = Array.isArray(currentState.socketEntries) ? currentState.socketEntries : [];
|
||||
const nextEntries = socketEntries.length > 0 ? socketEntries.filter((e) => e.socketId !== socketId) : [];
|
||||
const nextSocketIds =
|
||||
nextEntries.length > 0 ? [...new Set(nextEntries.map((e) => e.socketId).filter(Boolean))] : (currentState.socketIds || []).filter((id) => id !== socketId);
|
||||
|
||||
if (nextSocketIds.length > 0) {
|
||||
const hm: Record<string, string> = { socketIds: JSON.stringify(nextSocketIds) };
|
||||
if (socketEntries.length > 0) {
|
||||
hm.socketEntries = JSON.stringify(nextEntries);
|
||||
}
|
||||
await this.getClient().hset(key, hm);
|
||||
} else {
|
||||
// 如果没有 socketId 了,删除整个 key(用户离开)
|
||||
await this.getClient().del(key);
|
||||
}
|
||||
}
|
||||
@ -330,10 +399,14 @@ export class MeetingRedisService {
|
||||
* 获取用户的所有 Socket 连接 ID
|
||||
* @param roomId - 房间 ID
|
||||
* @param shortUid - 短 UID
|
||||
* @param joinMode - 加入方式(可选;用于多端共存的场景)
|
||||
* @returns Socket 连接 ID 数组
|
||||
*/
|
||||
async getSocketIds(roomId: string, shortUid: number): Promise<string[]> {
|
||||
async getSocketIds(roomId: string, shortUid: number, joinMode?: JoinMode): Promise<string[]> {
|
||||
const state = await this.getUserState(roomId, shortUid);
|
||||
if (joinMode && Array.isArray(state.socketEntries) && state.socketEntries.length > 0) {
|
||||
return state.socketEntries.filter((e) => e.joinMode === joinMode).map((e) => e.socketId);
|
||||
}
|
||||
return state.socketIds || [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user