feat(meeting): 支持房间结束时间管理及投屏双流模式
- 新增房间结束时间字段,支持老师端设置和续期 - 实现投屏双流模式,老师可同时展示摄像头和屏幕共享 - 学生端支持切换主画面显示摄像头或投屏内容 - 优化屏幕共享实现,使用独立声网客户端避免轨道冲突 - 增加设备冲突检测逻辑,区分教室端、监控端和学生端入口
This commit is contained in:
@ -6,7 +6,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { RedisService } from '../../plugins/redis/redis.service';
|
import { RedisService } from '../../plugins/redis/redis.service';
|
||||||
import type { Redis } from 'ioredis';
|
import type { Redis } from 'ioredis';
|
||||||
import type { BlacklistUser, UserPermissionState } from './types';
|
import type { BlacklistUser, JoinMode, UserPermissionState } from './types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MeetingRedisService {
|
export class MeetingRedisService {
|
||||||
@ -63,6 +63,7 @@ export class MeetingRedisService {
|
|||||||
* - classStatus: 课堂状态(not_started | in_class | finished)
|
* - classStatus: 课堂状态(not_started | in_class | finished)
|
||||||
* - speakerUid: 当前主讲人短 UID(可选)
|
* - speakerUid: 当前主讲人短 UID(可选)
|
||||||
* - teacherUid: 老师短 UID(可选)
|
* - teacherUid: 老师短 UID(可选)
|
||||||
|
* - endTimestamp: 房间结束时间(毫秒时间戳,可选)
|
||||||
* 适用场景:同步课堂状态、切换主讲人、判断课程是否在进行中
|
* 适用场景:同步课堂状态、切换主讲人、判断课程是否在进行中
|
||||||
* 过期时间:24 小时
|
* 过期时间:24 小时
|
||||||
*/
|
*/
|
||||||
@ -115,18 +116,32 @@ export class MeetingRedisService {
|
|||||||
async getUserState(roomId: string, shortUid: number): Promise<UserPermissionState> {
|
async getUserState(roomId: string, shortUid: number): Promise<UserPermissionState> {
|
||||||
const key = this.getUserStateKey(roomId, shortUid);
|
const key = this.getUserStateKey(roomId, shortUid);
|
||||||
const map = await this.getClient().hgetall(key);
|
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[] = [];
|
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 {
|
try {
|
||||||
socketIds = JSON.parse(map.socketIds);
|
socketIds = JSON.parse(map.socketIds);
|
||||||
|
socketEntries = socketIds.map((socketId) => ({ socketId, joinMode: 'classroom' }));
|
||||||
} catch {
|
} catch {
|
||||||
socketIds = [];
|
socketIds = [];
|
||||||
|
socketEntries = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
isAudioMuted: map?.isAudioMuted === '1',
|
isAudioMuted: map?.isAudioMuted === '1',
|
||||||
isVideoMuted: map?.isVideoMuted === '1',
|
isVideoMuted: map?.isVideoMuted === '1',
|
||||||
socketIds,
|
socketIds,
|
||||||
|
socketEntries,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,12 +238,17 @@ export class MeetingRedisService {
|
|||||||
* @param roomId - 房间 ID
|
* @param roomId - 房间 ID
|
||||||
* @param screenShareUid - 投屏人短 UID,null 表示停止投屏
|
* @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}`;
|
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
||||||
if (screenShareUid === null) {
|
if (screenShareUid === null) {
|
||||||
await this.getClient().hdel(key, 'screenShareUid');
|
await this.getClient().hdel(key, 'screenShareUid');
|
||||||
|
await this.getClient().hdel(key, 'screenShareOwnerUid');
|
||||||
} else {
|
} 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);
|
await this.getClient().expire(key, 24 * 60 * 60);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -267,22 +287,67 @@ export class MeetingRedisService {
|
|||||||
classStatus: 'finished' | 'in_class' | 'not_started';
|
classStatus: 'finished' | 'in_class' | 'not_started';
|
||||||
speakerUid?: number;
|
speakerUid?: number;
|
||||||
screenShareUid?: number;
|
screenShareUid?: number;
|
||||||
|
screenShareOwnerUid?: number;
|
||||||
teacherUid?: number;
|
teacherUid?: number;
|
||||||
|
endTimestamp?: number;
|
||||||
}> {
|
}> {
|
||||||
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
||||||
const map = await this.getClient().hgetall(key);
|
const map = await this.getClient().hgetall(key);
|
||||||
const status = (map?.classStatus as any) || 'not_started';
|
const status = (map?.classStatus as any) || 'not_started';
|
||||||
const speakerUid = map?.speakerUid ? Number(map.speakerUid) : undefined;
|
const speakerUid = map?.speakerUid ? Number(map.speakerUid) : undefined;
|
||||||
const screenShareUid = map?.screenShareUid ? Number(map.screenShareUid) : 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 teacherUid = map?.teacherUid ? Number(map.teacherUid) : undefined;
|
||||||
|
const endTimestamp = map?.endTimestamp ? Number(map.endTimestamp) : undefined;
|
||||||
return {
|
return {
|
||||||
classStatus: status,
|
classStatus: status,
|
||||||
...(Number.isFinite(speakerUid) ? { speakerUid } : {}),
|
...(Number.isFinite(speakerUid) ? { speakerUid } : {}),
|
||||||
...(Number.isFinite(screenShareUid) ? { screenShareUid } : {}),
|
...(Number.isFinite(screenShareUid) ? { screenShareUid } : {}),
|
||||||
|
...(Number.isFinite(screenShareOwnerUid) ? { screenShareOwnerUid } : {}),
|
||||||
...(Number.isFinite(teacherUid) ? { teacherUid } : {}),
|
...(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 连接管理 ====================
|
// ==================== Socket 连接管理 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -290,18 +355,18 @@ export class MeetingRedisService {
|
|||||||
* @param roomId - 房间 ID
|
* @param roomId - 房间 ID
|
||||||
* @param shortUid - 短 UID
|
* @param shortUid - 短 UID
|
||||||
* @param socketId - Socket.IO 连接 ID
|
* @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);
|
const key = this.getUserStateKey(roomId, shortUid);
|
||||||
// 获取当前 socketIds
|
|
||||||
const currentState = await this.getUserState(roomId, shortUid);
|
const currentState = await this.getUserState(roomId, shortUid);
|
||||||
const socketIds = currentState.socketIds || [];
|
const socketEntries = Array.isArray(currentState.socketEntries) ? currentState.socketEntries : [];
|
||||||
// 添加新 socketId(如果不存在)
|
const existed = socketEntries.some((e) => e.socketId === socketId);
|
||||||
if (!socketIds.includes(socketId)) {
|
if (!existed) {
|
||||||
socketIds.push(socketId);
|
socketEntries.push({ socketId, joinMode });
|
||||||
}
|
}
|
||||||
// 存储到 Redis
|
const socketIds = [...new Set(socketEntries.map((e) => e.socketId).filter(Boolean))];
|
||||||
await this.getClient().hset(key, { socketIds: JSON.stringify(socketIds) });
|
await this.getClient().hset(key, { socketIds: JSON.stringify(socketIds), socketEntries: JSON.stringify(socketEntries) });
|
||||||
await this.getClient().expire(key, 24 * 60 * 60);
|
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> {
|
async removeSocket(roomId: string, shortUid: number, socketId: string): Promise<void> {
|
||||||
const key = this.getUserStateKey(roomId, shortUid);
|
const key = this.getUserStateKey(roomId, shortUid);
|
||||||
// 获取当前 socketIds
|
|
||||||
const currentState = await this.getUserState(roomId, shortUid);
|
const currentState = await this.getUserState(roomId, shortUid);
|
||||||
const socketIds = currentState.socketIds || [];
|
const socketEntries = Array.isArray(currentState.socketEntries) ? currentState.socketEntries : [];
|
||||||
// 移除指定的 socketId
|
const nextEntries = socketEntries.length > 0 ? socketEntries.filter((e) => e.socketId !== socketId) : [];
|
||||||
const newSocketIds = socketIds.filter((id) => id !== socketId);
|
const nextSocketIds =
|
||||||
if (newSocketIds.length > 0) {
|
nextEntries.length > 0 ? [...new Set(nextEntries.map((e) => e.socketId).filter(Boolean))] : (currentState.socketIds || []).filter((id) => id !== socketId);
|
||||||
await this.getClient().hset(key, { socketIds: JSON.stringify(newSocketIds) });
|
|
||||||
|
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 {
|
} else {
|
||||||
// 如果没有 socketId 了,删除整个 key(用户离开)
|
|
||||||
await this.getClient().del(key);
|
await this.getClient().del(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -330,10 +399,14 @@ export class MeetingRedisService {
|
|||||||
* 获取用户的所有 Socket 连接 ID
|
* 获取用户的所有 Socket 连接 ID
|
||||||
* @param roomId - 房间 ID
|
* @param roomId - 房间 ID
|
||||||
* @param shortUid - 短 UID
|
* @param shortUid - 短 UID
|
||||||
|
* @param joinMode - 加入方式(可选;用于多端共存的场景)
|
||||||
* @returns Socket 连接 ID 数组
|
* @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);
|
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 || [];
|
return state.socketIds || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { LoggerService } from '@/plugins/logger/logger.service';
|
|||||||
import type {
|
import type {
|
||||||
ClientToServerMessageType,
|
ClientToServerMessageType,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
|
JoinMode,
|
||||||
JoinRoomData,
|
JoinRoomData,
|
||||||
KickUserData,
|
KickUserData,
|
||||||
MeetingNamespace,
|
MeetingNamespace,
|
||||||
@ -42,6 +43,9 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
|||||||
// 注入 Socket.IO namespace(带泛型,确保 server.in().fetchSockets() 等返回强类型)
|
// 注入 Socket.IO namespace(带泛型,确保 server.in().fetchSockets() 等返回强类型)
|
||||||
public server: MeetingNamespace | null = null;
|
public server: MeetingNamespace | null = null;
|
||||||
|
|
||||||
|
private readonly roomExpiryTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
private readonly roomExpiryLastNotifiedMinute = new Map<string, number>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
// 房间/用户状态:Redis 持久化
|
// 房间/用户状态:Redis 持久化
|
||||||
private readonly redisService: MeetingRedisService,
|
private readonly redisService: MeetingRedisService,
|
||||||
@ -92,10 +96,69 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
|||||||
// 如果房间内没有 socket 连接,则清理 Redis 数据
|
// 如果房间内没有 socket 连接,则清理 Redis 数据
|
||||||
if (sockets.length === 0) {
|
if (sockets.length === 0) {
|
||||||
await this.redisService.clearRoomAll(roomId);
|
await this.redisService.clearRoomAll(roomId);
|
||||||
|
this.clearRoomExpiryTimer(roomId);
|
||||||
this.log(`[会议] 房间已空,已清理 Redis 状态:roomId=${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
|
* 初始化后,在 namespace 层添加连接鉴权 middleware
|
||||||
* @param server - 注入的 Socket.IO namespace(带泛型,确保 server.in().fetchSockets() 等返回强类型)
|
* @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(() => {});
|
await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是老师(创建者)主动断开连接,发送下课消息给所有人
|
// 如果是老师(创建者)在“教室端”主动断开连接,发送下课消息给所有人
|
||||||
if (roomId && isHost) {
|
if (roomId && isHost && socket.data.joinMode === 'classroom') {
|
||||||
this.log(`老师(创建者)断开连接,发送下课消息:roomId=${roomId}`);
|
this.log(`老师(创建者)断开连接,发送下课消息:roomId=${roomId}`);
|
||||||
// 设置课堂状态为已结束
|
// 设置课堂状态为已结束
|
||||||
await this.redisService.setClassStatus(roomId, 'finished');
|
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 } });
|
this.server?.to(roomId).emit('message', { type: 'sev_class_ended', data: { fromRoomId: roomId } });
|
||||||
// 清理 Redis 数据
|
// 清理 Redis 数据
|
||||||
await this.redisService.clearRoomAll(roomId);
|
await this.redisService.clearRoomAll(roomId);
|
||||||
|
this.clearRoomExpiryTimer(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roomId) {
|
if (roomId) {
|
||||||
@ -177,6 +241,9 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
|||||||
// 从鉴权后的 user 中获取 shortUid(鉴权通过就有短 ID)
|
// 从鉴权后的 user 中获取 shortUid(鉴权通过就有短 ID)
|
||||||
const shortUid = socket.data.user?.shortUid;
|
const shortUid = socket.data.user?.shortUid;
|
||||||
const userName = socket.data.user?.userName || data.userName || '用户';
|
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) {
|
if (!shortUid) {
|
||||||
this.logger.warn({}, '[会议] 用户未认证,无 shortUid', 'MeetingWebSocket');
|
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) {
|
if (existingSocketIds.length > 0) {
|
||||||
// 过滤掉当前 socket 自己的连接(同一设备刷新等情况)
|
// 过滤掉当前 socket 自己的连接(同一设备刷新等情况)
|
||||||
const otherDeviceSocketIds = existingSocketIds.filter((id) => id !== socket.id);
|
const otherDeviceSocketIds = existingSocketIds.filter((id) => id !== socket.id);
|
||||||
@ -217,7 +284,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
|||||||
if (result.isValid && result.sockets) {
|
if (result.isValid && result.sockets) {
|
||||||
this.server?.to(result.oldSocketId).emit('message', {
|
this.server?.to(result.oldSocketId).emit('message', {
|
||||||
type: 'sev_device_conflict',
|
type: 'sev_device_conflict',
|
||||||
data: { fromRoomId: roomId, reason: '您已在其他设备进入课程', targetUid: shortUid },
|
data: { fromRoomId: roomId, reason: '您已在其他设备进入相同入口', targetUid: shortUid },
|
||||||
} satisfies SucceedMessage);
|
} satisfies SucceedMessage);
|
||||||
result.sockets.forEach((s) => s.disconnect(true));
|
result.sockets.forEach((s) => s.disconnect(true));
|
||||||
validSocketIds.push(result.oldSocketId);
|
validSocketIds.push(result.oldSocketId);
|
||||||
@ -243,9 +310,10 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
|||||||
socket.join(roomId);
|
socket.join(roomId);
|
||||||
socket.data.roomId = roomId;
|
socket.data.roomId = roomId;
|
||||||
socket.data.courseRoomId = courseRoomId;
|
socket.data.courseRoomId = courseRoomId;
|
||||||
|
socket.data.joinMode = joinMode;
|
||||||
|
|
||||||
// 注册 socketId 到 Redis(用于踢人、禁麦等控制功能)
|
// 注册 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);
|
await this.redisService.addUserToRoom(roomId, shortUid);
|
||||||
|
|
||||||
@ -265,6 +333,17 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
|||||||
await this.redisService.setSpeaker(roomId, speakerUid).catch(() => {});
|
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/恢复状态/房间状态
|
// 下发 join 成功包:包含 roomId/shortUid/tokenInfo/恢复状态/房间状态
|
||||||
socket.emit('message', {
|
socket.emit('message', {
|
||||||
type: 'sev_join_room',
|
type: 'sev_join_room',
|
||||||
@ -276,8 +355,10 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
|||||||
isVideoMuted: userState.isVideoMuted,
|
isVideoMuted: userState.isVideoMuted,
|
||||||
classStatus: roomState.classStatus,
|
classStatus: roomState.classStatus,
|
||||||
screenShareUid: roomState.screenShareUid,
|
screenShareUid: roomState.screenShareUid,
|
||||||
|
screenShareOwnerUid: roomState.screenShareOwnerUid,
|
||||||
speakerUid,
|
speakerUid,
|
||||||
teacherUid,
|
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')
|
@SubscribeMessage<ClientToServerMessageType>('client_leave_room')
|
||||||
public async handleLeaveRoom(@MessageBody() data: { courseRoomId?: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
public async handleLeaveRoom(@MessageBody() data: { courseRoomId?: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||||
// Socket.IO 会自动处理离房逻辑,这里仅记录
|
// Socket.IO 会自动处理离房逻辑,这里仅记录
|
||||||
@ -299,16 +409,13 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
|||||||
if (roomId && Number.isFinite(shortUid) && shortUid > 0) {
|
if (roomId && Number.isFinite(shortUid) && shortUid > 0) {
|
||||||
// 移除 socketId 映射
|
// 移除 socketId 映射
|
||||||
await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {});
|
await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {});
|
||||||
// 从房间用户列表移除
|
|
||||||
await this.redisService.removeUserFromRoom(roomId, shortUid).catch(() => {});
|
|
||||||
// 清理用户状态
|
|
||||||
await this.redisService.clearUserState(roomId, shortUid).catch(() => {});
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
roomId && socket.leave(roomId);
|
roomId && socket.leave(roomId);
|
||||||
} catch {}
|
} catch {}
|
||||||
socket.data.roomId = undefined;
|
socket.data.roomId = undefined;
|
||||||
socket.data.courseRoomId = undefined;
|
socket.data.courseRoomId = undefined;
|
||||||
|
socket.data.joinMode = undefined;
|
||||||
if (roomId) {
|
if (roomId) {
|
||||||
await this.cleanupRoomIfEmpty(roomId);
|
await this.cleanupRoomIfEmpty(roomId);
|
||||||
}
|
}
|
||||||
@ -548,8 +655,8 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
|||||||
* 处理开始投屏消息(仅创建者)
|
* 处理开始投屏消息(仅创建者)
|
||||||
*/
|
*/
|
||||||
@SubscribeMessage<ClientToServerMessageType>('client_start_screen_share')
|
@SubscribeMessage<ClientToServerMessageType>('client_start_screen_share')
|
||||||
public async handleStartScreenShare(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
public async handleStartScreenShare(@MessageBody() data: { roomId: string; screenUid: number }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||||
if (!data?.roomId) {
|
if (!data?.roomId || typeof data.screenUid !== 'number') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isHost = socket.data.user?.role !== 0;
|
const isHost = socket.data.user?.role !== 0;
|
||||||
@ -558,18 +665,22 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targetUid = socket.data.user?.shortUid ?? 0;
|
const targetUid = socket.data.user?.shortUid ?? 0;
|
||||||
|
const screenUid = Number(data.screenUid);
|
||||||
|
if (!Number.isFinite(screenUid) || screenUid <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 保存投屏状态到 Redis
|
// 保存投屏状态到 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.server?.to(data.roomId).emit('message', { type: 'sev_start_screen_share', data: { fromRoomId: data.roomId, targetUid, screenUid } });
|
||||||
this.log(`创建者开始投屏:roomId=${data.roomId}, shortUid=${targetUid}`);
|
this.log(`创建者开始投屏:roomId=${data.roomId}, shortUid=${targetUid}, screenUid=${screenUid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理停止投屏消息(仅创建者)
|
* 处理停止投屏消息(仅创建者)
|
||||||
*/
|
*/
|
||||||
@SubscribeMessage<ClientToServerMessageType>('client_stop_screen_share')
|
@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) {
|
if (!data?.roomId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -579,10 +690,11 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targetUid = socket.data.user?.shortUid ?? 0;
|
const targetUid = socket.data.user?.shortUid ?? 0;
|
||||||
|
const screenUid = typeof data.screenUid === 'number' ? Number(data.screenUid) : 0;
|
||||||
// 清除投屏状态
|
// 清除投屏状态
|
||||||
await this.redisService.setScreenSharing(data.roomId, null);
|
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.server?.to(data.roomId).emit('message', { type: 'sev_stop_screen_share', data: { fromRoomId: data.roomId, targetUid, screenUid } });
|
||||||
this.log(`创建者停止投屏:roomId=${data.roomId}, shortUid=${targetUid}`);
|
this.log(`创建者停止投屏:roomId=${data.roomId}, shortUid=${targetUid}, screenUid=${screenUid}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/sort-type-constituents */
|
||||||
/**
|
/**
|
||||||
* meeting WebSocket 模块的“类型总出口”
|
* meeting WebSocket 模块的“类型总出口”
|
||||||
*
|
*
|
||||||
@ -41,6 +42,13 @@ export interface MeetingSocketData {
|
|||||||
* - 主要用于清理 courseRoomId -> roomId 的映射 key
|
* - 主要用于清理 courseRoomId -> roomId 的映射 key
|
||||||
*/
|
*/
|
||||||
courseRoomId?: string;
|
courseRoomId?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入方式(课程/监控/学生)
|
||||||
|
* - 由 handleJoinRoom 写入
|
||||||
|
* - 用于区分教师端窗口断开时是否需要触发下课逻辑
|
||||||
|
*/
|
||||||
|
joinMode?: JoinMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeetingJoinRoomData {
|
export interface MeetingJoinRoomData {
|
||||||
@ -79,6 +87,7 @@ export interface MeetingMuteUserData {
|
|||||||
export interface MeetingScreenShareData {
|
export interface MeetingScreenShareData {
|
||||||
/** 房间 ID(服务端实际 Socket.IO 房间名) */
|
/** 房间 ID(服务端实际 Socket.IO 房间名) */
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
screenUid: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -87,8 +96,10 @@ export interface MeetingScreenShareData {
|
|||||||
export interface MeetingScreenShareNotifyData {
|
export interface MeetingScreenShareNotifyData {
|
||||||
/** 来源房间 */
|
/** 来源房间 */
|
||||||
fromRoomId: string;
|
fromRoomId: string;
|
||||||
/** 投屏用户短 UID */
|
/** 目标用户声网 shortUid */
|
||||||
targetUid: number;
|
targetUid: number;
|
||||||
|
/** 投屏用户声网 shortUid */
|
||||||
|
screenUid: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeetingClientToServerEvents {
|
export interface MeetingClientToServerEvents {
|
||||||
@ -112,6 +123,8 @@ export interface MeetingClientToServerEvents {
|
|||||||
client_start_screen_share: (data: MeetingScreenShareData) => void;
|
client_start_screen_share: (data: MeetingScreenShareData) => void;
|
||||||
/** 创建者停止投屏 */
|
/** 创建者停止投屏 */
|
||||||
client_stop_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> {
|
export interface MeetingWsMessagePacket<T extends string = string, D = unknown> {
|
||||||
@ -148,10 +161,13 @@ export interface MeetingJoinRoomSuccessData {
|
|||||||
classStatus: 'finished' | 'in_class' | 'not_started';
|
classStatus: 'finished' | 'in_class' | 'not_started';
|
||||||
/** 投屏状态:正在投屏的用户短 UID,undefined 表示无人投屏 */
|
/** 投屏状态:正在投屏的用户短 UID,undefined 表示无人投屏 */
|
||||||
screenShareUid?: number;
|
screenShareUid?: number;
|
||||||
|
screenShareOwnerUid?: number;
|
||||||
/** 上台的短uid,默认为老师的uid */
|
/** 上台的短uid,默认为老师的uid */
|
||||||
speakerUid: number;
|
speakerUid: number;
|
||||||
/** 老师的短uid */
|
/** 老师的短uid */
|
||||||
teacherUid: number;
|
teacherUid: number;
|
||||||
|
/** 房间结束时间(毫秒时间戳) */
|
||||||
|
endTimestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeetingKickedData {
|
export interface MeetingKickedData {
|
||||||
@ -182,9 +198,25 @@ export interface MeetingClassStateData {
|
|||||||
fromRoomId: string;
|
fromRoomId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MeetingRoomWillExpireData {
|
||||||
|
/** 来源房间 */
|
||||||
|
fromRoomId: string;
|
||||||
|
/** 房间结束时间(毫秒时间戳) */
|
||||||
|
endTimestamp: number;
|
||||||
|
/** 剩余毫秒数 */
|
||||||
|
remainingMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeetingRoomEndTimestampUpdatedData {
|
||||||
|
/** 来源房间 */
|
||||||
|
fromRoomId: string;
|
||||||
|
/** 房间结束时间(毫秒时间戳) */
|
||||||
|
endTimestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type MeetingDownlinkPacket =
|
export type MeetingDownlinkPacket =
|
||||||
/** 统一错误包(WsExceptionFilter/业务侧主动 emit 的错误) */
|
/** 统一错误包(WsExceptionFilter/业务侧主动 emit 的错误) */
|
||||||
| MeetingWsMessagePacket<'error', { reason: string; code?: number }>
|
| ErrorMessage
|
||||||
/** 下课通知 */
|
/** 下课通知 */
|
||||||
| MeetingWsMessagePacket<'sev_class_ended', MeetingClassStateData>
|
| MeetingWsMessagePacket<'sev_class_ended', MeetingClassStateData>
|
||||||
/** 上课通知 */
|
/** 上课通知 */
|
||||||
@ -208,7 +240,11 @@ export type MeetingDownlinkPacket =
|
|||||||
/** 解除禁麦通知 */
|
/** 解除禁麦通知 */
|
||||||
| MeetingWsMessagePacket<'sev_unmute_audio', MeetingControlData>
|
| 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 {
|
export interface MeetingServerToClientEvents {
|
||||||
/** 约定:服务端统一通过 message 事件下发业务包(packet.type 决定语义) */
|
/** 约定:服务端统一通过 message 事件下发业务包(packet.type 决定语义) */
|
||||||
@ -230,7 +266,8 @@ export type ClientToServerMessageType =
|
|||||||
| 'client_start_screen_share' // 开始投屏
|
| 'client_start_screen_share' // 开始投屏
|
||||||
| 'client_stop_screen_share' // 停止投屏
|
| 'client_stop_screen_share' // 停止投屏
|
||||||
| 'client_unmute_audio' // 解除禁麦(仅创建者)
|
| '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_start_screen_share' // 开始投屏通知(广播给所有人)
|
||||||
| 'sev_stop_screen_share' // 停止投屏通知(广播给所有人)
|
| 'sev_stop_screen_share' // 停止投屏通知(广播给所有人)
|
||||||
| 'sev_unmute_audio' // 解除禁麦通知(转发给目标用户)
|
| 'sev_unmute_audio' // 解除禁麦通知(转发给目标用户)
|
||||||
| 'sev_unmute_video'; // 解除禁视频通知(转发给目标用户)
|
| 'sev_unmute_video' // 解除禁视频通知(转发给目标用户)
|
||||||
|
| 'sev_room_will_expire' // 房间即将到期提醒(仅老师端)
|
||||||
|
| 'sev_room_end_timestamp_updated'; // 房间结束时间更新(续期后广播)
|
||||||
|
|
||||||
export interface ErrorMessage {
|
export interface ErrorMessage {
|
||||||
// 固定为 error,便于前端统一处理
|
// 固定为 error,便于前端统一处理
|
||||||
@ -270,6 +309,8 @@ export interface SucceedMessage {
|
|||||||
/**
|
/**
|
||||||
* 加入房间数据
|
* 加入房间数据
|
||||||
*/
|
*/
|
||||||
|
export type JoinMode = 'classroom' | 'monitor' | 'student';
|
||||||
|
|
||||||
export interface JoinRoomData {
|
export interface JoinRoomData {
|
||||||
// 课程房间 ID(前端传入)
|
// 课程房间 ID(前端传入)
|
||||||
courseRoomId: string;
|
courseRoomId: string;
|
||||||
@ -277,6 +318,10 @@ export interface JoinRoomData {
|
|||||||
homeworkId: number;
|
homeworkId: number;
|
||||||
// 用户名(仅用于日志/展示)
|
// 用户名(仅用于日志/展示)
|
||||||
userName: string;
|
userName: string;
|
||||||
|
/** 加入方式(课程/监控/学生) */
|
||||||
|
joinMode: JoinMode;
|
||||||
|
/** 房间结束时间(毫秒时间戳),仅老师端首次进入时会写入 */
|
||||||
|
endTimestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -306,6 +351,8 @@ export interface UserPermissionState {
|
|||||||
isVideoMuted: boolean;
|
isVideoMuted: boolean;
|
||||||
/** 用户的所有 Socket 连接 ID(支持多设备同时在线) */
|
/** 用户的所有 Socket 连接 ID(支持多设备同时在线) */
|
||||||
socketIds: string[];
|
socketIds: string[];
|
||||||
|
/** 用户的 Socket 连接详情(用于区分加入方式) */
|
||||||
|
socketEntries?: Array<{ socketId: string; joinMode: JoinMode }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -103,14 +103,6 @@
|
|||||||
<el-option v-for="mic in microphoneList" :key="mic.deviceId" :label="mic.label" :value="mic.deviceId"></el-option>
|
<el-option v-for="mic in microphoneList" :key="mic.deviceId" :label="mic.label" :value="mic.deviceId"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</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>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
@ -158,12 +150,22 @@
|
|||||||
|
|
||||||
/** 表单引用 */
|
/** 表单引用 */
|
||||||
const formRef = ref<FormInstance>();
|
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({
|
const formData = ref({
|
||||||
selectedCamera: useStorage<string>('meeting_selected_camera', ''),
|
selectedCamera: useStorage<string>('meeting_selected_camera', ''),
|
||||||
selectedMicrophone: useStorage<string>('meeting_selected_microphone', ''),
|
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 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 || '');
|
const userName = computed(() => userStore.userInfo?.name || '');
|
||||||
|
|
||||||
@ -220,7 +211,7 @@
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 页面加载时就初始化本地轨道,让用户能看到预览
|
// 页面加载时就初始化本地轨道,让用户能看到预览
|
||||||
try {
|
try {
|
||||||
formData.value.meetingDuration = Math.round(dateInfo.value.timeDiff / 1000 / 60);
|
formData.value.endTimestamp = dateInfo.value.endTimestamp;
|
||||||
await getDevices();
|
await getDevices();
|
||||||
await initLocalTracks();
|
await initLocalTracks();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -432,6 +423,13 @@
|
|||||||
// 触发表单验证
|
// 触发表单验证
|
||||||
|
|
||||||
console.log('即将跳转到 meeting-room 页面');
|
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 =
|
const targetPath =
|
||||||
role.value === 'student' ? '/meeting/meeting-room-student' : target === 'monitor' ? '/meeting/meeting-room-monitor' : '/meeting/meeting-room';
|
role.value === 'student' ? '/meeting/meeting-room-student' : target === 'monitor' ? '/meeting/meeting-room-monitor' : '/meeting/meeting-room';
|
||||||
router.push({
|
router.push({
|
||||||
@ -442,7 +440,8 @@
|
|||||||
role: role.value,
|
role: role.value,
|
||||||
cameraDeviceId: formData.value.selectedCamera,
|
cameraDeviceId: formData.value.selectedCamera,
|
||||||
microphoneDeviceId: formData.value.selectedMicrophone,
|
microphoneDeviceId: formData.value.selectedMicrophone,
|
||||||
meetingDuration: formData.value.meetingDuration,
|
endTimestamp,
|
||||||
|
joinMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,9 +43,9 @@
|
|||||||
import type { IAgoraRTCRemoteUser } from 'agora-rtc-sdk-ng';
|
import type { IAgoraRTCRemoteUser } from 'agora-rtc-sdk-ng';
|
||||||
import IconMdiWifi from '~icons/mdi/wifi';
|
import IconMdiWifi from '~icons/mdi/wifi';
|
||||||
import IconMdiVideo from '~icons/mdi/video';
|
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 { 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 type { OnJoinRoomData, ServerToClientEvents } from './types';
|
||||||
import { useAgoraMeeting } from './useAgoraMeeting';
|
import { useAgoraMeeting } from './useAgoraMeeting';
|
||||||
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
|
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
|
||||||
@ -104,8 +104,26 @@
|
|||||||
{ disableLocalTracks }
|
{ 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 socket = useWebSocket();
|
||||||
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId);
|
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId, joinMode, endTimestamp);
|
||||||
|
|
||||||
const { networkQuality, selectedUserUid, leaveChannel, cleanup } = meeting;
|
const { networkQuality, selectedUserUid, leaveChannel, cleanup } = meeting;
|
||||||
|
|
||||||
@ -192,6 +210,43 @@
|
|||||||
if (typeof data?.teacherUid === 'number') {
|
if (typeof data?.teacherUid === 'number') {
|
||||||
teacherUid.value = data.teacherUid;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'sev_set_main_video': {
|
case 'sev_set_main_video': {
|
||||||
|
|||||||
@ -25,11 +25,19 @@
|
|||||||
<span v-if="isPrimaryScreenSharing" class="badge">投屏中</span>
|
<span v-if="isPrimaryScreenSharing" class="badge">投屏中</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="selfWindowRef" class="self-window" :style="selfWindowStyle" @pointerdown.prevent="handleSelfPointerDown" @click.stop>
|
<div
|
||||||
<div id="player-local" class="player-wrapper"></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">
|
<div class="self-label video-label">
|
||||||
<icon-mdi-account class="local-icon"></icon-mdi-account>
|
<icon-mdi-account class="local-icon"></icon-mdi-account>
|
||||||
<span>{{ userName }}</span>
|
<span>{{ selfWindowName }}</span>
|
||||||
|
<span v-if="isTeacherDualStream" class="badge">点击切换</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -107,32 +115,48 @@
|
|||||||
/** 主讲短 UID */
|
/** 主讲短 UID */
|
||||||
const speakerUid = ref<number | string | null>(null);
|
const speakerUid = ref<number | string | null>(null);
|
||||||
/** 投屏用户 ID */
|
/** 投屏用户 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, {
|
const meeting = useAgoraMeeting(
|
||||||
onTokenWillExpire: () => {
|
courseRoomId,
|
||||||
Toast.warning('Token 即将过期,正在自动刷新...');
|
userName,
|
||||||
},
|
shortUid,
|
||||||
onTokenExpired: () => {
|
{
|
||||||
Toast.error('Token 已过期,请重新加入会议');
|
onTokenWillExpire: () => {
|
||||||
},
|
Toast.warning('Token 即将过期,正在自动刷新...');
|
||||||
onConnectionStateChange: (state: string) => {
|
},
|
||||||
if (state === 'DISCONNECTED') {
|
onTokenExpired: () => {
|
||||||
Toast.warning('网络连接已断开');
|
Toast.error('Token 已过期,请重新加入会议');
|
||||||
} else if (state === 'RECONNECTING') {
|
},
|
||||||
Toast.info('正在重新连接...');
|
onConnectionStateChange: (state: string) => {
|
||||||
}
|
if (state === 'DISCONNECTED') {
|
||||||
},
|
Toast.warning('网络连接已断开');
|
||||||
onError: (error: Error) => {
|
} else if (state === 'RECONNECTING') {
|
||||||
Toast.error(error.message || '操作失败');
|
Toast.info('正在重新连接...');
|
||||||
},
|
}
|
||||||
onLeft: () => {
|
},
|
||||||
Toast.success('已离开会议');
|
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 socket = useWebSocket();
|
||||||
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId);
|
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId, joinMode, endTimestamp);
|
||||||
const streamType = useStreamType(meeting.client);
|
const streamType = useStreamType(meeting.client);
|
||||||
|
|
||||||
const { isMuted, isVideoOff, muteLoading, videoLoading, leaveLoading, networkQuality, selectedUserUid, toggleMute, toggleVideo, leaveChannel, cleanup } =
|
const { isMuted, isVideoOff, muteLoading, videoLoading, leaveLoading, networkQuality, selectedUserUid, toggleMute, toggleVideo, leaveChannel, cleanup } =
|
||||||
@ -151,14 +175,51 @@
|
|||||||
return qualityMap[networkQuality.value] || '网络未知';
|
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 primaryUid = computed<number | string | null>(() => {
|
||||||
const candidate = screenShareUid.value ?? speakerUid.value ?? (teacherUid.value || null);
|
if (isTeacherDualStream.value && studentPrimaryOverrideUid.value) {
|
||||||
const local = meeting.localUser.uid;
|
const candidate = studentPrimaryOverrideUid.value;
|
||||||
if (candidate && local && candidate === local && teacherUid.value) {
|
if (candidate === teacherUid.value || candidate === screenShareStreamUid.value) {
|
||||||
return teacherUid.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) {
|
if (!uid) {
|
||||||
return '主讲';
|
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;
|
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}`);
|
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 */
|
/** 舞台容器 DOM */
|
||||||
const stageRef = ref<HTMLDivElement | null>(null);
|
const stageRef = ref<HTMLDivElement | null>(null);
|
||||||
@ -301,16 +365,59 @@
|
|||||||
() => primaryUid.value,
|
() => primaryUid.value,
|
||||||
(uid) => {
|
(uid) => {
|
||||||
ensurePrimaryVideoPlaying(uid);
|
ensurePrimaryVideoPlaying(uid);
|
||||||
}
|
if (uid) {
|
||||||
|
selectedUserUid.value = uid;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => meeting.remoteUsers.value.length,
|
() => meeting.remoteUsers.value.length,
|
||||||
() => {
|
() => {
|
||||||
ensurePrimaryVideoPlaying(primaryUid.value);
|
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);
|
const isLeaving = ref<boolean>(false);
|
||||||
type MeetingWsPacket = Parameters<ServerToClientEvents['message']>[0];
|
type MeetingWsPacket = Parameters<ServerToClientEvents['message']>[0];
|
||||||
|
|
||||||
@ -360,8 +467,11 @@
|
|||||||
selectedUserUid.value = data.speakerUid;
|
selectedUserUid.value = data.speakerUid;
|
||||||
streamType.setMainVideo(data.speakerUid);
|
streamType.setMainVideo(data.speakerUid);
|
||||||
}
|
}
|
||||||
if (data?.screenShareUid) {
|
if (typeof data?.screenShareUid === 'number') {
|
||||||
screenShareUid.value = data.screenShareUid;
|
screenShareStreamUid.value = data.screenShareUid;
|
||||||
|
}
|
||||||
|
if (typeof data?.screenShareOwnerUid === 'number') {
|
||||||
|
screenShareOwnerUid.value = data.screenShareOwnerUid;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -395,8 +505,12 @@
|
|||||||
}
|
}
|
||||||
case 'sev_start_screen_share': {
|
case 'sev_start_screen_share': {
|
||||||
const targetUid = packet.data?.targetUid;
|
const targetUid = packet.data?.targetUid;
|
||||||
|
const screenUid = packet.data?.screenUid;
|
||||||
if (typeof targetUid === 'number') {
|
if (typeof targetUid === 'number') {
|
||||||
screenShareUid.value = targetUid;
|
screenShareOwnerUid.value = targetUid;
|
||||||
|
}
|
||||||
|
if (typeof screenUid === 'number') {
|
||||||
|
screenShareStreamUid.value = screenUid;
|
||||||
Toast.info?.(`用户 ${targetUid} 开始投屏`);
|
Toast.info?.(`用户 ${targetUid} 开始投屏`);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ensurePrimaryVideoPlaying(primaryUid.value);
|
ensurePrimaryVideoPlaying(primaryUid.value);
|
||||||
@ -407,9 +521,8 @@
|
|||||||
case 'sev_stop_screen_share': {
|
case 'sev_stop_screen_share': {
|
||||||
const targetUid = packet.data?.targetUid;
|
const targetUid = packet.data?.targetUid;
|
||||||
if (typeof targetUid === 'number') {
|
if (typeof targetUid === 'number') {
|
||||||
if (screenShareUid.value === targetUid) {
|
screenShareOwnerUid.value = null;
|
||||||
screenShareUid.value = null;
|
screenShareStreamUid.value = null;
|
||||||
}
|
|
||||||
Toast.info?.(`用户 ${targetUid} 停止投屏`);
|
Toast.info?.(`用户 ${targetUid} 停止投屏`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -426,8 +539,11 @@
|
|||||||
speakerUid.value = roomData.speakerUid;
|
speakerUid.value = roomData.speakerUid;
|
||||||
selectedUserUid.value = roomData.speakerUid;
|
selectedUserUid.value = roomData.speakerUid;
|
||||||
streamType.setMainVideo(roomData.speakerUid);
|
streamType.setMainVideo(roomData.speakerUid);
|
||||||
if (roomData.screenShareUid) {
|
if (typeof roomData.screenShareUid === 'number') {
|
||||||
screenShareUid.value = roomData.screenShareUid;
|
screenShareStreamUid.value = roomData.screenShareUid;
|
||||||
|
}
|
||||||
|
if (typeof roomData.screenShareOwnerUid === 'number') {
|
||||||
|
screenShareOwnerUid.value = roomData.screenShareOwnerUid;
|
||||||
}
|
}
|
||||||
await meeting.initAndJoin(roomData);
|
await meeting.initAndJoin(roomData);
|
||||||
initSelfWindowPosition();
|
initSelfWindowPosition();
|
||||||
@ -443,7 +559,8 @@
|
|||||||
}
|
}
|
||||||
case 'sev_class_ended': {
|
case 'sev_class_ended': {
|
||||||
classStatus.value = 'finished';
|
classStatus.value = 'finished';
|
||||||
screenShareUid.value = null;
|
screenShareOwnerUid.value = null;
|
||||||
|
screenShareStreamUid.value = null;
|
||||||
leaveChannel().catch(() => {});
|
leaveChannel().catch(() => {});
|
||||||
Toast.info?.('老师已下课,等待重新上课');
|
Toast.info?.('老师已下课,等待重新上课');
|
||||||
break;
|
break;
|
||||||
@ -481,7 +598,10 @@
|
|||||||
streamType.setMainVideo(roomData.speakerUid);
|
streamType.setMainVideo(roomData.speakerUid);
|
||||||
classStatus.value = roomData?.classStatus || 'not_started';
|
classStatus.value = roomData?.classStatus || 'not_started';
|
||||||
if (roomData.screenShareUid) {
|
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') {
|
if (classStatus.value === 'in_class') {
|
||||||
await meeting.initAndJoin(roomData);
|
await meeting.initAndJoin(roomData);
|
||||||
|
|||||||
@ -145,7 +145,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { playPromptTone } from '@/utils/prompt-tone';
|
import { playPromptTone } from '@/utils/prompt-tone';
|
||||||
import { useResizeObserver } from '@vueuse/core';
|
import { useResizeObserver } from '@vueuse/core';
|
||||||
import type { IAgoraRTCRemoteUser } from 'agora-rtc-sdk-ng';
|
|
||||||
import IconMdiAccount from '~icons/mdi/account';
|
import IconMdiAccount from '~icons/mdi/account';
|
||||||
import IconMdiExitToApp from '~icons/mdi/exit-to-app';
|
import IconMdiExitToApp from '~icons/mdi/exit-to-app';
|
||||||
import IconMdiLoading from '~icons/mdi/loading';
|
import IconMdiLoading from '~icons/mdi/loading';
|
||||||
@ -156,14 +155,14 @@
|
|||||||
import IconMdiMonitor from '~icons/mdi/monitor';
|
import IconMdiMonitor from '~icons/mdi/monitor';
|
||||||
import IconMdiMonitorShare from '~icons/mdi/monitor-share';
|
import IconMdiMonitorShare from '~icons/mdi/monitor-share';
|
||||||
import IconMdiWifi from '~icons/mdi/wifi';
|
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 { useRoute, useRouter } from 'vue-router';
|
||||||
import { useStreamType } from './useStreamType';
|
import { useStreamType } from './useStreamType';
|
||||||
import { usePermissions } from './usePermissions';
|
import { usePermissions } from './usePermissions';
|
||||||
import type { DisplayUser, OnJoinRoomData, ServerToClientEvents } from './types';
|
import type { DisplayUser, OnJoinRoomData, ServerToClientEvents } from './types';
|
||||||
import { useAgoraMeeting } from './useAgoraMeeting';
|
import { useAgoraMeeting } from './useAgoraMeeting';
|
||||||
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
|
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';
|
import { getBlacklistAxios, removeFromBlacklistAxios } from '@/api/meeting';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -208,6 +207,9 @@
|
|||||||
const shortUid = ref<number>(0);
|
const shortUid = ref<number>(0);
|
||||||
/** 房间号 */
|
/** 房间号 */
|
||||||
const roomId = ref<string>(''); // 房间 ID 会在加入后设置
|
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, {
|
const meeting = useAgoraMeeting(courseRoomId, userName, shortUid, {
|
||||||
onTokenWillExpire: () => {
|
onTokenWillExpire: () => {
|
||||||
Toast.warning('Token 即将过期,正在自动刷新...');
|
Toast.warning('Token 即将过期,正在自动刷新...');
|
||||||
@ -228,12 +230,26 @@
|
|||||||
onLeft: () => {
|
onLeft: () => {
|
||||||
Toast.success('已离开会议');
|
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);
|
const permissions = usePermissions(socket, roomId);
|
||||||
|
|
||||||
// 使用会议加入流程 Hook
|
// 使用会议加入流程 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
|
// 使用双流模式 Hook
|
||||||
const streamType = useStreamType(meeting.client);
|
const streamType = useStreamType(meeting.client);
|
||||||
// 从 Hook 中解构所需变量
|
// 从 Hook 中解构所需变量
|
||||||
@ -262,15 +278,23 @@
|
|||||||
|
|
||||||
/** 投屏操作加载状态 */
|
/** 投屏操作加载状态 */
|
||||||
const screenShareLoading = ref(false);
|
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
|
* @param {number | string} uid - 用户 ID
|
||||||
*/
|
*/
|
||||||
function isUserScreenSharing(uid: number | string): boolean {
|
function isUserScreenSharing(uid: number | string): boolean {
|
||||||
return screenShareUid.value === uid;
|
return screenShareOwnerUid.value === uid;
|
||||||
}
|
}
|
||||||
|
|
||||||
let isLeaving = false;
|
let isLeaving = false;
|
||||||
@ -288,43 +312,63 @@
|
|||||||
if (data?.classStatus) {
|
if (data?.classStatus) {
|
||||||
classStatus.value = data.classStatus;
|
classStatus.value = data.classStatus;
|
||||||
}
|
}
|
||||||
// 恢复投屏状态(从 Redis 中获取)
|
if (typeof data?.endTimestamp === 'number') {
|
||||||
if (data?.screenShareUid) {
|
roomEndTimestamp.value = data.endTimestamp;
|
||||||
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?.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;
|
break;
|
||||||
}
|
}
|
||||||
case 'sev_mute_audio': {
|
case 'sev_mute_audio': {
|
||||||
@ -408,41 +452,23 @@
|
|||||||
}
|
}
|
||||||
case 'sev_start_screen_share': {
|
case 'sev_start_screen_share': {
|
||||||
const targetUid = packet.data?.targetUid;
|
const targetUid = packet.data?.targetUid;
|
||||||
|
const screenUid = packet.data?.screenUid;
|
||||||
console.log('[屏幕共享] 收到 sev_start_screen_share 消息:', targetUid);
|
console.log('[屏幕共享] 收到 sev_start_screen_share 消息:', targetUid);
|
||||||
if (typeof targetUid === 'number') {
|
if (typeof targetUid === 'number') {
|
||||||
screenShareUid.value = targetUid;
|
screenShareOwnerUid.value = targetUid;
|
||||||
console.log('[屏幕共享] screenShareUid 已设置为:', targetUid);
|
if (typeof screenUid === 'number') {
|
||||||
|
screenShareStreamUid.value = screenUid;
|
||||||
|
}
|
||||||
|
console.log('[屏幕共享] screenShareOwnerUid 已设置为:', targetUid);
|
||||||
Toast.info?.(`用户 ${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;
|
break;
|
||||||
}
|
}
|
||||||
case 'sev_stop_screen_share': {
|
case 'sev_stop_screen_share': {
|
||||||
const targetUid = packet.data?.targetUid;
|
const targetUid = packet.data?.targetUid;
|
||||||
if (typeof targetUid === 'number') {
|
if (typeof targetUid === 'number') {
|
||||||
screenShareUid.value = null;
|
screenShareOwnerUid.value = null;
|
||||||
|
screenShareStreamUid.value = null;
|
||||||
Toast.info?.(`用户 ${targetUid} 停止投屏`);
|
Toast.info?.(`用户 ${targetUid} 停止投屏`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -471,7 +497,8 @@
|
|||||||
// 下课时自动停止投屏
|
// 下课时自动停止投屏
|
||||||
if (isScreenSharing.value) {
|
if (isScreenSharing.value) {
|
||||||
stopScreenTrack().catch(() => {});
|
stopScreenTrack().catch(() => {});
|
||||||
screenShareUid.value = null;
|
screenShareOwnerUid.value = null;
|
||||||
|
screenShareStreamUid.value = null;
|
||||||
}
|
}
|
||||||
leaveChannel().catch(() => {});
|
leaveChannel().catch(() => {});
|
||||||
Toast.info?.('老师已下课,等待重新上课');
|
Toast.info?.('老师已下课,等待重新上课');
|
||||||
@ -670,21 +697,24 @@
|
|||||||
try {
|
try {
|
||||||
console.log('[投屏] toggleScreenShare 被调用,isScreenSharing.value:', isScreenSharing.value);
|
console.log('[投屏] toggleScreenShare 被调用,isScreenSharing.value:', isScreenSharing.value);
|
||||||
if (isScreenSharing.value) {
|
if (isScreenSharing.value) {
|
||||||
|
const currentScreenUid = screenShareStreamUid.value || meeting.localScreenUid.value || undefined;
|
||||||
// 停止投屏
|
// 停止投屏
|
||||||
await stopScreenTrack();
|
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('已停止投屏');
|
Toast.success('已停止投屏');
|
||||||
} else {
|
} else {
|
||||||
// 开始投屏
|
// 开始投屏
|
||||||
console.log('[投屏] 准备开始投屏');
|
console.log('[投屏] 准备开始投屏');
|
||||||
await startScreenShare();
|
const screenUid = await startScreenShare();
|
||||||
// 通知后端开始投屏(带上自己的短 UID)
|
// 通知后端开始投屏(带上自己的短 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('已开始投屏');
|
Toast.success('已开始投屏');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -66,6 +66,11 @@ export type ClassStatus = 'finished' | 'in_class' | 'not_started';
|
|||||||
*/
|
*/
|
||||||
export type UserRole = 'student' | 'teacher';
|
export type UserRole = 'student' | 'teacher';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入方式
|
||||||
|
*/
|
||||||
|
export type JoinMode = 'classroom' | 'monitor' | 'student';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 会议状态(响应式状态替代回调)
|
// 会议状态(响应式状态替代回调)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -160,6 +165,10 @@ export interface SendJoinRoomData {
|
|||||||
userName: string;
|
userName: string;
|
||||||
/** 作业 ID */
|
/** 作业 ID */
|
||||||
homeworkId: number;
|
homeworkId: number;
|
||||||
|
/** 加入方式(课程/监控/学生) */
|
||||||
|
joinMode: JoinMode;
|
||||||
|
/** 房间结束时间(毫秒时间戳),仅老师端首次进入时会写入 */
|
||||||
|
endTimestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -253,10 +262,35 @@ export interface OnJoinRoomData {
|
|||||||
classStatus?: ClassStatus;
|
classStatus?: ClassStatus;
|
||||||
/** 投屏状态:正在投屏的用户短 UID,undefined 表示无人投屏 */
|
/** 投屏状态:正在投屏的用户短 UID,undefined 表示无人投屏 */
|
||||||
screenShareUid?: number;
|
screenShareUid?: number;
|
||||||
|
screenShareOwnerUid?: number;
|
||||||
/** 主讲人短 UID(默认等于 teacherUid) */
|
/** 主讲人短 UID(默认等于 teacherUid) */
|
||||||
speakerUid: number;
|
speakerUid: number;
|
||||||
/** 老师短 UID */
|
/** 老师短 UID */
|
||||||
teacherUid: number;
|
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>;
|
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 消息包 - 客户端接收:被踢出会议
|
* WebSocket 消息包 - 客户端接收:被踢出会议
|
||||||
*/
|
*/
|
||||||
@ -353,6 +397,8 @@ export type DownlinkControlMessagePacket =
|
|||||||
| OnKickedPacket
|
| OnKickedPacket
|
||||||
| OnMuteAudioPacket
|
| OnMuteAudioPacket
|
||||||
| OnMuteVideoPacket
|
| OnMuteVideoPacket
|
||||||
|
| OnRoomEndTimestampUpdatedPacket
|
||||||
|
| OnRoomWillExpirePacket
|
||||||
| OnScreenShareStartedPacket
|
| OnScreenShareStartedPacket
|
||||||
| OnScreenShareStoppedPacket
|
| OnScreenShareStoppedPacket
|
||||||
| OnSetMainVideoPacket
|
| OnSetMainVideoPacket
|
||||||
@ -377,6 +423,7 @@ export type ControlCommandType =
|
|||||||
| 'client_kick_user' // 踢出用户
|
| 'client_kick_user' // 踢出用户
|
||||||
| 'client_mute_audio' // 禁麦(关闭用户麦克风)
|
| 'client_mute_audio' // 禁麦(关闭用户麦克风)
|
||||||
| 'client_mute_video' // 禁视频(关闭用户摄像头)
|
| 'client_mute_video' // 禁视频(关闭用户摄像头)
|
||||||
|
| 'client_renew_room_end_timestamp' // 续期房间结束时间
|
||||||
| 'client_set_main_video' // 设置主屏(指定大窗口显示的用户)
|
| 'client_set_main_video' // 设置主屏(指定大窗口显示的用户)
|
||||||
| 'client_start_class' // 开始上课
|
| 'client_start_class' // 开始上课
|
||||||
| 'client_start_screen_share' // 开始投屏
|
| 'client_start_screen_share' // 开始投屏
|
||||||
@ -428,6 +475,7 @@ export interface SendStartScreenShareData {
|
|||||||
roomId: string;
|
roomId: string;
|
||||||
/** 短 UID(可选,当前后端不需要) */
|
/** 短 UID(可选,当前后端不需要) */
|
||||||
shortUid?: number;
|
shortUid?: number;
|
||||||
|
screenUid: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -441,6 +489,7 @@ export type SendStartScreenSharePacket = WebSocketPacket<'client_start_screen_sh
|
|||||||
export interface SendStopScreenShareData {
|
export interface SendStopScreenShareData {
|
||||||
/** 房间 ID */
|
/** 房间 ID */
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
screenUid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -456,6 +505,7 @@ export interface OnScreenShareStartedData {
|
|||||||
fromRoomId: string;
|
fromRoomId: string;
|
||||||
/** 开始共享的用户 ID */
|
/** 开始共享的用户 ID */
|
||||||
targetUid: number;
|
targetUid: number;
|
||||||
|
screenUid: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -471,6 +521,7 @@ export interface OnScreenShareStoppedData {
|
|||||||
fromRoomId: string;
|
fromRoomId: string;
|
||||||
/** 停止共享的用户 ID */
|
/** 停止共享的用户 ID */
|
||||||
targetUid: number;
|
targetUid: number;
|
||||||
|
screenUid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -494,6 +545,7 @@ export interface ClientToServerEvents {
|
|||||||
client_end_class: (data: { roomId: string }) => void;
|
client_end_class: (data: { roomId: string }) => void;
|
||||||
client_start_screen_share: (data: SendStartScreenShareData) => void;
|
client_start_screen_share: (data: SendStartScreenShareData) => void;
|
||||||
client_stop_screen_share: (data: SendStopScreenShareData) => 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;
|
onTokenExpired?: () => void;
|
||||||
/** 离开会议回调 */
|
/** 离开会议回调 */
|
||||||
onLeft?: () => void;
|
onLeft?: () => void;
|
||||||
|
onLocalScreenShareEnded?: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { type Ref, computed, nextTick, reactive, ref, shallowRef, unref, watch,
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import type { LocalUser, MeetingEventCallbacks, NetworkQualityLevel, OnJoinRoomData } from './types';
|
import type { LocalUser, MeetingEventCallbacks, NetworkQualityLevel, OnJoinRoomData } from './types';
|
||||||
import { TokenManager } from './useTokenManager';
|
import { TokenManager } from './useTokenManager';
|
||||||
|
import { getMeetingTokenAxios } from '@/api/meeting';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 声网会议 Hook 配置
|
* 声网会议 Hook 配置
|
||||||
@ -28,6 +29,12 @@ import { TokenManager } from './useTokenManager';
|
|||||||
export interface AgoraMeetingOptions {
|
export interface AgoraMeetingOptions {
|
||||||
/** 是否禁用本地音视频轨道(用于监控端等纯观看场景) */
|
/** 是否禁用本地音视频轨道(用于监控端等纯观看场景) */
|
||||||
disableLocalTracks?: Ref<boolean> | boolean;
|
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 localVideoTrack: ICameraVideoTrack | null = null;
|
||||||
/** 本地屏幕共享视频轨道(内部使用) */
|
/** 本地屏幕共享视频轨道(内部使用) */
|
||||||
let localScreenTrack: ILocalVideoTrack | null = null;
|
let localScreenTrack: ILocalVideoTrack | null = null;
|
||||||
|
let screenClient: IAgoraRTCClient | null = null;
|
||||||
|
|
||||||
/** 是否已加入频道 */
|
/** 是否已加入频道 */
|
||||||
const isJoined = ref(false);
|
const isJoined = ref(false);
|
||||||
@ -96,6 +104,7 @@ export function useAgoraMeeting(
|
|||||||
|
|
||||||
/** 是否正在共享屏幕 */
|
/** 是否正在共享屏幕 */
|
||||||
const isScreenSharing = ref(false);
|
const isScreenSharing = ref(false);
|
||||||
|
const localScreenUid = ref<number | null>(null);
|
||||||
/** 是否禁用本地音视频轨道 */
|
/** 是否禁用本地音视频轨道 */
|
||||||
const disableLocalTracks = computed(() => Boolean(unref(options?.disableLocalTracks ?? false)));
|
const disableLocalTracks = computed(() => Boolean(unref(options?.disableLocalTracks ?? false)));
|
||||||
|
|
||||||
@ -200,7 +209,6 @@ export function useAgoraMeeting(
|
|||||||
*/
|
*/
|
||||||
function initClient(): void {
|
function initClient(): void {
|
||||||
try {
|
try {
|
||||||
AgoraRTC.setLogLevel(2);
|
|
||||||
client.value = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });
|
client.value = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });
|
||||||
if (tokenManagerRef.value) {
|
if (tokenManagerRef.value) {
|
||||||
tokenManagerRef.value.bindClient(client.value);
|
tokenManagerRef.value.bindClient(client.value);
|
||||||
@ -217,29 +225,18 @@ export function useAgoraMeeting(
|
|||||||
const playerDiv = document.getElementById(playerId);
|
const playerDiv = document.getElementById(playerId);
|
||||||
console.log('【声网】视频播放元素:', playerDiv, 'videoTrack:', user.videoTrack);
|
console.log('【声网】视频播放元素:', playerDiv, 'videoTrack:', user.videoTrack);
|
||||||
if (playerDiv && user.videoTrack) {
|
if (playerDiv && user.videoTrack) {
|
||||||
user.videoTrack.play(playerDiv);
|
try {
|
||||||
console.log('【声网】远程视频已播放:', user.uid);
|
user.videoTrack.play(playerDiv);
|
||||||
|
console.log('【声网】远程视频已播放:', user.uid);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('【声网】远程视频播放异常:', error);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('【声网】视频播放失败:元素或轨道不存在');
|
console.warn('【声网】视频播放失败:元素或轨道不存在');
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 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') => {
|
client.value.on('user-published', async (user: IAgoraRTCRemoteUser, mediaType: 'audio' | 'video') => {
|
||||||
console.log('【声网事件】user-published:', user.uid, '媒体类型:', mediaType);
|
console.log('【声网事件】user-published:', user.uid, '媒体类型:', mediaType);
|
||||||
console.log('【声网事件】user 对象:', user);
|
console.log('【声网事件】user 对象:', user);
|
||||||
@ -254,8 +251,8 @@ export function useAgoraMeeting(
|
|||||||
}
|
}
|
||||||
console.log('【声网】订阅成功:', user.uid, '类型:', mediaType, isMainVideo ? '主视频 (高流)' : '小视频 (低流)');
|
console.log('【声网】订阅成功:', user.uid, '类型:', mediaType, isMainVideo ? '主视频 (高流)' : '小视频 (低流)');
|
||||||
|
|
||||||
// 播放远程视频到普通播放器
|
// 播放远程视频到普通播放器(默认使用 player-${uid},可通过 options 关闭)
|
||||||
if (mediaType === 'video') {
|
if (mediaType === 'video' && (options?.autoPlayRemoteVideo ?? true)) {
|
||||||
playRemoteVideo(user, `player-${user.uid}`);
|
playRemoteVideo(user, `player-${user.uid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -280,46 +277,6 @@ export function useAgoraMeeting(
|
|||||||
callbacks?.onUserPublished?.(user, mediaType);
|
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) => {
|
client.value.on('stream-type-changed', (uid: number | string, streamType: RemoteStreamType) => {
|
||||||
console.log(' 订阅的视频流类型:', streamType);
|
console.log(' 订阅的视频流类型:', streamType);
|
||||||
@ -682,28 +639,29 @@ export function useAgoraMeeting(
|
|||||||
*/
|
*/
|
||||||
async function stopScreenTrack(): Promise<void> {
|
async function stopScreenTrack(): Promise<void> {
|
||||||
const track = localScreenTrack;
|
const track = localScreenTrack;
|
||||||
|
const currentScreenClient = screenClient;
|
||||||
|
localScreenTrack = null;
|
||||||
|
screenClient = null;
|
||||||
|
isScreenSharing.value = false;
|
||||||
|
localScreenUid.value = null;
|
||||||
if (track) {
|
if (track) {
|
||||||
localScreenTrack = null;
|
|
||||||
isScreenSharing.value = false;
|
|
||||||
try {
|
try {
|
||||||
await client.value?.unpublish(track);
|
await currentScreenClient?.unpublish(track);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
track.stop();
|
track.stop();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
track.close();
|
track.close();
|
||||||
console.log('[Agora] 屏幕共享已停止');
|
} catch {}
|
||||||
|
}
|
||||||
// 停止屏幕共享后重新发布摄像头视频
|
if (currentScreenClient) {
|
||||||
if (localVideoTrack && !isVideoOff.value) {
|
try {
|
||||||
console.log('[Agora] 停止投屏后重新发布摄像头视频');
|
currentScreenClient.removeAllListeners?.();
|
||||||
try {
|
} catch {}
|
||||||
await client.value?.publish(localVideoTrack);
|
try {
|
||||||
console.log('[Agora] 摄像头视频已重新发布');
|
await currentScreenClient.leave();
|
||||||
} catch (error) {
|
} catch {}
|
||||||
console.error('[Agora] 重新发布摄像头视频失败:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Agora] 停止屏幕共享失败:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -712,10 +670,8 @@ export function useAgoraMeeting(
|
|||||||
* 默认选择当前网页所在的屏幕,获取不到就使用第一个屏幕
|
* 默认选择当前网页所在的屏幕,获取不到就使用第一个屏幕
|
||||||
* 注意:声网不允许同时发布多个视频轨道,所以发布屏幕共享前需要先取消发布摄像头视频
|
* 注意:声网不允许同时发布多个视频轨道,所以发布屏幕共享前需要先取消发布摄像头视频
|
||||||
*/
|
*/
|
||||||
async function startScreenShare(): Promise<void> {
|
async function startScreenShare(): Promise<number> {
|
||||||
console.log('[屏幕共享] 开始执行 startScreenShare');
|
console.log('[屏幕共享] 开始执行 startScreenShare');
|
||||||
console.log('[屏幕共享] client.value:', client.value ? '存在' : '不存在');
|
|
||||||
console.log('[屏幕共享] isJoined.value:', isJoined.value);
|
|
||||||
|
|
||||||
if (!client.value || !isJoined.value) {
|
if (!client.value || !isJoined.value) {
|
||||||
console.error('[屏幕共享] 未加入会议,无法共享屏幕');
|
console.error('[屏幕共享] 未加入会议,无法共享屏幕');
|
||||||
@ -730,82 +686,55 @@ export function useAgoraMeeting(
|
|||||||
console.log('[屏幕共享] 显示 FullLoading');
|
console.log('[屏幕共享] 显示 FullLoading');
|
||||||
FullLoading.show('正在开启屏幕共享...');
|
FullLoading.show('正在开启屏幕共享...');
|
||||||
|
|
||||||
// ✅ 先取消发布摄像头视频(声网不允许同时发布多个视频轨道)
|
const baseUid = Number(shortUidRef.value || 0);
|
||||||
console.log('[屏幕共享] 准备取消发布摄像头视频');
|
if (!Number.isFinite(baseUid) || baseUid <= 0) {
|
||||||
if (localVideoTrack && !isVideoOff.value) {
|
FullLoading.hide();
|
||||||
try {
|
throw new Error('用户信息未就绪,无法开启屏幕共享');
|
||||||
await client.value?.unpublish(localVideoTrack);
|
|
||||||
console.log('[屏幕共享] 已取消发布摄像头视频');
|
|
||||||
// 等待 unpublish 完成,确保客户端内部状态已更新
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve();
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[屏幕共享] 取消发布摄像头视频失败:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 navigator.mediaDevices.getDisplayMedia 获取屏幕流
|
const nextScreenUid = baseUid + 1000000;
|
||||||
// 这样可以默认选择当前网页所在的屏幕
|
if (!Number.isFinite(nextScreenUid) || nextScreenUid <= 0 || nextScreenUid > 4294967295) {
|
||||||
console.log('[屏幕共享] 调用 navigator.mediaDevices.getDisplayMedia');
|
FullLoading.hide();
|
||||||
const screenStream = await navigator.mediaDevices.getDisplayMedia({
|
throw new Error('生成投屏 UID 失败');
|
||||||
video: {
|
}
|
||||||
width: { ideal: 1920 },
|
|
||||||
height: { ideal: 1080 },
|
const tokenRes = await getMeetingTokenAxios(courseRoomId.value, nextScreenUid);
|
||||||
frameRate: { ideal: 15 },
|
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);
|
const screenVideoTrack = Array.isArray(trackResult) ? trackResult[0] : trackResult;
|
||||||
console.log('[屏幕共享] screenStream.getVideoTracks():', screenStream.getVideoTracks());
|
|
||||||
|
|
||||||
const screenTrack = screenStream.getVideoTracks()[0];
|
await nextClient.publish(screenVideoTrack);
|
||||||
console.log('[屏幕共享] screenTrack:', screenTrack);
|
localScreenTrack = screenVideoTrack;
|
||||||
|
screenClient = nextClient;
|
||||||
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;
|
|
||||||
isScreenSharing.value = true;
|
isScreenSharing.value = true;
|
||||||
|
localScreenUid.value = nextScreenUid;
|
||||||
console.log('[屏幕共享] localScreenTrack:', localScreenTrack);
|
|
||||||
console.log('[屏幕共享] isScreenSharing.value:', isScreenSharing.value);
|
|
||||||
|
|
||||||
FullLoading.hide();
|
FullLoading.hide();
|
||||||
|
|
||||||
// 监听屏幕共享轨道结束事件(用户点击浏览器停止共享按钮)
|
screenVideoTrack.on('track-ended', () => {
|
||||||
screenTrack.onended = () => {
|
stopScreenTrack()
|
||||||
console.log('[屏幕共享] 用户停止了屏幕共享(onended 事件触发)');
|
.then(() => {
|
||||||
stopScreenTrack().catch(() => {});
|
callbacks?.onLocalScreenShareEnded?.();
|
||||||
};
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
console.log('[屏幕共享] startScreenShare 执行完毕');
|
console.log('[屏幕共享] startScreenShare 执行完毕');
|
||||||
|
return nextScreenUid;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[屏幕共享] ❌ 屏幕共享失败:', error);
|
console.error('[屏幕共享] ❌ 屏幕共享失败:', error);
|
||||||
FullLoading.hide();
|
FullLoading.hide();
|
||||||
@ -887,6 +816,7 @@ export function useAgoraMeeting(
|
|||||||
localVideoTrack,
|
localVideoTrack,
|
||||||
/** 是否正在共享屏幕 */
|
/** 是否正在共享屏幕 */
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
|
localScreenUid,
|
||||||
/** 初始化并加入会议 */
|
/** 初始化并加入会议 */
|
||||||
initAndJoin,
|
initAndJoin,
|
||||||
/** 加入频道 */
|
/** 加入频道 */
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { type ComputedRef, ref } from 'vue';
|
import { type ComputedRef, ref } from 'vue';
|
||||||
import { io } from 'socket.io-client';
|
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 { getDefaultAuthHeaders } from '@/utils/axios/auth-headers';
|
||||||
import router from '@/router/router';
|
import router from '@/router/router';
|
||||||
let socket: MeetingClientSocket | null = null;
|
let socket: MeetingClientSocket | null = null;
|
||||||
@ -114,7 +114,9 @@ export function useMeetingSocket(
|
|||||||
courseRoomId: ComputedRef<string>,
|
courseRoomId: ComputedRef<string>,
|
||||||
longUserId: ComputedRef<number>,
|
longUserId: ComputedRef<number>,
|
||||||
userName: ComputedRef<string>,
|
userName: ComputedRef<string>,
|
||||||
homeworkId: ComputedRef<number>
|
homeworkId: ComputedRef<number>,
|
||||||
|
joinMode: ComputedRef<JoinMode>,
|
||||||
|
endTimestamp: ComputedRef<number | null>
|
||||||
) {
|
) {
|
||||||
/** 是否已加入房间 */
|
/** 是否已加入房间 */
|
||||||
const isJoined = ref(false);
|
const isJoined = ref(false);
|
||||||
@ -151,6 +153,8 @@ export function useMeetingSocket(
|
|||||||
courseRoomId: courseRoomId.value,
|
courseRoomId: courseRoomId.value,
|
||||||
userName: userName.value,
|
userName: userName.value,
|
||||||
homeworkId: homeworkId.value,
|
homeworkId: homeworkId.value,
|
||||||
|
joinMode: joinMode.value,
|
||||||
|
...(endTimestamp.value ? { endTimestamp: endTimestamp.value } : {}),
|
||||||
};
|
};
|
||||||
ws.emit('client_join_room', joinData);
|
ws.emit('client_join_room', joinData);
|
||||||
};
|
};
|
||||||
@ -187,10 +191,21 @@ export function useMeetingSocket(
|
|||||||
ws.emit('client_leave_room', { courseRoomId: courseRoomId.value });
|
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 {
|
return {
|
||||||
isJoined,
|
isJoined,
|
||||||
joinRoom,
|
joinRoom,
|
||||||
leaveRoom,
|
leaveRoom,
|
||||||
|
renewRoomEndTimestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user