mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-13 00:24:44 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0caeaf62c4 | ||
|
|
6b69d35a20 | ||
|
|
75825e1104 | ||
|
|
720f808ed6 | ||
|
|
2abf7bdf42 |
55
README.md
55
README.md
@@ -1,23 +1,51 @@
|
||||
# 文件快传 - P2P文件传输工具
|
||||
|
||||
|
||||
### 在线体验 https://transfer.52python.cn
|
||||
**安全、快速、简单的点对点文件传输解决方案 - 无需注册,即传即用**
|
||||
|
||||
[在线体验](https://transfer.52python.cn) • [GitHub](https://github.com/MatrixSeven/file-transfer-go)
|
||||
|
||||

|
||||
|
||||
> 安全、快速、简单的点对点文件传输解决方案 - 无需注册,即传即用
|
||||
|
||||
|
||||
## ✨ 核心功能
|
||||
## ✨ 核心功能[端到端数据传输完全基于WebRTC的P2P直连]
|
||||
<div align="center">
|
||||
|
||||
- 📁 **文件传输** - 支持多文件同时传输,基于WebRTC的P2P直连
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
- 📁 **文件传输** - 支持多文件同时传输
|
||||
- 📝 **文字传输** - 快速分享文本内容
|
||||
- 🖥️ **桌面共享** - 实时屏幕共享(开发中)
|
||||
- 🖥️ **桌面共享** - 实时屏幕共享
|
||||
- 🔗 **连接状态同步** - 实时连接状态UI同步
|
||||
- 🔒 **端到端加密** - 数据传输安全,服务器不存储文件
|
||||
- 📱 **响应式设计** - 完美适配手机、平板、电脑
|
||||
- 🖥️ **多平台支持** - 支持linux/macos/win 单文件部署
|
||||
|
||||
## 🔄 最近更新日志
|
||||
|
||||
### 2025-08-24
|
||||
- ✅ **文件传输 ACK 确认支持** - 实现了可靠的数据传输机制,每个数据块都需要接收方确认
|
||||
- ✅ **修复组件渲染后重复注册/解绑 bug** - 解决了 React 组件重复渲染导致的处理器反复注册问题
|
||||
- ✅ **修复进度显示 Infinity% 问题** - 解决了除零错误和进度闪烁问题
|
||||
|
||||
### 2025-08-14
|
||||
- ✅ **分离UI组件,统一UI状态** - 重构UI架构,提高代码复用性和可维护性
|
||||
- ✅ **共享底层链接** - 优化WebRTC连接管理,支持多个业务模块共享连接
|
||||
- ✅ **远程桌面支持** - 新增实时屏幕共享功能
|
||||
- ✅ **修复 WebRTC 连接状态异常** - 增强了连接状态错误处理和恢复能力
|
||||
|
||||
## 🚀 技术栈
|
||||
|
||||
|
||||
|
||||
**前端** - Next.js 15 + React 18 + TypeScript + Tailwind CSS
|
||||
**后端** - Go + WebSocket + 内存存储
|
||||
**传输** - WebRTC DataChannel + P2P直连
|
||||
@@ -35,11 +63,14 @@ cd file-transfer-go
|
||||
|
||||
## 🎯 使用方法
|
||||
|
||||
**发送文件**
|
||||
### 发送文件
|
||||
1. 选择文件 → 生成取件码 → 分享6位码
|
||||
|
||||
**接收文件**
|
||||
1. 输入取件码 → 自动连接 → 下载文件
|
||||
### 文字传输
|
||||
1. 输入文字内容 → 生成取件码 → 分享给对方
|
||||
|
||||
### 桌面共享
|
||||
1. 点击共享桌面 → 生成取件码 → 对方输入码观看
|
||||
|
||||
## 📊 项目架构
|
||||
|
||||
@@ -65,4 +96,10 @@ MIT License
|
||||
|
||||
---
|
||||
|
||||
⭐ 觉得有用请给个星标!
|
||||
<div align="center">
|
||||
|
||||
⭐ 如果觉得这个项目对你有帮助,请给个星标!
|
||||
|
||||
[]
|
||||
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
263
chuan-next/src/components/ConnectionStatus.tsx
Normal file
263
chuan-next/src/components/ConnectionStatus.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWebRTCStore } from '@/hooks/webrtc/webRTCStore';
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
// 房间信息 - 只需要这个基本信息
|
||||
currentRoom?: { code: string; role: 'sender' | 'receiver' } | null;
|
||||
// 样式类名
|
||||
className?: string;
|
||||
// 紧凑模式
|
||||
compact?: boolean;
|
||||
// 内联模式 - 只返回状态文本,不包含UI结构
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
// 连接状态枚举
|
||||
const getConnectionStatus = (connection: any, currentRoom: any) => {
|
||||
const isWebSocketConnected = connection?.isWebSocketConnected || false;
|
||||
const isPeerConnected = connection?.isPeerConnected || false;
|
||||
const isConnecting = connection?.isConnecting || false;
|
||||
const error = connection?.error || null;
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
type: 'error' as const,
|
||||
message: '连接失败',
|
||||
detail: error,
|
||||
};
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
return {
|
||||
type: 'connecting' as const,
|
||||
message: '正在连接',
|
||||
detail: '建立房间连接中...',
|
||||
};
|
||||
}
|
||||
|
||||
if (!currentRoom) {
|
||||
return {
|
||||
type: 'disconnected' as const,
|
||||
message: '未连接',
|
||||
detail: '尚未创建房间',
|
||||
};
|
||||
}
|
||||
|
||||
// 如果有房间信息但WebSocket未连接,且不是正在连接状态
|
||||
// 可能是状态更新的时序问题,显示连接中状态
|
||||
if (!isWebSocketConnected && !isConnecting) {
|
||||
return {
|
||||
type: 'connecting' as const,
|
||||
message: '连接中',
|
||||
detail: '正在建立WebSocket连接...',
|
||||
};
|
||||
}
|
||||
|
||||
if (isWebSocketConnected && !isPeerConnected) {
|
||||
return {
|
||||
type: 'room-ready' as const,
|
||||
message: '房间已创建',
|
||||
detail: '等待对方加入并建立P2P连接...',
|
||||
};
|
||||
}
|
||||
|
||||
if (isWebSocketConnected && isPeerConnected) {
|
||||
return {
|
||||
type: 'connected' as const,
|
||||
message: 'P2P连接成功',
|
||||
detail: '可以开始传输',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'unknown' as const,
|
||||
message: '状态未知',
|
||||
detail: '',
|
||||
};
|
||||
};
|
||||
|
||||
// 状态颜色映射
|
||||
const getStatusColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'connected':
|
||||
return 'text-green-600';
|
||||
case 'connecting':
|
||||
case 'room-ready':
|
||||
return 'text-yellow-600';
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
case 'disconnected':
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
// 状态图标
|
||||
const StatusIcon = ({ type, className = 'w-3 h-3' }: { type: string; className?: string }) => {
|
||||
const iconClass = cn('inline-block', className);
|
||||
|
||||
switch (type) {
|
||||
case 'connected':
|
||||
return <div className={cn(iconClass, 'bg-green-500 rounded-full')} />;
|
||||
case 'connecting':
|
||||
case 'room-ready':
|
||||
return (
|
||||
<div className={cn(iconClass, 'bg-yellow-500 rounded-full animate-pulse')} />
|
||||
);
|
||||
case 'error':
|
||||
return <div className={cn(iconClass, 'bg-red-500 rounded-full')} />;
|
||||
case 'disconnected':
|
||||
case 'unknown':
|
||||
default:
|
||||
return <div className={cn(iconClass, 'bg-gray-400 rounded-full')} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取连接状态文字描述
|
||||
const getConnectionStatusText = (connection: any) => {
|
||||
const isWebSocketConnected = connection?.isWebSocketConnected || false;
|
||||
const isPeerConnected = connection?.isPeerConnected || false;
|
||||
const isConnecting = connection?.isConnecting || false;
|
||||
const error = connection?.error || null;
|
||||
|
||||
const wsStatus = isWebSocketConnected ? 'WS已连接' : 'WS未连接';
|
||||
const rtcStatus = isPeerConnected ? 'RTC已连接' :
|
||||
isWebSocketConnected ? 'RTC等待连接' : 'RTC未连接';
|
||||
|
||||
if (error) {
|
||||
return `${wsStatus} ${rtcStatus} - 连接失败`;
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
return `${wsStatus} ${rtcStatus} - 连接中`;
|
||||
}
|
||||
|
||||
if (isPeerConnected) {
|
||||
return `${wsStatus} ${rtcStatus} - P2P连接成功`;
|
||||
}
|
||||
|
||||
return `${wsStatus} ${rtcStatus}`;
|
||||
};
|
||||
|
||||
export function ConnectionStatus(props: ConnectionStatusProps) {
|
||||
const { currentRoom, className, compact = false, inline = false } = props;
|
||||
|
||||
// 使用全局WebRTC状态
|
||||
const webrtcState = useWebRTCStore();
|
||||
|
||||
// 创建connection对象以兼容现有代码
|
||||
const connection = {
|
||||
isWebSocketConnected: webrtcState.isWebSocketConnected,
|
||||
isPeerConnected: webrtcState.isPeerConnected,
|
||||
isConnecting: webrtcState.isConnecting,
|
||||
error: webrtcState.error,
|
||||
};
|
||||
|
||||
// 如果是内联模式,只返回状态文字
|
||||
if (inline) {
|
||||
return <span className={cn('text-sm text-slate-600', className)}>{getConnectionStatusText(connection)}</span>;
|
||||
}
|
||||
|
||||
const status = getConnectionStatus(connection, currentRoom);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={cn('flex items-center', className)}>
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 连接状态指示器 */}
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusIcon
|
||||
type={connection.isWebSocketConnected ? 'connected' : 'disconnected'}
|
||||
className="w-2.5 h-2.5"
|
||||
/>
|
||||
<span className="text-sm text-slate-600 font-medium">WS</span>
|
||||
</div>
|
||||
<span className="text-slate-300 font-medium">|</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusIcon
|
||||
type={connection.isPeerConnected ? 'connected' : 'disconnected'}
|
||||
className="w-2.5 h-2.5"
|
||||
/>
|
||||
<span className="text-sm text-slate-600 font-medium">RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<div className="space-y-2">
|
||||
{/* 主要状态 */}
|
||||
<div className={cn('font-medium text-sm', getStatusColor(status.type))}>
|
||||
{status.message}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{status.detail}
|
||||
</div>
|
||||
|
||||
{/* 详细连接状态 */}
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 font-medium">WS</span>
|
||||
<StatusIcon
|
||||
type={connection.isWebSocketConnected ? 'connected' : 'disconnected'}
|
||||
className="w-2.5 h-2.5"
|
||||
/>
|
||||
<span className={cn(
|
||||
connection.isWebSocketConnected ? 'text-green-600' : 'text-slate-500'
|
||||
)}>
|
||||
{connection.isWebSocketConnected ? '已连接' : '未连接'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-slate-300">|</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 font-medium">RTC</span>
|
||||
<StatusIcon
|
||||
type={connection.isPeerConnected ? 'connected' : 'disconnected'}
|
||||
className="w-2.5 h-2.5"
|
||||
/>
|
||||
<span className={cn(
|
||||
connection.isPeerConnected ? 'text-green-600' : 'text-slate-500'
|
||||
)}>
|
||||
{connection.isPeerConnected ? '已连接' : '未连接'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误信息
|
||||
{connection.error && (
|
||||
<div className="text-xs text-red-600 bg-red-50 rounded p-2">
|
||||
{connection.error}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 简化版本的 Hook,用于快速集成 - 现在已经不需要了,但保留兼容性
|
||||
export function useConnectionStatus(webrtcConnection?: any) {
|
||||
// 这个hook现在不再需要,因为ConnectionStatus组件直接使用底层连接
|
||||
// 但为了向后兼容,保留这个接口
|
||||
return useMemo(() => ({
|
||||
isWebSocketConnected: webrtcConnection?.isWebSocketConnected || false,
|
||||
isPeerConnected: webrtcConnection?.isPeerConnected || false,
|
||||
isConnecting: webrtcConnection?.isConnecting || false,
|
||||
currentRoom: webrtcConnection?.currentRoom || null,
|
||||
error: webrtcConnection?.error || null,
|
||||
}), [
|
||||
webrtcConnection?.isWebSocketConnected,
|
||||
webrtcConnection?.isPeerConnected,
|
||||
webrtcConnection?.isConnecting,
|
||||
webrtcConnection?.currentRoom,
|
||||
webrtcConnection?.error,
|
||||
]);
|
||||
}
|
||||
@@ -3,15 +3,15 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Share, Monitor, Copy, Play, Square, Repeat, Users, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useDesktopShareBusiness } from '@/hooks/webrtc/useDesktopShareBusiness';
|
||||
import DesktopViewer from '@/components/DesktopViewer';
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay';
|
||||
import { Share, Monitor } from 'lucide-react';
|
||||
import WebRTCDesktopReceiver from '@/components/webrtc/WebRTCDesktopReceiver';
|
||||
import WebRTCDesktopSender from '@/components/webrtc/WebRTCDesktopSender';
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
import { useWebRTCStore } from '@/hooks/webrtc/webRTCStore';
|
||||
|
||||
|
||||
interface DesktopShareProps {
|
||||
// 保留向后兼容性的props
|
||||
// 保留向后兼容性的props(已废弃,但保留接口)
|
||||
onStartSharing?: () => Promise<string>;
|
||||
onStopSharing?: () => Promise<void>;
|
||||
onJoinSharing?: (code: string) => Promise<void>;
|
||||
@@ -25,24 +25,22 @@ export default function DesktopShare({
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<'share' | 'view'>('share');
|
||||
const [inputCode, setInputCode] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 使用全局WebRTC状态
|
||||
const webrtcState = useWebRTCStore();
|
||||
|
||||
// 使用桌面共享业务逻辑
|
||||
const desktopShare = useDesktopShareBusiness();
|
||||
|
||||
// 从URL参数中获取初始模式
|
||||
// 从URL参数中获取初始模式和房间代码
|
||||
useEffect(() => {
|
||||
const urlMode = searchParams.get('mode');
|
||||
const type = searchParams.get('type');
|
||||
const urlCode = searchParams.get('code');
|
||||
|
||||
if (type === 'desktop' && urlMode) {
|
||||
if (urlMode === 'send') {
|
||||
setMode('share');
|
||||
} else if (urlMode === 'receive') {
|
||||
setMode('view');
|
||||
// 如果URL中有房间代码,将在DesktopShareReceiver组件中自动加入
|
||||
}
|
||||
}
|
||||
}, [searchParams]);
|
||||
@@ -53,144 +51,32 @@ export default function DesktopShare({
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set('type', 'desktop');
|
||||
currentUrl.searchParams.set('mode', newMode === 'share' ? 'send' : 'receive');
|
||||
// 清除代码参数,避免模式切换时的混乱
|
||||
currentUrl.searchParams.delete('code');
|
||||
router.replace(currentUrl.pathname + currentUrl.search);
|
||||
}, [router]);
|
||||
|
||||
// 复制房间代码
|
||||
const copyCode = useCallback(async (code: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
showToast('房间代码已复制到剪贴板', 'success');
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
showToast('复制失败,请手动复制', 'error');
|
||||
// 获取初始房间代码(用于接收者模式)
|
||||
const getInitialCode = useCallback(() => {
|
||||
const urlMode = searchParams.get('mode');
|
||||
const type = searchParams.get('type');
|
||||
const code = searchParams.get('code');
|
||||
console.log('[DesktopShare] getInitialCode 调用, URL参数:', { type, urlMode, code });
|
||||
|
||||
if (type === 'desktop' && urlMode === 'receive') {
|
||||
const result = code || '';
|
||||
console.log('[DesktopShare] getInitialCode 返回:', result);
|
||||
return result;
|
||||
}
|
||||
}, [showToast]);
|
||||
console.log('[DesktopShare] getInitialCode 返回空字符串');
|
||||
return '';
|
||||
}, [searchParams]);
|
||||
|
||||
// 创建房间
|
||||
const handleCreateRoom = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShare] 用户点击创建房间');
|
||||
|
||||
const roomCode = await desktopShare.createRoom();
|
||||
console.log('[DesktopShare] 房间创建成功:', roomCode);
|
||||
|
||||
showToast(`房间创建成功!代码: ${roomCode}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 创建房间失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 开始桌面共享
|
||||
const handleStartSharing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShare] 用户点击开始桌面共享');
|
||||
|
||||
await desktopShare.startSharing();
|
||||
console.log('[DesktopShare] 桌面共享开始成功');
|
||||
|
||||
showToast('桌面共享已开始', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 开始桌面共享失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 切换桌面
|
||||
const handleSwitchDesktop = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShare] 用户点击切换桌面');
|
||||
|
||||
await desktopShare.switchDesktop();
|
||||
console.log('[DesktopShare] 桌面切换成功');
|
||||
|
||||
showToast('桌面切换成功', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 切换桌面失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 停止桌面共享
|
||||
const handleStopSharing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShare] 用户点击停止桌面共享');
|
||||
|
||||
await desktopShare.stopSharing();
|
||||
console.log('[DesktopShare] 桌面共享停止成功');
|
||||
|
||||
showToast('桌面共享已停止', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 停止桌面共享失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 加入观看
|
||||
const handleJoinViewing = useCallback(async () => {
|
||||
if (!inputCode.trim()) {
|
||||
showToast('请输入房间代码', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShare] 用户加入观看房间:', inputCode);
|
||||
|
||||
await desktopShare.joinSharing(inputCode.trim().toUpperCase());
|
||||
console.log('[DesktopShare] 加入观看成功');
|
||||
|
||||
showToast('已加入桌面共享', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 加入观看失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '加入观看失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, inputCode, showToast]);
|
||||
|
||||
// 停止观看
|
||||
const handleStopViewing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await desktopShare.stopViewing();
|
||||
showToast('已退出桌面共享', 'success');
|
||||
setInputCode('');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 停止观看失败:', error);
|
||||
showToast('退出失败', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 连接状态指示器
|
||||
const getConnectionStatus = () => {
|
||||
if (desktopShare.isConnecting) return { icon: Wifi, text: '连接中...', color: 'text-yellow-600' };
|
||||
if (desktopShare.isPeerConnected) return { icon: Wifi, text: 'P2P已连接', color: 'text-green-600' };
|
||||
if (desktopShare.isWebSocketConnected) return { icon: Users, text: '等待对方加入', color: 'text-blue-600' };
|
||||
return { icon: WifiOff, text: '未连接', color: 'text-gray-600' };
|
||||
};
|
||||
|
||||
const connectionStatus = getConnectionStatus();
|
||||
// 连接状态变化处理 - 现在不需要了,因为使用全局状态
|
||||
const handleConnectionChange = useCallback((connection: any) => {
|
||||
// 这个函数现在可能不需要了,但为了兼容现有的子组件接口,保留它
|
||||
console.log('桌面共享连接状态变化:', connection);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
@@ -216,423 +102,15 @@ export default function DesktopShare({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'share' ? (
|
||||
/* 共享模式 */
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
{!desktopShare.connectionCode ? (
|
||||
// 创建房间前的界面
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">共享桌面</h2>
|
||||
<p className="text-sm text-slate-600">分享您的屏幕给其他人</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${desktopShare.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-slate-400'}`}></div>
|
||||
<span className={desktopShare.isWebSocketConnected ? 'text-blue-600' : 'text-slate-600'}>WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${desktopShare.isPeerConnected ? 'bg-emerald-500 animate-pulse' : 'bg-slate-400'}`}></div>
|
||||
<span className={desktopShare.isPeerConnected ? 'text-emerald-600' : 'text-slate-600'}>RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-full flex items-center justify-center">
|
||||
<Monitor className="w-10 h-10 text-purple-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-800 mb-4">创建桌面共享房间</h3>
|
||||
<p className="text-slate-600 mb-8">创建房间后将生成分享码,等待接收方加入后即可开始桌面共享</p>
|
||||
|
||||
<Button
|
||||
onClick={handleCreateRoom}
|
||||
disabled={isLoading || desktopShare.isConnecting}
|
||||
className="px-8 py-3 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-lg font-medium rounded-xl shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share className="w-5 h-5 mr-2" />
|
||||
创建桌面共享房间
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 房间已创建,显示取件码和等待界面
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">共享桌面</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
{desktopShare.isPeerConnected ? '✅ 接收方已连接,现在可以开始共享桌面' :
|
||||
desktopShare.isWebSocketConnected ? '⏳ 房间已创建,等待接收方加入建立P2P连接' :
|
||||
'⚠️ 等待连接'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${desktopShare.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-red-500'}`}></div>
|
||||
<span className={desktopShare.isWebSocketConnected ? 'text-blue-600' : 'text-red-600'}>WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${desktopShare.isPeerConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-400'}`}></div>
|
||||
<span className={desktopShare.isPeerConnected ? 'text-emerald-600' : 'text-orange-600'}>RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 桌面共享控制区域 */}
|
||||
{desktopShare.canStartSharing && (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-slate-800 flex items-center">
|
||||
<Monitor className="w-5 h-5 mr-2" />
|
||||
桌面共享控制
|
||||
</h4>
|
||||
{desktopShare.isSharing && (
|
||||
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md">
|
||||
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
|
||||
<span className="font-medium">共享中</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!desktopShare.isSharing ? (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleStartSharing}
|
||||
disabled={isLoading || !desktopShare.isPeerConnected}
|
||||
className={`w-full px-8 py-3 text-lg font-medium rounded-xl shadow-lg ${
|
||||
desktopShare.isPeerConnected
|
||||
? 'bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
{isLoading ? '启动中...' : '选择并开始共享桌面'}
|
||||
</Button>
|
||||
|
||||
{!desktopShare.isPeerConnected && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
等待接收方加入房间建立P2P连接...
|
||||
</p>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
|
||||
<span className="text-sm text-purple-600">正在等待连接</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center space-x-2 text-green-600 mb-4">
|
||||
<Play className="w-5 h-5" />
|
||||
<span className="font-semibold">桌面共享进行中</span>
|
||||
</div>
|
||||
<div className="flex justify-center space-x-3">
|
||||
<Button
|
||||
onClick={handleSwitchDesktop}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Repeat className="w-4 h-4 mr-2" />
|
||||
{isLoading ? '切换中...' : '切换桌面'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStopSharing}
|
||||
disabled={isLoading}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{isLoading ? '停止中...' : '停止共享'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 取件码显示 - 和文件传输一致的风格 */}
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
{/* 左上角状态提示 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">房间码生成成功!</h3>
|
||||
<p className="text-sm text-slate-600">分享以下信息给观看方</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间区域:取件码 + 分隔线 + 二维码 */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
|
||||
{/* 左侧:取件码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">房间代码</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<div className="text-2xl font-bold font-mono bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent tracking-wider">
|
||||
{desktopShare.connectionCode}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => copyCode(desktopShare.connectionCode)}
|
||||
className="w-full px-4 py-2.5 bg-purple-500 hover:bg-purple-600 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3"
|
||||
>
|
||||
复制房间代码
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
|
||||
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
|
||||
|
||||
{/* 右侧:二维码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">扫码观看</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<QRCodeDisplay
|
||||
value={`${typeof window !== 'undefined' ? window.location.origin : ''}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`}
|
||||
size={120}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
|
||||
使用手机扫码快速观看
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:观看链接 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
|
||||
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
|
||||
{`${typeof window !== 'undefined' ? window.location.origin : ''}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const link = `${window.location.origin}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
showToast('观看链接已复制', 'success');
|
||||
}}
|
||||
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
|
||||
>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* 观看模式 */
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
<div className="space-y-6">
|
||||
{!desktopShare.isViewing ? (
|
||||
// 输入房间代码界面 - 与文本消息风格一致
|
||||
<div>
|
||||
<div className="flex items-center mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">输入房间代码</h2>
|
||||
<p className="text-sm text-slate-600">请输入6位房间代码来观看桌面共享</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleJoinViewing(); }} className="space-y-4 sm:space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inputCode}
|
||||
onChange={(e) => setInputCode(e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase())}
|
||||
placeholder="请输入房间代码"
|
||||
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-purple-500 focus:ring-purple-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
|
||||
maxLength={6}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-xs sm:text-sm text-slate-500">
|
||||
{inputCode.length}/6 位
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={inputCode.length !== 6 || isLoading}
|
||||
className="w-full h-10 sm:h-12 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-base sm:text-lg font-medium rounded-xl shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-105 disabled:opacity-50 disabled:scale-100"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>连接中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Monitor className="w-5 h-5" />
|
||||
<span>加入观看</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
// 已连接,显示桌面观看界面
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">桌面观看</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
<span className="text-emerald-600">✅ 已连接,正在观看桌面共享</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接成功状态 */}
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-6">
|
||||
<h4 className="font-semibold text-emerald-800 mb-1">已连接到桌面共享房间</h4>
|
||||
<p className="text-emerald-700">房间代码: {inputCode}</p>
|
||||
</div>
|
||||
|
||||
{/* 观看中的控制面板 */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-white rounded-lg p-3 shadow-lg border flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span className="font-semibold">观看中</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleStopViewing}
|
||||
disabled={isLoading}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{isLoading ? '退出中...' : '退出观看'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 桌面显示区域 */}
|
||||
{desktopShare.remoteStream ? (
|
||||
<DesktopViewer
|
||||
stream={desktopShare.remoteStream}
|
||||
isConnected={desktopShare.isViewing}
|
||||
connectionCode={inputCode}
|
||||
onDisconnect={handleStopViewing}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-8 border border-slate-200">
|
||||
<div className="text-center">
|
||||
<Monitor className="w-16 h-16 mx-auto text-slate-400 mb-4" />
|
||||
<p className="text-slate-600 mb-2">等待接收桌面画面...</p>
|
||||
<p className="text-sm text-slate-500">发送方开始共享后,桌面画面将在这里显示</p>
|
||||
|
||||
<div className="flex items-center justify-center space-x-2 mt-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
|
||||
<span className="text-sm text-purple-600">等待桌面流...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误显示 */}
|
||||
{desktopShare.error && (
|
||||
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-600 text-sm">{desktopShare.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 调试信息 */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => setShowDebug(!showDebug)}
|
||||
className="text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showDebug ? '隐藏' : '显示'}调试信息
|
||||
</button>
|
||||
|
||||
{showDebug && (
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded text-xs text-gray-600 space-y-1">
|
||||
<div>WebSocket连接: {desktopShare.isWebSocketConnected ? '✅' : '❌'}</div>
|
||||
<div>P2P连接: {desktopShare.isPeerConnected ? '✅' : '❌'}</div>
|
||||
<div>房间代码: {desktopShare.connectionCode || '未创建'}</div>
|
||||
<div>共享状态: {desktopShare.isSharing ? '进行中' : '未共享'}</div>
|
||||
<div>观看状态: {desktopShare.isViewing ? '观看中' : '未观看'}</div>
|
||||
<div>等待对方: {desktopShare.isWaitingForPeer ? '是' : '否'}</div>
|
||||
<div>远程流: {desktopShare.remoteStream ? '已接收' : '无'}</div>
|
||||
</div>
|
||||
{/* 根据模式渲染对应的组件 */}
|
||||
<div>
|
||||
{mode === 'share' ? (
|
||||
<WebRTCDesktopSender onConnectionChange={handleConnectionChange} />
|
||||
) : (
|
||||
<WebRTCDesktopReceiver
|
||||
initialCode={getInitialCode()}
|
||||
onConnectionChange={handleConnectionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Monitor, Maximize, Minimize, Volume2, VolumeX, Settings, X } from 'lucide-react';
|
||||
import { Monitor, Maximize, Minimize, Volume2, VolumeX, Settings, X, Play } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface DesktopViewerProps {
|
||||
@@ -22,6 +22,9 @@ export default function DesktopViewer({
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [needsUserInteraction, setNeedsUserInteraction] = useState(false);
|
||||
const hasAttemptedAutoplayRef = useRef(false);
|
||||
const [videoStats, setVideoStats] = useState<{
|
||||
resolution: string;
|
||||
fps: number;
|
||||
@@ -39,9 +42,69 @@ export default function DesktopViewer({
|
||||
|
||||
videoRef.current.srcObject = stream;
|
||||
console.log('[DesktopViewer] ✅ 视频元素已设置流');
|
||||
|
||||
// 重置状态
|
||||
hasAttemptedAutoplayRef.current = false;
|
||||
setNeedsUserInteraction(false);
|
||||
setIsPlaying(false);
|
||||
|
||||
// 添加事件监听器来调试视频加载
|
||||
const video = videoRef.current;
|
||||
const handleLoadStart = () => console.log('[DesktopViewer] 📹 视频开始加载');
|
||||
const handleLoadedMetadata = () => {
|
||||
console.log('[DesktopViewer] 📹 视频元数据已加载');
|
||||
console.log('[DesktopViewer] 📹 视频尺寸:', video.videoWidth, 'x', video.videoHeight);
|
||||
};
|
||||
const handleCanPlay = () => {
|
||||
console.log('[DesktopViewer] 📹 视频可以开始播放');
|
||||
// 只在还未尝试过自动播放时才尝试
|
||||
if (!hasAttemptedAutoplayRef.current) {
|
||||
hasAttemptedAutoplayRef.current = true;
|
||||
video.play()
|
||||
.then(() => {
|
||||
console.log('[DesktopViewer] ✅ 视频自动播放成功');
|
||||
setIsPlaying(true);
|
||||
setNeedsUserInteraction(false);
|
||||
})
|
||||
.catch(e => {
|
||||
console.log('[DesktopViewer] 📹 自动播放被阻止,需要用户交互:', e.message);
|
||||
setIsPlaying(false);
|
||||
setNeedsUserInteraction(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
const handlePlay = () => {
|
||||
console.log('[DesktopViewer] 📹 视频开始播放');
|
||||
setIsPlaying(true);
|
||||
setNeedsUserInteraction(false);
|
||||
};
|
||||
const handlePause = () => {
|
||||
console.log('[DesktopViewer] 📹 视频暂停');
|
||||
setIsPlaying(false);
|
||||
};
|
||||
const handleError = (e: Event) => console.error('[DesktopViewer] 📹 视频播放错误:', e);
|
||||
|
||||
video.addEventListener('loadstart', handleLoadStart);
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.addEventListener('canplay', handleCanPlay);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('error', handleError);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadstart', handleLoadStart);
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('canplay', handleCanPlay);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('error', handleError);
|
||||
};
|
||||
} else if (videoRef.current && !stream) {
|
||||
console.log('[DesktopViewer] ❌ 清除视频流');
|
||||
videoRef.current.srcObject = null;
|
||||
setIsPlaying(false);
|
||||
setNeedsUserInteraction(false);
|
||||
hasAttemptedAutoplayRef.current = false;
|
||||
}
|
||||
}, [stream]);
|
||||
|
||||
@@ -176,6 +239,21 @@ export default function DesktopViewer({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 手动播放视频
|
||||
const handleManualPlay = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play()
|
||||
.then(() => {
|
||||
console.log('[DesktopViewer] ✅ 手动播放成功');
|
||||
setIsPlaying(true);
|
||||
setNeedsUserInteraction(false);
|
||||
})
|
||||
.catch(e => {
|
||||
console.error('[DesktopViewer] ❌ 手动播放失败:', e);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -223,6 +301,19 @@ export default function DesktopViewer({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 需要用户交互的播放覆盖层 - 只在自动播放尝试失败后显示 */}
|
||||
{hasAttemptedAutoplayRef.current && needsUserInteraction && !isPlaying && (
|
||||
<div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center text-white z-10">
|
||||
<div className="text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors cursor-pointer" onClick={handleManualPlay}>
|
||||
<Play className="w-10 h-10 text-white ml-1" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">点击播放桌面共享</h3>
|
||||
<p className="text-sm opacity-75">浏览器需要用户交互才能开始播放媒体</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 连接状态覆盖层 */}
|
||||
{!isConnected && (
|
||||
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center text-white">
|
||||
@@ -244,8 +335,8 @@ export default function DesktopViewer({
|
||||
{/* 左侧信息 */}
|
||||
<div className="flex items-center space-x-4 text-white text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||
<span>桌面共享中</span>
|
||||
<div className={`w-2 h-2 rounded-full ${isPlaying ? 'bg-green-500 animate-pulse' : 'bg-yellow-500'}`}></div>
|
||||
<span>{isPlaying ? '桌面共享中' : needsUserInteraction ? '等待播放' : '连接中'}</span>
|
||||
</div>
|
||||
{videoStats.resolution !== '0x0' && (
|
||||
<>
|
||||
|
||||
123
chuan-next/src/components/RoomInfoDisplay.tsx
Normal file
123
chuan-next/src/components/RoomInfoDisplay.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface RoomInfoDisplayProps {
|
||||
// 房间信息
|
||||
code: string;
|
||||
link: string;
|
||||
|
||||
// 显示配置
|
||||
icon: LucideIcon;
|
||||
iconColor?: string; // 图标背景渐变色,如 'from-emerald-500 to-teal-500'
|
||||
codeColor?: string; // 代码文字渐变色,如 'from-emerald-600 to-teal-600'
|
||||
|
||||
// 文案配置
|
||||
title: string; // 如 "取件码生成成功!" 或 "房间码生成成功!"
|
||||
subtitle: string; // 如 "分享以下信息给接收方" 或 "分享以下信息给观看方"
|
||||
codeLabel: string; // 如 "取件码" 或 "房间代码"
|
||||
qrLabel: string; // 如 "扫码传输" 或 "扫码观看"
|
||||
copyButtonText: string; // 如 "复制取件码" 或 "复制房间代码"
|
||||
copyButtonColor?: string; // 复制按钮颜色,如 'bg-emerald-500 hover:bg-emerald-600'
|
||||
qrButtonText: string; // 如 "使用手机扫码快速访问" 或 "使用手机扫码快速观看"
|
||||
linkButtonText: string; // 如 "复制取件链接" 或 "复制观看链接"
|
||||
|
||||
// 事件回调
|
||||
onCopyCode: () => void;
|
||||
onCopyLink: () => void;
|
||||
|
||||
// 样式配置
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function RoomInfoDisplay({
|
||||
code,
|
||||
link,
|
||||
icon: Icon,
|
||||
iconColor = 'from-emerald-500 to-teal-500',
|
||||
codeColor = 'from-emerald-600 to-teal-600',
|
||||
title,
|
||||
subtitle,
|
||||
codeLabel,
|
||||
qrLabel,
|
||||
copyButtonText,
|
||||
copyButtonColor = 'bg-emerald-500 hover:bg-emerald-600',
|
||||
qrButtonText,
|
||||
linkButtonText,
|
||||
onCopyCode,
|
||||
onCopyLink,
|
||||
className = ''
|
||||
}: RoomInfoDisplayProps) {
|
||||
return (
|
||||
<div className={`border-t border-slate-200 pt-6 ${className}`}>
|
||||
{/* 左上角状态提示 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 bg-gradient-to-br ${iconColor} rounded-xl flex items-center justify-center`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">{title}</h3>
|
||||
<p className="text-sm text-slate-600">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间区域:代码 + 分隔线 + 二维码 */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
|
||||
{/* 左侧:代码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">{codeLabel}</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<div className={`text-2xl font-bold font-mono bg-gradient-to-r ${codeColor} bg-clip-text text-transparent tracking-wider`}>
|
||||
{code}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onCopyCode}
|
||||
className={`w-full px-4 py-2.5 ${copyButtonColor} text-white rounded-lg font-medium shadow transition-all duration-200 mt-3`}
|
||||
>
|
||||
{copyButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
|
||||
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
|
||||
|
||||
{/* 右侧:二维码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">{qrLabel}</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<QRCodeDisplay
|
||||
value={link}
|
||||
size={120}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
|
||||
{qrButtonText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:链接 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
|
||||
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
|
||||
{link}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onCopyLink}
|
||||
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
|
||||
>
|
||||
{linkButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useSharedWebRTCManager } from '@/hooks/webrtc/useSharedWebRTCManager';
|
||||
import { useFileTransferBusiness } from '@/hooks/webrtc/useFileTransferBusiness';
|
||||
@@ -42,8 +42,9 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
const urlProcessedRef = useRef(false); // 使用 ref 防止重复处理 URL
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 创建共享连接
|
||||
// 创建共享连接 - 使用 useMemo 稳定引用
|
||||
const connection = useSharedWebRTCManager();
|
||||
const stableConnection = useMemo(() => connection, [connection.isConnected, connection.isConnecting, connection.isWebSocketConnected, connection.error]);
|
||||
|
||||
// 使用共享连接创建业务层
|
||||
const {
|
||||
@@ -60,7 +61,7 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
onFileListReceived,
|
||||
onFileRequested,
|
||||
onFileProgress
|
||||
} = useFileTransferBusiness(connection);
|
||||
} = useFileTransferBusiness(stableConnection);
|
||||
|
||||
// 加入房间 (接收模式) - 提前定义以供 useEffect 使用
|
||||
const joinRoom = useCallback(async (code: string) => {
|
||||
@@ -332,12 +333,12 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
}
|
||||
|
||||
const code = data.code;
|
||||
setPickupCode(code);
|
||||
|
||||
console.log('房间创建成功,取件码:', code);
|
||||
|
||||
// 连接WebRTC作为发送方
|
||||
connect(code, 'sender');
|
||||
// 先连接WebRTC作为发送方,再设置取件码
|
||||
// 这样可以确保UI状态与连接状态同步
|
||||
await connect(code, 'sender');
|
||||
setPickupCode(code);
|
||||
|
||||
showToast(`房间创建成功,取件码: ${code}`, "success");
|
||||
} catch (error) {
|
||||
@@ -846,6 +847,8 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
|
||||
{mode === 'send' ? (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
{/* 连接状态显示 */}
|
||||
|
||||
<WebRTCFileUpload
|
||||
selectedFiles={selectedFiles}
|
||||
fileList={fileList}
|
||||
@@ -860,12 +863,12 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
onClearFiles={clearFiles}
|
||||
onReset={resetRoom}
|
||||
disabled={!!currentTransferFile}
|
||||
isConnected={isConnected}
|
||||
isWebSocketConnected={isWebSocketConnected}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
|
||||
|
||||
<WebRTCFileReceive
|
||||
onJoinRoom={joinRoom}
|
||||
files={fileList}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Send, Download, X } from 'lucide-react';
|
||||
import { WebRTCTextSender } from '@/components/webrtc/WebRTCTextSender';
|
||||
import { WebRTCTextReceiver } from '@/components/webrtc/WebRTCTextReceiver';
|
||||
import { useWebRTCStore } from '@/hooks/webrtc/webRTCStore';
|
||||
|
||||
export const WebRTCTextImageTransfer: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -15,6 +16,9 @@ export const WebRTCTextImageTransfer: React.FC = () => {
|
||||
const [mode, setMode] = useState<'send' | 'receive'>('send');
|
||||
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
|
||||
// 使用全局WebRTC状态
|
||||
const webrtcState = useWebRTCStore();
|
||||
|
||||
// 从URL参数中获取初始模式
|
||||
useEffect(() => {
|
||||
@@ -32,7 +36,7 @@ export const WebRTCTextImageTransfer: React.FC = () => {
|
||||
}, [searchParams, hasProcessedInitialUrl]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = (newMode: 'send' | 'receive') => {
|
||||
const updateMode = useCallback((newMode: 'send' | 'receive') => {
|
||||
console.log('=== 切换模式 ===', newMode);
|
||||
|
||||
setMode(newMode);
|
||||
@@ -45,10 +49,10 @@ export const WebRTCTextImageTransfer: React.FC = () => {
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
}, [searchParams, router]);
|
||||
|
||||
// 重新开始函数
|
||||
const handleRestart = () => {
|
||||
const handleRestart = useCallback(() => {
|
||||
setPreviewImage(null);
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -56,10 +60,21 @@ export const WebRTCTextImageTransfer: React.FC = () => {
|
||||
params.set('mode', mode);
|
||||
params.delete('code');
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
}, [searchParams, mode, router]);
|
||||
|
||||
const code = searchParams.get('code') || '';
|
||||
|
||||
// 连接状态变化处理 - 现在不需要了,因为使用全局状态
|
||||
const handleConnectionChange = useCallback((connection: any) => {
|
||||
// 这个函数现在可能不需要了,但为了兼容现有的子组件接口,保留它
|
||||
console.log('连接状态变化:', connection);
|
||||
}, []);
|
||||
|
||||
// 关闭图片预览
|
||||
const closePreview = useCallback(() => {
|
||||
setPreviewImage(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 模式切换 */}
|
||||
@@ -85,24 +100,31 @@ export const WebRTCTextImageTransfer: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-4 sm:p-6 animate-fade-in-up">
|
||||
|
||||
|
||||
{mode === 'send' ? (
|
||||
<WebRTCTextSender onRestart={handleRestart} onPreviewImage={setPreviewImage} />
|
||||
<WebRTCTextSender
|
||||
onRestart={handleRestart}
|
||||
onPreviewImage={setPreviewImage}
|
||||
onConnectionChange={handleConnectionChange}
|
||||
/>
|
||||
) : (
|
||||
<WebRTCTextReceiver
|
||||
initialCode={code}
|
||||
onPreviewImage={setPreviewImage}
|
||||
onRestart={handleRestart}
|
||||
onConnectionChange={handleConnectionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图片预览模态框 */}
|
||||
{previewImage && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" onClick={() => setPreviewImage(null)}>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" onClick={closePreview}>
|
||||
<div className="relative max-w-4xl max-h-4xl">
|
||||
<img src={previewImage} alt="预览" className="max-w-full max-h-full" />
|
||||
<Button
|
||||
onClick={() => setPreviewImage(null)}
|
||||
onClick={closePreview}
|
||||
className="absolute top-4 right-4 bg-white text-black hover:bg-gray-200"
|
||||
size="sm"
|
||||
>
|
||||
|
||||
241
chuan-next/src/components/webrtc/WebRTCDesktopReceiver.tsx
Normal file
241
chuan-next/src/components/webrtc/WebRTCDesktopReceiver.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Monitor, Square } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useDesktopShareBusiness } from '@/hooks/webrtc/useDesktopShareBusiness';
|
||||
import DesktopViewer from '@/components/DesktopViewer';
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
|
||||
interface WebRTCDesktopReceiverProps {
|
||||
className?: string;
|
||||
initialCode?: string; // 支持从URL参数传入的房间代码
|
||||
onConnectionChange?: (connection: any) => void;
|
||||
}
|
||||
|
||||
export default function WebRTCDesktopReceiver({ className, initialCode, onConnectionChange }: WebRTCDesktopReceiverProps) {
|
||||
const [inputCode, setInputCode] = useState(initialCode || '');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
const hasTriedAutoJoin = React.useRef(false); // 添加 ref 来跟踪是否已尝试自动加入
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 使用桌面共享业务逻辑
|
||||
const desktopShare = useDesktopShareBusiness();
|
||||
|
||||
// 通知父组件连接状态变化
|
||||
useEffect(() => {
|
||||
if (onConnectionChange && desktopShare.webRTCConnection) {
|
||||
onConnectionChange(desktopShare.webRTCConnection);
|
||||
}
|
||||
}, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]);
|
||||
|
||||
// 加入观看
|
||||
const handleJoinViewing = useCallback(async () => {
|
||||
if (!inputCode.trim()) {
|
||||
showToast('请输入房间代码', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShareReceiver] 用户加入观看房间:', inputCode);
|
||||
|
||||
await desktopShare.joinSharing(inputCode.trim().toUpperCase());
|
||||
console.log('[DesktopShareReceiver] 加入观看成功');
|
||||
|
||||
showToast('已加入桌面共享', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareReceiver] 加入观看失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '加入观看失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, inputCode, showToast]);
|
||||
|
||||
// 停止观看
|
||||
const handleStopViewing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await desktopShare.stopViewing();
|
||||
showToast('已退出桌面共享', 'success');
|
||||
setInputCode('');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareReceiver] 停止观看失败:', error);
|
||||
showToast('退出失败', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 如果有初始代码且还未加入观看,自动尝试加入
|
||||
React.useEffect(() => {
|
||||
console.log('[WebRTCDesktopReceiver] useEffect 触发, 参数:', {
|
||||
initialCode,
|
||||
isViewing: desktopShare.isViewing,
|
||||
isConnecting: desktopShare.isConnecting,
|
||||
hasTriedAutoJoin: hasTriedAutoJoin.current
|
||||
});
|
||||
|
||||
const autoJoin = async () => {
|
||||
if (initialCode && !desktopShare.isViewing && !desktopShare.isConnecting && !hasTriedAutoJoin.current) {
|
||||
hasTriedAutoJoin.current = true;
|
||||
console.log('[WebRTCDesktopReceiver] 检测到初始代码,自动加入观看:', initialCode);
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await desktopShare.joinSharing(initialCode.trim().toUpperCase());
|
||||
console.log('[WebRTCDesktopReceiver] 自动加入观看成功');
|
||||
showToast('已加入桌面共享', 'success');
|
||||
} catch (error) {
|
||||
console.error('[WebRTCDesktopReceiver] 自动加入观看失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '加入观看失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
console.log('[WebRTCDesktopReceiver] 不满足自动加入条件:', {
|
||||
hasInitialCode: !!initialCode,
|
||||
notViewing: !desktopShare.isViewing,
|
||||
notConnecting: !desktopShare.isConnecting,
|
||||
notTriedBefore: !hasTriedAutoJoin.current
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
autoJoin();
|
||||
}, [initialCode, desktopShare.isViewing, desktopShare.isConnecting]); // 移除了 desktopShare.joinSharing 和 showToast
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 sm:space-y-6 ${className || ''}`}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
<div className="space-y-6">
|
||||
{!desktopShare.isViewing ? (
|
||||
// 输入房间代码界面 - 与文本消息风格一致
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">输入房间代码</h2>
|
||||
<p className="text-sm text-slate-600">请输入6位房间代码来观看桌面共享</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionStatus
|
||||
currentRoom={desktopShare.connectionCode ? { code: desktopShare.connectionCode, role: 'receiver' } : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleJoinViewing(); }} className="space-y-4 sm:space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inputCode}
|
||||
onChange={(e) => setInputCode(e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase())}
|
||||
placeholder="请输入房间代码"
|
||||
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-purple-500 focus:ring-purple-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
|
||||
maxLength={6}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-xs sm:text-sm text-slate-500">
|
||||
{inputCode.length}/6 位
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={inputCode.length !== 6 || isLoading}
|
||||
className="w-full h-10 sm:h-12 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-base sm:text-lg font-medium rounded-xl shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-105 disabled:opacity-50 disabled:scale-100"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>连接中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Monitor className="w-5 h-5" />
|
||||
<span>加入观看</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
// 已连接,显示桌面观看界面
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">桌面观看</h3>
|
||||
<p className="text-sm text-slate-600">房间代码: {inputCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接状态 */}
|
||||
<ConnectionStatus
|
||||
currentRoom={{ code: inputCode, role: 'receiver' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 观看中的控制面板 */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-white rounded-lg p-3 shadow-lg border flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span className="font-semibold">观看中</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleStopViewing}
|
||||
disabled={isLoading}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{isLoading ? '退出中...' : '退出观看'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 桌面显示区域 */}
|
||||
{desktopShare.remoteStream ? (
|
||||
<DesktopViewer
|
||||
stream={desktopShare.remoteStream}
|
||||
isConnected={desktopShare.isViewing}
|
||||
connectionCode={inputCode}
|
||||
onDisconnect={handleStopViewing}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-8 border border-slate-200">
|
||||
<div className="text-center">
|
||||
<Monitor className="w-16 h-16 mx-auto text-slate-400 mb-4" />
|
||||
<p className="text-slate-600 mb-2">等待接收桌面画面...</p>
|
||||
<p className="text-sm text-slate-500">发送方开始共享后,桌面画面将在这里显示</p>
|
||||
|
||||
<div className="flex items-center justify-center space-x-2 mt-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
|
||||
<span className="text-sm text-purple-600">等待桌面流...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
290
chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx
Normal file
290
chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Share, Monitor, Copy, Play, Square, Repeat, Users, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useDesktopShareBusiness } from '@/hooks/webrtc/useDesktopShareBusiness';
|
||||
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
|
||||
interface WebRTCDesktopSenderProps {
|
||||
className?: string;
|
||||
onConnectionChange?: (connection: any) => void;
|
||||
}
|
||||
|
||||
export default function WebRTCDesktopSender({ className, onConnectionChange }: WebRTCDesktopSenderProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 使用桌面共享业务逻辑
|
||||
const desktopShare = useDesktopShareBusiness();
|
||||
|
||||
// 通知父组件连接状态变化
|
||||
useEffect(() => {
|
||||
if (onConnectionChange && desktopShare.webRTCConnection) {
|
||||
onConnectionChange(desktopShare.webRTCConnection);
|
||||
}
|
||||
}, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]);
|
||||
|
||||
// 复制房间代码
|
||||
const copyCode = useCallback(async (code: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
showToast('房间代码已复制到剪贴板', 'success');
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
showToast('复制失败,请手动复制', 'error');
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
// 创建房间
|
||||
const handleCreateRoom = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShareSender] 用户点击创建房间');
|
||||
|
||||
const roomCode = await desktopShare.createRoom();
|
||||
console.log('[DesktopShareSender] 房间创建成功:', roomCode);
|
||||
|
||||
showToast(`房间创建成功!代码: ${roomCode}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareSender] 创建房间失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 开始桌面共享
|
||||
const handleStartSharing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShareSender] 用户点击开始桌面共享');
|
||||
|
||||
await desktopShare.startSharing();
|
||||
console.log('[DesktopShareSender] 桌面共享开始成功');
|
||||
|
||||
showToast('桌面共享已开始', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareSender] 开始桌面共享失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 切换桌面
|
||||
const handleSwitchDesktop = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShareSender] 用户点击切换桌面');
|
||||
|
||||
await desktopShare.switchDesktop();
|
||||
console.log('[DesktopShareSender] 桌面切换成功');
|
||||
|
||||
showToast('桌面切换成功', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareSender] 切换桌面失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 停止桌面共享
|
||||
const handleStopSharing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShareSender] 用户点击停止桌面共享');
|
||||
|
||||
await desktopShare.stopSharing();
|
||||
console.log('[DesktopShareSender] 桌面共享停止成功');
|
||||
|
||||
showToast('桌面共享已停止', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareSender] 停止桌面共享失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 sm:space-y-6 ${className || ''}`}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
{!desktopShare.connectionCode ? (
|
||||
// 创建房间前的界面
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">共享桌面</h2>
|
||||
<p className="text-sm text-slate-600">分享您的屏幕给其他人</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionStatus
|
||||
currentRoom={desktopShare.connectionCode ? { code: desktopShare.connectionCode, role: 'sender' } : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-full flex items-center justify-center">
|
||||
<Monitor className="w-10 h-10 text-purple-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">创建桌面共享房间</h3>
|
||||
<p className="text-slate-600 mb-8">创建房间后将生成分享码,等待接收方加入后即可开始桌面共享</p>
|
||||
|
||||
<Button
|
||||
onClick={handleCreateRoom}
|
||||
disabled={isLoading || desktopShare.isConnecting}
|
||||
className="px-8 py-3 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-lg font-medium rounded-xl shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share className="w-5 h-5 mr-2" />
|
||||
创建桌面共享房间
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 房间已创建,显示取件码和等待界面
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">共享桌面</h2>
|
||||
<p className="text-sm text-slate-600">房间代码: {desktopShare.connectionCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionStatus
|
||||
currentRoom={{ code: desktopShare.connectionCode, role: 'sender' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 桌面共享控制区域 */}
|
||||
{desktopShare.canStartSharing && (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-slate-800 flex items-center">
|
||||
<Monitor className="w-5 h-5 mr-2" />
|
||||
桌面共享控制
|
||||
</h4>
|
||||
{desktopShare.isSharing && (
|
||||
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md">
|
||||
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
|
||||
<span className="font-medium">共享中</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!desktopShare.isSharing ? (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleStartSharing}
|
||||
disabled={isLoading || !desktopShare.isPeerConnected}
|
||||
className={`w-full px-8 py-3 text-lg font-medium rounded-xl shadow-lg ${
|
||||
desktopShare.isPeerConnected
|
||||
? 'bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
{isLoading ? '启动中...' : '选择并开始共享桌面'}
|
||||
</Button>
|
||||
|
||||
{!desktopShare.isPeerConnected && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
等待接收方加入房间建立P2P连接...
|
||||
</p>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
|
||||
<span className="text-sm text-purple-600">正在等待连接</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center space-x-2 text-green-600 mb-4">
|
||||
<Play className="w-5 h-5" />
|
||||
<span className="font-semibold">桌面共享进行中</span>
|
||||
</div>
|
||||
<div className="flex justify-center space-x-3">
|
||||
<Button
|
||||
onClick={handleSwitchDesktop}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Repeat className="w-4 h-4 mr-2" />
|
||||
{isLoading ? '切换中...' : '切换桌面'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStopSharing}
|
||||
disabled={isLoading}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{isLoading ? '停止中...' : '停止共享'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 房间信息显示 */}
|
||||
<RoomInfoDisplay
|
||||
code={desktopShare.connectionCode}
|
||||
link={`${typeof window !== 'undefined' ? window.location.origin : ''}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`}
|
||||
icon={Monitor}
|
||||
iconColor="from-emerald-500 to-teal-500"
|
||||
codeColor="from-purple-600 to-indigo-600"
|
||||
title="房间码生成成功!"
|
||||
subtitle="分享以下信息给观看方"
|
||||
codeLabel="房间代码"
|
||||
qrLabel="扫码观看"
|
||||
copyButtonText="复制房间代码"
|
||||
copyButtonColor="bg-purple-500 hover:bg-purple-600"
|
||||
qrButtonText="使用手机扫码快速观看"
|
||||
linkButtonText="复制链接"
|
||||
onCopyCode={() => copyCode(desktopShare.connectionCode)}
|
||||
onCopyLink={() => {
|
||||
const link = `${window.location.origin}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
showToast('观看链接已复制', 'success');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Download, FileText, Image, Video, Music, Archive } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
@@ -133,63 +134,31 @@ export function WebRTCFileReceive({
|
||||
return (
|
||||
<div>
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">等待文件</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
{isConnected ? '已连接到房间,等待发送方选择文件...' : '正在连接到房间...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isWebSocketConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">文件接收中</h3>
|
||||
<p className="text-sm text-slate-600">取件码: {pickupCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<ConnectionStatus
|
||||
currentRoom={pickupCode ? { code: pickupCode, role: 'receiver' } : null}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={onReset}
|
||||
variant="outline"
|
||||
className="text-slate-600 hover:text-slate-800 border-slate-200 hover:border-slate-300"
|
||||
>
|
||||
重新开始
|
||||
</Button>
|
||||
</div>
|
||||
</div> <div className="text-center">
|
||||
{/* 连接状态指示器 */}
|
||||
<div className="flex items-center justify-center space-x-4 mb-6">
|
||||
<div className="flex items-center">
|
||||
@@ -226,67 +195,22 @@ export function WebRTCFileReceive({
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">可下载文件</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
{isConnected ? (
|
||||
<span className="text-emerald-600">✅ 已连接,可以下载文件</span>
|
||||
) : (
|
||||
<span className="text-amber-600">⏳ 正在建立连接...</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600">房间代码: {pickupCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isWebSocketConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
{files.length} 个文件
|
||||
</div>
|
||||
</div>
|
||||
{/* 连接状态 */}
|
||||
<ConnectionStatus
|
||||
|
||||
currentRoom={{ code: pickupCode, role: 'receiver' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -371,8 +295,8 @@ export function WebRTCFileReceive({
|
||||
return (
|
||||
<div>
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex items-center justify-between mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
@@ -382,57 +306,10 @@ export function WebRTCFileReceive({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">WS</span>
|
||||
</>
|
||||
) : isWebSocketConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : isConnecting ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 连接状态 */}
|
||||
<ConnectionStatus
|
||||
currentRoom={null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { Upload, FileText, Image, Video, Music, Archive, X } from 'lucide-react';
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay';
|
||||
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
@@ -45,8 +46,6 @@ interface WebRTCFileUploadProps {
|
||||
onClearFiles?: () => void;
|
||||
onReset?: () => void;
|
||||
disabled?: boolean;
|
||||
isConnected?: boolean;
|
||||
isWebSocketConnected?: boolean;
|
||||
}
|
||||
|
||||
export function WebRTCFileUpload({
|
||||
@@ -62,9 +61,7 @@ export function WebRTCFileUpload({
|
||||
onRemoveFile,
|
||||
onClearFiles,
|
||||
onReset,
|
||||
disabled = false,
|
||||
isConnected = false,
|
||||
isWebSocketConnected = false
|
||||
disabled = false
|
||||
}: WebRTCFileUploadProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -115,9 +112,9 @@ export function WebRTCFileUpload({
|
||||
if (selectedFiles.length === 0 && !pickupCode) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
{/* 功能标题 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Upload className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
@@ -127,29 +124,9 @@ export function WebRTCFileUpload({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectionStatus
|
||||
currentRoom={null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -197,63 +174,22 @@ export function WebRTCFileUpload({
|
||||
{/* 文件列表 */}
|
||||
<div>
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-4 sm:mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* 标题部分 */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">已选择文件</h3>
|
||||
<p className="text-sm text-slate-500">{selectedFiles.length} 个文件准备传输</p>
|
||||
<p className="text-sm text-slate-600">{selectedFiles.length} 个文件准备传输</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isWebSocketConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : pickupCode ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 使用 ConnectionStatus 组件 */}
|
||||
<ConnectionStatus
|
||||
currentRoom={pickupCode ? { code: pickupCode, role: 'sender' } : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-4 sm:mb-6">
|
||||
@@ -397,80 +333,24 @@ export function WebRTCFileUpload({
|
||||
</div>
|
||||
|
||||
{/* 取件码展示 */}
|
||||
{pickupCode && (
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
{/* 左上角状态提示 - 类似已选择文件的风格 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">取件码生成成功!</h3>
|
||||
<p className="text-sm text-slate-600">分享以下信息给接收方</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间区域:取件码 + 分隔线 + 二维码 */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
|
||||
{/* 左侧:取件码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">取件码</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<div className="text-2xl font-bold font-mono bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent tracking-wider">
|
||||
{pickupCode}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onCopyCode}
|
||||
className="w-full px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3"
|
||||
>
|
||||
复制取件码
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
|
||||
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
|
||||
|
||||
{/* 右侧:二维码 */}
|
||||
{pickupLink && (
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">扫码传输</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<QRCodeDisplay
|
||||
value={pickupLink}
|
||||
size={120}
|
||||
title=""
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
|
||||
使用手机扫码快速访问
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:取件链接 */}
|
||||
{pickupLink && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
|
||||
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
|
||||
{pickupLink}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onCopyLink}
|
||||
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
|
||||
>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{pickupCode && pickupLink && (
|
||||
<RoomInfoDisplay
|
||||
code={pickupCode}
|
||||
link={pickupLink}
|
||||
icon={FileText}
|
||||
iconColor="from-emerald-500 to-teal-500"
|
||||
codeColor="from-emerald-600 to-teal-600"
|
||||
title="取件码生成成功!"
|
||||
subtitle="分享以下信息给接收方"
|
||||
codeLabel="取件码"
|
||||
qrLabel="扫码传输"
|
||||
copyButtonText="复制取件码"
|
||||
copyButtonColor="bg-emerald-500 hover:bg-emerald-600"
|
||||
qrButtonText="使用手机扫码快速访问"
|
||||
linkButtonText="复制链接"
|
||||
onCopyCode={onCopyCode || (() => {})}
|
||||
onCopyLink={onCopyLink || (() => {})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,17 +8,20 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { MessageSquare, Image, Download } from 'lucide-react';
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
|
||||
interface WebRTCTextReceiverProps {
|
||||
initialCode?: string;
|
||||
onPreviewImage: (imageUrl: string) => void;
|
||||
onRestart?: () => void;
|
||||
onConnectionChange?: (connection: any) => void;
|
||||
}
|
||||
|
||||
export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
initialCode = '',
|
||||
onPreviewImage,
|
||||
onRestart
|
||||
onRestart,
|
||||
onConnectionChange
|
||||
}) => {
|
||||
const { showToast } = useToast();
|
||||
|
||||
@@ -29,12 +32,13 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
const [receivedImages, setReceivedImages] = useState<Array<{ id: string, content: string, fileName?: string }>>([]);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
// Ref用于防止重复自动连接
|
||||
const hasTriedAutoConnect = useRef(false);
|
||||
|
||||
|
||||
// 创建共享连接 [需要优化]
|
||||
// 创建共享连接
|
||||
const connection = useSharedWebRTCManager();
|
||||
|
||||
|
||||
// 使用共享连接创建业务层
|
||||
const textTransfer = useTextTransferBusiness(connection);
|
||||
const fileTransfer = useFileTransferBusiness(connection);
|
||||
@@ -42,116 +46,49 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
// 连接所有传输通道
|
||||
const connectAll = useCallback(async (code: string, role: 'sender' | 'receiver') => {
|
||||
console.log('=== 连接所有传输通道 ===', { code, role });
|
||||
// 只需要连接一次,因为使用的是共享连接
|
||||
await connection.connect(code, role);
|
||||
// await Promise.all([
|
||||
// textTransfer.connect(code, role),
|
||||
// fileTransfer.connect(code, role)
|
||||
// ]);
|
||||
}, [textTransfer, fileTransfer]);
|
||||
}, [connection]);
|
||||
|
||||
// 是否有任何连接
|
||||
const hasAnyConnection = textTransfer.isConnected || fileTransfer.isConnected;
|
||||
|
||||
|
||||
// 是否正在连接
|
||||
const isAnyConnecting = textTransfer.isConnecting || fileTransfer.isConnecting;
|
||||
|
||||
// 通知父组件连接状态变化
|
||||
useEffect(() => {
|
||||
if (onConnectionChange) {
|
||||
onConnectionChange(connection);
|
||||
}
|
||||
}, [onConnectionChange, connection.isConnected, connection.isConnecting, connection.isPeerConnected]);
|
||||
|
||||
// 是否有任何错误
|
||||
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
|
||||
|
||||
// 监听连接错误并显示 toast
|
||||
useEffect(() => {
|
||||
if (hasAnyError) {
|
||||
console.error('[WebRTCTextReceiver] 连接错误:', hasAnyError);
|
||||
showToast(hasAnyError, 'error');
|
||||
}
|
||||
}, [hasAnyError, showToast]);
|
||||
|
||||
// 验证取件码是否存在
|
||||
const validatePickupCode = async (code: string): Promise<boolean> => {
|
||||
try {
|
||||
setIsValidating(true);
|
||||
|
||||
console.log('开始验证取件码:', code);
|
||||
const response = await fetch(`/api/room-info?code=${code}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('验证响应:', { status: response.status, data });
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
const errorMessage = data.message || '取件码验证失败';
|
||||
showToast(errorMessage, 'error');
|
||||
console.log('验证失败:', errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('取件码验证成功:', data.room);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('验证取件码时发生错误:', error);
|
||||
const errorMessage = '网络错误,请检查连接后重试';
|
||||
showToast(errorMessage, 'error');
|
||||
return false;
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重新开始
|
||||
const restart = () => {
|
||||
setPickupCode('');
|
||||
setInputCode('');
|
||||
setReceivedText('');
|
||||
setReceivedImages([]);
|
||||
setIsTyping(false);
|
||||
|
||||
// 断开连接
|
||||
|
||||
// 清理接收的图片URL
|
||||
receivedImages.forEach(img => {
|
||||
if (img.content.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(img.content);
|
||||
}
|
||||
});
|
||||
setReceivedImages([]);
|
||||
|
||||
// 断开连接(只需要断开一次)
|
||||
connection.disconnect();
|
||||
|
||||
|
||||
if (onRestart) {
|
||||
onRestart();
|
||||
}
|
||||
};
|
||||
|
||||
// 加入房间
|
||||
const joinRoom = useCallback(async (code: string) => {
|
||||
const trimmedCode = code.trim().toUpperCase();
|
||||
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
showToast('请输入正确的6位取件码', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAnyConnecting || isValidating) {
|
||||
console.log('已经在连接中,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAnyConnection) {
|
||||
console.log('已经连接,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('=== 开始验证和连接房间 ===', trimmedCode);
|
||||
|
||||
const isValid = await validatePickupCode(trimmedCode);
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPickupCode(trimmedCode);
|
||||
await connectAll(trimmedCode, 'receiver');
|
||||
|
||||
console.log('=== 房间连接成功 ===', trimmedCode);
|
||||
showToast(`成功加入消息房间: ${trimmedCode}`, "success");
|
||||
} catch (error) {
|
||||
console.error('加入房间失败:', error);
|
||||
showToast(error instanceof Error ? error.message : '加入房间失败', "error");
|
||||
setPickupCode('');
|
||||
}
|
||||
}, [isAnyConnecting, hasAnyConnection, connectAll, showToast, isValidating, validatePickupCode]);
|
||||
|
||||
// 监听实时文本同步
|
||||
useEffect(() => {
|
||||
const cleanup = textTransfer.onTextSync((text: string) => {
|
||||
@@ -174,25 +111,66 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
useEffect(() => {
|
||||
const cleanup = fileTransfer.onFileReceived((fileData) => {
|
||||
if (fileData.file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const imageData = e.target?.result as string;
|
||||
setReceivedImages(prev => [...prev, {
|
||||
id: fileData.id,
|
||||
content: imageData,
|
||||
fileName: fileData.file.name
|
||||
}]);
|
||||
};
|
||||
reader.readAsDataURL(fileData.file);
|
||||
const imageUrl = URL.createObjectURL(fileData.file);
|
||||
const imageId = Date.now().toString();
|
||||
|
||||
setReceivedImages(prev => [...prev, {
|
||||
id: imageId,
|
||||
content: imageUrl,
|
||||
fileName: fileData.file.name
|
||||
}]);
|
||||
|
||||
showToast(`收到图片: ${fileData.file.name}`, "success");
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [fileTransfer.onFileReceived]);
|
||||
|
||||
// 验证并加入房间
|
||||
const joinRoom = useCallback(async (code: string) => {
|
||||
if (!code || code.length !== 6) return;
|
||||
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
console.log('=== 开始加入房间 ===', code);
|
||||
|
||||
// 验证房间
|
||||
const response = await fetch(`/api/room-info?code=${code}`);
|
||||
const roomData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(roomData.error || '房间不存在或已过期');
|
||||
}
|
||||
|
||||
console.log('=== 房间验证成功 ===', roomData);
|
||||
setPickupCode(code);
|
||||
|
||||
// 连接到房间
|
||||
await connectAll(code, 'receiver');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('加入房间失败:', error);
|
||||
showToast(error.message || '加入房间失败', "error");
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [connectAll, showToast]);
|
||||
|
||||
// 复制文本到剪贴板
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast('已复制到剪贴板', "success");
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
showToast('复制失败', "error");
|
||||
}
|
||||
};
|
||||
|
||||
// 处理初始代码连接
|
||||
useEffect(() => {
|
||||
// initialCode isAutoConnected
|
||||
console.log(`initialCode: ${initialCode}, hasTriedAutoConnect: ${hasTriedAutoConnect.current}`);
|
||||
if (initialCode && initialCode.length === 6 && !hasTriedAutoConnect.current) {
|
||||
console.log('=== 自动连接初始代码 ===', initialCode);
|
||||
@@ -201,15 +179,15 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
joinRoom(initialCode);
|
||||
return;
|
||||
}
|
||||
}, [initialCode]);
|
||||
}, [initialCode, joinRoom]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!hasAnyConnection ? (
|
||||
// 输入取件码界面
|
||||
<div>
|
||||
<div className="flex items-center mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex items-center justify-between mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
@@ -218,6 +196,12 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
<p className="text-sm text-slate-600">请输入6位取件码来获取实时文字内容</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<ConnectionStatus
|
||||
currentRoom={pickupCode ? { code: pickupCode, role: 'receiver' } : null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); joinRoom(inputCode); }} className="space-y-4 sm:space-y-6">
|
||||
@@ -266,94 +250,112 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
) : (
|
||||
// 已连接,显示实时文本
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">实时文字内容</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
<span className="text-emerald-600">✅ 已连接,正在实时接收文字</span>
|
||||
</p>
|
||||
<p className="text-sm text-slate-600">取件码: {pickupCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<ConnectionStatus
|
||||
|
||||
{/* 连接成功状态 */}
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-6">
|
||||
<h4 className="font-semibold text-emerald-800 mb-1">已连接到文字房间</h4>
|
||||
<p className="text-emerald-700">取件码: {pickupCode}</p>
|
||||
</div>
|
||||
|
||||
{/* 实时文本显示区域 */}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-slate-800 flex items-center">
|
||||
<MessageSquare className="w-5 h-5 mr-2" />
|
||||
实时文字内容
|
||||
</h4>
|
||||
<div className="flex items-center space-x-3 text-sm">
|
||||
<span className="text-slate-500">
|
||||
{receivedText.length} / 50,000 字符
|
||||
</span>
|
||||
{textTransfer.isConnected && (
|
||||
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md">
|
||||
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
|
||||
<span className="font-medium">WebRTC实时同步</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={receivedText}
|
||||
readOnly
|
||||
placeholder="等待对方发送文字内容... 💡 实时同步显示,对方的编辑会立即显示在这里"
|
||||
className="w-full h-40 px-4 py-3 border border-slate-300 rounded-lg bg-slate-50 text-slate-700 placeholder-slate-400 resize-none"
|
||||
currentRoom={pickupCode ? { code: pickupCode, role: 'receiver' } : null}
|
||||
/>
|
||||
{!receivedText && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 rounded-lg border border-slate-300">
|
||||
<div className="text-center">
|
||||
<MessageSquare className="w-12 h-12 text-slate-400 mx-auto mb-4" />
|
||||
<p className="text-slate-600">等待接收文字内容...</p>
|
||||
<p className="text-sm text-slate-500 mt-2">对方发送的文字将在这里实时显示</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={restart}
|
||||
variant="outline"
|
||||
className="text-slate-600 hover:text-slate-800 border-slate-200 hover:border-slate-300"
|
||||
>
|
||||
重新开始
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文本显示区域 */}
|
||||
<div className="bg-white/90 backdrop-blur-sm border border-slate-200 rounded-2xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-slate-800 flex items-center space-x-2">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span>接收到的文字</span>
|
||||
</h4>
|
||||
|
||||
{receivedText && (
|
||||
<Button
|
||||
onClick={() => copyToClipboard(receivedText)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-slate-600 hover:text-slate-800 h-8 px-3"
|
||||
>
|
||||
<span>复制</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 打字状态提示 */}
|
||||
{isTyping && (
|
||||
<div className="flex items-center space-x-2 mt-3 text-sm text-slate-500">
|
||||
<div className="flex space-x-1">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1 h-1 bg-slate-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: `${i * 0.1}s` }}
|
||||
></div>
|
||||
))}
|
||||
<div className="min-h-[200px] bg-slate-50/50 rounded-xl p-4 border border-slate-100">
|
||||
{receivedText ? (
|
||||
<div className="space-y-2">
|
||||
<pre className="whitespace-pre-wrap text-slate-700 text-sm leading-relaxed font-sans">
|
||||
{receivedText}
|
||||
</pre>
|
||||
{isTyping && (
|
||||
<div className="flex items-center space-x-2 text-slate-500 text-sm">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '0ms'}}></div>
|
||||
<div className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '150ms'}}></div>
|
||||
<div className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '300ms'}}></div>
|
||||
</div>
|
||||
<span>对方正在输入...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="italic">对方正在输入...</span>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-400 space-y-3">
|
||||
<MessageSquare className="w-12 h-12 text-slate-300" />
|
||||
<p className="text-center">
|
||||
{connection.isPeerConnected ?
|
||||
'等待对方发送文字内容...' :
|
||||
'等待连接建立...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 接收到的图片 */}
|
||||
{/* 图片显示区域 */}
|
||||
{receivedImages.length > 0 && (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-6 border border-slate-200">
|
||||
<h4 className="text-lg font-semibold text-slate-800 mb-4">接收的图片</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div className="bg-white/90 backdrop-blur-sm border border-slate-200 rounded-2xl p-6 space-y-4">
|
||||
<h4 className="font-medium text-slate-800 flex items-center space-x-2">
|
||||
<Image className="w-4 h-4" />
|
||||
<span>接收到的图片 ({receivedImages.length})</span>
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{receivedImages.map((image) => (
|
||||
<img
|
||||
<div
|
||||
key={image.id}
|
||||
src={image.content}
|
||||
alt={image.fileName}
|
||||
className="w-full h-32 object-cover rounded-lg border cursor-pointer hover:opacity-80 transition-opacity"
|
||||
className="group relative aspect-square bg-slate-50 rounded-xl overflow-hidden border border-slate-200 hover:border-slate-300 transition-all duration-200 cursor-pointer"
|
||||
onClick={() => onPreviewImage(image.content)}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src={image.content}
|
||||
alt={image.fileName || '接收的图片'}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200 flex items-center justify-center">
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div className="bg-white/90 rounded-lg px-3 py-1">
|
||||
<span className="text-sm text-slate-700">点击查看</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,4 +364,4 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -7,14 +7,16 @@ import { useFileTransferBusiness } from '@/hooks/webrtc/useFileTransferBusiness'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { MessageSquare, Image, Send, Copy } from 'lucide-react';
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay';
|
||||
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
|
||||
interface WebRTCTextSenderProps {
|
||||
onRestart?: () => void;
|
||||
onPreviewImage?: (imageUrl: string) => void;
|
||||
onConnectionChange?: (connection: any) => void;
|
||||
}
|
||||
|
||||
export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, onPreviewImage }) => {
|
||||
export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, onPreviewImage, onConnectionChange }) => {
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 状态管理
|
||||
@@ -48,6 +50,13 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
|
||||
// 是否正在连接
|
||||
const isAnyConnecting = textTransfer.isConnecting || fileTransfer.isConnecting;
|
||||
|
||||
// 通知父组件连接状态变化
|
||||
useEffect(() => {
|
||||
if (onConnectionChange) {
|
||||
onConnectionChange(connection);
|
||||
}
|
||||
}, [onConnectionChange, connection.isConnected, connection.isConnecting, connection.isPeerConnected]);
|
||||
|
||||
// 是否有任何错误
|
||||
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
|
||||
|
||||
@@ -286,36 +295,17 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${textTransfer.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-slate-400'}`}></div>
|
||||
<span className={textTransfer.isWebSocketConnected ? 'text-blue-600' : 'text-slate-600'}>WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${textTransfer.isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-slate-400'}`}></div>
|
||||
<span className={textTransfer.isConnected ? 'text-emerald-600' : 'text-slate-600'}>RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 连接状态 */}
|
||||
<ConnectionStatus
|
||||
currentRoom={pickupCode ? { code: pickupCode, role: 'sender' } : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center">
|
||||
<MessageSquare className="w-10 h-10 text-blue-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-800 mb-4">创建文字传输房间</h3>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">创建文字传输房间</h3>
|
||||
<p className="text-slate-600 mb-8">创建房间后可以实时同步文字内容</p>
|
||||
|
||||
<Button
|
||||
@@ -341,45 +331,22 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
|
||||
// 房间已创建,显示取件码和文本传输界面
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">传送文字</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
{hasAnyConnection ? '实时编辑,对方可以同步看到' : '等待对方连接'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${textTransfer.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-red-500'}`}></div>
|
||||
<span className={textTransfer.isWebSocketConnected ? 'text-blue-600' : 'text-red-600'}>WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${textTransfer.isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-400'}`}></div>
|
||||
<span className={textTransfer.isConnected ? 'text-emerald-600' : 'text-orange-600'}>RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-green-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
|
||||
{/* 文字编辑区域 - 移到最上面 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">发送文本</h2>
|
||||
<p className="text-sm text-slate-600">输入您想要传输的文本内容</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionStatus
|
||||
currentRoom={pickupCode ? { code: pickupCode, role: 'sender' } : null}
|
||||
/>
|
||||
</div> {/* 文字编辑区域 - 移到最上面 */}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-slate-800 flex items-center">
|
||||
@@ -471,74 +438,24 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 取件码显示 - 和文件传输一致的风格 */}
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
{/* 左上角状态提示 - 类似已选择文件的风格 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">取件码生成成功!</h3>
|
||||
<p className="text-sm text-slate-600">分享以下信息给接收方</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间区域:取件码 + 分隔线 + 二维码 */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
|
||||
{/* 左侧:取件码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">取件码</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<div className="text-2xl font-bold font-mono bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent tracking-wider">
|
||||
{pickupCode}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={copyCode}
|
||||
className="w-full px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3"
|
||||
>
|
||||
复制取件码
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
|
||||
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
|
||||
|
||||
{/* 右侧:二维码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">扫码传输</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<QRCodeDisplay
|
||||
value={pickupLink}
|
||||
size={120}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
|
||||
使用手机扫码快速访问
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:取件链接 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
|
||||
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
|
||||
{pickupLink}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={copyShareLink}
|
||||
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
|
||||
>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 取件码显示 */}
|
||||
<RoomInfoDisplay
|
||||
code={pickupCode}
|
||||
link={pickupLink}
|
||||
icon={MessageSquare}
|
||||
iconColor="from-emerald-500 to-teal-500"
|
||||
codeColor="from-emerald-600 to-teal-600"
|
||||
title="取件码生成成功!"
|
||||
subtitle="分享以下信息给接收方"
|
||||
codeLabel="取件码"
|
||||
qrLabel="扫码传输"
|
||||
copyButtonText="复制取件码"
|
||||
copyButtonColor="bg-emerald-500 hover:bg-emerald-600"
|
||||
qrButtonText="使用手机扫码快速访问"
|
||||
linkButtonText="复制链接"
|
||||
onCopyCode={copyCode}
|
||||
onCopyLink={copyShareLink}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -29,6 +29,37 @@ export function useDesktopShareBusiness() {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 处理远程流
|
||||
const handleRemoteStream = useCallback((stream: MediaStream) => {
|
||||
console.log('[DesktopShare] 收到远程流:', stream.getTracks().length, '个轨道');
|
||||
updateState({ remoteStream: stream });
|
||||
|
||||
// 如果有视频元素引用,设置流
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = stream;
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 设置远程轨道处理器(始终监听)
|
||||
useEffect(() => {
|
||||
console.log('[DesktopShare] 🎧 设置远程轨道处理器');
|
||||
webRTC.onTrack((event: RTCTrackEvent) => {
|
||||
console.log('[DesktopShare] 🎥 收到远程轨道:', event.track.kind, event.track.id);
|
||||
console.log('[DesktopShare] 远程流数量:', event.streams.length);
|
||||
|
||||
if (event.streams.length > 0) {
|
||||
const remoteStream = event.streams[0];
|
||||
console.log('[DesktopShare] 🎬 设置远程流,轨道数量:', remoteStream.getTracks().length);
|
||||
remoteStream.getTracks().forEach(track => {
|
||||
console.log('[DesktopShare] 远程轨道:', track.kind, track.id, track.enabled, track.readyState);
|
||||
});
|
||||
handleRemoteStream(remoteStream);
|
||||
} else {
|
||||
console.warn('[DesktopShare] ⚠️ 收到轨道但没有关联的流');
|
||||
}
|
||||
});
|
||||
}, [webRTC, handleRemoteStream]);
|
||||
|
||||
// 生成6位房间代码
|
||||
const generateRoomCode = useCallback(() => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
@@ -141,17 +172,6 @@ export function useDesktopShareBusiness() {
|
||||
};
|
||||
}, [webRTC]);
|
||||
|
||||
// 处理远程流
|
||||
const handleRemoteStream = useCallback((stream: MediaStream) => {
|
||||
console.log('[DesktopShare] 收到远程流:', stream.getTracks().length, '个轨道');
|
||||
updateState({ remoteStream: stream });
|
||||
|
||||
// 如果有视频元素引用,设置流
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = stream;
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 创建房间(只建立连接,等待对方加入)
|
||||
const createRoom = useCallback(async (): Promise<string> => {
|
||||
try {
|
||||
@@ -313,21 +333,6 @@ export function useDesktopShareBusiness() {
|
||||
console.log('[DesktopShare] ⏳ 等待连接稳定...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 设置远程流处理 - 在连接建立后设置
|
||||
console.log('[DesktopShare] 📡 设置远程流处理器...');
|
||||
webRTC.onTrack((event: RTCTrackEvent) => {
|
||||
console.log('[DesktopShare] 🎥 收到远程轨道:', event.track.kind, event.track.id);
|
||||
console.log('[DesktopShare] 远程流数量:', event.streams.length);
|
||||
|
||||
if (event.streams.length > 0) {
|
||||
const remoteStream = event.streams[0];
|
||||
console.log('[DesktopShare] 🎬 设置远程流,轨道数量:', remoteStream.getTracks().length);
|
||||
handleRemoteStream(remoteStream);
|
||||
} else {
|
||||
console.warn('[DesktopShare] ⚠️ 收到轨道但没有关联的流');
|
||||
}
|
||||
});
|
||||
|
||||
updateState({ isViewing: true });
|
||||
console.log('[DesktopShare] 👁️ 已进入桌面共享观看模式,等待接收流...');
|
||||
} catch (error) {
|
||||
@@ -336,7 +341,7 @@ export function useDesktopShareBusiness() {
|
||||
updateState({ error: errorMessage, isViewing: false });
|
||||
throw error;
|
||||
}
|
||||
}, [webRTC, handleRemoteStream, updateState]);
|
||||
}, [webRTC, updateState]);
|
||||
|
||||
// 停止观看桌面共享
|
||||
const stopViewing = useCallback(async (): Promise<void> => {
|
||||
@@ -403,5 +408,8 @@ export function useDesktopShareBusiness() {
|
||||
|
||||
// WebRTC连接状态
|
||||
webRTCError: webRTC.error,
|
||||
|
||||
// 暴露WebRTC连接对象
|
||||
webRTCConnection: webRTC,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ interface FileTransferState {
|
||||
receivedFiles: Array<{ id: string; file: File }>;
|
||||
}
|
||||
|
||||
// 单个文件的接收进度
|
||||
interface FileReceiveProgress {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
receivedChunks: number;
|
||||
totalChunks: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
// 文件信息
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
@@ -36,6 +45,28 @@ interface FileChunk {
|
||||
fileId: string;
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
checksum?: string; // 数据校验和
|
||||
}
|
||||
|
||||
// 块确认信息
|
||||
interface ChunkAck {
|
||||
fileId: string;
|
||||
chunkIndex: number;
|
||||
success: boolean;
|
||||
checksum?: string;
|
||||
}
|
||||
|
||||
// 传输状态
|
||||
interface TransferStatus {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
totalChunks: number;
|
||||
sentChunks: Set<number>;
|
||||
acknowledgedChunks: Set<number>;
|
||||
failedChunks: Set<number>;
|
||||
lastChunkTime: number;
|
||||
retryCount: Map<number, number>;
|
||||
averageSpeed: number; // KB/s
|
||||
}
|
||||
|
||||
// 回调类型
|
||||
@@ -46,6 +77,40 @@ type FileListReceivedCallback = (fileList: FileInfo[]) => void;
|
||||
|
||||
const CHANNEL_NAME = 'file-transfer';
|
||||
const CHUNK_SIZE = 256 * 1024; // 256KB
|
||||
const MAX_RETRIES = 5; // 最大重试次数
|
||||
const RETRY_DELAY = 1000; // 重试延迟(毫秒)
|
||||
const ACK_TIMEOUT = 5000; // 确认超时(毫秒)
|
||||
|
||||
/**
|
||||
* 计算数据的CRC32校验和
|
||||
*/
|
||||
function calculateChecksum(data: ArrayBuffer): string {
|
||||
const buffer = new Uint8Array(data);
|
||||
let crc = 0xFFFFFFFF;
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
crc ^= buffer[i];
|
||||
for (let j = 0; j < 8; j++) {
|
||||
crc = crc & 1 ? (crc >>> 1) ^ 0xEDB88320 : crc >>> 1;
|
||||
}
|
||||
}
|
||||
|
||||
return (crc ^ 0xFFFFFFFF).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成简单的校验和(备用方案)
|
||||
*/
|
||||
function simpleChecksum(data: ArrayBuffer): string {
|
||||
const buffer = new Uint8Array(data);
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < Math.min(buffer.length, 1000); i++) {
|
||||
sum += buffer[i];
|
||||
}
|
||||
|
||||
return sum.toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件传输业务层
|
||||
@@ -80,6 +145,15 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
const fileProgressCallbacks = useRef<Set<FileProgressCallback>>(new Set());
|
||||
const fileListCallbacks = useRef<Set<FileListReceivedCallback>>(new Set());
|
||||
|
||||
// 传输状态管理
|
||||
const transferStatus = useRef<Map<string, TransferStatus>>(new Map());
|
||||
const pendingChunks = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const chunkAckCallbacks = useRef<Map<string, Set<(ack: ChunkAck) => void>>>(new Map());
|
||||
|
||||
// 接收文件进度跟踪
|
||||
const receiveProgress = useRef<Map<string, FileReceiveProgress>>(new Map());
|
||||
const activeReceiveFile = useRef<string | null>(null);
|
||||
|
||||
const updateState = useCallback((updates: Partial<FileTransferState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
@@ -98,7 +172,19 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
chunks: [],
|
||||
receivedChunks: 0,
|
||||
});
|
||||
|
||||
// 初始化接收进度跟踪
|
||||
const totalChunks = Math.ceil(metadata.size / CHUNK_SIZE);
|
||||
receiveProgress.current.set(metadata.id, {
|
||||
fileId: metadata.id,
|
||||
fileName: metadata.name,
|
||||
receivedChunks: 0,
|
||||
totalChunks,
|
||||
progress: 0
|
||||
});
|
||||
|
||||
// 设置当前活跃的接收文件
|
||||
activeReceiveFile.current = metadata.id;
|
||||
updateState({ isTransferring: true, progress: 0 });
|
||||
break;
|
||||
|
||||
@@ -129,6 +215,12 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
|
||||
fileReceivedCallbacks.current.forEach(cb => cb({ id: fileId, file }));
|
||||
receivingFiles.current.delete(fileId);
|
||||
receiveProgress.current.delete(fileId);
|
||||
|
||||
// 清除活跃文件
|
||||
if (activeReceiveFile.current === fileId) {
|
||||
activeReceiveFile.current = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -142,6 +234,37 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
console.log('收到文件请求:', fileName, requestedFileId);
|
||||
fileRequestedCallbacks.current.forEach(cb => cb(requestedFileId, fileName));
|
||||
break;
|
||||
|
||||
case 'file-chunk-ack':
|
||||
const ack: ChunkAck = message.payload;
|
||||
console.log('收到块确认:', ack);
|
||||
|
||||
// 清除超时定时器
|
||||
const chunkKey = `${ack.fileId}-${ack.chunkIndex}`;
|
||||
const timeout = pendingChunks.current.get(chunkKey);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
pendingChunks.current.delete(chunkKey);
|
||||
}
|
||||
|
||||
// 调用确认回调
|
||||
const callbacks = chunkAckCallbacks.current.get(chunkKey);
|
||||
if (callbacks) {
|
||||
callbacks.forEach(cb => cb(ack));
|
||||
chunkAckCallbacks.current.delete(chunkKey);
|
||||
}
|
||||
|
||||
// 更新传输状态
|
||||
const status = transferStatus.current.get(ack.fileId);
|
||||
if (status) {
|
||||
if (ack.success) {
|
||||
status.acknowledgedChunks.add(ack.chunkIndex);
|
||||
status.failedChunks.delete(ack.chunkIndex);
|
||||
} else {
|
||||
status.failedChunks.add(ack.chunkIndex);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
@@ -152,28 +275,74 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { fileId, chunkIndex, totalChunks } = expectedChunk.current;
|
||||
const { fileId, chunkIndex, totalChunks, checksum: expectedChecksum } = expectedChunk.current;
|
||||
const fileInfo = receivingFiles.current.get(fileId);
|
||||
|
||||
if (fileInfo) {
|
||||
// 验证数据完整性
|
||||
const actualChecksum = calculateChecksum(data);
|
||||
const isValid = !expectedChecksum || actualChecksum === expectedChecksum;
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`文件块校验失败: 期望 ${expectedChecksum}, 实际 ${actualChecksum}`);
|
||||
|
||||
// 发送失败确认
|
||||
connection.sendMessage({
|
||||
type: 'file-chunk-ack',
|
||||
payload: {
|
||||
fileId,
|
||||
chunkIndex,
|
||||
success: false,
|
||||
checksum: actualChecksum
|
||||
}
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
expectedChunk.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 数据有效,保存到缓存
|
||||
fileInfo.chunks[chunkIndex] = data;
|
||||
fileInfo.receivedChunks++;
|
||||
|
||||
const progress = (fileInfo.receivedChunks / totalChunks) * 100;
|
||||
updateState({ progress });
|
||||
|
||||
fileProgressCallbacks.current.forEach(cb => cb({
|
||||
fileId: fileId,
|
||||
fileName: fileInfo.metadata.name,
|
||||
progress
|
||||
}));
|
||||
// 更新接收进度跟踪
|
||||
const progressInfo = receiveProgress.current.get(fileId);
|
||||
if (progressInfo) {
|
||||
progressInfo.receivedChunks++;
|
||||
progressInfo.progress = progressInfo.totalChunks > 0 ?
|
||||
(progressInfo.receivedChunks / progressInfo.totalChunks) * 100 : 0;
|
||||
|
||||
// 只有当这个文件是当前活跃文件时才更新全局进度
|
||||
if (activeReceiveFile.current === fileId) {
|
||||
updateState({ progress: progressInfo.progress });
|
||||
}
|
||||
|
||||
// 触发进度回调
|
||||
fileProgressCallbacks.current.forEach(cb => cb({
|
||||
fileId: fileId,
|
||||
fileName: progressInfo.fileName,
|
||||
progress: progressInfo.progress
|
||||
}));
|
||||
|
||||
console.log(`文件 ${fileInfo.metadata.name} 接收进度: ${progress.toFixed(1)}%`);
|
||||
console.log(`文件 ${progressInfo.fileName} 接收进度: ${progressInfo.progress.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
// 发送成功确认
|
||||
connection.sendMessage({
|
||||
type: 'file-chunk-ack',
|
||||
payload: {
|
||||
fileId,
|
||||
chunkIndex,
|
||||
success: true,
|
||||
checksum: actualChecksum
|
||||
}
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
expectedChunk.current = null;
|
||||
}
|
||||
}, [updateState]);
|
||||
}, [updateState, connection]);
|
||||
|
||||
// 设置处理器
|
||||
// 设置处理器 - 使用稳定的引用避免反复注册
|
||||
useEffect(() => {
|
||||
// 使用共享连接的注册方式
|
||||
const unregisterMessage = connection.registerMessageHandler(CHANNEL_NAME, handleMessage);
|
||||
@@ -183,7 +352,7 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
unregisterMessage();
|
||||
unregisterData();
|
||||
};
|
||||
}, [handleMessage, handleData]);
|
||||
}, [connection]); // 只依赖 connection 对象,不依赖处理函数
|
||||
|
||||
// 监听连接状态变化 (直接使用 connection 的状态)
|
||||
useEffect(() => {
|
||||
@@ -201,8 +370,60 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
return connection.connect(roomCode, role);
|
||||
}, [connection]);
|
||||
|
||||
// 发送文件
|
||||
const sendFile = useCallback(async (file: File, fileId?: string) => {
|
||||
// 安全发送单个文件块
|
||||
const sendChunkWithAck = useCallback(async (
|
||||
fileId: string,
|
||||
chunkIndex: number,
|
||||
chunkData: ArrayBuffer,
|
||||
checksum: string,
|
||||
retryCount = 0
|
||||
): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const chunkKey = `${fileId}-${chunkIndex}`;
|
||||
|
||||
// 设置确认回调
|
||||
const ackCallback = (ack: ChunkAck) => {
|
||||
if (ack.success) {
|
||||
resolve(true);
|
||||
} else {
|
||||
console.warn(`文件块 ${chunkIndex} 确认失败,准备重试`);
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 注册确认回调
|
||||
if (!chunkAckCallbacks.current.has(chunkKey)) {
|
||||
chunkAckCallbacks.current.set(chunkKey, new Set());
|
||||
}
|
||||
chunkAckCallbacks.current.get(chunkKey)!.add(ackCallback);
|
||||
|
||||
// 设置超时定时器
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn(`文件块 ${chunkIndex} 确认超时`);
|
||||
chunkAckCallbacks.current.get(chunkKey)?.delete(ackCallback);
|
||||
resolve(false);
|
||||
}, ACK_TIMEOUT);
|
||||
|
||||
pendingChunks.current.set(chunkKey, timeout);
|
||||
|
||||
// 发送块信息
|
||||
connection.sendMessage({
|
||||
type: 'file-chunk-info',
|
||||
payload: {
|
||||
fileId,
|
||||
chunkIndex,
|
||||
totalChunks: 0, // 这里不需要,因为已经在元数据中发送
|
||||
checksum
|
||||
}
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
// 发送块数据
|
||||
connection.sendData(chunkData);
|
||||
});
|
||||
}, [connection]);
|
||||
|
||||
// 安全发送文件
|
||||
const sendFileSecure = useCallback(async (file: File, fileId?: string) => {
|
||||
if (connection.getChannelState() !== 'open') {
|
||||
updateState({ error: '连接未就绪' });
|
||||
return;
|
||||
@@ -211,10 +432,24 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
const actualFileId = fileId || `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
|
||||
console.log('开始发送文件:', file.name, '文件ID:', actualFileId, '总块数:', totalChunks);
|
||||
console.log('开始安全发送文件:', file.name, '文件ID:', actualFileId, '总块数:', totalChunks);
|
||||
|
||||
updateState({ isTransferring: true, progress: 0, error: null });
|
||||
|
||||
// 初始化传输状态
|
||||
const status: TransferStatus = {
|
||||
fileId: actualFileId,
|
||||
fileName: file.name,
|
||||
totalChunks,
|
||||
sentChunks: new Set(),
|
||||
acknowledgedChunks: new Set(),
|
||||
failedChunks: new Set(),
|
||||
lastChunkTime: Date.now(),
|
||||
retryCount: new Map(),
|
||||
averageSpeed: 0
|
||||
};
|
||||
transferStatus.current.set(actualFileId, status);
|
||||
|
||||
try {
|
||||
// 1. 发送文件元数据
|
||||
connection.sendMessage({
|
||||
@@ -229,25 +464,52 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
|
||||
// 2. 分块发送文件
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
const start = chunkIndex * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
let success = false;
|
||||
let retryCount = 0;
|
||||
|
||||
// 先发送块信息
|
||||
connection.sendMessage({
|
||||
type: 'file-chunk-info',
|
||||
payload: {
|
||||
fileId: actualFileId,
|
||||
chunkIndex,
|
||||
totalChunks
|
||||
while (!success && retryCount <= MAX_RETRIES) {
|
||||
const start = chunkIndex * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
const arrayBuffer = await chunk.arrayBuffer();
|
||||
const checksum = calculateChecksum(arrayBuffer);
|
||||
|
||||
console.log(`发送文件块 ${chunkIndex}/${totalChunks}, 重试次数: ${retryCount}`);
|
||||
|
||||
// 发送块并等待确认
|
||||
success = await sendChunkWithAck(actualFileId, chunkIndex, arrayBuffer, checksum, retryCount);
|
||||
|
||||
if (success) {
|
||||
status.sentChunks.add(chunkIndex);
|
||||
status.acknowledgedChunks.add(chunkIndex);
|
||||
status.failedChunks.delete(chunkIndex);
|
||||
|
||||
// 计算传输速度
|
||||
const now = Date.now();
|
||||
const timeDiff = (now - status.lastChunkTime) / 1000; // 秒
|
||||
if (timeDiff > 0) {
|
||||
const speed = (arrayBuffer.byteLength / 1024) / timeDiff; // KB/s
|
||||
status.averageSpeed = status.averageSpeed * 0.7 + speed * 0.3; // 平滑平均
|
||||
}
|
||||
status.lastChunkTime = now;
|
||||
} else {
|
||||
retryCount++;
|
||||
status.retryCount.set(chunkIndex, retryCount);
|
||||
|
||||
if (retryCount > MAX_RETRIES) {
|
||||
status.failedChunks.add(chunkIndex);
|
||||
throw new Error(`文件块 ${chunkIndex} 发送失败,超过最大重试次数`);
|
||||
}
|
||||
|
||||
// 指数退避
|
||||
const delay = Math.min(RETRY_DELAY * Math.pow(2, retryCount - 1), 10000);
|
||||
console.log(`等待 ${delay}ms 后重试文件块 ${chunkIndex}`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}, CHANNEL_NAME);
|
||||
}
|
||||
|
||||
// 再发送块数据
|
||||
const arrayBuffer = await chunk.arrayBuffer();
|
||||
connection.sendData(arrayBuffer);
|
||||
|
||||
const progress = ((chunkIndex + 1) / totalChunks) * 100;
|
||||
// 更新进度
|
||||
const progress = (status.acknowledgedChunks.size / totalChunks) * 100;
|
||||
updateState({ progress });
|
||||
|
||||
fileProgressCallbacks.current.forEach(cb => cb({
|
||||
@@ -256,48 +518,73 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
progress
|
||||
}));
|
||||
|
||||
// 简单的流控:等待一小段时间让接收方处理
|
||||
if (chunkIndex % 10 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
// 自适应流控:根据传输速度调整发送间隔
|
||||
if (status.averageSpeed > 0) {
|
||||
const chunkSize = Math.min(CHUNK_SIZE, file.size - chunkIndex * CHUNK_SIZE);
|
||||
const expectedTime = (chunkSize / 1024) / status.averageSpeed;
|
||||
const actualTime = Date.now() - status.lastChunkTime;
|
||||
const delay = Math.max(0, expectedTime - actualTime);
|
||||
|
||||
if (delay > 10) {
|
||||
await new Promise(resolve => setTimeout(resolve, Math.min(delay, 100)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 发送完成信号
|
||||
// 3. 验证所有块都已确认
|
||||
if (status.acknowledgedChunks.size !== totalChunks) {
|
||||
throw new Error(`文件传输不完整:${status.acknowledgedChunks.size}/${totalChunks} 块已确认`);
|
||||
}
|
||||
|
||||
// 4. 发送完成信号
|
||||
connection.sendMessage({
|
||||
type: 'file-complete',
|
||||
payload: { fileId: actualFileId }
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
updateState({ isTransferring: false, progress: 100 });
|
||||
console.log('文件发送完成:', file.name);
|
||||
console.log('文件安全发送完成:', file.name, `平均速度: ${status.averageSpeed.toFixed(2)} KB/s`);
|
||||
transferStatus.current.delete(actualFileId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送文件失败:', error);
|
||||
console.error('安全发送文件失败:', error);
|
||||
updateState({
|
||||
error: error instanceof Error ? error.message : '发送失败',
|
||||
isTransferring: false
|
||||
});
|
||||
transferStatus.current.delete(actualFileId);
|
||||
}
|
||||
}, [connection, updateState]);
|
||||
}, [connection, updateState, sendChunkWithAck]);
|
||||
|
||||
// 保持原有的 sendFile 方法用于向后兼容
|
||||
const sendFile = useCallback(async (file: File, fileId?: string) => {
|
||||
// 默认使用新的安全发送方法
|
||||
return sendFileSecure(file, fileId);
|
||||
}, [sendFileSecure]);
|
||||
|
||||
// 发送文件列表
|
||||
const sendFileList = useCallback((fileList: FileInfo[]) => {
|
||||
if (!connection.isPeerConnected) {
|
||||
// 检查连接状态 - 优先检查数据通道状态,因为 P2P 连接可能已经建立但状态未及时更新
|
||||
const channelState = connection.getChannelState();
|
||||
const peerConnected = connection.isPeerConnected;
|
||||
|
||||
console.log('发送文件列表检查:', {
|
||||
channelState,
|
||||
peerConnected,
|
||||
fileListLength: fileList.length
|
||||
});
|
||||
|
||||
// 如果数据通道已打开或者 P2P 已连接,就可以发送文件列表
|
||||
if (channelState === 'open' || peerConnected) {
|
||||
console.log('发送文件列表:', fileList);
|
||||
|
||||
connection.sendMessage({
|
||||
type: 'file-list',
|
||||
payload: fileList
|
||||
}, CHANNEL_NAME);
|
||||
} else {
|
||||
console.log('P2P连接未建立,等待连接后再发送文件列表');
|
||||
return;
|
||||
}
|
||||
|
||||
if (connection.getChannelState() !== 'open') {
|
||||
console.error('数据通道未准备就绪,无法发送文件列表');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('发送文件列表:', fileList);
|
||||
|
||||
connection.sendMessage({
|
||||
type: 'file-list',
|
||||
payload: fileList
|
||||
}, CHANNEL_NAME);
|
||||
}, [connection]);
|
||||
|
||||
// 请求文件
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { getWsUrl } from '@/lib/config';
|
||||
import { useWebRTCStore } from './webRTCStore';
|
||||
|
||||
// 基础连接状态
|
||||
interface WebRTCState {
|
||||
@@ -60,13 +61,8 @@ export interface WebRTCConnection {
|
||||
* 创建单一的 WebRTC 连接实例,供多个业务模块共享使用
|
||||
*/
|
||||
export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
const [state, setState] = useState<WebRTCState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
});
|
||||
// 使用全局状态 store
|
||||
const webrtcStore = useWebRTCStore();
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
@@ -89,8 +85,8 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
];
|
||||
|
||||
const updateState = useCallback((updates: Partial<WebRTCState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
webrtcStore.updateState(updates);
|
||||
}, [webrtcStore]);
|
||||
|
||||
// 清理连接
|
||||
const cleanup = useCallback(() => {
|
||||
@@ -203,7 +199,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
console.log('[SharedWebRTC] 🚀 开始连接到房间:', roomCode, role);
|
||||
|
||||
// 如果正在连接中,避免重复连接
|
||||
if (state.isConnecting) {
|
||||
if (webrtcStore.isConnecting) {
|
||||
console.warn('[SharedWebRTC] ⚠️ 正在连接中,跳过重复连接请求');
|
||||
return;
|
||||
}
|
||||
@@ -211,6 +207,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
// 清理之前的连接
|
||||
cleanup();
|
||||
currentRoom.current = { code: roomCode, role };
|
||||
webrtcStore.setCurrentRoom({ code: roomCode, role });
|
||||
updateState({ isConnecting: true, error: null });
|
||||
|
||||
// 注意:不在这里设置超时,因为WebSocket连接很快,
|
||||
@@ -292,11 +289,25 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
|
||||
case 'answer':
|
||||
console.log('[SharedWebRTC] 📬 处理answer...');
|
||||
if (pc.signalingState === 'have-local-offer') {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[SharedWebRTC] ✅ answer 处理完成');
|
||||
} else {
|
||||
console.warn('[SharedWebRTC] ⚠️ PeerConnection状态不是have-local-offer:', pc.signalingState);
|
||||
try {
|
||||
if (pc.signalingState === 'have-local-offer') {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[SharedWebRTC] ✅ answer 处理完成');
|
||||
} else {
|
||||
console.warn('[SharedWebRTC] ⚠️ PeerConnection状态不是have-local-offer:', pc.signalingState);
|
||||
// 如果状态不对,尝试重新创建 offer
|
||||
if (pc.connectionState === 'connected' || pc.connectionState === 'connecting') {
|
||||
console.log('[SharedWebRTC] 🔄 连接状态正常但信令状态异常,尝试重新创建offer');
|
||||
// 这里不直接处理,让连接自然建立
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] ❌ 处理answer失败:', error);
|
||||
if (error instanceof Error && error.message.includes('Failed to set local answer sdp')) {
|
||||
console.warn('[SharedWebRTC] ⚠️ Answer处理失败,可能是连接状态变化导致的');
|
||||
// 清理连接状态,让客户端重新连接
|
||||
updateState({ error: 'WebRTC连接状态异常,请重新连接', isPeerConnected: false });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -466,20 +477,14 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
isConnecting: false
|
||||
});
|
||||
}
|
||||
}, [updateState, cleanup, createOffer, handleDataChannelMessage, state.isConnecting, state.isConnected]);
|
||||
}, [updateState, cleanup, createOffer, handleDataChannelMessage, webrtcStore.isConnecting, webrtcStore.isConnected]);
|
||||
|
||||
// 断开连接
|
||||
const disconnect = useCallback(() => {
|
||||
console.log('[SharedWebRTC] 断开连接');
|
||||
cleanup();
|
||||
setState({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
});
|
||||
}, [cleanup]);
|
||||
webrtcStore.reset();
|
||||
}, [cleanup, webrtcStore]);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
|
||||
@@ -549,8 +554,8 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
return currentRoom.current?.code === roomCode &&
|
||||
currentRoom.current?.role === role &&
|
||||
state.isConnected;
|
||||
}, [state.isConnected]);
|
||||
webrtcStore.isConnected;
|
||||
}, [webrtcStore.isConnected]);
|
||||
|
||||
// 添加媒体轨道
|
||||
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
|
||||
@@ -632,11 +637,11 @@ export function useSharedWebRTCManager(): WebRTCConnection {
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isConnected: state.isConnected,
|
||||
isConnecting: state.isConnecting,
|
||||
isWebSocketConnected: state.isWebSocketConnected,
|
||||
isPeerConnected: state.isPeerConnected,
|
||||
error: state.error,
|
||||
isConnected: webrtcStore.isConnected,
|
||||
isConnecting: webrtcStore.isConnecting,
|
||||
isWebSocketConnected: webrtcStore.isWebSocketConnected,
|
||||
isPeerConnected: webrtcStore.isPeerConnected,
|
||||
error: webrtcStore.error,
|
||||
|
||||
// 操作方法
|
||||
connect,
|
||||
|
||||
41
chuan-next/src/hooks/webrtc/webRTCStore.ts
Normal file
41
chuan-next/src/hooks/webrtc/webRTCStore.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface WebRTCState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
isPeerConnected: boolean;
|
||||
error: string | null;
|
||||
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
|
||||
}
|
||||
|
||||
interface WebRTCStore extends WebRTCState {
|
||||
updateState: (updates: Partial<WebRTCState>) => void;
|
||||
setCurrentRoom: (room: { code: string; role: 'sender' | 'receiver' } | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState: WebRTCState = {
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
currentRoom: null,
|
||||
};
|
||||
|
||||
export const useWebRTCStore = create<WebRTCStore>((set) => ({
|
||||
...initialState,
|
||||
|
||||
updateState: (updates) => set((state) => ({
|
||||
...state,
|
||||
...updates,
|
||||
})),
|
||||
|
||||
setCurrentRoom: (room) => set((state) => ({
|
||||
...state,
|
||||
currentRoom: room,
|
||||
})),
|
||||
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
74
chuan-next/tailwind.config.js
Normal file
74
chuan-next/tailwind.config.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
borderWidth: {
|
||||
'3': '3px',
|
||||
'4': '4px',
|
||||
},
|
||||
boxShadow: {
|
||||
'cartoon-sm': '2px 2px 0 #000',
|
||||
'cartoon': '4px 4px 0 #000',
|
||||
'cartoon-md': '6px 6px 0 #000',
|
||||
'cartoon-lg': '8px 8px 0 #000',
|
||||
'cartoon-xl': '10px 10px 0 #000',
|
||||
},
|
||||
animation: {
|
||||
'bounce-in': 'bounce-in 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55)',
|
||||
'wiggle': 'wiggle 0.5s ease-in-out infinite',
|
||||
'float-cartoon': 'float-cartoon 4s ease-in-out infinite',
|
||||
'rainbow': 'rainbow 5s ease infinite',
|
||||
'gradient-shift': 'gradientShift 15s ease infinite',
|
||||
},
|
||||
fontFamily: {
|
||||
'cartoon': ['"Comic Sans MS"', '"Chalkboard SE"', '"Comic Neue"', 'cursive'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -3530,3 +3530,8 @@ yocto-queue@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
|
||||
zustand@^5.0.7:
|
||||
version "5.0.7"
|
||||
resolved "https://registry.npmmirror.com/zustand/-/zustand-5.0.7.tgz#e325364e82c992a84bf386d8445aa7f180c450dc"
|
||||
integrity sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==
|
||||
|
||||
Reference in New Issue
Block a user