feat(meeting): 支持房间结束时间管理及投屏双流模式

- 新增房间结束时间字段,支持老师端设置和续期
- 实现投屏双流模式,老师可同时展示摄像头和屏幕共享
- 学生端支持切换主画面显示摄像头或投屏内容
- 优化屏幕共享实现,使用独立声网客户端避免轨道冲突
- 增加设备冲突检测逻辑,区分教室端、监控端和学生端入口
This commit is contained in:
2026-03-13 17:45:51 +08:00
parent d09d3645ea
commit 3ac83f1b94
10 changed files with 768 additions and 334 deletions

View File

@ -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 - 投屏人短 UIDnull 表示停止投屏
*/
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 || [];
}

View File

@ -18,6 +18,7 @@ import { LoggerService } from '@/plugins/logger/logger.service';
import type {
ClientToServerMessageType,
ErrorMessage,
JoinMode,
JoinRoomData,
KickUserData,
MeetingNamespace,
@ -42,6 +43,9 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
// 注入 Socket.IO namespace带泛型确保 server.in().fetchSockets() 等返回强类型)
public server: MeetingNamespace | null = null;
private readonly roomExpiryTimers = new Map<string, NodeJS.Timeout>();
private readonly roomExpiryLastNotifiedMinute = new Map<string, number>();
public constructor(
// 房间/用户状态Redis 持久化
private readonly redisService: MeetingRedisService,
@ -92,10 +96,69 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
// 如果房间内没有 socket 连接,则清理 Redis 数据
if (sockets.length === 0) {
await this.redisService.clearRoomAll(roomId);
this.clearRoomExpiryTimer(roomId);
this.log(`[会议] 房间已空,已清理 Redis 状态roomId=${roomId}`);
}
}
private clearRoomExpiryTimer(roomId: string): void {
const timer = this.roomExpiryTimers.get(roomId);
if (timer) {
clearInterval(timer);
}
this.roomExpiryTimers.delete(roomId);
this.roomExpiryLastNotifiedMinute.delete(roomId);
}
private ensureRoomExpiryTimer(roomId: string): void {
if (this.roomExpiryTimers.has(roomId)) {
return;
}
const timer = setInterval(async () => {
try {
const endTimestamp = await this.redisService.getRoomEndTimestamp(roomId);
if (!endTimestamp) {
return;
}
const now = Date.now();
const remainingMs = endTimestamp - now;
if (remainingMs <= 0) {
return;
}
if (remainingMs > 5 * 60 * 1000) {
return;
}
const currentMinute = Math.floor(now / 60000);
const lastMinute = this.roomExpiryLastNotifiedMinute.get(roomId);
if (lastMinute === currentMinute) {
return;
}
this.roomExpiryLastNotifiedMinute.set(roomId, currentMinute);
const ns = this.server;
if (!ns) {
return;
}
const sockets = await ns.in(roomId).fetchSockets();
const teacherSockets = sockets.filter((s) => s.data.user?.role !== 0);
for (const s of teacherSockets) {
ns.to(s.id).emit('message', {
type: 'sev_room_will_expire',
data: { fromRoomId: roomId, endTimestamp, remainingMs },
});
}
} catch (error) {
this.logger.error({ error }, `[会议] 房间到期提醒定时器执行失败: roomId=${roomId}`, 'MeetingWebSocket');
}
}, 30 * 1000);
this.roomExpiryTimers.set(roomId, timer);
}
/**
* 初始化后,在 namespace 层添加连接鉴权 middleware
* @param server - 注入的 Socket.IO namespace带泛型确保 server.in().fetchSockets() 等返回强类型)
@ -143,8 +206,8 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {});
}
// 如果是老师(创建者)主动断开连接,发送下课消息给所有人
if (roomId && isHost) {
// 如果是老师(创建者)在“教室端”主动断开连接,发送下课消息给所有人
if (roomId && isHost && socket.data.joinMode === 'classroom') {
this.log(`老师创建者断开连接发送下课消息roomId=${roomId}`);
// 设置课堂状态为已结束
await this.redisService.setClassStatus(roomId, 'finished');
@ -152,6 +215,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
this.server?.to(roomId).emit('message', { type: 'sev_class_ended', data: { fromRoomId: roomId } });
// 清理 Redis 数据
await this.redisService.clearRoomAll(roomId);
this.clearRoomExpiryTimer(roomId);
}
if (roomId) {
@ -177,6 +241,9 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
// 从鉴权后的 user 中获取 shortUid鉴权通过就有短 ID
const shortUid = socket.data.user?.shortUid;
const userName = socket.data.user?.userName || data.userName || '用户';
const isHost = socket.data.user?.role !== 0;
const joinMode: JoinMode =
data?.joinMode === 'classroom' || data?.joinMode === 'monitor' || data?.joinMode === 'student' ? data.joinMode : isHost ? 'classroom' : 'student';
if (!shortUid) {
this.logger.warn({}, '[会议] 用户未认证,无 shortUid', 'MeetingWebSocket');
@ -196,7 +263,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
}
// 检查该用户是否已在其他设备加入了房间
const existingSocketIds = await this.redisService.getSocketIds(roomId, shortUid);
const existingSocketIds = await this.redisService.getSocketIds(roomId, shortUid, joinMode);
if (existingSocketIds.length > 0) {
// 过滤掉当前 socket 自己的连接(同一设备刷新等情况)
const otherDeviceSocketIds = existingSocketIds.filter((id) => id !== socket.id);
@ -217,7 +284,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
if (result.isValid && result.sockets) {
this.server?.to(result.oldSocketId).emit('message', {
type: 'sev_device_conflict',
data: { fromRoomId: roomId, reason: '您已在其他设备进入课程', targetUid: shortUid },
data: { fromRoomId: roomId, reason: '您已在其他设备进入相同入口', targetUid: shortUid },
} satisfies SucceedMessage);
result.sockets.forEach((s) => s.disconnect(true));
validSocketIds.push(result.oldSocketId);
@ -243,9 +310,10 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
socket.join(roomId);
socket.data.roomId = roomId;
socket.data.courseRoomId = courseRoomId;
socket.data.joinMode = joinMode;
// 注册 socketId 到 Redis用于踢人、禁麦等控制功能
await this.redisService.addSocket(roomId, shortUid, socket.id);
await this.redisService.addSocket(roomId, shortUid, socket.id, joinMode);
// 添加用户到房间用户列表
await this.redisService.addUserToRoom(roomId, shortUid);
@ -265,6 +333,17 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
await this.redisService.setSpeaker(roomId, speakerUid).catch(() => {});
}
if (isHost && typeof data.endTimestamp === 'number') {
const now = Date.now();
const nextEndTimestamp = Number(data.endTimestamp);
if (Number.isFinite(nextEndTimestamp) && nextEndTimestamp >= now + 5 * 60 * 1000) {
await this.redisService.setRoomEndTimestamp(roomId, nextEndTimestamp, 'max').catch(() => {});
}
}
const resolvedEndTimestamp = await this.redisService.getRoomEndTimestamp(roomId).catch(() => null);
this.ensureRoomExpiryTimer(roomId);
// 下发 join 成功包:包含 roomId/shortUid/tokenInfo/恢复状态/房间状态
socket.emit('message', {
type: 'sev_join_room',
@ -276,8 +355,10 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
isVideoMuted: userState.isVideoMuted,
classStatus: roomState.classStatus,
screenShareUid: roomState.screenShareUid,
screenShareOwnerUid: roomState.screenShareOwnerUid,
speakerUid,
teacherUid,
...(resolvedEndTimestamp ? { endTimestamp: resolvedEndTimestamp } : {}),
},
});
@ -290,6 +371,35 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
}
}
@SubscribeMessage<ClientToServerMessageType>('client_renew_room_end_timestamp')
public async handleRenewRoomEndTimestamp(
@MessageBody() data: { roomId: string; endTimestamp: number },
@ConnectedSocket() socket: MeetingSocket
): Promise<void> {
if (!data?.roomId || typeof data.endTimestamp !== 'number') {
return;
}
const isHost = socket.data.user?.role !== 0;
if (!isHost) {
return;
}
const now = Date.now();
const nextEndTimestamp = Number(data.endTimestamp);
if (!Number.isFinite(nextEndTimestamp) || nextEndTimestamp < now + 5 * 60 * 1000) {
socket.emit('message', { type: 'error', data: { reason: '结束时间不能小于当前时间的5分钟' } } satisfies ErrorMessage);
return;
}
await this.redisService.setRoomEndTimestamp(data.roomId, nextEndTimestamp, 'max');
this.ensureRoomExpiryTimer(data.roomId);
this.server?.to(data.roomId).emit('message', {
type: 'sev_room_end_timestamp_updated',
data: { fromRoomId: data.roomId, endTimestamp: nextEndTimestamp },
});
this.log(`房间 ${data.roomId} 已续期,新的结束时间: ${nextEndTimestamp}`);
}
@SubscribeMessage<ClientToServerMessageType>('client_leave_room')
public async handleLeaveRoom(@MessageBody() data: { courseRoomId?: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
// Socket.IO 会自动处理离房逻辑,这里仅记录
@ -299,16 +409,13 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
if (roomId && Number.isFinite(shortUid) && shortUid > 0) {
// 移除 socketId 映射
await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {});
// 从房间用户列表移除
await this.redisService.removeUserFromRoom(roomId, shortUid).catch(() => {});
// 清理用户状态
await this.redisService.clearUserState(roomId, shortUid).catch(() => {});
}
try {
roomId && socket.leave(roomId);
} catch {}
socket.data.roomId = undefined;
socket.data.courseRoomId = undefined;
socket.data.joinMode = undefined;
if (roomId) {
await this.cleanupRoomIfEmpty(roomId);
}
@ -548,8 +655,8 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
* 处理开始投屏消息(仅创建者)
*/
@SubscribeMessage<ClientToServerMessageType>('client_start_screen_share')
public async handleStartScreenShare(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
if (!data?.roomId) {
public async handleStartScreenShare(@MessageBody() data: { roomId: string; screenUid: number }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
if (!data?.roomId || typeof data.screenUid !== 'number') {
return;
}
const isHost = socket.data.user?.role !== 0;
@ -558,18 +665,22 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
return;
}
const targetUid = socket.data.user?.shortUid ?? 0;
const screenUid = Number(data.screenUid);
if (!Number.isFinite(screenUid) || screenUid <= 0) {
return;
}
// 保存投屏状态到 Redis
await this.redisService.setScreenSharing(data.roomId, targetUid);
await this.redisService.setScreenSharing(data.roomId, screenUid, targetUid);
// 广播给整个房间:有人开始投屏
this.server?.to(data.roomId).emit('message', { type: 'sev_start_screen_share', data: { fromRoomId: data.roomId, targetUid } });
this.log(`创建者开始投屏roomId=${data.roomId}, shortUid=${targetUid}`);
this.server?.to(data.roomId).emit('message', { type: 'sev_start_screen_share', data: { fromRoomId: data.roomId, targetUid, screenUid } });
this.log(`创建者开始投屏roomId=${data.roomId}, shortUid=${targetUid}, screenUid=${screenUid}`);
}
/**
* 处理停止投屏消息(仅创建者)
*/
@SubscribeMessage<ClientToServerMessageType>('client_stop_screen_share')
public async handleStopScreenShare(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
public async handleStopScreenShare(@MessageBody() data: { roomId: string; screenUid?: number }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
if (!data?.roomId) {
return;
}
@ -579,10 +690,11 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
return;
}
const targetUid = socket.data.user?.shortUid ?? 0;
const screenUid = typeof data.screenUid === 'number' ? Number(data.screenUid) : 0;
// 清除投屏状态
await this.redisService.setScreenSharing(data.roomId, null);
// 广播给整个房间:有人停止投屏
this.server?.to(data.roomId).emit('message', { type: 'sev_stop_screen_share', data: { fromRoomId: data.roomId, targetUid } });
this.log(`创建者停止投屏roomId=${data.roomId}, shortUid=${targetUid}`);
this.server?.to(data.roomId).emit('message', { type: 'sev_stop_screen_share', data: { fromRoomId: data.roomId, targetUid, screenUid } });
this.log(`创建者停止投屏roomId=${data.roomId}, shortUid=${targetUid}, screenUid=${screenUid}`);
}
}

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/sort-type-constituents */
/**
* meeting WebSocket 模块的“类型总出口”
*
@ -41,6 +42,13 @@ export interface MeetingSocketData {
* - 主要用于清理 courseRoomId -> roomId 的映射 key
*/
courseRoomId?: string;
/**
* 加入方式(课程/监控/学生)
* - 由 handleJoinRoom 写入
* - 用于区分教师端窗口断开时是否需要触发下课逻辑
*/
joinMode?: JoinMode;
}
export interface MeetingJoinRoomData {
@ -79,6 +87,7 @@ export interface MeetingMuteUserData {
export interface MeetingScreenShareData {
/** 房间 ID服务端实际 Socket.IO 房间名) */
roomId: string;
screenUid: number;
}
/**
@ -87,8 +96,10 @@ export interface MeetingScreenShareData {
export interface MeetingScreenShareNotifyData {
/** 来源房间 */
fromRoomId: string;
/** 投屏用户短 UID */
/** 目标用户声网 shortUid */
targetUid: number;
/** 投屏用户声网 shortUid */
screenUid: number;
}
export interface MeetingClientToServerEvents {
@ -112,6 +123,8 @@ export interface MeetingClientToServerEvents {
client_start_screen_share: (data: MeetingScreenShareData) => void;
/** 创建者停止投屏 */
client_stop_screen_share: (data: MeetingScreenShareData) => void;
/** 续期房间结束时间(仅创建者/老师) */
client_renew_room_end_timestamp: (data: { roomId: string; endTimestamp: number }) => void;
}
export interface MeetingWsMessagePacket<T extends string = string, D = unknown> {
@ -148,10 +161,13 @@ export interface MeetingJoinRoomSuccessData {
classStatus: 'finished' | 'in_class' | 'not_started';
/** 投屏状态:正在投屏的用户短 UIDundefined 表示无人投屏 */
screenShareUid?: number;
screenShareOwnerUid?: number;
/** 上台的短uid默认为老师的uid */
speakerUid: number;
/** 老师的短uid */
teacherUid: number;
/** 房间结束时间(毫秒时间戳) */
endTimestamp?: number;
}
export interface MeetingKickedData {
@ -182,9 +198,25 @@ export interface MeetingClassStateData {
fromRoomId: string;
}
export interface MeetingRoomWillExpireData {
/** 来源房间 */
fromRoomId: string;
/** 房间结束时间(毫秒时间戳) */
endTimestamp: number;
/** 剩余毫秒数 */
remainingMs: number;
}
export interface MeetingRoomEndTimestampUpdatedData {
/** 来源房间 */
fromRoomId: string;
/** 房间结束时间(毫秒时间戳) */
endTimestamp: number;
}
export type MeetingDownlinkPacket =
/** 统一错误包WsExceptionFilter/业务侧主动 emit 的错误) */
| MeetingWsMessagePacket<'error', { reason: string; code?: number }>
| ErrorMessage
/** 下课通知 */
| MeetingWsMessagePacket<'sev_class_ended', MeetingClassStateData>
/** 上课通知 */
@ -208,7 +240,11 @@ export type MeetingDownlinkPacket =
/** 解除禁麦通知 */
| MeetingWsMessagePacket<'sev_unmute_audio', MeetingControlData>
/** 解除禁视频通知 */
| MeetingWsMessagePacket<'sev_unmute_video', MeetingControlData>;
| MeetingWsMessagePacket<'sev_unmute_video', MeetingControlData>
/** 房间即将到期提醒(仅老师端) */
| MeetingWsMessagePacket<'sev_room_will_expire', MeetingRoomWillExpireData>
/** 房间结束时间更新(续期后广播) */
| MeetingWsMessagePacket<'sev_room_end_timestamp_updated', MeetingRoomEndTimestampUpdatedData>;
export interface MeetingServerToClientEvents {
/** 约定:服务端统一通过 message 事件下发业务包packet.type 决定语义) */
@ -230,7 +266,8 @@ export type ClientToServerMessageType =
| 'client_start_screen_share' // 开始投屏
| 'client_stop_screen_share' // 停止投屏
| 'client_unmute_audio' // 解除禁麦(仅创建者)
| 'client_unmute_video'; // 解除禁视频(仅创建者)
| 'client_unmute_video' // 解除禁视频(仅创建者)
| 'client_renew_room_end_timestamp'; // 续期房间结束时间(仅创建者)
/**
* 服务端 -> 客户端(下发消息)
@ -247,7 +284,9 @@ export type ServerToClientMessageType =
| 'sev_start_screen_share' // 开始投屏通知(广播给所有人)
| 'sev_stop_screen_share' // 停止投屏通知(广播给所有人)
| 'sev_unmute_audio' // 解除禁麦通知(转发给目标用户)
| 'sev_unmute_video'; // 解除禁视频通知(转发给目标用户)
| 'sev_unmute_video' // 解除禁视频通知(转发给目标用户)
| 'sev_room_will_expire' // 房间即将到期提醒(仅老师端)
| 'sev_room_end_timestamp_updated'; // 房间结束时间更新(续期后广播)
export interface ErrorMessage {
// 固定为 error便于前端统一处理
@ -270,6 +309,8 @@ export interface SucceedMessage {
/**
* 加入房间数据
*/
export type JoinMode = 'classroom' | 'monitor' | 'student';
export interface JoinRoomData {
// 课程房间 ID前端传入
courseRoomId: string;
@ -277,6 +318,10 @@ export interface JoinRoomData {
homeworkId: number;
// 用户名(仅用于日志/展示)
userName: string;
/** 加入方式(课程/监控/学生) */
joinMode: JoinMode;
/** 房间结束时间(毫秒时间戳),仅老师端首次进入时会写入 */
endTimestamp?: number;
}
/**
@ -306,6 +351,8 @@ export interface UserPermissionState {
isVideoMuted: boolean;
/** 用户的所有 Socket 连接 ID支持多设备同时在线 */
socketIds: string[];
/** 用户的 Socket 连接详情(用于区分加入方式) */
socketEntries?: Array<{ socketId: string; joinMode: JoinMode }>;
}
/**

View File

@ -103,14 +103,6 @@
<el-option v-for="mic in microphoneList" :key="mic.deviceId" :label="mic.label" :value="mic.deviceId"></el-option>
</el-select>
</el-form-item>
<el-form-item label="预计时长" prop="meetingDuration">
<template #label>
<div class="title-box">
<span class="title">预计时长</span>
</div>
</template>
<el-input-number v-model="formData.meetingDuration" style="width: 100%" :min="5" :max="240" :step="1"></el-input-number>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
@ -158,12 +150,22 @@
/** 表单引用 */
const formRef = ref<FormInstance>();
/** 时间信息 */
const dateInfo = computed(() => {
const startTimeInfo = getDateObj(meetingStore.currCourseRoom?.startTime || Date.now());
const endTimeInfo = getDateObj(meetingStore.currCourseRoom?.endTime || Date.now());
return {
showStartTime: `${startTimeInfo.month}-${startTimeInfo.day} ${startTimeInfo.hour}:${startTimeInfo.minute}`,
showEndTime: `${endTimeInfo.month}-${endTimeInfo.day} ${endTimeInfo.hour}:${endTimeInfo.minute}`,
endTimestamp: endTimeInfo.timestamp,
};
});
/** 表单数据 */
const formData = ref({
selectedCamera: useStorage<string>('meeting_selected_camera', ''),
selectedMicrophone: useStorage<string>('meeting_selected_microphone', ''),
meetingDuration: 40,
// eslint-disable-next-line vue/no-ref-object-reactivity-loss
endTimestamp: dateInfo.value.endTimestamp,
});
/** 表单验证规则 */
@ -182,17 +184,6 @@
/** 课程房间教师 */
const courseTeacher = computed(() => meetingStore.currCourseRoom?.teacher || '');
/** 时间信息 */
const dateInfo = computed(() => {
const startTimeInfo = getDateObj(meetingStore.currCourseRoom?.startTime || Date.now());
const endTimeInfo = getDateObj(meetingStore.currCourseRoom?.endTime || Date.now());
return {
showStartTime: `${startTimeInfo.month}-${startTimeInfo.day} ${startTimeInfo.hour}:${startTimeInfo.minute}`,
showEndTime: `${endTimeInfo.month}-${endTimeInfo.day} ${endTimeInfo.hour}:${endTimeInfo.minute}`,
timeDiff: endTimeInfo.timestamp - startTimeInfo.timestamp,
};
});
/** 用户名称 */
const userName = computed(() => userStore.userInfo?.name || '');
@ -220,7 +211,7 @@
onMounted(async () => {
// 页面加载时就初始化本地轨道,让用户能看到预览
try {
formData.value.meetingDuration = Math.round(dateInfo.value.timeDiff / 1000 / 60);
formData.value.endTimestamp = dateInfo.value.endTimestamp;
await getDevices();
await initLocalTracks();
} catch (error: any) {
@ -432,6 +423,13 @@
// 触发表单验证
console.log('即将跳转到 meeting-room 页面');
const endTimestamp = Number(formData.value.endTimestamp);
const now = Date.now();
if (!Number.isFinite(endTimestamp) || endTimestamp < now + 5 * 60 * 1000) {
Toast.error('结束时间不能小于当前时间的5分钟');
return;
}
const joinMode = role.value === 'student' ? 'student' : target === 'monitor' ? 'monitor' : 'classroom';
const targetPath =
role.value === 'student' ? '/meeting/meeting-room-student' : target === 'monitor' ? '/meeting/meeting-room-monitor' : '/meeting/meeting-room';
router.push({
@ -442,7 +440,8 @@
role: role.value,
cameraDeviceId: formData.value.selectedCamera,
microphoneDeviceId: formData.value.selectedMicrophone,
meetingDuration: formData.value.meetingDuration,
endTimestamp,
joinMode,
},
});
}

View File

@ -43,9 +43,9 @@
import type { IAgoraRTCRemoteUser } from 'agora-rtc-sdk-ng';
import IconMdiWifi from '~icons/mdi/wifi';
import IconMdiVideo from '~icons/mdi/video';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage as Toast } from 'element-plus';
import { ElMessageBox, ElMessage as Toast } from 'element-plus';
import type { OnJoinRoomData, ServerToClientEvents } from './types';
import { useAgoraMeeting } from './useAgoraMeeting';
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
@ -104,8 +104,26 @@
{ disableLocalTracks }
);
/** 加入方式 */
const joinMode = computed(() => 'monitor' as const);
/** 房间结束时间(毫秒时间戳) */
const endTimestamp = computed(() => {
const t = Number(route.query.endTimestamp || 0);
return Number.isFinite(t) && t > 0 ? t : null;
});
/** 房间结束时间(毫秒时间戳) */
const roomEndTimestamp = ref<number | null>(null);
/** 是否正在弹出续期窗口 */
const isRenewPrompting = ref<boolean>(false);
watchEffect(() => {
if (roomEndTimestamp.value === null && endTimestamp.value) {
roomEndTimestamp.value = endTimestamp.value;
}
});
const socket = useWebSocket();
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId);
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId, joinMode, endTimestamp);
const { networkQuality, selectedUserUid, leaveChannel, cleanup } = meeting;
@ -192,6 +210,43 @@
if (typeof data?.teacherUid === 'number') {
teacherUid.value = data.teacherUid;
}
if (typeof data?.endTimestamp === 'number') {
roomEndTimestamp.value = data.endTimestamp;
}
break;
}
case 'sev_room_end_timestamp_updated': {
roomEndTimestamp.value = packet.data.endTimestamp;
Toast.success('课程已续期');
break;
}
case 'sev_room_will_expire': {
if (isRenewPrompting.value) {
break;
}
isRenewPrompting.value = true;
const baseEndTimestamp = roomEndTimestamp.value || packet.data.endTimestamp;
const minutesLeft = Math.max(1, Math.ceil(packet.data.remainingMs / 60000));
ElMessageBox.prompt(`课程将在约 ${minutesLeft} 分钟后结束,是否续期?`, '续期', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: '10',
inputPattern: /^\d+$/,
inputErrorMessage: '请输入分钟数',
})
.then(({ value }) => {
const minutes = Number(value);
if (!Number.isFinite(minutes) || minutes <= 0) {
Toast.error('续期分钟数不正确');
return;
}
const nextEndTimestamp = baseEndTimestamp + minutes * 60 * 1000;
meetingSocket.renewRoomEndTimestamp(socket, packet.data.fromRoomId, nextEndTimestamp);
})
.catch(() => {})
.finally(() => {
isRenewPrompting.value = false;
});
break;
}
case 'sev_set_main_video': {

View File

@ -25,11 +25,19 @@
<span v-if="isPrimaryScreenSharing" class="badge">投屏中</span>
</div>
</div>
<div ref="selfWindowRef" class="self-window" :style="selfWindowStyle" @pointerdown.prevent="handleSelfPointerDown" @click.stop>
<div id="player-local" class="player-wrapper"></div>
<div
ref="selfWindowRef"
class="self-window"
:style="selfWindowStyle"
@pointerdown.prevent="handleSelfPointerDown"
@click.stop="handleSelfWindowClick"
>
<div v-if="selfWindowUid" :id="`player-${selfWindowUid}`" class="player-wrapper"></div>
<div v-else id="player-local" class="player-wrapper"></div>
<div class="self-label video-label">
<icon-mdi-account class="local-icon"></icon-mdi-account>
<span>{{ userName }}</span>
<span>{{ selfWindowName }}</span>
<span v-if="isTeacherDualStream" class="badge">点击切换</span>
</div>
</div>
</template>
@ -107,32 +115,48 @@
/** 主讲短 UID */
const speakerUid = ref<number | string | null>(null);
/** 投屏用户 ID */
const screenShareUid = ref<number | string | null>(null);
const screenShareStreamUid = ref<number | string | null>(null);
const screenShareOwnerUid = ref<number | string | null>(null);
const studentPrimaryOverrideUid = ref<number | string | null>(null);
const meeting = useAgoraMeeting(courseRoomId, userName, shortUid, {
onTokenWillExpire: () => {
Toast.warning('Token 即将过期,正在自动刷新...');
},
onTokenExpired: () => {
Toast.error('Token 已过期,请重新加入会议');
},
onConnectionStateChange: (state: string) => {
if (state === 'DISCONNECTED') {
Toast.warning('网络连接已断开');
} else if (state === 'RECONNECTING') {
Toast.info('正在重新连接...');
}
},
onError: (error: Error) => {
Toast.error(error.message || '操作失败');
},
onLeft: () => {
Toast.success('已离开会议');
const meeting = useAgoraMeeting(
courseRoomId,
userName,
shortUid,
{
onTokenWillExpire: () => {
Toast.warning('Token 即将过期,正在自动刷新...');
},
onTokenExpired: () => {
Toast.error('Token 已过期,请重新加入会议');
},
onConnectionStateChange: (state: string) => {
if (state === 'DISCONNECTED') {
Toast.warning('网络连接已断开');
} else if (state === 'RECONNECTING') {
Toast.info('正在重新连接...');
}
},
onError: (error: Error) => {
Toast.error(error.message || '操作失败');
},
onLeft: () => {
Toast.success('已离开会议');
},
},
{ autoPlayRemoteVideo: false }
);
/** 加入方式 */
const joinMode = computed(() => 'student' as const);
/** 房间结束时间(毫秒时间戳) */
const endTimestamp = computed(() => {
const t = Number(route.query.endTimestamp || 0);
return Number.isFinite(t) && t > 0 ? t : null;
});
const socket = useWebSocket();
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId);
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId, joinMode, endTimestamp);
const streamType = useStreamType(meeting.client);
const { isMuted, isVideoOff, muteLoading, videoLoading, leaveLoading, networkQuality, selectedUserUid, toggleMute, toggleVideo, leaveChannel, cleanup } =
@ -151,14 +175,51 @@
return qualityMap[networkQuality.value] || '网络未知';
});
/** 主讲 UID投屏优先其次主讲 */
const isTeacherDualStream = computed(() => Boolean(teacherUid.value && screenShareStreamUid.value));
const teacherPreferredPrimaryUid = computed<number | string | null>(() => {
return screenShareStreamUid.value ?? speakerUid.value ?? (teacherUid.value || null);
});
const primaryUid = computed<number | string | null>(() => {
const candidate = screenShareUid.value ?? speakerUid.value ?? (teacherUid.value || null);
const local = meeting.localUser.uid;
if (candidate && local && candidate === local && teacherUid.value) {
return teacherUid.value;
if (isTeacherDualStream.value && studentPrimaryOverrideUid.value) {
const candidate = studentPrimaryOverrideUid.value;
if (candidate === teacherUid.value || candidate === screenShareStreamUid.value) {
return candidate;
}
}
return candidate;
return teacherPreferredPrimaryUid.value;
});
const secondaryUid = computed<number | string | null>(() => {
if (!isTeacherDualStream.value) {
return null;
}
if (!teacherUid.value || !screenShareStreamUid.value) {
return null;
}
return primaryUid.value === screenShareStreamUid.value ? teacherUid.value : screenShareStreamUid.value;
});
const selfWindowUid = computed<number | string | null>(() => secondaryUid.value);
const screenOwnerName = computed(() => {
const uid = screenShareOwnerUid.value;
if (!uid) {
return '';
}
const u = meeting.remoteUsers.value.find((it: IAgoraRTCRemoteUser) => it.uid === uid) as (IAgoraRTCRemoteUser & { userName?: string }) | undefined;
return u?.userName || (uid === meeting.localUser.uid ? userName.value : `用户 ${uid}`);
});
const selfWindowName = computed(() => {
if (!selfWindowUid.value) {
return userName.value;
}
if (selfWindowUid.value === screenShareStreamUid.value) {
return `${screenOwnerName.value || '主讲'}(投屏)`;
}
return '主讲(摄像头)';
});
/** 主讲名称 */
@ -167,12 +228,15 @@
if (!uid) {
return '主讲';
}
if (screenShareStreamUid.value && uid === screenShareStreamUid.value) {
return `${screenOwnerName.value || '主讲'}(投屏)`;
}
const u = meeting.remoteUsers.value.find((it: IAgoraRTCRemoteUser) => it.uid === uid) as (IAgoraRTCRemoteUser & { userName?: string }) | undefined;
return u?.userName || (uid === meeting.localUser.uid ? userName.value : `用户 ${uid}`);
});
/** 是否为投屏主画面 */
const isPrimaryScreenSharing = computed(() => Boolean(primaryUid.value && screenShareUid.value === primaryUid.value));
const isPrimaryScreenSharing = computed(() => Boolean(primaryUid.value && screenShareStreamUid.value === primaryUid.value));
/** 舞台容器 DOM */
const stageRef = ref<HTMLDivElement | null>(null);
@ -301,16 +365,59 @@
() => primaryUid.value,
(uid) => {
ensurePrimaryVideoPlaying(uid);
}
if (uid) {
selectedUserUid.value = uid;
}
},
{ immediate: true }
);
watch(
() => meeting.remoteUsers.value.length,
() => {
ensurePrimaryVideoPlaying(primaryUid.value);
ensurePrimaryVideoPlaying(secondaryUid.value);
}
);
watch(
() => secondaryUid.value,
(uid) => {
ensurePrimaryVideoPlaying(uid);
}
);
watch(
() => teacherPreferredPrimaryUid.value,
() => {
studentPrimaryOverrideUid.value = null;
}
);
watch(
() => isTeacherDualStream.value,
(enabled) => {
if (!enabled) {
studentPrimaryOverrideUid.value = null;
}
}
);
const handleSelfWindowClick = () => {
if (!isTeacherDualStream.value) {
return;
}
if (isDragging.value) {
return;
}
const uid = selfWindowUid.value;
if (!uid) {
return;
}
studentPrimaryOverrideUid.value = uid;
ensurePrimaryVideoPlaying(uid);
};
const isLeaving = ref<boolean>(false);
type MeetingWsPacket = Parameters<ServerToClientEvents['message']>[0];
@ -360,8 +467,11 @@
selectedUserUid.value = data.speakerUid;
streamType.setMainVideo(data.speakerUid);
}
if (data?.screenShareUid) {
screenShareUid.value = data.screenShareUid;
if (typeof data?.screenShareUid === 'number') {
screenShareStreamUid.value = data.screenShareUid;
}
if (typeof data?.screenShareOwnerUid === 'number') {
screenShareOwnerUid.value = data.screenShareOwnerUid;
}
break;
}
@ -395,8 +505,12 @@
}
case 'sev_start_screen_share': {
const targetUid = packet.data?.targetUid;
const screenUid = packet.data?.screenUid;
if (typeof targetUid === 'number') {
screenShareUid.value = targetUid;
screenShareOwnerUid.value = targetUid;
}
if (typeof screenUid === 'number') {
screenShareStreamUid.value = screenUid;
Toast.info?.(`用户 ${targetUid} 开始投屏`);
setTimeout(() => {
ensurePrimaryVideoPlaying(primaryUid.value);
@ -407,9 +521,8 @@
case 'sev_stop_screen_share': {
const targetUid = packet.data?.targetUid;
if (typeof targetUid === 'number') {
if (screenShareUid.value === targetUid) {
screenShareUid.value = null;
}
screenShareOwnerUid.value = null;
screenShareStreamUid.value = null;
Toast.info?.(`用户 ${targetUid} 停止投屏`);
}
break;
@ -426,8 +539,11 @@
speakerUid.value = roomData.speakerUid;
selectedUserUid.value = roomData.speakerUid;
streamType.setMainVideo(roomData.speakerUid);
if (roomData.screenShareUid) {
screenShareUid.value = roomData.screenShareUid;
if (typeof roomData.screenShareUid === 'number') {
screenShareStreamUid.value = roomData.screenShareUid;
}
if (typeof roomData.screenShareOwnerUid === 'number') {
screenShareOwnerUid.value = roomData.screenShareOwnerUid;
}
await meeting.initAndJoin(roomData);
initSelfWindowPosition();
@ -443,7 +559,8 @@
}
case 'sev_class_ended': {
classStatus.value = 'finished';
screenShareUid.value = null;
screenShareOwnerUid.value = null;
screenShareStreamUid.value = null;
leaveChannel().catch(() => {});
Toast.info?.('老师已下课,等待重新上课');
break;
@ -481,7 +598,10 @@
streamType.setMainVideo(roomData.speakerUid);
classStatus.value = roomData?.classStatus || 'not_started';
if (roomData.screenShareUid) {
screenShareUid.value = roomData.screenShareUid;
screenShareStreamUid.value = roomData.screenShareUid;
}
if (typeof roomData.screenShareOwnerUid === 'number') {
screenShareOwnerUid.value = roomData.screenShareOwnerUid;
}
if (classStatus.value === 'in_class') {
await meeting.initAndJoin(roomData);

View File

@ -145,7 +145,6 @@
<script setup lang="ts">
import { playPromptTone } from '@/utils/prompt-tone';
import { useResizeObserver } from '@vueuse/core';
import type { IAgoraRTCRemoteUser } from 'agora-rtc-sdk-ng';
import IconMdiAccount from '~icons/mdi/account';
import IconMdiExitToApp from '~icons/mdi/exit-to-app';
import IconMdiLoading from '~icons/mdi/loading';
@ -156,14 +155,14 @@
import IconMdiMonitor from '~icons/mdi/monitor';
import IconMdiMonitorShare from '~icons/mdi/monitor-share';
import IconMdiWifi from '~icons/mdi/wifi';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStreamType } from './useStreamType';
import { usePermissions } from './usePermissions';
import type { DisplayUser, OnJoinRoomData, ServerToClientEvents } from './types';
import { useAgoraMeeting } from './useAgoraMeeting';
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
import { ElMessage as Toast } from 'element-plus';
import { ElMessageBox, ElMessage as Toast } from 'element-plus';
import { getBlacklistAxios, removeFromBlacklistAxios } from '@/api/meeting';
const route = useRoute();
@ -208,6 +207,9 @@
const shortUid = ref<number>(0);
/** 房间号 */
const roomId = ref<string>(''); // 房间 ID 会在加入后设置
const socket = useWebSocket();
const screenShareOwnerUid = ref<number | string | null>(null);
const screenShareStreamUid = ref<number | null>(null);
const meeting = useAgoraMeeting(courseRoomId, userName, shortUid, {
onTokenWillExpire: () => {
Toast.warning('Token 即将过期,正在自动刷新...');
@ -228,12 +230,26 @@
onLeft: () => {
Toast.success('已离开会议');
},
onLocalScreenShareEnded: () => {
if (roomId.value) {
socket.emit('client_stop_screen_share', { roomId: roomId.value, screenUid: screenShareStreamUid.value || undefined });
}
screenShareOwnerUid.value = null;
screenShareStreamUid.value = null;
Toast.info?.('已停止投屏');
},
});
const socket = useWebSocket();
const permissions = usePermissions(socket, roomId);
// 使用会议加入流程 Hook
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId);
/** 加入方式 */
const joinMode = computed(() => 'classroom' as const);
/** 房间结束时间(毫秒时间戳) */
const endTimestamp = computed(() => {
const t = Number(route.query.endTimestamp || 0);
return Number.isFinite(t) && t > 0 ? t : null;
});
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId, joinMode, endTimestamp);
// 使用双流模式 Hook
const streamType = useStreamType(meeting.client);
// 从 Hook 中解构所需变量
@ -262,15 +278,23 @@
/** 投屏操作加载状态 */
const screenShareLoading = ref(false);
/** 投屏用户 ID */
const screenShareUid = ref<number | string | null>(null);
/** 房间结束时间(毫秒时间戳) */
const roomEndTimestamp = ref<number | null>(null);
/** 是否正在弹出续期窗口 */
const isRenewPrompting = ref<boolean>(false);
watchEffect(() => {
if (roomEndTimestamp.value === null && endTimestamp.value) {
roomEndTimestamp.value = endTimestamp.value;
}
});
/**
* 检查用户是否正在投屏
* @param {number | string} uid - 用户 ID
*/
function isUserScreenSharing(uid: number | string): boolean {
return screenShareUid.value === uid;
return screenShareOwnerUid.value === uid;
}
let isLeaving = false;
@ -288,43 +312,63 @@
if (data?.classStatus) {
classStatus.value = data.classStatus;
}
// 恢复投屏状态(从 Redis 中获取)
if (data?.screenShareUid) {
screenShareUid.value = data.screenShareUid;
console.log('[屏幕共享] 从 Redis 恢复投屏状态:', data.screenShareUid);
// 如果投屏者是自己,需要重新开启屏幕共享
if (data.screenShareUid === shortUid.value && isHost.value) {
console.log('[屏幕共享] 检测到自己是投屏者,准备恢复投屏状态');
// 等待加入声网会议后再开启屏幕共享
setTimeout(async () => {
if (meeting.isJoined.value && classStatus.value === 'in_class') {
try {
console.log('[屏幕共享] 开始恢复投屏');
await startScreenShare();
socket.emit('client_start_screen_share', { roomId: roomId.value });
Toast.info('已恢复投屏状态');
} catch (error) {
console.error('[屏幕共享] 恢复投屏失败:', error);
}
}
}, 1000);
} else if (data.screenShareUid !== shortUid.value) {
// 如果投屏者是其他人,需要查找并播放屏幕共享流
setTimeout(() => {
const client = meeting.client?.value;
if (client) {
const user = client.remoteUsers?.find((u: IAgoraRTCRemoteUser) => u.uid === data.screenShareUid);
if (user && user.videoTrack) {
const playerDiv = document.getElementById(`player-${data.screenShareUid}`);
if (playerDiv) {
user.videoTrack.play(playerDiv);
console.log(`【声网】✅ 恢复播放屏幕共享:${data.screenShareUid}`);
}
}
}
}, 500);
}
if (typeof data?.endTimestamp === 'number') {
roomEndTimestamp.value = data.endTimestamp;
}
if (typeof data?.screenShareUid === 'number') {
screenShareStreamUid.value = data.screenShareUid;
}
if (typeof data?.screenShareOwnerUid === 'number') {
screenShareOwnerUid.value = data.screenShareOwnerUid;
}
if (screenShareOwnerUid.value === shortUid.value && isHost.value && !meeting.isScreenSharing.value) {
setTimeout(async () => {
if (meeting.isJoined.value && classStatus.value === 'in_class') {
try {
const screenUid = await startScreenShare();
socket.emit('client_start_screen_share', { roomId: roomId.value, screenUid });
screenShareStreamUid.value = screenUid;
screenShareOwnerUid.value = shortUid.value;
Toast.info?.('已恢复投屏状态');
} catch {}
}
}, 1000);
}
break;
}
case 'sev_room_end_timestamp_updated': {
roomEndTimestamp.value = packet.data.endTimestamp;
Toast.success('课程已续期');
break;
}
case 'sev_room_will_expire': {
if (isRenewPrompting.value) {
break;
}
isRenewPrompting.value = true;
const baseEndTimestamp = roomEndTimestamp.value || packet.data.endTimestamp;
const minutesLeft = Math.max(1, Math.ceil(packet.data.remainingMs / 60000));
ElMessageBox.prompt(`课程将在约 ${minutesLeft} 分钟后结束,是否续期?`, '续期', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: '10',
inputPattern: /^\d+$/,
inputErrorMessage: '请输入分钟数',
})
.then(({ value }) => {
const minutes = Number(value);
if (!Number.isFinite(minutes) || minutes <= 0) {
Toast.error('续期分钟数不正确');
return;
}
const nextEndTimestamp = baseEndTimestamp + minutes * 60 * 1000;
meetingSocket.renewRoomEndTimestamp(socket, packet.data.fromRoomId, nextEndTimestamp);
})
.catch(() => {})
.finally(() => {
isRenewPrompting.value = false;
});
break;
}
case 'sev_mute_audio': {
@ -408,41 +452,23 @@
}
case 'sev_start_screen_share': {
const targetUid = packet.data?.targetUid;
const screenUid = packet.data?.screenUid;
console.log('[屏幕共享] 收到 sev_start_screen_share 消息:', targetUid);
if (typeof targetUid === 'number') {
screenShareUid.value = targetUid;
console.log('[屏幕共享] screenShareUid 已设置为:', targetUid);
screenShareOwnerUid.value = targetUid;
if (typeof screenUid === 'number') {
screenShareStreamUid.value = screenUid;
}
console.log('[屏幕共享] screenShareOwnerUid 已设置为:', targetUid);
Toast.info?.(`用户 ${targetUid} 开始投屏`);
// 主动查找并播放屏幕共享流到普通视频网格(解决后加入用户收不到声网事件的问题)
console.log('[屏幕共享] 准备主动查找并播放屏幕共享流');
setTimeout(() => {
const client = meeting.client?.value;
console.log('[屏幕共享] client:', client ? '存在' : '不存在');
if (client) {
const user = client.remoteUsers?.find((u: IAgoraRTCRemoteUser) => u.uid === targetUid);
console.log('[屏幕共享] 找到用户:', user ? `uid=${user.uid}` : '未找到');
if (user && user.videoTrack) {
const playerDiv = document.getElementById(`player-${targetUid}`);
console.log(`[屏幕共享] player-${targetUid} 元素:`, playerDiv ? '存在' : '不存在');
console.log('[屏幕共享] user.videoTrack:', user.videoTrack);
if (playerDiv) {
user.videoTrack.play(playerDiv);
console.log(`【声网】✅ 主动播放屏幕共享:${targetUid}`);
} else {
console.error(`[屏幕共享] ❌ player-${targetUid} 元素不存在`);
}
} else {
console.error('[屏幕共享] ❌ 用户或 videoTrack 不存在');
}
}
}, 500);
}
break;
}
case 'sev_stop_screen_share': {
const targetUid = packet.data?.targetUid;
if (typeof targetUid === 'number') {
screenShareUid.value = null;
screenShareOwnerUid.value = null;
screenShareStreamUid.value = null;
Toast.info?.(`用户 ${targetUid} 停止投屏`);
}
break;
@ -471,7 +497,8 @@
// 下课时自动停止投屏
if (isScreenSharing.value) {
stopScreenTrack().catch(() => {});
screenShareUid.value = null;
screenShareOwnerUid.value = null;
screenShareStreamUid.value = null;
}
leaveChannel().catch(() => {});
Toast.info?.('老师已下课,等待重新上课');
@ -670,21 +697,24 @@
try {
console.log('[投屏] toggleScreenShare 被调用isScreenSharing.value:', isScreenSharing.value);
if (isScreenSharing.value) {
const currentScreenUid = screenShareStreamUid.value || meeting.localScreenUid.value || undefined;
// 停止投屏
await stopScreenTrack();
// 通知后端停止投屏
socket.emit('client_stop_screen_share', { roomId: roomId.value });
socket.emit('client_stop_screen_share', { roomId: roomId.value, screenUid: currentScreenUid });
// 清除投屏状态
screenShareUid.value = null;
screenShareOwnerUid.value = null;
screenShareStreamUid.value = null;
Toast.success('已停止投屏');
} else {
// 开始投屏
console.log('[投屏] 准备开始投屏');
await startScreenShare();
const screenUid = await startScreenShare();
// 通知后端开始投屏(带上自己的短 UID
socket.emit('client_start_screen_share', { roomId: roomId.value, shortUid: shortUid.value });
socket.emit('client_start_screen_share', { roomId: roomId.value, shortUid: shortUid.value, screenUid });
// 设置投屏状态
screenShareUid.value = shortUid.value;
screenShareOwnerUid.value = shortUid.value;
screenShareStreamUid.value = screenUid;
Toast.success('已开始投屏');
}
} catch (error) {

View File

@ -66,6 +66,11 @@ export type ClassStatus = 'finished' | 'in_class' | 'not_started';
*/
export type UserRole = 'student' | 'teacher';
/**
* 加入方式
*/
export type JoinMode = 'classroom' | 'monitor' | 'student';
// ============================================================================
// 会议状态(响应式状态替代回调)
// ============================================================================
@ -160,6 +165,10 @@ export interface SendJoinRoomData {
userName: string;
/** 作业 ID */
homeworkId: number;
/** 加入方式(课程/监控/学生) */
joinMode: JoinMode;
/** 房间结束时间(毫秒时间戳),仅老师端首次进入时会写入 */
endTimestamp?: number;
}
/**
@ -253,10 +262,35 @@ export interface OnJoinRoomData {
classStatus?: ClassStatus;
/** 投屏状态:正在投屏的用户短 UIDundefined 表示无人投屏 */
screenShareUid?: number;
screenShareOwnerUid?: number;
/** 主讲人短 UID默认等于 teacherUid */
speakerUid: number;
/** 老师短 UID */
teacherUid: number;
/** 房间结束时间(毫秒时间戳) */
endTimestamp?: number;
}
/**
* 房间即将到期提醒数据
*/
export interface OnRoomWillExpireData {
/** 来源房间 */
fromRoomId: string;
/** 房间结束时间(毫秒时间戳) */
endTimestamp: number;
/** 剩余毫秒数 */
remainingMs: number;
}
/**
* 房间结束时间更新数据
*/
export interface OnRoomEndTimestampUpdatedData {
/** 来源房间 */
fromRoomId: string;
/** 房间结束时间(毫秒时间戳) */
endTimestamp: number;
}
/**
@ -294,6 +328,16 @@ export interface OnSetMainVideoData extends OnControlMessageData {
*/
export type OnJoinRoomSuccessPacket = WebSocketPacket<'sev_join_room', OnJoinRoomData>;
/**
* WebSocket 消息包 - 服务端下发:房间即将到期提醒
*/
export type OnRoomWillExpirePacket = WebSocketPacket<'sev_room_will_expire', OnRoomWillExpireData>;
/**
* WebSocket 消息包 - 服务端下发:房间结束时间更新
*/
export type OnRoomEndTimestampUpdatedPacket = WebSocketPacket<'sev_room_end_timestamp_updated', OnRoomEndTimestampUpdatedData>;
/**
* WebSocket 消息包 - 客户端接收:被踢出会议
*/
@ -353,6 +397,8 @@ export type DownlinkControlMessagePacket =
| OnKickedPacket
| OnMuteAudioPacket
| OnMuteVideoPacket
| OnRoomEndTimestampUpdatedPacket
| OnRoomWillExpirePacket
| OnScreenShareStartedPacket
| OnScreenShareStoppedPacket
| OnSetMainVideoPacket
@ -377,6 +423,7 @@ export type ControlCommandType =
| 'client_kick_user' // 踢出用户
| 'client_mute_audio' // 禁麦(关闭用户麦克风)
| 'client_mute_video' // 禁视频(关闭用户摄像头)
| 'client_renew_room_end_timestamp' // 续期房间结束时间
| 'client_set_main_video' // 设置主屏(指定大窗口显示的用户)
| 'client_start_class' // 开始上课
| 'client_start_screen_share' // 开始投屏
@ -428,6 +475,7 @@ export interface SendStartScreenShareData {
roomId: string;
/** 短 UID可选当前后端不需要 */
shortUid?: number;
screenUid: number;
}
/**
@ -441,6 +489,7 @@ export type SendStartScreenSharePacket = WebSocketPacket<'client_start_screen_sh
export interface SendStopScreenShareData {
/** 房间 ID */
roomId: string;
screenUid?: number;
}
/**
@ -456,6 +505,7 @@ export interface OnScreenShareStartedData {
fromRoomId: string;
/** 开始共享的用户 ID */
targetUid: number;
screenUid: number;
}
/**
@ -471,6 +521,7 @@ export interface OnScreenShareStoppedData {
fromRoomId: string;
/** 停止共享的用户 ID */
targetUid: number;
screenUid?: number;
}
/**
@ -494,6 +545,7 @@ export interface ClientToServerEvents {
client_end_class: (data: { roomId: string }) => void;
client_start_screen_share: (data: SendStartScreenShareData) => void;
client_stop_screen_share: (data: SendStopScreenShareData) => void;
client_renew_room_end_timestamp: (data: { roomId: string; endTimestamp: number }) => void;
}
/**
@ -544,4 +596,5 @@ export interface MeetingEventCallbacks {
onTokenExpired?: () => void;
/** 离开会议回调 */
onLeft?: () => void;
onLocalScreenShareEnded?: () => void;
}

View File

@ -21,6 +21,7 @@ import { type Ref, computed, nextTick, reactive, ref, shallowRef, unref, watch,
import { useRoute } from 'vue-router';
import type { LocalUser, MeetingEventCallbacks, NetworkQualityLevel, OnJoinRoomData } from './types';
import { TokenManager } from './useTokenManager';
import { getMeetingTokenAxios } from '@/api/meeting';
/**
* 声网会议 Hook 配置
@ -28,6 +29,12 @@ import { TokenManager } from './useTokenManager';
export interface AgoraMeetingOptions {
/** 是否禁用本地音视频轨道(用于监控端等纯观看场景) */
disableLocalTracks?: Ref<boolean> | boolean;
/**
* 是否在 user-published 时自动播放到 player-${uid}
* - 教室端/监控端(有列表容器)可保持默认 true
* - 学生端(只有一个主画面容器)应设为 false由页面自行播放
*/
autoPlayRemoteVideo?: boolean;
}
/**
@ -63,6 +70,7 @@ export function useAgoraMeeting(
let localVideoTrack: ICameraVideoTrack | null = null;
/** 本地屏幕共享视频轨道(内部使用) */
let localScreenTrack: ILocalVideoTrack | null = null;
let screenClient: IAgoraRTCClient | null = null;
/** 是否已加入频道 */
const isJoined = ref(false);
@ -96,6 +104,7 @@ export function useAgoraMeeting(
/** 是否正在共享屏幕 */
const isScreenSharing = ref(false);
const localScreenUid = ref<number | null>(null);
/** 是否禁用本地音视频轨道 */
const disableLocalTracks = computed(() => Boolean(unref(options?.disableLocalTracks ?? false)));
@ -200,7 +209,6 @@ export function useAgoraMeeting(
*/
function initClient(): void {
try {
AgoraRTC.setLogLevel(2);
client.value = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });
if (tokenManagerRef.value) {
tokenManagerRef.value.bindClient(client.value);
@ -217,29 +225,18 @@ export function useAgoraMeeting(
const playerDiv = document.getElementById(playerId);
console.log('【声网】视频播放元素:', playerDiv, 'videoTrack:', user.videoTrack);
if (playerDiv && user.videoTrack) {
user.videoTrack.play(playerDiv);
console.log('【声网】远程视频已播放:', user.uid);
try {
user.videoTrack.play(playerDiv);
console.log('【声网】远程视频已播放:', user.uid);
} catch (error) {
console.warn('【声网】远程视频播放异常:', error);
}
} else {
console.warn('【声网】视频播放失败:元素或轨道不存在');
}
}, 100);
};
/**
* 检查并播放屏幕共享流
* @param user - 远程用户
*/
const checkAndPlayScreenShare = (user: IAgoraRTCRemoteUser) => {
// 检查是否有屏幕共享(通过检查用户是否有屏幕共享标志)
// 声网的屏幕共享流和普通视频流是同一个 videoTrack
// 但是可以通过用户是否有 screenTrack 来判断
const playerDiv = document.getElementById('player-screen-share');
if (playerDiv && user.videoTrack) {
user.videoTrack.play(playerDiv);
console.log(`【声网】✅ 屏幕共享已播放:${user.uid}`);
}
};
client.value.on('user-published', async (user: IAgoraRTCRemoteUser, mediaType: 'audio' | 'video') => {
console.log('【声网事件】user-published:', user.uid, '媒体类型:', mediaType);
console.log('【声网事件】user 对象:', user);
@ -254,8 +251,8 @@ export function useAgoraMeeting(
}
console.log('【声网】订阅成功:', user.uid, '类型:', mediaType, isMainVideo ? '主视频 (高流)' : '小视频 (低流)');
// 播放远程视频到普通播放器
if (mediaType === 'video') {
// 播放远程视频到普通播放器(默认使用 player-${uid},可通过 options 关闭)
if (mediaType === 'video' && (options?.autoPlayRemoteVideo ?? true)) {
playRemoteVideo(user, `player-${user.uid}`);
}
@ -280,46 +277,6 @@ export function useAgoraMeeting(
callbacks?.onUserPublished?.(user, mediaType);
});
/** 用户发布了屏幕共享流(新增) */
client.value.on('screen-video-subscribed', (user: IAgoraRTCRemoteUser) => {
console.log(`【声网事件】screen-video-subscribed: ${user.uid}`);
// 播放屏幕共享流到专用播放器
setTimeout(() => {
const playerDiv = document.getElementById('player-screen-share');
if (playerDiv && user.videoTrack) {
user.videoTrack.play(playerDiv);
console.log(`【声网】✅ 屏幕共享已播放:${user.uid}`);
}
}, 100);
});
/** 用户屏幕共享状态变化回调 */
client.value.on('user-screen-capture-changed', async (uid: number | string, hasScreenCapture: boolean) => {
console.log(`用户 ${uid} 屏幕共享状态变化:`, hasScreenCapture ? '开始投屏' : '停止投屏');
if (hasScreenCapture) {
// 找到对应的远程用户
const user = client.value?.remoteUsers?.find((u) => u.uid === uid);
if (user) {
try {
// 订阅屏幕共享流
await client.value?.subscribe(user, 'video');
console.log(`【声网】已订阅用户 ${uid} 的屏幕共享流`);
// 播放屏幕共享视频到专用的屏幕共享播放器
checkAndPlayScreenShare(user);
} catch (error) {
console.error(`【声网】订阅屏幕共享流失败:`, error);
}
}
} else {
// 停止投屏时,清空屏幕共享播放器
const playerDiv = document.getElementById('player-screen-share');
if (playerDiv) {
playerDiv.innerHTML = '';
}
console.log(`用户 ${uid} 停止投屏,已清空屏幕共享播放器`);
}
});
/** 订阅的视频流类型发生改变回调 ((高码率、高分辨率)变为视频小流(低码率、低分辨率),或视频小流变为视频大流。) */
client.value.on('stream-type-changed', (uid: number | string, streamType: RemoteStreamType) => {
console.log(' 订阅的视频流类型:', streamType);
@ -682,28 +639,29 @@ export function useAgoraMeeting(
*/
async function stopScreenTrack(): Promise<void> {
const track = localScreenTrack;
const currentScreenClient = screenClient;
localScreenTrack = null;
screenClient = null;
isScreenSharing.value = false;
localScreenUid.value = null;
if (track) {
localScreenTrack = null;
isScreenSharing.value = false;
try {
await client.value?.unpublish(track);
await currentScreenClient?.unpublish(track);
} catch {}
try {
track.stop();
} catch {}
try {
track.close();
console.log('[Agora] 屏幕共享已停止');
// 停止屏幕共享后重新发布摄像头视频
if (localVideoTrack && !isVideoOff.value) {
console.log('[Agora] 停止投屏后重新发布摄像头视频');
try {
await client.value?.publish(localVideoTrack);
console.log('[Agora] 摄像头视频已重新发布');
} catch (error) {
console.error('[Agora] 重新发布摄像头视频失败:', error);
}
}
} catch (error) {
console.error('[Agora] 停止屏幕共享失败:', error);
}
} catch {}
}
if (currentScreenClient) {
try {
currentScreenClient.removeAllListeners?.();
} catch {}
try {
await currentScreenClient.leave();
} catch {}
}
}
@ -712,10 +670,8 @@ export function useAgoraMeeting(
* 默认选择当前网页所在的屏幕,获取不到就使用第一个屏幕
* 注意:声网不允许同时发布多个视频轨道,所以发布屏幕共享前需要先取消发布摄像头视频
*/
async function startScreenShare(): Promise<void> {
async function startScreenShare(): Promise<number> {
console.log('[屏幕共享] 开始执行 startScreenShare');
console.log('[屏幕共享] client.value:', client.value ? '存在' : '不存在');
console.log('[屏幕共享] isJoined.value:', isJoined.value);
if (!client.value || !isJoined.value) {
console.error('[屏幕共享] 未加入会议,无法共享屏幕');
@ -730,82 +686,55 @@ export function useAgoraMeeting(
console.log('[屏幕共享] 显示 FullLoading');
FullLoading.show('正在开启屏幕共享...');
// ✅ 先取消发布摄像头视频(声网不允许同时发布多个视频轨道)
console.log('[屏幕共享] 准备取消发布摄像头视频');
if (localVideoTrack && !isVideoOff.value) {
try {
await client.value?.unpublish(localVideoTrack);
console.log('[屏幕共享] 已取消发布摄像头视频');
// 等待 unpublish 完成,确保客户端内部状态已更新
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 100);
});
} catch (error) {
console.error('[屏幕共享] 取消发布摄像头视频失败:', error);
}
const baseUid = Number(shortUidRef.value || 0);
if (!Number.isFinite(baseUid) || baseUid <= 0) {
FullLoading.hide();
throw new Error('用户信息未就绪,无法开启屏幕共享');
}
// 使用 navigator.mediaDevices.getDisplayMedia 获取屏幕流
// 这样可以默认选择当前网页所在的屏幕
console.log('[屏幕共享] 调用 navigator.mediaDevices.getDisplayMedia');
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 15 },
const nextScreenUid = baseUid + 1000000;
if (!Number.isFinite(nextScreenUid) || nextScreenUid <= 0 || nextScreenUid > 4294967295) {
FullLoading.hide();
throw new Error('生成投屏 UID 失败');
}
const tokenRes = await getMeetingTokenAxios(courseRoomId.value, nextScreenUid);
const tokenInfo = tokenRes.data;
if (!tokenInfo?.appid || !tokenInfo?.rtcToken) {
FullLoading.hide();
throw new Error('获取投屏 Token 失败');
}
const nextClient = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });
await nextClient.join(tokenInfo.appid, courseRoomId.value, tokenInfo.rtcToken, nextScreenUid);
const trackResult = await AgoraRTC.createScreenVideoTrack(
{
encoderConfig: '1080p',
},
audio: false, // 不采集系统音频
});
'auto'
);
console.log('[屏幕共享] getDisplayMedia 返回:', screenStream);
console.log('[屏幕共享] screenStream.getVideoTracks():', screenStream.getVideoTracks());
const screenVideoTrack = Array.isArray(trackResult) ? trackResult[0] : trackResult;
const screenTrack = screenStream.getVideoTracks()[0];
console.log('[屏幕共享] screenTrack:', screenTrack);
if (!screenTrack) {
console.error('[屏幕共享] 未找到可用的屏幕轨道');
FullLoading.hide();
throw new Error('未找到可用的屏幕');
}
// 使用声网的 createCustomVideoTrack 创建自定义视频轨道
console.log('[屏幕共享] 调用 AgoraRTC.createCustomVideoTrack');
const customVideoTrack = AgoraRTC.createCustomVideoTrack({
mediaStreamTrack: screenTrack,
});
console.log('[屏幕共享] createCustomVideoTrack 返回:', customVideoTrack);
if (!customVideoTrack) {
console.error('[屏幕共享] 屏幕共享轨道创建失败');
FullLoading.hide();
throw new Error('屏幕共享轨道创建失败');
}
console.log('[屏幕共享] 屏幕共享轨道创建成功,准备发布');
console.log('[屏幕共享] client.value.publish 前的状态:', client.value);
// 发布屏幕共享轨道
await client.value?.publish(customVideoTrack);
console.log('[屏幕共享] ✅ 屏幕共享轨道已发布');
localScreenTrack = customVideoTrack;
await nextClient.publish(screenVideoTrack);
localScreenTrack = screenVideoTrack;
screenClient = nextClient;
isScreenSharing.value = true;
console.log('[屏幕共享] localScreenTrack:', localScreenTrack);
console.log('[屏幕共享] isScreenSharing.value:', isScreenSharing.value);
localScreenUid.value = nextScreenUid;
FullLoading.hide();
// 监听屏幕共享轨道结束事件(用户点击浏览器停止共享按钮)
screenTrack.onended = () => {
console.log('[屏幕共享] 用户停止了屏幕共享onended 事件触发)');
stopScreenTrack().catch(() => {});
};
screenVideoTrack.on('track-ended', () => {
stopScreenTrack()
.then(() => {
callbacks?.onLocalScreenShareEnded?.();
})
.catch(() => {});
});
console.log('[屏幕共享] startScreenShare 执行完毕');
return nextScreenUid;
} catch (error) {
console.error('[屏幕共享] ❌ 屏幕共享失败:', error);
FullLoading.hide();
@ -887,6 +816,7 @@ export function useAgoraMeeting(
localVideoTrack,
/** 是否正在共享屏幕 */
isScreenSharing,
localScreenUid,
/** 初始化并加入会议 */
initAndJoin,
/** 加入频道 */

View File

@ -5,7 +5,7 @@
import { type ComputedRef, ref } from 'vue';
import { io } from 'socket.io-client';
import type { MeetingClientSocket, OnJoinRoomData, SendJoinRoomData, ServerToClientEvents } from './types';
import type { JoinMode, MeetingClientSocket, OnJoinRoomData, SendJoinRoomData, ServerToClientEvents } from './types';
import { getDefaultAuthHeaders } from '@/utils/axios/auth-headers';
import router from '@/router/router';
let socket: MeetingClientSocket | null = null;
@ -114,7 +114,9 @@ export function useMeetingSocket(
courseRoomId: ComputedRef<string>,
longUserId: ComputedRef<number>,
userName: ComputedRef<string>,
homeworkId: ComputedRef<number>
homeworkId: ComputedRef<number>,
joinMode: ComputedRef<JoinMode>,
endTimestamp: ComputedRef<number | null>
) {
/** 是否已加入房间 */
const isJoined = ref(false);
@ -151,6 +153,8 @@ export function useMeetingSocket(
courseRoomId: courseRoomId.value,
userName: userName.value,
homeworkId: homeworkId.value,
joinMode: joinMode.value,
...(endTimestamp.value ? { endTimestamp: endTimestamp.value } : {}),
};
ws.emit('client_join_room', joinData);
};
@ -187,10 +191,21 @@ export function useMeetingSocket(
ws.emit('client_leave_room', { courseRoomId: courseRoomId.value });
}
/**
* 续期房间结束时间(仅老师端)
* @param ws - Socket.IO 客户端实例
* @param roomId - 服务端房间 ID
* @param nextEndTimestamp - 新的结束时间(毫秒时间戳)
*/
function renewRoomEndTimestamp(ws: MeetingClientSocket, roomId: string, nextEndTimestamp: number): void {
ws.emit('client_renew_room_end_timestamp', { roomId, endTimestamp: nextEndTimestamp });
}
return {
isJoined,
joinRoom,
leaveRoom,
renewRoomEndTimestamp,
};
}