396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
// Socket.IO 观影室服务器逻辑(共享代码)
|
||
import { Server as SocketIOServer, Socket } from 'socket.io';
|
||
import type {
|
||
Room,
|
||
Member,
|
||
PlayState,
|
||
LiveState,
|
||
ChatMessage,
|
||
ServerToClientEvents,
|
||
ClientToServerEvents,
|
||
RoomMemberInfo,
|
||
} from '@/types/watch-room';
|
||
|
||
type TypedSocket = Socket<ClientToServerEvents, ServerToClientEvents>;
|
||
|
||
export class WatchRoomServer {
|
||
private rooms: Map<string, Room> = new Map();
|
||
private members: Map<string, Map<string, Member>> = new Map(); // roomId -> userId -> Member
|
||
private socketToRoom: Map<string, RoomMemberInfo> = new Map(); // socketId -> RoomMemberInfo
|
||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||
|
||
constructor(private io: SocketIOServer<ClientToServerEvents, ServerToClientEvents>) {
|
||
this.setupEventHandlers();
|
||
this.startCleanupTimer();
|
||
}
|
||
|
||
private setupEventHandlers() {
|
||
this.io.on('connection', (socket: TypedSocket) => {
|
||
console.log(`[WatchRoom] Client connected: ${socket.id}`);
|
||
|
||
// 创建房间
|
||
socket.on('room:create', (data, callback) => {
|
||
try {
|
||
const roomId = this.generateRoomId();
|
||
const userId = socket.id;
|
||
const ownerToken = this.generateRoomId(); // 生成房主令牌
|
||
|
||
const room: Room = {
|
||
id: roomId,
|
||
name: data.name,
|
||
description: data.description,
|
||
password: data.password,
|
||
isPublic: data.isPublic,
|
||
ownerId: userId,
|
||
ownerName: data.userName,
|
||
ownerToken: ownerToken, // 保存房主令牌
|
||
memberCount: 1,
|
||
currentState: null,
|
||
createdAt: Date.now(),
|
||
lastOwnerHeartbeat: Date.now(),
|
||
};
|
||
|
||
const member: Member = {
|
||
id: userId,
|
||
name: data.userName,
|
||
isOwner: true,
|
||
lastHeartbeat: Date.now(),
|
||
};
|
||
|
||
this.rooms.set(roomId, room);
|
||
this.members.set(roomId, new Map([[userId, member]]));
|
||
this.socketToRoom.set(socket.id, {
|
||
roomId,
|
||
userId,
|
||
userName: data.userName,
|
||
isOwner: true,
|
||
});
|
||
|
||
socket.join(roomId);
|
||
|
||
console.log(`[WatchRoom] Room created: ${roomId} by ${data.userName}`);
|
||
callback({ success: true, room });
|
||
} catch (error) {
|
||
console.error('[WatchRoom] Error creating room:', error);
|
||
callback({ success: false, error: '创建房间失败' });
|
||
}
|
||
});
|
||
|
||
// 加入房间
|
||
socket.on('room:join', (data, callback) => {
|
||
try {
|
||
const room = this.rooms.get(data.roomId);
|
||
if (!room) {
|
||
return callback({ success: false, error: '房间不存在' });
|
||
}
|
||
|
||
// 检查密码
|
||
if (room.password && room.password !== data.password) {
|
||
return callback({ success: false, error: '密码错误' });
|
||
}
|
||
|
||
const userId = socket.id;
|
||
const member: Member = {
|
||
id: userId,
|
||
name: data.userName,
|
||
isOwner: false,
|
||
lastHeartbeat: Date.now(),
|
||
};
|
||
|
||
const roomMembers = this.members.get(data.roomId);
|
||
if (roomMembers) {
|
||
roomMembers.set(userId, member);
|
||
room.memberCount = roomMembers.size;
|
||
this.rooms.set(data.roomId, room);
|
||
}
|
||
|
||
this.socketToRoom.set(socket.id, {
|
||
roomId: data.roomId,
|
||
userId,
|
||
userName: data.userName,
|
||
isOwner: false,
|
||
});
|
||
|
||
socket.join(data.roomId);
|
||
|
||
// 通知房间内其他成员
|
||
socket.to(data.roomId).emit('room:member-joined', member);
|
||
|
||
console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}`);
|
||
|
||
const members = Array.from(roomMembers?.values() || []);
|
||
callback({ success: true, room, members });
|
||
} catch (error) {
|
||
console.error('[WatchRoom] Error joining room:', error);
|
||
callback({ success: false, error: '加入房间失败' });
|
||
}
|
||
});
|
||
|
||
// 离开房间
|
||
socket.on('room:leave', () => {
|
||
this.handleLeaveRoom(socket);
|
||
});
|
||
|
||
// 获取房间列表
|
||
socket.on('room:list', (callback) => {
|
||
const publicRooms = Array.from(this.rooms.values()).filter((room) => room.isPublic);
|
||
callback(publicRooms);
|
||
});
|
||
|
||
// 播放状态更新
|
||
socket.on('play:update', (state) => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo || !roomInfo.isOwner) return;
|
||
|
||
const room = this.rooms.get(roomInfo.roomId);
|
||
if (room) {
|
||
room.currentState = state;
|
||
this.rooms.set(roomInfo.roomId, room);
|
||
socket.to(roomInfo.roomId).emit('play:update', state);
|
||
}
|
||
});
|
||
|
||
// 播放进度跳转
|
||
socket.on('play:seek', (currentTime) => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) return;
|
||
|
||
socket.to(roomInfo.roomId).emit('play:seek', currentTime);
|
||
});
|
||
|
||
// 播放
|
||
socket.on('play:play', () => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) return;
|
||
|
||
socket.to(roomInfo.roomId).emit('play:play');
|
||
});
|
||
|
||
// 暂停
|
||
socket.on('play:pause', () => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) return;
|
||
|
||
socket.to(roomInfo.roomId).emit('play:pause');
|
||
});
|
||
|
||
// 切换视频/集数
|
||
socket.on('play:change', (state) => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo || !roomInfo.isOwner) return;
|
||
|
||
const room = this.rooms.get(roomInfo.roomId);
|
||
if (room) {
|
||
room.currentState = state;
|
||
this.rooms.set(roomInfo.roomId, room);
|
||
socket.to(roomInfo.roomId).emit('play:change', state);
|
||
}
|
||
});
|
||
|
||
// 切换直播频道
|
||
socket.on('live:change', (state) => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo || !roomInfo.isOwner) return;
|
||
|
||
const room = this.rooms.get(roomInfo.roomId);
|
||
if (room) {
|
||
room.currentState = state;
|
||
this.rooms.set(roomInfo.roomId, room);
|
||
socket.to(roomInfo.roomId).emit('live:change', state);
|
||
}
|
||
});
|
||
|
||
// 聊天消息
|
||
socket.on('chat:message', (data) => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) return;
|
||
|
||
const message: ChatMessage = {
|
||
id: this.generateMessageId(),
|
||
userId: roomInfo.userId,
|
||
userName: roomInfo.userName,
|
||
content: data.content,
|
||
type: data.type,
|
||
timestamp: Date.now(),
|
||
};
|
||
|
||
this.io.to(roomInfo.roomId).emit('chat:message', message);
|
||
});
|
||
|
||
// WebRTC 信令
|
||
socket.on('voice:offer', (data) => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) return;
|
||
|
||
this.io.to(data.targetUserId).emit('voice:offer', {
|
||
userId: socket.id,
|
||
offer: data.offer,
|
||
});
|
||
});
|
||
|
||
socket.on('voice:answer', (data) => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) return;
|
||
|
||
this.io.to(data.targetUserId).emit('voice:answer', {
|
||
userId: socket.id,
|
||
answer: data.answer,
|
||
});
|
||
});
|
||
|
||
socket.on('voice:ice', (data) => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) return;
|
||
|
||
this.io.to(data.targetUserId).emit('voice:ice', {
|
||
userId: socket.id,
|
||
candidate: data.candidate,
|
||
});
|
||
});
|
||
|
||
// 清除房间播放状态(房主离开播放/直播页面时调用)
|
||
socket.on('state:clear', (callback) => {
|
||
console.log('[WatchRoom] Received state:clear from', socket.id);
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
|
||
if (!roomInfo) {
|
||
console.log('[WatchRoom] No room info found for socket');
|
||
if (callback) callback({ success: false, error: 'Not in a room' });
|
||
return;
|
||
}
|
||
|
||
if (!roomInfo.isOwner) {
|
||
console.log('[WatchRoom] User is not owner');
|
||
if (callback) callback({ success: false, error: 'Not owner' });
|
||
return;
|
||
}
|
||
|
||
const room = this.rooms.get(roomInfo.roomId);
|
||
if (room) {
|
||
console.log(`[WatchRoom] Clearing room state for ${roomInfo.roomId}`);
|
||
room.currentState = null;
|
||
this.rooms.set(roomInfo.roomId, room);
|
||
// 通知房间内其他成员状态已清除
|
||
socket.to(roomInfo.roomId).emit('state:cleared');
|
||
if (callback) callback({ success: true });
|
||
} else {
|
||
console.log('[WatchRoom] Room not found');
|
||
if (callback) callback({ success: false, error: 'Room not found' });
|
||
}
|
||
});
|
||
|
||
// 心跳
|
||
socket.on('heartbeat', () => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) return;
|
||
|
||
const roomMembers = this.members.get(roomInfo.roomId);
|
||
const member = roomMembers?.get(roomInfo.userId);
|
||
if (member) {
|
||
member.lastHeartbeat = Date.now();
|
||
roomMembers?.set(roomInfo.userId, member);
|
||
}
|
||
|
||
// 如果是房主,更新房间心跳
|
||
if (roomInfo.isOwner) {
|
||
const room = this.rooms.get(roomInfo.roomId);
|
||
if (room) {
|
||
room.lastOwnerHeartbeat = Date.now();
|
||
this.rooms.set(roomInfo.roomId, room);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 断开连接
|
||
socket.on('disconnect', () => {
|
||
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
|
||
this.handleLeaveRoom(socket);
|
||
});
|
||
});
|
||
}
|
||
|
||
private handleLeaveRoom(socket: TypedSocket) {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) return;
|
||
|
||
const { roomId, userId, isOwner } = roomInfo;
|
||
|
||
// 从房间成员中移除
|
||
const roomMembers = this.members.get(roomId);
|
||
if (roomMembers) {
|
||
roomMembers.delete(userId);
|
||
|
||
const room = this.rooms.get(roomId);
|
||
if (room) {
|
||
room.memberCount = roomMembers.size;
|
||
this.rooms.set(roomId, room);
|
||
}
|
||
|
||
// 通知其他成员
|
||
socket.to(roomId).emit('room:member-left', userId);
|
||
|
||
// 如果是房主离开,记录时间但不立即删除房间
|
||
if (isOwner) {
|
||
console.log(`[WatchRoom] Owner left room ${roomId}, will auto-delete after 5 minutes`);
|
||
}
|
||
|
||
// 如果房间没人了,立即删除
|
||
if (roomMembers.size === 0) {
|
||
this.deleteRoom(roomId);
|
||
}
|
||
}
|
||
|
||
socket.leave(roomId);
|
||
this.socketToRoom.delete(socket.id);
|
||
}
|
||
|
||
private deleteRoom(roomId: string) {
|
||
console.log(`[WatchRoom] Deleting room ${roomId}`);
|
||
this.io.to(roomId).emit('room:deleted');
|
||
this.rooms.delete(roomId);
|
||
this.members.delete(roomId);
|
||
}
|
||
|
||
// 定时清理房间(房主断开5分钟后删除)
|
||
private startCleanupTimer() {
|
||
this.cleanupInterval = setInterval(() => {
|
||
const now = Date.now();
|
||
const deleteTimeout = 5 * 60 * 1000; // 5分钟 - 删除房间
|
||
const clearStateTimeout = 30 * 1000; // 30秒 - 清除播放状态
|
||
|
||
this.rooms.forEach((room, roomId) => {
|
||
const timeSinceHeartbeat = now - room.lastOwnerHeartbeat;
|
||
|
||
// 如果房主心跳超过30秒,清除播放状态
|
||
if (timeSinceHeartbeat > clearStateTimeout && room.currentState !== null) {
|
||
console.log(`[WatchRoom] Room ${roomId} owner inactive for 30s, clearing play state`);
|
||
room.currentState = null;
|
||
this.rooms.set(roomId, room);
|
||
// 通知房间内所有成员状态已清除
|
||
this.io.to(roomId).emit('state:cleared');
|
||
}
|
||
|
||
// 检查房主是否超时5分钟 - 删除房间
|
||
if (timeSinceHeartbeat > deleteTimeout) {
|
||
console.log(`[WatchRoom] Room ${roomId} owner timeout, deleting...`);
|
||
this.deleteRoom(roomId);
|
||
}
|
||
});
|
||
}, 10000); // 每10秒检查一次,确保更及时的清理
|
||
}
|
||
|
||
private generateRoomId(): string {
|
||
return Math.random().toString(36).substring(2, 8).toUpperCase();
|
||
}
|
||
|
||
private generateMessageId(): string {
|
||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||
}
|
||
|
||
public destroy() {
|
||
if (this.cleanupInterval) {
|
||
clearInterval(this.cleanupInterval);
|
||
}
|
||
}
|
||
}
|