6 Commits

Author SHA1 Message Date
MatrixSeven
0caeaf62c4 feat: 更新README.md,优化功能描述和最近更新日志 2025-08-24 16:07:06 +08:00
MatrixSeven
6b69d35a20 feat:处理组件渲染导致重复注册事件文件 2025-08-24 15:41:58 +08:00
MatrixSeven
75825e1104 feat: 文件传输ACK确认机制.保证数据完整. 2025-08-24 15:24:12 +08:00
MatrixSeven
720f808ed6 feat:状态组件同步,UI细节处理 2025-08-15 19:24:55 +08:00
MatrixSeven
2abf7bdf42 feat: UI分离 2025-08-15 14:15:51 +08:00
MatrixSeven
3b7fa7c653 feat:桌面共享支持 2025-08-14 15:37:03 +08:00
26 changed files with 3191 additions and 1213 deletions

View File

@@ -1,23 +1,51 @@
# 文件快传 - P2P文件传输工具
### 在线体验 https://transfer.52python.cn
**安全、快速、简单的点对点文件传输解决方案 - 无需注册,即传即用**
[在线体验](https://transfer.52python.cn) • [GitHub](https://github.com/MatrixSeven/file-transfer-go)
![项目演示](img.png)
> 安全、快速、简单的点对点文件传输解决方案 - 无需注册,即传即用
## ✨ 核心功能
## ✨ 核心功能[端到端数据传输完全基于WebRTC的P2P直连]
<div align="center">
- 📁 **文件传输** - 支持多文件同时传输基于WebRTC的P2P直连
![React](https://img.shields.io/badge/React-18-blue.svg)
![Next.js](https://img.shields.io/badge/Next.js-15-black.svg)
![TypeScript](https://img.shields.io/badge/TypeScript-5-blue.svg)
![Go](https://img.shields.io/badge/Go-1.22-blue.svg)
![WebRTC](https://img.shields.io/badge/WebRTC-green.svg)
![Tailwind CSS](https://img.shields.io/badge/Tailwind%20CSS-3.4-blue.svg)
</div>
- 📁 **文件传输** - 支持多文件同时传输
- 📝 **文字传输** - 快速分享文本内容
- 🖥️ **桌面共享** - 实时屏幕共享(开发中)
- 🖥️ **桌面共享** - 实时屏幕共享
- 🔗 **连接状态同步** - 实时连接状态UI同步
- 🔒 **端到端加密** - 数据传输安全,服务器不存储文件
- 📱 **响应式设计** - 完美适配手机、平板、电脑
- 🖥️ **多平台支持** - 支持linux/macos/win 单文件部署
## 🔄 最近更新日志
### 2025-08-24
-**文件传输 ACK 确认支持** - 实现了可靠的数据传输机制,每个数据块都需要接收方确认
-**修复组件渲染后重复注册/解绑 bug** - 解决了 React 组件重复渲染导致的处理器反复注册问题
-**修复进度显示 Infinity% 问题** - 解决了除零错误和进度闪烁问题
### 2025-08-14
-**分离UI组件统一UI状态** - 重构UI架构提高代码复用性和可维护性
-**共享底层链接** - 优化WebRTC连接管理支持多个业务模块共享连接
-**远程桌面支持** - 新增实时屏幕共享功能
-**修复 WebRTC 连接状态异常** - 增强了连接状态错误处理和恢复能力
## 🚀 技术栈
**前端** - Next.js 15 + React 18 + TypeScript + Tailwind CSS
**后端** - Go + WebSocket + 内存存储
**传输** - WebRTC DataChannel + P2P直连
@@ -35,11 +63,14 @@ cd file-transfer-go
## 🎯 使用方法
**发送文件**
### 发送文件
1. 选择文件 → 生成取件码 → 分享6位码
**接收文件**
1. 输入取件码 → 自动连接 → 下载文件
### 文字传输
1. 输入文字内容 → 生成取件码 → 分享给对方
### 桌面共享
1. 点击共享桌面 → 生成取件码 → 对方输入码观看
## 📊 项目架构
@@ -65,4 +96,10 @@ MIT License
---
⭐ 觉得有用请给个星标!
<div align="center">
⭐ 如果觉得这个项目对你有帮助,请给个星标!
[![Star History Chart](https://api.star-history.com/svg?repos=MatrixSeven/file-transfer-go&type=timeline)]
</div>

View File

@@ -35,7 +35,8 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

View File

@@ -3,10 +3,11 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Upload, MessageSquare, Monitor } from 'lucide-react';
import { Upload, MessageSquare, Monitor, TestTube } from 'lucide-react';
import Hero from '@/components/Hero';
import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer';
import {WebRTCTextImageTransfer} from '@/components/WebRTCTextImageTransfer';
import { WebRTCTextImageTransfer } from '@/components/WebRTCTextImageTransfer';
import DesktopShare from '@/components/DesktopShare';
export default function HomePage() {
const searchParams = useSearchParams();
@@ -73,13 +74,12 @@ export default function HomePage() {
</TabsTrigger>
<TabsTrigger
value="desktop"
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"
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"
>
<Monitor 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>
</TabsTrigger>
</TabsTrigger>
</TabsList>
</div>
@@ -94,23 +94,7 @@ export default function HomePage() {
</TabsContent>
<TabsContent value="desktop" 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-purple-100 to-purple-200 rounded-full flex items-center justify-center">
<Monitor className="w-8 h-8 text-purple-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-purple-50 border border-purple-200 rounded-lg p-4">
<p className="text-sm text-purple-700">
🚧
</p>
</div>
<p className="text-xs text-slate-500 mt-4">
使
</p>
</div>
</div>
<DesktopShare />
</TabsContent>
</div>
</Tabs>

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "../styles/animations.css";
:root {
--background: 0 0% 100%;

View File

@@ -0,0 +1,263 @@
import React, { useEffect, useState, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { useWebRTCStore } from '@/hooks/webrtc/webRTCStore';
interface ConnectionStatusProps {
// 房间信息 - 只需要这个基本信息
currentRoom?: { code: string; role: 'sender' | 'receiver' } | null;
// 样式类名
className?: string;
// 紧凑模式
compact?: boolean;
// 内联模式 - 只返回状态文本不包含UI结构
inline?: boolean;
}
// 连接状态枚举
const getConnectionStatus = (connection: any, currentRoom: any) => {
const isWebSocketConnected = connection?.isWebSocketConnected || false;
const isPeerConnected = connection?.isPeerConnected || false;
const isConnecting = connection?.isConnecting || false;
const error = connection?.error || null;
if (error) {
return {
type: 'error' as const,
message: '连接失败',
detail: error,
};
}
if (isConnecting) {
return {
type: 'connecting' as const,
message: '正在连接',
detail: '建立房间连接中...',
};
}
if (!currentRoom) {
return {
type: 'disconnected' as const,
message: '未连接',
detail: '尚未创建房间',
};
}
// 如果有房间信息但WebSocket未连接且不是正在连接状态
// 可能是状态更新的时序问题,显示连接中状态
if (!isWebSocketConnected && !isConnecting) {
return {
type: 'connecting' as const,
message: '连接中',
detail: '正在建立WebSocket连接...',
};
}
if (isWebSocketConnected && !isPeerConnected) {
return {
type: 'room-ready' as const,
message: '房间已创建',
detail: '等待对方加入并建立P2P连接...',
};
}
if (isWebSocketConnected && isPeerConnected) {
return {
type: 'connected' as const,
message: 'P2P连接成功',
detail: '可以开始传输',
};
}
return {
type: 'unknown' as const,
message: '状态未知',
detail: '',
};
};
// 状态颜色映射
const getStatusColor = (type: string) => {
switch (type) {
case 'connected':
return 'text-green-600';
case 'connecting':
case 'room-ready':
return 'text-yellow-600';
case 'error':
return 'text-red-600';
case 'disconnected':
case 'unknown':
default:
return 'text-gray-500';
}
};
// 状态图标
const StatusIcon = ({ type, className = 'w-3 h-3' }: { type: string; className?: string }) => {
const iconClass = cn('inline-block', className);
switch (type) {
case 'connected':
return <div className={cn(iconClass, 'bg-green-500 rounded-full')} />;
case 'connecting':
case 'room-ready':
return (
<div className={cn(iconClass, 'bg-yellow-500 rounded-full animate-pulse')} />
);
case 'error':
return <div className={cn(iconClass, 'bg-red-500 rounded-full')} />;
case 'disconnected':
case 'unknown':
default:
return <div className={cn(iconClass, 'bg-gray-400 rounded-full')} />;
}
};
// 获取连接状态文字描述
const getConnectionStatusText = (connection: any) => {
const isWebSocketConnected = connection?.isWebSocketConnected || false;
const isPeerConnected = connection?.isPeerConnected || false;
const isConnecting = connection?.isConnecting || false;
const error = connection?.error || null;
const wsStatus = isWebSocketConnected ? 'WS已连接' : 'WS未连接';
const rtcStatus = isPeerConnected ? 'RTC已连接' :
isWebSocketConnected ? 'RTC等待连接' : 'RTC未连接';
if (error) {
return `${wsStatus} ${rtcStatus} - 连接失败`;
}
if (isConnecting) {
return `${wsStatus} ${rtcStatus} - 连接中`;
}
if (isPeerConnected) {
return `${wsStatus} ${rtcStatus} - P2P连接成功`;
}
return `${wsStatus} ${rtcStatus}`;
};
export function ConnectionStatus(props: ConnectionStatusProps) {
const { currentRoom, className, compact = false, inline = false } = props;
// 使用全局WebRTC状态
const webrtcState = useWebRTCStore();
// 创建connection对象以兼容现有代码
const connection = {
isWebSocketConnected: webrtcState.isWebSocketConnected,
isPeerConnected: webrtcState.isPeerConnected,
isConnecting: webrtcState.isConnecting,
error: webrtcState.error,
};
// 如果是内联模式,只返回状态文字
if (inline) {
return <span className={cn('text-sm text-slate-600', className)}>{getConnectionStatusText(connection)}</span>;
}
const status = getConnectionStatus(connection, currentRoom);
if (compact) {
return (
<div className={cn('flex items-center', className)}>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 连接状态指示器 */}
<div className="flex items-center gap-3 text-sm">
<div className="flex items-center gap-1.5">
<StatusIcon
type={connection.isWebSocketConnected ? 'connected' : 'disconnected'}
className="w-2.5 h-2.5"
/>
<span className="text-sm text-slate-600 font-medium">WS</span>
</div>
<span className="text-slate-300 font-medium">|</span>
<div className="flex items-center gap-1.5">
<StatusIcon
type={connection.isPeerConnected ? 'connected' : 'disconnected'}
className="w-2.5 h-2.5"
/>
<span className="text-sm text-slate-600 font-medium">RTC</span>
</div>
</div>
</div>
);
}
return (
<div className={cn(className)}>
<div className="space-y-2">
{/* 主要状态 */}
<div className={cn('font-medium text-sm', getStatusColor(status.type))}>
{status.message}
</div>
<div className="text-xs text-muted-foreground">
{status.detail}
</div>
{/* 详细连接状态 */}
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-2">
<span className="text-slate-500 font-medium">WS</span>
<StatusIcon
type={connection.isWebSocketConnected ? 'connected' : 'disconnected'}
className="w-2.5 h-2.5"
/>
<span className={cn(
connection.isWebSocketConnected ? 'text-green-600' : 'text-slate-500'
)}>
{connection.isWebSocketConnected ? '已连接' : '未连接'}
</span>
</div>
<span className="text-slate-300">|</span>
<div className="flex items-center gap-2">
<span className="text-slate-500 font-medium">RTC</span>
<StatusIcon
type={connection.isPeerConnected ? 'connected' : 'disconnected'}
className="w-2.5 h-2.5"
/>
<span className={cn(
connection.isPeerConnected ? 'text-green-600' : 'text-slate-500'
)}>
{connection.isPeerConnected ? '已连接' : '未连接'}
</span>
</div>
</div>
{/* 错误信息
{connection.error && (
<div className="text-xs text-red-600 bg-red-50 rounded p-2">
{connection.error}
</div>
)} */}
</div>
</div>
);
}
// 简化版本的 Hook用于快速集成 - 现在已经不需要了,但保留兼容性
export function useConnectionStatus(webrtcConnection?: any) {
// 这个hook现在不再需要因为ConnectionStatus组件直接使用底层连接
// 但为了向后兼容,保留这个接口
return useMemo(() => ({
isWebSocketConnected: webrtcConnection?.isWebSocketConnected || false,
isPeerConnected: webrtcConnection?.isPeerConnected || false,
isConnecting: webrtcConnection?.isConnecting || false,
currentRoom: webrtcConnection?.currentRoom || null,
error: webrtcConnection?.error || null,
}), [
webrtcConnection?.isWebSocketConnected,
webrtcConnection?.isPeerConnected,
webrtcConnection?.isConnecting,
webrtcConnection?.currentRoom,
webrtcConnection?.error,
]);
}

View File

@@ -3,38 +3,44 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Share, Monitor, Copy, Play, Square } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
import { Share, Monitor } from 'lucide-react';
import WebRTCDesktopReceiver from '@/components/webrtc/WebRTCDesktopReceiver';
import WebRTCDesktopSender from '@/components/webrtc/WebRTCDesktopSender';
import { ConnectionStatus } from '@/components/ConnectionStatus';
import { useWebRTCStore } from '@/hooks/webrtc/webRTCStore';
interface DesktopShareProps {
onStartSharing?: () => Promise<string>; // 返回连接码
// 保留向后兼容性的props已废弃但保留接口
onStartSharing?: () => Promise<string>;
onStopSharing?: () => Promise<void>;
onJoinSharing?: (code: string) => Promise<void>;
}
export default function DesktopShare({ onStartSharing, onStopSharing, onJoinSharing }: DesktopShareProps) {
export default function DesktopShare({
onStartSharing,
onStopSharing,
onJoinSharing
}: DesktopShareProps) {
const searchParams = useSearchParams();
const router = useRouter();
const [mode, setMode] = useState<'share' | 'view'>('share');
const [connectionCode, setConnectionCode] = useState('');
const [inputCode, setInputCode] = useState('');
const [isSharing, setIsSharing] = useState(false);
const [isViewing, setIsViewing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { showToast } = useToast();
// 使用全局WebRTC状态
const webrtcState = useWebRTCStore();
// 从URL参数中获取初始模式
// 从URL参数中获取初始模式和房间代码
useEffect(() => {
const urlMode = searchParams.get('mode');
const type = searchParams.get('type');
const urlCode = searchParams.get('code');
if (type === 'desktop' && urlMode) {
// 将send映射为sharereceive映射为view
if (urlMode === 'send') {
setMode('share');
} else if (urlMode === 'receive') {
setMode('view');
// 如果URL中有房间代码将在DesktopShareReceiver组件中自动加入
}
}
}, [searchParams]);
@@ -42,75 +48,39 @@ export default function DesktopShare({ onStartSharing, onStopSharing, onJoinShar
// 更新URL参数
const updateMode = useCallback((newMode: 'share' | 'view') => {
setMode(newMode);
const params = new URLSearchParams(searchParams.toString());
params.set('type', 'desktop');
// 将share映射为sendview映射为receive以保持一致性
params.set('mode', newMode === 'share' ? 'send' : 'receive');
router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, router]);
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('type', 'desktop');
currentUrl.searchParams.set('mode', newMode === 'share' ? 'send' : 'receive');
// 清除代码参数,避免模式切换时的混乱
currentUrl.searchParams.delete('code');
router.replace(currentUrl.pathname + currentUrl.search);
}, [router]);
const handleStartSharing = useCallback(async () => {
if (!onStartSharing) return;
// 获取初始房间代码(用于接收者模式)
const getInitialCode = useCallback(() => {
const urlMode = searchParams.get('mode');
const type = searchParams.get('type');
const code = searchParams.get('code');
console.log('[DesktopShare] getInitialCode 调用, URL参数:', { type, urlMode, code });
setIsLoading(true);
try {
const code = await onStartSharing();
setConnectionCode(code);
setIsSharing(true);
showToast('桌面共享已开始!', 'success');
} catch (error) {
console.error('开始共享失败:', error);
showToast('开始共享失败,请重试', 'error');
} finally {
setIsLoading(false);
if (type === 'desktop' && urlMode === 'receive') {
const result = code || '';
console.log('[DesktopShare] getInitialCode 返回:', result);
return result;
}
}, [onStartSharing, showToast]);
console.log('[DesktopShare] getInitialCode 返回空字符串');
return '';
}, [searchParams]);
const handleStopSharing = useCallback(async () => {
if (!onStopSharing) return;
setIsLoading(true);
try {
await onStopSharing();
setIsSharing(false);
setConnectionCode('');
showToast('桌面共享已停止', 'success');
} catch (error) {
console.error('停止共享失败:', error);
showToast('停止共享失败', 'error');
} finally {
setIsLoading(false);
}
}, [onStopSharing, showToast]);
const handleJoinSharing = useCallback(async () => {
if (!inputCode.trim() || !onJoinSharing) return;
setIsLoading(true);
try {
await onJoinSharing(inputCode);
setIsViewing(true);
showToast('已连接到桌面共享!', 'success');
} catch (error) {
console.error('连接失败:', error);
showToast('连接失败,请检查连接码', 'error');
} finally {
setIsLoading(false);
}
}, [inputCode, onJoinSharing, showToast]);
const copyToClipboard = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
showToast('已复制到剪贴板!', 'success');
} catch (err) {
showToast('复制失败', 'error');
}
}, [showToast]);
// 连接状态变化处理 - 现在不需要了,因为使用全局状态
const handleConnectionChange = useCallback((connection: any) => {
// 这个函数现在可能不需要了,但为了兼容现有的子组件接口,保留它
console.log('桌面共享连接状态变化:', connection);
}, []);
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
@@ -132,229 +102,17 @@ export default function DesktopShare({ onStartSharing, onStopSharing, onJoinShar
</div>
</div>
{mode === 'share' ? (
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-4 sm:p-6 animate-fade-in-up">
{/* 功能标题和状态 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center">
<Share 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">
{isSharing ? '桌面共享进行中' : '开始共享您的桌面屏幕'}
</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 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">
{isSharing ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">RTC</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">RTC</span>
</>
)}
</div>
</div>
{isSharing && connectionCode && (
<div className="mt-1 text-xs text-purple-600">
{connectionCode}
</div>
)}
</div>
</div>
<div className="space-y-4">
{!isSharing ? (
<Button
onClick={handleStartSharing}
disabled={isLoading}
className="w-full h-12 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Play className="w-5 h-5 mr-2" />
</>
)}
</Button>
) : (
<div className="space-y-4">
<div className="p-4 bg-gradient-to-r from-purple-50 to-pink-50 rounded-xl border border-purple-200">
<div className="text-center">
<p className="text-sm text-purple-700 mb-2"></p>
<div className="text-2xl font-bold font-mono text-purple-600 mb-3">{connectionCode}</div>
<Button
onClick={() => copyToClipboard(connectionCode)}
size="sm"
className="bg-purple-500 hover:bg-purple-600 text-white"
>
<Copy className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<Button
onClick={handleStopSharing}
disabled={isLoading}
className="w-full h-12 bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Square className="w-5 h-5 mr-2" />
</>
)}
</Button>
</div>
)}
</div>
</div>
) : (
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-4 sm:p-6 animate-fade-in-up">
{/* 功能标题和状态 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600">
{isViewing ? '正在观看桌面共享' : '输入连接码观看他人的桌面'}
</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 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">
{isViewing ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">RTC</span>
</>
) : isLoading ? (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">RTC</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">RTC</span>
</>
)}
</div>
</div>
{isViewing && (
<div className="mt-1 text-xs text-indigo-600">
</div>
)}
</div>
</div>
<div className="space-y-4">
{!isViewing ? (
<>
<Input
value={inputCode}
onChange={(e) => setInputCode(e.target.value.toUpperCase().slice(0, 6))}
placeholder="请输入连接码"
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-indigo-500 focus:ring-indigo-500 bg-white/80 backdrop-blur-sm"
maxLength={6}
disabled={isLoading}
/>
<Button
onClick={handleJoinSharing}
disabled={inputCode.length !== 6 || isLoading}
className="w-full h-12 bg-gradient-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Monitor className="w-5 h-5 mr-2" />
</>
)}
</Button>
</>
) : (
<div className="space-y-4">
<div className="aspect-video bg-slate-900 rounded-xl flex items-center justify-center text-white">
<div className="text-center">
<Monitor className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm opacity-75"></p>
</div>
</div>
<Button
onClick={() => setIsViewing(false)}
className="w-full h-12 bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
<Square className="w-5 h-5 mr-2" />
</Button>
</div>
)}
</div>
</div>
)}
{/* 根据模式渲染对应的组件 */}
<div>
{mode === 'share' ? (
<WebRTCDesktopSender onConnectionChange={handleConnectionChange} />
) : (
<WebRTCDesktopReceiver
initialCode={getInitialCode()}
onConnectionChange={handleConnectionChange}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,435 @@
"use client";
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Monitor, Maximize, Minimize, Volume2, VolumeX, Settings, X, Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface DesktopViewerProps {
stream: MediaStream | null;
isConnected: boolean;
connectionCode?: string;
onDisconnect: () => void;
}
export default function DesktopViewer({
stream,
isConnected,
connectionCode,
onDisconnect
}: DesktopViewerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [showControls, setShowControls] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [needsUserInteraction, setNeedsUserInteraction] = useState(false);
const hasAttemptedAutoplayRef = useRef(false);
const [videoStats, setVideoStats] = useState<{
resolution: string;
fps: number;
}>({ resolution: '0x0', fps: 0 });
const hideControlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 设置视频流
useEffect(() => {
if (videoRef.current && stream) {
console.log('[DesktopViewer] 🎬 设置视频流,轨道数量:', stream.getTracks().length);
stream.getTracks().forEach(track => {
console.log('[DesktopViewer] 轨道详情:', track.kind, track.id, track.enabled, track.readyState);
});
videoRef.current.srcObject = stream;
console.log('[DesktopViewer] ✅ 视频元素已设置流');
// 重置状态
hasAttemptedAutoplayRef.current = false;
setNeedsUserInteraction(false);
setIsPlaying(false);
// 添加事件监听器来调试视频加载
const video = videoRef.current;
const handleLoadStart = () => console.log('[DesktopViewer] 📹 视频开始加载');
const handleLoadedMetadata = () => {
console.log('[DesktopViewer] 📹 视频元数据已加载');
console.log('[DesktopViewer] 📹 视频尺寸:', video.videoWidth, 'x', video.videoHeight);
};
const handleCanPlay = () => {
console.log('[DesktopViewer] 📹 视频可以开始播放');
// 只在还未尝试过自动播放时才尝试
if (!hasAttemptedAutoplayRef.current) {
hasAttemptedAutoplayRef.current = true;
video.play()
.then(() => {
console.log('[DesktopViewer] ✅ 视频自动播放成功');
setIsPlaying(true);
setNeedsUserInteraction(false);
})
.catch(e => {
console.log('[DesktopViewer] 📹 自动播放被阻止,需要用户交互:', e.message);
setIsPlaying(false);
setNeedsUserInteraction(true);
});
}
};
const handlePlay = () => {
console.log('[DesktopViewer] 📹 视频开始播放');
setIsPlaying(true);
setNeedsUserInteraction(false);
};
const handlePause = () => {
console.log('[DesktopViewer] 📹 视频暂停');
setIsPlaying(false);
};
const handleError = (e: Event) => console.error('[DesktopViewer] 📹 视频播放错误:', e);
video.addEventListener('loadstart', handleLoadStart);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('error', handleError);
return () => {
video.removeEventListener('loadstart', handleLoadStart);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('error', handleError);
};
} else if (videoRef.current && !stream) {
console.log('[DesktopViewer] ❌ 清除视频流');
videoRef.current.srcObject = null;
setIsPlaying(false);
setNeedsUserInteraction(false);
hasAttemptedAutoplayRef.current = false;
}
}, [stream]);
// 监控视频统计信息
useEffect(() => {
if (!videoRef.current) return;
const video = videoRef.current;
const updateStats = () => {
if (video.videoWidth && video.videoHeight) {
setVideoStats({
resolution: `${video.videoWidth}x${video.videoHeight}`,
fps: 0, // 实际FPS需要更复杂的计算
});
}
};
video.addEventListener('loadedmetadata', updateStats);
video.addEventListener('resize', updateStats);
const interval = setInterval(updateStats, 1000);
return () => {
video.removeEventListener('loadedmetadata', updateStats);
video.removeEventListener('resize', updateStats);
clearInterval(interval);
};
}, []);
// 全屏相关处理
useEffect(() => {
const handleFullscreenChange = () => {
const isCurrentlyFullscreen = !!document.fullscreenElement;
setIsFullscreen(isCurrentlyFullscreen);
if (isCurrentlyFullscreen) {
// 全屏时自动隐藏控制栏,鼠标移动时显示
setShowControls(false);
} else {
setShowControls(true);
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
// 鼠标移动处理(全屏时)
const handleMouseMove = useCallback(() => {
if (isFullscreen) {
setShowControls(true);
// 清除之前的定时器
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current);
}
// 3秒后自动隐藏控制栏
hideControlsTimeoutRef.current = setTimeout(() => {
setShowControls(false);
}, 3000);
}
}, [isFullscreen]);
// 键盘快捷键
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Escape':
if (isFullscreen) {
exitFullscreen();
}
break;
case 'f':
case 'F':
if (event.ctrlKey) {
event.preventDefault();
toggleFullscreen();
}
break;
case 'm':
case 'M':
if (event.ctrlKey) {
event.preventDefault();
toggleMute();
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isFullscreen]);
// 切换全屏
const toggleFullscreen = useCallback(async () => {
if (!containerRef.current) return;
try {
if (isFullscreen) {
await document.exitFullscreen();
} else {
await containerRef.current.requestFullscreen();
}
} catch (error) {
console.error('[DesktopViewer] 全屏切换失败:', error);
}
}, [isFullscreen]);
// 退出全屏
const exitFullscreen = useCallback(async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
}
} catch (error) {
console.error('[DesktopViewer] 退出全屏失败:', error);
}
}, []);
// 切换静音
const toggleMute = useCallback(() => {
if (videoRef.current) {
videoRef.current.muted = !videoRef.current.muted;
setIsMuted(videoRef.current.muted);
}
}, []);
// 手动播放视频
const handleManualPlay = useCallback(() => {
if (videoRef.current) {
videoRef.current.play()
.then(() => {
console.log('[DesktopViewer] ✅ 手动播放成功');
setIsPlaying(true);
setNeedsUserInteraction(false);
})
.catch(e => {
console.error('[DesktopViewer] ❌ 手动播放失败:', e);
});
}
}, []);
// 清理定时器
useEffect(() => {
return () => {
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current);
}
};
}, []);
if (!stream) {
return (
<div className="flex flex-col items-center justify-center h-96 bg-slate-900 rounded-xl text-white">
<Monitor className="w-16 h-16 opacity-50 mb-4" />
<p className="text-lg opacity-75">
{isConnected ? '等待桌面共享流...' : '等待桌面共享连接...'}
</p>
{connectionCode && (
<p className="text-sm opacity-50 mt-2">: {connectionCode}</p>
)}
<div className="mt-4 flex items-center space-x-2 text-sm">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-yellow-500 animate-pulse'}`}></div>
<span>{isConnected ? '已连接,等待视频流' : '正在建立连接'}</span>
</div>
</div>
);
}
return (
<div
ref={containerRef}
className={`relative bg-black rounded-xl overflow-hidden ${isFullscreen ? 'fixed inset-0 z-50' : 'w-full'}`}
onMouseMove={handleMouseMove}
onMouseEnter={() => isFullscreen && setShowControls(true)}
>
{/* 主视频显示 */}
<video
ref={videoRef}
autoPlay
playsInline
muted={isMuted}
className={`w-full h-full object-contain ${isFullscreen ? 'cursor-none' : ''}`}
style={{
aspectRatio: isFullscreen ? 'unset' : '16/9',
minHeight: isFullscreen ? '100vh' : '400px'
}}
/>
{/* 需要用户交互的播放覆盖层 - 只在自动播放尝试失败后显示 */}
{hasAttemptedAutoplayRef.current && needsUserInteraction && !isPlaying && (
<div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center text-white z-10">
<div className="text-center">
<div className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors cursor-pointer" onClick={handleManualPlay}>
<Play className="w-10 h-10 text-white ml-1" />
</div>
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm opacity-75"></p>
</div>
</div>
)}
{/* 连接状态覆盖层 */}
{!isConnected && (
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center text-white">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mb-4"></div>
<p className="text-lg">...</p>
{connectionCode && (
<p className="text-sm opacity-75 mt-2">: {connectionCode}</p>
)}
</div>
)}
{/* 控制栏 */}
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 transition-all duration-300 ${
showControls || !isFullscreen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<div className="flex items-center justify-between">
{/* 左侧信息 */}
<div className="flex items-center space-x-4 text-white text-sm">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isPlaying ? 'bg-green-500 animate-pulse' : 'bg-yellow-500'}`}></div>
<span>{isPlaying ? '桌面共享中' : needsUserInteraction ? '等待播放' : '连接中'}</span>
</div>
{videoStats.resolution !== '0x0' && (
<>
<div className="w-px h-4 bg-white/30"></div>
<span>{videoStats.resolution}</span>
</>
)}
{connectionCode && (
<>
<div className="w-px h-4 bg-white/30"></div>
<span className="font-mono">{connectionCode}</span>
</>
)}
</div>
{/* 右侧控制按钮 */}
<div className="flex items-center space-x-2">
{/* 音频控制 */}
<Button
variant="ghost"
size="sm"
onClick={toggleMute}
className="text-white hover:bg-white/20"
>
{isMuted ? (
<VolumeX className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</Button>
{/* 设置 */}
<Button
variant="ghost"
size="sm"
className="text-white hover:bg-white/20"
>
<Settings className="w-4 h-4" />
</Button>
{/* 全屏切换 */}
<Button
variant="ghost"
size="sm"
onClick={toggleFullscreen}
className="text-white hover:bg-white/20"
title={isFullscreen ? "退出全屏 (Esc)" : "全屏 (Ctrl+F)"}
>
{isFullscreen ? (
<Minimize className="w-4 h-4" />
) : (
<Maximize className="w-4 h-4" />
)}
</Button>
{/* 断开连接 */}
<Button
variant="ghost"
size="sm"
onClick={onDisconnect}
className="text-white hover:bg-red-500/30"
title="断开连接"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* 快捷键提示(仅全屏时显示) */}
{isFullscreen && showControls && (
<div className="mt-2 text-xs text-white/60 text-center">
<p>快捷键: Esc 退 | Ctrl+F | Ctrl+M </p>
</div>
)}
</div>
{/* 加载状态 */}
{stream && !isConnected && (
<div className="absolute top-4 left-4 bg-black/60 text-white px-3 py-2 rounded-lg text-sm flex items-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>...</span>
</div>
)}
{/* 网络状态指示器 */}
<div className="absolute top-4 right-4 bg-black/60 text-white px-3 py-2 rounded-lg text-xs">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${
isConnected ? 'bg-green-500' : 'bg-yellow-500 animate-pulse'
}`}></div>
<span>{isConnected ? '已连接' : '连接中'}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import React from 'react';
import { Button } from '@/components/ui/button';
import QRCodeDisplay from '@/components/QRCodeDisplay';
import { LucideIcon } from 'lucide-react';
interface RoomInfoDisplayProps {
// 房间信息
code: string;
link: string;
// 显示配置
icon: LucideIcon;
iconColor?: string; // 图标背景渐变色,如 'from-emerald-500 to-teal-500'
codeColor?: string; // 代码文字渐变色,如 'from-emerald-600 to-teal-600'
// 文案配置
title: string; // 如 "取件码生成成功!" 或 "房间码生成成功!"
subtitle: string; // 如 "分享以下信息给接收方" 或 "分享以下信息给观看方"
codeLabel: string; // 如 "取件码" 或 "房间代码"
qrLabel: string; // 如 "扫码传输" 或 "扫码观看"
copyButtonText: string; // 如 "复制取件码" 或 "复制房间代码"
copyButtonColor?: string; // 复制按钮颜色,如 'bg-emerald-500 hover:bg-emerald-600'
qrButtonText: string; // 如 "使用手机扫码快速访问" 或 "使用手机扫码快速观看"
linkButtonText: string; // 如 "复制取件链接" 或 "复制观看链接"
// 事件回调
onCopyCode: () => void;
onCopyLink: () => void;
// 样式配置
className?: string;
}
export default function RoomInfoDisplay({
code,
link,
icon: Icon,
iconColor = 'from-emerald-500 to-teal-500',
codeColor = 'from-emerald-600 to-teal-600',
title,
subtitle,
codeLabel,
qrLabel,
copyButtonText,
copyButtonColor = 'bg-emerald-500 hover:bg-emerald-600',
qrButtonText,
linkButtonText,
onCopyCode,
onCopyLink,
className = ''
}: RoomInfoDisplayProps) {
return (
<div className={`border-t border-slate-200 pt-6 ${className}`}>
{/* 左上角状态提示 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 bg-gradient-to-br ${iconColor} rounded-xl flex items-center justify-center`}>
<Icon className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800">{title}</h3>
<p className="text-sm text-slate-600">{subtitle}</p>
</div>
</div>
</div>
{/* 中间区域:代码 + 分隔线 + 二维码 */}
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
{/* 左侧:代码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3">{codeLabel}</label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<div className={`text-2xl font-bold font-mono bg-gradient-to-r ${codeColor} bg-clip-text text-transparent tracking-wider`}>
{code}
</div>
</div>
<Button
onClick={onCopyCode}
className={`w-full px-4 py-2.5 ${copyButtonColor} text-white rounded-lg font-medium shadow transition-all duration-200 mt-3`}
>
{copyButtonText}
</Button>
</div>
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
{/* 右侧:二维码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3">{qrLabel}</label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<QRCodeDisplay
value={link}
size={120}
/>
</div>
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
{qrButtonText}
</div>
</div>
</div>
{/* 底部:链接 */}
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
{link}
</div>
</div>
<Button
onClick={onCopyLink}
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
>
{linkButtonText}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useSharedWebRTCManager } from '@/hooks/webrtc/useSharedWebRTCManager';
import { useFileTransferBusiness } from '@/hooks/webrtc/useFileTransferBusiness';
@@ -42,8 +42,9 @@ export const WebRTCFileTransfer: React.FC = () => {
const urlProcessedRef = useRef(false); // 使用 ref 防止重复处理 URL
const fileInputRef = useRef<HTMLInputElement>(null);
// 创建共享连接
// 创建共享连接 - 使用 useMemo 稳定引用
const connection = useSharedWebRTCManager();
const stableConnection = useMemo(() => connection, [connection.isConnected, connection.isConnecting, connection.isWebSocketConnected, connection.error]);
// 使用共享连接创建业务层
const {
@@ -60,7 +61,7 @@ export const WebRTCFileTransfer: React.FC = () => {
onFileListReceived,
onFileRequested,
onFileProgress
} = useFileTransferBusiness(connection);
} = useFileTransferBusiness(stableConnection);
// 加入房间 (接收模式) - 提前定义以供 useEffect 使用
const joinRoom = useCallback(async (code: string) => {
@@ -287,8 +288,8 @@ export const WebRTCFileTransfer: React.FC = () => {
const updatedList = [...prev, ...newFileInfos];
console.log('更新后的文件列表:', updatedList);
// 如果已连接,立即同步文件列表
if (isConnected && pickupCode) {
// 如果P2P连接已建立,立即同步文件列表
if (isConnected && connection.isPeerConnected && pickupCode) {
console.log('立即同步文件列表到对端');
setTimeout(() => sendFileList(updatedList), 100);
}
@@ -332,12 +333,12 @@ export const WebRTCFileTransfer: React.FC = () => {
}
const code = data.code;
setPickupCode(code);
console.log('房间创建成功,取件码:', code);
// 连接WebRTC作为发送方
connect(code, 'sender');
// 连接WebRTC作为发送方,再设置取件码
// 这样可以确保UI状态与连接状态同步
await connect(code, 'sender');
setPickupCode(code);
showToast(`房间创建成功,取件码: ${code}`, "success");
} catch (error) {
@@ -573,18 +574,23 @@ export const WebRTCFileTransfer: React.FC = () => {
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
));
// 增加额外检查:只有在之前曾经连接成功过的情况下才显示断开提示
// 通过检查是否有文件列表来判断是否曾经连接过
if (fileList.length > 0 || currentTransferFile) {
showToast('与服务器的连接已断开,请重新连接', "error");
// 清理传输状态
console.log('WebSocket断开清理传输状态');
setCurrentTransferFile(null);
setFileList(prev => prev.map(item =>
item.status === 'downloading'
? { ...item, status: 'ready' as const, progress: 0 }
: item
));
}
}
// WebSocket连接成功时的提示
@@ -592,7 +598,7 @@ export const WebRTCFileTransfer: React.FC = () => {
console.log('WebSocket已连接正在建立P2P连接...');
}
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, showToast]);
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, showToast, fileList.length, currentTransferFile]);
// 监听连接状态变化,清理传输状态
useEffect(() => {
@@ -646,8 +652,8 @@ export const WebRTCFileTransfer: React.FC = () => {
console.log('正在建立WebRTC连接...');
}
// 只有在连接成功且没有错误时才发送文件列表
if (isConnected && !error && pickupCode && mode === 'send' && selectedFiles.length > 0) {
// 只有在P2P连接建立且没有错误时才发送文件列表
if (isConnected && connection.isPeerConnected && !error && pickupCode && mode === 'send' && selectedFiles.length > 0) {
// 确保有文件列表
if (fileList.length === 0) {
console.log('创建文件列表并发送...');
@@ -662,7 +668,7 @@ export const WebRTCFileTransfer: React.FC = () => {
setFileList(newFileInfos);
// 延迟发送,确保数据通道已准备好
setTimeout(() => {
if (isConnected && !error) { // 再次检查连接状态
if (isConnected && connection.isPeerConnected && !error) { // 再次检查连接状态
sendFileList(newFileInfos);
}
}, 500);
@@ -670,13 +676,26 @@ export const WebRTCFileTransfer: React.FC = () => {
console.log('发送现有文件列表...');
// 延迟发送,确保数据通道已准备好
setTimeout(() => {
if (isConnected && !error) { // 再次检查连接状态
if (isConnected && connection.isPeerConnected && !error) { // 再次检查连接状态
sendFileList(fileList);
}
}, 500);
}
}
}, [isConnected, isConnecting, isWebSocketConnected, pickupCode, mode, selectedFiles.length, error]);
}, [isConnected, connection.isPeerConnected, isConnecting, isWebSocketConnected, pickupCode, mode, selectedFiles.length, error]);
// 监听P2P连接建立自动发送文件列表
useEffect(() => {
if (connection.isPeerConnected && mode === 'send' && fileList.length > 0) {
console.log('P2P连接已建立发送文件列表...');
// 稍微延迟一下,确保数据通道完全准备好
setTimeout(() => {
if (connection.isPeerConnected && connection.getChannelState() === 'open') {
sendFileList(fileList);
}
}, 200);
}
}, [connection.isPeerConnected, mode, fileList.length, sendFileList]);
// 请求下载文件(接收方调用)
const requestFile = (fileId: string) => {
@@ -765,7 +784,8 @@ export const WebRTCFileTransfer: React.FC = () => {
console.log('=== 清空文件 ===');
setSelectedFiles([]);
setFileList([]);
if (isConnected && pickupCode) {
// 只有在P2P连接建立且数据通道准备好时才发送清空消息
if (isConnected && connection.isPeerConnected && connection.getChannelState() === 'open' && pickupCode) {
sendFileList([]);
}
};
@@ -827,6 +847,8 @@ export const WebRTCFileTransfer: React.FC = () => {
{mode === 'send' ? (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
{/* 连接状态显示 */}
<WebRTCFileUpload
selectedFiles={selectedFiles}
fileList={fileList}
@@ -841,12 +863,12 @@ export const WebRTCFileTransfer: React.FC = () => {
onClearFiles={clearFiles}
onReset={resetRoom}
disabled={!!currentTransferFile}
isConnected={isConnected}
isWebSocketConnected={isWebSocketConnected}
/>
</div>
) : (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
<WebRTCFileReceive
onJoinRoom={joinRoom}
files={fileList}

View File

@@ -1,11 +1,12 @@
"use client";
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Send, Download, X } from 'lucide-react';
import { WebRTCTextSender } from '@/components/webrtc/WebRTCTextSender';
import { WebRTCTextReceiver } from '@/components/webrtc/WebRTCTextReceiver';
import { useWebRTCStore } from '@/hooks/webrtc/webRTCStore';
export const WebRTCTextImageTransfer: React.FC = () => {
const searchParams = useSearchParams();
@@ -15,6 +16,9 @@ export const WebRTCTextImageTransfer: React.FC = () => {
const [mode, setMode] = useState<'send' | 'receive'>('send');
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
// 使用全局WebRTC状态
const webrtcState = useWebRTCStore();
// 从URL参数中获取初始模式
useEffect(() => {
@@ -32,7 +36,7 @@ export const WebRTCTextImageTransfer: React.FC = () => {
}, [searchParams, hasProcessedInitialUrl]);
// 更新URL参数
const updateMode = (newMode: 'send' | 'receive') => {
const updateMode = useCallback((newMode: 'send' | 'receive') => {
console.log('=== 切换模式 ===', newMode);
setMode(newMode);
@@ -45,10 +49,10 @@ export const WebRTCTextImageTransfer: React.FC = () => {
}
router.push(`?${params.toString()}`, { scroll: false });
};
}, [searchParams, router]);
// 重新开始函数
const handleRestart = () => {
const handleRestart = useCallback(() => {
setPreviewImage(null);
const params = new URLSearchParams(searchParams.toString());
@@ -56,10 +60,21 @@ export const WebRTCTextImageTransfer: React.FC = () => {
params.set('mode', mode);
params.delete('code');
router.push(`?${params.toString()}`, { scroll: false });
};
}, [searchParams, mode, router]);
const code = searchParams.get('code') || '';
// 连接状态变化处理 - 现在不需要了,因为使用全局状态
const handleConnectionChange = useCallback((connection: any) => {
// 这个函数现在可能不需要了,但为了兼容现有的子组件接口,保留它
console.log('连接状态变化:', connection);
}, []);
// 关闭图片预览
const closePreview = useCallback(() => {
setPreviewImage(null);
}, []);
return (
<div className="space-y-4 sm:space-y-6">
{/* 模式切换 */}
@@ -85,24 +100,31 @@ export const WebRTCTextImageTransfer: React.FC = () => {
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-4 sm:p-6 animate-fade-in-up">
{mode === 'send' ? (
<WebRTCTextSender onRestart={handleRestart} onPreviewImage={setPreviewImage} />
<WebRTCTextSender
onRestart={handleRestart}
onPreviewImage={setPreviewImage}
onConnectionChange={handleConnectionChange}
/>
) : (
<WebRTCTextReceiver
initialCode={code}
onPreviewImage={setPreviewImage}
onRestart={handleRestart}
onConnectionChange={handleConnectionChange}
/>
)}
</div>
{/* 图片预览模态框 */}
{previewImage && (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" onClick={() => setPreviewImage(null)}>
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" onClick={closePreview}>
<div className="relative max-w-4xl max-h-4xl">
<img src={previewImage} alt="预览" className="max-w-full max-h-full" />
<Button
onClick={() => setPreviewImage(null)}
onClick={closePreview}
className="absolute top-4 right-4 bg-white text-black hover:bg-gray-200"
size="sm"
>

View File

@@ -0,0 +1,241 @@
"use client";
import React, { useState, useCallback, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Monitor, Square } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
import { useDesktopShareBusiness } from '@/hooks/webrtc/useDesktopShareBusiness';
import DesktopViewer from '@/components/DesktopViewer';
import { ConnectionStatus } from '@/components/ConnectionStatus';
interface WebRTCDesktopReceiverProps {
className?: string;
initialCode?: string; // 支持从URL参数传入的房间代码
onConnectionChange?: (connection: any) => void;
}
export default function WebRTCDesktopReceiver({ className, initialCode, onConnectionChange }: WebRTCDesktopReceiverProps) {
const [inputCode, setInputCode] = useState(initialCode || '');
const [isLoading, setIsLoading] = useState(false);
const [showDebug, setShowDebug] = useState(false);
const hasTriedAutoJoin = React.useRef(false); // 添加 ref 来跟踪是否已尝试自动加入
const { showToast } = useToast();
// 使用桌面共享业务逻辑
const desktopShare = useDesktopShareBusiness();
// 通知父组件连接状态变化
useEffect(() => {
if (onConnectionChange && desktopShare.webRTCConnection) {
onConnectionChange(desktopShare.webRTCConnection);
}
}, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]);
// 加入观看
const handleJoinViewing = useCallback(async () => {
if (!inputCode.trim()) {
showToast('请输入房间代码', 'error');
return;
}
try {
setIsLoading(true);
console.log('[DesktopShareReceiver] 用户加入观看房间:', inputCode);
await desktopShare.joinSharing(inputCode.trim().toUpperCase());
console.log('[DesktopShareReceiver] 加入观看成功');
showToast('已加入桌面共享', 'success');
} catch (error) {
console.error('[DesktopShareReceiver] 加入观看失败:', error);
const errorMessage = error instanceof Error ? error.message : '加入观看失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, inputCode, showToast]);
// 停止观看
const handleStopViewing = useCallback(async () => {
try {
setIsLoading(true);
await desktopShare.stopViewing();
showToast('已退出桌面共享', 'success');
setInputCode('');
} catch (error) {
console.error('[DesktopShareReceiver] 停止观看失败:', error);
showToast('退出失败', 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 如果有初始代码且还未加入观看,自动尝试加入
React.useEffect(() => {
console.log('[WebRTCDesktopReceiver] useEffect 触发, 参数:', {
initialCode,
isViewing: desktopShare.isViewing,
isConnecting: desktopShare.isConnecting,
hasTriedAutoJoin: hasTriedAutoJoin.current
});
const autoJoin = async () => {
if (initialCode && !desktopShare.isViewing && !desktopShare.isConnecting && !hasTriedAutoJoin.current) {
hasTriedAutoJoin.current = true;
console.log('[WebRTCDesktopReceiver] 检测到初始代码,自动加入观看:', initialCode);
try {
setIsLoading(true);
await desktopShare.joinSharing(initialCode.trim().toUpperCase());
console.log('[WebRTCDesktopReceiver] 自动加入观看成功');
showToast('已加入桌面共享', 'success');
} catch (error) {
console.error('[WebRTCDesktopReceiver] 自动加入观看失败:', error);
const errorMessage = error instanceof Error ? error.message : '加入观看失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
} else {
console.log('[WebRTCDesktopReceiver] 不满足自动加入条件:', {
hasInitialCode: !!initialCode,
notViewing: !desktopShare.isViewing,
notConnecting: !desktopShare.isConnecting,
notTriedBefore: !hasTriedAutoJoin.current
});
}
};
autoJoin();
}, [initialCode, desktopShare.isViewing, desktopShare.isConnecting]); // 移除了 desktopShare.joinSharing 和 showToast
return (
<div className={`space-y-4 sm:space-y-6 ${className || ''}`}>
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
<div className="space-y-6">
{!desktopShare.isViewing ? (
// 输入房间代码界面 - 与文本消息风格一致
<div>
<div className="flex items-center justify-between mb-6 sm:mb-8">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600">6</p>
</div>
</div>
<ConnectionStatus
currentRoom={desktopShare.connectionCode ? { code: desktopShare.connectionCode, role: 'receiver' } : null}
/>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleJoinViewing(); }} className="space-y-4 sm:space-y-6">
<div className="space-y-3">
<div className="relative">
<Input
value={inputCode}
onChange={(e) => setInputCode(e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase())}
placeholder="请输入房间代码"
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-purple-500 focus:ring-purple-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
maxLength={6}
disabled={isLoading}
/>
</div>
<p className="text-center text-xs sm:text-sm text-slate-500">
{inputCode.length}/6
</p>
</div>
<div className="flex justify-center">
<Button
type="submit"
disabled={inputCode.length !== 6 || isLoading}
className="w-full h-10 sm:h-12 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-base sm:text-lg font-medium rounded-xl shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-105 disabled:opacity-50 disabled:scale-100"
>
{isLoading ? (
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<Monitor className="w-5 h-5" />
<span></span>
</div>
)}
</Button>
</div>
</form>
</div>
) : (
// 已连接,显示桌面观看界面
<div className="space-y-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600">: {inputCode}</p>
</div>
</div>
{/* 连接状态 */}
<ConnectionStatus
currentRoom={{ code: inputCode, role: 'receiver' }}
/>
</div>
{/* 观看中的控制面板 */}
<div className="flex justify-center mb-4">
<div className="bg-white rounded-lg p-3 shadow-lg border flex items-center space-x-4">
<div className="flex items-center space-x-2 text-green-600">
<Monitor className="w-4 h-4" />
<span className="font-semibold"></span>
</div>
<Button
onClick={handleStopViewing}
disabled={isLoading}
variant="destructive"
size="sm"
>
<Square className="w-4 h-4 mr-2" />
{isLoading ? '退出中...' : '退出观看'}
</Button>
</div>
</div>
{/* 桌面显示区域 */}
{desktopShare.remoteStream ? (
<DesktopViewer
stream={desktopShare.remoteStream}
isConnected={desktopShare.isViewing}
connectionCode={inputCode}
onDisconnect={handleStopViewing}
/>
) : (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-8 border border-slate-200">
<div className="text-center">
<Monitor className="w-16 h-16 mx-auto text-slate-400 mb-4" />
<p className="text-slate-600 mb-2">...</p>
<p className="text-sm text-slate-500"></p>
<div className="flex items-center justify-center space-x-2 mt-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
<span className="text-sm text-purple-600">...</span>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,290 @@
"use client";
import React, { useState, useCallback, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Share, Monitor, Copy, Play, Square, Repeat, Users, Wifi, WifiOff } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
import { useDesktopShareBusiness } from '@/hooks/webrtc/useDesktopShareBusiness';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { ConnectionStatus } from '@/components/ConnectionStatus';
interface WebRTCDesktopSenderProps {
className?: string;
onConnectionChange?: (connection: any) => void;
}
export default function WebRTCDesktopSender({ className, onConnectionChange }: WebRTCDesktopSenderProps) {
const [isLoading, setIsLoading] = useState(false);
const { showToast } = useToast();
// 使用桌面共享业务逻辑
const desktopShare = useDesktopShareBusiness();
// 通知父组件连接状态变化
useEffect(() => {
if (onConnectionChange && desktopShare.webRTCConnection) {
onConnectionChange(desktopShare.webRTCConnection);
}
}, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]);
// 复制房间代码
const copyCode = useCallback(async (code: string) => {
try {
await navigator.clipboard.writeText(code);
showToast('房间代码已复制到剪贴板', 'success');
} catch (error) {
console.error('复制失败:', error);
showToast('复制失败,请手动复制', 'error');
}
}, [showToast]);
// 创建房间
const handleCreateRoom = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShareSender] 用户点击创建房间');
const roomCode = await desktopShare.createRoom();
console.log('[DesktopShareSender] 房间创建成功:', roomCode);
showToast(`房间创建成功!代码: ${roomCode}`, 'success');
} catch (error) {
console.error('[DesktopShareSender] 创建房间失败:', error);
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 开始桌面共享
const handleStartSharing = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShareSender] 用户点击开始桌面共享');
await desktopShare.startSharing();
console.log('[DesktopShareSender] 桌面共享开始成功');
showToast('桌面共享已开始', 'success');
} catch (error) {
console.error('[DesktopShareSender] 开始桌面共享失败:', error);
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 切换桌面
const handleSwitchDesktop = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShareSender] 用户点击切换桌面');
await desktopShare.switchDesktop();
console.log('[DesktopShareSender] 桌面切换成功');
showToast('桌面切换成功', 'success');
} catch (error) {
console.error('[DesktopShareSender] 切换桌面失败:', error);
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
// 停止桌面共享
const handleStopSharing = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShareSender] 用户点击停止桌面共享');
await desktopShare.stopSharing();
console.log('[DesktopShareSender] 桌面共享停止成功');
showToast('桌面共享已停止', 'success');
} catch (error) {
console.error('[DesktopShareSender] 停止桌面共享失败:', error);
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
showToast(errorMessage, 'error');
} finally {
setIsLoading(false);
}
}, [desktopShare, showToast]);
return (
<div className={`space-y-4 sm:space-y-6 ${className || ''}`}>
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
{!desktopShare.connectionCode ? (
// 创建房间前的界面
<div className="space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600"></p>
</div>
</div>
<ConnectionStatus
currentRoom={desktopShare.connectionCode ? { code: desktopShare.connectionCode, role: 'sender' } : null}
/>
</div>
<div className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-full flex items-center justify-center">
<Monitor className="w-10 h-10 text-purple-500" />
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-4"></h3>
<p className="text-slate-600 mb-8"></p>
<Button
onClick={handleCreateRoom}
disabled={isLoading || desktopShare.isConnecting}
className="px-8 py-3 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-lg font-medium rounded-xl shadow-lg"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Share className="w-5 h-5 mr-2" />
</>
)}
</Button>
</div>
</div>
) : (
// 房间已创建,显示取件码和等待界面
<div className="space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Monitor className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600">: {desktopShare.connectionCode}</p>
</div>
</div>
<ConnectionStatus
currentRoom={{ code: desktopShare.connectionCode, role: 'sender' }}
/>
</div>
{/* 桌面共享控制区域 */}
{desktopShare.canStartSharing && (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200 mb-6">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-slate-800 flex items-center">
<Monitor className="w-5 h-5 mr-2" />
</h4>
{desktopShare.isSharing && (
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md">
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
<span className="font-medium"></span>
</div>
)}
</div>
<div className="space-y-4">
{!desktopShare.isSharing ? (
<div className="space-y-3">
<Button
onClick={handleStartSharing}
disabled={isLoading || !desktopShare.isPeerConnected}
className={`w-full px-8 py-3 text-lg font-medium rounded-xl shadow-lg ${
desktopShare.isPeerConnected
? 'bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
<Play className="w-5 h-5 mr-2" />
{isLoading ? '启动中...' : '选择并开始共享桌面'}
</Button>
{!desktopShare.isPeerConnected && (
<div className="text-center">
<p className="text-sm text-gray-500 mb-2">
P2P连接...
</p>
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
<span className="text-sm text-purple-600"></span>
</div>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-center space-x-2 text-green-600 mb-4">
<Play className="w-5 h-5" />
<span className="font-semibold"></span>
</div>
<div className="flex justify-center space-x-3">
<Button
onClick={handleSwitchDesktop}
disabled={isLoading}
variant="outline"
size="sm"
>
<Repeat className="w-4 h-4 mr-2" />
{isLoading ? '切换中...' : '切换桌面'}
</Button>
<Button
onClick={handleStopSharing}
disabled={isLoading}
variant="destructive"
size="sm"
>
<Square className="w-4 h-4 mr-2" />
{isLoading ? '停止中...' : '停止共享'}
</Button>
</div>
</div>
)}
</div>
</div>
)}
{/* 房间信息显示 */}
<RoomInfoDisplay
code={desktopShare.connectionCode}
link={`${typeof window !== 'undefined' ? window.location.origin : ''}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`}
icon={Monitor}
iconColor="from-emerald-500 to-teal-500"
codeColor="from-purple-600 to-indigo-600"
title="房间码生成成功!"
subtitle="分享以下信息给观看方"
codeLabel="房间代码"
qrLabel="扫码观看"
copyButtonText="复制房间代码"
copyButtonColor="bg-purple-500 hover:bg-purple-600"
qrButtonText="使用手机扫码快速观看"
linkButtonText="复制链接"
onCopyCode={() => copyCode(desktopShare.connectionCode)}
onCopyLink={() => {
const link = `${window.location.origin}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`;
navigator.clipboard.writeText(link);
showToast('观看链接已复制', 'success');
}}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Download, FileText, Image, Video, Music, Archive } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
import { ConnectionStatus } from '@/components/ConnectionStatus';
interface FileInfo {
id: string;
@@ -133,63 +134,31 @@ export function WebRTCFileReceive({
return (
<div>
{/* 功能标题和状态 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600">
{isConnected ? '已连接到房间,等待发送方选择文件...' : '正在连接到房间...'}
</p>
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
{isWebSocketConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">WS</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">WS</span>
</>
)}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
{isConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">RTC</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">RTC</span>
</>
)}
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600">: {pickupCode}</p>
</div>
</div>
</div>
</div>
<div className="text-center">
<div className="flex items-center space-x-4">
<ConnectionStatus
currentRoom={pickupCode ? { code: pickupCode, role: 'receiver' } : null}
/>
<Button
onClick={onReset}
variant="outline"
className="text-slate-600 hover:text-slate-800 border-slate-200 hover:border-slate-300"
>
</Button>
</div>
</div> <div className="text-center">
{/* 连接状态指示器 */}
<div className="flex items-center justify-center space-x-4 mb-6">
<div className="flex items-center">
@@ -226,67 +195,22 @@ export function WebRTCFileReceive({
return (
<div className="space-y-4 sm:space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center">
<div className="flex items-center space-x-3 flex-1">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-500">
{isConnected ? (
<span className="text-emerald-600"> </span>
) : (
<span className="text-amber-600"> ...</span>
)}
</p>
<p className="text-sm text-slate-600">: {pickupCode}</p>
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
{isWebSocketConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">WS</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">WS</span>
</>
)}
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
{isConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">RTC</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">RTC</span>
</>
)}
</div>
</div>
<div className="mt-1 text-xs text-slate-400">
{files.length}
</div>
</div>
{/* 连接状态 */}
<ConnectionStatus
currentRoom={{ code: pickupCode, role: 'receiver' }}
/>
</div>
<div>
@@ -371,8 +295,8 @@ export function WebRTCFileReceive({
return (
<div>
{/* 功能标题和状态 */}
<div className="flex items-center mb-6 sm:mb-8">
<div className="flex items-center space-x-3 flex-1">
<div className="flex items-center justify-between mb-6 sm:mb-8">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
@@ -382,57 +306,10 @@ export function WebRTCFileReceive({
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
{isConnecting ? (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">WS</span>
</>
) : isWebSocketConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">WS</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">WS</span>
</>
)}
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
{isConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">RTC</span>
</>
) : isConnecting ? (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">RTC</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">RTC</span>
</>
)}
</div>
</div>
</div>
{/* 连接状态 */}
<ConnectionStatus
currentRoom={null}
/>
</div>
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">

View File

@@ -2,9 +2,10 @@
import React, { useState, useRef, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { Upload, FileText, Image, Video, Music, Archive, X } from 'lucide-react';
import QRCodeDisplay from '@/components/QRCodeDisplay';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { ConnectionStatus } from '@/components/ConnectionStatus';
interface FileInfo {
id: string;
@@ -45,8 +46,6 @@ interface WebRTCFileUploadProps {
onClearFiles?: () => void;
onReset?: () => void;
disabled?: boolean;
isConnected?: boolean;
isWebSocketConnected?: boolean;
}
export function WebRTCFileUpload({
@@ -62,9 +61,7 @@ export function WebRTCFileUpload({
onRemoveFile,
onClearFiles,
onReset,
disabled = false,
isConnected = false,
isWebSocketConnected = false
disabled = false
}: WebRTCFileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -115,9 +112,9 @@ export function WebRTCFileUpload({
if (selectedFiles.length === 0 && !pickupCode) {
return (
<div className="space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center">
<div className="flex items-center space-x-3 flex-1">
{/* 功能标题 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
<Upload className="w-5 h-5 text-white" />
</div>
@@ -127,29 +124,9 @@ export function WebRTCFileUpload({
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">WS</span>
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">RTC</span>
</div>
</div>
</div>
<ConnectionStatus
currentRoom={null}
/>
</div>
<div
@@ -197,63 +174,22 @@ export function WebRTCFileUpload({
{/* 文件列表 */}
<div>
{/* 功能标题和状态 */}
<div className="flex items-center mb-4 sm:mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<div className="flex items-center justify-between mb-6">
{/* 标题部分 */}
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-500">{selectedFiles.length} </p>
<p className="text-sm text-slate-600">{selectedFiles.length} </p>
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
{isWebSocketConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">WS</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">WS</span>
</>
)}
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
{isConnected ? (
<>
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-emerald-600">RTC</span>
</>
) : pickupCode ? (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
<span className="text-orange-600">RTC</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
<span className="text-slate-600">RTC</span>
</>
)}
</div>
</div>
</div>
{/* 使用 ConnectionStatus 组件 */}
<ConnectionStatus
currentRoom={pickupCode ? { code: pickupCode, role: 'sender' } : null}
/>
</div>
<div className="space-y-3 mb-4 sm:mb-6">
@@ -397,80 +333,24 @@ export function WebRTCFileUpload({
</div>
{/* 取件码展示 */}
{pickupCode && (
<div className="border-t border-slate-200 pt-6">
{/* 左上角状态提示 - 类似已选择文件的风格 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600"></p>
</div>
</div>
</div>
{/* 中间区域:取件码 + 分隔线 + 二维码 */}
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
{/* 左侧:取件码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<div className="text-2xl font-bold font-mono bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent tracking-wider">
{pickupCode}
</div>
</div>
<Button
onClick={onCopyCode}
className="w-full px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3"
>
</Button>
</div>
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
{/* 右侧:二维码 */}
{pickupLink && (
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<QRCodeDisplay
value={pickupLink}
size={120}
title=""
className="w-auto"
/>
</div>
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
使访
</div>
</div>
)}
</div>
{/* 底部:取件链接 */}
{pickupLink && (
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
{pickupLink}
</div>
</div>
<Button
onClick={onCopyLink}
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
>
</Button>
</div>
</div>
)}
</div>
{pickupCode && pickupLink && (
<RoomInfoDisplay
code={pickupCode}
link={pickupLink}
icon={FileText}
iconColor="from-emerald-500 to-teal-500"
codeColor="from-emerald-600 to-teal-600"
title="取件码生成成功!"
subtitle="分享以下信息给接收方"
codeLabel="取件码"
qrLabel="扫码传输"
copyButtonText="复制取件码"
copyButtonColor="bg-emerald-500 hover:bg-emerald-600"
qrButtonText="使用手机扫码快速访问"
linkButtonText="复制链接"
onCopyCode={onCopyCode || (() => {})}
onCopyLink={onCopyLink || (() => {})}
/>
)}
</div>
);

View File

@@ -8,17 +8,20 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/toast-simple';
import { MessageSquare, Image, Download } from 'lucide-react';
import { ConnectionStatus } from '@/components/ConnectionStatus';
interface WebRTCTextReceiverProps {
initialCode?: string;
onPreviewImage: (imageUrl: string) => void;
onRestart?: () => void;
onConnectionChange?: (connection: any) => void;
}
export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
initialCode = '',
onPreviewImage,
onRestart
onRestart,
onConnectionChange
}) => {
const { showToast } = useToast();
@@ -29,12 +32,13 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
const [receivedImages, setReceivedImages] = useState<Array<{ id: string, content: string, fileName?: string }>>([]);
const [isTyping, setIsTyping] = useState(false);
const [isValidating, setIsValidating] = useState(false);
// Ref用于防止重复自动连接
const hasTriedAutoConnect = useRef(false);
// 创建共享连接 [需要优化]
// 创建共享连接
const connection = useSharedWebRTCManager();
// 使用共享连接创建业务层
const textTransfer = useTextTransferBusiness(connection);
const fileTransfer = useFileTransferBusiness(connection);
@@ -42,117 +46,49 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
// 连接所有传输通道
const connectAll = useCallback(async (code: string, role: 'sender' | 'receiver') => {
console.log('=== 连接所有传输通道 ===', { code, role });
// 只需要连接一次,因为使用的是共享连接
await connection.connect(code, role);
// await Promise.all([
// textTransfer.connect(code, role),
// fileTransfer.connect(code, role)
// ]);
}, [textTransfer, fileTransfer]);
}, [connection]);
// 是否有任何连接
const hasAnyConnection = textTransfer.isConnected || fileTransfer.isConnected;
// 是否正在连接
const isAnyConnecting = textTransfer.isConnecting || fileTransfer.isConnecting;
// 通知父组件连接状态变化
useEffect(() => {
if (onConnectionChange) {
onConnectionChange(connection);
}
}, [onConnectionChange, connection.isConnected, connection.isConnecting, connection.isPeerConnected]);
// 是否有任何错误
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
// 监听连接错误并显示 toast
useEffect(() => {
if (hasAnyError) {
console.error('[WebRTCTextReceiver] 连接错误:', hasAnyError);
showToast(hasAnyError, 'error');
}
}, [hasAnyError, showToast]);
// 验证取件码是否存在
const validatePickupCode = async (code: string): Promise<boolean> => {
try {
setIsValidating(true);
console.log('开始验证取件码:', code);
const response = await fetch(`/api/room-info?code=${code}`);
const data = await response.json();
console.log('验证响应:', { status: response.status, data });
if (!response.ok || !data.success) {
const errorMessage = data.message || '取件码验证失败';
showToast(errorMessage, 'error');
console.log('验证失败:', errorMessage);
return false;
}
console.log('取件码验证成功:', data.room);
return true;
} catch (error) {
console.error('验证取件码时发生错误:', error);
const errorMessage = '网络错误,请检查连接后重试';
showToast(errorMessage, 'error');
return false;
} finally {
setIsValidating(false);
}
};
// 重新开始
const restart = () => {
setPickupCode('');
setInputCode('');
setReceivedText('');
setReceivedImages([]);
setIsTyping(false);
// 断开连接
textTransfer.disconnect();
fileTransfer.disconnect();
// 清理接收的图片URL
receivedImages.forEach(img => {
if (img.content.startsWith('blob:')) {
URL.revokeObjectURL(img.content);
}
});
setReceivedImages([]);
// 断开连接(只需要断开一次)
connection.disconnect();
if (onRestart) {
onRestart();
}
};
// 加入房间
const joinRoom = useCallback(async (code: string) => {
const trimmedCode = code.trim().toUpperCase();
if (!trimmedCode || trimmedCode.length !== 6) {
showToast('请输入正确的6位取件码', "error");
return;
}
if (isAnyConnecting || isValidating) {
console.log('已经在连接中,跳过重复请求');
return;
}
if (hasAnyConnection) {
console.log('已经连接,跳过重复请求');
return;
}
try {
console.log('=== 开始验证和连接房间 ===', trimmedCode);
const isValid = await validatePickupCode(trimmedCode);
if (!isValid) {
return;
}
setPickupCode(trimmedCode);
await connectAll(trimmedCode, 'receiver');
console.log('=== 房间连接成功 ===', trimmedCode);
showToast(`成功加入消息房间: ${trimmedCode}`, "success");
} catch (error) {
console.error('加入房间失败:', error);
showToast(error instanceof Error ? error.message : '加入房间失败', "error");
setPickupCode('');
}
}, [isAnyConnecting, hasAnyConnection, connectAll, showToast, isValidating, validatePickupCode]);
// 监听实时文本同步
useEffect(() => {
const cleanup = textTransfer.onTextSync((text: string) => {
@@ -175,25 +111,66 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
useEffect(() => {
const cleanup = fileTransfer.onFileReceived((fileData) => {
if (fileData.file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const imageData = e.target?.result as string;
setReceivedImages(prev => [...prev, {
id: fileData.id,
content: imageData,
fileName: fileData.file.name
}]);
};
reader.readAsDataURL(fileData.file);
const imageUrl = URL.createObjectURL(fileData.file);
const imageId = Date.now().toString();
setReceivedImages(prev => [...prev, {
id: imageId,
content: imageUrl,
fileName: fileData.file.name
}]);
showToast(`收到图片: ${fileData.file.name}`, "success");
}
});
return cleanup;
}, [fileTransfer.onFileReceived]);
// 验证并加入房间
const joinRoom = useCallback(async (code: string) => {
if (!code || code.length !== 6) return;
setIsValidating(true);
try {
console.log('=== 开始加入房间 ===', code);
// 验证房间
const response = await fetch(`/api/room-info?code=${code}`);
const roomData = await response.json();
if (!response.ok) {
throw new Error(roomData.error || '房间不存在或已过期');
}
console.log('=== 房间验证成功 ===', roomData);
setPickupCode(code);
// 连接到房间
await connectAll(code, 'receiver');
} catch (error: any) {
console.error('加入房间失败:', error);
showToast(error.message || '加入房间失败', "error");
} finally {
setIsValidating(false);
}
}, [connectAll, showToast]);
// 复制文本到剪贴板
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
showToast('已复制到剪贴板', "success");
} catch (error) {
console.error('复制失败:', error);
showToast('复制失败', "error");
}
};
// 处理初始代码连接
useEffect(() => {
// initialCode isAutoConnected
console.log(`initialCode: ${initialCode}, hasTriedAutoConnect: ${hasTriedAutoConnect.current}`);
if (initialCode && initialCode.length === 6 && !hasTriedAutoConnect.current) {
console.log('=== 自动连接初始代码 ===', initialCode);
@@ -202,15 +179,15 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
joinRoom(initialCode);
return;
}
}, [initialCode]);
}, [initialCode, joinRoom]);
return (
<div className="space-y-6">
{!hasAnyConnection ? (
// 输入取件码界面
<div>
<div className="flex items-center mb-6 sm:mb-8">
<div className="flex items-center space-x-3 flex-1">
<div className="flex items-center justify-between mb-6 sm:mb-8">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
@@ -219,6 +196,12 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
<p className="text-sm text-slate-600">6</p>
</div>
</div>
<div className="text-left">
<ConnectionStatus
currentRoom={pickupCode ? { code: pickupCode, role: 'receiver' } : null}
/>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); joinRoom(inputCode); }} className="space-y-4 sm:space-y-6">
@@ -267,94 +250,112 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
) : (
// 已连接,显示实时文本
<div className="space-y-6">
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-500">
<span className="text-emerald-600"> </span>
</p>
<p className="text-sm text-slate-600">: {pickupCode}</p>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<ConnectionStatus
{/* 连接成功状态 */}
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-6">
<h4 className="font-semibold text-emerald-800 mb-1"></h4>
<p className="text-emerald-700">: {pickupCode}</p>
</div>
{/* 实时文本显示区域 */}
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-slate-800 flex items-center">
<MessageSquare className="w-5 h-5 mr-2" />
</h4>
<div className="flex items-center space-x-3 text-sm">
<span className="text-slate-500">
{receivedText.length} / 50,000
</span>
{textTransfer.isConnected && (
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md">
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
<span className="font-medium">WebRTC实时同步</span>
</div>
)}
</div>
</div>
<div className="relative">
<textarea
value={receivedText}
readOnly
placeholder="等待对方发送文字内容...&#10;&#10;💡 实时同步显示,对方的编辑会立即显示在这里"
className="w-full h-40 px-4 py-3 border border-slate-300 rounded-lg bg-slate-50 text-slate-700 placeholder-slate-400 resize-none"
currentRoom={pickupCode ? { code: pickupCode, role: 'receiver' } : null}
/>
{!receivedText && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 rounded-lg border border-slate-300">
<div className="text-center">
<MessageSquare className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600">...</p>
<p className="text-sm text-slate-500 mt-2"></p>
</div>
</div>
<Button
onClick={restart}
variant="outline"
className="text-slate-600 hover:text-slate-800 border-slate-200 hover:border-slate-300"
>
</Button>
</div>
</div>
{/* 文本显示区域 */}
<div className="bg-white/90 backdrop-blur-sm border border-slate-200 rounded-2xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium text-slate-800 flex items-center space-x-2">
<MessageSquare className="w-4 h-4" />
<span></span>
</h4>
{receivedText && (
<Button
onClick={() => copyToClipboard(receivedText)}
size="sm"
variant="ghost"
className="text-slate-600 hover:text-slate-800 h-8 px-3"
>
<span></span>
</Button>
)}
</div>
{/* 打字状态提示 */}
{isTyping && (
<div className="flex items-center space-x-2 mt-3 text-sm text-slate-500">
<div className="flex space-x-1">
{[...Array(3)].map((_, i) => (
<div
key={i}
className="w-1 h-1 bg-slate-400 rounded-full animate-bounce"
style={{ animationDelay: `${i * 0.1}s` }}
></div>
))}
<div className="min-h-[200px] bg-slate-50/50 rounded-xl p-4 border border-slate-100">
{receivedText ? (
<div className="space-y-2">
<pre className="whitespace-pre-wrap text-slate-700 text-sm leading-relaxed font-sans">
{receivedText}
</pre>
{isTyping && (
<div className="flex items-center space-x-2 text-slate-500 text-sm">
<div className="flex space-x-1">
<div className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '0ms'}}></div>
<div className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '150ms'}}></div>
<div className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '300ms'}}></div>
</div>
<span>...</span>
</div>
)}
</div>
<span className="italic">...</span>
</div>
)}
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-400 space-y-3">
<MessageSquare className="w-12 h-12 text-slate-300" />
<p className="text-center">
{connection.isPeerConnected ?
'等待对方发送文字内容...' :
'等待连接建立...'}
</p>
</div>
)}
</div>
</div>
{/* 接收到的图片 */}
{/* 图片显示区域 */}
{receivedImages.length > 0 && (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-6 border border-slate-200">
<h4 className="text-lg font-semibold text-slate-800 mb-4"></h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="bg-white/90 backdrop-blur-sm border border-slate-200 rounded-2xl p-6 space-y-4">
<h4 className="font-medium text-slate-800 flex items-center space-x-2">
<Image className="w-4 h-4" />
<span> ({receivedImages.length})</span>
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{receivedImages.map((image) => (
<img
<div
key={image.id}
src={image.content}
alt={image.fileName}
className="w-full h-32 object-cover rounded-lg border cursor-pointer hover:opacity-80 transition-opacity"
className="group relative aspect-square bg-slate-50 rounded-xl overflow-hidden border border-slate-200 hover:border-slate-300 transition-all duration-200 cursor-pointer"
onClick={() => onPreviewImage(image.content)}
/>
>
<img
src={image.content}
alt={image.fileName || '接收的图片'}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200 flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="bg-white/90 rounded-lg px-3 py-1">
<span className="text-sm text-slate-700"></span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
@@ -363,4 +364,4 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
)}
</div>
);
};
};

View File

@@ -7,14 +7,16 @@ import { useFileTransferBusiness } from '@/hooks/webrtc/useFileTransferBusiness'
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { MessageSquare, Image, Send, Copy } from 'lucide-react';
import QRCodeDisplay from '@/components/QRCodeDisplay';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { ConnectionStatus } from '@/components/ConnectionStatus';
interface WebRTCTextSenderProps {
onRestart?: () => void;
onPreviewImage?: (imageUrl: string) => void;
onConnectionChange?: (connection: any) => void;
}
export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, onPreviewImage }) => {
export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, onPreviewImage, onConnectionChange }) => {
const { showToast } = useToast();
// 状态管理
@@ -38,11 +40,9 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
// 连接所有传输通道
const connectAll = useCallback(async (code: string, role: 'sender' | 'receiver') => {
console.log('=== 连接所有传输通道 ===', { code, role });
await Promise.all([
textTransfer.connect(code, role),
fileTransfer.connect(code, role)
]);
}, [textTransfer, fileTransfer]);
// 只需要连接一次,因为使用的是共享连接
await connection.connect(code, role);
}, [connection]);
// 是否有任何连接
const hasAnyConnection = textTransfer.isConnected || fileTransfer.isConnected;
@@ -50,6 +50,13 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
// 是否正在连接
const isAnyConnecting = textTransfer.isConnecting || fileTransfer.isConnecting;
// 通知父组件连接状态变化
useEffect(() => {
if (onConnectionChange) {
onConnectionChange(connection);
}
}, [onConnectionChange, connection.isConnected, connection.isConnecting, connection.isPeerConnected]);
// 是否有任何错误
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
@@ -63,9 +70,8 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
sentImages.forEach(img => URL.revokeObjectURL(img.url));
setSentImages([]);
// 断开连接
textTransfer.disconnect();
fileTransfer.disconnect();
// 断开连接(只需要断开一次)
connection.disconnect();
if (onRestart) {
onRestart();
@@ -141,7 +147,7 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
// 如果有初始文本,发送它
if (currentText) {
setTimeout(() => {
if (textTransfer.isConnected) {
if (connection.isPeerConnected && textTransfer.isConnected) {
// 发送实时文本同步
textTransfer.sendTextSync(currentText);
@@ -171,8 +177,8 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
const newHeight = Math.min(Math.max(textarea.scrollHeight, 100), 300); // 最小100px最大300px
textarea.style.height = `${newHeight}px`;
// 实时同步文本内容(如果已连接
if (textTransfer.isConnected) {
// 实时同步文本内容(如果P2P连接已建立
if (connection.isPeerConnected && textTransfer.isConnected) {
// 发送实时文本同步
textTransfer.sendTextSync(value);
@@ -215,9 +221,11 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
}]);
// 发送文件
if (fileTransfer.isConnected) {
if (connection.isPeerConnected && fileTransfer.isConnected) {
fileTransfer.sendFile(file);
showToast('图片发送中...', "success");
} else if (!connection.isPeerConnected) {
showToast('等待对方加入P2P网络...', "error");
} else {
showToast('请先连接到房间', "error");
}
@@ -287,36 +295,17 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${textTransfer.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-slate-400'}`}></div>
<span className={textTransfer.isWebSocketConnected ? 'text-blue-600' : 'text-slate-600'}>WS</span>
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${textTransfer.isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-slate-400'}`}></div>
<span className={textTransfer.isConnected ? 'text-emerald-600' : 'text-slate-600'}>RTC</span>
</div>
</div>
</div>
{/* 连接状态 */}
<ConnectionStatus
currentRoom={pickupCode ? { code: pickupCode, role: 'sender' } : null}
/>
</div>
<div className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center">
<MessageSquare className="w-10 h-10 text-blue-500" />
</div>
<h3 className="text-xl font-semibold text-slate-800 mb-4"></h3>
<h3 className="text-lg font-semibold text-slate-800 mb-4"></h3>
<p className="text-slate-600 mb-8"></p>
<Button
@@ -342,45 +331,22 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
// 房间已创建,显示取件码和文本传输界面
<div className="space-y-6">
{/* 功能标题和状态 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600">
{hasAnyConnection ? '实时编辑,对方可以同步看到' : '等待对方连接'}
</p>
</div>
</div>
{/* 竖线分割 */}
<div className="w-px h-12 bg-slate-200 mx-4"></div>
{/* 状态显示 */}
<div className="text-right">
<div className="text-sm text-slate-500 mb-1"></div>
<div className="flex items-center justify-end space-x-3 text-sm">
{/* WebSocket状态 */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${textTransfer.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-red-500'}`}></div>
<span className={textTransfer.isWebSocketConnected ? 'text-blue-600' : 'text-red-600'}>WS</span>
</div>
{/* 分隔符 */}
<div className="text-slate-300">|</div>
{/* WebRTC状态 */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${textTransfer.isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-400'}`}></div>
<span className={textTransfer.isConnected ? 'text-emerald-600' : 'text-orange-600'}>RTC</span>
</div>
</div>
</div>
{/* 功能标题和状态 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-green-500 to-teal-500 rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-white" />
</div>
{/* 文字编辑区域 - 移到最上面 */}
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600"></p>
</div>
</div>
<ConnectionStatus
currentRoom={pickupCode ? { code: pickupCode, role: 'sender' } : null}
/>
</div> {/* 文字编辑区域 - 移到最上面 */}
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-3">
<h4 className="text-lg font-medium text-slate-800 flex items-center">
@@ -409,8 +375,16 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
value={textInput}
onChange={handleTextInputChange}
onPaste={handlePaste}
placeholder="在这里编辑文字内容...&#10;&#10;💡 支持实时同步编辑,对方可以看到你的修改&#10;💡 可以直接粘贴图片 (Ctrl+V)"
className="w-full h-40 px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none text-slate-700 placeholder-slate-400"
disabled={!connection.isPeerConnected}
placeholder={connection.isPeerConnected
? "在这里编辑文字内容...&#10;&#10;💡 支持实时同步编辑,对方可以看到你的修改&#10;💡 可以直接粘贴图片 (Ctrl+V)"
: "等待对方加入P2P网络...&#10;&#10;📡 建立连接后即可开始输入文字"
}
className={`w-full h-40 px-4 py-3 border rounded-lg resize-none text-slate-700 ${
connection.isPeerConnected
? "border-slate-300 focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder-slate-400"
: "border-slate-200 bg-slate-50 cursor-not-allowed placeholder-slate-300"
}`}
/>
<div className="flex items-center justify-between mt-3">
@@ -419,7 +393,10 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
onClick={() => fileInputRef.current?.click()}
variant="outline"
size="sm"
className="flex items-center space-x-1"
disabled={!connection.isPeerConnected}
className={`flex items-center space-x-1 ${
!connection.isPeerConnected ? 'cursor-not-allowed opacity-50' : ''
}`}
>
<Image className="w-4 h-4" />
<span></span>
@@ -461,74 +438,24 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
</div>
)}
{/* 取件码显示 - 和文件传输一致的风格 */}
<div className="border-t border-slate-200 pt-6">
{/* 左上角状态提示 - 类似已选择文件的风格 */}
<div className="flex items-center mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600"></p>
</div>
</div>
</div>
{/* 中间区域:取件码 + 分隔线 + 二维码 */}
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
{/* 左侧:取件码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<div className="text-2xl font-bold font-mono bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent tracking-wider">
{pickupCode}
</div>
</div>
<Button
onClick={copyCode}
className="w-full px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3"
>
</Button>
</div>
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
{/* 右侧:二维码 */}
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-3"></label>
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
<QRCodeDisplay
value={pickupLink}
size={120}
/>
</div>
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
使访
</div>
</div>
</div>
{/* 底部:取件链接 */}
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
{pickupLink}
</div>
</div>
<Button
onClick={copyShareLink}
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
>
</Button>
</div>
</div>
</div>
{/* 取件码显示 */}
<RoomInfoDisplay
code={pickupCode}
link={pickupLink}
icon={MessageSquare}
iconColor="from-emerald-500 to-teal-500"
codeColor="from-emerald-600 to-teal-600"
title="取件码生成成功!"
subtitle="分享以下信息给接收方"
codeLabel="取件码"
qrLabel="扫码传输"
copyButtonText="复制取件码"
copyButtonColor="bg-emerald-500 hover:bg-emerald-600"
qrButtonText="使用手机扫码快速访问"
linkButtonText="复制链接"
onCopyCode={copyCode}
onCopyLink={copyShareLink}
/>
</div>
)}

View File

@@ -0,0 +1,415 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useSharedWebRTCManager } from './useSharedWebRTCManager';
interface DesktopShareState {
isSharing: boolean;
isViewing: boolean;
connectionCode: string;
remoteStream: MediaStream | null;
error: string | null;
isWaitingForPeer: boolean; // 新增:是否等待对方连接
}
export function useDesktopShareBusiness() {
const webRTC = useSharedWebRTCManager();
const [state, setState] = useState<DesktopShareState>({
isSharing: false,
isViewing: false,
connectionCode: '',
remoteStream: null,
error: null,
isWaitingForPeer: false,
});
const localStreamRef = useRef<MediaStream | null>(null);
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
const currentSenderRef = useRef<RTCRtpSender | null>(null);
const updateState = useCallback((updates: Partial<DesktopShareState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
// 处理远程流
const handleRemoteStream = useCallback((stream: MediaStream) => {
console.log('[DesktopShare] 收到远程流:', stream.getTracks().length, '个轨道');
updateState({ remoteStream: stream });
// 如果有视频元素引用,设置流
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = stream;
}
}, [updateState]);
// 设置远程轨道处理器(始终监听)
useEffect(() => {
console.log('[DesktopShare] 🎧 设置远程轨道处理器');
webRTC.onTrack((event: RTCTrackEvent) => {
console.log('[DesktopShare] 🎥 收到远程轨道:', event.track.kind, event.track.id);
console.log('[DesktopShare] 远程流数量:', event.streams.length);
if (event.streams.length > 0) {
const remoteStream = event.streams[0];
console.log('[DesktopShare] 🎬 设置远程流,轨道数量:', remoteStream.getTracks().length);
remoteStream.getTracks().forEach(track => {
console.log('[DesktopShare] 远程轨道:', track.kind, track.id, track.enabled, track.readyState);
});
handleRemoteStream(remoteStream);
} else {
console.warn('[DesktopShare] ⚠️ 收到轨道但没有关联的流');
}
});
}, [webRTC, handleRemoteStream]);
// 生成6位房间代码
const generateRoomCode = useCallback(() => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 6; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}, []);
// 获取桌面共享流
const getDesktopStream = useCallback(async (): Promise<MediaStream> => {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always',
displaySurface: 'monitor',
} as DisplayMediaStreamOptions['video'],
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
} as DisplayMediaStreamOptions['audio'],
});
console.log('[DesktopShare] 获取桌面流成功:', stream.getTracks().length, '个轨道');
return stream;
} catch (error) {
console.error('[DesktopShare] 获取桌面流失败:', error);
throw new Error('无法获取桌面共享权限,请确保允许屏幕共享');
}
}, []);
// 设置视频轨道发送
const setupVideoSending = useCallback(async (stream: MediaStream) => {
console.log('[DesktopShare] 🎬 开始设置视频轨道发送...');
// 移除之前的轨道(如果存在)
if (currentSenderRef.current) {
console.log('[DesktopShare] 🗑️ 移除之前的视频轨道');
webRTC.removeTrack(currentSenderRef.current);
currentSenderRef.current = null;
}
// 添加新的视频轨道到PeerConnection
const videoTrack = stream.getVideoTracks()[0];
const audioTrack = stream.getAudioTracks()[0];
if (videoTrack) {
console.log('[DesktopShare] 📹 添加视频轨道:', videoTrack.id, videoTrack.readyState);
const videoSender = webRTC.addTrack(videoTrack, stream);
if (videoSender) {
currentSenderRef.current = videoSender;
console.log('[DesktopShare] ✅ 视频轨道添加成功');
} else {
console.warn('[DesktopShare] ⚠️ 视频轨道添加返回null');
}
} else {
console.error('[DesktopShare] ❌ 未找到视频轨道');
throw new Error('未找到视频轨道');
}
if (audioTrack) {
try {
console.log('[DesktopShare] 🎵 添加音频轨道:', audioTrack.id, audioTrack.readyState);
const audioSender = webRTC.addTrack(audioTrack, stream);
if (audioSender) {
console.log('[DesktopShare] ✅ 音频轨道添加成功');
} else {
console.warn('[DesktopShare] ⚠️ 音频轨道添加返回null');
}
} catch (error) {
console.warn('[DesktopShare] ⚠️ 音频轨道添加失败,继续视频共享:', error);
}
} else {
console.log('[DesktopShare] 未检测到音频轨道(这通常是正常的)');
}
// 轨道添加完成,现在需要重新协商以包含媒体轨道
console.log('[DesktopShare] ✅ 桌面共享轨道添加完成,开始重新协商');
// 检查P2P连接是否已建立
if (!webRTC.isPeerConnected) {
console.error('[DesktopShare] ❌ P2P连接尚未建立无法开始媒体传输');
throw new Error('P2P连接尚未建立');
}
// 创建新的offer包含媒体轨道
console.log('[DesktopShare] 📨 创建包含媒体轨道的新offer进行重新协商');
const success = await webRTC.createOfferNow();
if (success) {
console.log('[DesktopShare] ✅ 媒体轨道重新协商成功');
} else {
console.error('[DesktopShare] ❌ 媒体轨道重新协商失败');
throw new Error('媒体轨道重新协商失败');
}
// 监听流结束事件(用户停止共享)
const handleStreamEnded = () => {
console.log('[DesktopShare] 🛑 用户停止了屏幕共享');
stopSharing();
};
videoTrack?.addEventListener('ended', handleStreamEnded);
audioTrack?.addEventListener('ended', handleStreamEnded);
return () => {
videoTrack?.removeEventListener('ended', handleStreamEnded);
audioTrack?.removeEventListener('ended', handleStreamEnded);
};
}, [webRTC]);
// 创建房间(只建立连接,等待对方加入)
const createRoom = useCallback(async (): Promise<string> => {
try {
updateState({ error: null, isWaitingForPeer: false });
// 生成房间代码
const roomCode = generateRoomCode();
console.log('[DesktopShare] 🚀 创建桌面共享房间,代码:', roomCode);
// 建立WebRTC连接作为发送方
console.log('[DesktopShare] 📡 正在建立WebRTC连接...');
await webRTC.connect(roomCode, 'sender');
console.log('[DesktopShare] ✅ WebSocket连接已建立');
updateState({
connectionCode: roomCode,
isWaitingForPeer: true, // 标记为等待对方连接
});
console.log('[DesktopShare] 🎯 房间创建完成等待对方加入建立P2P连接');
return roomCode;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
console.error('[DesktopShare] ❌ 创建房间失败:', error);
updateState({ error: errorMessage, connectionCode: '', isWaitingForPeer: false });
throw error;
}
}, [webRTC, generateRoomCode, updateState]);
// 开始桌面共享(在接收方加入后)
const startSharing = useCallback(async (): Promise<void> => {
try {
// 检查WebSocket连接状态
if (!webRTC.isWebSocketConnected) {
throw new Error('WebSocket连接未建立请先创建房间');
}
updateState({ error: null });
console.log('[DesktopShare] 📺 正在请求桌面共享权限...');
// 获取桌面流
const stream = await getDesktopStream();
localStreamRef.current = stream;
console.log('[DesktopShare] ✅ 桌面流获取成功');
// 设置视频发送这会添加轨道并创建offer启动P2P连接
console.log('[DesktopShare] 📤 正在设置视频轨道推送并建立P2P连接...');
await setupVideoSending(stream);
console.log('[DesktopShare] ✅ 视频轨道推送设置完成');
updateState({
isSharing: true,
isWaitingForPeer: false,
});
console.log('[DesktopShare] 🎉 桌面共享已开始');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
console.error('[DesktopShare] ❌ 开始共享失败:', error);
updateState({ error: errorMessage, isSharing: false });
// 清理资源
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
localStreamRef.current = null;
}
throw error;
}
}, [webRTC, getDesktopStream, setupVideoSending, updateState]);
// 切换桌面共享(重新选择屏幕)
const switchDesktop = useCallback(async (): Promise<void> => {
try {
if (!webRTC.isPeerConnected) {
throw new Error('P2P连接未建立');
}
if (!state.isSharing) {
throw new Error('当前未在共享桌面');
}
updateState({ error: null });
console.log('[DesktopShare] 🔄 正在切换桌面共享...');
// 获取新的桌面流
const newStream = await getDesktopStream();
// 停止之前的流
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
}
localStreamRef.current = newStream;
console.log('[DesktopShare] ✅ 新桌面流获取成功');
// 设置新的视频发送
await setupVideoSending(newStream);
console.log('[DesktopShare] ✅ 桌面切换完成');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
console.error('[DesktopShare] ❌ 切换桌面失败:', error);
updateState({ error: errorMessage });
throw error;
}
}, [webRTC, state.isSharing, getDesktopStream, setupVideoSending, updateState]);
// 停止桌面共享
const stopSharing = useCallback(async (): Promise<void> => {
try {
console.log('[DesktopShare] 停止桌面共享');
// 停止本地流
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => {
track.stop();
console.log('[DesktopShare] 停止轨道:', track.kind);
});
localStreamRef.current = null;
}
// 移除发送器
if (currentSenderRef.current) {
webRTC.removeTrack(currentSenderRef.current);
currentSenderRef.current = null;
}
// 断开WebRTC连接
webRTC.disconnect();
updateState({
isSharing: false,
connectionCode: '',
error: null,
isWaitingForPeer: false,
});
console.log('[DesktopShare] 桌面共享已停止');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
console.error('[DesktopShare] 停止共享失败:', error);
updateState({ error: errorMessage });
}
}, [webRTC, updateState]);
// 加入桌面共享观看
const joinSharing = useCallback(async (code: string): Promise<void> => {
try {
updateState({ error: null });
console.log('[DesktopShare] 🔍 正在加入桌面共享观看:', code);
// 连接WebRTC
console.log('[DesktopShare] 🔗 正在连接WebRTC作为接收方...');
await webRTC.connect(code, 'receiver');
console.log('[DesktopShare] ✅ WebRTC连接建立完成');
// 等待连接完全建立
console.log('[DesktopShare] ⏳ 等待连接稳定...');
await new Promise(resolve => setTimeout(resolve, 1000));
updateState({ isViewing: true });
console.log('[DesktopShare] 👁️ 已进入桌面共享观看模式,等待接收流...');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '加入桌面共享失败';
console.error('[DesktopShare] ❌ 加入观看失败:', error);
updateState({ error: errorMessage, isViewing: false });
throw error;
}
}, [webRTC, updateState]);
// 停止观看桌面共享
const stopViewing = useCallback(async (): Promise<void> => {
try {
console.log('[DesktopShare] 停止观看桌面共享');
// 断开WebRTC连接
webRTC.disconnect();
updateState({
isViewing: false,
remoteStream: null,
error: null,
});
console.log('[DesktopShare] 已停止观看桌面共享');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '停止观看失败';
console.error('[DesktopShare] 停止观看失败:', error);
updateState({ error: errorMessage });
}
}, [webRTC, updateState]);
// 设置远程视频元素引用
const setRemoteVideoRef = useCallback((videoElement: HTMLVideoElement | null) => {
remoteVideoRef.current = videoElement;
if (videoElement && state.remoteStream) {
videoElement.srcObject = state.remoteStream;
}
}, [state.remoteStream]);
// 清理资源
useEffect(() => {
return () => {
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
}
};
}, []);
return {
// 状态
isSharing: state.isSharing,
isViewing: state.isViewing,
connectionCode: state.connectionCode,
remoteStream: state.remoteStream,
error: state.error,
isWaitingForPeer: state.isWaitingForPeer,
isConnected: webRTC.isConnected,
isConnecting: webRTC.isConnecting,
isWebSocketConnected: webRTC.isWebSocketConnected,
isPeerConnected: webRTC.isPeerConnected,
// 新增表示是否可以开始共享WebSocket已连接且有房间代码
canStartSharing: webRTC.isWebSocketConnected && !!state.connectionCode,
// 方法
createRoom, // 创建房间
startSharing, // 选择桌面并建立P2P连接
switchDesktop, // 新增:切换桌面
stopSharing,
joinSharing,
stopViewing,
setRemoteVideoRef,
// WebRTC连接状态
webRTCError: webRTC.error,
// 暴露WebRTC连接对象
webRTCConnection: webRTC,
};
}

View File

@@ -3,12 +3,25 @@ import type { WebRTCConnection } from './useSharedWebRTCManager';
// 文件传输状态
interface FileTransferState {
isConnecting: boolean;
isConnected: boolean;
isWebSocketConnected: boolean;
connectionError: string | null;
isTransferring: boolean;
progress: number;
error: string | null;
receivedFiles: Array<{ id: string; file: File }>;
}
// 单个文件的接收进度
interface FileReceiveProgress {
fileId: string;
fileName: string;
receivedChunks: number;
totalChunks: number;
progress: number;
}
// 文件信息
interface FileInfo {
id: string;
@@ -32,6 +45,28 @@ interface FileChunk {
fileId: string;
chunkIndex: number;
totalChunks: number;
checksum?: string; // 数据校验和
}
// 块确认信息
interface ChunkAck {
fileId: string;
chunkIndex: number;
success: boolean;
checksum?: string;
}
// 传输状态
interface TransferStatus {
fileId: string;
fileName: string;
totalChunks: number;
sentChunks: Set<number>;
acknowledgedChunks: Set<number>;
failedChunks: Set<number>;
lastChunkTime: number;
retryCount: Map<number, number>;
averageSpeed: number; // KB/s
}
// 回调类型
@@ -42,6 +77,40 @@ type FileListReceivedCallback = (fileList: FileInfo[]) => void;
const CHANNEL_NAME = 'file-transfer';
const CHUNK_SIZE = 256 * 1024; // 256KB
const MAX_RETRIES = 5; // 最大重试次数
const RETRY_DELAY = 1000; // 重试延迟(毫秒)
const ACK_TIMEOUT = 5000; // 确认超时(毫秒)
/**
* 计算数据的CRC32校验和
*/
function calculateChecksum(data: ArrayBuffer): string {
const buffer = new Uint8Array(data);
let crc = 0xFFFFFFFF;
for (let i = 0; i < buffer.length; i++) {
crc ^= buffer[i];
for (let j = 0; j < 8; j++) {
crc = crc & 1 ? (crc >>> 1) ^ 0xEDB88320 : crc >>> 1;
}
}
return (crc ^ 0xFFFFFFFF).toString(16).padStart(8, '0');
}
/**
* 生成简单的校验和(备用方案)
*/
function simpleChecksum(data: ArrayBuffer): string {
const buffer = new Uint8Array(data);
let sum = 0;
for (let i = 0; i < Math.min(buffer.length, 1000); i++) {
sum += buffer[i];
}
return sum.toString(16);
}
/**
* 文件传输业务层
@@ -50,6 +119,10 @@ const CHUNK_SIZE = 256 * 1024; // 256KB
export function useFileTransferBusiness(connection: WebRTCConnection) {
const [state, setState] = useState<FileTransferState>({
isConnecting: false,
isConnected: false,
isWebSocketConnected: false,
connectionError: null,
isTransferring: false,
progress: 0,
error: null,
@@ -72,6 +145,15 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
const fileProgressCallbacks = useRef<Set<FileProgressCallback>>(new Set());
const fileListCallbacks = useRef<Set<FileListReceivedCallback>>(new Set());
// 传输状态管理
const transferStatus = useRef<Map<string, TransferStatus>>(new Map());
const pendingChunks = useRef<Map<string, NodeJS.Timeout>>(new Map());
const chunkAckCallbacks = useRef<Map<string, Set<(ack: ChunkAck) => void>>>(new Map());
// 接收文件进度跟踪
const receiveProgress = useRef<Map<string, FileReceiveProgress>>(new Map());
const activeReceiveFile = useRef<string | null>(null);
const updateState = useCallback((updates: Partial<FileTransferState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
@@ -90,7 +172,19 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
chunks: [],
receivedChunks: 0,
});
// 初始化接收进度跟踪
const totalChunks = Math.ceil(metadata.size / CHUNK_SIZE);
receiveProgress.current.set(metadata.id, {
fileId: metadata.id,
fileName: metadata.name,
receivedChunks: 0,
totalChunks,
progress: 0
});
// 设置当前活跃的接收文件
activeReceiveFile.current = metadata.id;
updateState({ isTransferring: true, progress: 0 });
break;
@@ -121,6 +215,12 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
fileReceivedCallbacks.current.forEach(cb => cb({ id: fileId, file }));
receivingFiles.current.delete(fileId);
receiveProgress.current.delete(fileId);
// 清除活跃文件
if (activeReceiveFile.current === fileId) {
activeReceiveFile.current = null;
}
}
break;
@@ -134,6 +234,37 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
console.log('收到文件请求:', fileName, requestedFileId);
fileRequestedCallbacks.current.forEach(cb => cb(requestedFileId, fileName));
break;
case 'file-chunk-ack':
const ack: ChunkAck = message.payload;
console.log('收到块确认:', ack);
// 清除超时定时器
const chunkKey = `${ack.fileId}-${ack.chunkIndex}`;
const timeout = pendingChunks.current.get(chunkKey);
if (timeout) {
clearTimeout(timeout);
pendingChunks.current.delete(chunkKey);
}
// 调用确认回调
const callbacks = chunkAckCallbacks.current.get(chunkKey);
if (callbacks) {
callbacks.forEach(cb => cb(ack));
chunkAckCallbacks.current.delete(chunkKey);
}
// 更新传输状态
const status = transferStatus.current.get(ack.fileId);
if (status) {
if (ack.success) {
status.acknowledgedChunks.add(ack.chunkIndex);
status.failedChunks.delete(ack.chunkIndex);
} else {
status.failedChunks.add(ack.chunkIndex);
}
}
break;
}
}, [updateState]);
@@ -144,28 +275,74 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
return;
}
const { fileId, chunkIndex, totalChunks } = expectedChunk.current;
const { fileId, chunkIndex, totalChunks, checksum: expectedChecksum } = expectedChunk.current;
const fileInfo = receivingFiles.current.get(fileId);
if (fileInfo) {
// 验证数据完整性
const actualChecksum = calculateChecksum(data);
const isValid = !expectedChecksum || actualChecksum === expectedChecksum;
if (!isValid) {
console.warn(`文件块校验失败: 期望 ${expectedChecksum}, 实际 ${actualChecksum}`);
// 发送失败确认
connection.sendMessage({
type: 'file-chunk-ack',
payload: {
fileId,
chunkIndex,
success: false,
checksum: actualChecksum
}
}, CHANNEL_NAME);
expectedChunk.current = null;
return;
}
// 数据有效,保存到缓存
fileInfo.chunks[chunkIndex] = data;
fileInfo.receivedChunks++;
const progress = (fileInfo.receivedChunks / totalChunks) * 100;
updateState({ progress });
fileProgressCallbacks.current.forEach(cb => cb({
fileId: fileId,
fileName: fileInfo.metadata.name,
progress
}));
// 更新接收进度跟踪
const progressInfo = receiveProgress.current.get(fileId);
if (progressInfo) {
progressInfo.receivedChunks++;
progressInfo.progress = progressInfo.totalChunks > 0 ?
(progressInfo.receivedChunks / progressInfo.totalChunks) * 100 : 0;
// 只有当这个文件是当前活跃文件时才更新全局进度
if (activeReceiveFile.current === fileId) {
updateState({ progress: progressInfo.progress });
}
// 触发进度回调
fileProgressCallbacks.current.forEach(cb => cb({
fileId: fileId,
fileName: progressInfo.fileName,
progress: progressInfo.progress
}));
console.log(`文件 ${fileInfo.metadata.name} 接收进度: ${progress.toFixed(1)}%`);
console.log(`文件 ${progressInfo.fileName} 接收进度: ${progressInfo.progress.toFixed(1)}%`);
}
// 发送成功确认
connection.sendMessage({
type: 'file-chunk-ack',
payload: {
fileId,
chunkIndex,
success: true,
checksum: actualChecksum
}
}, CHANNEL_NAME);
expectedChunk.current = null;
}
}, [updateState]);
}, [updateState, connection]);
// 设置处理器
// 设置处理器 - 使用稳定的引用避免反复注册
useEffect(() => {
// 使用共享连接的注册方式
const unregisterMessage = connection.registerMessageHandler(CHANNEL_NAME, handleMessage);
@@ -175,15 +352,78 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
unregisterMessage();
unregisterData();
};
}, [handleMessage, handleData]);
}, [connection]); // 只依赖 connection 对象,不依赖处理函数
// 监听连接状态变化 (直接使用 connection 的状态)
useEffect(() => {
// 同步连接状态
updateState({
isConnecting: connection.isConnecting,
isConnected: connection.isConnected,
isWebSocketConnected: connection.isWebSocketConnected,
connectionError: connection.error
});
}, [connection.isConnecting, connection.isConnected, connection.isWebSocketConnected, connection.error, updateState]);
// 连接
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
return connection.connect(roomCode, role);
}, [connection]);
// 发送文件
const sendFile = useCallback(async (file: File, fileId?: string) => {
// 安全发送单个文件
const sendChunkWithAck = useCallback(async (
fileId: string,
chunkIndex: number,
chunkData: ArrayBuffer,
checksum: string,
retryCount = 0
): Promise<boolean> => {
return new Promise((resolve) => {
const chunkKey = `${fileId}-${chunkIndex}`;
// 设置确认回调
const ackCallback = (ack: ChunkAck) => {
if (ack.success) {
resolve(true);
} else {
console.warn(`文件块 ${chunkIndex} 确认失败,准备重试`);
resolve(false);
}
};
// 注册确认回调
if (!chunkAckCallbacks.current.has(chunkKey)) {
chunkAckCallbacks.current.set(chunkKey, new Set());
}
chunkAckCallbacks.current.get(chunkKey)!.add(ackCallback);
// 设置超时定时器
const timeout = setTimeout(() => {
console.warn(`文件块 ${chunkIndex} 确认超时`);
chunkAckCallbacks.current.get(chunkKey)?.delete(ackCallback);
resolve(false);
}, ACK_TIMEOUT);
pendingChunks.current.set(chunkKey, timeout);
// 发送块信息
connection.sendMessage({
type: 'file-chunk-info',
payload: {
fileId,
chunkIndex,
totalChunks: 0, // 这里不需要,因为已经在元数据中发送
checksum
}
}, CHANNEL_NAME);
// 发送块数据
connection.sendData(chunkData);
});
}, [connection]);
// 安全发送文件
const sendFileSecure = useCallback(async (file: File, fileId?: string) => {
if (connection.getChannelState() !== 'open') {
updateState({ error: '连接未就绪' });
return;
@@ -192,10 +432,24 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
const actualFileId = fileId || `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
console.log('开始发送文件:', file.name, '文件ID:', actualFileId, '总块数:', totalChunks);
console.log('开始安全发送文件:', file.name, '文件ID:', actualFileId, '总块数:', totalChunks);
updateState({ isTransferring: true, progress: 0, error: null });
// 初始化传输状态
const status: TransferStatus = {
fileId: actualFileId,
fileName: file.name,
totalChunks,
sentChunks: new Set(),
acknowledgedChunks: new Set(),
failedChunks: new Set(),
lastChunkTime: Date.now(),
retryCount: new Map(),
averageSpeed: 0
};
transferStatus.current.set(actualFileId, status);
try {
// 1. 发送文件元数据
connection.sendMessage({
@@ -210,25 +464,52 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
// 2. 分块发送文件
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
let success = false;
let retryCount = 0;
// 先发送块信息
connection.sendMessage({
type: 'file-chunk-info',
payload: {
fileId: actualFileId,
chunkIndex,
totalChunks
while (!success && retryCount <= MAX_RETRIES) {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const arrayBuffer = await chunk.arrayBuffer();
const checksum = calculateChecksum(arrayBuffer);
console.log(`发送文件块 ${chunkIndex}/${totalChunks}, 重试次数: ${retryCount}`);
// 发送块并等待确认
success = await sendChunkWithAck(actualFileId, chunkIndex, arrayBuffer, checksum, retryCount);
if (success) {
status.sentChunks.add(chunkIndex);
status.acknowledgedChunks.add(chunkIndex);
status.failedChunks.delete(chunkIndex);
// 计算传输速度
const now = Date.now();
const timeDiff = (now - status.lastChunkTime) / 1000; // 秒
if (timeDiff > 0) {
const speed = (arrayBuffer.byteLength / 1024) / timeDiff; // KB/s
status.averageSpeed = status.averageSpeed * 0.7 + speed * 0.3; // 平滑平均
}
status.lastChunkTime = now;
} else {
retryCount++;
status.retryCount.set(chunkIndex, retryCount);
if (retryCount > MAX_RETRIES) {
status.failedChunks.add(chunkIndex);
throw new Error(`文件块 ${chunkIndex} 发送失败,超过最大重试次数`);
}
// 指数退避
const delay = Math.min(RETRY_DELAY * Math.pow(2, retryCount - 1), 10000);
console.log(`等待 ${delay}ms 后重试文件块 ${chunkIndex}`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}, CHANNEL_NAME);
}
// 再发送块数据
const arrayBuffer = await chunk.arrayBuffer();
connection.sendData(arrayBuffer);
const progress = ((chunkIndex + 1) / totalChunks) * 100;
// 更新进度
const progress = (status.acknowledgedChunks.size / totalChunks) * 100;
updateState({ progress });
fileProgressCallbacks.current.forEach(cb => cb({
@@ -237,43 +518,73 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
progress
}));
// 简单的流控:等待一小段时间让接收方处理
if (chunkIndex % 10 === 0) {
await new Promise(resolve => setTimeout(resolve, 10));
// 自适应流控:根据传输速度调整发送间隔
if (status.averageSpeed > 0) {
const chunkSize = Math.min(CHUNK_SIZE, file.size - chunkIndex * CHUNK_SIZE);
const expectedTime = (chunkSize / 1024) / status.averageSpeed;
const actualTime = Date.now() - status.lastChunkTime;
const delay = Math.max(0, expectedTime - actualTime);
if (delay > 10) {
await new Promise(resolve => setTimeout(resolve, Math.min(delay, 100)));
}
}
}
// 3. 发送完成信号
// 3. 验证所有块都已确认
if (status.acknowledgedChunks.size !== totalChunks) {
throw new Error(`文件传输不完整:${status.acknowledgedChunks.size}/${totalChunks} 块已确认`);
}
// 4. 发送完成信号
connection.sendMessage({
type: 'file-complete',
payload: { fileId: actualFileId }
}, CHANNEL_NAME);
updateState({ isTransferring: false, progress: 100 });
console.log('文件发送完成:', file.name);
console.log('文件安全发送完成:', file.name, `平均速度: ${status.averageSpeed.toFixed(2)} KB/s`);
transferStatus.current.delete(actualFileId);
} catch (error) {
console.error('发送文件失败:', error);
console.error('安全发送文件失败:', error);
updateState({
error: error instanceof Error ? error.message : '发送失败',
isTransferring: false
});
transferStatus.current.delete(actualFileId);
}
}, [connection, updateState]);
}, [connection, updateState, sendChunkWithAck]);
// 保持原有的 sendFile 方法用于向后兼容
const sendFile = useCallback(async (file: File, fileId?: string) => {
// 默认使用新的安全发送方法
return sendFileSecure(file, fileId);
}, [sendFileSecure]);
// 发送文件列表
const sendFileList = useCallback((fileList: FileInfo[]) => {
if (connection.getChannelState() !== 'open') {
console.error('数据通道未准备就绪,无法发送文件列表');
return;
}
console.log('发送文件列表:', fileList);
// 检查连接状态 - 优先检查数据通道状态,因为 P2P 连接可能已经建立但状态未及时更新
const channelState = connection.getChannelState();
const peerConnected = connection.isPeerConnected;
connection.sendMessage({
type: 'file-list',
payload: fileList
}, CHANNEL_NAME);
console.log('发送文件列表检查:', {
channelState,
peerConnected,
fileListLength: fileList.length
});
// 如果数据通道已打开或者 P2P 已连接,就可以发送文件列表
if (channelState === 'open' || peerConnected) {
console.log('发送文件列表:', fileList);
connection.sendMessage({
type: 'file-list',
payload: fileList
}, CHANNEL_NAME);
} else {
console.log('P2P连接未建立等待连接后再发送文件列表');
}
}, [connection]);
// 请求文件
@@ -313,13 +624,7 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
}, []);
return {
// 继承基础连接状态
isConnected: connection.isConnected,
isConnecting: connection.isConnecting,
isWebSocketConnected: connection.isWebSocketConnected,
connectionError: connection.error,
// 文件传输状态
// 文件传输状态(包括连接状态
...state,
// 操作方法

View File

@@ -1,11 +1,13 @@
import { useState, useRef, useCallback } from 'react';
import { config } from '@/lib/config';
import { getWsUrl } from '@/lib/config';
import { useWebRTCStore } from './webRTCStore';
// 基础连接状态
interface WebRTCState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean; // 新增P2P连接状态
error: string | null;
}
@@ -26,6 +28,7 @@ export interface WebRTCConnection {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean; // 新增P2P连接状态
error: string | null;
// 操作方法
@@ -44,6 +47,13 @@ export interface WebRTCConnection {
// 当前房间信息
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
// 媒体轨道方法
addTrack: (track: MediaStreamTrack, stream: MediaStream) => RTCRtpSender | null;
removeTrack: (sender: RTCRtpSender) => void;
onTrack: (callback: (event: RTCTrackEvent) => void) => void;
getPeerConnection: () => RTCPeerConnection | null;
createOfferNow: () => Promise<boolean>;
}
/**
@@ -51,12 +61,8 @@ export interface WebRTCConnection {
* 创建单一的 WebRTC 连接实例,供多个业务模块共享使用
*/
export function useSharedWebRTCManager(): WebRTCConnection {
const [state, setState] = useState<WebRTCState>({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
error: null,
});
// 使用全局状态 store
const webrtcStore = useWebRTCStore();
const wsRef = useRef<WebSocket | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
@@ -70,58 +76,61 @@ export function useSharedWebRTCManager(): WebRTCConnection {
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
const dataHandlers = useRef<Map<string, DataHandler>>(new Map());
// STUN 服务器配置
// STUN 服务器配置 - 使用更稳定的服务器
const STUN_SERVERS = [
{ urls: 'stun:stun.chat.bilibili.com' },
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.miwifi.com' },
{ urls: 'stun:turn.cloudflare.com:3478' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'stun:global.stun.twilio.com:3478' },
];
const updateState = useCallback((updates: Partial<WebRTCState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
webrtcStore.updateState(updates);
}, [webrtcStore]);
// 清理连接
const cleanup = useCallback(() => {
// console.log('[SharedWebRTC] 清理连接');
// if (timeoutRef.current) {
// clearTimeout(timeoutRef.current);
// timeoutRef.current = null;
// }
console.log('[SharedWebRTC] 清理连接');
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// if (dcRef.current) {
// dcRef.current.close();
// dcRef.current = null;
// }
if (dcRef.current) {
dcRef.current.close();
dcRef.current = null;
}
// if (pcRef.current) {
// pcRef.current.close();
// pcRef.current = null;
// }
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
// if (wsRef.current) {
// wsRef.current.close();
// wsRef.current = null;
// }
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
// currentRoom.current = null;
currentRoom.current = null;
}, []);
// 创建 Offer
const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
try {
console.log('[SharedWebRTC] 🎬 开始创建offer当前轨道数量:', pc.getSenders().length);
const offer = await pc.createOffer({
offerToReceiveAudio: false,
offerToReceiveVideo: false,
offerToReceiveAudio: true, // 改为true以支持音频接收
offerToReceiveVideo: true, // 改为true以支持视频接收
});
console.log('[SharedWebRTC] 📝 Offer创建成功设置本地描述...');
await pc.setLocalDescription(offer);
const iceTimeout = setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[SharedWebRTC] 发送 offer (超时发送)');
console.log('[SharedWebRTC] 📤 发送 offer (超时发送)');
}
}, 3000);
@@ -129,7 +138,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
clearTimeout(iceTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[SharedWebRTC] 发送 offer (ICE收集完成)');
console.log('[SharedWebRTC] 📤 发送 offer (ICE收集完成)');
}
} else {
pc.onicegatheringstatechange = () => {
@@ -137,13 +146,13 @@ export function useSharedWebRTCManager(): WebRTCConnection {
clearTimeout(iceTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[SharedWebRTC] 发送 offer (ICE收集完成)');
console.log('[SharedWebRTC] 📤 发送 offer (ICE收集完成)');
}
}
};
}
} catch (error) {
console.error('[SharedWebRTC] 创建 offer 失败:', error);
console.error('[SharedWebRTC] 创建 offer 失败:', error);
updateState({ error: '创建连接失败', isConnecting: false });
}
}, [updateState]);
@@ -187,60 +196,25 @@ export function useSharedWebRTCManager(): WebRTCConnection {
// 连接到房间
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
console.log('[SharedWebRTC] 连接到房间:', roomCode, role);
console.log('[SharedWebRTC] 🚀 开始连接到房间:', roomCode, role);
// 检查是否已经连接到相同房间
if (currentRoom.current?.code === roomCode && currentRoom.current?.role === role) {
if (state.isConnected) {
console.log('[SharedWebRTC] 已连接到相同房间,复用连接');
return;
}
if (state.isConnecting) {
console.log('[SharedWebRTC] 正在连接到相同房间,等待连接完成');
return new Promise<void>((resolve, reject) => {
const checkConnection = () => {
if (state.isConnected) {
resolve();
} else if (!state.isConnecting) {
reject(new Error('连接失败'));
} else {
setTimeout(checkConnection, 100);
}
};
checkConnection();
});
}
}
// 如果要连接到不同房间,先断开当前连接
if (currentRoom.current && (currentRoom.current.code !== roomCode || currentRoom.current.role !== role)) {
console.log('[SharedWebRTC] 切换到新房间,断开当前连接');
cleanup();
updateState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
error: null,
});
}
if (state.isConnecting) {
console.warn('[SharedWebRTC] 正在连接中,跳过重复连接请求');
// 如果正在连接中,避免重复连接
if (webrtcStore.isConnecting) {
console.warn('[SharedWebRTC] ⚠️ 正在连接中,跳过重复连接请求');
return;
}
// 清理之前的连接
cleanup();
currentRoom.current = { code: roomCode, role };
webrtcStore.setCurrentRoom({ code: roomCode, role });
updateState({ isConnecting: true, error: null });
// 设置连接超时
timeoutRef.current = setTimeout(() => {
console.warn('[SharedWebRTC] 连接超时');
updateState({ error: '连接超时,请检查网络状况或重新尝试', isConnecting: false });
cleanup();
}, 30000);
// 注意不在这里设置超时因为WebSocket连接很快
// WebRTC连接的建立是在后续添加轨道时进行的
try {
console.log('[SharedWebRTC] 🔧 创建PeerConnection...');
// 创建 PeerConnection
const pc = new RTCPeerConnection({
iceServers: STUN_SERVERS,
@@ -248,69 +222,133 @@ export function useSharedWebRTCManager(): WebRTCConnection {
});
pcRef.current = pc;
// 连接 WebSocket
const wsUrl = config.api.wsUrl.replace('/ws/p2p', '/ws/webrtc');
const ws = new WebSocket(`${wsUrl}?code=${roomCode}&role=${role}&channel=shared`);
// 连接 WebSocket - 使用动态URL
const baseWsUrl = getWsUrl();
if (!baseWsUrl) {
throw new Error('WebSocket URL未配置');
}
// 构建完整的WebSocket URL
const wsUrl = baseWsUrl.replace('/ws/p2p', `/ws/webrtc?code=${roomCode}&role=${role}&channel=shared`);
console.log('[SharedWebRTC] 🌐 连接WebSocket:', wsUrl);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
// WebSocket 事件处理
ws.onopen = () => {
console.log('[SharedWebRTC] WebSocket 连接已建立');
updateState({ isWebSocketConnected: true });
if (role === 'sender') {
createOffer(pc, ws);
}
console.log('[SharedWebRTC] WebSocket 连接已建立,房间准备就绪');
updateState({
isWebSocketConnected: true,
isConnecting: false, // WebSocket连接成功即表示初始连接完成
isConnected: true // 可以开始后续操作
});
};
ws.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
console.log('[SharedWebRTC] 收到信令消息:', message.type);
console.log('[SharedWebRTC] 📨 收到信令消息:', message.type);
switch (message.type) {
case 'peer-joined':
// 对方加入房间的通知
console.log('[SharedWebRTC] 👥 对方已加入房间,角色:', message.payload?.role);
if (role === 'sender' && message.payload?.role === 'receiver') {
console.log('[SharedWebRTC] 🚀 接收方已连接发送方自动建立P2P连接');
updateState({ isPeerConnected: true }); // 标记对方已加入可以开始P2P
// 发送方自动创建offer建立基础P2P连接
try {
console.log('[SharedWebRTC] 📡 自动创建基础P2P连接offer');
await createOffer(pc, ws);
} catch (error) {
console.error('[SharedWebRTC] 自动创建基础P2P连接失败:', error);
}
} else if (role === 'receiver' && message.payload?.role === 'sender') {
console.log('[SharedWebRTC] 🚀 发送方已连接接收方准备接收P2P连接');
updateState({ isPeerConnected: true }); // 标记对方已加入
}
break;
case 'offer':
console.log('[SharedWebRTC] 📬 处理offer...');
if (pc.signalingState === 'stable') {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
console.log('[SharedWebRTC] ✅ 设置远程描述完成');
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
console.log('[SharedWebRTC] ✅ 创建并设置answer完成');
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
console.log('[SharedWebRTC] 发送 answer');
console.log('[SharedWebRTC] 📤 发送 answer');
} else {
console.warn('[SharedWebRTC] ⚠️ PeerConnection状态不是stable:', pc.signalingState);
}
break;
case 'answer':
if (pc.signalingState === 'have-local-offer') {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
console.log('[SharedWebRTC] 处理 answer 完成');
console.log('[SharedWebRTC] 📬 处理answer...');
try {
if (pc.signalingState === 'have-local-offer') {
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
console.log('[SharedWebRTC] ✅ answer 处理完成');
} else {
console.warn('[SharedWebRTC] ⚠️ PeerConnection状态不是have-local-offer:', pc.signalingState);
// 如果状态不对,尝试重新创建 offer
if (pc.connectionState === 'connected' || pc.connectionState === 'connecting') {
console.log('[SharedWebRTC] 🔄 连接状态正常但信令状态异常尝试重新创建offer');
// 这里不直接处理,让连接自然建立
}
}
} catch (error) {
console.error('[SharedWebRTC] ❌ 处理answer失败:', error);
if (error instanceof Error && error.message.includes('Failed to set local answer sdp')) {
console.warn('[SharedWebRTC] ⚠️ Answer处理失败可能是连接状态变化导致的');
// 清理连接状态,让客户端重新连接
updateState({ error: 'WebRTC连接状态异常请重新连接', isPeerConnected: false });
}
}
break;
case 'ice-candidate':
if (message.payload && pc.remoteDescription) {
await pc.addIceCandidate(new RTCIceCandidate(message.payload));
console.log('[SharedWebRTC] 添加 ICE 候选');
try {
await pc.addIceCandidate(new RTCIceCandidate(message.payload));
console.log('[SharedWebRTC] ✅ 添加 ICE 候选成功');
} catch (err) {
console.warn('[SharedWebRTC] ⚠️ 添加 ICE 候选失败:', err);
}
} else {
console.warn('[SharedWebRTC] ⚠️ ICE候选无效或远程描述未设置');
}
break;
case 'error':
console.error('[SharedWebRTC] 信令错误:', message.error);
console.error('[SharedWebRTC] ❌ 信令服务器错误:', message.error);
updateState({ error: message.error, isConnecting: false });
break;
default:
console.warn('[SharedWebRTC] ⚠️ 未知消息类型:', message.type);
}
} catch (error) {
console.error('[SharedWebRTC] 处理信令消息失败:', error);
console.error('[SharedWebRTC] 处理信令消息失败:', error);
updateState({ error: '信令处理失败: ' + error, isConnecting: false });
}
};
ws.onerror = (error) => {
console.error('[SharedWebRTC] WebSocket 错误:', error);
updateState({ error: 'WebSocket连接失败请检查网络连接', isConnecting: false });
console.error('[SharedWebRTC] WebSocket 错误:', error);
updateState({ error: 'WebSocket连接失败请检查服务器是否运行在8080端口', isConnecting: false });
};
ws.onclose = () => {
console.log('[SharedWebRTC] WebSocket 连接已关闭');
ws.onclose = (event) => {
console.log('[SharedWebRTC] 🔌 WebSocket 连接已关闭, 代码:', event.code, '原因:', event.reason);
updateState({ isWebSocketConnected: false });
if (event.code !== 1000 && event.code !== 1001) { // 非正常关闭
updateState({ error: `WebSocket异常关闭 (${event.code}): ${event.reason || '未知原因'}`, isConnecting: false });
}
};
// PeerConnection 事件处理
@@ -320,32 +358,63 @@ export function useSharedWebRTCManager(): WebRTCConnection {
type: 'ice-candidate',
payload: event.candidate
}));
console.log('[SharedWebRTC] 发送 ICE 候选');
console.log('[SharedWebRTC] 📤 发送 ICE 候选:', event.candidate.candidate.substring(0, 50) + '...');
} else if (!event.candidate) {
console.log('[SharedWebRTC] 🏁 ICE 收集完成');
}
};
pc.oniceconnectionstatechange = () => {
console.log('[SharedWebRTC] 🧊 ICE连接状态变化:', pc.iceConnectionState);
switch (pc.iceConnectionState) {
case 'checking':
console.log('[SharedWebRTC] 🔍 正在检查ICE连接...');
break;
case 'connected':
case 'completed':
console.log('[SharedWebRTC] ✅ ICE连接成功');
break;
case 'failed':
console.error('[SharedWebRTC] ❌ ICE连接失败');
updateState({ error: 'ICE连接失败可能是网络防火墙阻止了连接', isConnecting: false });
break;
case 'disconnected':
console.log('[SharedWebRTC] 🔌 ICE连接断开');
break;
case 'closed':
console.log('[SharedWebRTC] 🚫 ICE连接已关闭');
break;
}
};
pc.onconnectionstatechange = () => {
console.log('[SharedWebRTC] 连接状态变化:', pc.connectionState);
console.log('[SharedWebRTC] 🔗 WebRTC连接状态变化:', pc.connectionState);
switch (pc.connectionState) {
case 'connecting':
console.log('[SharedWebRTC] 🔄 WebRTC正在连接中...');
updateState({ isPeerConnected: false });
break;
case 'connected':
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
updateState({ isConnected: true, isConnecting: false, error: null });
console.log('[SharedWebRTC] 🎉 WebRTC P2P连接已完全建立可以进行媒体传输');
updateState({ isPeerConnected: true, 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;
// 只有在数据通道也未打开的情况下才认为连接真正失败
const currentDc = dcRef.current;
if (!currentDc || currentDc.readyState !== 'open') {
console.error('[SharedWebRTC] ❌ WebRTC连接失败数据通道未建立');
updateState({ error: 'WebRTC连接失败请检查网络设置或重试', isPeerConnected: false });
} else {
console.log('[SharedWebRTC] ⚠️ WebRTC连接状态为failed但数据通道正常忽略此状态');
}
break;
case 'disconnected':
console.log('[SharedWebRTC] 🔌 WebRTC连接已断开');
updateState({ isPeerConnected: false });
break;
case 'closed':
updateState({ isConnected: false, isConnecting: false });
console.log('[SharedWebRTC] 🚫 WebRTC连接已关闭');
updateState({ isPeerConnected: false });
break;
}
};
@@ -360,6 +429,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
dataChannel.onopen = () => {
console.log('[SharedWebRTC] 数据通道已打开 (发送方)');
updateState({ isPeerConnected: true, error: null, isConnecting: false });
};
dataChannel.onmessage = handleDataChannelMessage;
@@ -375,6 +445,7 @@ export function useSharedWebRTCManager(): WebRTCConnection {
dataChannel.onopen = () => {
console.log('[SharedWebRTC] 数据通道已打开 (接收方)');
updateState({ isPeerConnected: true, error: null, isConnecting: false });
};
dataChannel.onmessage = handleDataChannelMessage;
@@ -386,6 +457,19 @@ export function useSharedWebRTCManager(): WebRTCConnection {
};
}
// 设置轨道接收处理(对于接收方)
pc.ontrack = (event) => {
console.log('[SharedWebRTC] 🎥 PeerConnection收到轨道:', event.track.kind, event.track.id);
console.log('[SharedWebRTC] 关联的流数量:', event.streams.length);
if (event.streams.length > 0) {
console.log('[SharedWebRTC] 🎬 轨道关联到流:', event.streams[0].id);
}
// 这里不处理,让具体的业务逻辑处理
// onTrack会被业务逻辑重新设置
};
} catch (error) {
console.error('[SharedWebRTC] 连接失败:', error);
updateState({
@@ -393,19 +477,14 @@ export function useSharedWebRTCManager(): WebRTCConnection {
isConnecting: false
});
}
}, [updateState, cleanup, createOffer, handleDataChannelMessage, state.isConnecting, state.isConnected]);
}, [updateState, cleanup, createOffer, handleDataChannelMessage, webrtcStore.isConnecting, webrtcStore.isConnected]);
// 断开连接
const disconnect = useCallback(() => {
console.log('[SharedWebRTC] 断开连接');
cleanup();
setState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
error: null,
});
}, [cleanup]);
webrtcStore.reset();
}, [cleanup, webrtcStore]);
// 发送消息
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
@@ -475,15 +554,94 @@ export function useSharedWebRTCManager(): WebRTCConnection {
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
return currentRoom.current?.code === roomCode &&
currentRoom.current?.role === role &&
state.isConnected;
}, [state.isConnected]);
webrtcStore.isConnected;
}, [webrtcStore.isConnected]);
// 添加媒体轨道
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
const pc = pcRef.current;
if (!pc) {
console.error('[SharedWebRTC] PeerConnection 不可用');
return null;
}
try {
return pc.addTrack(track, stream);
} catch (error) {
console.error('[SharedWebRTC] 添加轨道失败:', error);
return null;
}
}, []);
// 移除媒体轨道
const removeTrack = useCallback((sender: RTCRtpSender) => {
const pc = pcRef.current;
if (!pc) {
console.error('[SharedWebRTC] PeerConnection 不可用');
return;
}
try {
pc.removeTrack(sender);
} catch (error) {
console.error('[SharedWebRTC] 移除轨道失败:', error);
}
}, []);
// 设置轨道处理器
const onTrack = useCallback((handler: (event: RTCTrackEvent) => void) => {
const pc = pcRef.current;
if (!pc) {
console.warn('[SharedWebRTC] PeerConnection 尚未准备就绪将在连接建立后设置onTrack');
// 延迟设置等待PeerConnection准备就绪
const checkAndSetTrackHandler = () => {
const currentPc = pcRef.current;
if (currentPc) {
console.log('[SharedWebRTC] ✅ PeerConnection 已准备就绪设置onTrack处理器');
currentPc.ontrack = handler;
} else {
console.log('[SharedWebRTC] ⏳ 等待PeerConnection准备就绪...');
setTimeout(checkAndSetTrackHandler, 100);
}
};
checkAndSetTrackHandler();
return;
}
console.log('[SharedWebRTC] ✅ 立即设置onTrack处理器');
pc.ontrack = handler;
}, []);
// 获取PeerConnection实例
const getPeerConnection = useCallback(() => {
return pcRef.current;
}, []);
// 立即创建offer用于媒体轨道添加后的重新协商
const createOfferNow = useCallback(async () => {
const pc = pcRef.current;
const ws = wsRef.current;
if (!pc || !ws) {
console.error('[SharedWebRTC] PeerConnection 或 WebSocket 不可用');
return false;
}
try {
await createOffer(pc, ws);
return true;
} catch (error) {
console.error('[SharedWebRTC] 创建 offer 失败:', error);
return false;
}
}, [createOffer]);
return {
// 状态
isConnected: state.isConnected,
isConnecting: state.isConnecting,
isWebSocketConnected: state.isWebSocketConnected,
error: state.error,
isConnected: webrtcStore.isConnected,
isConnecting: webrtcStore.isConnecting,
isWebSocketConnected: webrtcStore.isWebSocketConnected,
isPeerConnected: webrtcStore.isPeerConnected,
error: webrtcStore.error,
// 操作方法
connect,
@@ -499,6 +657,13 @@ export function useSharedWebRTCManager(): WebRTCConnection {
getChannelState,
isConnectedToRoom,
// 媒体轨道方法
addTrack,
removeTrack,
onTrack,
getPeerConnection,
createOfferNow,
// 当前房间信息
currentRoom: currentRoom.current,
};

View File

@@ -84,9 +84,14 @@ export function useTextTransferBusiness(connection: WebRTCConnection) {
// 监听连接状态变化 (直接使用 connection 的状态)
useEffect(() => {
// 这里我们直接依赖 connection 的状态变化
// 由于我们使用共享连接,状态会自动同步
}, []);
// 同步连接状态
updateState({
isConnecting: connection.isConnecting,
isConnected: connection.isConnected,
isWebSocketConnected: connection.isWebSocketConnected,
connectionError: connection.error
});
}, [connection.isConnecting, connection.isConnected, connection.isWebSocketConnected, connection.error, updateState]);
// 连接
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
@@ -100,28 +105,32 @@ export function useTextTransferBusiness(connection: WebRTCConnection) {
// 发送实时文本同步 (替代原来的 sendMessage)
const sendTextSync = useCallback((text: string) => {
if (!connection) return;
if (!connection || !connection.isPeerConnected) return;
const message = {
type: 'text-sync',
payload: { text }
};
connection.sendMessage(message, CHANNEL_NAME);
console.log('发送实时文本同步:', text.length, '字符');
const success = connection.sendMessage(message, CHANNEL_NAME);
if (success) {
console.log('发送实时文本同步:', text.length, '字符');
}
}, [connection]);
// 发送打字状态
const sendTypingStatus = useCallback((isTyping: boolean) => {
if (!connection) return;
if (!connection || !connection.isPeerConnected) return;
const message = {
type: 'text-typing',
payload: { typing: isTyping }
};
connection.sendMessage(message, CHANNEL_NAME);
console.log('发送打字状态:', isTyping);
const success = connection.sendMessage(message, CHANNEL_NAME);
if (success) {
console.log('发送打字状态:', isTyping);
}
}, [connection]);
// 设置文本同步回调

View File

@@ -0,0 +1,41 @@
import { create } from 'zustand';
interface WebRTCState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean;
error: string | null;
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
}
interface WebRTCStore extends WebRTCState {
updateState: (updates: Partial<WebRTCState>) => void;
setCurrentRoom: (room: { code: string; role: 'sender' | 'receiver' } | null) => void;
reset: () => void;
}
const initialState: WebRTCState = {
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
error: null,
currentRoom: null,
};
export const useWebRTCStore = create<WebRTCStore>((set) => ({
...initialState,
updateState: (updates) => set((state) => ({
...state,
...updates,
})),
setCurrentRoom: (room) => set((state) => ({
...state,
currentRoom: room,
})),
reset: () => set(initialState),
}));

View File

@@ -24,7 +24,7 @@ const getCurrentBaseUrl = () => {
return 'http://localhost:8080';
};
// 动态获取 WebSocket URL
// 动态获取 WebSocket URL - 总是在客户端运行时计算
const getCurrentWsUrl = () => {
if (typeof window !== 'undefined') {
// 检查是否是 Next.js 开发服务器(端口 3000 或 3001
@@ -40,8 +40,8 @@ const getCurrentWsUrl = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws/p2p`;
}
// 服务器端默认值
return 'ws://localhost:8080/ws/p2p';
// 服务器端返回空字符串,强制在客户端计算
return '';
};
export const config = {
@@ -61,8 +61,8 @@ export const config = {
// 直接后端URL (客户端在静态模式下使用) - 如果环境变量为空,则使用当前域名
directBackendUrl: getEnv('NEXT_PUBLIC_BACKEND_URL') || getCurrentBaseUrl(),
// WebSocket地址 - 如果环境变量为空,则使用当前域名构建
wsUrl: getEnv('NEXT_PUBLIC_WS_URL') || getCurrentWsUrl(),
// WebSocket地址 - 在客户端运行时动态计算,不在构建时预设
wsUrl: '', // 将通过 getWsUrl() 函数动态获取
},
// 超时配置
@@ -113,12 +113,23 @@ export function getDirectBackendUrl(path: string): string {
}
/**
* 获取WebSocket URL
* 获取WebSocket URL - 总是在客户端运行时动态计算
* @returns WebSocket连接地址
*/
export function getWsUrl(): string {
// 实时获取当前域名构建的 WebSocket URL
return getEnv('NEXT_PUBLIC_WS_URL') || getCurrentWsUrl()
// 优先使用环境变量
const envWsUrl = getEnv('NEXT_PUBLIC_WS_URL');
if (envWsUrl) {
return envWsUrl;
}
// 如果是服务器端SSG构建时返回空字符串
if (typeof window === 'undefined') {
return '';
}
// 客户端运行时动态计算
return getCurrentWsUrl();
}
/**

View File

@@ -0,0 +1,66 @@
/* 动画样式 */
@keyframes fade-in-up {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.5s ease-out;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(156, 163, 175, 0.4);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(156, 163, 175, 0.6);
}
/* 全屏时隐藏鼠标(桌面共享专用) */
.cursor-none {
cursor: none;
}
.cursor-none:hover {
cursor: none;
}
/* 桌面共享控制栏过渡 */
.desktop-controls-enter {
opacity: 0;
transform: translateY(100%);
}
.desktop-controls-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 300ms ease-in-out, transform 300ms ease-in-out;
}
.desktop-controls-exit {
opacity: 1;
transform: translateY(0);
}
.desktop-controls-exit-active {
opacity: 0;
transform: translateY(100%);
transition: opacity 300ms ease-in-out, transform 300ms ease-in-out;
}

View File

@@ -0,0 +1,74 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
borderWidth: {
'3': '3px',
'4': '4px',
},
boxShadow: {
'cartoon-sm': '2px 2px 0 #000',
'cartoon': '4px 4px 0 #000',
'cartoon-md': '6px 6px 0 #000',
'cartoon-lg': '8px 8px 0 #000',
'cartoon-xl': '10px 10px 0 #000',
},
animation: {
'bounce-in': 'bounce-in 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55)',
'wiggle': 'wiggle 0.5s ease-in-out infinite',
'float-cartoon': 'float-cartoon 4s ease-in-out infinite',
'rainbow': 'rainbow 5s ease infinite',
'gradient-shift': 'gradientShift 15s ease infinite',
},
fontFamily: {
'cartoon': ['"Comic Sans MS"', '"Chalkboard SE"', '"Comic Neue"', 'cursive'],
},
},
},
plugins: [],
}

View File

@@ -3530,3 +3530,8 @@ yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zustand@^5.0.7:
version "5.0.7"
resolved "https://registry.npmmirror.com/zustand/-/zustand-5.0.7.tgz#e325364e82c992a84bf386d8445aa7f180c450dc"
integrity sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==

View File

@@ -138,8 +138,33 @@ func (ws *WebRTCService) addClientToRoom(code string, client *WebRTCClient) {
if client.Role == "sender" {
room.Sender = client
// 如果发送方连接,检查是否有接收方在等待,通知接收方
if room.Receiver != nil {
log.Printf("通知接收方:发送方已连接")
peerJoinedMsg := &WebRTCMessage{
Type: "peer-joined",
From: client.ID,
Payload: map[string]interface{}{
"role": "sender",
},
}
room.Receiver.Connection.WriteJSON(peerJoinedMsg)
}
} else {
room.Receiver = client
// 如果接收方连接通知发送方可以开始建立P2P连接
if room.Sender != nil {
log.Printf("通知发送方接收方已连接可以开始建立P2P连接")
peerJoinedMsg := &WebRTCMessage{
Type: "peer-joined",
From: client.ID,
Payload: map[string]interface{}{
"role": "receiver",
},
}
room.Sender.Connection.WriteJSON(peerJoinedMsg)
}
// 如果接收方连接且有保存的offer立即发送给接收方
if room.LastOffer != nil {
log.Printf("向新连接的接收方发送保存的offer")