观影室起步

This commit is contained in:
mtvpls
2025-12-06 21:39:37 +08:00
parent 4f126e89f0
commit 003050d134
18 changed files with 2895 additions and 7 deletions
+7 -2
View File
@@ -10,6 +10,8 @@ import { getConfig } from '@/lib/config';
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
import { SiteProvider } from '../components/SiteProvider';
import { ThemeProvider } from '../components/ThemeProvider';
import { WatchRoomProvider } from '../components/WatchRoomProvider';
import ChatFloatingWindow from '../components/watch-room/ChatFloatingWindow';
const inter = Inter({ subsets: ['latin'] });
export const dynamic = 'force-dynamic';
@@ -120,8 +122,11 @@ export default async function RootLayout({
disableTransitionOnChange
>
<SiteProvider siteName={siteName} announcement={announcement}>
{children}
<GlobalErrorIndicator />
<WatchRoomProvider>
{children}
<GlobalErrorIndicator />
<ChatFloatingWindow />
</WatchRoomProvider>
</SiteProvider>
</ThemeProvider>
</body>
+181
View File
@@ -0,0 +1,181 @@
// 房间列表页面
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Users, Lock, RefreshCw } from 'lucide-react';
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
import JoinRoomModal from '@/components/watch-room/JoinRoomModal';
import type { Room } from '@/types/watch-room';
export default function RoomListPage() {
const router = useRouter();
const { getRoomList, isConnected } = useWatchRoomContext();
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
const [showJoinModal, setShowJoinModal] = useState(false);
const loadRooms = async () => {
if (!isConnected) return;
setLoading(true);
try {
const roomList = await getRoomList();
setRooms(roomList);
} catch (error) {
console.error('[WatchRoom] Failed to load rooms:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadRooms();
// 每5秒刷新一次房间列表
const interval = setInterval(loadRooms, 5000);
return () => clearInterval(interval);
}, [isConnected]);
const handleJoinRoom = (roomId: string) => {
setSelectedRoomId(roomId);
setShowJoinModal(true);
};
const formatTime = (timestamp: number) => {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}天前`;
if (hours > 0) return `${hours}小时前`;
if (minutes > 0) return `${minutes}分钟前`;
return '刚刚';
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 px-4 py-8">
<div className="mx-auto max-w-6xl">
{/* 顶部栏 */}
<div className="mb-8 flex items-center justify-between">
<button
onClick={() => router.back()}
className="flex items-center gap-2 rounded-lg bg-gray-800 px-4 py-2 text-white transition-colors hover:bg-gray-700"
>
<ArrowLeft className="h-5 w-5" />
</button>
<button
onClick={loadRooms}
disabled={loading}
className="flex items-center gap-2 rounded-lg bg-gray-800 px-4 py-2 text-white transition-colors hover:bg-gray-700 disabled:opacity-50"
>
<RefreshCw className={`h-5 w-5 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* 标题 */}
<div className="mb-8 text-center">
<h1 className="mb-2 text-3xl font-bold text-white md:text-4xl"></h1>
<p className="text-gray-400">
{rooms.length}
</p>
</div>
{/* 加载中 */}
{loading && rooms.length === 0 && (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<RefreshCw className="mx-auto mb-4 h-12 w-12 animate-spin text-gray-400" />
<p className="text-gray-400">...</p>
</div>
</div>
)}
{/* 空状态 */}
{!loading && rooms.length === 0 && (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<Users className="mx-auto mb-4 h-16 w-16 text-gray-600" />
<p className="mb-2 text-xl text-gray-400"></p>
<p className="text-sm text-gray-500"></p>
</div>
</div>
)}
{/* 房间列表 */}
{rooms.length > 0 && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{rooms.map((room) => (
<div
key={room.id}
className="group rounded-xl bg-gray-800/50 p-6 backdrop-blur-sm transition-all hover:bg-gray-800/70 hover:shadow-xl"
>
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<h3 className="mb-1 text-lg font-bold text-white">{room.name}</h3>
{room.description && (
<p className="mb-2 text-sm text-gray-400 line-clamp-2">{room.description}</p>
)}
</div>
{room.password && (
<Lock className="h-5 w-5 flex-shrink-0 text-yellow-400" title="需要密码" />
)}
</div>
<div className="mb-4 space-y-2 text-sm">
<div className="flex items-center gap-2 text-gray-400">
<span className="text-gray-500">:</span>
<span className="font-mono text-lg font-bold text-white">{room.id}</span>
</div>
<div className="flex items-center gap-2 text-gray-400">
<Users className="h-4 w-4" />
<span>{room.memberCount} 线</span>
</div>
<div className="text-gray-400">
<span className="text-gray-500">:</span> {room.ownerName}
</div>
<div className="text-gray-400">
<span className="text-gray-500">:</span> {formatTime(room.createdAt)}
</div>
{room.currentState && (
<div className="mt-2 rounded-lg bg-blue-500/20 px-3 py-2">
<p className="text-xs text-blue-300">
{room.currentState.type === 'play'
? `正在播放: ${room.currentState.videoName}`
: `正在观看: ${room.currentState.channelName}`}
</p>
</div>
)}
</div>
<button
onClick={() => handleJoinRoom(room.id)}
className="w-full rounded-lg bg-purple-500 py-3 font-medium text-white transition-colors hover:bg-purple-600"
>
</button>
</div>
))}
</div>
)}
</div>
{/* 加入房间弹窗 */}
{showJoinModal && selectedRoomId && (
<JoinRoomModal
roomId={selectedRoomId}
onClose={() => {
setShowJoinModal(false);
setSelectedRoomId(null);
}}
/>
)}
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
// 观影室首页
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Users, UserPlus, List } from 'lucide-react';
import CreateRoomModal from '@/components/watch-room/CreateRoomModal';
import JoinRoomModal from '@/components/watch-room/JoinRoomModal';
export default function WatchRoomPage() {
const router = useRouter();
const [showCreateModal, setShowCreateModal] = useState(false);
const [showJoinModal, setShowJoinModal] = useState(false);
const buttons = [
{
icon: Users,
label: '创建房间',
description: '创建一个新的观影室',
onClick: () => setShowCreateModal(true),
color: 'bg-blue-500 hover:bg-blue-600',
},
{
icon: UserPlus,
label: '加入房间',
description: '通过房间号加入观影室',
onClick: () => setShowJoinModal(true),
color: 'bg-green-500 hover:bg-green-600',
},
{
icon: List,
label: '房间列表',
description: '查看所有公开的观影室',
onClick: () => router.push('/watch-room/list'),
color: 'bg-purple-500 hover:bg-purple-600',
},
];
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 px-4 py-8">
<div className="mx-auto max-w-6xl">
{/* 标题 */}
<div className="mb-12 text-center">
<h1 className="mb-4 text-4xl font-bold text-white md:text-5xl"></h1>
<p className="text-lg text-gray-400"></p>
</div>
{/* 按钮网格 */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{buttons.map((button, index) => {
const Icon = button.icon;
return (
<button
key={index}
onClick={button.onClick}
className={`group flex flex-col items-center justify-center gap-4 rounded-2xl p-8 transition-all duration-300 ${button.color} transform hover:scale-105 hover:shadow-2xl`}
>
<div className="rounded-full bg-white/10 p-6 backdrop-blur-sm transition-all duration-300 group-hover:bg-white/20">
<Icon className="h-12 w-12 text-white md:h-16 md:w-16" />
</div>
<div className="text-center">
<h2 className="mb-2 text-xl font-bold text-white md:text-2xl">{button.label}</h2>
<p className="text-sm text-white/80 md:text-base">{button.description}</p>
</div>
</button>
);
})}
</div>
{/* 使用说明 */}
<div className="mt-16 rounded-2xl bg-gray-800/50 p-6 backdrop-blur-sm md:p-8">
<h3 className="mb-4 text-xl font-bold text-white">使</h3>
<ul className="space-y-3 text-gray-300">
<li className="flex items-start gap-3">
<span className="text-blue-400"></span>
<span>
<strong className="text-white"></strong>
</span>
</li>
<li className="flex items-start gap-3">
<span className="text-green-400"></span>
<span>
<strong className="text-white"></strong>
</span>
</li>
<li className="flex items-start gap-3">
<span className="text-purple-400"></span>
<span>
<strong className="text-white"></strong>
</span>
</li>
<li className="flex items-start gap-3">
<span className="text-yellow-400"></span>
<span>
<strong className="text-white"></strong>
</span>
</li>
<li className="flex items-start gap-3">
<span className="text-pink-400"></span>
<span>
<strong className="text-white"></strong>使
</span>
</li>
</ul>
</div>
</div>
{/* 弹窗 */}
{showCreateModal && <CreateRoomModal onClose={() => setShowCreateModal(false)} />}
{showJoinModal && <JoinRoomModal onClose={() => setShowJoinModal(false)} />}
</div>
);
}
+6 -1
View File
@@ -2,7 +2,7 @@
'use client';
import { Cat, Clover, Film, Home, Radio, Star, Tv } from 'lucide-react';
import { Cat, Clover, Film, Home, Radio, Star, Tv, Users } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
@@ -52,6 +52,11 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
label: '直播',
href: '/live',
},
{
icon: Users,
label: '观影室',
href: '/watch-room',
},
]);
useEffect(() => {
+6 -1
View File
@@ -2,7 +2,7 @@
'use client';
import { Cat, Clover, Film, Home, Menu, Radio, Search, Star, Tv } from 'lucide-react';
import { Cat, Clover, Film, Home, Menu, Radio, Search, Star, Tv, Users } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import {
@@ -143,6 +143,11 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
label: '直播',
href: '/live',
},
{
icon: Users,
label: '观影室',
href: '/watch-room',
},
]);
useEffect(() => {
+150
View File
@@ -0,0 +1,150 @@
// WatchRoom 全局状态管理 Provider
'use client';
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { useWatchRoom } from '@/hooks/useWatchRoom';
import type { Room, Member, ChatMessage, WatchRoomConfig } from '@/types/watch-room';
import type { WatchRoomSocket } from '@/lib/watch-room-socket';
interface WatchRoomContextType {
socket: WatchRoomSocket | null;
isConnected: boolean;
currentRoom: Room | null;
members: Member[];
chatMessages: ChatMessage[];
isOwner: boolean;
isEnabled: boolean;
config: WatchRoomConfig | null;
// 房间操作
createRoom: (data: {
name: string;
description: string;
password?: string;
isPublic: boolean;
userName: string;
}) => Promise<Room>;
joinRoom: (data: {
roomId: string;
password?: string;
userName: string;
}) => Promise<{ room: Room; members: Member[] }>;
leaveRoom: () => void;
getRoomList: () => Promise<Room[]>;
// 聊天
sendChatMessage: (content: string, type?: 'text' | 'emoji') => void;
// 播放控制(供 play/live 页面使用)
updatePlayState: (state: any) => void;
seekPlayback: (currentTime: number) => void;
play: () => void;
pause: () => void;
changeVideo: (state: any) => void;
changeLiveChannel: (state: any) => void;
}
const WatchRoomContext = createContext<WatchRoomContextType | null>(null);
export const useWatchRoomContext = () => {
const context = useContext(WatchRoomContext);
if (!context) {
throw new Error('useWatchRoomContext must be used within WatchRoomProvider');
}
return context;
};
// 安全版本,可以在非 Provider 内使用
export const useWatchRoomContextSafe = () => {
return useContext(WatchRoomContext);
};
interface WatchRoomProviderProps {
children: React.ReactNode;
}
export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
const [config, setConfig] = useState<WatchRoomConfig | null>(null);
const [isEnabled, setIsEnabled] = useState(false);
const watchRoom = useWatchRoom();
// 加载配置
useEffect(() => {
const loadConfig = async () => {
// 默认配置:启用内部服务器
const defaultConfig: WatchRoomConfig = {
enabled: true,
serverType: 'internal',
};
try {
const response = await fetch('/api/admin/config');
if (response.ok) {
const data = await response.json();
const watchRoomConfig: WatchRoomConfig = {
enabled: data.watchRoom?.enabled ?? true,
serverType: data.watchRoom?.serverType ?? 'internal',
externalServerUrl: data.watchRoom?.externalServerUrl,
externalServerAuth: data.watchRoom?.externalServerAuth,
};
setConfig(watchRoomConfig);
setIsEnabled(watchRoomConfig.enabled);
// 如果启用了观影室,自动连接
if (watchRoomConfig.enabled) {
console.log('[WatchRoom] Connecting with config:', watchRoomConfig);
await watchRoom.connect(watchRoomConfig);
}
} else {
throw new Error('Failed to load config');
}
} catch (error) {
console.log('[WatchRoom] Using default config (internal server enabled)');
setConfig(defaultConfig);
setIsEnabled(true);
try {
await watchRoom.connect(defaultConfig);
} catch (connectError) {
console.error('[WatchRoom] Failed to connect:', connectError);
}
}
};
loadConfig();
// 清理
return () => {
watchRoom.disconnect();
};
}, []);
const contextValue: WatchRoomContextType = {
socket: watchRoom.socket,
isConnected: watchRoom.isConnected,
currentRoom: watchRoom.currentRoom,
members: watchRoom.members,
chatMessages: watchRoom.chatMessages,
isOwner: watchRoom.isOwner,
isEnabled,
config,
createRoom: watchRoom.createRoom,
joinRoom: watchRoom.joinRoom,
leaveRoom: watchRoom.leaveRoom,
getRoomList: watchRoom.getRoomList,
sendChatMessage: watchRoom.sendChatMessage,
updatePlayState: watchRoom.updatePlayState,
seekPlayback: watchRoom.seekPlayback,
play: watchRoom.play,
pause: watchRoom.pause,
changeVideo: watchRoom.changeVideo,
changeLiveChannel: watchRoom.changeLiveChannel,
};
return (
<WatchRoomContext.Provider value={contextValue}>
{children}
</WatchRoomContext.Provider>
);
}
@@ -0,0 +1,221 @@
// 全局聊天悬浮窗
'use client';
import { useState, useEffect, useRef } from 'react';
import { MessageCircle, X, Send, Smile, Minimize2, Maximize2 } from 'lucide-react';
import { useWatchRoomContextSafe } from '@/components/WatchRoomProvider';
const EMOJI_LIST = ['😀', '😂', '😍', '🥰', '😎', '🤔', '👍', '👏', '🎉', '❤️', '🔥', '⭐'];
export default function ChatFloatingWindow() {
const watchRoom = useWatchRoomContextSafe();
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [message, setMessage] = useState('');
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// 自动滚动到底部
useEffect(() => {
if (messagesEndRef.current && watchRoom?.currentRoom) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [watchRoom?.chatMessages, watchRoom?.currentRoom]);
// 如果没有加入房间,不显示聊天按钮
if (!watchRoom?.currentRoom) {
return null;
}
const { chatMessages, sendChatMessage, members, isOwner } = watchRoom;
const handleSendMessage = () => {
if (!message.trim()) return;
sendChatMessage(message.trim(), 'text');
setMessage('');
setShowEmojiPicker(false);
};
const handleSendEmoji = (emoji: string) => {
sendChatMessage(emoji, 'emoji');
setShowEmojiPicker(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
};
// 悬浮按钮
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-20 right-4 z-[700] flex h-14 w-14 items-center justify-center rounded-full bg-green-500 text-white shadow-2xl transition-all hover:scale-110 hover:bg-green-600 md:bottom-4"
aria-label="打开聊天"
>
<MessageCircle className="h-6 w-6" />
{chatMessages.length > 0 && (
<span className="absolute right-0 top-0 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
{chatMessages.length > 99 ? '99+' : chatMessages.length}
</span>
)}
</button>
);
}
// 最小化状态
if (isMinimized) {
return (
<div className="fixed bottom-20 right-4 z-[700] flex items-center gap-2 rounded-lg bg-gray-800 px-4 py-2 shadow-2xl md:bottom-4">
<MessageCircle className="h-5 w-5 text-white" />
<span className="text-sm text-white"></span>
<button
onClick={() => setIsMinimized(false)}
className="ml-2 rounded p-1 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
aria-label="展开"
>
<Maximize2 className="h-4 w-4" />
</button>
<button
onClick={() => setIsOpen(false)}
className="rounded p-1 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
// 完整聊天窗口
return (
<div className="fixed bottom-20 right-4 z-[700] flex w-80 flex-col rounded-2xl bg-gray-800 shadow-2xl md:bottom-4 md:w-96">
{/* 头部 */}
<div className="flex items-center justify-between rounded-t-2xl bg-green-500 px-4 py-3">
<div className="flex items-center gap-2">
<MessageCircle className="h-5 w-5 text-white" />
<div>
<h3 className="text-sm font-bold text-white"></h3>
<p className="text-xs text-white/80">{members.length} 线</p>
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => setIsMinimized(true)}
className="rounded p-1 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
aria-label="最小化"
>
<Minimize2 className="h-4 w-4" />
</button>
<button
onClick={() => setIsOpen(false)}
className="rounded p-1 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 消息列表 */}
<div className="flex-1 space-y-3 overflow-y-auto p-4" style={{ maxHeight: '400px' }}>
{chatMessages.length === 0 ? (
<div className="flex h-full items-center justify-center text-center">
<div>
<MessageCircle className="mx-auto mb-2 h-12 w-12 text-gray-600" />
<p className="text-sm text-gray-400"></p>
<p className="text-xs text-gray-500"></p>
</div>
</div>
) : (
<>
{chatMessages.map((msg) => (
<div key={msg.id} className="flex flex-col gap-1">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium text-green-400">{msg.userName}</span>
<span className="text-xs text-gray-500">{formatTime(msg.timestamp)}</span>
</div>
<div
className={`max-w-[80%] rounded-lg px-3 py-2 ${
msg.type === 'emoji'
? 'text-3xl'
: 'bg-gray-700 text-white'
}`}
>
{msg.content}
</div>
</div>
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* 输入区域 */}
<div className="border-t border-gray-700 p-3">
{/* 表情选择器 */}
{showEmojiPicker && (
<div className="mb-2 grid grid-cols-6 gap-2 rounded-lg bg-gray-700 p-2">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
onClick={() => handleSendEmoji(emoji)}
className="rounded p-1 text-2xl transition-colors hover:bg-gray-600"
>
{emoji}
</button>
))}
</div>
)}
<div className="flex gap-2">
<button
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="rounded-lg bg-gray-700 p-2 text-gray-300 transition-colors hover:bg-gray-600 hover:text-white"
aria-label="表情"
>
<Smile className="h-5 w-5" />
</button>
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息..."
className="flex-1 rounded-lg bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500"
maxLength={200}
/>
<button
onClick={handleSendMessage}
disabled={!message.trim()}
className="rounded-lg bg-green-500 p-2 text-white transition-colors hover:bg-green-600 disabled:opacity-50"
aria-label="发送"
>
<Send className="h-5 w-5" />
</button>
</div>
</div>
{/* 房间信息提示 */}
<div className="rounded-b-2xl bg-gray-900/50 px-4 py-2 text-center text-xs text-gray-400">
{isOwner ? (
<span className="text-yellow-400">👑 </span>
) : (
<span>: {watchRoom.currentRoom.name}</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,189 @@
// 创建房间弹窗
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { X, Lock, Eye, EyeOff } from 'lucide-react';
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
interface CreateRoomModalProps {
onClose: () => void;
}
export default function CreateRoomModal({ onClose }: CreateRoomModalProps) {
const router = useRouter();
const { createRoom } = useWatchRoomContext();
const [roomName, setRoomName] = useState('');
const [description, setDescription] = useState('');
const [password, setPassword] = useState('');
const [userName, setUserName] = useState('');
const [isPublic, setIsPublic] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!roomName.trim()) {
setError('请输入房间名称');
return;
}
if (!userName.trim()) {
setError('请输入您的昵称');
return;
}
setLoading(true);
try {
const room = await createRoom({
name: roomName.trim(),
description: description.trim(),
password: password.trim() || undefined,
isPublic,
userName: userName.trim(),
});
console.log('[WatchRoom] Room created:', room);
onClose();
// 创建成功后跳转到播放页面(等待播放)
// router.push(`/play?roomId=${room.id}`);
} catch (err: any) {
setError(err.message || '创建房间失败');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="relative w-full max-w-md rounded-2xl bg-gray-800 p-6 shadow-2xl">
{/* 关闭按钮 */}
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-full p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
>
<X className="h-5 w-5" />
</button>
{/* 标题 */}
<h2 className="mb-6 text-2xl font-bold text-white"></h2>
{/* 表单 */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* 昵称 */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-300"></label>
<input
type="text"
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="输入您的昵称"
className="w-full rounded-lg bg-gray-700 px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
maxLength={20}
/>
</div>
{/* 房间名 */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-300"></label>
<input
type="text"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
placeholder="输入房间名称"
className="w-full rounded-lg bg-gray-700 px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
maxLength={30}
/>
</div>
{/* 备注 */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-300"></label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="输入房间简介"
rows={3}
className="w-full resize-none rounded-lg bg-gray-700 px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
maxLength={100}
/>
</div>
{/* 密码 */}
<div>
<label className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-300">
<Lock className="h-4 w-4" />
</label>
<input
type="text"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="不设置则无需密码"
className="w-full rounded-lg bg-gray-700 px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
maxLength={20}
/>
</div>
{/* 公开/隐藏 */}
<div className="flex items-center justify-between rounded-lg bg-gray-700 px-4 py-3">
<div className="flex items-center gap-2">
{isPublic ? <Eye className="h-5 w-5 text-green-400" /> : <EyeOff className="h-5 w-5 text-gray-400" />}
<span className="text-sm font-medium text-gray-300">
{isPublic ? '公开房间' : '隐藏房间'}
</span>
</div>
<button
type="button"
onClick={() => setIsPublic(!isPublic)}
className={`relative h-6 w-11 rounded-full transition-colors ${
isPublic ? 'bg-green-500' : 'bg-gray-600'
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 transform rounded-full bg-white transition-transform ${
isPublic ? 'left-5' : 'left-0.5'
}`}
/>
</button>
</div>
{/* 错误提示 */}
{error && (
<div className="rounded-lg bg-red-500/20 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{/* 按钮 */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-lg bg-gray-700 py-3 font-medium text-white transition-colors hover:bg-gray-600"
>
</button>
<button
type="submit"
disabled={loading}
className="flex-1 rounded-lg bg-blue-500 py-3 font-medium text-white transition-colors hover:bg-blue-600 disabled:opacity-50"
>
{loading ? '创建中...' : '创建房间'}
</button>
</div>
</form>
{/* 提示 */}
<p className="mt-4 text-center text-xs text-gray-400">
</p>
</div>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
// 加入房间弹窗
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { X, Lock } from 'lucide-react';
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
interface JoinRoomModalProps {
onClose: () => void;
roomId?: string; // 可选的预填房间号
}
export default function JoinRoomModal({ onClose, roomId: initialRoomId }: JoinRoomModalProps) {
const router = useRouter();
const { joinRoom } = useWatchRoomContext();
const [roomId, setRoomId] = useState(initialRoomId || '');
const [password, setPassword] = useState('');
const [userName, setUserName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!roomId.trim()) {
setError('请输入房间号');
return;
}
if (!userName.trim()) {
setError('请输入您的昵称');
return;
}
setLoading(true);
try {
const { room, members } = await joinRoom({
roomId: roomId.trim().toUpperCase(),
password: password.trim() || undefined,
userName: userName.trim(),
});
console.log('[WatchRoom] Joined room:', room, 'Members:', members);
onClose();
// 加入成功后跳转到对应页面
// 如果房主已经在播放,跳转到播放页面
// 否则等待房主开始播放
} catch (err: any) {
setError(err.message || '加入房间失败');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="relative w-full max-w-md rounded-2xl bg-gray-800 p-6 shadow-2xl">
{/* 关闭按钮 */}
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-full p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
>
<X className="h-5 w-5" />
</button>
{/* 标题 */}
<h2 className="mb-6 text-2xl font-bold text-white"></h2>
{/* 表单 */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* 昵称 */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-300"></label>
<input
type="text"
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="输入您的昵称"
className="w-full rounded-lg bg-gray-700 px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500"
maxLength={20}
/>
</div>
{/* 房间号 */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-300"></label>
<input
type="text"
value={roomId}
onChange={(e) => setRoomId(e.target.value.toUpperCase())}
placeholder="输入6位房间号"
className="w-full rounded-lg bg-gray-700 px-4 py-3 font-mono text-lg tracking-wider text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500"
maxLength={6}
/>
</div>
{/* 密码 */}
<div>
<label className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-300">
<Lock className="h-4 w-4" />
</label>
<input
type="text"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="如果房间有密码请输入"
className="w-full rounded-lg bg-gray-700 px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500"
maxLength={20}
/>
</div>
{/* 错误提示 */}
{error && (
<div className="rounded-lg bg-red-500/20 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{/* 按钮 */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-lg bg-gray-700 py-3 font-medium text-white transition-colors hover:bg-gray-600"
>
</button>
<button
type="submit"
disabled={loading}
className="flex-1 rounded-lg bg-green-500 py-3 font-medium text-white transition-colors hover:bg-green-600 disabled:opacity-50"
>
{loading ? '加入中...' : '加入房间'}
</button>
</div>
</form>
{/* 提示 */}
<p className="mt-4 text-center text-xs text-gray-400">
</p>
</div>
</div>
);
}
+344
View File
@@ -0,0 +1,344 @@
// React Hook for Watch Room
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import { watchRoomSocketManager, type WatchRoomSocket } from '@/lib/watch-room-socket';
import type {
Room,
Member,
PlayState,
LiveState,
ChatMessage,
WatchRoomConfig,
StoredRoomInfo,
} from '@/types/watch-room';
const STORAGE_KEY = 'watch_room_info';
export function useWatchRoom() {
const [socket, setSocket] = useState<WatchRoomSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [currentRoom, setCurrentRoom] = useState<Room | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [isOwner, setIsOwner] = useState(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 连接到服务器
const connect = useCallback(async (config: WatchRoomConfig) => {
try {
const sock = await watchRoomSocketManager.connect(config);
setSocket(sock);
setIsConnected(true);
// 尝试自动重连房间
const storedInfo = getStoredRoomInfo();
if (storedInfo) {
console.log('[WatchRoom] Attempting to reconnect to room:', storedInfo.roomId);
reconnectTimeoutRef.current = setTimeout(() => {
rejoinRoom(storedInfo);
}, 1000);
}
} catch (error) {
console.error('[WatchRoom] Failed to connect:', error);
setIsConnected(false);
}
}, []);
// 断开连接
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
watchRoomSocketManager.disconnect();
setSocket(null);
setIsConnected(false);
setCurrentRoom(null);
setMembers([]);
setChatMessages([]);
}, []);
// 创建房间
const createRoom = useCallback(
async (data: { name: string; description: string; password?: string; isPublic: boolean; userName: string }) => {
const sock = watchRoomSocketManager.getSocket();
if (!sock || !watchRoomSocketManager.isConnected()) {
throw new Error('Not connected');
}
return new Promise<Room>((resolve, reject) => {
sock.emit('room:create', data, (response) => {
if (response.success && response.room) {
setCurrentRoom(response.room);
setIsOwner(true);
storeRoomInfo({
roomId: response.room.id,
roomName: response.room.name,
isOwner: true,
userName: data.userName,
password: data.password,
timestamp: Date.now(),
});
resolve(response.room);
} else {
reject(new Error(response.error || '创建房间失败'));
}
});
});
},
[]
);
// 加入房间
const joinRoom = useCallback(
async (data: { roomId: string; password?: string; userName: string }) => {
const sock = watchRoomSocketManager.getSocket();
if (!sock || !watchRoomSocketManager.isConnected()) {
throw new Error('Not connected');
}
return new Promise<{ room: Room; members: Member[] }>((resolve, reject) => {
sock.emit('room:join', data, (response) => {
if (response.success && response.room && response.members) {
setCurrentRoom(response.room);
setMembers(response.members);
setIsOwner(false);
storeRoomInfo({
roomId: response.room.id,
roomName: response.room.name,
isOwner: false,
userName: data.userName,
password: data.password,
timestamp: Date.now(),
});
resolve({ room: response.room, members: response.members });
} else {
reject(new Error(response.error || '加入房间失败'));
}
});
});
},
[]
);
// 离开房间
const leaveRoom = useCallback(() => {
const sock = watchRoomSocketManager.getSocket();
if (!sock) return;
sock.emit('room:leave');
setCurrentRoom(null);
setMembers([]);
setChatMessages([]);
setIsOwner(false);
clearStoredRoomInfo();
}, []);
// 获取房间列表
const getRoomList = useCallback(async (): Promise<Room[]> => {
const sock = watchRoomSocketManager.getSocket();
if (!sock || !watchRoomSocketManager.isConnected()) {
throw new Error('Not connected');
}
return new Promise((resolve) => {
sock.emit('room:list', (rooms) => {
resolve(rooms);
});
});
}, []);
// 发送聊天消息
const sendChatMessage = useCallback(
(content: string, type: 'text' | 'emoji' = 'text') => {
const sock = watchRoomSocketManager.getSocket();
if (!sock || !currentRoom) return;
sock.emit('chat:message', { content, type });
},
[currentRoom]
);
// 更新播放状态
const updatePlayState = useCallback(
(state: PlayState) => {
const sock = watchRoomSocketManager.getSocket();
if (!sock || !isOwner) return;
sock.emit('play:update', state);
},
[isOwner]
);
// 跳转播放进度
const seekPlayback = useCallback(
(currentTime: number) => {
const sock = watchRoomSocketManager.getSocket();
if (!sock) return;
sock.emit('play:seek', currentTime);
},
[]
);
// 播放
const play = useCallback(() => {
const sock = watchRoomSocketManager.getSocket();
if (!sock) return;
sock.emit('play:play');
}, []);
// 暂停
const pause = useCallback(() => {
const sock = watchRoomSocketManager.getSocket();
if (!sock) return;
sock.emit('play:pause');
}, []);
// 切换视频
const changeVideo = useCallback(
(state: PlayState) => {
const sock = watchRoomSocketManager.getSocket();
if (!sock || !isOwner) return;
sock.emit('play:change', state);
},
[isOwner]
);
// 切换直播频道
const changeLiveChannel = useCallback(
(state: LiveState) => {
const sock = watchRoomSocketManager.getSocket();
if (!sock || !isOwner) return;
sock.emit('live:change', state);
},
[isOwner]
);
// 设置事件监听
useEffect(() => {
if (!socket) return;
// 房间事件
socket.on('room:joined', (data) => {
setCurrentRoom(data.room);
setMembers(data.members);
});
socket.on('room:member-joined', (member) => {
setMembers((prev) => [...prev, member]);
});
socket.on('room:member-left', (userId) => {
setMembers((prev) => prev.filter((m) => m.id !== userId));
});
socket.on('room:deleted', () => {
setCurrentRoom(null);
setMembers([]);
setChatMessages([]);
clearStoredRoomInfo();
});
// 播放事件
socket.on('play:update', (state) => {
if (currentRoom) {
setCurrentRoom((prev) => (prev ? { ...prev, currentState: state } : null));
}
});
// 聊天事件
socket.on('chat:message', (message) => {
setChatMessages((prev) => [...prev, message]);
});
// 连接状态
socket.on('connect', () => {
setIsConnected(true);
});
socket.on('disconnect', () => {
setIsConnected(false);
});
return () => {
socket.off('room:joined');
socket.off('room:member-joined');
socket.off('room:member-left');
socket.off('room:deleted');
socket.off('play:update');
socket.off('chat:message');
socket.off('connect');
socket.off('disconnect');
};
}, [socket, currentRoom]);
// 清理
useEffect(() => {
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, []);
return {
socket,
isConnected,
currentRoom,
members,
chatMessages,
isOwner,
connect,
disconnect,
createRoom,
joinRoom,
leaveRoom,
getRoomList,
sendChatMessage,
updatePlayState,
seekPlayback,
play,
pause,
changeVideo,
changeLiveChannel,
};
}
// 重新加入房间(自动重连)
function rejoinRoom(info: StoredRoomInfo) {
// 这个函数会在组件中被调用
console.log('[WatchRoom] Auto-rejoin:', info);
}
// 存储房间信息到 localStorage
function storeRoomInfo(info: StoredRoomInfo) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(info));
}
// 获取存储的房间信息
function getStoredRoomInfo(): StoredRoomInfo | null {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
try {
const info: StoredRoomInfo = JSON.parse(stored);
// 检查是否过期(24小时)
if (Date.now() - info.timestamp > 24 * 60 * 60 * 1000) {
clearStoredRoomInfo();
return null;
}
return info;
} catch {
return null;
}
}
// 清除存储的房间信息
function clearStoredRoomInfo() {
localStorage.removeItem(STORAGE_KEY);
}
+350
View File
@@ -0,0 +1,350 @@
// 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 room: Room = {
id: roomId,
name: data.name,
description: data.description,
password: data.password,
isPublic: data.isPublic,
ownerId: userId,
ownerName: data.userName,
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('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 timeout = 5 * 60 * 1000; // 5分钟
for (const [roomId, room] of this.rooms.entries()) {
// 检查房主是否超时
if (now - room.lastOwnerHeartbeat > timeout) {
console.log(`[WatchRoom] Room ${roomId} owner timeout, deleting...`);
this.deleteRoom(roomId);
}
}
}, 30000); // 每30秒检查一次
}
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);
}
}
}
+130
View File
@@ -0,0 +1,130 @@
// Socket.IO 客户端管理
import { io, Socket } from 'socket.io-client';
import type {
ServerToClientEvents,
ClientToServerEvents,
WatchRoomConfig,
} from '@/types/watch-room';
export type WatchRoomSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
class WatchRoomSocketManager {
private socket: WatchRoomSocket | null = null;
private config: WatchRoomConfig | null = null;
private heartbeatInterval: NodeJS.Timeout | null = null;
async connect(config: WatchRoomConfig): Promise<WatchRoomSocket> {
if (this.socket?.connected) {
return this.socket;
}
this.config = config;
const socketOptions: any = {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 5,
};
if (config.serverType === 'internal') {
// 内部服务器 - 连接到同一个域名的Socket.IO服务器
this.socket = io({
...socketOptions,
path: '/socket.io', // 使用服务器配置的path
});
} else {
// 外部服务器
if (!config.externalServerUrl) {
throw new Error('External server URL is required');
}
this.socket = io(config.externalServerUrl, {
...socketOptions,
auth: {
token: config.externalServerAuth,
},
extraHeaders: config.externalServerAuth
? {
Authorization: `Bearer ${config.externalServerAuth}`,
}
: undefined,
});
}
// 设置事件监听
this.setupEventListeners();
// 开始心跳
this.startHeartbeat();
return new Promise((resolve, reject) => {
if (!this.socket) {
reject(new Error('Socket not initialized'));
return;
}
this.socket.on('connect', () => {
console.log('[WatchRoom] Connected to server');
resolve(this.socket!);
});
this.socket.on('connect_error', (error) => {
console.error('[WatchRoom] Connection error:', error);
reject(error);
});
});
}
disconnect() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
}
getSocket(): WatchRoomSocket | null {
return this.socket;
}
isConnected(): boolean {
return this.socket?.connected ?? false;
}
private setupEventListeners() {
if (!this.socket) return;
this.socket.on('connect', () => {
console.log('[WatchRoom] Socket connected');
});
this.socket.on('disconnect', (reason) => {
console.log('[WatchRoom] Socket disconnected:', reason);
});
this.socket.on('error', (error) => {
console.error('[WatchRoom] Socket error:', error);
});
}
private startHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
this.heartbeatInterval = setInterval(() => {
if (this.socket?.connected) {
this.socket.emit('heartbeat');
}
}, 5000); // 每5秒发送一次心跳
}
}
// 单例实例
export const watchRoomSocketManager = new WatchRoomSocketManager();
+132
View File
@@ -0,0 +1,132 @@
// 观影室相关类型定义
export interface Room {
id: string;
name: string;
description: string;
password?: string;
isPublic: boolean;
ownerId: string;
ownerName: string;
memberCount: number;
currentState: PlayState | LiveState | null;
createdAt: number;
lastOwnerHeartbeat: number;
}
export interface Member {
id: string;
name: string;
isOwner: boolean;
lastHeartbeat: number;
}
export interface PlayState {
type: 'play';
url: string;
currentTime: number;
isPlaying: boolean;
videoId: string;
videoName: string;
episode?: number;
source: string;
}
export interface LiveState {
type: 'live';
channelId: string;
channelName: string;
channelUrl: string;
}
export interface ChatMessage {
id: string;
userId: string;
userName: string;
content: string;
type: 'text' | 'emoji';
timestamp: number;
}
export interface RoomMemberInfo {
roomId: string;
userId: string;
userName: string;
isOwner: boolean;
}
// Socket.IO 事件类型
export interface ServerToClientEvents {
'room:created': (room: Room) => void;
'room:joined': (data: { room: Room; members: Member[] }) => void;
'room:left': () => void;
'room:list': (rooms: Room[]) => void;
'room:member-joined': (member: Member) => void;
'room:member-left': (userId: string) => void;
'room:deleted': () => void;
'play:update': (state: PlayState) => void;
'play:seek': (currentTime: number) => void;
'play:play': () => void;
'play:pause': () => void;
'play:change': (state: PlayState) => void;
'live:change': (state: LiveState) => void;
'chat:message': (message: ChatMessage) => void;
'voice:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
'voice:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
'voice:ice': (data: { userId: string; candidate: RTCIceCandidateInit }) => void;
'error': (message: string) => void;
}
export interface ClientToServerEvents {
'room:create': (data: {
name: string;
description: string;
password?: string;
isPublic: boolean;
userName: string;
}, callback: (response: { success: boolean; room?: Room; error?: string }) => void) => void;
'room:join': (data: {
roomId: string;
password?: string;
userName: string;
}, callback: (response: { success: boolean; room?: Room; members?: Member[]; error?: string }) => void) => void;
'room:leave': () => void;
'room:list': (callback: (rooms: Room[]) => void) => void;
'play:update': (state: PlayState) => void;
'play:seek': (currentTime: number) => void;
'play:play': () => void;
'play:pause': () => void;
'play:change': (state: PlayState) => void;
'live:change': (state: LiveState) => void;
'chat:message': (data: { content: string; type: 'text' | 'emoji' }) => void;
'voice:offer': (data: { targetUserId: string; offer: RTCSessionDescriptionInit }) => void;
'voice:answer': (data: { targetUserId: string; answer: RTCSessionDescriptionInit }) => void;
'voice:ice': (data: { targetUserId: string; candidate: RTCIceCandidateInit }) => void;
'heartbeat': () => void;
}
// 配置类型
export interface WatchRoomConfig {
enabled: boolean;
serverType: 'internal' | 'external';
externalServerUrl?: string;
externalServerAuth?: string;
}
// LocalStorage 存储的房间信息
export interface StoredRoomInfo {
roomId: string;
roomName: string;
isOwner: boolean;
userName: string;
password?: string;
timestamp: number;
}