555 lines
18 KiB
JavaScript
555 lines
18 KiB
JavaScript
// Next.js 自定义服务器 + Socket.IO
|
||
const { createServer } = require('http');
|
||
const { parse } = require('url');
|
||
const next = require('next');
|
||
const { Server } = require('socket.io');
|
||
|
||
const dev = process.env.NODE_ENV !== 'production';
|
||
const hostname = process.env.HOSTNAME || '0.0.0.0';
|
||
const port = parseInt(process.env.PORT || '3000', 10);
|
||
|
||
const app = next({ dev, hostname, port });
|
||
const handle = app.getRequestHandler();
|
||
|
||
// 读取观影室配置的辅助函数
|
||
async function getWatchRoomConfig() {
|
||
// 观影室配置现在统一从环境变量读取
|
||
const config = {
|
||
enabled: process.env.WATCH_ROOM_ENABLED === 'true',
|
||
serverType: (process.env.WATCH_ROOM_SERVER_TYPE || 'internal'),
|
||
externalServerUrl: process.env.WATCH_ROOM_EXTERNAL_SERVER_URL,
|
||
externalServerAuth: process.env.WATCH_ROOM_EXTERNAL_SERVER_AUTH,
|
||
};
|
||
|
||
console.log(`[WatchRoom] Watch room ${config.enabled ? 'enabled' : 'disabled'} via environment variable.`);
|
||
return config;
|
||
}
|
||
|
||
// 观影室服务器类
|
||
class WatchRoomServer {
|
||
constructor(io) {
|
||
this.io = io;
|
||
this.rooms = new Map();
|
||
this.members = new Map();
|
||
this.socketToRoom = new Map();
|
||
this.roomDeletionTimers = new Map(); // 房间延迟删除定时器
|
||
this.cleanupInterval = null;
|
||
this.setupEventHandlers();
|
||
this.startCleanupTimer();
|
||
}
|
||
|
||
setupEventHandlers() {
|
||
this.io.on('connection', (socket) => {
|
||
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 = {
|
||
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 = {
|
||
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;
|
||
let isOwner = false;
|
||
|
||
// 检查是否是房主重连(通过 ownerToken 验证)
|
||
if (data.ownerToken && data.ownerToken === room.ownerToken) {
|
||
isOwner = true;
|
||
// 更新房主的 socket.id
|
||
room.ownerId = userId;
|
||
room.lastOwnerHeartbeat = Date.now();
|
||
this.rooms.set(data.roomId, room);
|
||
console.log(`[WatchRoom] Owner ${data.userName} reconnected to room ${data.roomId}`);
|
||
}
|
||
|
||
// 取消房间的删除定时器(如果有人重连)
|
||
if (this.roomDeletionTimers.has(data.roomId)) {
|
||
console.log(`[WatchRoom] Cancelling deletion timer for room ${data.roomId}`);
|
||
clearTimeout(this.roomDeletionTimers.get(data.roomId));
|
||
this.roomDeletionTimers.delete(data.roomId);
|
||
}
|
||
|
||
const member = {
|
||
id: userId,
|
||
name: data.userName,
|
||
isOwner: isOwner,
|
||
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: isOwner,
|
||
});
|
||
|
||
socket.join(data.roomId);
|
||
socket.to(data.roomId).emit('room:member-joined', member);
|
||
|
||
console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}${isOwner ? ' (as owner)' : ''}`);
|
||
|
||
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) => {
|
||
console.log(`[WatchRoom] Received play:update from ${socket.id}:`, state);
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) {
|
||
console.log('[WatchRoom] No room info for socket, ignoring play:update');
|
||
return;
|
||
}
|
||
|
||
const room = this.rooms.get(roomInfo.roomId);
|
||
if (room) {
|
||
room.currentState = state;
|
||
this.rooms.set(roomInfo.roomId, room);
|
||
console.log(`[WatchRoom] Broadcasting play:update to room ${roomInfo.roomId} from ${roomInfo.userName}`);
|
||
socket.to(roomInfo.roomId).emit('play:update', state);
|
||
} else {
|
||
console.log('[WatchRoom] Room not found for play:update');
|
||
}
|
||
});
|
||
|
||
// 播放进度跳转
|
||
socket.on('play:seek', (currentTime) => {
|
||
console.log(`[WatchRoom] Received play:seek from ${socket.id}:`, currentTime);
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) {
|
||
console.log('[WatchRoom] No room info for socket, ignoring play:seek');
|
||
return;
|
||
}
|
||
console.log(`[WatchRoom] Broadcasting play:seek to room ${roomInfo.roomId}`);
|
||
socket.to(roomInfo.roomId).emit('play:seek', currentTime);
|
||
});
|
||
|
||
// 播放
|
||
socket.on('play:play', () => {
|
||
console.log(`[WatchRoom] Received play:play from ${socket.id}`);
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) {
|
||
console.log('[WatchRoom] No room info for socket, ignoring play:play');
|
||
return;
|
||
}
|
||
console.log(`[WatchRoom] Broadcasting play:play to room ${roomInfo.roomId}`);
|
||
socket.to(roomInfo.roomId).emit('play:play');
|
||
});
|
||
|
||
// 暂停
|
||
socket.on('play:pause', () => {
|
||
console.log(`[WatchRoom] Received play:pause from ${socket.id}`);
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) {
|
||
console.log('[WatchRoom] No room info for socket, ignoring play:pause');
|
||
return;
|
||
}
|
||
console.log(`[WatchRoom] Broadcasting play:pause to room ${roomInfo.roomId}`);
|
||
socket.to(roomInfo.roomId).emit('play:pause');
|
||
});
|
||
|
||
// 切换视频/集数
|
||
socket.on('play:change', (state) => {
|
||
console.log(`[WatchRoom] Received play:change from ${socket.id}:`, state);
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) {
|
||
console.log('[WatchRoom] No room info for socket, ignoring play:change');
|
||
return;
|
||
}
|
||
if (!roomInfo.isOwner) {
|
||
console.log('[WatchRoom] User is not owner, ignoring play:change');
|
||
return;
|
||
}
|
||
|
||
const room = this.rooms.get(roomInfo.roomId);
|
||
if (room) {
|
||
room.currentState = state;
|
||
this.rooms.set(roomInfo.roomId, room);
|
||
console.log(`[WatchRoom] Broadcasting play:change to room ${roomInfo.roomId}`);
|
||
socket.to(roomInfo.roomId).emit('play:change', state);
|
||
} else {
|
||
console.log('[WatchRoom] Room not found for play:change');
|
||
}
|
||
});
|
||
|
||
// 切换直播频道
|
||
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 = {
|
||
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('voice:audio-chunk', (data) => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) return;
|
||
|
||
// 将音频数据转发给房间内的其他成员
|
||
socket.to(roomInfo.roomId).emit('voice:audio-chunk', {
|
||
userId: socket.id,
|
||
audioData: data.audioData,
|
||
sampleRate: data.sampleRate || 16000,
|
||
});
|
||
});
|
||
|
||
// 心跳
|
||
socket.on('heartbeat', () => {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
|
||
// 如果用户在房间中,更新心跳时间
|
||
if (roomInfo) {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 无论是否在房间中,都响应心跳包(pong)
|
||
socket.emit('heartbeat:pong', { timestamp: Date.now() });
|
||
});
|
||
|
||
// 断开连接
|
||
socket.on('disconnect', () => {
|
||
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
|
||
this.handleLeaveRoom(socket);
|
||
});
|
||
});
|
||
}
|
||
|
||
handleLeaveRoom(socket) {
|
||
const roomInfo = this.socketToRoom.get(socket.id);
|
||
if (!roomInfo) return;
|
||
|
||
const { roomId, userId, isOwner } = roomInfo;
|
||
const room = this.rooms.get(roomId);
|
||
const roomMembers = this.members.get(roomId);
|
||
|
||
if (roomMembers) {
|
||
roomMembers.delete(userId);
|
||
|
||
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 actively left room ${roomId}, disbanding room`);
|
||
|
||
// 通知所有成员房间被解散
|
||
socket.to(roomId).emit('room:deleted', { reason: 'owner_left' });
|
||
|
||
// 强制所有成员离开房间
|
||
const members = Array.from(roomMembers.keys());
|
||
members.forEach(memberId => {
|
||
this.socketToRoom.delete(memberId);
|
||
});
|
||
|
||
// 立即删除房间(跳过通知,因为上面已经发送了)
|
||
this.deleteRoom(roomId, true);
|
||
|
||
// 清除可能存在的删除定时器
|
||
if (this.roomDeletionTimers.has(roomId)) {
|
||
clearTimeout(this.roomDeletionTimers.get(roomId));
|
||
this.roomDeletionTimers.delete(roomId);
|
||
}
|
||
} else {
|
||
// 普通成员离开,房间为空时延迟删除
|
||
if (roomMembers.size === 0) {
|
||
console.log(`[WatchRoom] Room ${roomId} is now empty, will delete in 30 seconds if no one rejoins`);
|
||
|
||
const deletionTimer = setTimeout(() => {
|
||
// 再次检查房间是否仍然为空
|
||
const currentRoomMembers = this.members.get(roomId);
|
||
if (currentRoomMembers && currentRoomMembers.size === 0) {
|
||
console.log(`[WatchRoom] Room ${roomId} deletion timer expired, deleting room`);
|
||
this.deleteRoom(roomId);
|
||
this.roomDeletionTimers.delete(roomId);
|
||
}
|
||
}, 30000); // 30秒后删除
|
||
|
||
this.roomDeletionTimers.set(roomId, deletionTimer);
|
||
}
|
||
}
|
||
}
|
||
|
||
socket.leave(roomId);
|
||
this.socketToRoom.delete(socket.id);
|
||
}
|
||
|
||
deleteRoom(roomId, skipNotify = false) {
|
||
console.log(`[WatchRoom] Deleting room ${roomId}`);
|
||
|
||
// 如果不跳过通知,则发送 room:deleted 事件
|
||
if (!skipNotify) {
|
||
this.io.to(roomId).emit('room:deleted');
|
||
}
|
||
|
||
this.rooms.delete(roomId);
|
||
this.members.delete(roomId);
|
||
}
|
||
|
||
startCleanupTimer() {
|
||
this.cleanupInterval = setInterval(() => {
|
||
const now = Date.now();
|
||
const deleteTimeout = 5 * 60 * 1000; // 5分钟 - 删除房间
|
||
const clearStateTimeout = 30 * 1000; // 30秒 - 清除播放状态
|
||
|
||
for (const [roomId, room] of this.rooms.entries()) {
|
||
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秒检查一次,确保更及时的清理
|
||
}
|
||
|
||
generateRoomId() {
|
||
return Math.random().toString(36).substring(2, 8).toUpperCase();
|
||
}
|
||
|
||
generateMessageId() {
|
||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||
}
|
||
|
||
destroy() {
|
||
if (this.cleanupInterval) {
|
||
clearInterval(this.cleanupInterval);
|
||
}
|
||
|
||
// 清理所有房间删除定时器
|
||
for (const timer of this.roomDeletionTimers.values()) {
|
||
clearTimeout(timer);
|
||
}
|
||
this.roomDeletionTimers.clear();
|
||
}
|
||
}
|
||
|
||
app.prepare().then(async () => {
|
||
const httpServer = createServer(async (req, res) => {
|
||
try {
|
||
const parsedUrl = parse(req.url, true);
|
||
await handle(req, res, parsedUrl);
|
||
} catch (err) {
|
||
console.error('Error occurred handling', req.url, err);
|
||
res.statusCode = 500;
|
||
res.end('Internal server error');
|
||
}
|
||
});
|
||
|
||
// 读取观影室配置
|
||
const watchRoomConfig = await getWatchRoomConfig();
|
||
console.log('[WatchRoom] Config:', watchRoomConfig);
|
||
|
||
let watchRoomServer = null;
|
||
|
||
// 只在启用观影室且使用内部服务器时初始化 Socket.IO
|
||
if (watchRoomConfig.enabled && watchRoomConfig.serverType === 'internal') {
|
||
console.log('[WatchRoom] Initializing Socket.IO server...');
|
||
|
||
// 初始化 Socket.IO
|
||
const io = new Server(httpServer, {
|
||
path: '/socket.io',
|
||
cors: {
|
||
origin: '*',
|
||
methods: ['GET', 'POST'],
|
||
},
|
||
});
|
||
|
||
// 初始化观影室服务器
|
||
watchRoomServer = new WatchRoomServer(io);
|
||
console.log('[WatchRoom] Socket.IO server initialized');
|
||
} else {
|
||
if (!watchRoomConfig.enabled) {
|
||
console.log('[WatchRoom] Watch room is disabled');
|
||
} else if (watchRoomConfig.serverType === 'external') {
|
||
console.log('[WatchRoom] Using external watch room server');
|
||
}
|
||
}
|
||
|
||
httpServer
|
||
.once('error', (err) => {
|
||
console.error(err);
|
||
process.exit(1);
|
||
})
|
||
.listen(port, () => {
|
||
console.log(`> Ready on http://${hostname}:${port}`);
|
||
if (watchRoomConfig.enabled && watchRoomConfig.serverType === 'internal') {
|
||
console.log(`> Socket.IO ready on ws://${hostname}:${port}`);
|
||
}
|
||
});
|
||
|
||
// 优雅关闭
|
||
process.on('SIGINT', () => {
|
||
console.log('\n[Server] Shutting down...');
|
||
if (watchRoomServer) {
|
||
watchRoomServer.destroy();
|
||
}
|
||
httpServer.close(() => {
|
||
console.log('[Server] Server closed');
|
||
process.exit(0);
|
||
});
|
||
});
|
||
|
||
process.on('SIGTERM', () => {
|
||
console.log('\n[Server] Shutting down...');
|
||
if (watchRoomServer) {
|
||
watchRoomServer.destroy();
|
||
}
|
||
httpServer.close(() => {
|
||
console.log('[Server] Server closed');
|
||
process.exit(0);
|
||
});
|
||
});
|
||
});
|