完善观影室
This commit is contained in:
@@ -33,7 +33,21 @@
|
||||
- 侧边栏添加观影室入口
|
||||
- 底部导航栏添加观影室入口
|
||||
|
||||
#### 3. 功能特性
|
||||
#### 3. 同步功能
|
||||
- ✅ **播放同步** (`usePlaySync` Hook)
|
||||
- 房主播放/暂停/进度跳转实时同步
|
||||
- 房主换集、换源自动同步
|
||||
- 房员自动跟随房主操作
|
||||
- 房员禁用控制器(显示"观影室模式"提示)
|
||||
- 防抖机制避免频繁同步
|
||||
- 进度差异超过2秒才同步,避免网络抖动
|
||||
|
||||
- ✅ **直播同步** (`useLiveSync` Hook)
|
||||
- 房主切换频道实时同步
|
||||
- 房员自动跟随频道切换
|
||||
- 延迟广播机制避免频繁触发
|
||||
|
||||
#### 4. 通用功能特性
|
||||
- ✅ LocalStorage 自动重连
|
||||
- ✅ 心跳机制(每5秒)
|
||||
- ✅ 房主断开5分钟后自动删除房间
|
||||
@@ -77,16 +91,6 @@ pnpm watch-room:server # 在另一个终端运行
|
||||
## 待实现功能 🚧
|
||||
|
||||
### 高优先级(核心功能)
|
||||
- ⏳ **播放同步功能** - 修改 `src/app/play/page.tsx`
|
||||
- 房主播放、暂停、进度同步
|
||||
- 房主换集、换源同步
|
||||
- 房员禁用播放器控制
|
||||
- 房员自动跟随房主进度
|
||||
|
||||
- ⏳ **直播同步功能** - 修改 `src/app/live/page.tsx`
|
||||
- 房主切换频道同步
|
||||
- 房员自动跟随频道
|
||||
|
||||
- ⏳ **管理面板配置**
|
||||
- 添加观影室开关
|
||||
- 配置服务器类型(内部/外部)
|
||||
@@ -150,7 +154,9 @@ src/
|
||||
│ ├── watch-room-server.ts # Socket.IO 服务器逻辑
|
||||
│ └── watch-room-socket.ts # Socket 客户端管理
|
||||
├── hooks/
|
||||
│ └── useWatchRoom.ts # React Hook
|
||||
│ ├── useWatchRoom.ts # 房间管理 Hook
|
||||
│ ├── usePlaySync.ts # 播放同步 Hook
|
||||
│ └── useLiveSync.ts # 直播同步 Hook
|
||||
├── components/
|
||||
│ ├── WatchRoomProvider.tsx # 全局状态管理
|
||||
│ └── watch-room/
|
||||
@@ -207,16 +213,16 @@ interface WatchRoomConfig {
|
||||
- [x] 心跳机制
|
||||
- [x] 刷新页面自动重连
|
||||
|
||||
### 播放同步测试(待实现)
|
||||
- [ ] 房主播放/暂停同步
|
||||
- [ ] 房主进度跳转同步
|
||||
- [ ] 房主换集同步
|
||||
- [ ] 房主换源同步
|
||||
- [ ] 房员操作同步给全员
|
||||
### 播放同步测试
|
||||
- [x] 房主播放/暂停同步
|
||||
- [x] 房主进度跳转同步
|
||||
- [x] 房主换集同步
|
||||
- [x] 房主换源同步
|
||||
- [x] 房员禁用控制器
|
||||
|
||||
### 直播同步测试(待实现)
|
||||
- [ ] 房主切换频道同步
|
||||
- [ ] 房员自动跟随频道
|
||||
### 直播同步测试
|
||||
- [x] 房主切换频道同步
|
||||
- [x] 房员自动跟随频道
|
||||
|
||||
### 移动端测试
|
||||
- [x] 底部导航显示正常
|
||||
@@ -226,22 +232,58 @@ interface WatchRoomConfig {
|
||||
|
||||
---
|
||||
|
||||
## 完整测试指南
|
||||
|
||||
### 测试场景 1: 创建房间并观看视频
|
||||
|
||||
1. **房主操作**:
|
||||
- 访问观影室页面,点击"创建房间"
|
||||
- 输入房间名称、描述和昵称
|
||||
- 创建后,进入播放页面 (`/play`)
|
||||
- 选择任意视频并播放
|
||||
- 尝试播放/暂停、跳转进度、切换集数、切换源
|
||||
|
||||
2. **房员操作** (使用另一个浏览器或无痕模式):
|
||||
- 访问观影室页面,点击"加入房间"
|
||||
- 输入房间号和昵称
|
||||
- 进入相同的播放页面
|
||||
- 观察播放器自动同步房主操作
|
||||
- 注意: 集数选择器上会显示"观影室模式"覆盖层,无法切换
|
||||
|
||||
### 测试场景 2: 聊天功能
|
||||
|
||||
1. 房主和房员都能看到右下角的绿色聊天按钮
|
||||
2. 点击打开聊天窗口
|
||||
3. 发送文字消息和表情
|
||||
4. 验证双方都能收到消息
|
||||
5. 测试最小化和关闭功能
|
||||
|
||||
### 测试场景 3: 直播同步
|
||||
|
||||
1. **房主操作**:
|
||||
- 在房间内,进入直播页面 (`/live`)
|
||||
- 切换不同频道
|
||||
|
||||
2. **房员操作**:
|
||||
- 同样进入直播页面
|
||||
- 观察频道自动跟随房主切换
|
||||
|
||||
### 测试场景 4: 自动重连
|
||||
|
||||
1. 房主或房员刷新页面
|
||||
2. 验证自动重连到原房间
|
||||
3. 验证聊天记录不丢失
|
||||
4. 验证播放状态继续同步
|
||||
|
||||
---
|
||||
|
||||
## 下一步计划
|
||||
|
||||
1. **实现播放同步** (`src/app/play/page.tsx`)
|
||||
- 集成 `useWatchRoom` Hook
|
||||
- 监听播放器事件并同步
|
||||
- 接收服务器播放状态更新
|
||||
|
||||
2. **实现直播同步** (`src/app/live/page.tsx`)
|
||||
- 监听频道切换事件
|
||||
- 同步频道状态
|
||||
|
||||
3. **添加管理面板配置**
|
||||
1. **添加管理面板配置**
|
||||
- 在 `src/app/admin/page.tsx` 添加观影室配置项
|
||||
- 保存配置到数据库
|
||||
|
||||
4. **实现 WebRTC 语音聊天**
|
||||
2. **实现 WebRTC 语音聊天** (可选)
|
||||
- 创建 WebRTC 连接管理
|
||||
- 添加麦克风/喇叭控制按钮
|
||||
- 实现服务器中转回退
|
||||
@@ -250,9 +292,9 @@ interface WatchRoomConfig {
|
||||
|
||||
## 已知问题
|
||||
|
||||
1. **播放同步功能未实现**: 需要修改 play 和 live 页面
|
||||
2. **管理面板配置未添加**: 目前使用默认配置(启用内部服务器)
|
||||
3. **语音聊天未实现**: 仅支持文字和表情
|
||||
1. **管理面板配置未添加**: 目前使用默认配置(启用内部服务器)
|
||||
2. **语音聊天未实现**: 仅支持文字和表情
|
||||
3. **房间成员列表未显示**: 可以在聊天窗口顶部看到在线人数,但没有详细成员列表
|
||||
|
||||
---
|
||||
|
||||
|
||||
117
server.js
117
server.js
@@ -18,6 +18,7 @@ class WatchRoomServer {
|
||||
this.rooms = new Map();
|
||||
this.members = new Map();
|
||||
this.socketToRoom = new Map();
|
||||
this.roomDeletionTimers = new Map(); // 房间延迟删除定时器
|
||||
this.cleanupInterval = null;
|
||||
this.setupEventHandlers();
|
||||
this.startCleanupTimer();
|
||||
@@ -32,6 +33,7 @@ class WatchRoomServer {
|
||||
try {
|
||||
const roomId = this.generateRoomId();
|
||||
const userId = socket.id;
|
||||
const ownerToken = this.generateRoomId(); // 生成房主令牌
|
||||
|
||||
const room = {
|
||||
id: roomId,
|
||||
@@ -41,6 +43,7 @@ class WatchRoomServer {
|
||||
isPublic: data.isPublic,
|
||||
ownerId: userId,
|
||||
ownerName: data.userName,
|
||||
ownerToken: ownerToken, // 保存房主令牌
|
||||
memberCount: 1,
|
||||
currentState: null,
|
||||
createdAt: Date.now(),
|
||||
@@ -86,10 +89,29 @@ class WatchRoomServer {
|
||||
}
|
||||
|
||||
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: false,
|
||||
isOwner: isOwner,
|
||||
lastHeartbeat: Date.now(),
|
||||
};
|
||||
|
||||
@@ -104,13 +126,13 @@ class WatchRoomServer {
|
||||
roomId: data.roomId,
|
||||
userId,
|
||||
userName: data.userName,
|
||||
isOwner: false,
|
||||
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}`);
|
||||
console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}${isOwner ? ' (as owner)' : ''}`);
|
||||
|
||||
const members = Array.from(roomMembers?.values() || []);
|
||||
callback({ success: true, room, members });
|
||||
@@ -131,37 +153,59 @@ class WatchRoomServer {
|
||||
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 || !roomInfo.isOwner) return;
|
||||
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) return;
|
||||
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) return;
|
||||
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) return;
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -270,12 +314,12 @@ class WatchRoomServer {
|
||||
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);
|
||||
|
||||
const room = this.rooms.get(roomId);
|
||||
if (room) {
|
||||
room.memberCount = roomMembers.size;
|
||||
this.rooms.set(roomId, room);
|
||||
@@ -283,12 +327,44 @@ class WatchRoomServer {
|
||||
|
||||
socket.to(roomId).emit('room:member-left', userId);
|
||||
|
||||
// 如果是房主主动离开,解散房间并踢出所有成员
|
||||
if (isOwner) {
|
||||
console.log(`[WatchRoom] Owner left room ${roomId}, will auto-delete after 5 minutes`);
|
||||
}
|
||||
console.log(`[WatchRoom] Owner actively left room ${roomId}, disbanding room`);
|
||||
|
||||
if (roomMembers.size === 0) {
|
||||
this.deleteRoom(roomId);
|
||||
// 通知所有成员房间被解散
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,9 +372,14 @@ class WatchRoomServer {
|
||||
this.socketToRoom.delete(socket.id);
|
||||
}
|
||||
|
||||
deleteRoom(roomId) {
|
||||
deleteRoom(roomId, skipNotify = false) {
|
||||
console.log(`[WatchRoom] Deleting room ${roomId}`);
|
||||
this.io.to(roomId).emit('room:deleted');
|
||||
|
||||
// 如果不跳过通知,则发送 room:deleted 事件
|
||||
if (!skipNotify) {
|
||||
this.io.to(roomId).emit('room:deleted');
|
||||
}
|
||||
|
||||
this.rooms.delete(roomId);
|
||||
this.members.delete(roomId);
|
||||
}
|
||||
@@ -329,6 +410,12 @@ class WatchRoomServer {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
// 清理所有房间删除定时器
|
||||
for (const timer of this.roomDeletionTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.roomDeletionTimers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Heart, Radio, Tv } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useLiveSync } from '@/hooks/useLiveSync';
|
||||
|
||||
import {
|
||||
deleteFavorite,
|
||||
generateStorageKey,
|
||||
@@ -122,6 +124,20 @@ function LivePageClient() {
|
||||
const favoritedRef = useRef(false);
|
||||
const currentChannelRef = useRef<LiveChannel | null>(null);
|
||||
|
||||
// 观影室同步功能
|
||||
const liveSync = useLiveSync({
|
||||
currentChannelId: currentChannel?.id || '',
|
||||
currentChannelName: currentChannel?.name || '',
|
||||
currentChannelUrl: currentChannel?.url || '',
|
||||
onChannelChange: (channelId, channelUrl) => {
|
||||
// 房员接收到频道切换指令
|
||||
const channel = currentChannels.find(c => c.id === channelId);
|
||||
if (channel) {
|
||||
handleChannelChange(channel);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// EPG数据清洗函数 - 去除重叠的节目,保留时间较短的,只显示今日节目
|
||||
const cleanEpgData = (programs: Array<{ start: string; end: string; title: string }>) => {
|
||||
if (!programs || programs.length === 0) return programs;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Heart } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { usePlaySync } from '@/hooks/usePlaySync';
|
||||
|
||||
|
||||
import {
|
||||
deleteFavorite,
|
||||
@@ -328,7 +330,28 @@ function PlayPageClient() {
|
||||
needPreferRef.current = needPrefer;
|
||||
}, [needPrefer]);
|
||||
// 集数相关
|
||||
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(0);
|
||||
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(() => {
|
||||
const episodeParam = searchParams.get('episode');
|
||||
if (episodeParam) {
|
||||
const episode = parseInt(episodeParam, 10);
|
||||
return episode > 0 ? episode - 1 : 0; // URL 中是 1-based,内部是 0-based
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// 监听 URL 参数变化,更新集数索引(用于房员跟随换集)
|
||||
useEffect(() => {
|
||||
const episodeParam = searchParams.get('episode');
|
||||
if (episodeParam) {
|
||||
const episode = parseInt(episodeParam, 10);
|
||||
const newIndex = episode > 0 ? episode - 1 : 0;
|
||||
console.log('[PlayPage] Checking episode from URL:', { urlEpisode: episode, currentIndex: currentEpisodeIndex, newIndex });
|
||||
if (newIndex !== currentEpisodeIndex) {
|
||||
console.log('[PlayPage] URL episode changed, updating index to:', newIndex);
|
||||
setCurrentEpisodeIndex(newIndex);
|
||||
}
|
||||
}
|
||||
}, [searchParams, currentEpisodeIndex]);
|
||||
|
||||
const currentSourceRef = useRef(currentSource);
|
||||
const currentIdRef = useRef(currentId);
|
||||
@@ -429,6 +452,9 @@ function PlayPageClient() {
|
||||
'initing' | 'sourceChanging'
|
||||
>('initing');
|
||||
|
||||
// 播放器就绪状态(用于触发 usePlaySync 的事件监听器设置)
|
||||
const [playerReady, setPlayerReady] = useState(false);
|
||||
|
||||
// 播放进度保存相关
|
||||
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSaveTimeRef = useRef<number>(0);
|
||||
@@ -439,6 +465,19 @@ function PlayPageClient() {
|
||||
// Wake Lock 相关
|
||||
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
|
||||
|
||||
// 观影室同步功能
|
||||
const playSync = usePlaySync({
|
||||
artPlayerRef,
|
||||
videoId: currentId || '', // 使用 currentId 状态而不是 searchParams
|
||||
videoName: videoTitle || detail?.title || '正在加载...',
|
||||
videoYear: videoYear || detail?.year || '',
|
||||
searchTitle: searchTitle || '',
|
||||
currentEpisode: currentEpisodeIndex + 1,
|
||||
currentSource: currentSource || '',
|
||||
videoUrl: videoUrl || '',
|
||||
playerReady: playerReady, // 传递播放器就绪状态
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 工具函数(Utils)
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -2721,6 +2760,13 @@ function PlayPageClient() {
|
||||
html: '<i class="art-icon flex"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" fill="currentColor"/></svg></i>',
|
||||
tooltip: '播放下一集',
|
||||
click: function () {
|
||||
// 房员禁用下一集按钮
|
||||
if (playSync.shouldDisableControls) {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '房员无法切换集数,请等待房主操作';
|
||||
}
|
||||
return;
|
||||
}
|
||||
handleNextEpisode();
|
||||
},
|
||||
},
|
||||
@@ -2731,6 +2777,10 @@ function PlayPageClient() {
|
||||
artPlayerRef.current.on('ready', async () => {
|
||||
setError(null);
|
||||
|
||||
// 标记播放器已就绪,触发 usePlaySync 设置事件监听器
|
||||
setPlayerReady(true);
|
||||
console.log('[PlayPage] Player ready, triggering sync setup');
|
||||
|
||||
// 从 art.storage 读取弹幕设置并应用
|
||||
if (artPlayerRef.current) {
|
||||
const storedDanmakuSettings = artPlayerRef.current.storage.get('danmaku_settings');
|
||||
@@ -2905,8 +2955,17 @@ function PlayPageClient() {
|
||||
}
|
||||
});
|
||||
|
||||
// 监听视频播放结束事件,自动播放下一集
|
||||
// 监听视频播放结束事件,自动播放下一集(房员禁用)
|
||||
artPlayerRef.current.on('video:ended', () => {
|
||||
// 房员禁用自动播放下一集
|
||||
if (playSync.shouldDisableControls) {
|
||||
console.log('[PlayPage] Member cannot auto-play next episode');
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '等待房主切换下一集';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const d = detailRef.current;
|
||||
const idx = currentEpisodeIndexRef.current;
|
||||
if (d && d.episodes && idx < d.episodes.length - 1) {
|
||||
@@ -3686,18 +3745,29 @@ function PlayPageClient() {
|
||||
|
||||
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
|
||||
<div
|
||||
className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out relative ${
|
||||
isEpisodeSelectorCollapsed
|
||||
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
|
||||
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
|
||||
}`}
|
||||
>
|
||||
{/* 观影室房员禁用层 */}
|
||||
{playSync.isInRoom && playSync.shouldDisableControls && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="text-center p-4">
|
||||
<p className="text-white text-lg font-bold mb-2">👥 观影室模式</p>
|
||||
<p className="text-gray-300 text-sm">
|
||||
{playSync.isOwner ? '您是房主,可以控制播放' : '房主控制中,无法切换集数和播放源'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<EpisodeSelector
|
||||
totalEpisodes={totalEpisodes}
|
||||
episodes_titles={detail?.episodes_titles || []}
|
||||
value={currentEpisodeIndex + 1}
|
||||
onChange={handleEpisodeChange}
|
||||
onSourceChange={handleSourceChange}
|
||||
onChange={playSync.shouldDisableControls ? () => {} : handleEpisodeChange}
|
||||
onSourceChange={playSync.shouldDisableControls ? () => {} : handleSourceChange}
|
||||
currentSource={currentSource}
|
||||
currentId={currentId}
|
||||
videoTitle={searchTitle || videoTitle}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// 房间列表页面
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +1,663 @@
|
||||
// 观影室首页
|
||||
// 观影室首页 - 选项卡式界面
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Users, UserPlus, List as ListIcon, Lock, RefreshCw } from 'lucide-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';
|
||||
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
import type { Room } from '@/types/watch-room';
|
||||
|
||||
type TabType = 'create' | 'join' | 'list';
|
||||
|
||||
export default function WatchRoomPage() {
|
||||
const router = useRouter();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showJoinModal, setShowJoinModal] = useState(false);
|
||||
const { getRoomList, isConnected, createRoom, joinRoom, currentRoom, isOwner, members } = useWatchRoomContext();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('create');
|
||||
|
||||
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',
|
||||
},
|
||||
// 获取当前登录用户(在客户端挂载后读取,避免 hydration 错误)
|
||||
const [currentUsername, setCurrentUsername] = useState<string>('游客');
|
||||
|
||||
useEffect(() => {
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
setCurrentUsername(authInfo?.username || '游客');
|
||||
}, []);
|
||||
|
||||
// 创建房间表单
|
||||
const [createForm, setCreateForm] = useState({
|
||||
roomName: '',
|
||||
description: '',
|
||||
password: '',
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
// 加入房间表单
|
||||
const [joinForm, setJoinForm] = useState({
|
||||
roomId: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
// 房间列表
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [joinLoading, setJoinLoading] = 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);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换到房间列表 tab 时加载房间
|
||||
useEffect(() => {
|
||||
if (activeTab === 'list') {
|
||||
loadRooms();
|
||||
// 每5秒刷新一次
|
||||
const interval = setInterval(loadRooms, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [activeTab, isConnected]);
|
||||
|
||||
// 处理创建房间
|
||||
const handleCreateRoom = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!createForm.roomName.trim()) {
|
||||
alert('请输入房间名称');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateLoading(true);
|
||||
try {
|
||||
await createRoom({
|
||||
name: createForm.roomName.trim(),
|
||||
description: createForm.description.trim(),
|
||||
password: createForm.password.trim() || undefined,
|
||||
isPublic: createForm.isPublic,
|
||||
userName: currentUsername,
|
||||
});
|
||||
|
||||
// 清空表单
|
||||
setCreateForm({
|
||||
roomName: '',
|
||||
description: '',
|
||||
password: '',
|
||||
isPublic: true,
|
||||
});
|
||||
} catch (error: any) {
|
||||
alert(error.message || '创建房间失败');
|
||||
} finally {
|
||||
setCreateLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理加入房间
|
||||
const handleJoinRoom = async (e: React.FormEvent, roomId?: string) => {
|
||||
e.preventDefault();
|
||||
const targetRoomId = roomId || joinForm.roomId.trim().toUpperCase();
|
||||
if (!targetRoomId) {
|
||||
alert('请输入房间ID');
|
||||
return;
|
||||
}
|
||||
|
||||
setJoinLoading(true);
|
||||
try {
|
||||
const result = await joinRoom({
|
||||
roomId: targetRoomId,
|
||||
password: joinForm.password.trim() || undefined,
|
||||
userName: currentUsername,
|
||||
});
|
||||
|
||||
// 清空表单
|
||||
setJoinForm({
|
||||
roomId: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
// 注意:加入房间后,isOwner 状态会在 useWatchRoom 中更新
|
||||
// 跳转逻辑会在 useEffect 中处理
|
||||
} catch (error: any) {
|
||||
alert(error.message || '加入房间失败');
|
||||
} finally {
|
||||
setJoinLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听房间状态,房员加入后自动跟随房主播放
|
||||
useEffect(() => {
|
||||
if (!currentRoom || isOwner) return;
|
||||
|
||||
// 房员加入房间后,检查房主的播放状态
|
||||
if (currentRoom.currentState) {
|
||||
const state = currentRoom.currentState;
|
||||
if (state.type === 'play') {
|
||||
// 跳转到播放页面,使用完整参数
|
||||
console.log('[WatchRoom] Member joining/following, redirecting to play page');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
id: state.videoId,
|
||||
source: state.source,
|
||||
episode: String(state.episode || 1),
|
||||
});
|
||||
|
||||
// 添加可选参数
|
||||
if (state.videoName) params.set('title', state.videoName);
|
||||
if (state.videoYear) params.set('year', state.videoYear);
|
||||
if (state.searchTitle) params.set('stitle', state.searchTitle);
|
||||
|
||||
router.push(`/play?${params.toString()}`);
|
||||
} else if (state.type === 'live') {
|
||||
// 跳转到直播页面
|
||||
console.log('[WatchRoom] Member joining/following, redirecting to live page');
|
||||
router.push(`/live?id=${state.channelId}`);
|
||||
}
|
||||
}
|
||||
// 如果房主还没播放,留在观影室页面等待
|
||||
}, [currentRoom?.currentState, isOwner, router]);
|
||||
|
||||
// 从房间列表加入房间
|
||||
const handleJoinFromList = (room: Room) => {
|
||||
setJoinForm({
|
||||
roomId: room.id,
|
||||
password: '',
|
||||
});
|
||||
setActiveTab('join');
|
||||
};
|
||||
|
||||
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 '刚刚';
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'create' as TabType, label: '创建房间', icon: Users },
|
||||
{ id: 'join' as TabType, label: '加入房间', icon: UserPlus },
|
||||
{ id: 'list' as TabType, label: '房间列表', icon: ListIcon },
|
||||
];
|
||||
|
||||
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>
|
||||
<PageLayout activePath="/watch-room">
|
||||
<div className="flex flex-col gap-4 py-4 px-5 lg:px-[3rem] 2xl:px-20">
|
||||
{/* 房员等待提示 */}
|
||||
{currentRoom && !isOwner && !currentRoom.currentState && (
|
||||
<div className="mb-4 bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl p-6 shadow-lg">
|
||||
<div className="flex items-center justify-center gap-4 text-white">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-1">等待房主开始播放</h3>
|
||||
<p className="text-sm text-white/80">
|
||||
房间: {currentRoom.name} | 房主: {currentRoom.ownerName}
|
||||
</p>
|
||||
<p className="text-xs text-white/70 mt-1">
|
||||
当房主开始播放时,您将自动跳转到相同的视频或频道
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 页面标题 */}
|
||||
<div className="py-1">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<Users className="w-6 h-6 text-blue-500" />
|
||||
观影室
|
||||
{currentRoom && (
|
||||
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
({isOwner ? '房主' : '房员'})
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
与好友一起看视频,实时同步播放
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 按钮网格 */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{buttons.map((button, index) => {
|
||||
const Icon = button.icon;
|
||||
{/* 选项卡 */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.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`}
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors relative
|
||||
${
|
||||
activeTab === tab.id
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<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>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400" />
|
||||
)}
|
||||
</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 className="flex-1">
|
||||
{/* 创建房间 */}
|
||||
{activeTab === 'create' && (
|
||||
<div className="max-w-2xl mx-auto py-8">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">
|
||||
创建新房间
|
||||
</h2>
|
||||
|
||||
{/* 如果已在房间内,显示当前房间信息 */}
|
||||
{currentRoom ? (
|
||||
<div className="space-y-4">
|
||||
{/* 房间信息卡片 */}
|
||||
<div className="bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl p-6 text-white">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold mb-1">{currentRoom.name}</h3>
|
||||
<p className="text-blue-100 text-sm">{currentRoom.description || '暂无描述'}</p>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<span className="bg-yellow-400 text-yellow-900 px-3 py-1 rounded-full text-xs font-bold">
|
||||
房主
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||
<p className="text-blue-100 text-xs mb-1">房间号</p>
|
||||
<p className="text-xl font-mono font-bold">{currentRoom.id}</p>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||
<p className="text-blue-100 text-xs mb-1">成员数</p>
|
||||
<p className="text-xl font-bold">{members.length} 人</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 成员列表 */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">房间成员</h4>
|
||||
<div className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white font-bold">
|
||||
{member.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{member.name}
|
||||
</span>
|
||||
</div>
|
||||
{member.isOwner && (
|
||||
<span className="text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 px-2 py-1 rounded">
|
||||
房主
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
💡 前往播放页面或直播页面开始观影,房间成员将自动同步您的操作
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleCreateRoom} className="space-y-4">
|
||||
{/* 显示当前用户 */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>当前用户:</strong>{currentUsername}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
房间名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.roomName}
|
||||
onChange={(e) => setCreateForm({ ...createForm, roomName: e.target.value })}
|
||||
placeholder="请输入房间名称"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
房间描述
|
||||
</label>
|
||||
<textarea
|
||||
value={createForm.description}
|
||||
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
|
||||
placeholder="请输入房间描述(可选)"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
房间密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={createForm.password}
|
||||
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
|
||||
placeholder="留空表示无需密码"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isPublic"
|
||||
checked={createForm.isPublic}
|
||||
onChange={(e) => setCreateForm({ ...createForm, isPublic: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="isPublic" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
在房间列表中公开显示
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createLoading || !createForm.roomName.trim()}
|
||||
className="w-full bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white font-medium py-3 rounded-lg transition-colors"
|
||||
>
|
||||
{createLoading ? '创建中...' : '创建房间'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 使用说明 - 仅在未在房间内时显示 */}
|
||||
{!currentRoom && (
|
||||
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>提示:</strong>创建房间后,您将成为房主。所有成员的播放进度将自动跟随您的操作。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加入房间 */}
|
||||
{activeTab === 'join' && (
|
||||
<div className="max-w-2xl mx-auto py-8">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">
|
||||
加入房间
|
||||
</h2>
|
||||
|
||||
{/* 如果已在房间内,显示当前房间信息 */}
|
||||
{currentRoom ? (
|
||||
<div className="space-y-4">
|
||||
{/* 房间信息卡片 */}
|
||||
<div className="bg-gradient-to-r from-green-500 to-teal-600 rounded-xl p-6 text-white">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold mb-1">{currentRoom.name}</h3>
|
||||
<p className="text-green-100 text-sm">{currentRoom.description || '暂无描述'}</p>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<span className="bg-yellow-400 text-yellow-900 px-3 py-1 rounded-full text-xs font-bold">
|
||||
房主
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||
<p className="text-green-100 text-xs mb-1">房间号</p>
|
||||
<p className="text-xl font-mono font-bold">{currentRoom.id}</p>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||
<p className="text-green-100 text-xs mb-1">成员数</p>
|
||||
<p className="text-xl font-bold">{members.length} 人</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 成员列表 */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">房间成员</h4>
|
||||
<div className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-400 to-teal-500 flex items-center justify-center text-white font-bold">
|
||||
{member.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{member.name}
|
||||
</span>
|
||||
</div>
|
||||
{member.isOwner && (
|
||||
<span className="text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 px-2 py-1 rounded">
|
||||
房主
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
💡 {isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleJoinRoom} className="space-y-4">
|
||||
{/* 显示当前用户 */}
|
||||
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-3 border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
<strong>当前用户:</strong>{currentUsername}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
房间号 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={joinForm.roomId}
|
||||
onChange={(e) => setJoinForm({ ...joinForm, roomId: e.target.value.toUpperCase() })}
|
||||
placeholder="请输入6位房间号"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 font-mono text-lg tracking-wider focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
房间密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={joinForm.password}
|
||||
onChange={(e) => setJoinForm({ ...joinForm, password: e.target.value })}
|
||||
placeholder="如果房间有密码,请输入"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={joinLoading || !joinForm.roomId.trim()}
|
||||
className="w-full bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white font-medium py-3 rounded-lg transition-colors"
|
||||
>
|
||||
{joinLoading ? '加入中...' : '加入房间'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 使用说明 - 仅在未在房间内时显示 */}
|
||||
{!currentRoom && (
|
||||
<div className="mt-6 bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
<strong>提示:</strong>加入房间后,您的播放进度将自动跟随房主的操作。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 房间列表 */}
|
||||
{activeTab === 'list' && (
|
||||
<div className="py-4">
|
||||
{/* 顶部操作栏 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
找到 <span className="font-medium text-gray-900 dark:text-gray-100">{rooms.length}</span> 个公开房间
|
||||
</p>
|
||||
<button
|
||||
onClick={loadRooms}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg text-gray-700 dark:text-gray-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</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-500 dark: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-400" />
|
||||
<p className="mb-2 text-xl text-gray-600 dark:text-gray-400">暂无公开房间</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
创建一个新房间或通过房间号加入私密房间
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 房间卡片列表 */}
|
||||
{rooms.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{rooms.map((room) => (
|
||||
<div
|
||||
key={room.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl p-5 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
{room.name}
|
||||
</h3>
|
||||
{room.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1">
|
||||
{room.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{room.password && (
|
||||
<Lock className="w-5 h-5 text-yellow-500 flex-shrink-0 ml-2" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">房间号</span>
|
||||
<span className="font-mono text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{room.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{room.memberCount} 人在线</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>房主</span>
|
||||
<span className="font-medium">{room.ownerName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>创建时间</span>
|
||||
<span>{formatTime(room.createdAt)}</span>
|
||||
</div>
|
||||
{room.currentState && (
|
||||
<div className="mt-2 rounded-lg bg-blue-50 dark:bg-blue-900/30 px-3 py-2 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 truncate">
|
||||
{room.currentState.type === 'play'
|
||||
? `正在播放: ${room.currentState.videoName}`
|
||||
: `正在观看: ${room.currentState.channelName}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleJoinFromList(room)}
|
||||
className="w-full bg-purple-500 hover:bg-purple-600 text-white font-medium py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
加入房间
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 弹窗 */}
|
||||
{showCreateModal && <CreateRoomModal onClose={() => setShowCreateModal(false)} />}
|
||||
{showJoinModal && <JoinRoomModal onClose={() => setShowJoinModal(false)} />}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback } fr
|
||||
import { useWatchRoom } from '@/hooks/useWatchRoom';
|
||||
import type { Room, Member, ChatMessage, WatchRoomConfig } from '@/types/watch-room';
|
||||
import type { WatchRoomSocket } from '@/lib/watch-room-socket';
|
||||
import Toast, { ToastProps } from '@/components/Toast';
|
||||
|
||||
interface WatchRoomContextType {
|
||||
socket: WatchRoomSocket | null;
|
||||
@@ -66,8 +67,31 @@ interface WatchRoomProviderProps {
|
||||
export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
const [config, setConfig] = useState<WatchRoomConfig | null>(null);
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
|
||||
const watchRoom = useWatchRoom();
|
||||
// 处理房间删除的回调
|
||||
const handleRoomDeleted = useCallback((data?: { reason?: string }) => {
|
||||
console.log('[WatchRoomProvider] Room deleted:', data);
|
||||
|
||||
// 显示Toast提示
|
||||
if (data?.reason === 'owner_left') {
|
||||
setToast({
|
||||
message: '房主已解散房间',
|
||||
type: 'error',
|
||||
duration: 4000,
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
message: '房间已被删除',
|
||||
type: 'info',
|
||||
duration: 3000,
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const watchRoom = useWatchRoom(handleRoomDeleted);
|
||||
|
||||
// 加载配置
|
||||
useEffect(() => {
|
||||
@@ -145,6 +169,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
return (
|
||||
<WatchRoomContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{toast && <Toast {...toast} />}
|
||||
</WatchRoomContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// 全局聊天悬浮窗
|
||||
// 全局聊天悬浮窗和房间信息按钮
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { MessageCircle, X, Send, Smile, Minimize2, Maximize2 } from 'lucide-react';
|
||||
import { MessageCircle, X, Send, Smile, Minimize2, Maximize2, Info, Users, LogOut, XCircle } from 'lucide-react';
|
||||
import { useWatchRoomContextSafe } from '@/components/WatchRoomProvider';
|
||||
|
||||
const EMOJI_LIST = ['😀', '😂', '😍', '🥰', '😎', '🤔', '👍', '👏', '🎉', '❤️', '🔥', '⭐'];
|
||||
@@ -13,6 +13,7 @@ export default function ChatFloatingWindow() {
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [showRoomInfo, setShowRoomInfo] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自动滚动到底部
|
||||
@@ -27,7 +28,7 @@ export default function ChatFloatingWindow() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { chatMessages, sendChatMessage, members, isOwner } = watchRoom;
|
||||
const { chatMessages, sendChatMessage, members, isOwner, currentRoom, leaveRoom } = watchRoom;
|
||||
|
||||
const handleSendMessage = () => {
|
||||
if (!message.trim()) return;
|
||||
@@ -57,51 +58,222 @@ export default function ChatFloatingWindow() {
|
||||
});
|
||||
};
|
||||
|
||||
// 悬浮按钮
|
||||
if (!isOpen) {
|
||||
const handleLeaveRoom = () => {
|
||||
if (confirm(isOwner ? '确定要解散房间吗?所有成员将被踢出房间。' : '确定要退出房间吗?')) {
|
||||
leaveRoom();
|
||||
setShowRoomInfo(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 悬浮按钮组
|
||||
if (!isOpen && !showRoomInfo) {
|
||||
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>
|
||||
<div className="fixed bottom-20 right-4 z-[700] flex flex-col gap-3 md:bottom-4">
|
||||
{/* 房间信息按钮 */}
|
||||
<button
|
||||
onClick={() => setShowRoomInfo(true)}
|
||||
className="flex h-14 w-14 items-center justify-center rounded-full bg-blue-500 text-white shadow-2xl transition-all hover:scale-110 hover:bg-blue-600"
|
||||
aria-label="房间信息"
|
||||
>
|
||||
<Info className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* 聊天按钮 */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="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"
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 房间信息模态框
|
||||
if (showRoomInfo) {
|
||||
return (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||
onClick={() => setShowRoomInfo(false)}
|
||||
onTouchMove={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
style={{
|
||||
touchAction: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 房间信息面板 */}
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'>
|
||||
<div
|
||||
className='h-full p-6'
|
||||
data-panel-content
|
||||
onTouchMove={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
touchAction: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Info className='h-6 w-6 text-blue-500 dark:text-blue-400' />
|
||||
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>房间信息</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRoomInfo(false)}
|
||||
className='rounded-full p-1.5 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
aria-label='关闭'
|
||||
>
|
||||
<X className='h-5 w-5' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className='space-y-4'>
|
||||
{/* 房间基本信息 */}
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between rounded-lg bg-gray-50 dark:bg-gray-800 p-4 border border-gray-200 dark:border-gray-700'>
|
||||
<span className='text-sm font-medium text-gray-600 dark:text-gray-400'>房间名称</span>
|
||||
<span className='text-sm font-semibold text-gray-900 dark:text-gray-100'>{currentRoom.name}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg bg-gray-50 dark:bg-gray-800 p-4 border border-gray-200 dark:border-gray-700'>
|
||||
<span className='text-sm font-medium text-gray-600 dark:text-gray-400'>房间号</span>
|
||||
<span className='text-lg font-mono font-bold text-gray-900 dark:text-gray-100'>{currentRoom.id}</span>
|
||||
</div>
|
||||
|
||||
{currentRoom.description && (
|
||||
<div className='rounded-lg bg-gray-50 dark:bg-gray-800 p-4 border border-gray-200 dark:border-gray-700'>
|
||||
<span className='text-sm font-medium text-gray-600 dark:text-gray-400 block mb-2'>房间描述</span>
|
||||
<p className='text-sm text-gray-700 dark:text-gray-300'>{currentRoom.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg bg-gray-50 dark:bg-gray-800 p-4 border border-gray-200 dark:border-gray-700'>
|
||||
<span className='text-sm font-medium text-gray-600 dark:text-gray-400'>房主</span>
|
||||
<span className='text-sm font-semibold text-gray-900 dark:text-gray-100'>{currentRoom.ownerName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 成员列表 */}
|
||||
<div className='rounded-lg bg-gray-50 dark:bg-gray-800 p-4 border border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex items-center gap-2 mb-3'>
|
||||
<Users className='h-4 w-4 text-gray-600 dark:text-gray-400' />
|
||||
<span className='text-sm font-medium text-gray-600 dark:text-gray-400'>成员列表 ({members.length})</span>
|
||||
</div>
|
||||
<div className='space-y-2 max-h-40 overflow-y-auto'>
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className='flex items-center justify-between bg-white dark:bg-gray-700 rounded-lg p-3 border border-gray-200 dark:border-gray-600'
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='w-8 h-8 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white font-bold text-sm'>
|
||||
{member.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className='text-sm font-medium text-gray-900 dark:text-gray-100'>{member.name}</span>
|
||||
</div>
|
||||
{member.isOwner && (
|
||||
<span className='text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 px-2 py-1 rounded-full font-bold'>
|
||||
房主
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<button
|
||||
onClick={handleLeaveRoom}
|
||||
className={`w-full flex items-center justify-center gap-2 rounded-lg py-3 font-medium transition-colors ${
|
||||
isOwner
|
||||
? 'bg-red-500 hover:bg-red-600 text-white'
|
||||
: 'bg-gray-600 hover:bg-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{isOwner ? (
|
||||
<>
|
||||
<XCircle className='h-5 w-5' />
|
||||
解散房间
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogOut className='h-5 w-5' />
|
||||
退出房间
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 最小化状态
|
||||
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="展开"
|
||||
onClick={() => setShowRoomInfo(true)}
|
||||
className="fixed bottom-36 right-4 z-[700] flex h-12 w-12 items-center justify-center rounded-full bg-blue-500 text-white shadow-2xl transition-all hover:scale-110 hover:bg-blue-600 md:bottom-20"
|
||||
aria-label="房间信息"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
<Info className="h-5 w-5" />
|
||||
</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>
|
||||
|
||||
{/* 最小化的聊天窗口 */}
|
||||
<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">
|
||||
<>
|
||||
{/* 房间信息按钮 */}
|
||||
<button
|
||||
onClick={() => setShowRoomInfo(true)}
|
||||
className="fixed bottom-[30rem] right-4 z-[700] flex h-12 w-12 items-center justify-center rounded-full bg-blue-500 text-white shadow-2xl transition-all hover:scale-110 hover:bg-blue-600 md:bottom-[28rem]"
|
||||
aria-label="房间信息"
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 聊天窗口 */}
|
||||
<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">
|
||||
@@ -217,5 +389,6 @@ export default function ChatFloatingWindow() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
// 创建房间弹窗
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// 加入房间弹窗
|
||||
'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>
|
||||
);
|
||||
}
|
||||
96
src/hooks/useLiveSync.ts
Normal file
96
src/hooks/useLiveSync.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// React Hook for Live Page Synchronization
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useWatchRoomContextSafe } from '@/components/WatchRoomProvider';
|
||||
import type { LiveState } from '@/types/watch-room';
|
||||
|
||||
interface UseLiveSyncOptions {
|
||||
currentChannelId: string;
|
||||
currentChannelName: string;
|
||||
currentChannelUrl: string;
|
||||
onChannelChange?: (channelId: string, channelUrl: string) => void;
|
||||
}
|
||||
|
||||
export function useLiveSync({
|
||||
currentChannelId,
|
||||
currentChannelName,
|
||||
currentChannelUrl,
|
||||
onChannelChange,
|
||||
}: UseLiveSyncOptions) {
|
||||
const watchRoom = useWatchRoomContextSafe();
|
||||
const syncingRef = useRef(false); // 防止循环同步
|
||||
|
||||
// 检查是否在房间内
|
||||
const isInRoom = !!(watchRoom && watchRoom.currentRoom);
|
||||
const isOwner = watchRoom?.isOwner || false;
|
||||
const currentRoom = watchRoom?.currentRoom;
|
||||
const socket = watchRoom?.socket;
|
||||
|
||||
// 房主:广播频道切换
|
||||
const broadcastChannelChange = useCallback(() => {
|
||||
if (!isOwner || !socket || syncingRef.current || !watchRoom) return;
|
||||
|
||||
if (!currentChannelId || !currentChannelName || !currentChannelUrl) return;
|
||||
|
||||
const state: LiveState = {
|
||||
type: 'live',
|
||||
channelId: currentChannelId,
|
||||
channelName: currentChannelName,
|
||||
channelUrl: currentChannelUrl,
|
||||
};
|
||||
|
||||
console.log('[LiveSync] Broadcasting channel change:', state);
|
||||
watchRoom.changeLiveChannel(state);
|
||||
}, [isOwner, socket, currentChannelId, currentChannelName, currentChannelUrl, watchRoom]);
|
||||
|
||||
// 房员:接收并同步房主的频道切换
|
||||
useEffect(() => {
|
||||
if (!socket || !currentRoom || isOwner || !isInRoom) return;
|
||||
|
||||
const handleLiveChange = (state: LiveState) => {
|
||||
if (syncingRef.current) return;
|
||||
|
||||
console.log('[LiveSync] Received channel change:', state);
|
||||
syncingRef.current = true;
|
||||
|
||||
try {
|
||||
// 调用回调函数来切换频道
|
||||
if (onChannelChange) {
|
||||
onChannelChange(state.channelId, state.channelUrl);
|
||||
}
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
syncingRef.current = false;
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('live:change', handleLiveChange);
|
||||
|
||||
return () => {
|
||||
socket.off('live:change', handleLiveChange);
|
||||
};
|
||||
}, [socket, currentRoom, isOwner, onChannelChange, isInRoom]);
|
||||
|
||||
// 房主:当频道改变时自动广播
|
||||
useEffect(() => {
|
||||
if (!isOwner || !currentChannelId || !isInRoom) return;
|
||||
|
||||
// 防止初始化时广播
|
||||
if (syncingRef.current) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
broadcastChannelChange();
|
||||
}, 500); // 延迟广播,避免频繁触发
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOwner, currentChannelId, currentChannelUrl, broadcastChannelChange, isInRoom]);
|
||||
|
||||
return {
|
||||
isInRoom,
|
||||
isOwner,
|
||||
shouldDisableControls: isInRoom && !isOwner, // 房员禁用频道切换
|
||||
broadcastChannelChange, // 导出供手动调用
|
||||
};
|
||||
}
|
||||
352
src/hooks/usePlaySync.ts
Normal file
352
src/hooks/usePlaySync.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
// React Hook for Play Page Synchronization
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useWatchRoomContextSafe } from '@/components/WatchRoomProvider';
|
||||
import type { PlayState } from '@/types/watch-room';
|
||||
|
||||
interface UsePlaySyncOptions {
|
||||
artPlayerRef: React.MutableRefObject<any>;
|
||||
videoId: string;
|
||||
videoName: string;
|
||||
videoYear?: string;
|
||||
searchTitle?: string;
|
||||
currentEpisode?: number;
|
||||
currentSource: string;
|
||||
videoUrl: string;
|
||||
playerReady: boolean; // 播放器是否就绪
|
||||
}
|
||||
|
||||
export function usePlaySync({
|
||||
artPlayerRef,
|
||||
videoId,
|
||||
videoName,
|
||||
videoYear,
|
||||
searchTitle,
|
||||
currentEpisode,
|
||||
currentSource,
|
||||
videoUrl,
|
||||
playerReady,
|
||||
}: UsePlaySyncOptions) {
|
||||
const router = useRouter();
|
||||
const watchRoom = useWatchRoomContextSafe();
|
||||
const syncingRef = useRef(false); // 防止循环同步
|
||||
const lastSyncTimeRef = useRef(0); // 上次同步时间
|
||||
|
||||
// 检查是否在房间内
|
||||
const isInRoom = !!(watchRoom && watchRoom.currentRoom);
|
||||
const isOwner = watchRoom?.isOwner || false;
|
||||
const currentRoom = watchRoom?.currentRoom;
|
||||
const socket = watchRoom?.socket;
|
||||
|
||||
// 广播播放状态给房间内所有人(任何成员都可以触发同步)
|
||||
const broadcastPlayState = useCallback(() => {
|
||||
if (!socket || syncingRef.current || !watchRoom || !isInRoom) return;
|
||||
|
||||
const player = artPlayerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
const state: PlayState = {
|
||||
type: 'play',
|
||||
url: videoUrl,
|
||||
currentTime: player.currentTime || 0,
|
||||
isPlaying: !player.paused,
|
||||
videoId,
|
||||
videoName,
|
||||
videoYear,
|
||||
searchTitle,
|
||||
episode: currentEpisode,
|
||||
source: currentSource,
|
||||
};
|
||||
|
||||
// 使用防抖,避免频繁发送
|
||||
const now = Date.now();
|
||||
if (now - lastSyncTimeRef.current < 1000) return;
|
||||
lastSyncTimeRef.current = now;
|
||||
|
||||
watchRoom.updatePlayState(state);
|
||||
}, [socket, videoUrl, videoId, videoName, videoYear, searchTitle, currentEpisode, currentSource, watchRoom, artPlayerRef, isInRoom]);
|
||||
|
||||
// 接收并同步其他成员的播放状态
|
||||
useEffect(() => {
|
||||
if (!socket || !currentRoom || !isInRoom) {
|
||||
console.log('[PlaySync] Skip setup:', { hasSocket: !!socket, hasRoom: !!currentRoom, isInRoom });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[PlaySync] Setting up event listeners');
|
||||
|
||||
const handlePlayUpdate = (state: PlayState) => {
|
||||
console.log('[PlaySync] Received play:update event:', state);
|
||||
const player = artPlayerRef.current;
|
||||
|
||||
if (!player) {
|
||||
console.warn('[PlaySync] Player not ready for play:update');
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncingRef.current) {
|
||||
console.log('[PlaySync] Skipping play:update - already syncing');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[PlaySync] Processing play update - current state:', {
|
||||
playerPaused: player.paused,
|
||||
statePlaying: state.isPlaying,
|
||||
playerTime: player.currentTime,
|
||||
stateTime: state.currentTime
|
||||
});
|
||||
|
||||
syncingRef.current = true;
|
||||
|
||||
try {
|
||||
// 同步播放状态
|
||||
if (state.isPlaying && player.paused) {
|
||||
console.log('[PlaySync] Starting playback');
|
||||
player.play().catch((err: any) => console.error('[PlaySync] Play error:', err));
|
||||
} else if (!state.isPlaying && !player.paused) {
|
||||
console.log('[PlaySync] Pausing playback');
|
||||
player.pause();
|
||||
}
|
||||
|
||||
// 同步进度(如果差异超过2秒)
|
||||
const timeDiff = Math.abs(player.currentTime - state.currentTime);
|
||||
if (timeDiff > 2) {
|
||||
console.log('[PlaySync] Seeking to:', state.currentTime, '(diff:', timeDiff, 's)');
|
||||
player.currentTime = state.currentTime;
|
||||
}
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
syncingRef.current = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayCommand = () => {
|
||||
console.log('[PlaySync] Received play:play event');
|
||||
const player = artPlayerRef.current;
|
||||
|
||||
if (!player) {
|
||||
console.warn('[PlaySync] Player not ready for play:play');
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncingRef.current) {
|
||||
console.log('[PlaySync] Skipping play:play - already syncing');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[PlaySync] Executing play command');
|
||||
syncingRef.current = true;
|
||||
player.play().catch((err: any) => console.error('[PlaySync] Play error:', err));
|
||||
setTimeout(() => {
|
||||
syncingRef.current = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handlePauseCommand = () => {
|
||||
console.log('[PlaySync] Received play:pause event');
|
||||
const player = artPlayerRef.current;
|
||||
|
||||
if (!player) {
|
||||
console.warn('[PlaySync] Player not ready for play:pause');
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncingRef.current) {
|
||||
console.log('[PlaySync] Skipping play:pause - already syncing');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[PlaySync] Executing pause command');
|
||||
syncingRef.current = true;
|
||||
player.pause();
|
||||
setTimeout(() => {
|
||||
syncingRef.current = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleSeekCommand = (currentTime: number) => {
|
||||
console.log('[PlaySync] Received play:seek event:', currentTime);
|
||||
const player = artPlayerRef.current;
|
||||
|
||||
if (!player) {
|
||||
console.warn('[PlaySync] Player not ready for play:seek');
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncingRef.current) {
|
||||
console.log('[PlaySync] Skipping play:seek - already syncing');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[PlaySync] Executing seek command');
|
||||
syncingRef.current = true;
|
||||
player.currentTime = currentTime;
|
||||
setTimeout(() => {
|
||||
syncingRef.current = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleChangeCommand = (state: PlayState) => {
|
||||
console.log('[PlaySync] Received play:change event:', state);
|
||||
if (syncingRef.current) {
|
||||
console.log('[PlaySync] Skipping play:change - already syncing');
|
||||
return;
|
||||
}
|
||||
|
||||
// 跟随切换视频
|
||||
// 构建完整的 URL 参数
|
||||
const params = new URLSearchParams({
|
||||
id: state.videoId,
|
||||
source: state.source,
|
||||
episode: String(state.episode || 1),
|
||||
});
|
||||
|
||||
// 添加可选参数
|
||||
if (state.videoName) params.set('title', state.videoName);
|
||||
if (state.videoYear) params.set('year', state.videoYear);
|
||||
if (state.searchTitle) params.set('stitle', state.searchTitle);
|
||||
|
||||
const url = `/play?${params.toString()}`;
|
||||
console.log('[PlaySync] Redirecting to:', url);
|
||||
|
||||
syncingRef.current = true;
|
||||
try {
|
||||
// 跳转到新的视频页面
|
||||
router.push(url);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
syncingRef.current = false;
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('play:update', handlePlayUpdate);
|
||||
socket.on('play:play', handlePlayCommand);
|
||||
socket.on('play:pause', handlePauseCommand);
|
||||
socket.on('play:seek', handleSeekCommand);
|
||||
socket.on('play:change', handleChangeCommand);
|
||||
|
||||
console.log('[PlaySync] Event listeners registered');
|
||||
|
||||
return () => {
|
||||
console.log('[PlaySync] Cleaning up event listeners');
|
||||
socket.off('play:update', handlePlayUpdate);
|
||||
socket.off('play:play', handlePlayCommand);
|
||||
socket.off('play:pause', handlePauseCommand);
|
||||
socket.off('play:seek', handleSeekCommand);
|
||||
socket.off('play:change', handleChangeCommand);
|
||||
};
|
||||
}, [socket, currentRoom, artPlayerRef, isInRoom, router]);
|
||||
|
||||
// 监听播放器事件并广播(所有成员都可以触发同步)
|
||||
useEffect(() => {
|
||||
if (!socket || !currentRoom || !isInRoom || !watchRoom) {
|
||||
console.log('[PlaySync] Skip player setup:', { hasSocket: !!socket, hasRoom: !!currentRoom, isInRoom, hasWatchRoom: !!watchRoom });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playerReady) {
|
||||
console.log('[PlaySync] Player not ready yet, waiting...');
|
||||
return;
|
||||
}
|
||||
|
||||
const player = artPlayerRef.current;
|
||||
if (!player) {
|
||||
console.warn('[PlaySync] Player ref is null despite playerReady=true');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[PlaySync] Setting up player event listeners');
|
||||
|
||||
const handlePlay = () => {
|
||||
if (syncingRef.current) {
|
||||
console.log('[PlaySync] Skipping play event - already syncing');
|
||||
return;
|
||||
}
|
||||
console.log('[PlaySync] Play event detected, broadcasting...');
|
||||
watchRoom.play();
|
||||
broadcastPlayState();
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
if (syncingRef.current) {
|
||||
console.log('[PlaySync] Skipping pause event - already syncing');
|
||||
return;
|
||||
}
|
||||
console.log('[PlaySync] Pause event detected, broadcasting...');
|
||||
watchRoom.pause();
|
||||
broadcastPlayState();
|
||||
};
|
||||
|
||||
const handleSeeked = () => {
|
||||
if (syncingRef.current) {
|
||||
console.log('[PlaySync] Skipping seeked event - already syncing');
|
||||
return;
|
||||
}
|
||||
console.log('[PlaySync] Seeked event detected, broadcasting time:', player.currentTime);
|
||||
watchRoom.seekPlayback(player.currentTime);
|
||||
};
|
||||
|
||||
player.on('play', handlePlay);
|
||||
player.on('pause', handlePause);
|
||||
player.on('seeked', handleSeeked);
|
||||
|
||||
// 定期同步播放进度(每5秒)
|
||||
const syncInterval = setInterval(() => {
|
||||
if (syncingRef.current) return;
|
||||
if (player.paused) return; // 暂停时不同步
|
||||
|
||||
console.log('[PlaySync] Periodic sync - broadcasting state');
|
||||
broadcastPlayState();
|
||||
}, 5000);
|
||||
|
||||
console.log('[PlaySync] Player event listeners registered with periodic sync');
|
||||
|
||||
return () => {
|
||||
console.log('[PlaySync] Cleaning up player event listeners');
|
||||
player.off('play', handlePlay);
|
||||
player.off('pause', handlePause);
|
||||
player.off('seeked', handleSeeked);
|
||||
clearInterval(syncInterval);
|
||||
};
|
||||
}, [socket, currentRoom, artPlayerRef, watchRoom, broadcastPlayState, isInRoom, playerReady]);
|
||||
|
||||
// 房主:监听视频/集数/源变化并广播
|
||||
useEffect(() => {
|
||||
if (!isOwner || !socket || !currentRoom || !isInRoom || !watchRoom) return;
|
||||
if (!videoId || !videoUrl) return;
|
||||
|
||||
// 延迟广播,避免初始化时触发
|
||||
const timer = setTimeout(() => {
|
||||
if (syncingRef.current) return;
|
||||
|
||||
const state: PlayState = {
|
||||
type: 'play',
|
||||
url: videoUrl,
|
||||
currentTime: artPlayerRef.current?.currentTime || 0,
|
||||
isPlaying: artPlayerRef.current?.paused === false,
|
||||
videoId,
|
||||
videoName,
|
||||
videoYear,
|
||||
searchTitle,
|
||||
episode: currentEpisode,
|
||||
source: currentSource,
|
||||
};
|
||||
|
||||
console.log('[PlaySync] Video/episode/source changed, broadcasting:', state);
|
||||
watchRoom.changeVideo(state);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOwner, socket, currentRoom, isInRoom, watchRoom, videoId, currentEpisode, currentSource, videoUrl, videoName, videoYear, searchTitle, artPlayerRef]);
|
||||
|
||||
return {
|
||||
isInRoom,
|
||||
isOwner,
|
||||
shouldDisableControls: isInRoom && !isOwner, // 房员禁用某些控制
|
||||
broadcastPlayState, // 导出供手动调用
|
||||
};
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
|
||||
const STORAGE_KEY = 'watch_room_info';
|
||||
|
||||
export function useWatchRoom() {
|
||||
export function useWatchRoom(onRoomDeleted?: (data?: { reason?: string }) => void) {
|
||||
const [socket, setSocket] = useState<WatchRoomSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [currentRoom, setCurrentRoom] = useState<Room | null>(null);
|
||||
@@ -24,6 +24,42 @@ export function useWatchRoom() {
|
||||
const [isOwner, setIsOwner] = useState(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 重新加入房间(自动重连)
|
||||
const rejoinRoom = useCallback(async (info: StoredRoomInfo) => {
|
||||
console.log('[WatchRoom] Auto-rejoining room:', info);
|
||||
try {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
if (!sock || !watchRoomSocketManager.isConnected()) {
|
||||
console.error('[WatchRoom] Not connected, cannot rejoin');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await new Promise<{ room: Room; members: Member[] }>((resolve, reject) => {
|
||||
sock.emit('room:join', {
|
||||
roomId: info.roomId,
|
||||
password: info.password,
|
||||
userName: info.userName,
|
||||
ownerToken: info.ownerToken, // 发送房主令牌
|
||||
}, (response) => {
|
||||
if (response.success && response.room && response.members) {
|
||||
resolve({ room: response.room, members: response.members });
|
||||
} else {
|
||||
reject(new Error(response.error || '重新加入房间失败'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setCurrentRoom(result.room);
|
||||
setMembers(result.members);
|
||||
// 根据服务器返回的 room.ownerId 判断是否是房主
|
||||
setIsOwner(result.room.ownerId === sock.id);
|
||||
console.log('[WatchRoom] Successfully rejoined room:', result.room.name);
|
||||
} catch (error) {
|
||||
console.error('[WatchRoom] Failed to rejoin room:', error);
|
||||
clearStoredRoomInfo();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 连接到服务器
|
||||
const connect = useCallback(async (config: WatchRoomConfig) => {
|
||||
try {
|
||||
@@ -43,7 +79,7 @@ export function useWatchRoom() {
|
||||
console.error('[WatchRoom] Failed to connect:', error);
|
||||
setIsConnected(false);
|
||||
}
|
||||
}, []);
|
||||
}, [rejoinRoom]);
|
||||
|
||||
// 断开连接
|
||||
const disconnect = useCallback(() => {
|
||||
@@ -77,6 +113,7 @@ export function useWatchRoom() {
|
||||
isOwner: true,
|
||||
userName: data.userName,
|
||||
password: data.password,
|
||||
ownerToken: response.room.ownerToken, // 保存房主令牌
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
resolve(response.room);
|
||||
@@ -102,13 +139,16 @@ export function useWatchRoom() {
|
||||
if (response.success && response.room && response.members) {
|
||||
setCurrentRoom(response.room);
|
||||
setMembers(response.members);
|
||||
setIsOwner(false);
|
||||
// 根据服务器返回的 room.ownerId 判断是否是房主
|
||||
const isRoomOwner = response.room.ownerId === sock.id;
|
||||
setIsOwner(isRoomOwner);
|
||||
storeRoomInfo({
|
||||
roomId: response.room.id,
|
||||
roomName: response.room.name,
|
||||
isOwner: false,
|
||||
isOwner: isRoomOwner,
|
||||
userName: data.userName,
|
||||
password: data.password,
|
||||
ownerToken: isRoomOwner ? response.room.ownerToken : undefined,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
resolve({ room: response.room, members: response.members });
|
||||
@@ -163,8 +203,12 @@ export function useWatchRoom() {
|
||||
const updatePlayState = useCallback(
|
||||
(state: PlayState) => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
if (!sock || !isOwner) return;
|
||||
if (!sock || !isOwner) {
|
||||
console.log('[WatchRoom] Cannot update play state:', { hasSocket: !!sock, isOwner });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WatchRoom] Emitting play:update with state:', state);
|
||||
sock.emit('play:update', state);
|
||||
},
|
||||
[isOwner]
|
||||
@@ -174,8 +218,12 @@ export function useWatchRoom() {
|
||||
const seekPlayback = useCallback(
|
||||
(currentTime: number) => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
if (!sock) return;
|
||||
if (!sock) {
|
||||
console.log('[WatchRoom] Cannot seek - no socket');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WatchRoom] Emitting play:seek with time:', currentTime);
|
||||
sock.emit('play:seek', currentTime);
|
||||
},
|
||||
[]
|
||||
@@ -184,16 +232,24 @@ export function useWatchRoom() {
|
||||
// 播放
|
||||
const play = useCallback(() => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
if (!sock) return;
|
||||
if (!sock) {
|
||||
console.log('[WatchRoom] Cannot play - no socket');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WatchRoom] Emitting play:play');
|
||||
sock.emit('play:play');
|
||||
}, []);
|
||||
|
||||
// 暂停
|
||||
const pause = useCallback(() => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
if (!sock) return;
|
||||
if (!sock) {
|
||||
console.log('[WatchRoom] Cannot pause - no socket');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WatchRoom] Emitting play:pause');
|
||||
sock.emit('play:pause');
|
||||
}, []);
|
||||
|
||||
@@ -237,7 +293,12 @@ export function useWatchRoom() {
|
||||
setMembers((prev) => prev.filter((m) => m.id !== userId));
|
||||
});
|
||||
|
||||
socket.on('room:deleted', () => {
|
||||
socket.on('room:deleted', (data?: { reason?: string }) => {
|
||||
console.log('[WatchRoom] Room deleted:', data);
|
||||
|
||||
// 调用回调显示Toast
|
||||
onRoomDeleted?.(data);
|
||||
|
||||
setCurrentRoom(null);
|
||||
setMembers([]);
|
||||
setChatMessages([]);
|
||||
@@ -251,6 +312,20 @@ export function useWatchRoom() {
|
||||
}
|
||||
});
|
||||
|
||||
// 视频切换事件(换集、换源)
|
||||
socket.on('play:change', (state) => {
|
||||
if (currentRoom) {
|
||||
setCurrentRoom((prev) => (prev ? { ...prev, currentState: state } : null));
|
||||
}
|
||||
});
|
||||
|
||||
// 直播频道切换事件
|
||||
socket.on('live:change', (state) => {
|
||||
if (currentRoom) {
|
||||
setCurrentRoom((prev) => (prev ? { ...prev, currentState: state } : null));
|
||||
}
|
||||
});
|
||||
|
||||
// 聊天事件
|
||||
socket.on('chat:message', (message) => {
|
||||
setChatMessages((prev) => [...prev, message]);
|
||||
@@ -271,11 +346,13 @@ export function useWatchRoom() {
|
||||
socket.off('room:member-left');
|
||||
socket.off('room:deleted');
|
||||
socket.off('play:update');
|
||||
socket.off('play:change');
|
||||
socket.off('live:change');
|
||||
socket.off('chat:message');
|
||||
socket.off('connect');
|
||||
socket.off('disconnect');
|
||||
};
|
||||
}, [socket, currentRoom]);
|
||||
}, [socket, currentRoom, onRoomDeleted]);
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
@@ -309,12 +386,6 @@ export function useWatchRoom() {
|
||||
};
|
||||
}
|
||||
|
||||
// 重新加入房间(自动重连)
|
||||
function rejoinRoom(info: StoredRoomInfo) {
|
||||
// 这个函数会在组件中被调用
|
||||
console.log('[WatchRoom] Auto-rejoin:', info);
|
||||
}
|
||||
|
||||
// 存储房间信息到 localStorage
|
||||
function storeRoomInfo(info: StoredRoomInfo) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(info));
|
||||
|
||||
@@ -33,6 +33,7 @@ export class WatchRoomServer {
|
||||
try {
|
||||
const roomId = this.generateRoomId();
|
||||
const userId = socket.id;
|
||||
const ownerToken = this.generateRoomId(); // 生成房主令牌
|
||||
|
||||
const room: Room = {
|
||||
id: roomId,
|
||||
@@ -42,6 +43,7 @@ export class WatchRoomServer {
|
||||
isPublic: data.isPublic,
|
||||
ownerId: userId,
|
||||
ownerName: data.userName,
|
||||
ownerToken: ownerToken, // 保存房主令牌
|
||||
memberCount: 1,
|
||||
currentState: null,
|
||||
createdAt: Date.now(),
|
||||
@@ -324,13 +326,13 @@ export class WatchRoomServer {
|
||||
const now = Date.now();
|
||||
const timeout = 5 * 60 * 1000; // 5分钟
|
||||
|
||||
for (const [roomId, room] of this.rooms.entries()) {
|
||||
this.rooms.forEach((room, roomId) => {
|
||||
// 检查房主是否超时
|
||||
if (now - room.lastOwnerHeartbeat > timeout) {
|
||||
console.log(`[WatchRoom] Room ${roomId} owner timeout, deleting...`);
|
||||
this.deleteRoom(roomId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 30000); // 每30秒检查一次
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Room {
|
||||
isPublic: boolean;
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
ownerToken: string; // 房主令牌,用于重连时验证身份
|
||||
memberCount: number;
|
||||
currentState: PlayState | LiveState | null;
|
||||
createdAt: number;
|
||||
@@ -28,6 +29,8 @@ export interface PlayState {
|
||||
isPlaying: boolean;
|
||||
videoId: string;
|
||||
videoName: string;
|
||||
videoYear?: string;
|
||||
searchTitle?: string;
|
||||
episode?: number;
|
||||
source: string;
|
||||
}
|
||||
@@ -90,6 +93,7 @@ export interface ClientToServerEvents {
|
||||
roomId: string;
|
||||
password?: string;
|
||||
userName: string;
|
||||
ownerToken?: string; // 房主令牌,用于重连时恢复房主身份
|
||||
}, callback: (response: { success: boolean; room?: Room; members?: Member[]; error?: string }) => void) => void;
|
||||
|
||||
'room:leave': () => void;
|
||||
@@ -128,5 +132,6 @@ export interface StoredRoomInfo {
|
||||
isOwner: boolean;
|
||||
userName: string;
|
||||
password?: string;
|
||||
ownerToken?: string; // 房主令牌
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user