mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-24 07:44:44 +08:00
feat:统一连接层,精简前后端
This commit is contained in:
@@ -3,25 +3,40 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Upload, MessageSquare, Monitor, Github, ExternalLink } from 'lucide-react';
|
||||
import { Upload, MessageSquare, Monitor } from 'lucide-react';
|
||||
import Hero from '@/components/Hero';
|
||||
import TextTransfer from '@/components/TextTransfer';
|
||||
import DesktopShare from '@/components/DesktopShare';
|
||||
import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { WebRTCTextImageTransfer } from '@/components/WebRTCTextImageTransfer';
|
||||
|
||||
export default function HomePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState('webrtc');
|
||||
const [activeTab, setActiveTab] = useState('message');
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
// 根据URL参数设置初始标签(仅首次加载时)
|
||||
useEffect(() => {
|
||||
if (!hasInitialized) {
|
||||
const urlType = searchParams.get('type');
|
||||
if (urlType && ['webrtc', 'text', 'desktop'].includes(urlType)) {
|
||||
|
||||
console.log('=== HomePage URL处理 ===');
|
||||
console.log('URL type参数:', urlType);
|
||||
console.log('所有搜索参数:', Object.fromEntries(searchParams.entries()));
|
||||
|
||||
// 将旧的text类型重定向到message
|
||||
if (urlType === 'text') {
|
||||
console.log('检测到text类型,重定向到message标签页');
|
||||
setActiveTab('message');
|
||||
} else if (urlType === 'webrtc') {
|
||||
// webrtc类型对应文件传输标签页
|
||||
console.log('检测到webrtc类型,切换到webrtc标签页(文件传输)');
|
||||
setActiveTab('webrtc');
|
||||
} else if (urlType && ['message', 'desktop'].includes(urlType)) {
|
||||
console.log('切换到对应标签页:', urlType);
|
||||
setActiveTab(urlType);
|
||||
} else {
|
||||
console.log('没有有效的type参数,保持默认标签页');
|
||||
}
|
||||
|
||||
setHasInitialized(true);
|
||||
}
|
||||
}, [searchParams, hasInitialized]);
|
||||
@@ -39,27 +54,26 @@ export default function HomePage() {
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
{/* Tabs Navigation - 横向布局 */}
|
||||
<div className="mb-6">
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-lg mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-xl mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">
|
||||
<TabsTrigger
|
||||
value="webrtc"
|
||||
className="flex items-center justify-center space-x-2 px-4 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-blue-600"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-blue-600"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">文件传输</span>
|
||||
<span className="sm:hidden">文件</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="text"
|
||||
className="flex items-center justify-center space-x-2 px-4 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-emerald-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-emerald-600 relative"
|
||||
value="message"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-emerald-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-emerald-600"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">文本传输</span>
|
||||
<span className="sm:hidden">文本</span>
|
||||
<span className="text-xs bg-orange-100 text-orange-600 px-1.5 py-0.5 rounded ml-1 absolute -top-1 -right-1">开发中</span>
|
||||
<span className="hidden sm:inline">文本消息</span>
|
||||
<span className="sm:hidden">消息</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="desktop"
|
||||
className="flex items-center justify-center space-x-2 px-4 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-purple-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-purple-600 relative"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-purple-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-purple-600 relative"
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">共享桌面</span>
|
||||
@@ -75,24 +89,8 @@ export default function HomePage() {
|
||||
<WebRTCFileTransfer />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="text" className="mt-0 animate-fade-in-up">
|
||||
<div className="max-w-md mx-auto p-8 bg-white/90 backdrop-blur-sm rounded-2xl shadow-lg border border-slate-200">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-emerald-100 to-emerald-200 rounded-full flex items-center justify-center">
|
||||
<MessageSquare className="w-8 h-8 text-emerald-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-800 mb-2">文字传输</h3>
|
||||
<p className="text-slate-600 mb-4">此功能正在开发中...</p>
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<p className="text-sm text-emerald-700">
|
||||
🚧 敬请期待!我们正在为您开发更便捷的文字传输功能
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-4">
|
||||
目前请使用文件传输功能
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="message" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCTextImageTransfer />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import HomePage from './HomePage-new';
|
||||
import HomePage from './HomePage';
|
||||
|
||||
function HomePageWrapper() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">加载中...</div>}>
|
||||
<HomePage />
|
||||
</Suspense>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const GO_BACKEND_URL = process.env.GO_BACKEND_URL || 'http://localhost:8080';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
console.log('API Route: Creating text room, proxying to:', `${GO_BACKEND_URL}/api/create-text-room`);
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(`${GO_BACKEND_URL}/api/create-text-room`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
console.log('Backend response status:', response.status);
|
||||
console.log('Backend response headers:', Object.fromEntries(response.headers.entries()));
|
||||
|
||||
// 检查响应的 Content-Type
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
console.error('Backend returned non-JSON response:', text);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Backend returned invalid response',
|
||||
details: `Expected JSON, got: ${contentType}`,
|
||||
response: text
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Backend response data:', data);
|
||||
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
console.error('API Route Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create text room', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const GO_BACKEND_URL = process.env.GO_BACKEND_URL || 'http://localhost:8080';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get('code');
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '取件码不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('API Route: Getting WebRTC room status, proxying to:', `${GO_BACKEND_URL}/api/webrtc-room-status?code=${code}`);
|
||||
|
||||
const response = await fetch(`${GO_BACKEND_URL}/api/webrtc-room-status?code=${code}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Backend response:', response.status, data);
|
||||
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (error) {
|
||||
console.error('API Route Error:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '获取房间状态失败', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
const [pickupCode, setPickupCode] = useState('');
|
||||
const [mode, setMode] = useState<'send' | 'receive'>('send');
|
||||
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
|
||||
const [isJoiningRoom, setIsJoiningRoom] = useState(false); // 添加加入房间状态
|
||||
const urlProcessedRef = useRef(false); // 使用 ref 防止重复处理 URL
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
@@ -55,8 +57,94 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
onFileProgress
|
||||
} = useWebRTCTransfer();
|
||||
|
||||
// 加入房间 (接收模式) - 提前定义以供 useEffect 使用
|
||||
const joinRoom = useCallback(async (code: string) => {
|
||||
console.log('=== 加入房间 ===');
|
||||
console.log('取件码:', code);
|
||||
|
||||
const trimmedCode = code.trim();
|
||||
|
||||
// 检查取件码格式
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
showToast('请输入正确的6位取件码', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复调用 - 检查是否已经在连接或已连接
|
||||
if (isConnecting || isConnected || isJoiningRoom) {
|
||||
console.log('已在连接中或已连接,跳过重复的房间状态检查');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJoiningRoom(true);
|
||||
|
||||
try {
|
||||
// 先检查房间状态
|
||||
console.log('检查房间状态...');
|
||||
|
||||
const response = await fetch(`/api/room-info?code=${trimmedCode}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
let errorMessage = result.message || '房间不存在或已过期';
|
||||
if (result.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (result.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查取件码是否正确';
|
||||
}
|
||||
showToast(errorMessage, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查发送方是否在线 (使用新的字段名)
|
||||
if (!result.sender_online) {
|
||||
showToast('发送方不在线,请确认取件码是否正确或联系发送方', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('房间状态检查通过,开始连接...');
|
||||
setPickupCode(trimmedCode);
|
||||
|
||||
connect(trimmedCode, 'receiver');
|
||||
|
||||
showToast(`正在连接到房间: ${trimmedCode}`, "success");
|
||||
} catch (error) {
|
||||
console.error('检查房间状态失败:', error);
|
||||
let errorMessage = '检查房间状态失败';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('network') || error.message.includes('fetch')) {
|
||||
errorMessage = '网络连接失败,请检查网络状况';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
errorMessage = '请求超时,请重试';
|
||||
} else if (error.message.includes('HTTP 404')) {
|
||||
errorMessage = '房间不存在,请检查取件码';
|
||||
} else if (error.message.includes('HTTP 500')) {
|
||||
errorMessage = '服务器错误,请稍后重试';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsJoiningRoom(false); // 重置加入房间状态
|
||||
}
|
||||
}, [isConnecting, isConnected, isJoiningRoom, showToast, connect]); // 添加isJoiningRoom依赖
|
||||
|
||||
// 从URL参数中获取初始模式(仅在首次加载时处理)
|
||||
useEffect(() => {
|
||||
// 使用 ref 确保只处理一次,避免严格模式的重复调用
|
||||
if (urlProcessedRef.current) {
|
||||
console.log('URL已处理过,跳过重复处理');
|
||||
return;
|
||||
}
|
||||
|
||||
const urlMode = searchParams.get('mode') as 'send' | 'receive';
|
||||
const type = searchParams.get('type');
|
||||
const code = searchParams.get('code');
|
||||
@@ -66,16 +154,88 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
console.log('=== 处理初始URL参数 ===');
|
||||
console.log('URL模式:', urlMode, '类型:', type, '取件码:', code);
|
||||
|
||||
// 立即标记为已处理,防止重复
|
||||
urlProcessedRef.current = true;
|
||||
|
||||
setMode(urlMode);
|
||||
setHasProcessedInitialUrl(true);
|
||||
|
||||
if (code && urlMode === 'receive') {
|
||||
// 自动加入房间,使用房间状态检查
|
||||
console.log('URL中有取件码,自动加入房间');
|
||||
joinRoom(code);
|
||||
// 防止重复调用 - 检查连接状态和加入房间状态
|
||||
if (!isConnecting && !isConnected && !isJoiningRoom) {
|
||||
// 直接调用异步函数,不依赖 joinRoom
|
||||
const autoJoinRoom = async () => {
|
||||
const trimmedCode = code.trim();
|
||||
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
showToast('请输入正确的6位取件码', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJoiningRoom(true);
|
||||
|
||||
try {
|
||||
console.log('检查房间状态...');
|
||||
const response = await fetch(`/api/room-info?code=${trimmedCode}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
let errorMessage = result.message || '房间不存在或已过期';
|
||||
if (result.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (result.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查取件码是否正确';
|
||||
}
|
||||
showToast(errorMessage, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.sender_online) {
|
||||
showToast('发送方不在线,请确认取件码是否正确或联系发送方', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('房间状态检查通过,开始连接...');
|
||||
setPickupCode(trimmedCode);
|
||||
connect(trimmedCode, 'receiver');
|
||||
showToast(`正在连接到房间: ${trimmedCode}`, "success");
|
||||
} catch (error) {
|
||||
console.error('检查房间状态失败:', error);
|
||||
let errorMessage = '检查房间状态失败';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('network') || error.message.includes('fetch')) {
|
||||
errorMessage = '网络连接失败,请检查网络状况';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
errorMessage = '请求超时,请重试';
|
||||
} else if (error.message.includes('HTTP 404')) {
|
||||
errorMessage = '房间不存在,请检查取件码';
|
||||
} else if (error.message.includes('HTTP 500')) {
|
||||
errorMessage = '服务器错误,请稍后重试';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsJoiningRoom(false);
|
||||
}
|
||||
};
|
||||
|
||||
autoJoinRoom();
|
||||
} else {
|
||||
console.log('已在连接中或加入房间中,跳过重复处理');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [searchParams, hasProcessedInitialUrl]);
|
||||
}, [searchParams, hasProcessedInitialUrl, isConnecting, isConnected, isJoiningRoom, showToast, connect]); // 添加isJoiningRoom依赖
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = useCallback((newMode: 'send' | 'receive') => {
|
||||
@@ -150,6 +310,7 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'file',
|
||||
files: selectedFiles.map(file => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
@@ -176,50 +337,21 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
showToast(`房间创建成功,取件码: ${code}`, "success");
|
||||
} catch (error) {
|
||||
console.error('创建房间失败:', error);
|
||||
showToast(error instanceof Error ? error.message : '网络错误,请重试', "error");
|
||||
}
|
||||
};
|
||||
|
||||
// 加入房间 (接收模式)
|
||||
const joinRoom = async (code: string) => {
|
||||
console.log('=== 加入房间 ===');
|
||||
console.log('取件码:', code);
|
||||
|
||||
const trimmedCode = code.trim();
|
||||
|
||||
// 检查取件码格式
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
showToast('请输入正确的6位取件码', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 先检查房间状态
|
||||
console.log('检查房间状态...');
|
||||
showToast('正在检查房间状态...', "info");
|
||||
let errorMessage = '创建房间失败';
|
||||
|
||||
const response = await fetch(`/api/webrtc-room-status?code=${trimmedCode}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
showToast(result.message || '房间不存在或已过期', "error");
|
||||
return;
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('network') || error.message.includes('fetch')) {
|
||||
errorMessage = '网络连接失败,请检查网络后重试';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
errorMessage = '请求超时,请重试';
|
||||
} else if (error.message.includes('server') || error.message.includes('500')) {
|
||||
errorMessage = '服务器错误,请稍后重试';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查发送方是否在线
|
||||
if (!result.sender_online) {
|
||||
showToast('发送方不在线,请确认取件码是否正确', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('房间状态检查通过,开始连接...');
|
||||
setPickupCode(trimmedCode);
|
||||
connect(trimmedCode, 'receiver');
|
||||
|
||||
showToast(`正在连接到房间: ${trimmedCode}`, "info");
|
||||
} catch (error) {
|
||||
console.error('检查房间状态失败:', error);
|
||||
showToast('检查房间状态失败,请重试', "error");
|
||||
showToast(errorMessage, "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -259,15 +391,48 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
}, [onFileListReceived, mode]);
|
||||
|
||||
// 处理连接错误
|
||||
const [lastError, setLastError] = useState<string>('');
|
||||
useEffect(() => {
|
||||
if (error && mode === 'receive') {
|
||||
if (error && error !== lastError) {
|
||||
console.log('=== 连接错误处理 ===');
|
||||
console.log('错误信息:', error);
|
||||
console.log('当前模式:', mode);
|
||||
|
||||
// 根据错误类型显示不同的提示
|
||||
let errorMessage = error;
|
||||
|
||||
if (error.includes('WebSocket')) {
|
||||
errorMessage = '服务器连接失败,请检查网络连接或稍后重试';
|
||||
} else if (error.includes('数据通道')) {
|
||||
errorMessage = '数据通道连接失败,请重新尝试连接';
|
||||
} else if (error.includes('连接超时')) {
|
||||
errorMessage = '连接超时,请检查网络状况或重新尝试';
|
||||
} else if (error.includes('连接失败')) {
|
||||
errorMessage = 'WebRTC连接失败,可能是网络环境限制,请尝试刷新页面';
|
||||
} else if (error.includes('信令错误')) {
|
||||
errorMessage = '信令服务器错误,请稍后重试';
|
||||
} else if (error.includes('创建连接失败')) {
|
||||
errorMessage = '无法建立P2P连接,请检查网络设置';
|
||||
}
|
||||
|
||||
// 显示错误提示
|
||||
showToast(`连接失败: ${error}`, "error");
|
||||
showToast(errorMessage, "error");
|
||||
setLastError(error);
|
||||
|
||||
// 如果是严重连接错误,清理传输状态
|
||||
if (error.includes('连接失败') || error.includes('数据通道连接失败') || error.includes('WebSocket')) {
|
||||
console.log('严重连接错误,清理传输状态');
|
||||
setCurrentTransferFile(null);
|
||||
|
||||
// 重置所有正在传输的文件状态
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
));
|
||||
}
|
||||
}
|
||||
}, [error, mode, showToast]);
|
||||
}, [error, mode, showToast, lastError]);
|
||||
|
||||
// 处理文件接收
|
||||
useEffect(() => {
|
||||
@@ -285,15 +450,21 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
: item
|
||||
));
|
||||
|
||||
showToast(`${fileData.file.name} 已准备好下载`, "success");
|
||||
// 移除不必要的Toast - 文件完成状态在UI中已经显示
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileReceived, showToast]);
|
||||
}, [onFileReceived]);
|
||||
|
||||
// 监听文件级别的进度更新
|
||||
useEffect(() => {
|
||||
const cleanup = onFileProgress((progressInfo) => {
|
||||
// 检查连接状态,如果连接断开则忽略进度更新
|
||||
if (!isConnected || error) {
|
||||
console.log('连接已断开,忽略进度更新:', progressInfo.fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 文件进度更新 ===');
|
||||
console.log('文件:', progressInfo.fileName, 'ID:', progressInfo.fileId, '进度:', progressInfo.progress);
|
||||
|
||||
@@ -318,13 +489,13 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
|
||||
// 当传输完成时显示提示
|
||||
if (progressInfo.progress >= 100 && mode === 'send') {
|
||||
showToast(`文件发送完成: ${progressInfo.fileName}`, "success");
|
||||
// 移除不必要的Toast - 传输完成状态在UI中已经显示
|
||||
setCurrentTransferFile(null);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileProgress, mode, showToast]);
|
||||
}, [onFileProgress, mode, isConnected, error]);
|
||||
|
||||
// 实时更新传输进度(旧逻辑 - 删除)
|
||||
// useEffect(() => {
|
||||
@@ -338,6 +509,13 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
console.log('文件:', fileName, 'ID:', fileId, '当前模式:', mode);
|
||||
|
||||
if (mode === 'send') {
|
||||
// 检查连接状态
|
||||
if (!isConnected || error) {
|
||||
console.log('连接已断开,无法发送文件');
|
||||
showToast('连接已断开,无法发送文件', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('当前选中的文件列表:', selectedFiles.map(f => f.name));
|
||||
|
||||
// 在发送方的selectedFiles中查找对应文件
|
||||
@@ -360,30 +538,111 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
));
|
||||
|
||||
// 发送文件
|
||||
sendFile(file, fileId);
|
||||
|
||||
// 显示开始传输的提示
|
||||
showToast(`开始发送文件: ${fileName}`, "info");
|
||||
try {
|
||||
sendFile(file, fileId);
|
||||
|
||||
// 移除不必要的Toast - 传输开始状态在UI中已经显示
|
||||
} catch (sendError) {
|
||||
console.error('发送文件失败:', sendError);
|
||||
showToast(`发送文件失败: ${fileName}`, "error");
|
||||
|
||||
// 重置文件状态
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.id === fileId || item.name === fileName
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
));
|
||||
}
|
||||
} else {
|
||||
console.warn('接收模式下收到文件请求,忽略');
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileRequested, mode, selectedFiles, sendFile, showToast]);
|
||||
}, [onFileRequested, mode, selectedFiles, sendFile, isConnected, error]);
|
||||
|
||||
// 连接状态变化时同步文件列表
|
||||
// 监听WebSocket连接状态变化
|
||||
useEffect(() => {
|
||||
console.log('=== 连接状态变化 ===');
|
||||
console.log('=== WebSocket状态变化 ===');
|
||||
console.log('WebSocket连接状态:', isWebSocketConnected);
|
||||
console.log('WebRTC连接状态:', isConnected);
|
||||
console.log('连接中状态:', isConnecting);
|
||||
|
||||
// 如果WebSocket断开但不是主动断开的情况
|
||||
if (!isWebSocketConnected && !isConnected && !isConnecting && pickupCode) {
|
||||
showToast('与服务器的连接已断开,请重新连接', "error");
|
||||
|
||||
// 清理传输状态
|
||||
console.log('WebSocket断开,清理传输状态');
|
||||
setCurrentTransferFile(null);
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
));
|
||||
}
|
||||
|
||||
// WebSocket连接成功时的提示
|
||||
if (isWebSocketConnected && isConnecting && !isConnected) {
|
||||
console.log('WebSocket已连接,正在建立P2P连接...');
|
||||
}
|
||||
|
||||
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, showToast]);
|
||||
|
||||
// 监听连接状态变化,清理传输状态
|
||||
useEffect(() => {
|
||||
// 当连接断开或有错误时,清理所有传输状态
|
||||
if ((!isConnected && !isConnecting) || error) {
|
||||
if (currentTransferFile) {
|
||||
console.log('连接断开,清理当前传输文件状态:', currentTransferFile.fileName);
|
||||
setCurrentTransferFile(null);
|
||||
}
|
||||
|
||||
// 重置所有正在下载的文件状态
|
||||
setFileList(prev => {
|
||||
const hasDownloadingFiles = prev.some(item => item.status === 'downloading');
|
||||
if (hasDownloadingFiles) {
|
||||
console.log('重置正在传输的文件状态');
|
||||
return prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [isConnected, isConnecting, error, currentTransferFile]);
|
||||
|
||||
// 监听连接状态变化并提供用户反馈
|
||||
useEffect(() => {
|
||||
console.log('=== WebRTC连接状态变化 ===');
|
||||
console.log('连接状态:', {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isWebSocketConnected,
|
||||
pickupCode,
|
||||
mode,
|
||||
selectedFilesCount: selectedFiles.length,
|
||||
fileListCount: fileList.length
|
||||
});
|
||||
|
||||
if (isConnected && pickupCode && mode === 'send' && selectedFiles.length > 0) {
|
||||
// 连接成功时的提示
|
||||
if (isConnected && !isConnecting) {
|
||||
if (mode === 'send') {
|
||||
// 移除不必要的Toast - 连接状态在UI中已经显示
|
||||
} else {
|
||||
// 移除不必要的Toast - 连接状态在UI中已经显示
|
||||
}
|
||||
}
|
||||
|
||||
// 连接中的状态
|
||||
if (isConnecting && pickupCode) {
|
||||
console.log('正在建立WebRTC连接...');
|
||||
}
|
||||
|
||||
// 只有在连接成功且没有错误时才发送文件列表
|
||||
if (isConnected && !error && pickupCode && mode === 'send' && selectedFiles.length > 0) {
|
||||
// 确保有文件列表
|
||||
if (fileList.length === 0) {
|
||||
console.log('创建文件列表并发送...');
|
||||
@@ -398,17 +657,21 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
setFileList(newFileInfos);
|
||||
// 延迟发送,确保数据通道已准备好
|
||||
setTimeout(() => {
|
||||
sendFileList(newFileInfos);
|
||||
if (isConnected && !error) { // 再次检查连接状态
|
||||
sendFileList(newFileInfos);
|
||||
}
|
||||
}, 500);
|
||||
} else if (fileList.length > 0) {
|
||||
console.log('发送现有文件列表...');
|
||||
// 延迟发送,确保数据通道已准备好
|
||||
setTimeout(() => {
|
||||
sendFileList(fileList);
|
||||
if (isConnected && !error) { // 再次检查连接状态
|
||||
sendFileList(fileList);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}, [isConnected, pickupCode, mode, selectedFiles.length]);
|
||||
}, [isConnected, isConnecting, isWebSocketConnected, pickupCode, mode, selectedFiles.length, error]);
|
||||
|
||||
// 请求下载文件(接收方调用)
|
||||
const requestFile = (fileId: string) => {
|
||||
@@ -417,9 +680,16 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查连接状态
|
||||
if (!isConnected || error) {
|
||||
showToast('连接已断开,请重新连接后再试', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInfo = fileList.find(f => f.id === fileId);
|
||||
if (!fileInfo) {
|
||||
console.error('找不到文件信息:', fileId);
|
||||
showToast('找不到文件信息', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -441,22 +711,33 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
|
||||
// 使用hook的requestFile功能
|
||||
console.log('调用hook的requestFile...');
|
||||
requestFileFromHook(fileId, fileInfo.name);
|
||||
|
||||
showToast(`正在请求文件: ${fileInfo.name}`, "info");
|
||||
try {
|
||||
requestFileFromHook(fileId, fileInfo.name);
|
||||
// 移除不必要的Toast - 请求状态在UI中已经显示
|
||||
} catch (requestError) {
|
||||
console.error('请求文件失败:', requestError);
|
||||
showToast(`请求文件失败: ${fileInfo.name}`, "error");
|
||||
|
||||
// 重置文件状态
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.id === fileId
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 复制取件码
|
||||
const copyCode = () => {
|
||||
navigator.clipboard.writeText(pickupCode);
|
||||
showToast("取件码已复制到剪贴板", "success");
|
||||
showToast("取件码已复制", "success");
|
||||
};
|
||||
|
||||
// 复制链接
|
||||
const copyLink = () => {
|
||||
const link = `${window.location.origin}?type=webrtc&mode=receive&code=${pickupCode}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
showToast("取件链接已复制到剪贴板", "success");
|
||||
showToast("取件链接已复制", "success");
|
||||
};
|
||||
|
||||
// 重置状态
|
||||
@@ -515,13 +796,6 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
|
||||
const pickupLink = pickupCode ? `${typeof window !== 'undefined' ? window.location.origin : ''}?type=webrtc&mode=receive&code=${pickupCode}` : '';
|
||||
|
||||
// 显示错误信息
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
showToast(error, "error");
|
||||
}
|
||||
}, [error, showToast]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 模式切换 */}
|
||||
|
||||
765
chuan-next/src/components/WebRTCTextImageTransfer.tsx
Normal file
765
chuan-next/src/components/WebRTCTextImageTransfer.tsx
Normal file
@@ -0,0 +1,765 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useWebRTCTransfer } from '@/hooks/useWebRTCTransfer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { MessageSquare, Image, Send, Copy, Link, Upload, Download, X } from 'lucide-react';
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
type: 'text' | 'image';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
sender: 'self' | 'peer';
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const WebRTCTextImageTransfer: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 状态管理
|
||||
const [mode, setMode] = useState<'send' | 'receive'>('send');
|
||||
const [pickupCode, setPickupCode] = useState('');
|
||||
const [inputCode, setInputCode] = useState('');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [textInput, setTextInput] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
|
||||
// Refs
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const {
|
||||
text: textTransfer,
|
||||
file: fileTransfer,
|
||||
connectAll,
|
||||
disconnectAll,
|
||||
hasAnyConnection,
|
||||
isAnyConnecting,
|
||||
hasAnyError
|
||||
} = useWebRTCTransfer();
|
||||
|
||||
// 加入房间 - 提前定义以供 useEffect 使用
|
||||
const joinRoom = useCallback(async (code: string) => {
|
||||
const trimmedCode = code.trim().toUpperCase();
|
||||
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
showToast('请输入正确的6位取件码', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已经在连接或已经连接
|
||||
if (isAnyConnecting) {
|
||||
console.log('已经在连接中,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAnyConnection) {
|
||||
console.log('已经连接,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPickupCode(trimmedCode);
|
||||
|
||||
await connectAll(trimmedCode, 'receiver');
|
||||
|
||||
showToast(`成功加入消息房间: ${trimmedCode}`, "success");
|
||||
} catch (error) {
|
||||
console.error('加入房间失败:', error);
|
||||
showToast(error instanceof Error ? error.message : '加入房间失败', "error");
|
||||
}
|
||||
}, [isAnyConnecting, hasAnyConnection, connectAll]);
|
||||
|
||||
// 从URL参数中获取初始模式
|
||||
useEffect(() => {
|
||||
const urlMode = searchParams.get('mode') as 'send' | 'receive';
|
||||
const type = searchParams.get('type');
|
||||
const code = searchParams.get('code');
|
||||
|
||||
if (!hasProcessedInitialUrl && type === 'message' && urlMode && ['send', 'receive'].includes(urlMode)) {
|
||||
console.log('=== 处理初始URL参数 ===');
|
||||
console.log('URL模式:', urlMode, '类型:', type, '取件码:', code);
|
||||
|
||||
setMode(urlMode);
|
||||
setHasProcessedInitialUrl(true);
|
||||
|
||||
if (code && urlMode === 'receive') {
|
||||
setInputCode(code);
|
||||
// 延迟执行连接,避免重复调用
|
||||
const timeoutId = setTimeout(() => {
|
||||
// 检查是否已经连接或正在连接
|
||||
if (!hasAnyConnection && !isAnyConnecting) {
|
||||
joinRoom(code);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}, [searchParams, hasProcessedInitialUrl, hasAnyConnection, isAnyConnecting, joinRoom]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = useCallback((newMode: 'send' | 'receive') => {
|
||||
console.log('=== 切换模式 ===', newMode);
|
||||
|
||||
setMode(newMode);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', 'message');
|
||||
params.set('mode', newMode);
|
||||
|
||||
if (newMode === 'send') {
|
||||
params.delete('code');
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
|
||||
// 监听文本消息
|
||||
useEffect(() => {
|
||||
const cleanup = textTransfer.onMessageReceived((message) => {
|
||||
setMessages(prev => [...prev, {
|
||||
id: message.id,
|
||||
type: 'text',
|
||||
content: message.text,
|
||||
timestamp: new Date(message.timestamp),
|
||||
sender: 'peer'
|
||||
}]);
|
||||
|
||||
// 移除不必要的Toast提示 - 消息在聊天界面中已经显示了
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [textTransfer.onMessageReceived]);
|
||||
|
||||
// 监听打字状态
|
||||
useEffect(() => {
|
||||
const cleanup = textTransfer.onTypingStatus((typing) => {
|
||||
setIsTyping(typing);
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [textTransfer.onTypingStatus]);
|
||||
|
||||
// 监听文件(图片)接收
|
||||
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;
|
||||
setMessages(prev => [...prev, {
|
||||
id: fileData.id,
|
||||
type: 'image',
|
||||
content: imageData,
|
||||
timestamp: new Date(),
|
||||
sender: 'peer',
|
||||
fileName: fileData.file.name
|
||||
}]);
|
||||
|
||||
// 移除不必要的Toast提示 - 图片在聊天界面中已经显示了
|
||||
};
|
||||
reader.readAsDataURL(fileData.file);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [fileTransfer.onFileReceived]);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// 创建空房间
|
||||
const createRoom = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/create-room', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'message',
|
||||
initialText: textInput.trim() || '',
|
||||
hasImages: false,
|
||||
maxFileSize: 5 * 1024 * 1024,
|
||||
settings: {
|
||||
enableRealTimeText: true,
|
||||
enableImageTransfer: true
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '创建房间失败');
|
||||
}
|
||||
|
||||
const code = data.code;
|
||||
setPickupCode(code);
|
||||
|
||||
await connectAll(code, 'sender');
|
||||
|
||||
// 如果有初始文本,发送它
|
||||
if (textInput.trim()) {
|
||||
setTimeout(() => {
|
||||
sendTextMessage();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
showToast(`消息房间创建成功!取件码: ${code}`, "success");
|
||||
} catch (error) {
|
||||
console.error('创建房间失败:', error);
|
||||
showToast(error instanceof Error ? error.message : '创建房间失败', "error");
|
||||
}
|
||||
};
|
||||
|
||||
// 发送文本消息
|
||||
const sendTextMessage = () => {
|
||||
if (!textInput.trim() || !textTransfer.isConnected) return;
|
||||
|
||||
const message = {
|
||||
id: `msg_${Date.now()}`,
|
||||
type: 'text' as const,
|
||||
content: textInput.trim(),
|
||||
timestamp: new Date(),
|
||||
sender: 'self' as const
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, message]);
|
||||
textTransfer.sendMessage(textInput.trim());
|
||||
setTextInput('');
|
||||
|
||||
// 重置自动调整高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = '40px';
|
||||
}
|
||||
};
|
||||
|
||||
// 处理文本输入变化(实时同步)
|
||||
const handleTextInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setTextInput(value);
|
||||
|
||||
// 自动调整高度
|
||||
const textarea = e.target;
|
||||
textarea.style.height = '40px';
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
|
||||
|
||||
// 发送实时文字(如果已连接)
|
||||
if (textTransfer.isConnected) {
|
||||
// 发送打字状态
|
||||
textTransfer.sendTypingStatus(value.length > 0);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 设置新的定时器来停止打字状态
|
||||
if (value.length > 0) {
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
textTransfer.sendTypingStatus(false);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理图片选择
|
||||
const handleImageSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showToast('请选择图片文件', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
showToast('图片文件大小不能超过5MB', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const imageData = e.target?.result as string;
|
||||
const message = {
|
||||
id: `img_${Date.now()}`,
|
||||
type: 'image' as const,
|
||||
content: imageData,
|
||||
timestamp: new Date(),
|
||||
sender: 'self' as const,
|
||||
fileName: file.name
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, message]);
|
||||
|
||||
if (fileTransfer.isConnected) {
|
||||
fileTransfer.sendFile(file);
|
||||
// 移除发送图片的Toast提示 - 图片在聊天界面中已经显示了
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
// 复制分享链接
|
||||
const copyShareLink = () => {
|
||||
const baseUrl = window.location.origin + window.location.pathname;
|
||||
const shareLink = `${baseUrl}?type=message&mode=receive&code=${pickupCode}`;
|
||||
|
||||
navigator.clipboard.writeText(shareLink).then(() => {
|
||||
showToast('分享链接已复制', "success");
|
||||
}).catch(() => {
|
||||
showToast('复制失败,请手动复制', "error");
|
||||
});
|
||||
};
|
||||
|
||||
// 复制取件码
|
||||
const copyCode = () => {
|
||||
navigator.clipboard.writeText(pickupCode);
|
||||
showToast("取件码已复制", "success");
|
||||
};
|
||||
|
||||
// 重新开始
|
||||
const restart = () => {
|
||||
setPickupCode('');
|
||||
setInputCode('');
|
||||
setMessages([]);
|
||||
setTextInput('');
|
||||
setIsTyping(false);
|
||||
setPreviewImage(null);
|
||||
disconnectAll();
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', 'message');
|
||||
params.set('mode', mode);
|
||||
params.delete('code');
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const pickupLink = pickupCode ? `${typeof window !== 'undefined' ? window.location.origin : ''}?type=message&mode=receive&code=${pickupCode}` : '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 模式切换 */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
|
||||
<Button
|
||||
variant={mode === 'send' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('send')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
发送消息
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'receive' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('receive')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
接收消息
|
||||
</Button>
|
||||
</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">
|
||||
{mode === 'send' ? (
|
||||
<div className="space-y-6">
|
||||
{!pickupCode ? (
|
||||
// 创建房间前的界面 - 和文件传输完全一致的结构
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-green-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>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<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-green-500' : '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 ${textTransfer.isConnected ? 'bg-green-500' : 'bg-slate-400'}`}></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息输入区域 */}
|
||||
<div className="border-2 border-dashed border-slate-300 rounded-xl p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
消息内容
|
||||
</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={textInput}
|
||||
onChange={handleTextInputChange}
|
||||
placeholder="输入要发送的消息..."
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent resize-none"
|
||||
rows={4}
|
||||
style={{ minHeight: '100px', maxHeight: '200px' }}
|
||||
/>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
{textInput.length}/50000 字符 • 支持实时同步
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图片上传按钮 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Image className="w-4 h-4" />
|
||||
<span>添加图片</span>
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">支持 JPG, PNG, GIF 格式,最大 5MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 创建房间按钮 */}
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={createRoom}
|
||||
disabled={isAnyConnecting}
|
||||
className="px-8 py-3 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl shadow-lg"
|
||||
>
|
||||
{isAnyConnecting ? '创建中...' : '创建消息房间'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 房间已创建,显示取件码和聊天界面
|
||||
<div className="space-y-6">
|
||||
{/* 取件码显示 */}
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-emerald-800 mb-4">消息房间已创建</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm text-emerald-700 mb-2">取件码</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-3xl font-mono font-bold text-emerald-800 bg-white px-4 py-2 rounded-lg">
|
||||
{pickupCode}
|
||||
</span>
|
||||
<Button onClick={copyCode} size="sm" variant="outline">
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={copyShareLink} className="w-full" size="sm">
|
||||
<Link className="w-4 h-4 mr-2" />
|
||||
复制分享链接
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<QRCodeDisplay value={pickupLink} size={120} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接状态 */}
|
||||
{hasAnyConnection ? (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-blue-700 text-sm">✅ 已连接,开始实时聊天</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-amber-700 mb-2">等待对方连接...</p>
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 聊天界面 */}
|
||||
{hasAnyConnection && (
|
||||
<div className="space-y-4">
|
||||
{/* 消息历史 */}
|
||||
<div className="bg-slate-50 rounded-lg p-4 max-h-80 overflow-y-auto">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-slate-500 text-center">开始发送消息吧!</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className={`flex ${message.sender === 'self' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-xs ${message.sender === 'self' ? 'bg-emerald-500 text-white' : 'bg-white'} rounded-lg p-3 shadow`}>
|
||||
{message.type === 'text' ? (
|
||||
<p className="break-words">{message.content}</p>
|
||||
) : (
|
||||
<div>
|
||||
<img
|
||||
src={message.content}
|
||||
alt={message.fileName}
|
||||
className="max-w-full h-auto rounded cursor-pointer"
|
||||
onClick={() => setPreviewImage(message.content)}
|
||||
/>
|
||||
<p className="text-xs mt-1 opacity-75">{message.fileName}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 打字状态指示器 */}
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-gray-200 rounded-lg p-3">
|
||||
<p className="text-gray-600 text-sm">对方正在输入...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 消息输入区域 */}
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Image className="w-4 h-4" />
|
||||
</Button>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={textInput}
|
||||
onChange={handleTextInputChange}
|
||||
placeholder="输入消息..."
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent resize-none"
|
||||
rows={1}
|
||||
style={{ minHeight: '40px', maxHeight: '120px' }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendTextMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={sendTextMessage} disabled={!textInput.trim()} className="bg-emerald-500 hover:bg-emerald-600">
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 接收模式
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center">
|
||||
<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">输入取件码或通过分享链接加入房间</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">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${textTransfer.isWebSocketConnected ? 'bg-green-500' : 'bg-slate-400'}`}></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</div>
|
||||
<div className="text-slate-300">|</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${textTransfer.isConnected ? 'bg-green-500' : 'bg-slate-400'}`}></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasAnyConnection ? (
|
||||
<div className="border-2 border-dashed border-slate-300 rounded-xl p-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
输入6位取件码
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputCode}
|
||||
onChange={(e) => setInputCode(e.target.value.toUpperCase())}
|
||||
placeholder="取件码"
|
||||
maxLength={6}
|
||||
className="flex-1 px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-lg text-center"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => joinRoom(inputCode)}
|
||||
disabled={!inputCode.trim() || isAnyConnecting}
|
||||
className="px-6"
|
||||
>
|
||||
{isAnyConnecting ? '连接中...' : '加入'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 已连接,显示消息界面
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-blue-800 mb-1">已连接到消息房间</h4>
|
||||
<p className="text-blue-700">取件码: {pickupCode}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 rounded-lg p-4 max-h-80 overflow-y-auto">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-slate-500 text-center">等待接收消息...</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className={`flex ${message.sender === 'self' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-xs ${message.sender === 'self' ? 'bg-blue-500 text-white' : 'bg-white'} rounded-lg p-3 shadow`}>
|
||||
{message.type === 'text' ? (
|
||||
<p className="break-words">{message.content}</p>
|
||||
) : (
|
||||
<div>
|
||||
<img
|
||||
src={message.content}
|
||||
alt={message.fileName}
|
||||
className="max-w-full h-auto rounded cursor-pointer"
|
||||
onClick={() => setPreviewImage(message.content)}
|
||||
/>
|
||||
<p className="text-xs mt-1 opacity-75">{message.fileName}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-gray-200 rounded-lg p-3">
|
||||
<p className="text-gray-600 text-sm">对方正在输入...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 接收方也可以发送消息 */}
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Image className="w-4 h-4" />
|
||||
</Button>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={textInput}
|
||||
onChange={handleTextInputChange}
|
||||
placeholder="回复消息..."
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows={1}
|
||||
style={{ minHeight: '40px', maxHeight: '120px' }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendTextMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={sendTextMessage} disabled={!textInput.trim()}>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 图片预览模态框 */}
|
||||
{previewImage && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" onClick={() => setPreviewImage(null)}>
|
||||
<div className="relative max-w-4xl max-h-4xl">
|
||||
<img src={previewImage} alt="预览" className="max-w-full max-h-full" />
|
||||
<Button
|
||||
onClick={() => setPreviewImage(null)}
|
||||
className="absolute top-4 right-4 bg-white text-black hover:bg-gray-200"
|
||||
size="sm"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误状态和重新开始按钮 */}
|
||||
{hasAnyError && (
|
||||
<div className="text-center">
|
||||
<Button onClick={restart} variant="outline">
|
||||
重新开始
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { FileInfo, TransferProgress } from '@/types';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
|
||||
export const useFileReceiver = (
|
||||
receiverFiles: FileInfo[],
|
||||
transferProgresses: TransferProgress[],
|
||||
setTransferProgresses: (progresses: TransferProgress[] | ((prev: TransferProgress[]) => TransferProgress[])) => void,
|
||||
websocket: WebSocket | null,
|
||||
sendMessage: (message: any) => void
|
||||
) => {
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 下载文件
|
||||
const downloadFile = useCallback((fileId: string) => {
|
||||
console.log('开始下载文件:', fileId);
|
||||
if (!websocket || websocket.readyState !== WebSocket.OPEN) {
|
||||
showToast('连接未建立,请重试', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已有同文件的进行中传输
|
||||
const existingProgress = transferProgresses.find(p => p.originalFileId === fileId && p.status !== 'completed');
|
||||
if (existingProgress) {
|
||||
console.log('文件已在下载中,跳过重复请求:', fileId);
|
||||
showToast('文件正在下载中...', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = 'req_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
console.log('生成请求ID:', requestId);
|
||||
|
||||
sendMessage({
|
||||
type: 'file-request',
|
||||
payload: {
|
||||
file_id: fileId,
|
||||
request_id: requestId
|
||||
}
|
||||
});
|
||||
|
||||
// 更新传输状态
|
||||
const newProgress = {
|
||||
fileId: requestId, // 传输的唯一标识
|
||||
originalFileId: fileId, // 原始文件ID,用于UI匹配
|
||||
fileName: receiverFiles.find(f => f.id === fileId)?.name || fileId,
|
||||
progress: 0,
|
||||
receivedSize: 0,
|
||||
totalSize: 0,
|
||||
status: 'pending' as const
|
||||
};
|
||||
|
||||
console.log('添加传输进度:', newProgress);
|
||||
setTransferProgresses(prev => [
|
||||
...prev.filter(p => p.originalFileId !== fileId), // 移除该文件的旧进度记录
|
||||
newProgress
|
||||
]);
|
||||
}, [websocket, sendMessage, receiverFiles, showToast, transferProgresses, setTransferProgresses]);
|
||||
|
||||
return {
|
||||
downloadFile
|
||||
};
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
|
||||
export const useFileSender = (selectedFiles: File[], sendMessage: (message: any) => void) => {
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 处理文件请求(发送方)
|
||||
const handleFileRequest = useCallback(async (payload: any) => {
|
||||
const fileId = payload.file_id;
|
||||
const requestId = payload.request_id;
|
||||
|
||||
const fileIndex = parseInt(fileId.replace('file_', ''));
|
||||
const file = selectedFiles[fileIndex];
|
||||
|
||||
if (!file) {
|
||||
console.error('未找到请求的文件:', fileId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('开始发送文件:', file.name);
|
||||
showToast(`开始发送文件: ${file.name}`);
|
||||
|
||||
// 发送文件信息
|
||||
sendMessage({
|
||||
type: 'file-info',
|
||||
payload: {
|
||||
file_id: requestId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mime_type: file.type,
|
||||
last_modified: file.lastModified
|
||||
}
|
||||
});
|
||||
|
||||
// 分块发送文件
|
||||
const chunkSize = 65536;
|
||||
let offset = 0;
|
||||
|
||||
const sendChunk = () => {
|
||||
if (offset >= file.size) {
|
||||
sendMessage({
|
||||
type: 'file-complete',
|
||||
payload: { file_id: requestId }
|
||||
});
|
||||
showToast(`文件发送完成: ${file.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const slice = file.slice(offset, offset + chunkSize);
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const chunk = e.target?.result as ArrayBuffer;
|
||||
|
||||
sendMessage({
|
||||
type: 'file-chunk',
|
||||
payload: {
|
||||
file_id: requestId,
|
||||
offset: offset,
|
||||
data: Array.from(new Uint8Array(chunk)),
|
||||
is_last: offset + chunk.byteLength >= file.size
|
||||
}
|
||||
});
|
||||
|
||||
offset += chunk.byteLength;
|
||||
setTimeout(sendChunk, 10);
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(slice);
|
||||
};
|
||||
|
||||
sendChunk();
|
||||
}, [selectedFiles, sendMessage, showToast]);
|
||||
|
||||
return {
|
||||
handleFileRequest
|
||||
};
|
||||
};
|
||||
@@ -1,203 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { TransferProgress } from '@/types';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
|
||||
interface FileTransferData {
|
||||
fileId: string;
|
||||
chunks: Array<{ offset: number; data: Uint8Array }>;
|
||||
totalSize: number;
|
||||
receivedSize: number;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
export const useFileTransfer = () => {
|
||||
const [fileTransfers, setFileTransfers] = useState<Map<string, FileTransferData>>(new Map());
|
||||
const [completedDownloads, setCompletedDownloads] = useState<Set<string>>(new Set());
|
||||
const [transferProgresses, setTransferProgresses] = useState<TransferProgress[]>([]);
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 初始化文件传输
|
||||
const initFileTransfer = useCallback((fileInfo: any) => {
|
||||
console.log('初始化文件传输:', fileInfo);
|
||||
const transferKey = fileInfo.file_id;
|
||||
|
||||
setFileTransfers(prev => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(transferKey, {
|
||||
fileId: fileInfo.file_id,
|
||||
chunks: [],
|
||||
totalSize: fileInfo.size,
|
||||
receivedSize: 0,
|
||||
fileName: fileInfo.name,
|
||||
mimeType: fileInfo.mime_type,
|
||||
startTime: Date.now()
|
||||
});
|
||||
console.log('添加文件传输记录:', transferKey);
|
||||
return newMap;
|
||||
});
|
||||
|
||||
setTransferProgresses(prev => {
|
||||
const updated = prev.map(p => p.fileId === fileInfo.file_id
|
||||
? { ...p, status: 'downloading' as const, totalSize: fileInfo.size }
|
||||
: p
|
||||
);
|
||||
console.log('更新传输进度为下载中:', updated);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 组装并下载文件
|
||||
const assembleAndDownloadFile = useCallback((transferKey: string, transfer: FileTransferData) => {
|
||||
// 按偏移量排序数据块
|
||||
transfer.chunks.sort((a, b) => a.offset - b.offset);
|
||||
|
||||
// 合并所有数据块
|
||||
const totalSize = transfer.chunks.reduce((sum, chunk) => sum + chunk.data.length, 0);
|
||||
const mergedData = new Uint8Array(totalSize);
|
||||
let currentOffset = 0;
|
||||
|
||||
transfer.chunks.forEach((chunk) => {
|
||||
mergedData.set(chunk.data, currentOffset);
|
||||
currentOffset += chunk.data.length;
|
||||
});
|
||||
|
||||
// 创建Blob并触发下载
|
||||
const blob = new Blob([mergedData], { type: transfer.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = transfer.fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// 清理状态
|
||||
setFileTransfers(prev => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(transferKey);
|
||||
return newMap;
|
||||
});
|
||||
|
||||
setTransferProgresses(prev =>
|
||||
prev.filter(p => p.fileId !== transferKey)
|
||||
);
|
||||
|
||||
const transferTime = (Date.now() - transfer.startTime) / 1000;
|
||||
const speed = (transfer.totalSize / transferTime / 1024 / 1024).toFixed(2);
|
||||
showToast(`文件 "${transfer.fileName}" 下载完成!传输速度: ${speed} MB/s`);
|
||||
}, [showToast]);
|
||||
|
||||
// 接收文件数据块
|
||||
const receiveFileChunk = useCallback((chunkData: any) => {
|
||||
console.log('接收文件数据块:', chunkData);
|
||||
const transferKey = chunkData.file_id;
|
||||
|
||||
setFileTransfers(prev => {
|
||||
const newMap = new Map(prev);
|
||||
const transfer = newMap.get(transferKey);
|
||||
|
||||
if (transfer) {
|
||||
// 检查是否已经完成,如果已经完成就不再处理新的数据块
|
||||
if (transfer.receivedSize >= transfer.totalSize) {
|
||||
console.log('文件已完成,忽略额外的数据块');
|
||||
return newMap;
|
||||
}
|
||||
|
||||
const chunkArray = new Uint8Array(chunkData.data);
|
||||
transfer.chunks.push({
|
||||
offset: chunkData.offset,
|
||||
data: chunkArray
|
||||
});
|
||||
transfer.receivedSize += chunkArray.length;
|
||||
|
||||
// 确保不超过总大小
|
||||
if (transfer.receivedSize > transfer.totalSize) {
|
||||
transfer.receivedSize = transfer.totalSize;
|
||||
}
|
||||
|
||||
const progress = (transfer.receivedSize / transfer.totalSize) * 100;
|
||||
console.log(`文件 ${transferKey} 进度: ${progress.toFixed(2)}%`);
|
||||
|
||||
// 更新进度
|
||||
setTransferProgresses(prev => {
|
||||
const updated = prev.map(p => p.fileId === transferKey
|
||||
? {
|
||||
...p,
|
||||
progress,
|
||||
receivedSize: transfer.receivedSize,
|
||||
totalSize: transfer.totalSize
|
||||
}
|
||||
: p
|
||||
);
|
||||
console.log('更新进度状态:', updated);
|
||||
return updated;
|
||||
});
|
||||
|
||||
// 检查是否完成
|
||||
if (chunkData.is_last || transfer.receivedSize >= transfer.totalSize) {
|
||||
console.log('文件接收完成,准备下载');
|
||||
// 标记为完成,等待 file-complete 消息统一处理下载
|
||||
setTransferProgresses(prev =>
|
||||
prev.map(p => p.fileId === transferKey
|
||||
? { ...p, status: 'completed' as const, progress: 100, receivedSize: transfer.totalSize }
|
||||
: p
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到对应的文件传输:', transferKey);
|
||||
}
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 完成文件下载
|
||||
const completeFileDownload = useCallback((fileId: string) => {
|
||||
console.log('文件传输完成,开始下载:', fileId);
|
||||
|
||||
// 检查是否已经完成过下载
|
||||
if (completedDownloads.has(fileId)) {
|
||||
console.log('文件已经下载过,跳过重复下载:', fileId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记为已完成
|
||||
setCompletedDownloads(prev => new Set([...prev, fileId]));
|
||||
|
||||
// 查找对应的文件传输数据
|
||||
const transfer = fileTransfers.get(fileId);
|
||||
if (transfer) {
|
||||
assembleAndDownloadFile(fileId, transfer);
|
||||
|
||||
// 清理传输进度,移除已完成的文件进度显示
|
||||
setTimeout(() => {
|
||||
setTransferProgresses(prev =>
|
||||
prev.filter(p => p.fileId !== fileId)
|
||||
);
|
||||
}, 2000); // 2秒后清理,让用户看到完成状态
|
||||
} else {
|
||||
console.warn('未找到文件传输数据:', fileId);
|
||||
}
|
||||
}, [fileTransfers, assembleAndDownloadFile, completedDownloads]);
|
||||
|
||||
// 清理传输状态
|
||||
const clearTransfers = useCallback(() => {
|
||||
setFileTransfers(new Map());
|
||||
setCompletedDownloads(new Set());
|
||||
setTransferProgresses([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
fileTransfers,
|
||||
transferProgresses,
|
||||
initFileTransfer,
|
||||
receiveFileChunk,
|
||||
completeFileDownload,
|
||||
clearTransfers,
|
||||
setTransferProgresses
|
||||
};
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useWebRTCConnection } from './webrtc/useWebRTCConnection';
|
||||
import { useFileTransfer } from './webrtc/useFileTransfer';
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export function useWebRTCTransfer() {
|
||||
const connection = useWebRTCConnection();
|
||||
const fileTransfer = useFileTransfer();
|
||||
|
||||
// 文件列表回调存储
|
||||
const fileListCallbacks = useRef<Array<(fileList: FileInfo[]) => void>>([]);
|
||||
|
||||
// 设置数据通道消息处理
|
||||
useEffect(() => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (dataChannel && dataChannel.readyState === 'open') {
|
||||
console.log('设置数据通道消息处理器');
|
||||
|
||||
// 扩展消息处理以包含文件列表
|
||||
const originalHandler = fileTransfer.handleMessage;
|
||||
|
||||
dataChannel.onmessage = (event) => {
|
||||
console.log('收到数据通道消息:', typeof event.data);
|
||||
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'file-list') {
|
||||
console.log('收到文件列表:', message.payload);
|
||||
fileListCallbacks.current.forEach(callback => {
|
||||
callback(message.payload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析文件列表消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理其他消息类型
|
||||
originalHandler(event);
|
||||
};
|
||||
}
|
||||
}, [connection.isConnected, connection.getDataChannel, fileTransfer.handleMessage]);
|
||||
|
||||
// 发送文件
|
||||
const sendFile = useCallback((file: File, fileId?: string) => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (!dataChannel) {
|
||||
console.error('数据通道未准备就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
const actualFileId = fileId || `file_${Date.now()}`;
|
||||
console.log('=== 发送文件 ===');
|
||||
console.log('文件:', file.name, 'ID:', actualFileId, '大小:', file.size);
|
||||
|
||||
fileTransfer.sendFile(file, actualFileId, dataChannel);
|
||||
}, [connection.getDataChannel, fileTransfer.sendFile]);
|
||||
|
||||
// 请求文件
|
||||
const requestFile = useCallback((fileId: string, fileName: string) => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (!dataChannel) {
|
||||
console.error('数据通道未准备就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 请求文件 ===');
|
||||
console.log('文件:', fileName, 'ID:', fileId);
|
||||
|
||||
fileTransfer.requestFile(fileId, fileName, dataChannel);
|
||||
}, [connection.getDataChannel, fileTransfer.requestFile]);
|
||||
|
||||
// 发送文件列表
|
||||
const sendFileList = useCallback((fileList: FileInfo[]) => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('数据通道未准备就绪,无法发送文件列表');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 发送文件列表 ===');
|
||||
console.log('文件列表:', fileList);
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'file-list',
|
||||
payload: fileList
|
||||
});
|
||||
|
||||
try {
|
||||
dataChannel.send(message);
|
||||
console.log('文件列表已发送');
|
||||
} catch (error) {
|
||||
console.error('发送文件列表失败:', error);
|
||||
}
|
||||
}, [connection.getDataChannel]);
|
||||
|
||||
// 注册文件列表接收回调
|
||||
const onFileListReceived = useCallback((callback: (fileList: FileInfo[]) => void) => {
|
||||
console.log('注册文件列表回调');
|
||||
fileListCallbacks.current.push(callback);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
const index = fileListCallbacks.current.indexOf(callback);
|
||||
if (index > -1) {
|
||||
fileListCallbacks.current.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 连接状态
|
||||
isConnected: connection.isConnected,
|
||||
isConnecting: connection.isConnecting,
|
||||
error: connection.error || fileTransfer.error,
|
||||
|
||||
// 传输状态
|
||||
isTransferring: fileTransfer.isTransferring,
|
||||
transferProgress: fileTransfer.transferProgress,
|
||||
receivedFiles: fileTransfer.receivedFiles,
|
||||
|
||||
// 操作方法
|
||||
connect: connection.connect,
|
||||
disconnect: connection.disconnect,
|
||||
sendFile,
|
||||
requestFile,
|
||||
sendFileList,
|
||||
|
||||
// 回调注册
|
||||
onFileRequested: fileTransfer.onFileRequested,
|
||||
onFileReceived: fileTransfer.onFileReceived,
|
||||
onFileListReceived,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useWebRTCConnection } from './webrtc/useWebRTCConnection';
|
||||
import { useFileTransfer } from './webrtc/useFileTransfer';
|
||||
import { useCallback } from 'react';
|
||||
import { useFileTransferBusiness } from './webrtc/useFileTransferBusiness';
|
||||
import { useTextTransferBusiness } from './webrtc/useTextTransferBusiness';
|
||||
|
||||
// 文件信息接口(与现有组件兼容)
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -11,137 +12,147 @@ interface FileInfo {
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 WebRTC 传输 Hook - 新架构版本
|
||||
* 整合文件传输、文字传输等多种业务功能
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 独立连接:每个业务功能有自己独立的 WebRTC 连接
|
||||
* 2. 复用逻辑:所有业务功能复用相同的连接建立逻辑(useWebRTCCore)
|
||||
* 3. 简单精准:避免过度抽象,每个功能模块职责清晰
|
||||
* 4. 易于扩展:可以轻松添加新的业务功能(如屏幕共享、语音传输等)
|
||||
* 5. 向后兼容:与现有的 WebRTCFileTransfer 组件保持接口兼容
|
||||
*/
|
||||
export function useWebRTCTransfer() {
|
||||
const connection = useWebRTCConnection();
|
||||
const fileTransfer = useFileTransfer();
|
||||
const fileTransfer = useFileTransferBusiness();
|
||||
const textTransfer = useTextTransferBusiness();
|
||||
|
||||
// 文件传输连接
|
||||
const connectFileTransfer = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
console.log('连接文件传输通道...');
|
||||
return fileTransfer.connect(roomCode, role);
|
||||
}, [fileTransfer.connect]);
|
||||
|
||||
// 文字传输连接
|
||||
const connectTextTransfer = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
console.log('连接文字传输通道...');
|
||||
return textTransfer.connect(roomCode, role);
|
||||
}, [textTransfer.connect]);
|
||||
|
||||
// 统一连接方法 - 同时连接所有功能
|
||||
const connectAll = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
|
||||
console.log('=== 启动 WebRTC 多功能传输 ===');
|
||||
console.log('房间代码:', roomCode, '角色:', role);
|
||||
console.log('将建立独立的文件传输和文字传输连接');
|
||||
|
||||
// 并行连接所有功能(各自独立的连接)
|
||||
await Promise.all([
|
||||
connectFileTransfer(roomCode, role),
|
||||
connectTextTransfer(roomCode, role),
|
||||
]);
|
||||
|
||||
console.log('所有传输通道连接完成');
|
||||
}, [connectFileTransfer, connectTextTransfer]);
|
||||
|
||||
// 统一断开连接
|
||||
const disconnectAll = useCallback(() => {
|
||||
console.log('断开所有 WebRTC 传输连接');
|
||||
fileTransfer.disconnect();
|
||||
textTransfer.disconnect();
|
||||
}, [fileTransfer.disconnect, textTransfer.disconnect]);
|
||||
|
||||
// 文件列表回调存储
|
||||
const fileListCallbacks = useRef<Array<(fileList: FileInfo[]) => void>>([]);
|
||||
|
||||
// 设置数据通道消息处理
|
||||
useEffect(() => {
|
||||
const dataChannel = connection.localDataChannel || connection.remoteDataChannel;
|
||||
if (dataChannel && dataChannel.readyState === 'open') {
|
||||
console.log('设置数据通道消息处理器, 通道类型:', connection.localDataChannel ? '本地' : '远程');
|
||||
|
||||
// 扩展消息处理以包含文件列表
|
||||
const originalHandler = fileTransfer.handleMessage;
|
||||
|
||||
dataChannel.onmessage = (event) => {
|
||||
console.log('收到数据通道消息:', typeof event.data);
|
||||
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'file-list') {
|
||||
console.log('收到文件列表:', message.payload);
|
||||
fileListCallbacks.current.forEach(callback => {
|
||||
callback(message.payload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析文件列表消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理其他消息类型
|
||||
originalHandler(event);
|
||||
};
|
||||
}
|
||||
}, [connection.localDataChannel, connection.remoteDataChannel, fileTransfer.handleMessage]);
|
||||
|
||||
// 发送文件
|
||||
const sendFile = useCallback((file: File, fileId?: string) => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (!dataChannel) {
|
||||
console.error('数据通道未准备就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
const actualFileId = fileId || `file_${Date.now()}`;
|
||||
console.log('=== 发送文件 ===');
|
||||
console.log('文件:', file.name, 'ID:', actualFileId, '大小:', file.size);
|
||||
|
||||
fileTransfer.sendFile(file, actualFileId, dataChannel);
|
||||
}, [connection.getDataChannel, fileTransfer.sendFile]);
|
||||
|
||||
// 请求文件
|
||||
const requestFile = useCallback((fileId: string, fileName: string) => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (!dataChannel) {
|
||||
console.error('数据通道未准备就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 请求文件 ===');
|
||||
console.log('文件:', fileName, 'ID:', fileId);
|
||||
|
||||
fileTransfer.requestFile(fileId, fileName, dataChannel);
|
||||
}, [connection.getDataChannel, fileTransfer.requestFile]);
|
||||
|
||||
// 发送文件列表
|
||||
const sendFileList = useCallback((fileList: FileInfo[]) => {
|
||||
const dataChannel = connection.getDataChannel();
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('数据通道未准备就绪,无法发送文件列表');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 发送文件列表 ===');
|
||||
console.log('文件列表:', fileList);
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'file-list',
|
||||
payload: fileList
|
||||
});
|
||||
|
||||
try {
|
||||
dataChannel.send(message);
|
||||
console.log('文件列表已发送');
|
||||
} catch (error) {
|
||||
console.error('发送文件列表失败:', error);
|
||||
}
|
||||
}, [connection.getDataChannel]);
|
||||
|
||||
// 注册文件列表接收回调
|
||||
const onFileListReceived = useCallback((callback: (fileList: FileInfo[]) => void) => {
|
||||
console.log('注册文件列表回调');
|
||||
fileListCallbacks.current.push(callback);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
const index = fileListCallbacks.current.indexOf(callback);
|
||||
if (index > -1) {
|
||||
fileListCallbacks.current.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 为了与现有组件兼容,提供旧的接口形式
|
||||
return {
|
||||
// ===== 与现有组件兼容的接口 =====
|
||||
|
||||
// 连接状态
|
||||
isConnected: connection.isConnected,
|
||||
isConnecting: connection.isConnecting,
|
||||
isWebSocketConnected: connection.isWebSocketConnected,
|
||||
error: connection.error || fileTransfer.error,
|
||||
isConnected: fileTransfer.isConnected,
|
||||
isConnecting: fileTransfer.isConnecting,
|
||||
isWebSocketConnected: fileTransfer.isWebSocketConnected,
|
||||
error: fileTransfer.connectionError || fileTransfer.error,
|
||||
|
||||
// 传输状态
|
||||
isTransferring: fileTransfer.isTransferring,
|
||||
transferProgress: fileTransfer.transferProgress,
|
||||
transferProgress: fileTransfer.progress,
|
||||
receivedFiles: fileTransfer.receivedFiles,
|
||||
|
||||
// 操作方法
|
||||
connect: connection.connect,
|
||||
disconnect: connection.disconnect,
|
||||
sendFile,
|
||||
requestFile,
|
||||
sendFileList,
|
||||
// 主要方法
|
||||
connect: fileTransfer.connect,
|
||||
disconnect: fileTransfer.disconnect,
|
||||
sendFile: fileTransfer.sendFile,
|
||||
requestFile: fileTransfer.requestFile,
|
||||
sendFileList: fileTransfer.sendFileList,
|
||||
|
||||
// 回调注册
|
||||
// 回调方法
|
||||
onFileRequested: fileTransfer.onFileRequested,
|
||||
onFileReceived: fileTransfer.onFileReceived,
|
||||
onFileProgress: fileTransfer.onFileProgress,
|
||||
onFileListReceived,
|
||||
onFileListReceived: fileTransfer.onFileListReceived,
|
||||
|
||||
// ===== 新的命名空间接口(供Demo等组件使用) =====
|
||||
|
||||
// 统一操作
|
||||
connectAll,
|
||||
disconnectAll,
|
||||
|
||||
// 文件传输功能命名空间
|
||||
file: {
|
||||
// 连接状态
|
||||
isConnected: fileTransfer.isConnected,
|
||||
isConnecting: fileTransfer.isConnecting,
|
||||
isWebSocketConnected: fileTransfer.isWebSocketConnected,
|
||||
connectionError: fileTransfer.connectionError,
|
||||
|
||||
// 传输状态
|
||||
isTransferring: fileTransfer.isTransferring,
|
||||
progress: fileTransfer.progress,
|
||||
error: fileTransfer.error,
|
||||
receivedFiles: fileTransfer.receivedFiles,
|
||||
|
||||
// 方法
|
||||
connect: fileTransfer.connect,
|
||||
disconnect: fileTransfer.disconnect,
|
||||
sendFile: fileTransfer.sendFile,
|
||||
sendFileList: fileTransfer.sendFileList,
|
||||
requestFile: fileTransfer.requestFile,
|
||||
onFileReceived: fileTransfer.onFileReceived,
|
||||
onFileRequested: fileTransfer.onFileRequested,
|
||||
onFileProgress: fileTransfer.onFileProgress,
|
||||
onFileListReceived: fileTransfer.onFileListReceived,
|
||||
},
|
||||
|
||||
// 文字传输功能命名空间
|
||||
text: {
|
||||
// 连接状态
|
||||
isConnected: textTransfer.isConnected,
|
||||
isConnecting: textTransfer.isConnecting,
|
||||
isWebSocketConnected: textTransfer.isWebSocketConnected,
|
||||
connectionError: textTransfer.connectionError,
|
||||
|
||||
// 传输状态
|
||||
messages: textTransfer.messages,
|
||||
isTyping: textTransfer.isTyping,
|
||||
error: textTransfer.error,
|
||||
|
||||
// 方法
|
||||
connect: textTransfer.connect,
|
||||
disconnect: textTransfer.disconnect,
|
||||
sendMessage: textTransfer.sendMessage,
|
||||
sendTypingStatus: textTransfer.sendTypingStatus,
|
||||
clearMessages: textTransfer.clearMessages,
|
||||
onMessageReceived: textTransfer.onMessageReceived,
|
||||
onTypingStatus: textTransfer.onTypingStatus,
|
||||
},
|
||||
|
||||
// 整体状态(用于 UI 显示)
|
||||
hasAnyConnection: fileTransfer.isConnected || textTransfer.isConnected,
|
||||
isAnyConnecting: fileTransfer.isConnecting || textTransfer.isConnecting,
|
||||
hasAnyError: Boolean(fileTransfer.connectionError || textTransfer.connectionError),
|
||||
|
||||
// 可以继续添加其他业务功能
|
||||
// 例如:
|
||||
// screen: { ... }, // 屏幕共享
|
||||
// voice: { ... }, // 语音传输
|
||||
// video: { ... }, // 视频传输
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
interface FileTransferState {
|
||||
isTransferring: boolean;
|
||||
transferProgress: number;
|
||||
receivedFiles: Array<{ id: string; file: File }>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface FileProgressInfo {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface FileChunk {
|
||||
fileId: string;
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface FileMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 256 * 1024; // 256KB chunks - 平衡传输效率和内存使用
|
||||
|
||||
export function useFileTransfer() {
|
||||
const [state, setState] = useState<FileTransferState>({
|
||||
isTransferring: false,
|
||||
transferProgress: 0,
|
||||
receivedFiles: [],
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 存储接收中的文件数据
|
||||
const receivingFiles = useRef<Map<string, {
|
||||
metadata: FileMetadata;
|
||||
chunks: ArrayBuffer[];
|
||||
receivedChunks: number;
|
||||
totalChunks: number;
|
||||
}>>(new Map());
|
||||
|
||||
// 存储当前正在接收的块信息
|
||||
const expectedChunk = useRef<{
|
||||
fileId: string;
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
} | null>(null);
|
||||
|
||||
// 文件请求回调
|
||||
const fileRequestCallbacks = useRef<Array<(fileId: string, fileName: string) => void>>([]);
|
||||
const fileReceivedCallbacks = useRef<Array<(fileData: { id: string; file: File }) => void>>([]);
|
||||
const fileProgressCallbacks = useRef<Array<(progressInfo: FileProgressInfo) => void>>([]);
|
||||
|
||||
const updateState = useCallback((updates: Partial<FileTransferState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 发送文件
|
||||
const sendFile = useCallback(async (file: File, fileId: string, dataChannel: RTCDataChannel) => {
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('数据通道未准备就绪');
|
||||
updateState({ error: '数据通道未准备就绪' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 开始发送文件 ===');
|
||||
console.log('文件:', file.name, '大小:', file.size, 'ID:', fileId);
|
||||
|
||||
updateState({ isTransferring: true, transferProgress: 0, error: null });
|
||||
|
||||
try {
|
||||
// 发送文件元数据
|
||||
const metadata: FileMetadata = {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
};
|
||||
|
||||
const metadataMessage = JSON.stringify({
|
||||
type: 'file-start',
|
||||
payload: metadata
|
||||
});
|
||||
|
||||
console.log('发送文件元数据:', metadataMessage);
|
||||
dataChannel.send(metadataMessage);
|
||||
|
||||
// 计算分块数量
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
console.log('总分块数:', totalChunks);
|
||||
|
||||
// 分块发送文件
|
||||
let sentChunks = 0;
|
||||
|
||||
const sendNextChunk = () => {
|
||||
if (sentChunks >= totalChunks) {
|
||||
// 发送结束信号
|
||||
const endMessage = JSON.stringify({
|
||||
type: 'file-end',
|
||||
payload: { id: fileId }
|
||||
});
|
||||
dataChannel.send(endMessage);
|
||||
|
||||
updateState({ isTransferring: false, transferProgress: 100 });
|
||||
console.log('文件发送完成:', file.name);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = sentChunks * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (event.target?.result && dataChannel.readyState === 'open') {
|
||||
const arrayBuffer = event.target.result as ArrayBuffer;
|
||||
|
||||
// 检查缓冲区状态,避免过度缓冲
|
||||
if (dataChannel.bufferedAmount > 1024 * 1024) { // 1MB 缓冲区限制
|
||||
console.log('数据通道缓冲区满,等待清空...');
|
||||
// 等待缓冲区清空后再发送
|
||||
const waitForBuffer = () => {
|
||||
if (dataChannel.bufferedAmount < 256 * 1024) { // 等到缓冲区低于 256KB
|
||||
sendChunkData();
|
||||
} else {
|
||||
setTimeout(waitForBuffer, 10);
|
||||
}
|
||||
};
|
||||
waitForBuffer();
|
||||
} else {
|
||||
sendChunkData();
|
||||
}
|
||||
|
||||
function sendChunkData() {
|
||||
// 发送分块数据
|
||||
const chunkMessage = JSON.stringify({
|
||||
type: 'file-chunk',
|
||||
payload: {
|
||||
fileId,
|
||||
chunkIndex: sentChunks,
|
||||
totalChunks
|
||||
}
|
||||
});
|
||||
|
||||
dataChannel.send(chunkMessage);
|
||||
dataChannel.send(arrayBuffer);
|
||||
|
||||
sentChunks++;
|
||||
const progress = (sentChunks / totalChunks) * 100;
|
||||
updateState({ transferProgress: progress });
|
||||
|
||||
// 通知文件级别的进度
|
||||
fileProgressCallbacks.current.forEach(callback => {
|
||||
callback({
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
progress
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`发送进度: ${progress.toFixed(1)}%, 块: ${sentChunks}/${totalChunks}, 文件: ${file.name}, 缓冲区: ${dataChannel.bufferedAmount} bytes`);
|
||||
|
||||
// 立即发送下一个块,不等待
|
||||
sendNextChunk();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
console.error('读取文件块失败');
|
||||
updateState({ error: '读取文件失败', isTransferring: false });
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(chunk);
|
||||
};
|
||||
|
||||
sendNextChunk();
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送文件失败:', error);
|
||||
updateState({
|
||||
error: error instanceof Error ? error.message : '发送文件失败',
|
||||
isTransferring: false
|
||||
});
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 处理接收到的消息
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('收到消息:', message.type, message.payload);
|
||||
|
||||
switch (message.type) {
|
||||
case 'file-list':
|
||||
// 文件列表消息由主hook处理
|
||||
console.log('文件列表消息将由主hook处理');
|
||||
return;
|
||||
|
||||
case 'file-start':
|
||||
const metadata = message.payload as FileMetadata;
|
||||
console.log('开始接收文件:', metadata.name, '大小:', metadata.size);
|
||||
|
||||
receivingFiles.current.set(metadata.id, {
|
||||
metadata,
|
||||
chunks: [],
|
||||
receivedChunks: 0,
|
||||
totalChunks: Math.ceil(metadata.size / CHUNK_SIZE)
|
||||
});
|
||||
|
||||
updateState({ isTransferring: true, transferProgress: 0 });
|
||||
break;
|
||||
|
||||
case 'file-chunk':
|
||||
const chunkInfo = message.payload;
|
||||
const { fileId: chunkFileId, chunkIndex, totalChunks } = chunkInfo;
|
||||
console.log(`接收文件块信息: ${chunkIndex + 1}/${totalChunks}, 文件ID: ${chunkFileId}`);
|
||||
|
||||
// 设置期望的下一个块
|
||||
expectedChunk.current = {
|
||||
fileId: chunkFileId,
|
||||
chunkIndex,
|
||||
totalChunks
|
||||
};
|
||||
break;
|
||||
|
||||
case 'file-end':
|
||||
const { id: fileId } = message.payload;
|
||||
const fileInfo = receivingFiles.current.get(fileId);
|
||||
|
||||
if (fileInfo) {
|
||||
// 组装文件
|
||||
const blob = new Blob(fileInfo.chunks, { type: fileInfo.metadata.type });
|
||||
const file = new File([blob], fileInfo.metadata.name, { type: fileInfo.metadata.type });
|
||||
|
||||
console.log('文件接收完成:', file.name);
|
||||
|
||||
// 添加到接收文件列表
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
receivedFiles: [...prev.receivedFiles, { id: fileId, file }],
|
||||
isTransferring: false,
|
||||
transferProgress: 100
|
||||
}));
|
||||
|
||||
// 触发回调
|
||||
fileReceivedCallbacks.current.forEach(callback => {
|
||||
callback({ id: fileId, file });
|
||||
});
|
||||
|
||||
// 清理
|
||||
receivingFiles.current.delete(fileId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-request':
|
||||
const { fileId: requestedFileId, fileName } = message.payload;
|
||||
console.log('收到文件请求:', fileName, 'ID:', requestedFileId);
|
||||
|
||||
// 触发文件请求回调
|
||||
fileRequestCallbacks.current.forEach(callback => {
|
||||
callback(requestedFileId, fileName);
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析消息失败:', error);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
// 处理文件块数据
|
||||
const arrayBuffer = event.data;
|
||||
console.log('收到文件块数据:', arrayBuffer.byteLength, 'bytes');
|
||||
|
||||
// 检查是否有期望的块信息
|
||||
if (expectedChunk.current) {
|
||||
const { fileId, chunkIndex } = expectedChunk.current;
|
||||
const fileInfo = receivingFiles.current.get(fileId);
|
||||
|
||||
if (fileInfo) {
|
||||
// 确保chunks数组足够大
|
||||
if (!fileInfo.chunks[chunkIndex]) {
|
||||
fileInfo.chunks[chunkIndex] = arrayBuffer;
|
||||
fileInfo.receivedChunks++;
|
||||
|
||||
const progress = (fileInfo.receivedChunks / fileInfo.totalChunks) * 100;
|
||||
updateState({ transferProgress: progress });
|
||||
|
||||
// 通知文件级别的进度
|
||||
fileProgressCallbacks.current.forEach(callback => {
|
||||
callback({
|
||||
fileId,
|
||||
fileName: fileInfo.metadata.name,
|
||||
progress
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`文件接收进度: ${progress.toFixed(1)}%, 块: ${fileInfo.receivedChunks}/${fileInfo.totalChunks}, 文件: ${fileInfo.metadata.name}`);
|
||||
}
|
||||
|
||||
// 清除期望的块信息
|
||||
expectedChunk.current = null;
|
||||
}
|
||||
} else {
|
||||
console.warn('收到块数据但没有对应的块信息');
|
||||
}
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 请求文件
|
||||
const requestFile = useCallback((fileId: string, fileName: string, dataChannel: RTCDataChannel) => {
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('数据通道未准备就绪');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('请求文件:', fileName, 'ID:', fileId);
|
||||
|
||||
const requestMessage = JSON.stringify({
|
||||
type: 'file-request',
|
||||
payload: { fileId, fileName }
|
||||
});
|
||||
|
||||
dataChannel.send(requestMessage);
|
||||
}, []);
|
||||
|
||||
// 注册文件请求回调
|
||||
const onFileRequested = useCallback((callback: (fileId: string, fileName: string) => void) => {
|
||||
fileRequestCallbacks.current.push(callback);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
const index = fileRequestCallbacks.current.indexOf(callback);
|
||||
if (index > -1) {
|
||||
fileRequestCallbacks.current.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 注册文件接收回调
|
||||
const onFileReceived = useCallback((callback: (fileData: { id: string; file: File }) => void) => {
|
||||
fileReceivedCallbacks.current.push(callback);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
const index = fileReceivedCallbacks.current.indexOf(callback);
|
||||
if (index > -1) {
|
||||
fileReceivedCallbacks.current.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 注册文件进度回调
|
||||
const onFileProgress = useCallback((callback: (progressInfo: FileProgressInfo) => void) => {
|
||||
fileProgressCallbacks.current.push(callback);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
const index = fileProgressCallbacks.current.indexOf(callback);
|
||||
if (index > -1) {
|
||||
fileProgressCallbacks.current.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
sendFile,
|
||||
requestFile,
|
||||
handleMessage,
|
||||
onFileRequested,
|
||||
onFileReceived,
|
||||
onFileProgress,
|
||||
};
|
||||
}
|
||||
353
chuan-next/src/hooks/webrtc/useFileTransferBusiness.ts
Normal file
353
chuan-next/src/hooks/webrtc/useFileTransferBusiness.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useWebRTCCore } from './useWebRTCCore';
|
||||
|
||||
// 文件传输状态
|
||||
interface FileTransferState {
|
||||
isTransferring: boolean;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
receivedFiles: Array<{ id: string; file: File }>;
|
||||
}
|
||||
|
||||
// 文件信息(用于文件列表)
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
// 文件元数据
|
||||
interface FileMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// 文件块信息
|
||||
interface FileChunk {
|
||||
fileId: string;
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
}
|
||||
|
||||
// 回调类型
|
||||
type FileReceivedCallback = (fileData: { id: string; file: File }) => void;
|
||||
type FileRequestedCallback = (fileId: string, fileName: string) => void;
|
||||
type FileProgressCallback = (progressInfo: { fileId: string; fileName: string; progress: number }) => void;
|
||||
type FileListReceivedCallback = (fileList: FileInfo[]) => void;
|
||||
|
||||
const CHUNK_SIZE = 256 * 1024; // 256KB
|
||||
|
||||
/**
|
||||
* 文件传输业务层
|
||||
* 使用 WebRTC 核心连接逻辑实现文件传输功能
|
||||
* 每个实例有独立的连接,但复用相同的连接建立逻辑
|
||||
*
|
||||
* 支持功能:
|
||||
* - 文件发送/接收
|
||||
* - 文件列表同步
|
||||
* - 文件请求机制
|
||||
* - 进度跟踪
|
||||
* - 多文件传输
|
||||
*/
|
||||
export function useFileTransferBusiness() {
|
||||
const webrtcCore = useWebRTCCore('file-transfer');
|
||||
|
||||
const [state, setState] = useState<FileTransferState>({
|
||||
isTransferring: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
receivedFiles: [],
|
||||
});
|
||||
|
||||
// 接收文件缓存
|
||||
const receivingFiles = useRef<Map<string, {
|
||||
metadata: FileMetadata;
|
||||
chunks: ArrayBuffer[];
|
||||
receivedChunks: number;
|
||||
}>>(new Map());
|
||||
|
||||
// 当前期望的文件块
|
||||
const expectedChunk = useRef<FileChunk | null>(null);
|
||||
|
||||
// 回调存储
|
||||
const fileReceivedCallbacks = useRef<Set<FileReceivedCallback>>(new Set());
|
||||
const fileRequestedCallbacks = useRef<Set<FileRequestedCallback>>(new Set());
|
||||
const fileProgressCallbacks = useRef<Set<FileProgressCallback>>(new Set());
|
||||
const fileListCallbacks = useRef<Set<FileListReceivedCallback>>(new Set());
|
||||
|
||||
const updateState = useCallback((updates: Partial<FileTransferState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 处理文件传输消息
|
||||
const handleMessage = useCallback((message: any) => {
|
||||
console.log('文件传输处理消息:', message.type);
|
||||
|
||||
switch (message.type) {
|
||||
case 'file-metadata':
|
||||
const metadata: FileMetadata = message.payload;
|
||||
console.log('开始接收文件:', metadata.name);
|
||||
|
||||
receivingFiles.current.set(metadata.id, {
|
||||
metadata,
|
||||
chunks: [],
|
||||
receivedChunks: 0,
|
||||
});
|
||||
|
||||
updateState({ isTransferring: true, progress: 0 });
|
||||
break;
|
||||
|
||||
case 'file-chunk-info':
|
||||
expectedChunk.current = message.payload;
|
||||
console.log('准备接收文件块:', message.payload);
|
||||
break;
|
||||
|
||||
case 'file-complete':
|
||||
const { fileId } = message.payload;
|
||||
const fileInfo = receivingFiles.current.get(fileId);
|
||||
|
||||
if (fileInfo) {
|
||||
// 组装文件
|
||||
const blob = new Blob(fileInfo.chunks, { type: fileInfo.metadata.type });
|
||||
const file = new File([blob], fileInfo.metadata.name, {
|
||||
type: fileInfo.metadata.type
|
||||
});
|
||||
|
||||
console.log('文件接收完成:', file.name);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
receivedFiles: [...prev.receivedFiles, { id: fileId, file }],
|
||||
isTransferring: false,
|
||||
progress: 100
|
||||
}));
|
||||
|
||||
// 触发回调
|
||||
fileReceivedCallbacks.current.forEach(cb => cb({ id: fileId, file }));
|
||||
|
||||
// 清理
|
||||
receivingFiles.current.delete(fileId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-list':
|
||||
console.log('收到文件列表:', message.payload);
|
||||
fileListCallbacks.current.forEach(cb => cb(message.payload));
|
||||
break;
|
||||
|
||||
case 'file-request':
|
||||
const { fileId: requestedFileId, fileName } = message.payload;
|
||||
console.log('收到文件请求:', fileName, requestedFileId);
|
||||
fileRequestedCallbacks.current.forEach(cb => cb(requestedFileId, fileName));
|
||||
break;
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 处理文件块数据
|
||||
const handleData = useCallback((data: ArrayBuffer) => {
|
||||
if (!expectedChunk.current) {
|
||||
console.warn('收到数据但没有对应的块信息');
|
||||
return;
|
||||
}
|
||||
|
||||
const { fileId, chunkIndex, totalChunks } = expectedChunk.current;
|
||||
const fileInfo = receivingFiles.current.get(fileId);
|
||||
|
||||
if (fileInfo) {
|
||||
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
|
||||
}));
|
||||
|
||||
console.log(`文件 ${fileInfo.metadata.name} 接收进度: ${progress.toFixed(1)}%`);
|
||||
expectedChunk.current = null;
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 设置处理器
|
||||
useEffect(() => {
|
||||
webrtcCore.setMessageHandler(handleMessage);
|
||||
webrtcCore.setDataHandler(handleData);
|
||||
|
||||
return () => {
|
||||
webrtcCore.setMessageHandler(null);
|
||||
webrtcCore.setDataHandler(null);
|
||||
};
|
||||
}, [webrtcCore.setMessageHandler, webrtcCore.setDataHandler, handleMessage, handleData]);
|
||||
|
||||
// 连接
|
||||
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
return webrtcCore.connect(roomCode, role);
|
||||
}, [webrtcCore.connect]);
|
||||
|
||||
// 发送文件
|
||||
const sendFile = useCallback(async (file: File, fileId?: string) => {
|
||||
if (webrtcCore.getChannelState() !== 'open') {
|
||||
updateState({ error: '连接未就绪' });
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
updateState({ isTransferring: true, progress: 0, error: null });
|
||||
|
||||
try {
|
||||
// 1. 发送文件元数据
|
||||
webrtcCore.sendMessage({
|
||||
type: 'file-metadata',
|
||||
payload: {
|
||||
id: actualFileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// 先发送块信息
|
||||
webrtcCore.sendMessage({
|
||||
type: 'file-chunk-info',
|
||||
payload: {
|
||||
fileId: actualFileId,
|
||||
chunkIndex,
|
||||
totalChunks
|
||||
}
|
||||
});
|
||||
|
||||
// 再发送块数据
|
||||
const arrayBuffer = await chunk.arrayBuffer();
|
||||
webrtcCore.sendData(arrayBuffer);
|
||||
|
||||
const progress = ((chunkIndex + 1) / totalChunks) * 100;
|
||||
updateState({ progress });
|
||||
|
||||
// 触发文件级别的进度回调
|
||||
fileProgressCallbacks.current.forEach(cb => cb({
|
||||
fileId: actualFileId,
|
||||
fileName: file.name,
|
||||
progress
|
||||
}));
|
||||
|
||||
// 简单的流控:等待一小段时间让接收方处理
|
||||
if (chunkIndex % 10 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 发送完成信号
|
||||
webrtcCore.sendMessage({
|
||||
type: 'file-complete',
|
||||
payload: { fileId: actualFileId }
|
||||
});
|
||||
|
||||
updateState({ isTransferring: false, progress: 100 });
|
||||
console.log('文件发送完成:', file.name);
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送文件失败:', error);
|
||||
updateState({
|
||||
error: error instanceof Error ? error.message : '发送失败',
|
||||
isTransferring: false
|
||||
});
|
||||
}
|
||||
}, [webrtcCore.getChannelState, webrtcCore.sendMessage, webrtcCore.sendData, updateState]);
|
||||
|
||||
// 发送文件列表
|
||||
const sendFileList = useCallback((fileList: FileInfo[]) => {
|
||||
if (webrtcCore.getChannelState() !== 'open') {
|
||||
console.error('数据通道未准备就绪,无法发送文件列表');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('发送文件列表:', fileList);
|
||||
|
||||
webrtcCore.sendMessage({
|
||||
type: 'file-list',
|
||||
payload: fileList
|
||||
});
|
||||
}, [webrtcCore.getChannelState, webrtcCore.sendMessage]);
|
||||
|
||||
// 请求文件
|
||||
const requestFile = useCallback((fileId: string, fileName: string) => {
|
||||
if (webrtcCore.getChannelState() !== 'open') {
|
||||
console.error('数据通道未准备就绪,无法请求文件');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('请求文件:', fileName, fileId);
|
||||
|
||||
webrtcCore.sendMessage({
|
||||
type: 'file-request',
|
||||
payload: { fileId, fileName }
|
||||
});
|
||||
}, [webrtcCore.getChannelState, webrtcCore.sendMessage]);
|
||||
|
||||
// 注册文件接收回调
|
||||
const onFileReceived = useCallback((callback: FileReceivedCallback) => {
|
||||
fileReceivedCallbacks.current.add(callback);
|
||||
return () => { fileReceivedCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
// 注册文件请求回调
|
||||
const onFileRequested = useCallback((callback: FileRequestedCallback) => {
|
||||
fileRequestedCallbacks.current.add(callback);
|
||||
return () => { fileRequestedCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
// 注册进度回调
|
||||
const onFileProgress = useCallback((callback: FileProgressCallback) => {
|
||||
fileProgressCallbacks.current.add(callback);
|
||||
return () => { fileProgressCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
// 注册文件列表回调
|
||||
const onFileListReceived = useCallback((callback: FileListReceivedCallback) => {
|
||||
fileListCallbacks.current.add(callback);
|
||||
return () => { fileListCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 继承基础连接状态
|
||||
isConnected: webrtcCore.isConnected,
|
||||
isConnecting: webrtcCore.isConnecting,
|
||||
isWebSocketConnected: webrtcCore.isWebSocketConnected,
|
||||
connectionError: webrtcCore.error,
|
||||
|
||||
// 文件传输状态
|
||||
...state,
|
||||
|
||||
// 操作方法
|
||||
connect,
|
||||
disconnect: webrtcCore.disconnect,
|
||||
sendFile,
|
||||
sendFileList,
|
||||
requestFile,
|
||||
|
||||
// 回调注册
|
||||
onFileReceived,
|
||||
onFileRequested,
|
||||
onFileProgress,
|
||||
onFileListReceived,
|
||||
};
|
||||
}
|
||||
229
chuan-next/src/hooks/webrtc/useTextTransferBusiness.ts
Normal file
229
chuan-next/src/hooks/webrtc/useTextTransferBusiness.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useWebRTCCore } from './useWebRTCCore';
|
||||
|
||||
// 文字传输状态
|
||||
interface TextTransferState {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
sender: 'self' | 'peer';
|
||||
}>;
|
||||
isTyping: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// 消息类型
|
||||
interface TextMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// 回调类型
|
||||
type MessageReceivedCallback = (message: TextMessage) => void;
|
||||
type TypingStatusCallback = (isTyping: boolean) => void;
|
||||
type RealTimeTextCallback = (text: string) => void;
|
||||
|
||||
/**
|
||||
* 文字传输业务层
|
||||
* 使用 WebRTC 核心连接逻辑实现实时文字传输功能
|
||||
* 每个实例有独立的连接,但复用相同的连接建立逻辑
|
||||
*/
|
||||
export function useTextTransferBusiness() {
|
||||
const webrtcCore = useWebRTCCore('text-transfer');
|
||||
|
||||
const [state, setState] = useState<TextTransferState>({
|
||||
messages: [],
|
||||
isTyping: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 回调存储
|
||||
const messageCallbacks = useRef<Set<MessageReceivedCallback>>(new Set());
|
||||
const typingCallbacks = useRef<Set<TypingStatusCallback>>(new Set());
|
||||
const realTimeTextCallbacks = useRef<Set<RealTimeTextCallback>>(new Set());
|
||||
|
||||
// 打字状态防抖
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const updateState = useCallback((updates: Partial<TextTransferState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 处理文字传输消息
|
||||
const handleMessage = useCallback((message: any) => {
|
||||
switch (message.type) {
|
||||
case 'text-message':
|
||||
const textMessage: TextMessage = message.payload;
|
||||
console.log('收到文字消息:', textMessage.text);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
messages: [...prev.messages, {
|
||||
id: textMessage.id,
|
||||
text: textMessage.text,
|
||||
timestamp: new Date(textMessage.timestamp),
|
||||
sender: 'peer'
|
||||
}]
|
||||
}));
|
||||
|
||||
// 触发回调
|
||||
messageCallbacks.current.forEach(cb => cb(textMessage));
|
||||
break;
|
||||
|
||||
case 'typing-status':
|
||||
const { isTyping } = message.payload;
|
||||
updateState({ isTyping });
|
||||
typingCallbacks.current.forEach(cb => cb(isTyping));
|
||||
break;
|
||||
|
||||
case 'real-time-text':
|
||||
const { text } = message.payload;
|
||||
console.log('收到实时文本:', text);
|
||||
realTimeTextCallbacks.current.forEach(cb => cb(text));
|
||||
break;
|
||||
|
||||
case 'text-clear':
|
||||
console.log('收到清空消息指令');
|
||||
updateState({ messages: [] });
|
||||
break;
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 设置处理器
|
||||
useEffect(() => {
|
||||
webrtcCore.setMessageHandler(handleMessage);
|
||||
// 文字传输不需要数据处理器
|
||||
|
||||
return () => {
|
||||
webrtcCore.setMessageHandler(null);
|
||||
};
|
||||
}, [webrtcCore.setMessageHandler, handleMessage]);
|
||||
|
||||
// 连接
|
||||
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
return webrtcCore.connect(roomCode, role);
|
||||
}, [webrtcCore.connect]);
|
||||
|
||||
// 发送文字消息
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
if (webrtcCore.getChannelState() !== 'open') {
|
||||
updateState({ error: '连接未就绪' });
|
||||
return;
|
||||
}
|
||||
|
||||
const message: TextMessage = {
|
||||
id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
text,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log('发送文字消息:', text);
|
||||
|
||||
// 发送到对方
|
||||
const success = webrtcCore.sendMessage({
|
||||
type: 'text-message',
|
||||
payload: message
|
||||
});
|
||||
|
||||
if (success) {
|
||||
// 添加到本地消息列表
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
messages: [...prev.messages, {
|
||||
id: message.id,
|
||||
text: message.text,
|
||||
timestamp: new Date(message.timestamp),
|
||||
sender: 'self'
|
||||
}],
|
||||
error: null
|
||||
}));
|
||||
} else {
|
||||
updateState({ error: '发送消息失败' });
|
||||
}
|
||||
}, [webrtcCore.getChannelState, webrtcCore.sendMessage, updateState]);
|
||||
|
||||
// 发送打字状态
|
||||
const sendTypingStatus = useCallback((isTyping: boolean) => {
|
||||
if (webrtcCore.getChannelState() !== 'open') return;
|
||||
|
||||
webrtcCore.sendMessage({
|
||||
type: 'typing-status',
|
||||
payload: { isTyping }
|
||||
});
|
||||
|
||||
// 如果开始打字,设置自动停止
|
||||
if (isTyping) {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
sendTypingStatus(false);
|
||||
}, 3000); // 3秒后自动停止打字状态
|
||||
} else {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [webrtcCore.getChannelState, webrtcCore.sendMessage]);
|
||||
|
||||
// 发送实时文本
|
||||
const sendRealTimeText = useCallback((text: string) => {
|
||||
if (webrtcCore.getChannelState() !== 'open') return;
|
||||
|
||||
webrtcCore.sendMessage({
|
||||
type: 'real-time-text',
|
||||
payload: { text }
|
||||
});
|
||||
}, [webrtcCore.getChannelState, webrtcCore.sendMessage]);
|
||||
|
||||
// 清空消息
|
||||
const clearMessages = useCallback(() => {
|
||||
updateState({ messages: [] });
|
||||
|
||||
// 通知对方也清空
|
||||
if (webrtcCore.getChannelState() === 'open') {
|
||||
webrtcCore.sendMessage({
|
||||
type: 'text-clear',
|
||||
payload: {}
|
||||
});
|
||||
}
|
||||
}, [webrtcCore.getChannelState, webrtcCore.sendMessage, updateState]);
|
||||
|
||||
// 注册消息接收回调
|
||||
const onMessageReceived = useCallback((callback: MessageReceivedCallback) => {
|
||||
messageCallbacks.current.add(callback);
|
||||
return () => { messageCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
// 注册打字状态回调
|
||||
const onTypingStatus = useCallback((callback: TypingStatusCallback) => {
|
||||
typingCallbacks.current.add(callback);
|
||||
return () => { typingCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 继承基础连接状态
|
||||
isConnected: webrtcCore.isConnected,
|
||||
isConnecting: webrtcCore.isConnecting,
|
||||
isWebSocketConnected: webrtcCore.isWebSocketConnected,
|
||||
connectionError: webrtcCore.error,
|
||||
|
||||
// 文字传输状态
|
||||
...state,
|
||||
|
||||
// 操作方法
|
||||
connect,
|
||||
disconnect: webrtcCore.disconnect,
|
||||
sendMessage,
|
||||
sendTypingStatus,
|
||||
clearMessages,
|
||||
|
||||
// 回调注册
|
||||
onMessageReceived,
|
||||
onTypingStatus,
|
||||
};
|
||||
}
|
||||
@@ -1,808 +0,0 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
interface WebRTCConnectionState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
error: string | null;
|
||||
localDataChannel: RTCDataChannel | null;
|
||||
remoteDataChannel: RTCDataChannel | null;
|
||||
}
|
||||
|
||||
export function useWebRTCConnection() {
|
||||
const [state, setState] = useState<WebRTCConnectionState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
error: null,
|
||||
localDataChannel: null,
|
||||
remoteDataChannel: null,
|
||||
});
|
||||
|
||||
// 浏览器兼容性检测
|
||||
const detectBrowser = useCallback(() => {
|
||||
const userAgent = navigator.userAgent;
|
||||
const isEdge = /Edg/.test(userAgent);
|
||||
const isChrome = /Chrome/.test(userAgent) && !isEdge;
|
||||
const isSafari = /Safari/.test(userAgent) && !isChrome && !isEdge;
|
||||
const isFirefox = /Firefox/.test(userAgent);
|
||||
const isChromeFamily = isChrome || isEdge; // Chrome内核系列
|
||||
|
||||
console.log('浏览器检测结果:', {
|
||||
userAgent: userAgent.substring(0, 100) + '...',
|
||||
isEdge,
|
||||
isChrome,
|
||||
isSafari,
|
||||
isFirefox,
|
||||
isChromeFamily,
|
||||
webRTCSupport: {
|
||||
RTCPeerConnection: !!window.RTCPeerConnection,
|
||||
getUserMedia: !!(navigator.mediaDevices?.getUserMedia),
|
||||
WebSocket: !!window.WebSocket
|
||||
}
|
||||
});
|
||||
|
||||
return { isEdge, isChrome, isSafari, isFirefox, isChromeFamily };
|
||||
}, []);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const dcRef = useRef<RTCDataChannel | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pendingIceCandidates = useRef<RTCIceCandidate[]>([]);
|
||||
const iceGatheringTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const STUN_SERVERS = [
|
||||
// Edge 浏览器专用优化配置
|
||||
{ urls: 'stun:stun.miwifi.com' },
|
||||
{ urls: 'stun:stun.chat.bilibili.com' },
|
||||
{ urls: 'stun:turn.cloudflare.com:3478' },
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
// 备用 STUN 服务器
|
||||
{ urls: 'stun:stun.nextcloud.com:443' },
|
||||
{ urls: 'stun:stun.sipgate.net:10000' },
|
||||
{ urls: 'stun:stun.ekiga.net' },
|
||||
];
|
||||
|
||||
// 获取浏览器特定的 RTCConfiguration
|
||||
const getBrowserSpecificConfig = useCallback(() => {
|
||||
const { isSafari, isChromeFamily } = detectBrowser();
|
||||
|
||||
const baseConfig = {
|
||||
iceServers: STUN_SERVERS,
|
||||
iceCandidatePoolSize: 10,
|
||||
bundlePolicy: 'max-bundle' as RTCBundlePolicy,
|
||||
rtcpMuxPolicy: 'require' as RTCRtcpMuxPolicy,
|
||||
iceTransportPolicy: 'all' as RTCIceTransportPolicy
|
||||
};
|
||||
|
||||
if (isChromeFamily) {
|
||||
console.log('应用 Chrome 内核浏览器优化配置');
|
||||
return {
|
||||
...baseConfig,
|
||||
// Chrome 内核特定优化
|
||||
iceCandidatePoolSize: 20, // 增加候选池大小
|
||||
bundlePolicy: 'max-bundle' as RTCBundlePolicy,
|
||||
rtcpMuxPolicy: 'require' as RTCRtcpMuxPolicy,
|
||||
iceTransportPolicy: 'all' as RTCIceTransportPolicy,
|
||||
// Chrome 内核需要更宽松的配置
|
||||
sdpSemantics: 'unified-plan' as const, // 明确使用统一计划
|
||||
};
|
||||
}
|
||||
|
||||
if (isSafari) {
|
||||
console.log('应用 Safari 浏览器优化配置');
|
||||
return {
|
||||
...baseConfig,
|
||||
// Safari 特定优化
|
||||
iceCandidatePoolSize: 8,
|
||||
sdpSemantics: 'unified-plan' as const,
|
||||
};
|
||||
}
|
||||
|
||||
console.log('应用默认浏览器配置');
|
||||
return baseConfig;
|
||||
}, [detectBrowser, STUN_SERVERS]);
|
||||
|
||||
// 连接超时时间(30秒)
|
||||
const CONNECTION_TIMEOUT = 30000;
|
||||
|
||||
const updateState = useCallback((updates: Partial<WebRTCConnectionState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 处理缓存的ICE候选
|
||||
const processPendingIceCandidates = useCallback(async () => {
|
||||
const pc = pcRef.current;
|
||||
if (!pc || !pc.remoteDescription || pendingIceCandidates.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('处理缓存的ICE候选,数量:', pendingIceCandidates.current.length);
|
||||
|
||||
for (const candidate of pendingIceCandidates.current) {
|
||||
try {
|
||||
await pc.addIceCandidate(candidate);
|
||||
console.log('已添加缓存的ICE候选');
|
||||
} catch (error) {
|
||||
console.warn('添加缓存ICE候选失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 清空缓存
|
||||
pendingIceCandidates.current = [];
|
||||
}, []);
|
||||
|
||||
// 优化的Offer创建和发送
|
||||
const createAndSendOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
|
||||
console.log('开始创建offer...');
|
||||
|
||||
// 获取浏览器信息
|
||||
const browserInfo = detectBrowser();
|
||||
|
||||
try {
|
||||
// Chrome内核需要特殊的offer配置
|
||||
const offerOptions = browserInfo.isChromeFamily ? {
|
||||
offerToReceiveAudio: false,
|
||||
offerToReceiveVideo: false,
|
||||
iceRestart: false,
|
||||
// Chrome内核特定配置
|
||||
voiceActivityDetection: false
|
||||
} : {
|
||||
offerToReceiveAudio: false,
|
||||
offerToReceiveVideo: false,
|
||||
iceRestart: false
|
||||
};
|
||||
|
||||
console.log('使用的 offer 配置:', offerOptions);
|
||||
|
||||
// 创建offer
|
||||
const offer = await pc.createOffer(offerOptions);
|
||||
|
||||
await pc.setLocalDescription(offer);
|
||||
console.log('已设置本地描述,等待ICE候选收集...');
|
||||
|
||||
// Chrome内核需要更长的ICE收集时间
|
||||
const iceGatheringTimeout = browserInfo.isChromeFamily ? 5000 : 3000;
|
||||
|
||||
// 设置ICE收集超时 - 等待更多ICE候选
|
||||
const iceTimeout = setTimeout(() => {
|
||||
console.log('ICE收集超时,发送当前offer');
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('已发送offer (超时发送)');
|
||||
}
|
||||
}, iceGatheringTimeout);
|
||||
|
||||
iceGatheringTimeoutRef.current = iceTimeout;
|
||||
|
||||
// 监听ICE收集完成
|
||||
const handleIceGatheringComplete = () => {
|
||||
if (iceGatheringTimeoutRef.current) {
|
||||
clearTimeout(iceGatheringTimeoutRef.current);
|
||||
iceGatheringTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
const candidateCount = pc.localDescription?.sdp ?
|
||||
pc.localDescription.sdp.split('a=candidate:').length - 1 : 0;
|
||||
console.log('已发送offer (ICE收集完成)', '候选数量:', candidateCount);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查ICE收集状态
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
handleIceGatheringComplete();
|
||||
} else {
|
||||
// 监听ICE收集状态变化
|
||||
const originalHandler = pc.onicegatheringstatechange;
|
||||
pc.onicegatheringstatechange = (event) => {
|
||||
console.log('ICE收集状态变化:', pc.iceGatheringState);
|
||||
if (originalHandler) originalHandler.call(pc, event);
|
||||
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
handleIceGatheringComplete();
|
||||
// 恢复原始处理器
|
||||
pc.onicegatheringstatechange = originalHandler;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建offer失败:', error);
|
||||
updateState({ error: '创建连接失败', isConnecting: false });
|
||||
}
|
||||
}, [updateState, detectBrowser]);
|
||||
|
||||
// 清理超时定时器
|
||||
const clearConnectionTimeout = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 处理连接超时
|
||||
const handleConnectionTimeout = useCallback(() => {
|
||||
console.warn('WebRTC连接超时');
|
||||
|
||||
// 获取当前连接状态用于调试
|
||||
const pc = pcRef.current;
|
||||
const connectionInfo = {
|
||||
connectionState: pc?.connectionState || 'unknown',
|
||||
iceConnectionState: pc?.iceConnectionState || 'unknown',
|
||||
signalingState: pc?.signalingState || 'unknown',
|
||||
isWebSocketConnected: wsRef.current?.readyState === WebSocket.OPEN
|
||||
};
|
||||
|
||||
console.log('连接超时时的状态:', connectionInfo);
|
||||
|
||||
updateState({
|
||||
error: `连接超时 - WebSocket: ${connectionInfo.isWebSocketConnected ? '已连接' : '未连接'}, 信令状态: ${connectionInfo.signalingState}, 连接状态: ${connectionInfo.connectionState}`,
|
||||
isConnecting: false
|
||||
});
|
||||
|
||||
// 清理连接
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
|
||||
console.log('=== 开始WebRTC连接 ===');
|
||||
console.log('房间代码:', roomCode, '角色:', role);
|
||||
|
||||
// 浏览器兼容性检测
|
||||
const browserInfo = detectBrowser();
|
||||
console.log('当前浏览器:', browserInfo);
|
||||
|
||||
// 清理之前的超时定时器
|
||||
clearConnectionTimeout();
|
||||
|
||||
updateState({ isConnecting: true, error: null });
|
||||
|
||||
// Chrome内核浏览器使用更长的超时时间
|
||||
const timeoutDuration = browserInfo.isChromeFamily ? CONNECTION_TIMEOUT * 2 : CONNECTION_TIMEOUT;
|
||||
|
||||
// 只有接收方设置连接超时,发送方无限等待
|
||||
if (role === 'receiver') {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
handleConnectionTimeout();
|
||||
}, timeoutDuration);
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取浏览器特定的配置
|
||||
const rtcConfig = getBrowserSpecificConfig();
|
||||
console.log('使用的 RTCConfiguration:', rtcConfig);
|
||||
|
||||
// 创建PeerConnection
|
||||
const pc = new RTCPeerConnection(rtcConfig);
|
||||
pcRef.current = pc;
|
||||
|
||||
// 连接WebSocket信令服务器
|
||||
const wsUrl = config.api.wsUrl.replace('/ws/p2p', '/ws/webrtc');
|
||||
const finalWsUrl = `${wsUrl}?code=${roomCode}&role=${role}`;
|
||||
|
||||
console.log('WebSocket 连接信息:', {
|
||||
原始wsUrl: config.api.wsUrl,
|
||||
替换后wsUrl: wsUrl,
|
||||
最终URL: finalWsUrl,
|
||||
当前域名: typeof window !== 'undefined' ? window.location.host : 'unknown',
|
||||
协议: typeof window !== 'undefined' ? window.location.protocol : 'unknown',
|
||||
端口: typeof window !== 'undefined' ? window.location.port : 'unknown',
|
||||
浏览器: browserInfo
|
||||
});
|
||||
|
||||
const ws = new WebSocket(finalWsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
// Chrome内核特殊处理:增加连接超时检测
|
||||
let wsConnectTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
if (browserInfo.isChromeFamily) {
|
||||
wsConnectTimeout = setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.CONNECTING) {
|
||||
console.error('Chrome内核 - WebSocket连接超时');
|
||||
ws.close();
|
||||
updateState({
|
||||
error: 'WebSocket连接超时 - Chrome内核可能存在安全策略限制',
|
||||
isConnecting: false
|
||||
});
|
||||
}
|
||||
}, 10000); // 10秒超时
|
||||
}
|
||||
|
||||
// WebSocket事件处理
|
||||
ws.onopen = async () => {
|
||||
console.log('WebSocket连接已建立,URL:', finalWsUrl);
|
||||
|
||||
// 清理Chrome内核的连接超时
|
||||
if (wsConnectTimeout) {
|
||||
clearTimeout(wsConnectTimeout);
|
||||
wsConnectTimeout = null;
|
||||
}
|
||||
|
||||
updateState({ isWebSocketConnected: true });
|
||||
|
||||
// 如果是发送方,在WebSocket连接建立后创建offer
|
||||
if (role === 'sender') {
|
||||
// 使用优化的offer创建逻辑
|
||||
createAndSendOffer(pc, ws);
|
||||
// 发送方发送 offer 后,停止 connecting 状态
|
||||
updateState({ isConnecting: false });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('收到信令消息:', message, '当前PC状态:', pc.signalingState);
|
||||
|
||||
switch (message.type) {
|
||||
case 'offer':
|
||||
if (message.payload) {
|
||||
console.log('处理offer,当前状态:', pc.signalingState);
|
||||
try {
|
||||
// 根据W3C规范,只有在stable状态下才能接收offer
|
||||
if (pc.signalingState !== 'stable') {
|
||||
console.warn('跳过offer,信令状态不为stable:', pc.signalingState);
|
||||
return;
|
||||
}
|
||||
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('已设置远程描述,状态变为:', pc.signalingState);
|
||||
|
||||
// 创建answer
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
console.log('已创建并设置本地answer,状态变为:', pc.signalingState);
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
|
||||
console.log('已发送answer');
|
||||
}
|
||||
|
||||
// 接收方发送answer后,停止connecting状态
|
||||
updateState({ isConnecting: false });
|
||||
|
||||
// 处理缓存的ICE候选
|
||||
await processPendingIceCandidates();
|
||||
} catch (error) {
|
||||
console.error('处理offer失败:', error);
|
||||
updateState({ error: '信令交换失败', isConnecting: false });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'answer':
|
||||
if (message.payload) {
|
||||
console.log('处理answer,当前状态:', pc.signalingState);
|
||||
try {
|
||||
// 根据W3C规范,只有在have-local-offer状态下才能接收answer
|
||||
if (pc.signalingState !== 'have-local-offer') {
|
||||
console.warn('跳过answer,信令状态不为have-local-offer:', pc.signalingState);
|
||||
return;
|
||||
}
|
||||
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('信令交换完成,状态变为:', pc.signalingState);
|
||||
|
||||
// 处理缓存的ICE候选
|
||||
await processPendingIceCandidates();
|
||||
} catch (error) {
|
||||
console.error('处理answer失败:', error);
|
||||
updateState({ error: '信令交换失败', isConnecting: false });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ice-candidate':
|
||||
if (message.payload) {
|
||||
try {
|
||||
const candidate = new RTCIceCandidate(message.payload);
|
||||
|
||||
// 根据W3C规范,检查连接状态
|
||||
if (pc.signalingState === 'closed') {
|
||||
console.warn('跳过ICE候选,连接已关闭');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果有远程描述,直接添加ICE候选
|
||||
if (pc.remoteDescription) {
|
||||
await pc.addIceCandidate(candidate);
|
||||
console.log('已添加ICE候选:', {
|
||||
type: candidate.type,
|
||||
protocol: candidate.protocol,
|
||||
address: candidate.address?.substring(0, 10) + '...',
|
||||
port: candidate.port
|
||||
});
|
||||
} else {
|
||||
// 缓存ICE候选,等待远程描述设置后处理
|
||||
pendingIceCandidates.current.push(candidate);
|
||||
console.log('缓存ICE候选,等待远程描述:', {
|
||||
type: candidate.type,
|
||||
缓存数量: pendingIceCandidates.current.length
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('处理ICE候选失败:', error);
|
||||
// 根据W3C规范,ICE候选错误不应该终止连接
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'disconnection':
|
||||
console.log('收到断开连接通知:', message.payload);
|
||||
const disconnectionMessage = message.payload?.message || '对方已停止传输';
|
||||
updateState({
|
||||
error: disconnectionMessage,
|
||||
isConnecting: false,
|
||||
isConnected: false,
|
||||
isWebSocketConnected: false
|
||||
});
|
||||
// 关闭WebSocket连接
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close(1000, '对方已断开连接');
|
||||
}
|
||||
// 关闭WebRTC连接
|
||||
if (pc.connectionState !== 'closed') {
|
||||
pc.close();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('信令错误:', message.error);
|
||||
updateState({ error: message.error, isConnecting: false });
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理信令消息失败:', error);
|
||||
updateState({ error: '信令处理失败', isConnecting: false });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket错误:', error);
|
||||
console.error('WebSocket连接失败,URL:', finalWsUrl);
|
||||
|
||||
// 清理Chrome内核的连接超时
|
||||
if (wsConnectTimeout) {
|
||||
clearTimeout(wsConnectTimeout);
|
||||
wsConnectTimeout = null;
|
||||
}
|
||||
|
||||
// Chrome内核特殊错误处理
|
||||
const errorMessage = browserInfo.isChromeFamily
|
||||
? 'WebSocket连接失败 - Chrome内核可能阻止了不安全的连接,请确保使用HTTPS'
|
||||
: 'WebSocket连接失败';
|
||||
|
||||
updateState({
|
||||
error: errorMessage,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false
|
||||
});
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('WebSocket连接已关闭,代码:', event.code, '原因:', event.reason, 'URL:', finalWsUrl);
|
||||
updateState({ isWebSocketConnected: false });
|
||||
};
|
||||
|
||||
// ICE候选事件 - 增强处理,Edge浏览器特殊优化
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
const candidateInfo = {
|
||||
type: event.candidate.type,
|
||||
protocol: event.candidate.protocol,
|
||||
address: event.candidate.address,
|
||||
port: event.candidate.port,
|
||||
priority: event.candidate.priority,
|
||||
foundation: event.candidate.foundation
|
||||
};
|
||||
|
||||
console.log('ICE候选信息:', candidateInfo);
|
||||
|
||||
// Chrome内核浏览器特殊处理:检查候选质量和延迟
|
||||
if (browserInfo.isChromeFamily && event.candidate.priority !== null) {
|
||||
// Chrome内核可能生成质量较低的候选,添加延迟来等待更好的候选
|
||||
const isLowQualityCandidate = event.candidate.type === 'host' &&
|
||||
event.candidate.priority < 1000000;
|
||||
|
||||
if (isLowQualityCandidate) {
|
||||
console.log('Chrome内核 - 延迟发送低质量候选');
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
}));
|
||||
}
|
||||
}, 800); // Chrome内核需要更长延迟
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
console.log('ICE候选收集完成');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加ICE收集状态监听
|
||||
pc.onicegatheringstatechange = () => {
|
||||
console.log('ICE收集状态变化:', pc.iceGatheringState);
|
||||
};
|
||||
|
||||
// 信令状态变化监听 - 增强版
|
||||
pc.onsignalingstatechange = () => {
|
||||
console.log('信令状态变化:', pc.signalingState);
|
||||
|
||||
// 根据W3C规范验证状态转换
|
||||
switch (pc.signalingState) {
|
||||
case 'stable':
|
||||
console.log('信令协商完成,连接稳定');
|
||||
break;
|
||||
case 'have-local-offer':
|
||||
console.log('已发送offer,等待answer');
|
||||
break;
|
||||
case 'have-remote-offer':
|
||||
console.log('已接收offer,需要发送answer');
|
||||
break;
|
||||
case 'have-local-pranswer':
|
||||
console.log('已发送provisional answer');
|
||||
break;
|
||||
case 'have-remote-pranswer':
|
||||
console.log('已接收provisional answer');
|
||||
break;
|
||||
case 'closed':
|
||||
console.log('连接已关闭');
|
||||
break;
|
||||
default:
|
||||
console.warn('未知的信令状态:', pc.signalingState);
|
||||
}
|
||||
};
|
||||
|
||||
// 连接状态变化 - 根据W3C规范
|
||||
pc.onconnectionstatechange = () => {
|
||||
console.log('连接状态变化:', pc.connectionState);
|
||||
|
||||
switch (pc.connectionState) {
|
||||
case 'new':
|
||||
console.log('连接初始化');
|
||||
break;
|
||||
case 'connecting':
|
||||
console.log('正在建立连接...');
|
||||
// 只有在当前不是已连接状态时才设置为连接中
|
||||
if (!state.isConnected) {
|
||||
updateState({ isConnecting: true });
|
||||
}
|
||||
break;
|
||||
case 'connected':
|
||||
console.log('连接已建立');
|
||||
clearConnectionTimeout();
|
||||
updateState({
|
||||
isConnected: true,
|
||||
isConnecting: false
|
||||
});
|
||||
break;
|
||||
case 'disconnected':
|
||||
console.log('连接已断开');
|
||||
updateState({
|
||||
isConnected: false
|
||||
});
|
||||
break;
|
||||
case 'failed':
|
||||
console.log('连接失败');
|
||||
clearConnectionTimeout();
|
||||
updateState({
|
||||
error: '连接失败',
|
||||
isConnecting: false,
|
||||
isConnected: false
|
||||
});
|
||||
break;
|
||||
case 'closed':
|
||||
console.log('连接已关闭');
|
||||
updateState({
|
||||
isConnected: false,
|
||||
isConnecting: false
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// ICE连接状态变化 - 根据W3C规范
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
console.log('ICE连接状态变化:', pc.iceConnectionState);
|
||||
|
||||
switch (pc.iceConnectionState) {
|
||||
case 'new':
|
||||
console.log('ICE连接初始化');
|
||||
break;
|
||||
case 'checking':
|
||||
console.log('ICE正在检查连通性...');
|
||||
break;
|
||||
case 'connected':
|
||||
console.log('ICE连接成功');
|
||||
clearConnectionTimeout();
|
||||
break;
|
||||
case 'completed':
|
||||
console.log('ICE连接完成');
|
||||
clearConnectionTimeout();
|
||||
break;
|
||||
case 'disconnected':
|
||||
console.log('ICE连接断开');
|
||||
updateState({
|
||||
error: 'ICE连接断开',
|
||||
isConnected: false
|
||||
});
|
||||
break;
|
||||
case 'failed':
|
||||
console.log('ICE连接失败');
|
||||
clearConnectionTimeout();
|
||||
updateState({
|
||||
error: 'ICE连接失败',
|
||||
isConnecting: false,
|
||||
isConnected: false
|
||||
});
|
||||
break;
|
||||
case 'closed':
|
||||
console.log('ICE连接已关闭');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 如果是发送方,创建数据通道
|
||||
if (role === 'sender') {
|
||||
// 根据浏览器优化数据通道配置
|
||||
const dataChannelConfig = browserInfo.isChromeFamily ? {
|
||||
ordered: true,
|
||||
maxPacketLifeTime: undefined,
|
||||
maxRetransmits: undefined,
|
||||
// Chrome内核特定配置
|
||||
negotiated: false,
|
||||
id: undefined,
|
||||
protocol: '' // Chrome内核需要明确指定协议
|
||||
} : {
|
||||
ordered: true,
|
||||
maxPacketLifeTime: undefined,
|
||||
maxRetransmits: undefined
|
||||
};
|
||||
|
||||
console.log('创建数据通道,配置:', dataChannelConfig);
|
||||
|
||||
// 根据W3C规范,数据通道应该在设置本地描述之前创建
|
||||
const dataChannel = pc.createDataChannel('fileTransfer', dataChannelConfig);
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
// 设置缓冲区管理
|
||||
dataChannel.bufferedAmountLowThreshold = 256 * 1024; // 256KB
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('数据通道已打开 (发送方)');
|
||||
// 数据通道成功打开,清除超时定时器
|
||||
clearConnectionTimeout();
|
||||
console.log('数据通道配置:', {
|
||||
id: dataChannel.id,
|
||||
label: dataChannel.label,
|
||||
maxPacketLifeTime: dataChannel.maxPacketLifeTime,
|
||||
maxRetransmits: dataChannel.maxRetransmits,
|
||||
ordered: dataChannel.ordered,
|
||||
bufferedAmountLowThreshold: dataChannel.bufferedAmountLowThreshold,
|
||||
readyState: dataChannel.readyState
|
||||
});
|
||||
updateState({ localDataChannel: dataChannel });
|
||||
};
|
||||
|
||||
dataChannel.onclose = () => {
|
||||
console.log('数据通道已关闭 (发送方)');
|
||||
updateState({ localDataChannel: null });
|
||||
};
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('数据通道错误 (发送方):', error);
|
||||
updateState({ error: '数据通道连接失败', isConnecting: false });
|
||||
};
|
||||
} else {
|
||||
// 接收方等待数据通道
|
||||
pc.ondatachannel = (event) => {
|
||||
const dataChannel = event.channel;
|
||||
dcRef.current = dataChannel;
|
||||
console.log('收到数据通道 (接收方),标签:', dataChannel.label);
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('数据通道已打开 (接收方)');
|
||||
// 数据通道成功打开,清除超时定时器
|
||||
clearConnectionTimeout();
|
||||
console.log('数据通道配置:', {
|
||||
id: dataChannel.id,
|
||||
label: dataChannel.label,
|
||||
readyState: dataChannel.readyState
|
||||
});
|
||||
updateState({ remoteDataChannel: dataChannel });
|
||||
};
|
||||
|
||||
dataChannel.onclose = () => {
|
||||
console.log('数据通道已关闭 (接收方)');
|
||||
updateState({ remoteDataChannel: null });
|
||||
};
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('数据通道错误 (接收方):', error);
|
||||
updateState({ error: '数据通道连接失败', isConnecting: false });
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('连接失败:', error);
|
||||
clearConnectionTimeout();
|
||||
updateState({
|
||||
error: error instanceof Error ? error.message : '连接失败',
|
||||
isConnecting: false
|
||||
});
|
||||
}
|
||||
}, [updateState, clearConnectionTimeout, handleConnectionTimeout, processPendingIceCandidates, createAndSendOffer, detectBrowser, getBrowserSpecificConfig]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
console.log('断开WebRTC连接');
|
||||
|
||||
// 清理超时定时器
|
||||
clearConnectionTimeout();
|
||||
|
||||
// 清理ICE收集超时
|
||||
if (iceGatheringTimeoutRef.current) {
|
||||
clearTimeout(iceGatheringTimeoutRef.current);
|
||||
iceGatheringTimeoutRef.current = null;
|
||||
console.log('已清理ICE收集超时定时器');
|
||||
}
|
||||
|
||||
// 清理缓存的ICE候选
|
||||
pendingIceCandidates.current = [];
|
||||
|
||||
if (dcRef.current) {
|
||||
dcRef.current.close();
|
||||
dcRef.current = null;
|
||||
}
|
||||
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
setState({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
error: null,
|
||||
localDataChannel: null,
|
||||
remoteDataChannel: null,
|
||||
});
|
||||
}, [clearConnectionTimeout]);
|
||||
|
||||
const getDataChannel = useCallback(() => {
|
||||
return dcRef.current;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
connect,
|
||||
disconnect,
|
||||
getDataChannel,
|
||||
};
|
||||
}
|
||||
402
chuan-next/src/hooks/webrtc/useWebRTCCore.ts
Normal file
402
chuan-next/src/hooks/webrtc/useWebRTCCore.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
// 基础连接状态
|
||||
interface WebRTCCoreState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// 消息类型
|
||||
interface WebRTCMessage {
|
||||
type: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
// 消息处理器类型
|
||||
type MessageHandler = (message: WebRTCMessage) => void;
|
||||
type DataHandler = (data: ArrayBuffer) => void;
|
||||
|
||||
/**
|
||||
* WebRTC 核心连接逻辑
|
||||
* 提供可复用的连接建立逻辑,但每个业务模块独立使用
|
||||
* 不共享连接实例,只共享连接逻辑
|
||||
*/
|
||||
export function useWebRTCCore(channelLabel: string = 'data') {
|
||||
const [state, setState] = useState<WebRTCCoreState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const dcRef = useRef<RTCDataChannel | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 消息处理器存储
|
||||
const messageHandlerRef = useRef<MessageHandler | null>(null);
|
||||
const dataHandlerRef = useRef<DataHandler | null>(null);
|
||||
|
||||
// STUN 服务器配置
|
||||
const STUN_SERVERS = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun.miwifi.com' },
|
||||
{ urls: 'stun:turn.cloudflare.com:3478' },
|
||||
];
|
||||
|
||||
const updateState = useCallback((updates: Partial<WebRTCCoreState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 清理连接
|
||||
const cleanup = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (dcRef.current) {
|
||||
dcRef.current.close();
|
||||
dcRef.current = null;
|
||||
}
|
||||
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 创建 Offer
|
||||
const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
|
||||
try {
|
||||
const offer = await pc.createOffer({
|
||||
offerToReceiveAudio: false,
|
||||
offerToReceiveVideo: false,
|
||||
});
|
||||
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
// 等待 ICE 候选收集完成或超时
|
||||
const iceTimeout = setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log(`[${channelLabel}] 发送 offer (超时发送)`);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(iceTimeout);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log(`[${channelLabel}] 发送 offer (ICE收集完成)`);
|
||||
}
|
||||
} else {
|
||||
pc.onicegatheringstatechange = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(iceTimeout);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log(`[${channelLabel}] 发送 offer (ICE收集完成)`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${channelLabel}] 创建 offer 失败:`, error);
|
||||
updateState({ error: '创建连接失败', isConnecting: false });
|
||||
}
|
||||
}, [channelLabel, updateState]);
|
||||
|
||||
// 处理数据通道消息
|
||||
const handleDataChannelMessage = useCallback((event: MessageEvent) => {
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log(`[${channelLabel}] 收到消息:`, message.type);
|
||||
if (messageHandlerRef.current) {
|
||||
messageHandlerRef.current(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${channelLabel}] 解析消息失败:`, error);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
console.log(`[${channelLabel}] 收到数据:`, event.data.byteLength, 'bytes');
|
||||
if (dataHandlerRef.current) {
|
||||
dataHandlerRef.current(event.data);
|
||||
}
|
||||
}
|
||||
}, [channelLabel]);
|
||||
|
||||
// 连接到房间
|
||||
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
|
||||
console.log(`=== [${channelLabel}] WebRTC 连接开始 ===`);
|
||||
console.log(`[${channelLabel}] 房间代码:`, roomCode, '角色:', role);
|
||||
|
||||
// 检查是否已经在连接中或已连接
|
||||
if (state.isConnecting) {
|
||||
console.warn(`[${channelLabel}] 正在连接中,跳过重复连接请求`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.isConnected) {
|
||||
console.warn(`[${channelLabel}] 已经连接,跳过重复连接请求`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理之前的连接(如果存在)
|
||||
cleanup();
|
||||
|
||||
updateState({ isConnecting: true, error: null });
|
||||
|
||||
// 设置连接超时
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
console.warn(`[${channelLabel}] 连接超时`);
|
||||
updateState({ error: '连接超时,请检查网络状况或重新尝试', isConnecting: false });
|
||||
cleanup();
|
||||
}, 30000);
|
||||
|
||||
try {
|
||||
// 创建 PeerConnection
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: STUN_SERVERS,
|
||||
iceCandidatePoolSize: 10,
|
||||
});
|
||||
pcRef.current = pc;
|
||||
|
||||
// 连接 WebSocket - 使用不同的标识来区分不同的业务连接
|
||||
const wsUrl = config.api.wsUrl.replace('/ws/p2p', '/ws/webrtc');
|
||||
const ws = new WebSocket(`${wsUrl}?code=${roomCode}&role=${role}&channel=${channelLabel}`);
|
||||
wsRef.current = ws;
|
||||
|
||||
// WebSocket 事件处理
|
||||
ws.onopen = () => {
|
||||
console.log(`[${channelLabel}] WebSocket 连接已建立`);
|
||||
updateState({ isWebSocketConnected: true });
|
||||
|
||||
if (role === 'sender') {
|
||||
createOffer(pc, ws);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log(`[${channelLabel}] 收到信令消息:`, message.type);
|
||||
|
||||
switch (message.type) {
|
||||
case 'offer':
|
||||
if (pc.signalingState === 'stable') {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
|
||||
console.log(`[${channelLabel}] 发送 answer`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'answer':
|
||||
if (pc.signalingState === 'have-local-offer') {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log(`[${channelLabel}] 处理 answer 完成`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ice-candidate':
|
||||
if (message.payload && pc.remoteDescription) {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(message.payload));
|
||||
console.log(`[${channelLabel}] 添加 ICE 候选`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error(`[${channelLabel}] 信令错误:`, message.error);
|
||||
updateState({ error: message.error, isConnecting: false });
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${channelLabel}] 处理信令消息失败:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(`[${channelLabel}] WebSocket 错误:`, error);
|
||||
updateState({ error: 'WebSocket连接失败,请检查网络连接', isConnecting: false });
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log(`[${channelLabel}] WebSocket 连接已关闭`);
|
||||
updateState({ isWebSocketConnected: false });
|
||||
};
|
||||
|
||||
// PeerConnection 事件处理
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
}));
|
||||
console.log(`[${channelLabel}] 发送 ICE 候选`);
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
console.log(`[${channelLabel}] 连接状态变化:`, pc.connectionState);
|
||||
switch (pc.connectionState) {
|
||||
case 'connected':
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
updateState({ isConnected: true, isConnecting: false, error: null });
|
||||
break;
|
||||
case 'failed':
|
||||
updateState({ error: 'WebRTC连接失败,可能是网络防火墙阻止了连接', isConnecting: false, isConnected: false });
|
||||
break;
|
||||
case 'disconnected':
|
||||
updateState({ isConnected: false });
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
break;
|
||||
case 'closed':
|
||||
updateState({ isConnected: false, isConnecting: false });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 数据通道处理
|
||||
if (role === 'sender') {
|
||||
const dataChannel = pc.createDataChannel(channelLabel, {
|
||||
ordered: true,
|
||||
maxRetransmits: 3
|
||||
});
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log(`[${channelLabel}] 数据通道已打开 (发送方)`);
|
||||
};
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error(`[${channelLabel}] 数据通道错误:`, error);
|
||||
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
|
||||
};
|
||||
} else {
|
||||
pc.ondatachannel = (event) => {
|
||||
const dataChannel = event.channel;
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log(`[${channelLabel}] 数据通道已打开 (接收方)`);
|
||||
};
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error(`[${channelLabel}] 数据通道错误:`, error);
|
||||
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[${channelLabel}] 连接失败:`, error);
|
||||
updateState({
|
||||
error: error instanceof Error ? error.message : '连接失败',
|
||||
isConnecting: false
|
||||
});
|
||||
}
|
||||
}, [channelLabel, updateState, cleanup, createOffer, handleDataChannelMessage, state.isConnecting, state.isConnected]);
|
||||
|
||||
// 断开连接
|
||||
const disconnect = useCallback(() => {
|
||||
console.log(`[${channelLabel}] 断开 WebRTC 连接`);
|
||||
cleanup();
|
||||
setState({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
error: null,
|
||||
});
|
||||
}, [channelLabel, cleanup]);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback((message: WebRTCMessage) => {
|
||||
const dataChannel = dcRef.current;
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error(`[${channelLabel}] 数据通道未准备就绪`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
dataChannel.send(JSON.stringify(message));
|
||||
console.log(`[${channelLabel}] 发送消息:`, message.type);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[${channelLabel}] 发送消息失败:`, error);
|
||||
return false;
|
||||
}
|
||||
}, [channelLabel]);
|
||||
|
||||
// 发送二进制数据
|
||||
const sendData = useCallback((data: ArrayBuffer) => {
|
||||
const dataChannel = dcRef.current;
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error(`[${channelLabel}] 数据通道未准备就绪`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
dataChannel.send(data);
|
||||
console.log(`[${channelLabel}] 发送数据:`, data.byteLength, 'bytes');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[${channelLabel}] 发送数据失败:`, error);
|
||||
return false;
|
||||
}
|
||||
}, [channelLabel]);
|
||||
|
||||
// 设置消息处理器
|
||||
const setMessageHandler = useCallback((handler: MessageHandler | null) => {
|
||||
messageHandlerRef.current = handler;
|
||||
}, []);
|
||||
|
||||
// 设置数据处理器
|
||||
const setDataHandler = useCallback((handler: DataHandler | null) => {
|
||||
dataHandlerRef.current = handler;
|
||||
}, []);
|
||||
|
||||
// 获取数据通道状态
|
||||
const getChannelState = useCallback(() => {
|
||||
return dcRef.current?.readyState || 'closed';
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
...state,
|
||||
|
||||
// 操作方法
|
||||
connect,
|
||||
disconnect,
|
||||
sendMessage,
|
||||
sendData,
|
||||
|
||||
// 处理器设置
|
||||
setMessageHandler,
|
||||
setDataHandler,
|
||||
|
||||
// 工具方法
|
||||
getChannelState,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user